withoutMiddleware(ConditionalValidateCsrfToken::class); } public function test_admin_can_open_academy_dashboard(): void { $admin = User::factory()->create(['role' => 'admin']); $this->actingAs($admin) ->get('/moderation/academy/dashboard') ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Admin/Academy/Dashboard') ->where('stats.courses', 0) ->where('stats.lessons', 0) ->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']); $user = User::factory()->create(); $artwork = Artwork::factory()->create(['user_id' => $user->id]); $challenge = AcademyChallenge::query()->create([ 'title' => 'Moderated Challenge', 'slug' => 'moderated-challenge', 'access_level' => 'free', 'status' => 'active', 'active' => true, ]); $submission = AcademyChallengeSubmission::query()->create([ 'challenge_id' => $challenge->id, 'user_id' => $user->id, 'artwork_id' => $artwork->id, 'moderation_status' => 'pending', 'submitted_at' => now(), ]); $this->from('/moderation/academy/submissions') ->actingAs($admin) ->post(route('admin.academy.submissions.approve', ['academyChallengeSubmission' => $submission])) ->assertRedirect('/moderation/academy/submissions'); $this->assertDatabaseHas('academy_challenge_submissions', [ 'id' => $submission->id, 'moderation_status' => 'approved', ]); $this->from('/moderation/academy/submissions') ->actingAs($admin) ->post(route('admin.academy.submissions.reject', ['academyChallengeSubmission' => $submission])) ->assertRedirect('/moderation/academy/submissions'); $this->assertDatabaseHas('academy_challenge_submissions', [ 'id' => $submission->id, 'moderation_status' => 'rejected', ]); } public function test_admin_can_open_all_academy_modules(): void { $admin = User::factory()->create(['role' => 'admin']); foreach ([ '/moderation/academy/dashboard', '/moderation/academy/billing', '/moderation/academy/courses', '/moderation/academy/categories', '/moderation/academy/lessons', '/moderation/academy/prompts', '/moderation/academy/packs', '/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']); $course = AcademyCourse::query()->create([ 'title' => 'Builder Course', 'slug' => 'builder-course', 'excerpt' => 'Course builder test', 'access_level' => 'free', 'difficulty' => 'beginner', 'status' => 'draft', ]); $this->actingAs($admin) ->get(route('admin.academy.courses.builder.edit', ['academyCourse' => $course])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Admin/Academy/CourseBuilder') ->where('course.slug', 'builder-course') ->where('routes.reorder', route('admin.academy.courses.reorder', ['academyCourse' => $course]))); } public function test_course_builder_attach_rewrites_lesson_numbers_and_course_order(): void { $admin = User::factory()->create(['role' => 'admin']); $course = AcademyCourse::query()->create([ 'title' => 'Ordering Course', 'slug' => 'ordering-course', 'access_level' => 'free', 'difficulty' => 'beginner', 'status' => 'draft', ]); $firstLesson = AcademyLesson::query()->create([ 'title' => 'First Lesson', 'slug' => 'first-lesson', 'lesson_number' => 9, 'course_order' => 9, 'content' => '

Body

', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'reading_minutes' => 5, 'active' => true, 'published_at' => now()->subMinute(), ]); $secondLesson = AcademyLesson::query()->create([ 'title' => 'Second Lesson', 'slug' => 'second-lesson', 'lesson_number' => 8, 'course_order' => 8, 'content' => '

Body

', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'reading_minutes' => 5, 'active' => true, 'published_at' => now()->subMinute(), ]); $newLesson = AcademyLesson::query()->create([ 'title' => 'New Lesson', 'slug' => 'new-lesson', 'content' => '

Body

', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'reading_minutes' => 5, 'active' => true, 'published_at' => now()->subMinute(), ]); AcademyCourseLesson::query()->create([ 'course_id' => $course->id, 'lesson_id' => $firstLesson->id, 'order_num' => 4, 'is_required' => true, ]); AcademyCourseLesson::query()->create([ 'course_id' => $course->id, 'lesson_id' => $secondLesson->id, 'order_num' => 7, 'is_required' => true, ]); $this->actingAs($admin) ->post(route('admin.academy.courses.lessons.attach', ['academyCourse' => $course]), [ 'lesson_id' => $newLesson->id, 'order_num' => 12, 'is_required' => true, ]) ->assertRedirect(); $orderedCourseLessons = AcademyCourseLesson::query() ->where('course_id', $course->id) ->orderBy('order_num') ->get(); $this->assertSame([0, 1, 2], $orderedCourseLessons->pluck('order_num')->map(static fn ($value) => (int) $value)->all()); $this->assertSame([1, 2, 3], AcademyLesson::query()->whereIn('id', [$firstLesson->id, $secondLesson->id, $newLesson->id])->orderBy('course_order')->pluck('course_order')->map(static fn ($value) => (int) $value)->all()); $this->assertSame(1, $firstLesson->fresh()->lesson_number); $this->assertSame(1, $firstLesson->fresh()->course_order); $this->assertSame(2, $secondLesson->fresh()->lesson_number); $this->assertSame(2, $secondLesson->fresh()->course_order); $this->assertSame(3, $newLesson->fresh()->lesson_number); $this->assertSame(3, $newLesson->fresh()->course_order); } public function test_course_builder_reorder_rewrites_lesson_numbers_and_course_order(): void { $admin = User::factory()->create(['role' => 'admin']); $course = AcademyCourse::query()->create([ 'title' => 'Reorder Course', 'slug' => 'reorder-course', 'access_level' => 'free', 'difficulty' => 'beginner', 'status' => 'draft', ]); $firstLesson = AcademyLesson::query()->create([ 'title' => 'Alpha Lesson', 'slug' => 'alpha-lesson', 'lesson_number' => 1, 'course_order' => 1, 'content' => '

Body

', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'reading_minutes' => 5, 'active' => true, 'published_at' => now()->subMinute(), ]); $secondLesson = AcademyLesson::query()->create([ 'title' => 'Beta Lesson', 'slug' => 'beta-lesson', 'lesson_number' => 2, 'course_order' => 2, 'content' => '

Body

', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'reading_minutes' => 5, 'active' => true, 'published_at' => now()->subMinute(), ]); $firstCourseLesson = AcademyCourseLesson::query()->create([ 'course_id' => $course->id, 'lesson_id' => $firstLesson->id, 'order_num' => 0, 'is_required' => true, ]); $secondCourseLesson = AcademyCourseLesson::query()->create([ 'course_id' => $course->id, 'lesson_id' => $secondLesson->id, 'order_num' => 1, 'is_required' => true, ]); $this->actingAs($admin) ->patch(route('admin.academy.courses.reorder', ['academyCourse' => $course]), [ 'sections' => [], 'lessons' => [ [ 'id' => $secondCourseLesson->id, 'order_num' => 0, 'section_id' => null, ], [ 'id' => $firstCourseLesson->id, 'order_num' => 1, 'section_id' => null, ], ], ]) ->assertRedirect(); $this->assertSame(1, $secondLesson->fresh()->lesson_number); $this->assertSame(1, $secondLesson->fresh()->course_order); $this->assertSame(2, $firstLesson->fresh()->lesson_number); $this->assertSame(2, $firstLesson->fresh()->course_order); $this->assertSame([0, 1], AcademyCourseLesson::query()->where('course_id', $course->id)->orderBy('order_num')->pluck('order_num')->map(static fn ($value) => (int) $value)->all()); $this->assertSame([$secondLesson->id, $firstLesson->id], AcademyCourseLesson::query()->where('course_id', $course->id)->orderBy('order_num')->pluck('lesson_id')->map(static fn ($value) => (int) $value)->all()); } public function test_admin_can_open_course_create_form_with_editor_context(): void { $admin = User::factory()->create(['role' => 'admin']); $this->actingAs($admin) ->get(route('admin.academy.courses.create')) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Admin/Academy/CrudForm') ->where('resource', 'courses') ->where('editorContext.coverUploadUrl', route('api.studio.academy.lessons.media.upload')) ->where('editorContext.coverDeleteUrl', route('api.studio.academy.lessons.media.destroy')) ->where('editorContext.bodyMediaUploadUrl', route('api.studio.academy.lessons.media.upload')) ->where('record.cover_image_url', null) ->where('record.teaser_image_url', null)); } public function test_admin_can_store_prompt_with_ai_model_comparisons(): void { $admin = User::factory()->create(['role' => 'admin']); $response = $this->actingAs($admin) ->post(route('admin.academy.prompts.store'), [ 'title' => 'Prompt Comparison Template', 'slug' => 'prompt-comparison-template', 'excerpt' => 'Compare the same prompt across different AI models.', 'prompt' => 'Create a cinematic sci-fi skyline with reflective rain-soaked streets.', 'negative_prompt' => 'blurry, low detail, text, watermark', 'usage_notes' => 'Use this when you want reflective city lighting.', 'workflow_notes' => 'Best after a composition sketch pass.', 'difficulty' => 'beginner', 'access_level' => 'free', 'aspect_ratio' => '16:9', 'tags' => ['cinematic', 'city'], 'tool_notes' => [ [ 'provider' => 'Midjourney', 'model_name' => 'V7', 'notes' => 'Produces the strongest mood and lighting with minimal retries.', 'image_path' => 'academy/lessons/body/aa/bb/prompt-midjourney.webp', 'thumb_path' => 'academy/lessons/body/aa/bb/prompt-midjourney-thumb.webp', 'settings' => 'Midjourney V7 on Discord, stylize 200, 16:9 upscale.', 'strengths' => 'Atmosphere, composition, reflective light.', 'weaknesses' => 'Can over-stylize signage and crowd details.', 'best_for' => 'Quick concept frames and wallpaper-ready hero shots.', 'score' => 9, 'active' => true, ], ], 'preview_image' => '', 'featured' => false, 'prompt_of_week' => false, 'active' => true, 'published_at' => now()->subMinute()->toDateTimeString(), 'seo_title' => '', 'seo_description' => '', ]); $prompt = AcademyPromptTemplate::query()->where('slug', 'prompt-comparison-template')->firstOrFail(); $response->assertRedirect(route('admin.academy.prompts.edit', ['academyPromptTemplate' => $prompt])); $this->assertSame('Midjourney', $prompt->tool_notes[0]['provider'] ?? null); $this->assertSame('V7', $prompt->tool_notes[0]['model_name'] ?? null); $this->assertSame('Quick concept frames and wallpaper-ready hero shots.', $prompt->tool_notes[0]['best_for'] ?? null); $this->assertSame('academy/lessons/body/aa/bb/prompt-midjourney.webp', $prompt->tool_notes[0]['image_path'] ?? null); $this->assertSame(9, $prompt->tool_notes[0]['score'] ?? null); $this->actingAs($admin) ->get(route('admin.academy.prompts.edit', ['academyPromptTemplate' => $prompt])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Admin/Academy/CrudForm') ->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)); } 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'], ], ], 'filled_examples' => [ [ 'title' => 'Paris spring editorial poster', 'description' => 'Filled example for a travel poster run.', 'placeholder_values' => [ 'CITY_NAME' => 'Paris', 'WEATHER_STYLE' => 'mild spring light', ], 'prompt' => 'Create a Paris travel poster with mild spring light and editorial composition.', 'negative_prompt' => 'muddy weather, cluttered text', ], ], '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->assertSame('Paris spring editorial poster', $prompt->filled_examples[0]['title'] ?? null); $this->assertSame('Paris', $prompt->filled_examples[0]['placeholder_values']['CITY_NAME'] ?? null); $this->assertSame('Create a Paris travel poster with mild spring light and editorial composition.', $prompt->filled_examples[0]['prompt'] ?? null); $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)) ->where('record.filled_examples', json_encode([ [ 'title' => 'Paris spring editorial poster', 'description' => 'Filled example for a travel poster run.', 'placeholder_values' => [ 'CITY_NAME' => 'Paris', 'WEATHER_STYLE' => 'mild spring light', ], 'prompt' => 'Create a Paris travel poster with mild spring light and editorial composition.', 'negative_prompt' => 'muddy weather, cluttered text', ], ], 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 { $admin = User::factory()->create(['role' => 'admin']); $course = AcademyCourse::query()->create([ 'title' => 'Outline Course', 'slug' => 'outline-course', 'access_level' => 'free', 'difficulty' => 'beginner', 'status' => 'draft', ]); $section = $course->sections()->create([ 'title' => 'Introduction', 'slug' => 'introduction', 'order_num' => 0, 'is_visible' => true, ]); $requiredLesson = AcademyLesson::query()->create([ 'title' => 'Required Lesson', 'slug' => 'required-lesson', 'content' => '

Body

', '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' => '

Body

', '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' => '

Body

', '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' => '

Body

', 'difficulty' => 'advanced', 'access_level' => 'free', 'lesson_type' => 'article', 'reading_minutes' => 5, 'active' => true, ]); AcademyCourseLesson::query()->create([ 'course_id' => $course->id, 'section_id' => $section->id, 'lesson_id' => $requiredLesson->id, 'order_num' => 0, 'is_required' => true, ]); AcademyCourseLesson::query()->create([ 'course_id' => $course->id, 'section_id' => null, 'lesson_id' => $optionalLesson->id, '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])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Admin/Academy/CrudForm') ->where('editorContext.outlineSummary.section_count', 1) ->where('editorContext.outlineSummary.visible_section_count', 1) ->where('editorContext.outlineSummary.lesson_count', 2) ->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.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 { $admin = User::factory()->create(['role' => 'admin']); $response = $this->actingAs($admin) ->post(route('admin.academy.courses.store'), [ 'title' => 'Foundations Course', 'slug' => 'foundations-course', 'subtitle' => 'A guided path for Skinbase creators', 'excerpt' => 'A strong introduction to AI-assisted digital art workflows.', 'description' => '

What you will learn

Prompt structure, workflow cleanup, and publication readiness.

', 'cover_image' => 'academy/lessons/covers/aa/bb/course-cover.webp', 'teaser_image' => 'academy/lessons/covers/cc/dd/course-teaser.webp', 'access_level' => 'free', 'difficulty' => 'beginner', 'status' => 'published', 'is_featured' => true, 'order_num' => 5, 'estimated_minutes' => 90, 'published_at' => '', 'seo_title' => 'Foundations Course', 'seo_description' => 'A guided Academy course for Skinbase creators.', 'meta_keywords' => 'academy, ai art, workflow', 'og_title' => 'Foundations Course', 'og_description' => 'Learn the fundamentals of AI-assisted digital art.', 'og_image' => 'academy/lessons/covers/ee/ff/course-og.webp', ]); $course = AcademyCourse::query()->where('slug', 'foundations-course')->firstOrFail(); $response->assertRedirect(route('admin.academy.courses.edit', ['academyCourse' => $course])); $this->assertSame('academy/lessons/covers/aa/bb/course-cover.webp', $course->cover_image); $this->assertSame('academy/lessons/covers/cc/dd/course-teaser.webp', $course->teaser_image); $this->assertStringContainsString('

What you will learn

', (string) $course->description); $this->assertTrue((bool) $course->is_featured); $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' => '

Body

', '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']); $category = AcademyCategory::query()->create([ 'type' => 'lesson', 'name' => 'Prompting Basics', 'slug' => 'prompting-basics', 'order_num' => 10, 'active' => true, ]); Cache::put('academy.home', ['stale' => true], 600); Cache::put('academy.categories.lesson', ['stale' => true], 600); $this->actingAs($admin) ->patch(route('admin.academy.categories.update', ['academyCategory' => $category]), [ 'type' => 'lesson', 'name' => 'Prompting Basics Updated', 'slug' => 'prompting-basics', 'description' => 'Updated description', 'icon' => 'fa-wand-magic-sparkles', 'order_num' => 11, 'active' => true, ]) ->assertRedirect(route('admin.academy.categories.edit', ['academyCategory' => $category])); $this->assertNull(Cache::get('academy.home')); $this->assertNull(Cache::get('academy.categories.lesson')); } public function test_admin_can_create_a_lesson_with_ai_comparison_block(): void { $admin = User::factory()->create(['role' => 'admin']); $response = $this->actingAs($admin) ->post(route('admin.academy.lessons.store'), [ 'title' => 'AI Comparison Lesson', 'slug' => 'ai-comparison-lesson', 'excerpt' => 'Testing comparison block creation.', 'content' => '

Lesson body.

', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'cover_image' => '', 'article_cover_image' => 'academy/lessons/covers/article-cover.webp', 'tags' => ['ai comparison', 'models'], 'video_url' => '', 'reading_minutes' => 5, 'featured' => false, 'active' => true, 'published_at' => now()->subMinute()->toDateTimeString(), 'seo_title' => '', 'seo_description' => '', 'blocks' => [ [ 'type' => 'ai_comparison', 'title' => 'Same Prompt, Different AI Models', 'payload' => [ 'title' => 'Same Prompt, Different AI Models', 'intro' => 'Compare multiple tools.', 'prompt' => 'A peaceful fantasy forest wallpaper.', 'negative_prompt' => 'text, watermark', 'aspect_ratio' => '16:9', 'criteria' => ['Composition', 'Lighting'], ], 'sort_order' => 0, 'active' => true, 'comparison_results' => [], ], ], ]); $lesson = AcademyLesson::query()->where('slug', 'ai-comparison-lesson')->firstOrFail(); $response->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson])); $this->assertDatabaseHas('academy_lesson_blocks', [ 'lesson_id' => $lesson->id, 'type' => 'ai_comparison', 'active' => true, ]); } public function test_admin_can_create_a_lesson_from_markdown(): void { $admin = User::factory()->create(['role' => 'admin']); $markdown = <<<'MD' # Cleaner scene direction Use **specific nouns** and keep the camera angle stable. - State the subject - Add lighting MD; $response = $this->actingAs($admin) ->post(route('admin.academy.lessons.store'), [ 'title' => 'Markdown Lesson', 'slug' => 'markdown-lesson', 'excerpt' => 'Testing markdown lesson creation.', 'content' => '', 'content_markdown' => $markdown, 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'cover_image' => '', 'video_url' => '', 'reading_minutes' => 6, 'featured' => false, 'active' => true, 'published_at' => now()->subMinute()->toDateTimeString(), 'seo_title' => '', 'seo_description' => '', 'blocks' => [], ]); $lesson = AcademyLesson::query()->where('slug', 'markdown-lesson')->firstOrFail(); $response->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson])); $this->assertSame($markdown, $lesson->content_markdown); $this->assertStringContainsString('

Cleaner scene direction

', (string) $lesson->content); $this->assertStringContainsString('specific nouns', (string) $lesson->content); $this->assertStringContainsString('
  • State the subject
  • ', (string) $lesson->content); } 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'), [ 'title' => 'Ordered Lesson', 'slug' => 'ordered-lesson', 'lesson_number' => 3, 'course_order' => 3, 'series_name' => 'AI Art Basics', 'excerpt' => 'Testing ordering field persistence.', 'content' => '

    Lesson body.

    ', 'tags' => [$longTag, 'academy'], 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'cover_image' => '', 'video_url' => '', 'reading_minutes' => 5, 'featured' => false, 'active' => true, 'published_at' => now()->subMinute()->toDateTimeString(), 'seo_title' => '', 'seo_description' => '', 'blocks' => [], ]); $lesson = AcademyLesson::query()->where('slug', 'ordered-lesson')->firstOrFail(); $response->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson])); $this->assertDatabaseHas('academy_lessons', [ 'id' => $lesson->id, 'lesson_number' => 3, 'course_order' => 3, 'series_name' => 'AI Art Basics', ]); $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 = '

    '.implode(' ', array_fill(0, 420, 'prompt')).'

    '; $response = $this->actingAs($admin) ->post(route('admin.academy.lessons.store'), [ 'title' => 'Auto Reading Lesson', 'slug' => 'auto-reading-lesson', 'excerpt' => 'Estimate reading time from content.', 'content' => $body, 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'cover_image' => '', 'video_url' => '', 'reading_minutes' => 1, 'featured' => false, 'active' => true, 'published_at' => now()->subMinute()->toDateTimeString(), 'seo_title' => '', 'seo_description' => '', 'blocks' => [], ]); $lesson = AcademyLesson::query()->where('slug', 'auto-reading-lesson')->firstOrFail(); $response->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson])); $this->assertSame(3, (int) $lesson->reading_minutes); } public function test_admin_can_attach_lesson_to_courses_from_lesson_form(): void { $admin = User::factory()->create(['role' => 'admin']); $courseA = AcademyCourse::query()->create([ 'title' => 'Foundations', 'slug' => 'foundations', 'access_level' => 'free', 'difficulty' => 'beginner', 'status' => 'draft', ]); $courseB = AcademyCourse::query()->create([ 'title' => 'Prompt Engineering', 'slug' => 'prompt-engineering', 'access_level' => 'free', 'difficulty' => 'beginner', 'status' => 'draft', ]); $response = $this->actingAs($admin) ->post(route('admin.academy.lessons.store'), [ 'title' => 'Course Attached Lesson', 'slug' => 'course-attached-lesson', 'excerpt' => 'Attach this lesson to multiple courses.', 'content' => '

    Lesson body.

    ', 'course_ids' => [$courseA->id, $courseB->id], 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'cover_image' => '', 'video_url' => '', 'reading_minutes' => 5, 'featured' => false, 'active' => true, 'published_at' => now()->subMinute()->toDateTimeString(), 'seo_title' => '', 'seo_description' => '', 'blocks' => [], ]); $lesson = AcademyLesson::query()->where('slug', 'course-attached-lesson')->firstOrFail(); $response->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson])); $this->assertSame(2, AcademyCourseLesson::query()->where('lesson_id', $lesson->id)->count()); $this->assertDatabaseHas('academy_course_lessons', ['course_id' => $courseA->id, 'lesson_id' => $lesson->id]); $this->assertDatabaseHas('academy_course_lessons', ['course_id' => $courseB->id, 'lesson_id' => $lesson->id]); $this->assertSame(1, $courseA->fresh()->lessons_count_cache); $this->assertSame(1, $courseB->fresh()->lessons_count_cache); } public function test_admin_lesson_edit_form_includes_numbering_and_course_outline_context(): void { $admin = User::factory()->create(['role' => 'admin']); $course = AcademyCourse::query()->create([ 'title' => 'Lesson Mapping Course', 'slug' => 'lesson-mapping-course', 'access_level' => 'free', 'difficulty' => 'beginner', 'status' => 'draft', ]); AcademyLesson::query()->create([ 'title' => 'Lesson One', 'slug' => 'lesson-one', 'lesson_number' => 1, 'course_order' => 1, 'content' => '

    Body

    ', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'reading_minutes' => 5, 'active' => true, 'published_at' => now()->subMinute(), ]); $currentLesson = AcademyLesson::query()->create([ 'title' => 'Current Lesson', 'slug' => 'current-lesson', 'content' => '

    Body

    ', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'reading_minutes' => 5, 'active' => true, 'published_at' => now()->subMinute(), ]); $otherCourseLesson = AcademyLesson::query()->create([ 'title' => 'Lesson Three', 'slug' => 'lesson-three', 'lesson_number' => 3, 'course_order' => 3, 'content' => '

    Body

    ', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'reading_minutes' => 5, 'active' => true, 'published_at' => now()->subMinute(), ]); AcademyCourseLesson::query()->create([ 'course_id' => $course->id, 'lesson_id' => $otherCourseLesson->id, 'order_num' => 0, 'is_required' => true, ]); AcademyCourseLesson::query()->create([ 'course_id' => $course->id, 'lesson_id' => $currentLesson->id, 'order_num' => 1, 'is_required' => true, ]); $this->actingAs($admin) ->get(route('admin.academy.lessons.edit', ['academyLesson' => $currentLesson])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Admin/Academy/CrudForm') ->where('resource', 'lessons') ->where('record.course_ids.0', (string) $course->id) ->where('editorContext.currentLessonId', $currentLesson->id) ->where('editorContext.numbering.lesson_number.suggested', 2) ->where('editorContext.numbering.lesson_number.missing.0', 2) ->where('editorContext.numbering.course_order.suggested', 2) ->where('editorContext.courses.0.lesson_count', 2) ->where('editorContext.courses.0.attach_url', route('admin.academy.courses.lessons.attach', ['academyCourse' => $course])) ->where('editorContext.courses.0.reorder_url', route('admin.academy.courses.reorder', ['academyCourse' => $course])) ->where('editorContext.courses.0.lessons.0.order_num', 0) ->where('editorContext.courses.0.lessons.1.is_current', true) ->where('editorContext.courses.0.lessons.1.destroy_url', fn ($value) => is_string($value) && str_contains($value, "/moderation/academy/courses/{$course->id}/lessons/"))); } public function test_admin_can_store_article_cover_image(): void { $admin = User::factory()->create(['role' => 'admin']); $response = $this->actingAs($admin) ->post(route('admin.academy.lessons.store'), [ 'title' => 'Lesson With Article Cover', 'slug' => 'lesson-with-article-cover', 'excerpt' => 'Testing article cover persistence.', 'content' => '

    Lesson body.

    ', 'tags' => ['cover', 'article'], 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'cover_image' => 'academy/lessons/covers/hero-cover.webp', 'article_cover_image' => 'academy/lessons/covers/article-cover.webp', 'video_url' => '', 'reading_minutes' => 5, 'featured' => false, 'active' => true, 'published_at' => now()->subMinute()->toDateTimeString(), 'seo_title' => '', 'seo_description' => '', 'blocks' => [], ]); $lesson = AcademyLesson::query()->where('slug', 'lesson-with-article-cover')->firstOrFail(); $response->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson])); $this->assertDatabaseHas('academy_lessons', [ 'id' => $lesson->id, 'article_cover_image' => 'academy/lessons/covers/article-cover.webp', ]); } 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']); $lesson = AcademyLesson::query()->create([ 'title' => 'Existing Lesson', 'slug' => 'existing-lesson', 'content' => 'Body', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subMinute(), ]); $this->actingAs($admin) ->patch(route('admin.academy.lessons.update', ['academyLesson' => $lesson]), [ 'title' => $lesson->title, 'slug' => $lesson->slug, 'excerpt' => '', 'content' => $lesson->content, 'difficulty' => $lesson->difficulty, 'access_level' => $lesson->access_level, 'lesson_type' => $lesson->lesson_type, 'cover_image' => '', 'video_url' => '', 'reading_minutes' => 5, 'featured' => false, 'active' => true, 'published_at' => now()->subMinute()->toDateTimeString(), 'seo_title' => '', 'seo_description' => '', 'blocks' => [ [ 'type' => 'ai_comparison', 'title' => 'Same Prompt, Different AI Models', 'payload' => [ 'title' => 'Same Prompt, Different AI Models', 'intro' => 'Compare multiple tools.', 'prompt' => 'A peaceful fantasy forest wallpaper.', 'negative_prompt' => '', 'aspect_ratio' => '16:9', 'criteria' => ['Composition'], ], 'sort_order' => 0, 'active' => true, 'comparison_results' => [ [ 'provider' => 'OpenAI', 'model_name' => 'ChatGPT Images', 'image_path' => 'academy/lessons/body/aa/bb/example.webp', 'thumb_path' => 'academy/lessons/body/aa/bb/example-thumb.webp', 'settings' => 'Default quality', 'strengths' => 'Strong composition', 'weaknesses' => 'Slightly over-polished', 'best_for' => 'Wallpaper concepts', 'score' => 9, 'sort_order' => 0, 'active' => true, ], ], ], ], ]) ->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson])); $block = AcademyLessonBlock::query()->where('lesson_id', $lesson->id)->firstOrFail(); $this->assertDatabaseHas('academy_ai_comparison_results', [ 'lesson_block_id' => $block->id, 'provider' => 'OpenAI', 'model_name' => 'ChatGPT Images', 'score' => 9, ]); } public function test_admin_markdown_update_regenerates_lesson_html(): void { $admin = User::factory()->create(['role' => 'admin']); $lesson = AcademyLesson::query()->create([ 'title' => 'Markdown Update Lesson', 'slug' => 'markdown-update-lesson', 'content' => '

    Old body

    ', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subMinute(), ]); $markdown = <<<'MD' ## Prompt checklist 1. Start with the scene. 2. Add the style. > Keep one clear subject. MD; $this->actingAs($admin) ->patch(route('admin.academy.lessons.update', ['academyLesson' => $lesson]), [ 'title' => $lesson->title, 'slug' => $lesson->slug, 'excerpt' => '', 'content' => '', 'content_markdown' => $markdown, 'difficulty' => $lesson->difficulty, 'access_level' => $lesson->access_level, 'lesson_type' => $lesson->lesson_type, 'cover_image' => '', 'video_url' => '', 'reading_minutes' => 5, 'featured' => false, 'active' => true, 'published_at' => now()->subMinute()->toDateTimeString(), 'seo_title' => '', 'seo_description' => '', 'blocks' => [], ]) ->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson])); $lesson->refresh(); $this->assertSame($markdown, $lesson->content_markdown); $this->assertStringContainsString('

    Prompt checklist

    ', (string) $lesson->content); $this->assertStringContainsString('
      ', (string) $lesson->content); $this->assertStringContainsString('
      ', (string) $lesson->content); } public function test_admin_html_lesson_update_does_not_rewrite_legacy_html_from_generated_markdown(): void { $admin = User::factory()->create(['role' => 'admin']); $legacyHtml = '

      Final note

      Prompting is a creative skill.

      '; $lesson = AcademyLesson::query()->create([ 'title' => 'Legacy HTML Lesson', 'slug' => 'legacy-html-lesson', 'excerpt' => 'Legacy HTML body.', 'content' => $legacyHtml, 'content_markdown' => null, 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subMinute(), ]); $this->actingAs($admin) ->patch(route('admin.academy.lessons.update', ['academyLesson' => $lesson]), [ 'title' => $lesson->title, 'slug' => $lesson->slug, 'excerpt' => 'Updated excerpt only.', 'content' => $legacyHtml, 'content_markdown' => '', 'content_source' => 'html', 'difficulty' => $lesson->difficulty, 'access_level' => $lesson->access_level, 'lesson_type' => $lesson->lesson_type, 'cover_image' => '', 'video_url' => '', 'reading_minutes' => 5, 'featured' => false, 'active' => true, 'published_at' => now()->subMinute()->toDateTimeString(), 'seo_title' => '', 'seo_description' => '', 'blocks' => [], ]) ->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson])); $lesson->refresh(); $this->assertSame('Updated excerpt only.', $lesson->excerpt); $this->assertSame($legacyHtml, $lesson->content); $this->assertNull($lesson->content_markdown); } public function test_ai_comparison_score_must_stay_in_range(): void { $admin = User::factory()->create(['role' => 'admin']); $lesson = AcademyLesson::query()->create([ 'title' => 'Validation Lesson', 'slug' => 'validation-lesson', 'content' => 'Body', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subMinute(), ]); $this->from(route('admin.academy.lessons.edit', ['academyLesson' => $lesson])) ->actingAs($admin) ->patch(route('admin.academy.lessons.update', ['academyLesson' => $lesson]), [ 'title' => $lesson->title, 'slug' => $lesson->slug, 'excerpt' => '', 'content' => $lesson->content, 'difficulty' => $lesson->difficulty, 'access_level' => $lesson->access_level, 'lesson_type' => $lesson->lesson_type, 'cover_image' => '', 'video_url' => '', 'reading_minutes' => 5, 'featured' => false, 'active' => true, 'published_at' => now()->subMinute()->toDateTimeString(), 'seo_title' => '', 'seo_description' => '', 'blocks' => [ [ 'type' => 'ai_comparison', 'title' => 'Invalid score block', 'payload' => [ 'title' => 'Invalid score block', 'prompt' => 'Prompt', 'criteria' => ['Composition'], ], 'sort_order' => 0, 'active' => true, 'comparison_results' => [ [ 'provider' => 'OpenAI', 'model_name' => 'ChatGPT Images', 'image_path' => 'academy/lessons/body/aa/bb/example.webp', 'score' => 11, 'sort_order' => 0, 'active' => true, ], ], ], ], ]) ->assertRedirect(route('admin.academy.lessons.edit', ['academyLesson' => $lesson])) ->assertSessionHasErrors(['blocks.0.comparison_results.0.score']); } public function test_lesson_delete_soft_deletes_ai_comparison_children(): void { $admin = User::factory()->create(['role' => 'admin']); $lesson = AcademyLesson::query()->create([ 'title' => 'Delete Lesson', 'slug' => 'delete-lesson', 'content' => 'Body', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subMinute(), ]); $block = AcademyLessonBlock::query()->create([ 'lesson_id' => $lesson->id, 'type' => 'ai_comparison', 'title' => 'Delete Block', 'payload' => ['title' => 'Delete Block', 'prompt' => 'Prompt'], 'sort_order' => 0, 'active' => true, ]); $result = AcademyAiComparisonResult::query()->create([ 'lesson_block_id' => $block->id, 'provider' => 'OpenAI', 'model_name' => 'ChatGPT Images', 'image_path' => 'academy/lessons/body/aa/bb/example.webp', 'score' => 8, 'sort_order' => 0, 'active' => true, ]); $this->actingAs($admin) ->delete(route('admin.academy.lessons.destroy', ['academyLesson' => $lesson])) ->assertRedirect(route('admin.academy.lessons.index')); $this->assertSoftDeleted('academy_lessons', ['id' => $lesson->id]); $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(), ]); } }