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

View File

@@ -6,6 +6,7 @@ namespace Tests\Feature\Admin;
use App\Http\Middleware\ConditionalValidateCsrfToken;
use App\Models\AcademyAiComparisonResult;
use App\Models\AcademyBillingEvent;
use App\Models\AcademyCategory;
use App\Models\AcademyChallenge;
use App\Models\AcademyChallengeSubmission;
@@ -17,7 +18,11 @@ use App\Models\AcademyPromptTemplate;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;
@@ -46,6 +51,78 @@ final class AcademyAdminTest extends TestCase
->where('stats.prompts', 0));
}
public function test_admin_can_open_academy_billing_overview_with_live_stats(): void
{
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_monthly_test',
],
'pro_monthly' => [
'label' => 'Pro Monthly',
'tier' => 'pro',
'interval' => 'monthly',
'stripe_price_id' => 'price_pro_monthly_test',
],
]);
$admin = User::factory()->create(['role' => 'admin']);
$creatorUser = User::factory()->create();
$graceUser = User::factory()->create();
$proUser = User::factory()->create();
$this->seedAcademySubscription($creatorUser, 'price_creator_monthly_test');
$this->seedAcademySubscription($graceUser, 'price_creator_monthly_test', 'canceled', now()->addDays(5));
$this->seedAcademySubscription($proUser, 'price_pro_monthly_test');
AcademyBillingEvent::query()->create([
'user_id' => $graceUser->id,
'stripe_event_id' => 'evt_academy_billing_test_1',
'stripe_customer_id' => 'cus_academy_test_1',
'stripe_subscription_id' => 'sub_academy_test_1',
'event_type' => 'customer.subscription.updated',
'academy_tier' => 'creator',
'academy_plan' => 'creator_monthly',
'payload_summary' => ['status' => 'canceled', 'source' => 'test'],
'processed_at' => now()->subMinute(),
]);
$this->actingAs($admin)
->get('/moderation/academy/dashboard')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Admin/Academy/Dashboard')
->where('stats.active_subscribers', 3)
->where('stats.creator_subscribers', 2)
->where('stats.pro_subscribers', 1)
->where('stats.grace_period_subscribers', 1)
->where('links.billing', route('admin.academy.billing')));
$this->actingAs($admin)
->get(route('admin.academy.billing'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Admin/Academy/Billing')
->where('summary.enabled', true)
->where('summary.active_subscribers', 3)
->where('summary.creator_subscribers', 2)
->where('summary.pro_subscribers', 1)
->where('summary.grace_period_subscribers', 1)
->where('summary.missing_plan_keys', [])
->has('planBreakdown', 2)
->where('planBreakdown.0.key', 'creator_monthly')
->where('planBreakdown.0.subscribers', 2)
->where('planBreakdown.1.key', 'pro_monthly')
->where('planBreakdown.1.subscribers', 1)
->has('recentEvents', 1)
->where('recentEvents.0.event_type', 'customer.subscription.updated')
->where('recentEvents.0.user_id', $graceUser->id));
}
public function test_admin_can_approve_and_reject_challenge_submission(): void
{
$admin = User::factory()->create(['role' => 'admin']);
@@ -94,6 +171,7 @@ final class AcademyAdminTest extends TestCase
foreach ([
'/moderation/academy/dashboard',
'/moderation/academy/billing',
'/moderation/academy/courses',
'/moderation/academy/categories',
'/moderation/academy/lessons',
@@ -102,11 +180,56 @@ final class AcademyAdminTest extends TestCase
'/moderation/academy/challenges',
'/moderation/academy/submissions',
'/moderation/academy/badges',
'/moderation/academy/analytics',
'/moderation/academy/analytics/intelligence',
'/moderation/academy/analytics/content',
'/moderation/academy/analytics/prompts',
'/moderation/academy/analytics/lessons',
'/moderation/academy/analytics/courses',
'/moderation/academy/analytics/search',
'/moderation/academy/analytics/funnel',
] as $path) {
$this->actingAs($admin)->get($path)->assertOk();
}
}
public function test_non_admin_cannot_open_academy_analytics_pages(): void
{
$user = User::factory()->create(['role' => 'user']);
foreach ([
'/moderation/academy/analytics',
'/moderation/academy/analytics/intelligence',
'/moderation/academy/analytics/content',
'/moderation/academy/analytics/prompts',
'/moderation/academy/analytics/lessons',
'/moderation/academy/analytics/courses',
'/moderation/academy/analytics/search',
'/moderation/academy/analytics/funnel',
] as $path) {
$this->actingAs($user)->get($path)->assertStatus(302);
}
}
public function test_admin_can_open_academy_intelligence_dashboard(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$this->actingAs($admin)
->get('/moderation/academy/analytics/intelligence')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Admin/Academy/AnalyticsIntelligence')
->where('range.active', '30d')
->has('contentOpportunities.cards')
->has('searchGaps.summary')
->has('promptInsights.summary')
->has('lessonDropoffs.summary')
->has('courseHealth.summary')
->has('premiumInterest.summary')
->has('editorialRecommendations.summary'));
}
public function test_admin_can_open_course_builder(): void
{
$admin = User::factory()->create(['role' => 'admin']);
@@ -363,11 +486,369 @@ final class AcademyAdminTest extends TestCase
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Admin/Academy/CrudForm')
->where('editorContext.comparisonMediaUploadUrl', route('api.studio.academy.lessons.media.upload'))
->where('editorContext.comparisonMediaUploadUrl', route('api.studio.academy.lessons.media.upload'))
->where('record.tool_notes.0.provider', 'Midjourney')
->where('record.tool_notes.0.model_name', 'V7')
->where('record.tool_notes.0.image_path', 'academy/lessons/body/aa/bb/prompt-midjourney.webp')
->where('record.tool_notes.0.score', 9));
->where('record.tool_notes.0.model_name', 'V7')
->where('record.tool_notes.0.image_path', 'academy/lessons/body/aa/bb/prompt-midjourney.webp')
->where('record.tool_notes.0.score', 9));
}
public function test_prompt_comparison_upload_returns_thumbnail_and_medium_variants(): void
{
config()->set('uploads.object_storage.disk', 's3');
Storage::fake('s3');
$admin = User::factory()->create(['role' => 'admin']);
$response = $this->actingAs($admin)
->post(route('api.studio.academy.lessons.media.upload'), [
'slot' => 'body',
'image' => UploadedFile::fake()->image('comparison-source.png', 1600, 900),
]);
$response
->assertOk()
->assertJsonPath('slot', 'body')
->assertJsonPath('thumb_width', 480)
->assertJsonPath('medium_width', 960);
$payload = $response->json();
$this->assertIsString($payload['path'] ?? null);
$this->assertIsString($payload['thumb_path'] ?? null);
$this->assertIsString($payload['medium_path'] ?? null);
$this->assertNotSame($payload['path'], $payload['thumb_path']);
$this->assertNotSame('', $payload['medium_path']);
Storage::disk('s3')->assertExists($payload['path']);
Storage::disk('s3')->assertExists($payload['thumb_path']);
Storage::disk('s3')->assertExists($payload['medium_path']);
}
public function test_prompt_thumbnail_backfill_command_generates_missing_variants(): void
{
config()->set('uploads.object_storage.disk', 's3');
Storage::fake('s3');
$previewUpload = UploadedFile::fake()->image('prompt-preview.png', 1600, 900);
$comparisonUpload = UploadedFile::fake()->image('prompt-comparison.png', 1400, 1400);
Storage::disk('s3')->put(
'academy-prompts/previews/emoji-sticker-pack.webp',
file_get_contents($previewUpload->getPathname()) ?: ''
);
Storage::disk('s3')->put(
'academy/lessons/body/aa/bb/emoji-sticker-pack.webp',
file_get_contents($comparisonUpload->getPathname()) ?: ''
);
$prompt = AcademyPromptTemplate::query()->create([
'title' => 'Emoji Sticker Prompt',
'slug' => 'emoji-sticker-prompt',
'excerpt' => 'Prompt waiting for thumbs.',
'prompt' => 'Create a chibi emoji sticker collection.',
'difficulty' => 'beginner',
'access_level' => 'free',
'preview_image' => 'academy-prompts/previews/emoji-sticker-pack.webp',
'tool_notes' => [[
'provider' => 'ChatGPT',
'model_name' => '4o Image',
'image_path' => 'academy/lessons/body/aa/bb/emoji-sticker-pack.webp',
'thumb_path' => '',
'active' => true,
]],
'active' => true,
'published_at' => now()->subMinute(),
]);
$this->artisan('academy:prompts:generate-missing-thumbnails')
->expectsOutputToContain('Prompt thumbnail backfill complete.')
->assertSuccessful();
Storage::disk('s3')->assertExists('academy-prompts/previews/emoji-sticker-pack-thumb.webp');
Storage::disk('s3')->assertExists('academy-prompts/previews/emoji-sticker-pack-md.webp');
Storage::disk('s3')->assertExists('academy/lessons/body/aa/bb/emoji-sticker-pack-thumb.webp');
Storage::disk('s3')->assertExists('academy/lessons/body/aa/bb/emoji-sticker-pack-md.webp');
$this->assertSame(
'academy/lessons/body/aa/bb/emoji-sticker-pack-thumb.webp',
$prompt->fresh()->tool_notes[0]['thumb_path'] ?? null,
);
}
public function test_admin_can_store_prompt_with_advanced_prompt_metadata(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$response = $this->actingAs($admin)
->post(route('admin.academy.prompts.store'), [
'title' => 'City Climate Portrait',
'slug' => 'city-climate-portrait',
'excerpt' => 'Advanced prompt with structured documentation.',
'prompt' => 'Create a climate-driven city portrait.',
'negative_prompt' => 'blurry, low detail',
'usage_notes' => 'Use real data before generating.',
'workflow_notes' => 'Internal editorial workflow note.',
'documentation' => [
'summary' => 'This prompt creates a climate-aware city wallpaper.',
'best_for' => ['travel wallpapers', 'editorial posters'],
'how_to_use' => ['Choose a city', 'Collect climate data', 'Insert placeholders'],
'required_inputs' => ['City name', 'Monthly weather data'],
'workflow' => ['Research', 'Prompt prep', 'Generation'],
'tips' => ['Keep the climate ribbon subtle'],
'common_mistakes' => ['Inventing weather data'],
'data_accuracy_notes' => ['Use climate normals where possible'],
'display_notes' => 'Use the image-safe variant for most models.',
],
'placeholders' => [
[
'key' => 'CITY_NAME',
'label' => 'City name',
'description' => 'The featured city.',
'required' => true,
'example' => 'Paris',
'type' => 'text',
],
],
'helper_prompts' => [
[
'title' => 'Collect city climate data',
'description' => 'Gather facts and monthly weather data.',
'prompt' => 'Collect city and climate data for [CITY_NAME].',
'expected_output' => 'json',
],
],
'prompt_variants' => [
[
'title' => 'Image-safe version',
'slug' => 'image-safe-version',
'description' => 'Reduced text pressure for image models.',
'prompt' => 'Create an image-safe city climate portrait.',
'negative_prompt' => 'tiny text, clutter',
'recommended' => true,
'recommended_for' => ['general image generation'],
'risk_notes' => ['Climate icons may still be abstract'],
],
],
'difficulty' => 'intermediate',
'access_level' => 'creator',
'aspect_ratio' => '16:9',
'tags' => ['city', 'climate'],
'preview_image' => '',
'featured' => false,
'prompt_of_week' => false,
'active' => true,
'published_at' => now()->subMinute()->toDateTimeString(),
'seo_title' => '',
'seo_description' => '',
]);
$prompt = AcademyPromptTemplate::query()->where('slug', 'city-climate-portrait')->firstOrFail();
$response->assertRedirect(route('admin.academy.prompts.edit', ['academyPromptTemplate' => $prompt]));
$this->assertSame('This prompt creates a climate-aware city wallpaper.', $prompt->documentation['summary'] ?? null);
$this->assertSame('CITY_NAME', $prompt->placeholders[0]['key'] ?? null);
$this->assertSame('other', $prompt->helper_prompts[0]['type'] ?? null);
$this->assertTrue((bool) ($prompt->helper_prompts[0]['active'] ?? false));
$this->assertSame('Image-safe version', $prompt->prompt_variants[0]['title'] ?? null);
$this->assertTrue((bool) ($prompt->prompt_variants[0]['recommended'] ?? false));
$this->actingAs($admin)
->get(route('admin.academy.prompts.edit', ['academyPromptTemplate' => $prompt]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Admin/Academy/CrudForm')
->where('record.documentation', json_encode([
'summary' => 'This prompt creates a climate-aware city wallpaper.',
'display_notes' => 'Use the image-safe variant for most models.',
'best_for' => ['travel wallpapers', 'editorial posters'],
'how_to_use' => ['Choose a city', 'Collect climate data', 'Insert placeholders'],
'required_inputs' => ['City name', 'Monthly weather data'],
'workflow' => ['Research', 'Prompt prep', 'Generation'],
'tips' => ['Keep the climate ribbon subtle'],
'common_mistakes' => ['Inventing weather data'],
'data_accuracy_notes' => ['Use climate normals where possible'],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES))
->where('record.placeholders', json_encode([
[
'key' => 'CITY_NAME',
'label' => 'City name',
'description' => 'The featured city.',
'required' => true,
'example' => 'Paris',
'default' => null,
'type' => 'text',
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES))
->where('record.helper_prompts', json_encode([
[
'title' => 'Collect city climate data',
'type' => 'other',
'description' => 'Gather facts and monthly weather data.',
'prompt' => 'Collect city and climate data for [CITY_NAME].',
'expected_output' => 'json',
'active' => true,
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES))
->where('record.prompt_variants', json_encode([
[
'title' => 'Image-safe version',
'slug' => 'image-safe-version',
'description' => 'Reduced text pressure for image models.',
'prompt' => 'Create an image-safe city climate portrait.',
'negative_prompt' => 'tiny text, clutter',
'recommended' => true,
'recommended_for' => ['general image generation'],
'risk_notes' => ['Climate icons may still be abstract'],
'active' => true,
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)));
}
public function test_admin_can_store_prompt_when_advanced_json_fields_are_single_objects(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$response = $this->actingAs($admin)
->post(route('admin.academy.prompts.store'), [
'title' => 'Single Object Prompt',
'slug' => 'single-object-prompt',
'excerpt' => 'Uses single-object advanced payloads.',
'prompt' => 'Create a clean travel poster.',
'negative_prompt' => '',
'usage_notes' => '',
'workflow_notes' => '',
'documentation' => [
'summary' => 'Documentation still uses an object.',
],
'placeholders' => [
'key' => 'CITY_NAME',
'label' => 'City name',
'description' => 'Featured city.',
'required' => true,
'example' => 'Paris',
'type' => 'text',
],
'helper_prompts' => [
'title' => 'Collect city data',
'description' => 'Gather source facts.',
'prompt' => 'Collect city data for [CITY_NAME].',
'expected_output' => 'json',
],
'prompt_variants' => [
'title' => 'Image-safe version',
'slug' => 'image-safe-version',
'description' => 'Safer for image models.',
'prompt' => 'Create an image-safe travel poster.',
'recommended' => true,
],
'difficulty' => 'beginner',
'access_level' => 'free',
'aspect_ratio' => '16:9',
'tags' => ['travel'],
'preview_image' => '',
'featured' => false,
'prompt_of_week' => false,
'active' => true,
'published_at' => now()->subMinute()->toDateTimeString(),
'seo_title' => '',
'seo_description' => '',
]);
$prompt = AcademyPromptTemplate::query()->where('slug', 'single-object-prompt')->firstOrFail();
$response->assertRedirect(route('admin.academy.prompts.edit', ['academyPromptTemplate' => $prompt]));
$this->assertSame('CITY_NAME', $prompt->placeholders[0]['key'] ?? null);
$this->assertSame('Collect city data', $prompt->helper_prompts[0]['title'] ?? null);
$this->assertSame('Image-safe version', $prompt->prompt_variants[0]['title'] ?? null);
}
public function test_admin_can_store_prompt_placeholder_without_key_or_type(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$response = $this->actingAs($admin)
->post(route('admin.academy.prompts.store'), [
'title' => 'Loose Placeholder Prompt',
'slug' => 'loose-placeholder-prompt',
'excerpt' => 'Allows descriptive placeholders without a machine key.',
'prompt' => 'Create a stylized city scene.',
'negative_prompt' => '',
'usage_notes' => '',
'workflow_notes' => '',
'documentation' => null,
'placeholders' => [
[
'label' => 'City name',
'description' => 'The city featured in the artwork.',
'example' => 'Paris',
],
],
'helper_prompts' => [],
'prompt_variants' => [],
'difficulty' => 'beginner',
'access_level' => 'free',
'aspect_ratio' => '16:9',
'tags' => ['travel'],
'preview_image' => '',
'featured' => false,
'prompt_of_week' => false,
'active' => true,
'published_at' => now()->subMinute()->toDateTimeString(),
'seo_title' => '',
'seo_description' => '',
]);
$prompt = AcademyPromptTemplate::query()->where('slug', 'loose-placeholder-prompt')->firstOrFail();
$response->assertRedirect(route('admin.academy.prompts.edit', ['academyPromptTemplate' => $prompt]));
$this->assertSame('City name', $prompt->placeholders[0]['label'] ?? null);
$this->assertSame('The city featured in the artwork.', $prompt->placeholders[0]['description'] ?? null);
$this->assertNull($prompt->placeholders[0]['key'] ?? null);
$this->assertNull($prompt->placeholders[0]['type'] ?? null);
}
public function test_admin_can_store_prompt_placeholder_with_custom_type(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$response = $this->actingAs($admin)
->post(route('admin.academy.prompts.store'), [
'title' => 'Custom Type Prompt',
'slug' => 'custom-type-prompt',
'excerpt' => 'Allows custom placeholder type values.',
'prompt' => 'Create a branded city poster.',
'negative_prompt' => '',
'usage_notes' => '',
'workflow_notes' => '',
'documentation' => null,
'placeholders' => [
[
'label' => 'Location profile',
'description' => 'Region-specific context block.',
'type' => 'location_profile',
],
],
'helper_prompts' => [],
'prompt_variants' => [],
'difficulty' => 'beginner',
'access_level' => 'free',
'aspect_ratio' => '16:9',
'tags' => ['travel'],
'preview_image' => '',
'featured' => false,
'prompt_of_week' => false,
'active' => true,
'published_at' => now()->subMinute()->toDateTimeString(),
'seo_title' => '',
'seo_description' => '',
]);
$prompt = AcademyPromptTemplate::query()->where('slug', 'custom-type-prompt')->firstOrFail();
$response->assertRedirect(route('admin.academy.prompts.edit', ['academyPromptTemplate' => $prompt]));
$this->assertSame('location_profile', $prompt->placeholders[0]['type'] ?? null);
}
public function test_admin_course_edit_form_includes_outline_summary(): void
@@ -390,20 +871,54 @@ final class AcademyAdminTest extends TestCase
'title' => 'Required Lesson',
'slug' => 'required-lesson',
'content' => '<p>Body</p>',
'cover_image' => 'academy/lessons/covers/required-cover.webp',
'difficulty' => 'beginner',
'access_level' => 'free',
'lesson_type' => 'article',
'reading_minutes' => 5,
'published_at' => Carbon::parse('2026-05-10 09:30:00'),
'active' => true,
]);
$optionalLesson = AcademyLesson::query()->create([
'title' => 'Optional Lesson',
'slug' => 'optional-lesson',
'content' => '<p>Body</p>',
'article_cover_image' => 'academy/lessons/covers/optional-article-cover.webp',
'difficulty' => 'beginner',
'access_level' => 'free',
'lesson_type' => 'article',
'reading_minutes' => 5,
'published_at' => Carbon::parse('2099-05-18 14:00:00'),
'active' => true,
]);
$libraryLesson = AcademyLesson::query()->create([
'title' => 'Library Lesson',
'slug' => 'library-lesson',
'content' => '<p>Body</p>',
'cover_image' => 'academy/lessons/covers/library-cover.webp',
'difficulty' => 'intermediate',
'access_level' => 'free',
'lesson_type' => 'article',
'reading_minutes' => 5,
'published_at' => Carbon::parse('2099-06-01 08:15:00'),
'active' => false,
]);
$otherCourse = AcademyCourse::query()->create([
'title' => 'Other Course',
'slug' => 'other-course',
'excerpt' => 'Other course',
'access_level' => 'free',
'difficulty' => 'beginner',
'status' => 'draft',
]);
$otherCourseLesson = AcademyLesson::query()->create([
'title' => 'Used Elsewhere Lesson',
'slug' => 'used-elsewhere-lesson',
'content' => '<p>Body</p>',
'difficulty' => 'advanced',
'access_level' => 'free',
'lesson_type' => 'article',
'reading_minutes' => 5,
'active' => true,
]);
@@ -421,6 +936,13 @@ final class AcademyAdminTest extends TestCase
'order_num' => 1,
'is_required' => false,
]);
AcademyCourseLesson::query()->create([
'course_id' => $otherCourse->id,
'section_id' => null,
'lesson_id' => $otherCourseLesson->id,
'order_num' => 0,
'is_required' => true,
]);
$this->actingAs($admin)
->get(route('admin.academy.courses.edit', ['academyCourse' => $course]))
@@ -433,7 +955,25 @@ final class AcademyAdminTest extends TestCase
->where('editorContext.outlineSummary.required_lesson_count', 1)
->where('editorContext.outlineSummary.unsectioned_lesson_count', 1)
->where('editorContext.outlineSummary.sections.0.title', 'Introduction')
->where('editorContext.outlineSummary.sections.0.lesson_count', 1));
->where('editorContext.outlineSummary.sections.0.lesson_count', 1)
->where('editorContext.sectionStoreUrl', route('admin.academy.courses.sections.store', ['academyCourse' => $course]))
->where('editorContext.courseSections.0.title', 'Introduction')
->where('editorContext.courseSections.0.update_url', route('admin.academy.courses.sections.update', ['academyCourse' => $course, 'academyCourseSection' => $section]))
->where('editorContext.courseLessons.0.section_id', $section->id)
->where('editorContext.courseLessons.0.cover_image_url', fn ($url) => is_string($url) && str_ends_with($url, '/academy/lessons/covers/required-cover.webp'))
->where('editorContext.courseLessons.0.active', true)
->where('editorContext.courseLessons.0.publication_state', 'published')
->where('editorContext.courseLessons.0.publication_label', 'Published')
->where('editorContext.courseLessons.1.cover_image_url', fn ($url) => is_string($url) && str_ends_with($url, '/academy/lessons/covers/optional-article-cover.webp'))
->where('editorContext.courseLessons.1.publication_state', 'scheduled')
->where('editorContext.courseLessons.1.publication_label', 'Publishes 2099-05-18 14:00')
->where('editorContext.availableLessons', fn ($lessons) => count($lessons) === 1)
->where('editorContext.availableLessons.0.title', 'Library Lesson')
->where('editorContext.availableLessons.0.cover_image_url', fn ($url) => is_string($url) && str_ends_with($url, '/academy/lessons/covers/library-cover.webp'))
->where('editorContext.availableLessons.0.active', false)
->where('editorContext.availableLessons.0.publication_state', 'scheduled')
->where('editorContext.availableLessons.0.publication_label', 'Publishes 2099-06-01 08:15')
->where('editorContext.availableLessons.0.edit_url', route('admin.academy.lessons.edit', ['academyLesson' => $libraryLesson])));
}
public function test_admin_can_store_course_with_rich_description_and_media_fields(): void
@@ -474,6 +1014,123 @@ final class AcademyAdminTest extends TestCase
$this->assertNotNull($course->published_at);
}
public function test_admin_lessons_index_includes_course_names_and_order(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$course = AcademyCourse::query()->create([
'title' => 'Prompt Foundations',
'slug' => 'prompt-foundations',
'excerpt' => 'Prompt course',
'access_level' => 'free',
'difficulty' => 'beginner',
'status' => 'draft',
]);
$lesson = AcademyLesson::query()->create([
'title' => 'Subject and Scene Control',
'slug' => 'subject-and-scene-control',
'excerpt' => 'Learn how to direct the main subject cleanly.',
'content' => '<p>Body</p>',
'difficulty' => 'beginner',
'access_level' => 'free',
'lesson_type' => 'article',
'course_order' => 4,
'reading_minutes' => 5,
'active' => true,
]);
AcademyCourseLesson::query()->create([
'course_id' => $course->id,
'lesson_id' => $lesson->id,
'order_num' => 3,
'is_required' => true,
]);
$this->actingAs($admin)
->get(route('admin.academy.lessons.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Admin/Academy/CrudIndex')
->where('resource', 'lessons')
->where('columns.1', 'course_names')
->where('columns.2', 'course_order')
->where('items.data.0.title', 'Subject and Scene Control')
->where('items.data.0.course_names.0', 'Prompt Foundations')
->where('items.data.0.course_order', 4)
->where('items.data.0.active', true));
}
public function test_admin_can_import_course_lessons_from_json_toc(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$category = AcademyCategory::query()->create([
'type' => 'lesson',
'name' => 'Wallpaper Prompting',
'slug' => 'wallpaper-prompting',
'order_num' => 1,
'active' => true,
]);
$course = AcademyCourse::query()->create([
'title' => 'Wallpaper Prompt Engineering',
'slug' => 'wallpaper-prompt-engineering',
'excerpt' => 'Learn to structure clean wallpaper prompts.',
'access_level' => 'free',
'difficulty' => 'intermediate',
'status' => 'draft',
]);
$response = $this->actingAs($admin)
->post(route('admin.academy.courses.lessons.import', ['academyCourse' => $course]), [
'defaults' => [
'difficulty' => 'advanced',
'access_level' => 'creator',
'lesson_type' => 'article',
'active' => false,
'category_slug' => 'wallpaper-prompting',
],
'lessons' => [
[
'title' => 'What Makes a Great Wallpaper Prompt?',
'slug' => 'what-makes-a-great-wallpaper-prompt',
'goal' => 'Explain what separates random AI images from clean, usable wallpapers.',
],
[
'title' => 'Composition for Wallpapers',
'goal' => 'Cover centered subjects, negative space, cinematic framing, and icon-safe areas.',
'difficulty' => 'beginner',
'category' => 'Wallpaper Prompting',
],
],
]);
$response->assertRedirect(route('admin.academy.courses.edit', ['academyCourse' => $course]));
$firstLesson = AcademyLesson::query()->where('slug', 'what-makes-a-great-wallpaper-prompt')->firstOrFail();
$secondLesson = AcademyLesson::query()->where('slug', 'composition-for-wallpapers')->firstOrFail();
$this->assertSame('Explain what separates random AI images from clean, usable wallpapers.', $firstLesson->excerpt);
$this->assertSame('Cover centered subjects, negative space, cinematic framing, and icon-safe areas.', $secondLesson->excerpt);
$this->assertSame((int) $category->id, (int) $firstLesson->category_id);
$this->assertSame((int) $category->id, (int) $secondLesson->category_id);
$this->assertSame('advanced', $firstLesson->difficulty);
$this->assertSame('beginner', $secondLesson->difficulty);
$this->assertSame('creator', $firstLesson->access_level);
$this->assertFalse((bool) $firstLesson->active);
$this->assertFalse((bool) $secondLesson->active);
$courseLessons = AcademyCourseLesson::query()
->where('course_id', $course->id)
->orderBy('order_num')
->get();
$this->assertSame([$firstLesson->id, $secondLesson->id], $courseLessons->pluck('lesson_id')->map(static fn ($value) => (int) $value)->all());
$this->assertSame([0, 1], $courseLessons->pluck('order_num')->map(static fn ($value) => (int) $value)->all());
$this->assertSame(1, (int) $firstLesson->fresh()->lesson_number);
$this->assertSame(1, (int) $firstLesson->fresh()->course_order);
$this->assertSame(2, (int) $secondLesson->fresh()->lesson_number);
$this->assertSame(2, (int) $secondLesson->fresh()->course_order);
$this->assertSame(2, (int) $course->fresh()->lessons_count_cache);
}
public function test_admin_category_update_clears_academy_cache(): void
{
$admin = User::factory()->create(['role' => 'admin']);
@@ -602,6 +1259,7 @@ MD;
public function test_admin_can_store_lesson_numbering_fields(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$longTag = str_repeat('a', 100);
$response = $this->actingAs($admin)
->post(route('admin.academy.lessons.store'), [
@@ -612,7 +1270,7 @@ MD;
'series_name' => 'AI Art Basics',
'excerpt' => 'Testing ordering field persistence.',
'content' => '<p>Lesson body.</p>',
'tags' => ['workflow', 'academy'],
'tags' => [$longTag, 'academy'],
'difficulty' => 'beginner',
'access_level' => 'free',
'lesson_type' => 'article',
@@ -636,13 +1294,13 @@ MD;
'course_order' => 3,
'series_name' => 'AI Art Basics',
]);
$this->assertSame(['workflow', 'academy'], $lesson->fresh()->tags);
$this->assertSame([$longTag, 'academy'], $lesson->fresh()->tags);
}
public function test_admin_lesson_reading_time_is_calculated_from_content(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$body = '<p>' . implode(' ', array_fill(0, 420, 'prompt')) . '</p>';
$body = '<p>'.implode(' ', array_fill(0, 420, 'prompt')).'</p>';
$response = $this->actingAs($admin)
->post(route('admin.academy.lessons.store'), [
@@ -838,6 +1496,34 @@ MD;
]);
}
public function test_admin_can_upload_lesson_cover_image_at_six_hundred_width(): void
{
config()->set('uploads.object_storage.disk', 's3');
Storage::fake('s3');
$admin = User::factory()->create(['role' => 'admin']);
$response = $this->actingAs($admin)
->post(route('api.studio.academy.lessons.media.upload'), [
'slot' => 'cover',
'image' => UploadedFile::fake()->image('lesson-cover.png', 600, 315),
]);
$response
->assertOk()
->assertJsonPath('slot', 'cover')
->assertJsonPath('width', 600)
->assertJsonPath('height', 315);
$payload = $response->json();
$this->assertIsString($payload['path'] ?? null);
$this->assertIsString($payload['thumb_path'] ?? null);
Storage::disk('s3')->assertExists($payload['path']);
Storage::disk('s3')->assertExists($payload['thumb_path']);
}
public function test_admin_can_add_ai_comparison_result_to_existing_lesson(): void
{
$admin = User::factory()->create(['role' => 'admin']);
@@ -1113,4 +1799,30 @@ MD;
$this->assertSoftDeleted('academy_lesson_blocks', ['id' => $block->id]);
$this->assertSoftDeleted('academy_ai_comparison_results', ['id' => $result->id]);
}
private function seedAcademySubscription(User $user, string $priceId, string $status = 'active', ?Carbon $endsAt = null): void
{
$subscriptionId = DB::table('subscriptions')->insertGetId([
'user_id' => $user->id,
'type' => 'academy',
'stripe_id' => 'sub_'.$user->id.'_'.md5($priceId.$status.($endsAt?->toISOString() ?? 'active')),
'stripe_status' => $status,
'stripe_price' => $priceId,
'quantity' => 1,
'trial_ends_at' => null,
'ends_at' => $endsAt,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('subscription_items')->insert([
'subscription_id' => $subscriptionId,
'stripe_id' => 'si_'.$user->id.'_'.md5($priceId.$status),
'stripe_product' => 'prod_'.md5($priceId),
'stripe_price' => $priceId,
'quantity' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
}
}

View File

@@ -15,6 +15,7 @@ uses(RefreshDatabase::class);
it('renders JSON-LD structured data on published artwork page', function () {
$user = User::factory()->create(['name' => 'Schema Author']);
$licenseUrl = route('terms-of-service');
$contentType = ContentType::create([
'name' => 'Photography',
@@ -65,11 +66,15 @@ it('renders JSON-LD structured data on published artwork page', function () {
)->toArray()['json_ld'], JSON_UNESCAPED_SLASHES))
->toContain('"@type":"ImageObject"')
->toContain('"name":"Schema Ready Artwork"')
->toContain('"license":"' . $licenseUrl . '"')
->toContain('"acquireLicensePage":"' . $licenseUrl . '"')
->toContain('"copyrightNotice":"Schema Author"')
->toContain('"keywords":["neon","city"]');
});
it('builds artwork seo data with breadcrumb and image-license metadata', function () {
$user = User::factory()->create(['name' => 'Schema Breadcrumb Author']);
$licenseUrl = route('terms-of-service');
$contentType = ContentType::create([
'name' => 'Photography',
@@ -131,6 +136,8 @@ it('builds artwork seo data with breadcrumb and image-license metadata', functio
->toContain('"@type":"ImageObject"')
->toContain('"creditText":"Schema Breadcrumb Author"')
->toContain('"license":"https://skinbase.org/licenses/custom-license"')
->toContain('"acquireLicensePage":"' . $licenseUrl . '"')
->toContain('"copyrightNotice":"Schema Breadcrumb Author"')
->toContain('"@type":"BreadcrumbList"')
->toContain('"name":"Photography"')
->toContain('"name":"Forest"');

View File

@@ -44,5 +44,7 @@ it('returns latest comments api data', function (): void {
->assertJsonPath('data.0.comment_id', $comment->id)
->assertJsonPath('data.0.commenter.id', $author->id)
->assertJsonPath('data.0.artwork.id', $artwork->id)
->assertJsonPath('meta.total', 1);
->assertJsonPath('meta.current_page', 1)
->assertJsonPath('meta.per_page', 20)
->assertJsonPath('meta.has_more', false);
});

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Services\Sitemaps\SitemapReleaseManager;
use Illuminate\Support\Facades\Storage;
it('reports the latest sitemap build timestamp', function (): void {
Storage::fake('local');
$releaseId = '20260511123000-manual-release';
$builtAt = now()->subHours(2)->toAtomString();
Storage::disk('local')->put(
"sitemaps/releases/{$releaseId}/manifest.json",
json_encode([
'release_id' => $releaseId,
'status' => 'published',
'built_at' => $builtAt,
'published_at' => now()->toAtomString(),
'families' => [],
'documents' => [],
'totals' => [
'families' => 0,
'documents' => 0,
'urls' => 0,
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
);
$releases = app(SitemapReleaseManager::class)->listReleases();
expect($releases)
->toHaveCount(1)
->and($releases[0]['release_id'] ?? null)->toBe($releaseId)
->and($releases[0]['built_at'] ?? null)->toBe($builtAt);
$this->artisan('health:check', ['--only' => 'sitemap', '--json' => true])
->assertSuccessful();
});

View File

@@ -1,6 +1,7 @@
<?php
use App\Http\Middleware\HandleInertiaRequests;
use App\Models\Artwork;
use App\Models\User;
use cPad\Plugins\Forum\Models\ForumBoard;
use cPad\Plugins\Forum\Models\ForumCategory;
@@ -86,4 +87,79 @@ it('keeps board page opening-post queries bounded across many topics', function
->assertSee('Opening post for topic 1');
expect($forumPostQueryCount)->toBeLessThanOrEqual(3);
});
it('keeps board page artwork preview queries bounded when opening posts include embeds', function (): void {
$author = User::query()->create([
'username' => 'illustrator-embeds',
'username_changed_at' => now()->subDays(120),
'last_username_change_at' => now()->subDays(120),
'onboarding_step' => 'complete',
'name' => 'Illustration Embed Author',
'email' => 'illustration-embeds@example.com',
'email_verified_at' => now(),
'password' => 'password',
'is_active' => true,
]);
$category = ForumCategory::query()->create([
'name' => 'Art Query Budget Embeds',
'title' => 'Art Embeds',
'slug' => 'art-query-budget-embeds',
'description' => 'Art discussion with embeds',
'is_active' => true,
'position' => 1,
]);
$board = ForumBoard::query()->create([
'category_id' => $category->id,
'title' => 'Illustration Embeds',
'slug' => 'illustration-query-budget-embeds',
'description' => 'Illustration board with embeds',
'is_active' => true,
'position' => 1,
]);
$artwork = Artwork::factory()->for($author)->create([
'title' => 'Forum Preview Artwork',
'slug' => 'forum-preview-artwork',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
for ($index = 1; $index <= 10; $index++) {
$topic = ForumTopic::query()->create([
'board_id' => $board->id,
'user_id' => $author->id,
'title' => 'Embed Topic ' . $index,
'slug' => 'embed-topic-' . $index,
'replies_count' => 1,
'last_post_at' => now()->subMinutes($index),
]);
ForumPost::query()->create([
'thread_id' => $topic->id,
'topic_id' => $topic->id,
'user_id' => $author->id,
'content' => 'Opening post for embed topic ' . $index . ' [artwork:' . $artwork->id . ']',
'created_at' => now()->subMinutes($index + 30),
'updated_at' => now()->subMinutes($index + 30),
]);
}
$artworkQueryCount = 0;
DB::listen(function ($query) use (&$artworkQueryCount): void {
if (preg_match('/\b(from|join)\s+["`\[]?artworks\b/i', $query->sql) === 1) {
$artworkQueryCount++;
}
});
$this->get(route('forum.board.show', ['boardSlug' => $board->slug]))
->assertOk()
->assertSee('Illustration Embeds')
->assertSee('Embed Topic 1')
->assertSee('Opening post for embed topic 1');
expect($artworkQueryCount)->toBeLessThanOrEqual(1);
});

View File

@@ -88,7 +88,11 @@ it('renders discussion forum structured data on forum topic pages', function ():
->assertOk()
->assertSee('application/ld+json', false)
->assertSee('DiscussionForumPosting', false)
->assertSee('<script type="application/ld+json">{"@context":"https://schema.org","@type":"DiscussionForumPosting"', false)
->assertSee('"comment":[{"@type":"Comment"', false)
->assertSee('itemtype="https://schema.org/DiscussionForumPosting"', false)
->assertSee('itemprop="author"', false)
->assertSee('itemprop="text"', false)
->assertSee('itemprop="comment"', false)
->assertSee('itemtype="https://schema.org/Comment"', false)
->assertSee('itemprop="headline"', false)
@@ -101,6 +105,127 @@ it('renders discussion forum structured data on forum topic pages', function ():
->assertSee(route('profile.show', ['username' => 'forumreplier']), false);
});
it('falls back to the topic title when the opening post has no body', function (): void {
$author = User::query()->create([
'username' => 'forumfallbackauthor',
'username_changed_at' => now()->subDays(120),
'last_username_change_at' => now()->subDays(120),
'onboarding_step' => 'complete',
'name' => 'Forum Fallback Author',
'email' => 'forumfallbackauthor@example.com',
'email_verified_at' => now(),
'password' => 'password',
'is_active' => true,
]);
$category = ForumCategory::query()->create([
'name' => 'Forum SEO',
'title' => 'Forum SEO',
'slug' => 'forum-seo-fallback',
'description' => 'SEO discussion category',
'is_active' => true,
'position' => 1,
]);
$board = ForumBoard::query()->create([
'category_id' => $category->id,
'title' => 'Technical SEO',
'slug' => 'technical-seo-fallback',
'description' => 'Technical SEO board',
'is_active' => true,
'position' => 1,
]);
$topic = ForumTopic::query()->create([
'board_id' => $board->id,
'user_id' => $author->id,
'title' => 'Sparse topic body',
'slug' => 'sparse-topic-body',
'views' => 7,
'replies_count' => 0,
'last_post_at' => now(),
]);
ForumPost::query()->create([
'thread_id' => $topic->id,
'topic_id' => $topic->id,
'user_id' => $author->id,
'content' => '',
'created_at' => now()->subHour(),
'updated_at' => now()->subHour(),
]);
$this->get(route('forum.topic.show', ['topic' => $topic->slug]))
->assertOk()
->assertSee('itemtype="https://schema.org/DiscussionForumPosting"', false)
->assertSee('itemprop="author"', false)
->assertSee('itemprop="text"', false)
->assertSee('Sparse topic body', false)
->assertSee('No comments yet.', false)
->assertSee(route('profile.show', ['username' => 'forumfallbackauthor']), false);
});
it('falls back to a default author when the topic author is missing', function (): void {
$deletedAuthor = User::query()->create([
'username' => 'forumdeletedauthor',
'username_changed_at' => now()->subDays(120),
'last_username_change_at' => now()->subDays(120),
'onboarding_step' => 'complete',
'name' => 'Deleted Forum Author',
'email' => 'forumdeletedauthor@example.com',
'email_verified_at' => now(),
'password' => 'password',
'is_active' => true,
]);
$category = ForumCategory::query()->create([
'name' => 'Forum Missing Author',
'title' => 'Forum Missing Author',
'slug' => 'forum-missing-author',
'description' => 'Category for orphaned forum topics',
'is_active' => true,
'position' => 1,
]);
$board = ForumBoard::query()->create([
'category_id' => $category->id,
'title' => 'Missing Author Board',
'slug' => 'missing-author-board',
'description' => 'Board for orphaned forum topics',
'is_active' => true,
'position' => 1,
]);
$topic = ForumTopic::query()->create([
'board_id' => $board->id,
'user_id' => $deletedAuthor->id,
'title' => 'Missing author topic',
'slug' => 'missing-author-topic',
'views' => 3,
'replies_count' => 0,
'last_post_at' => now(),
]);
ForumPost::query()->create([
'thread_id' => $topic->id,
'topic_id' => $topic->id,
'user_id' => $deletedAuthor->id,
'content' => '',
'created_at' => now()->subHour(),
'updated_at' => now()->subHour(),
]);
$deletedAuthor->delete();
$this->get(route('forum.topic.show', ['topic' => $topic->slug]))
->assertOk()
->assertSee('DiscussionForumPosting', false)
->assertSee('"author":{"@type":"Person","name":"Skinbase"}', false)
->assertSee('"text":"Missing author topic"', false)
->assertSee('"comment":[{"@type":"Comment"', false)
->assertSee('No comments yet.', false);
});
it('renders item list microdata on forum board pages', function (): void {
$author = User::query()->create([
'username' => 'boardauthor',

View File

@@ -0,0 +1,107 @@
<?php
use App\Http\Middleware\HandleInertiaRequests;
use App\Models\User;
use cPad\Plugins\Forum\Models\ForumBoard;
use cPad\Plugins\Forum\Models\ForumCategory;
use cPad\Plugins\Forum\Models\ForumPost;
use cPad\Plugins\Forum\Models\ForumPostReaction;
use cPad\Plugins\Forum\Models\ForumTopic;
use Illuminate\Support\Facades\DB;
beforeEach(function (): void {
$this->withoutMiddleware(HandleInertiaRequests::class);
});
it('keeps forum homepage source queries bounded across many boards and trending topics', function (): void {
$author = User::query()->create([
'username' => 'forumhomepageauthor',
'username_changed_at' => now()->subDays(120),
'last_username_change_at' => now()->subDays(120),
'onboarding_step' => 'complete',
'name' => 'Forum Homepage Author',
'email' => 'forumhomepageauthor@example.com',
'email_verified_at' => now(),
'password' => 'password',
'is_active' => true,
]);
$category = ForumCategory::query()->create([
'name' => 'Forum Home Performance',
'title' => 'Forum Home Performance',
'slug' => 'forum-home-performance',
'description' => 'Forum home performance category',
'is_active' => true,
'position' => 1,
]);
$boards = collect();
for ($boardIndex = 1; $boardIndex <= 6; $boardIndex++) {
$board = ForumBoard::query()->create([
'category_id' => $category->id,
'title' => 'Board ' . $boardIndex,
'slug' => 'forum-home-board-' . $boardIndex,
'description' => 'Forum home board ' . $boardIndex,
'is_active' => true,
'position' => $boardIndex,
]);
$boards->push($board);
for ($topicIndex = 1; $topicIndex <= 3; $topicIndex++) {
$topic = ForumTopic::query()->create([
'board_id' => $board->id,
'user_id' => $author->id,
'title' => "Board {$boardIndex} Topic {$topicIndex}",
'slug' => "forum-home-board-{$boardIndex}-topic-{$topicIndex}",
'replies_count' => $topicIndex,
'views' => 25 + $topicIndex,
'last_post_at' => now()->subMinutes(($boardIndex * 10) + $topicIndex),
]);
$post = ForumPost::query()->create([
'thread_id' => $topic->id,
'topic_id' => $topic->id,
'user_id' => $author->id,
'content' => 'Opening post for board ' . $boardIndex . ' topic ' . $topicIndex,
'created_at' => now()->subMinutes(($boardIndex * 10) + $topicIndex + 5),
'updated_at' => now()->subMinutes(($boardIndex * 10) + $topicIndex + 5),
]);
ForumPostReaction::query()->create([
'post_id' => $post->id,
'user_id' => $author->id,
'reaction' => 'thumbs_up',
]);
}
}
$forumTopicsQueryCount = 0;
$forumPostsQueryCount = 0;
$forumReactionsQueryCount = 0;
DB::listen(function ($query) use (&$forumTopicsQueryCount, &$forumPostsQueryCount, &$forumReactionsQueryCount): void {
if (preg_match('/\bfrom\s+[`"\[]?forum_topics\b/i', $query->sql) === 1) {
$forumTopicsQueryCount++;
}
if (preg_match('/\bfrom\s+[`"\[]?forum_posts\b/i', $query->sql) === 1) {
$forumPostsQueryCount++;
}
if (preg_match('/\bfrom\s+[`"\[]?forum_post_reactions\b/i', $query->sql) === 1) {
$forumReactionsQueryCount++;
}
});
$this->get(route('forum.index'))
->assertOk()
->assertSee('Forum Home Performance')
->assertSee('Board 1')
->assertSee('Board 6');
expect($forumTopicsQueryCount)->toBeLessThanOrEqual(4);
expect($forumPostsQueryCount)->toBeLessThanOrEqual(1);
expect($forumReactionsQueryCount)->toBeLessThanOrEqual(1);
});

View File

@@ -314,6 +314,9 @@ it('renders structured data for public news pages', function (): void {
->assertSee('NewsArticle', false)
->assertSee('ImageObject', false)
->assertSee('creditText', false)
->assertSee('copyrightNotice', false)
->assertSee('creator', false)
->assertSee(route('profile.show', ['username' => $author->username]), false)
->assertSee(route('terms-of-service'), false)
->assertSee('acquireLicensePage', false)
->assertSee('BreadcrumbList', false);

View File

@@ -0,0 +1,143 @@
<?php
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\Group;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('keeps similar artworks results queries bounded when hydrating gallery items', function (): void {
$author = User::factory()->create([
'username' => 'similar-results-author',
'name' => 'Similar Results Author',
]);
$contentType = ContentType::query()->create([
'name' => 'Skins',
'slug' => 'skins',
'description' => 'Skins content type',
]);
$category = Category::query()->create([
'content_type_id' => $contentType->id,
'name' => 'Internet',
'slug' => 'internet-similar-results',
'description' => 'Internet skins',
'is_active' => true,
'sort_order' => 1,
]);
$source = Artwork::factory()->for($author)->create([
'title' => 'Similar Results Source',
'slug' => 'similar-results-source',
'published_at' => now()->subHour(),
'is_public' => true,
'is_approved' => true,
]);
$source->categories()->attach($category->id);
foreach (range(1, 20) as $index) {
$artwork = Artwork::factory()->for($author)->create([
'title' => 'Similar Results Artwork ' . $index,
'slug' => 'similar-results-artwork-' . $index,
'published_at' => now()->subMinutes($index),
'is_public' => true,
'is_approved' => true,
]);
$artwork->categories()->attach($category->id);
}
$categoryQueryCount = 0;
$userQueryCount = 0;
DB::listen(function ($query) use (&$categoryQueryCount, &$userQueryCount): void {
if (preg_match('/\b(from|join)\s+["`\[]?artwork_category\b/i', $query->sql) === 1 || preg_match('/\bfrom\s+["`\[]?categories\b/i', $query->sql) === 1) {
$categoryQueryCount++;
}
if (preg_match('/\bfrom\s+["`\[]?users\b/i', $query->sql) === 1) {
$userQueryCount++;
}
});
$this->getJson('/art/' . $source->id . '/similar-results')
->assertOk()
->assertJsonStructure(['data', 'similarity_source', 'total', 'current_page', 'last_page']);
expect($categoryQueryCount)->toBeLessThanOrEqual(4);
expect($userQueryCount)->toBeLessThanOrEqual(3);
});
it('keeps similar artworks results group queries bounded for group publishers', function (): void {
$author = User::factory()->create([
'username' => 'similar-results-group-author',
'name' => 'Similar Results Group Author',
]);
$group = Group::factory()->create([
'owner_user_id' => $author->id,
'name' => 'Similar Results Group',
'slug' => 'similar-results-group',
'visibility' => Group::VISIBILITY_PUBLIC,
'status' => Group::LIFECYCLE_ACTIVE,
]);
$contentType = ContentType::query()->create([
'name' => 'Skins',
'slug' => 'skins',
'description' => 'Skins content type',
]);
$category = Category::query()->create([
'content_type_id' => $contentType->id,
'name' => 'Internet',
'slug' => 'internet-similar-results-group',
'description' => 'Internet skins',
'is_active' => true,
'sort_order' => 1,
]);
$source = Artwork::factory()->for($author)->create([
'title' => 'Similar Results Group Source',
'slug' => 'similar-results-group-source',
'group_id' => $group->id,
'published_as_type' => Artwork::PUBLISHED_AS_GROUP,
'published_as_id' => $group->id,
'published_at' => now()->subHour(),
'is_public' => true,
'is_approved' => true,
]);
$source->categories()->attach($category->id);
foreach (range(1, 16) as $index) {
$artwork = Artwork::factory()->for($author)->create([
'title' => 'Similar Results Group Artwork ' . $index,
'slug' => 'similar-results-group-artwork-' . $index,
'group_id' => $group->id,
'published_as_type' => Artwork::PUBLISHED_AS_GROUP,
'published_as_id' => $group->id,
'published_at' => now()->subMinutes($index),
'is_public' => true,
'is_approved' => true,
]);
$artwork->categories()->attach($category->id);
}
$groupQueryCount = 0;
DB::listen(function ($query) use (&$groupQueryCount): void {
if (preg_match('/\bfrom\s+["`\[]?groups\b/i', $query->sql) === 1) {
$groupQueryCount++;
}
});
$this->getJson('/art/' . $source->id . '/similar-results')
->assertOk()
->assertJsonStructure(['data', 'similarity_source', 'total', 'current_page', 'last_page']);
expect($groupQueryCount)->toBeLessThanOrEqual(3);
});

View File

@@ -106,6 +106,38 @@ it('renders newsroom studio pages for moderators', function (): void {
->assertSee('Moderated newsroom article');
});
it('decodes legacy apostrophe entities in the newsroom editor', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
'username' => 'modedit',
'name' => 'Moderator Edit',
]);
$author = User::factory()->create([
'username' => 'writeredit',
'name' => 'Writer Edit',
]);
$category = studioNewsCategory();
$article = NewsArticle::query()->create([
'title' => 'Ericsson ENGINE to expand Telia&acute;s international carrier network',
'slug' => 'ericsson-engine-to-expand-telias-international-carrier-network',
'excerpt' => 'Studio-managed newsroom article.',
'content' => 'Studio body',
'author_id' => $author->id,
'category_id' => $category->id,
'type' => NewsArticle::TYPE_EDITORIAL,
'status' => 'draft',
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
]);
$this->actingAs($moderator)
->get(route('studio.news.edit', ['article' => $article->id]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioNewsEditor')
->where('article.title', "Ericsson ENGINE to expand Telia's international carrier network"));
});
it('filters newsroom listing by status type and category', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
@@ -172,6 +204,46 @@ it('filters newsroom listing by status type and category', function (): void {
->where('listing.items.0.title', 'Keep Me'));
});
it('paginates newsroom listing', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
]);
$author = User::factory()->create();
foreach (range(1, 16) as $index) {
NewsArticle::query()->create([
'title' => "Paged Article {$index}",
'slug' => "paged-article-{$index}",
'excerpt' => 'Paginated newsroom article.',
'content' => 'Content',
'author_id' => $author->id,
'type' => NewsArticle::TYPE_ANNOUNCEMENT,
'status' => 'draft',
'editorial_status' => NewsArticle::EDITORIAL_STATUS_DRAFT,
]);
}
$this->actingAs($moderator)
->get(route('studio.news.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioNewsIndex')
->where('listing.meta.current_page', 1)
->where('listing.meta.last_page', 2)
->where('listing.meta.total', 16)
->has('listing.items', 15));
$this->actingAs($moderator)
->get(route('studio.news.index', ['page' => 2]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioNewsIndex')
->where('listing.meta.current_page', 2)
->where('listing.meta.last_page', 2)
->where('listing.meta.total', 16)
->has('listing.items', 1));
});
it('stores a newsroom draft with taxonomy links', function (): void {
$moderator = User::factory()->create([
'role' => 'moderator',
@@ -220,6 +292,8 @@ it('stores a newsroom draft with taxonomy links', function (): void {
'status' => 'draft',
]);
expect($article->canonical_url)->toBe(route('news.show', ['slug' => 'stored-newsroom-draft']));
expect($article->tags()->pluck('news_tags.name')->all())
->toContain('Update')
->toContain('Studio Exclusive');

View File

@@ -2,7 +2,15 @@
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('renders the tag page with correct title and canonical', function (): void {
$tag = Tag::factory()->create(['name' => 'Cyberpunk', 'slug' => 'cyberpunk', 'is_active' => true]);
@@ -61,3 +69,61 @@ it('supports sort parameter without error', function (): void {
$this->get("/tag/space?sort={$sort}")->assertOk();
}
});
it('keeps tag page artwork relation queries bounded when the gallery is populated', function (): void {
$tag = Tag::factory()->create(['name' => 'Pixel Art', 'slug' => 'pixel-art', 'is_active' => true]);
$author = User::factory()->create([
'name' => 'Pixel Artist',
'username' => 'pixelartist',
]);
$contentType = ContentType::create([
'name' => 'Photography',
'slug' => 'photography',
'description' => 'Photography content',
]);
$category = Category::create([
'content_type_id' => $contentType->id,
'parent_id' => null,
'name' => 'Abstract',
'slug' => 'abstract-pixel-art',
'description' => 'Abstract works',
'is_active' => true,
'sort_order' => 0,
]);
foreach (range(1, 12) as $index) {
$artwork = Artwork::factory()->for($author)->create([
'title' => 'Pixel Art ' . $index,
'slug' => 'pixel-art-' . $index,
'published_at' => now()->subMinutes($index),
'is_public' => true,
'is_approved' => true,
]);
$artwork->categories()->attach($category->id);
$artwork->tags()->attach($tag->id, ['source' => 'user', 'confidence' => 1]);
}
$categoryQueryCount = 0;
$userQueryCount = 0;
DB::listen(function ($query) use (&$categoryQueryCount, &$userQueryCount): void {
if (preg_match('/\bfrom\s+["`\[]?categories\b/i', $query->sql) === 1 || preg_match('/\bfrom\s+["`\[]?category_content_type\b/i', $query->sql) === 1) {
$categoryQueryCount++;
}
if (preg_match('/\bfrom\s+["`\[]?users\b/i', $query->sql) === 1) {
$userQueryCount++;
}
});
$this->get('/tag/pixel-art')
->assertOk()
->assertSee('Pixel Art', false);
expect($categoryQueryCount)->toBeLessThanOrEqual(4);
expect($userQueryCount)->toBeLessThanOrEqual(3);
});

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\ContentType;
use App\Models\Category;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
it('renders featured artworks without lazy loading category content types', function (): void {
$contentType = ContentType::query()->create([
'name' => 'Digital Art',
'slug' => 'digital-art',
'description' => 'Digital art content type',
'order' => 1,
'hide_from_menu' => false,
]);
$category = Category::query()->create([
'content_type_id' => $contentType->id,
'parent_id' => null,
'name' => 'Featured Category',
'slug' => 'featured-category',
'description' => 'Featured category',
'is_active' => true,
'sort_order' => 1,
]);
$artwork = Artwork::factory()->create([
'title' => 'Featured Route Artwork',
'slug' => 'featured-route-artwork-' . Str::lower((string) Str::uuid()),
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHour(),
'has_missing_thumbnails' => false,
]);
$artwork->categories()->attach($category->id);
DB::table('artwork_features')->insert([
'artwork_id' => $artwork->id,
'priority' => 100,
'featured_at' => now()->subHour(),
'expires_at' => null,
'label' => null,
'note' => null,
'is_active' => true,
'created_by' => null,
'created_at' => now(),
'updated_at' => now(),
'deleted_at' => null,
]);
Model::preventLazyLoading();
try {
$this->withoutExceptionHandling()
->get(route('featured'))
->assertOk()
->assertSee('Featured Route Artwork');
} finally {
Model::preventLazyLoading(false);
}
});

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
use App\Models\World;
use App\Models\WorldWebStory;
use App\Models\WorldWebStoryPage;
use App\Services\Sitemaps\SitemapBuildService;
it('renders a published world web story as an amp story document', function (): void {
$world = World::factory()->current()->create([
'title' => 'Fantasy Realms',
'slug' => 'fantasy-realms-world',
]);
$story = WorldWebStory::factory()->visible()->for($world)->create([
'slug' => 'fantasy-realms',
'title' => 'Fantasy Realms',
'excerpt' => 'A cinematic journey through magical wallpapers, enchanted landscapes, and dreamlike digital art.',
'poster_portrait_path' => 'https://files.skinbase.org/web-stories/worlds/fantasy-realms/poster-portrait.webp',
'publisher_logo_path' => 'https://cdn.skinbase.org/images/skinbase_logo_96.webp',
]);
foreach (range(1, 5) as $position) {
WorldWebStoryPage::factory()->for($story, 'story')->create([
'position' => $position,
'headline' => 'Page ' . $position,
'background_path' => 'https://files.skinbase.org/web-stories/worlds/fantasy-realms/pages/page-0' . $position . '.webp',
'background_mobile_path' => 'https://files.skinbase.org/web-stories/worlds/fantasy-realms/pages/page-0' . $position . '.webp',
'alt_text' => 'Page ' . $position,
]);
}
$response = $this->get(route('web-stories.show', ['slug' => $story->slug]));
$response->assertOk();
$response->assertSee('<html amp', false);
$response->assertSee('<amp-story', false);
$response->assertSee('publisher-logo-src="https://cdn.skinbase.org/images/skinbase_logo_96.webp"', false);
$response->assertSee('poster-portrait-src="https://files.skinbase.org/web-stories/worlds/fantasy-realms/poster-portrait.webp"', false);
$response->assertSee('<link rel="canonical" href="' . route('web-stories.show', ['slug' => $story->slug]) . '">', false);
$response->assertDontSee('noindex,follow', false);
expect(substr_count((string) $response->getContent(), '<amp-story-page '))->toBeGreaterThanOrEqual(5);
});
it('does not expose draft world web stories publicly', function (): void {
$world = World::factory()->current()->create();
$story = WorldWebStory::factory()->for($world)->create([
'slug' => 'draft-world-story',
'status' => WorldWebStory::STATUS_DRAFT,
]);
$this->get(route('web-stories.show', ['slug' => $story->slug]))->assertNotFound();
});
it('renders the web stories index with published stories only', function (): void {
$world = World::factory()->current()->create([
'title' => 'Spring Vibes',
'slug' => 'spring-vibes-world',
]);
$visible = WorldWebStory::factory()->visible()->for($world)->create([
'slug' => 'spring-vibes',
'title' => 'Spring Vibes',
]);
$draft = WorldWebStory::factory()->for($world)->create([
'slug' => 'hidden-story',
'title' => 'Hidden Story',
]);
$response = $this->get(route('web-stories.index'));
$response->assertOk();
$response->assertSee($visible->title);
$response->assertDontSee($draft->title);
});
it('includes visible web stories in the dedicated sitemap only', function (): void {
$world = World::factory()->current()->create();
$visible = WorldWebStory::factory()->visible()->for($world)->create([
'slug' => 'hello-again',
]);
WorldWebStory::factory()->published()->for($world)->create([
'slug' => 'noindex-story',
'noindex' => true,
]);
WorldWebStory::factory()->for($world)->create([
'slug' => 'draft-story',
]);
$built = app(SitemapBuildService::class)->buildNamed('web-stories', force: true, persist: false);
expect($built)->not->toBeNull();
$path = public_path('sitemaps/web-stories.xml');
if (! is_dir(dirname($path))) {
mkdir(dirname($path), 0777, true);
}
file_put_contents($path, $built['content']);
$this->get('/sitemaps/web-stories.xml')->assertOk();
$xml = file_get_contents($path);
expect(str_contains($xml, route('web-stories.show', ['slug' => $visible->slug])))->toBeTrue();
expect(str_contains($xml, 'noindex-story'))->toBeFalse();
expect(str_contains($xml, 'draft-story'))->toBeFalse();
});

View File

@@ -6,6 +6,7 @@ use App\Models\User;
use App\Models\World;
use App\Models\WorldRewardGrant;
use App\Models\WorldSubmission;
use App\Models\WorldWebStory;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupChallenge;
@@ -152,6 +153,26 @@ it('includes rewarded contributors on public world pages', function (): void {
->where('rewardedContributors.items.0.badge_label', $world->title . ' Winner'));
});
it('exposes a published web story on the public world page payload', function (): void {
$world = publicWorld([
'title' => 'Fantasy Realms',
'slug' => 'fantasy-realms',
]);
$story = WorldWebStory::factory()->visible()->for($world)->create([
'slug' => 'fantasy-realms',
'title' => 'Fantasy Realms',
'excerpt' => 'A cinematic journey through magical wallpapers and dreamlike digital art.',
]);
$this->get(route('worlds.show', ['world' => $world->slug]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('World/WorldShow')
->where('webStory.slug', $story->slug)
->where('webStory.url', route('web-stories.show', ['slug' => $story->slug])));
});
it('renders recap payloads for ended worlds with published recaps', function (): void {
$creator = User::factory()->create([
'username' => 'recapcreator-' . Str::lower(Str::random(6)),

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Academy;
use App\Models\User;
use App\Services\Academy\AcademyAccessService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Cashier\Subscription;
use Tests\TestCase;
final class AcademyAccessServiceTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
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,
],
]);
}
public function test_current_tier_uses_cashier_subscription_items(): void
{
$user = User::factory()->create();
$this->attachSubscription($user, 'sub_creator_unit', 'price_creator_month');
$service = app(AcademyAccessService::class);
$this->assertSame('creator', $service->currentTier($user));
$this->assertTrue($service->canAccessContent($user, 'creator'));
$this->assertFalse($service->canAccessContent($user, 'pro'));
}
public function test_pro_subscription_unlocks_creator_and_pro_content(): void
{
$user = User::factory()->create();
$this->attachSubscription($user, 'sub_pro_unit', 'price_pro_month');
$service = app(AcademyAccessService::class);
$this->assertSame('pro', $service->currentTier($user));
$this->assertTrue($service->canAccessContent($user, 'creator'));
$this->assertTrue($service->canAccessContent($user, 'pro'));
}
public function test_grace_period_keeps_access_and_ended_subscription_loses_it(): void
{
$graceUser = User::factory()->create();
$endedUser = User::factory()->create();
$this->attachSubscription($graceUser, 'sub_grace_unit', 'price_creator_month', now()->addHour());
$this->attachSubscription($endedUser, 'sub_ended_unit', 'price_creator_month', now()->subHour());
$service = app(AcademyAccessService::class);
$this->assertTrue($service->canAccessContent($graceUser, 'creator'));
$this->assertFalse($service->canAccessContent($endedUser, 'creator'));
}
public function test_staff_and_moderators_bypass_billing_checks(): void
{
$admin = User::factory()->create(['role' => 'admin']);
$moderator = User::factory()->create(['role' => 'moderator']);
$service = app(AcademyAccessService::class);
$this->assertSame('admin', $service->currentTier($admin));
$this->assertSame('admin', $service->currentTier($moderator));
$this->assertTrue($service->canAccessContent($admin, 'pro'));
$this->assertTrue($service->canAccessContent($moderator, 'pro'));
}
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;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use App\Models\WorldWebStory;
use App\Models\WorldWebStoryPage;
use App\Services\WebStories\WorldWebStoryValidationService;
it('fails validation for missing poster, too few pages, long body, and missing background media', function (): void {
$story = WorldWebStory::factory()->published()->create([
'poster_portrait_path' => null,
'publisher_logo_path' => 'https://cdn.skinbase.org/images/skinbase_logo_96.webp',
]);
WorldWebStoryPage::factory()->for($story, 'story')->create([
'position' => 1,
'background_type' => WorldWebStoryPage::BACKGROUND_IMAGE,
'background_path' => null,
'background_mobile_path' => null,
'alt_text' => '',
'body' => str_repeat('a', 181),
]);
$result = app(WorldWebStoryValidationService::class)->validate($story->fresh('orderedPages'));
expect($result['valid'])->toBeFalse()
->and($result['errors'])->toContain('Poster portrait image is required.')
->and($result['errors'])->toContain('A published web story must have at least 5 active pages.')
->and(collect($result['errors'])->contains(fn (string $error): bool => str_contains($error, 'body exceeds 180 characters')))->toBeTrue()
->and(collect($result['errors'])->contains(fn (string $error): bool => str_contains($error, 'missing required background media')))->toBeTrue();
});