Implement academy analytics, billing, and web stories updates

This commit is contained in:
2026-05-26 07:27:29 +02:00
parent 456c3d6bb0
commit 0b33a1b074
177 changed files with 27360 additions and 2685 deletions

File diff suppressed because it is too large Load Diff

View 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,
],
]);
}
}

View 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,
],
]);
}
}

View File

@@ -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));
}

View 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));
}
}