Implement academy analytics, billing, and web stories updates
This commit is contained in:
1064
tests/Feature/Academy/AcademyAnalyticsTest.php
Normal file
1064
tests/Feature/Academy/AcademyAnalyticsTest.php
Normal file
File diff suppressed because it is too large
Load Diff
161
tests/Feature/Academy/AcademyBillingAccessTest.php
Normal file
161
tests/Feature/Academy/AcademyBillingAccessTest.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Academy;
|
||||
|
||||
use App\Http\Middleware\ConditionalValidateCsrfToken;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyPromptTemplate;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Laravel\Cashier\Subscription;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class AcademyBillingAccessTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withoutMiddleware(ConditionalValidateCsrfToken::class);
|
||||
$this->configureBilling();
|
||||
}
|
||||
|
||||
public function test_success_page_does_not_grant_access_by_itself(): void
|
||||
{
|
||||
$prompt = AcademyPromptTemplate::query()->create([
|
||||
'title' => 'Creator Prompt',
|
||||
'slug' => 'billing-success-does-not-unlock',
|
||||
'excerpt' => 'Locked creator prompt.',
|
||||
'prompt' => 'SECRET CREATOR PROMPT',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'creator',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('academy.billing.success', ['session_id' => 'cs_test_only']))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('currentTier', 'free')
|
||||
->where('isSubscribed', false));
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('academy.prompts.show', ['slug' => $prompt->slug]))
|
||||
->assertOk()
|
||||
->assertDontSee('SECRET CREATOR PROMPT')
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.locked', true)
|
||||
->where('item.prompt', null));
|
||||
}
|
||||
|
||||
public function test_canceled_subscription_on_grace_period_still_has_access(): void
|
||||
{
|
||||
$lesson = AcademyLesson::query()->create([
|
||||
'title' => 'Creator Grace Lesson',
|
||||
'slug' => 'creator-grace-lesson',
|
||||
'excerpt' => 'Should remain available in grace period.',
|
||||
'content' => 'VISIBLE DURING GRACE PERIOD',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'creator',
|
||||
'lesson_type' => 'article',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||
$this->attachSubscription($user, 'sub_grace', 'price_creator_month', now()->addDay());
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('academy.lessons.show', ['slug' => $lesson->slug]))
|
||||
->assertOk()
|
||||
->assertSee('VISIBLE DURING GRACE PERIOD')
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.locked', false)
|
||||
->where('item.content', 'VISIBLE DURING GRACE PERIOD'));
|
||||
}
|
||||
|
||||
public function test_ended_subscription_loses_paid_access(): void
|
||||
{
|
||||
$lesson = AcademyLesson::query()->create([
|
||||
'title' => 'Creator Ended Lesson',
|
||||
'slug' => 'creator-ended-lesson',
|
||||
'excerpt' => 'Should lock after grace period.',
|
||||
'content' => 'NO LONGER VISIBLE',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'creator',
|
||||
'lesson_type' => 'article',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||
$this->attachSubscription($user, 'sub_ended', 'price_creator_month', now()->subMinute());
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('academy.lessons.show', ['slug' => $lesson->slug]))
|
||||
->assertOk()
|
||||
->assertDontSee('NO LONGER VISIBLE')
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.locked', true)
|
||||
->where('item.content', null));
|
||||
}
|
||||
|
||||
public function test_billing_portal_route_requires_authentication(): void
|
||||
{
|
||||
$this->get(route('academy.billing.portal'))
|
||||
->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
private function attachSubscription(User $user, string $subscriptionId, string $priceId, ?\Illuminate\Support\Carbon $endsAt = null): Subscription
|
||||
{
|
||||
$subscription = $user->subscriptions()->create([
|
||||
'type' => 'academy',
|
||||
'stripe_id' => $subscriptionId,
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => $priceId,
|
||||
'quantity' => 1,
|
||||
'ends_at' => $endsAt,
|
||||
]);
|
||||
|
||||
$subscription->items()->create([
|
||||
'stripe_id' => 'si_'.$subscriptionId,
|
||||
'stripe_product' => 'prod_'.($priceId === 'price_pro_month' ? 'pro' : 'creator'),
|
||||
'stripe_price' => $priceId,
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
return $subscription;
|
||||
}
|
||||
|
||||
private function configureBilling(): void
|
||||
{
|
||||
config()->set('academy.enabled', true);
|
||||
config()->set('academy.payments_enabled', true);
|
||||
config()->set('academy_billing.enabled', true);
|
||||
config()->set('academy_billing.subscription_name', 'academy');
|
||||
config()->set('academy_billing.plans', [
|
||||
'creator_monthly' => [
|
||||
'label' => 'Creator Monthly',
|
||||
'tier' => 'creator',
|
||||
'interval' => 'monthly',
|
||||
'stripe_price_id' => 'price_creator_month',
|
||||
'featured' => false,
|
||||
],
|
||||
'pro_monthly' => [
|
||||
'label' => 'Pro Monthly',
|
||||
'tier' => 'pro',
|
||||
'interval' => 'monthly',
|
||||
'stripe_price_id' => 'price_pro_month',
|
||||
'featured' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
212
tests/Feature/Academy/AcademyBillingCheckoutTest.php
Normal file
212
tests/Feature/Academy/AcademyBillingCheckoutTest.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Academy;
|
||||
|
||||
use App\Http\Middleware\ConditionalValidateCsrfToken;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Laravel\Cashier\SubscriptionBuilder;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class AcademyBillingCheckoutTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->withoutMiddleware(ConditionalValidateCsrfToken::class);
|
||||
$this->configureBilling();
|
||||
}
|
||||
|
||||
public function test_guest_cannot_start_checkout_and_is_redirected_to_login(): void
|
||||
{
|
||||
$this->post(route('academy.billing.checkout'), ['plan' => 'creator_monthly'])
|
||||
->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_guest_can_view_pricing(): void
|
||||
{
|
||||
$this->get(route('academy.pricing'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Academy/Billing/Pricing')
|
||||
->where('currentTier', 'free')
|
||||
->where('isSubscribed', false)
|
||||
->where('activePlanKey', null)
|
||||
->where('catalog.0.plans.0.price_display', '4.99 EUR')
|
||||
->where('catalog.1.plans.0.price_display', '9.99 EUR'));
|
||||
}
|
||||
|
||||
public function test_pricing_shows_active_plan_for_subscriber(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email_verified_at' => now(),
|
||||
'stripe_id' => 'cus_pricing_active_plan',
|
||||
]);
|
||||
|
||||
$subscription = $user->subscriptions()->create([
|
||||
'type' => 'academy',
|
||||
'stripe_id' => 'sub_pricing_active_plan',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_1creatormonth',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$subscription->items()->create([
|
||||
'stripe_id' => 'si_pricing_active_plan',
|
||||
'stripe_product' => 'prod_creator',
|
||||
'stripe_price' => 'price_1creatormonth',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('academy.pricing'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Academy/Billing/Pricing')
|
||||
->where('currentTier', 'creator')
|
||||
->where('isSubscribed', true)
|
||||
->where('activePlanKey', 'creator_monthly')
|
||||
->where('activePlanLabel', 'Creator Monthly'));
|
||||
}
|
||||
|
||||
public function test_invalid_plan_returns_validation_error(): void
|
||||
{
|
||||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from(route('academy.pricing'))
|
||||
->post(route('academy.billing.checkout'), ['plan' => 'not_real'])
|
||||
->assertRedirect(route('academy.pricing'))
|
||||
->assertSessionHasErrors('plan');
|
||||
}
|
||||
|
||||
public function test_missing_price_id_fails_safely(): void
|
||||
{
|
||||
config()->set('academy_billing.plans.creator_monthly.stripe_price_id', '');
|
||||
|
||||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('academy.billing.checkout'), ['plan' => 'creator_monthly'])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', 'academy_billing_price_missing');
|
||||
}
|
||||
|
||||
public function test_numeric_price_id_fails_safely_before_stripe(): void
|
||||
{
|
||||
config()->set('academy_billing.plans.creator_monthly.stripe_price_id', '9.99');
|
||||
|
||||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson(route('academy.billing.checkout'), ['plan' => 'creator_monthly'])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('code', 'academy_billing_price_invalid');
|
||||
}
|
||||
|
||||
public function test_invalid_price_id_redirects_back_with_visible_flash_error(): void
|
||||
{
|
||||
config()->set('academy_billing.plans.creator_monthly.stripe_price_id', 'prod_not_a_price');
|
||||
|
||||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->from(route('academy.pricing'))
|
||||
->post(route('academy.billing.checkout'), ['plan' => 'creator_monthly'])
|
||||
->assertRedirect(route('academy.pricing'))
|
||||
->assertSessionHas('error', 'The selected Academy plan is misconfigured. Please contact support before continuing.');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
'error' => 'The selected Academy plan is misconfigured. Please contact support before continuing.',
|
||||
])
|
||||
->get(route('academy.pricing'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Academy/Billing/Pricing')
|
||||
->where('flash.error', 'The selected Academy plan is misconfigured. Please contact support before continuing.'));
|
||||
}
|
||||
|
||||
public function test_verified_user_can_start_checkout_for_configured_plan(): void
|
||||
{
|
||||
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||
$user = Mockery::mock($user)->makePartial();
|
||||
|
||||
$builder = Mockery::mock(new SubscriptionBuilder($user, 'academy', 'price_1creatormonth'))->makePartial();
|
||||
$builder->shouldReceive('withMetadata')
|
||||
->once()
|
||||
->andReturnSelf();
|
||||
$builder->shouldReceive('checkout')
|
||||
->once()
|
||||
->andReturn(redirect('https://checkout.stripe.test/session'));
|
||||
|
||||
$user->shouldReceive('subscription')->once()->with('academy')->andReturn(null);
|
||||
$user->shouldReceive('newSubscription')->once()->with('academy', 'price_1creatormonth')->andReturn($builder);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('academy.billing.checkout'), ['plan' => 'creator_monthly'])
|
||||
->assertRedirect('https://checkout.stripe.test/session');
|
||||
}
|
||||
|
||||
public function test_existing_subscriber_is_redirected_to_billing_portal(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email_verified_at' => now(),
|
||||
'stripe_id' => 'cus_checkout_redirect',
|
||||
]);
|
||||
|
||||
$subscription = $user->subscriptions()->create([
|
||||
'type' => 'academy',
|
||||
'stripe_id' => 'sub_checkout_redirect',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_1creatormonth',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$subscription->items()->create([
|
||||
'stripe_id' => 'si_checkout_redirect',
|
||||
'stripe_product' => 'prod_creator',
|
||||
'stripe_price' => 'price_1creatormonth',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->post(route('academy.billing.checkout'), ['plan' => 'creator_monthly'])
|
||||
->assertRedirect(route('academy.billing.portal'));
|
||||
}
|
||||
|
||||
private function configureBilling(): void
|
||||
{
|
||||
config()->set('academy.enabled', true);
|
||||
config()->set('academy.payments_enabled', true);
|
||||
config()->set('academy_billing.enabled', true);
|
||||
config()->set('academy_billing.subscription_name', 'academy');
|
||||
config()->set('academy_billing.plans', [
|
||||
'creator_monthly' => [
|
||||
'label' => 'Creator Monthly',
|
||||
'tier' => 'creator',
|
||||
'interval' => 'monthly',
|
||||
'amount' => '4.99',
|
||||
'currency' => 'EUR',
|
||||
'stripe_price_id' => 'price_1creatormonth',
|
||||
'featured' => false,
|
||||
],
|
||||
'pro_monthly' => [
|
||||
'label' => 'Pro Monthly',
|
||||
'tier' => 'pro',
|
||||
'interval' => 'monthly',
|
||||
'amount' => '9.99',
|
||||
'currency' => 'EUR',
|
||||
'stripe_price_id' => 'price_1promonth',
|
||||
'featured' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Tests\TestCase;
|
||||
|
||||
@@ -500,8 +501,18 @@ final class AcademyFeatureTest extends TestCase
|
||||
'excerpt' => 'Locked preview.',
|
||||
'prompt' => 'SECRET PREMIUM PROMPT STRING',
|
||||
'negative_prompt' => 'SECRET NEGATIVE STRING',
|
||||
'usage_notes' => 'SECRET WORKFLOW NOTE',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'creator',
|
||||
'tool_notes' => [[
|
||||
'display_type' => 'soft studio version',
|
||||
'provider' => 'ChatGPT',
|
||||
'model_name' => '4o Image',
|
||||
'image_path' => 'academy/lessons/body/cc/dd/chatgpt-comparison.webp',
|
||||
'settings' => 'SECRET SETTINGS STRING',
|
||||
'best_for' => 'SECRET BEST FOR STRING',
|
||||
'active' => true,
|
||||
]],
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
@@ -510,10 +521,21 @@ final class AcademyFeatureTest extends TestCase
|
||||
->assertOk()
|
||||
->assertDontSee('SECRET PREMIUM PROMPT STRING')
|
||||
->assertDontSee('SECRET NEGATIVE STRING')
|
||||
->assertDontSee('SECRET WORKFLOW NOTE')
|
||||
->assertDontSee('SECRET SETTINGS STRING')
|
||||
->assertDontSee('SECRET BEST FOR STRING')
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.locked', true)
|
||||
->where('item.prompt', null)
|
||||
->where('item.negative_prompt', null));
|
||||
->where('item.negative_prompt', null)
|
||||
->where('item.access_requirement', 'Requires Creator or Pro access.')
|
||||
->where('item.unlock_heading', 'Unlock the full Creator prompt.')
|
||||
->where('item.tool_notes', [])
|
||||
->where('item.public_examples.0.provider', 'ChatGPT')
|
||||
->where('item.public_examples.0.model_name', '4o Image')
|
||||
->where('item.public_examples.0.image_path', 'academy/lessons/body/cc/dd/chatgpt-comparison.webp')
|
||||
->where('seo.json_ld.0.isAccessibleForFree', false)
|
||||
->where('seo.json_ld.0.hasPart.cssSelector', '.academy-paywalled-content'));
|
||||
|
||||
$version = app(HandleInertiaRequests::class)
|
||||
->version(Request::create(route('academy.prompts.show', ['slug' => $prompt->slug]), 'GET'));
|
||||
@@ -527,8 +549,11 @@ final class AcademyFeatureTest extends TestCase
|
||||
->assertJsonPath('props.item.locked', true)
|
||||
->assertJsonPath('props.item.prompt', null)
|
||||
->assertJsonPath('props.item.negative_prompt', null)
|
||||
->assertJsonPath('props.item.tool_notes', [])
|
||||
->assertJsonPath('props.item.public_examples.0.provider', 'ChatGPT')
|
||||
->assertDontSee('SECRET PREMIUM PROMPT STRING')
|
||||
->assertDontSee('SECRET NEGATIVE STRING');
|
||||
->assertDontSee('SECRET NEGATIVE STRING')
|
||||
->assertDontSee('SECRET SETTINGS STRING');
|
||||
}
|
||||
|
||||
public function test_authorized_user_can_view_premium_prompt(): void
|
||||
@@ -563,10 +588,236 @@ final class AcademyFeatureTest extends TestCase
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.locked', false)
|
||||
->where('item.prompt', 'VISIBLE PREMIUM PROMPT')
|
||||
->where('item.public_examples.0.provider', 'ChatGPT')
|
||||
->where('item.tool_notes.0.provider', 'ChatGPT')
|
||||
->where('item.tool_notes.0.model_name', '4o Image')
|
||||
->where('item.tool_notes.0.image_path', 'academy/lessons/body/cc/dd/chatgpt-comparison.webp')
|
||||
->where('item.tool_notes.0.score', 8));
|
||||
->where('item.tool_notes.0.score', 8)
|
||||
->where('seo.json_ld.0.isAccessibleForFree', false)
|
||||
->where('seo.json_ld.0.hasPart.cssSelector', '.academy-paywalled-content'));
|
||||
}
|
||||
|
||||
public function test_prompt_payload_exposes_responsive_preview_and_comparison_images(): void
|
||||
{
|
||||
config()->set('uploads.object_storage.disk', 's3');
|
||||
Storage::fake('s3');
|
||||
|
||||
Storage::disk('s3')->put('academy-prompts/previews/sticker-pack.webp', 'preview');
|
||||
Storage::disk('s3')->put('academy-prompts/previews/sticker-pack-thumb.webp', 'preview-thumb');
|
||||
Storage::disk('s3')->put('academy-prompts/previews/sticker-pack-md.webp', 'preview-medium');
|
||||
Storage::disk('s3')->put('academy/lessons/body/cc/dd/chatgpt-comparison.webp', 'comparison');
|
||||
Storage::disk('s3')->put('academy/lessons/body/cc/dd/chatgpt-comparison-thumb.webp', 'comparison-thumb');
|
||||
Storage::disk('s3')->put('academy/lessons/body/cc/dd/chatgpt-comparison-md.webp', 'comparison-medium');
|
||||
|
||||
$prompt = AcademyPromptTemplate::query()->create([
|
||||
'title' => 'Responsive Prompt Media',
|
||||
'slug' => 'responsive-prompt-media',
|
||||
'excerpt' => 'Prompt with responsive preview assets.',
|
||||
'prompt' => 'Create a chibi emoji sticker collection with bright outlines.',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'preview_image' => 'academy-prompts/previews/sticker-pack.webp',
|
||||
'tool_notes' => [[
|
||||
'display_type' => 'sticker pack',
|
||||
'provider' => 'ChatGPT',
|
||||
'model_name' => '4o Image',
|
||||
'image_path' => 'academy/lessons/body/cc/dd/chatgpt-comparison.webp',
|
||||
'thumb_path' => 'academy/lessons/body/cc/dd/chatgpt-comparison-thumb.webp',
|
||||
'settings' => 'Square canvas, bold outline, soft pastel background.',
|
||||
'best_for' => 'Sticker-ready mascot packs.',
|
||||
'active' => true,
|
||||
]],
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->get(route('academy.prompts.show', ['slug' => $prompt->slug]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.preview_image_thumb', fn ($value) => is_string($value) && str_contains($value, 'academy-prompts/previews/sticker-pack-thumb.webp'))
|
||||
->where('item.preview_image_srcset', fn ($value) => is_string($value) && str_contains($value, 'academy-prompts/previews/sticker-pack-thumb.webp 480w') && str_contains($value, 'academy-prompts/previews/sticker-pack-md.webp 960w'))
|
||||
->where('item.public_examples.0.thumb_path', 'academy/lessons/body/cc/dd/chatgpt-comparison-thumb.webp')
|
||||
->where('item.public_examples.0.image_srcset', fn ($value) => is_string($value) && str_contains($value, 'chatgpt-comparison-thumb.webp 480w') && str_contains($value, 'chatgpt-comparison-md.webp 960w'))
|
||||
->where('item.tool_notes.0.thumb_path', 'academy/lessons/body/cc/dd/chatgpt-comparison-thumb.webp')
|
||||
->where('item.tool_notes.0.image_srcset', fn ($value) => is_string($value) && str_contains($value, 'chatgpt-comparison-thumb.webp 480w') && str_contains($value, 'chatgpt-comparison-md.webp 960w')));
|
||||
}
|
||||
|
||||
public function test_authorized_user_receives_active_advanced_prompt_metadata(): void
|
||||
{
|
||||
$prompt = AcademyPromptTemplate::query()->create([
|
||||
'title' => 'Advanced Creator Prompt',
|
||||
'slug' => 'advanced-creator-prompt',
|
||||
'excerpt' => 'Full prompt visible.',
|
||||
'prompt' => 'VISIBLE PREMIUM PROMPT FOR [CITY_NAME]',
|
||||
'negative_prompt' => 'VISIBLE NEGATIVE PROMPT',
|
||||
'documentation' => [
|
||||
'summary' => 'Advanced summary visible to everyone.',
|
||||
'how_to_use' => ['Collect data', 'Prepare prompt'],
|
||||
'best_for' => ['city wallpapers'],
|
||||
],
|
||||
'placeholders' => [
|
||||
[
|
||||
'key' => 'CITY_NAME',
|
||||
'label' => 'City name',
|
||||
'required' => true,
|
||||
'example' => 'Paris',
|
||||
'type' => 'text',
|
||||
],
|
||||
],
|
||||
'helper_prompts' => [
|
||||
[
|
||||
'title' => 'Collect city data',
|
||||
'description' => 'Gather landmark and climate data.',
|
||||
'prompt' => 'Collect city data for [CITY_NAME].',
|
||||
'expected_output' => 'json',
|
||||
'active' => true,
|
||||
],
|
||||
[
|
||||
'title' => 'Inactive helper',
|
||||
'description' => 'Should stay hidden publicly.',
|
||||
'prompt' => 'Hidden helper prompt.',
|
||||
'expected_output' => 'text',
|
||||
'active' => false,
|
||||
],
|
||||
],
|
||||
'prompt_variants' => [
|
||||
[
|
||||
'title' => 'Image-safe version',
|
||||
'slug' => 'image-safe-version',
|
||||
'description' => 'Safer for image models.',
|
||||
'prompt' => 'VISIBLE IMAGE SAFE PROMPT',
|
||||
'negative_prompt' => 'VISIBLE VARIANT NEGATIVE',
|
||||
'recommended' => true,
|
||||
'recommended_for' => ['general image generation'],
|
||||
'risk_notes' => ['Icons may still be abstract'],
|
||||
'active' => true,
|
||||
],
|
||||
[
|
||||
'title' => 'Inactive variant',
|
||||
'slug' => 'inactive-variant',
|
||||
'description' => 'Should stay hidden publicly.',
|
||||
'prompt' => 'HIDDEN VARIANT PROMPT',
|
||||
'active' => false,
|
||||
],
|
||||
],
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'creator',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$creator = User::factory()->create(['role' => 'academy_creator']);
|
||||
|
||||
$this->actingAs($creator)
|
||||
->get(route('academy.prompts.show', ['slug' => $prompt->slug]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.locked', false)
|
||||
->where('item.documentation.summary', 'Advanced summary visible to everyone.')
|
||||
->where('item.placeholders.0.key', 'CITY_NAME')
|
||||
->where('item.has_placeholder_inputs', true)
|
||||
->where('item.has_helper_prompts', true)
|
||||
->where('item.has_prompt_variants', true)
|
||||
->has('item.helper_prompts', 1)
|
||||
->where('item.helper_prompts.0.title', 'Collect city data')
|
||||
->has('item.prompt_variants', 1)
|
||||
->where('item.prompt_variants.0.title', 'Image-safe version')
|
||||
);
|
||||
}
|
||||
|
||||
public function test_locked_prompt_still_exposes_documentation_and_placeholders_but_hides_helper_prompts_and_variants(): void
|
||||
{
|
||||
$prompt = AcademyPromptTemplate::query()->create([
|
||||
'title' => 'Locked Advanced Prompt',
|
||||
'slug' => 'locked-advanced-prompt',
|
||||
'excerpt' => 'Locked prompt with public guidance.',
|
||||
'prompt' => 'SECRET ADVANCED PROMPT FOR [CITY_NAME]',
|
||||
'negative_prompt' => 'SECRET ADVANCED NEGATIVE',
|
||||
'documentation' => [
|
||||
'summary' => 'Public-facing overview.',
|
||||
'how_to_use' => ['Choose a city', 'Collect climate data'],
|
||||
'tips' => ['Use real data'],
|
||||
],
|
||||
'placeholders' => [
|
||||
[
|
||||
'key' => 'CITY_NAME',
|
||||
'label' => 'City name',
|
||||
'required' => true,
|
||||
'example' => 'Paris',
|
||||
'type' => 'text',
|
||||
],
|
||||
],
|
||||
'helper_prompts' => [
|
||||
[
|
||||
'title' => 'Collect city data',
|
||||
'description' => 'Hidden behind access.',
|
||||
'prompt' => 'SECRET HELPER PROMPT',
|
||||
'expected_output' => 'json',
|
||||
'active' => true,
|
||||
],
|
||||
],
|
||||
'prompt_variants' => [
|
||||
[
|
||||
'title' => 'Image-safe version',
|
||||
'description' => 'Hidden behind access.',
|
||||
'prompt' => 'SECRET VARIANT PROMPT',
|
||||
'negative_prompt' => 'SECRET VARIANT NEGATIVE',
|
||||
'active' => true,
|
||||
],
|
||||
],
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'creator',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->get(route('academy.prompts.show', ['slug' => $prompt->slug]))
|
||||
->assertOk()
|
||||
->assertDontSee('SECRET ADVANCED PROMPT')
|
||||
->assertDontSee('SECRET HELPER PROMPT')
|
||||
->assertDontSee('SECRET VARIANT PROMPT')
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.locked', true)
|
||||
->where('item.prompt', null)
|
||||
->where('item.documentation.summary', 'Public-facing overview.')
|
||||
->where('item.placeholders.0.key', 'CITY_NAME')
|
||||
->where('item.has_placeholder_inputs', true)
|
||||
->where('item.has_helper_prompts', true)
|
||||
->where('item.has_prompt_variants', true)
|
||||
->where('item.helper_prompts', [])
|
||||
->where('item.prompt_variants', []));
|
||||
}
|
||||
|
||||
public function test_prompt_without_placeholder_tokens_marks_placeholder_inputs_as_hidden(): void
|
||||
{
|
||||
$prompt = AcademyPromptTemplate::query()->create([
|
||||
'title' => 'Descriptor Only Prompt',
|
||||
'slug' => 'descriptor-only-prompt',
|
||||
'excerpt' => 'Has descriptive placeholder cards but no input tokens in the prompt.',
|
||||
'prompt' => 'Create a calm Roman rooftop garden scene at sunrise.',
|
||||
'documentation' => [
|
||||
'summary' => 'A fixed prompt with no user-substituted variables.',
|
||||
],
|
||||
'placeholders' => [
|
||||
[
|
||||
'key' => 'CITY_STYLE',
|
||||
'label' => 'City style',
|
||||
'description' => 'Editorial guidance only.',
|
||||
'example' => 'Historic Rome rooftop terrace with distant domes',
|
||||
'type' => 'string',
|
||||
],
|
||||
],
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->get(route('academy.prompts.show', ['slug' => $prompt->slug]))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.placeholders.0.key', 'CITY_STYLE')
|
||||
->where('item.has_placeholder_inputs', false));
|
||||
}
|
||||
|
||||
public function test_public_lesson_payload_includes_active_ai_comparison_block_and_hides_inactive_results(): void
|
||||
@@ -1008,6 +1259,7 @@ final class AcademyFeatureTest extends TestCase
|
||||
->assertDontSee((string) $prompt->negative_prompt)
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->where('item.locked', true)
|
||||
->where('item.access_requirement', $prompt->access_level === 'pro' ? 'Requires Pro access.' : 'Requires Creator or Pro access.')
|
||||
->where('item.prompt', null)
|
||||
->where('item.negative_prompt', null));
|
||||
}
|
||||
|
||||
131
tests/Feature/Academy/AcademyStripeWebhookTest.php
Normal file
131
tests/Feature/Academy/AcademyStripeWebhookTest.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Academy;
|
||||
|
||||
use App\Models\AcademyBillingEvent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Laravel\Cashier\Events\WebhookHandled;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class AcademyStripeWebhookTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config()->set('academy_billing.plans', [
|
||||
'creator_monthly' => [
|
||||
'label' => 'Creator Monthly',
|
||||
'tier' => 'creator',
|
||||
'interval' => 'monthly',
|
||||
'stripe_price_id' => 'price_creator_month',
|
||||
'featured' => false,
|
||||
],
|
||||
'pro_monthly' => [
|
||||
'label' => 'Pro Monthly',
|
||||
'tier' => 'pro',
|
||||
'interval' => 'monthly',
|
||||
'stripe_price_id' => 'price_pro_month',
|
||||
'featured' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_webhook_listener_stores_safe_audit_summary_and_records_handling_outcome(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'stripe_id' => 'cus_webhook_test',
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
Cache::put('academy.billing.account.'.$user->id, 'stale', 300);
|
||||
|
||||
event(new WebhookReceived([
|
||||
'id' => 'evt_academy_billing_test',
|
||||
'type' => 'customer.subscription.updated',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => 'sub_webhook_test',
|
||||
'customer' => 'cus_webhook_test',
|
||||
'status' => 'active',
|
||||
'items' => [
|
||||
'data' => [[
|
||||
'price' => [
|
||||
'id' => 'price_creator_month',
|
||||
],
|
||||
]],
|
||||
],
|
||||
'metadata' => [
|
||||
'academy_plan' => 'creator_monthly',
|
||||
'academy_tier' => 'creator',
|
||||
'user_id' => (string) $user->id,
|
||||
],
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
$subscription = $user->subscriptions()->create([
|
||||
'type' => 'academy',
|
||||
'stripe_id' => 'sub_webhook_test',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_creator_month',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$subscription->items()->create([
|
||||
'stripe_id' => 'si_webhook_test',
|
||||
'stripe_product' => 'prod_creator_month',
|
||||
'stripe_price' => 'price_creator_month',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
event(new WebhookHandled([
|
||||
'id' => 'evt_academy_billing_test',
|
||||
'type' => 'customer.subscription.updated',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => 'sub_webhook_test',
|
||||
'customer' => 'cus_webhook_test',
|
||||
'status' => 'active',
|
||||
'items' => [
|
||||
'data' => [[
|
||||
'price' => [
|
||||
'id' => 'price_creator_month',
|
||||
'product' => 'prod_creator_month',
|
||||
],
|
||||
'quantity' => 1,
|
||||
'id' => 'si_webhook_test',
|
||||
]],
|
||||
],
|
||||
'metadata' => [
|
||||
'academy_plan' => 'creator_monthly',
|
||||
'academy_tier' => 'creator',
|
||||
'user_id' => (string) $user->id,
|
||||
],
|
||||
'subscription' => 'sub_webhook_test',
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
$billingEvent = AcademyBillingEvent::query()->where('stripe_event_id', 'evt_academy_billing_test')->first();
|
||||
|
||||
$this->assertNotNull($billingEvent);
|
||||
$this->assertSame('customer.subscription.updated', $billingEvent->event_type);
|
||||
$this->assertSame('creator_monthly', $billingEvent->academy_plan);
|
||||
$this->assertSame('creator', $billingEvent->academy_tier);
|
||||
$this->assertSame('cus_webhook_test', $billingEvent->stripe_customer_id);
|
||||
$this->assertSame(['price_creator_month'], $billingEvent->payload_summary['price_ids'] ?? []);
|
||||
$this->assertTrue($billingEvent->payload_summary['received'] ?? false);
|
||||
$this->assertTrue($billingEvent->payload_summary['handled'] ?? false);
|
||||
$this->assertSame('local_subscription_synced', $billingEvent->payload_summary['outcome'] ?? null);
|
||||
$this->assertTrue($billingEvent->payload_summary['cache_cleared'] ?? false);
|
||||
$this->assertFalse(Cache::has('academy.billing.account.'.$user->id));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user