withoutMiddleware(ConditionalValidateCsrfToken::class); } public function test_academy_homepage_loads_when_enabled(): void { $this->get('/academy') ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/Index') ->where('links.promptPopular', route('academy.prompts.popular')) ->where('academyAccess.signedIn', false) ->where('academyAccess.status', 'guest') ->where('academyAccess.billingUrl', route('academy.pricing'))); } public function test_academy_homepage_exposes_access_summary_for_active_paid_user(): void { config()->set('academy_billing.plans', [ 'pro_monthly' => [ 'tier' => 'pro', 'stripe_price_id' => 'price_pro_test', ], ]); $user = User::factory()->create(); $subscription = Subscription::query()->create([ 'user_id' => $user->id, 'type' => 'academy', 'stripe_id' => 'sub_pro_test', 'stripe_status' => 'active', 'stripe_price' => 'price_pro_test', 'quantity' => 1, 'ends_at' => null, ]); SubscriptionItem::query()->create([ 'subscription_id' => $subscription->id, 'stripe_id' => 'si_pro_test', 'stripe_product' => 'prod_pro_test', 'stripe_price' => 'price_pro_test', 'quantity' => 1, ]); $this->actingAs($user) ->get('/academy') ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/Index') ->where('academyAccess.signedIn', true) ->where('academyAccess.tier', 'pro') ->where('academyAccess.tierLabel', 'Pro') ->where('academyAccess.status', 'active') ->where('academyAccess.statusLabel', 'Renews automatically') ->where('academyAccess.renewsAutomatically', true) ->where('academyAccess.billingUrl', route('academy.billing.account')) ->where('academyAccess.source', 'subscription')); } public function test_academy_routes_are_hidden_when_feature_is_disabled(): void { config(['academy.enabled' => false]); $this->get('/academy')->assertNotFound(); $this->get('/academy/lessons')->assertNotFound(); $this->get('/academy/courses')->assertNotFound(); } public function test_published_course_index_and_show_render(): void { $course = AcademyCourse::query()->create([ 'title' => 'AI Foundations', 'slug' => 'ai-foundations', 'excerpt' => 'A guided course.', 'description' => 'Course description', 'cover_image' => 'academy/lessons/covers/course-cover.webp', 'teaser_image' => 'academy/lessons/covers/course-teaser.webp', 'access_level' => 'free', 'difficulty' => 'beginner', 'status' => 'published', 'is_featured' => true, 'published_at' => now()->subMinute(), ]); $section = AcademyCourseSection::query()->create([ 'course_id' => $course->id, 'title' => 'Getting started', 'slug' => 'getting-started', 'order_num' => 0, 'is_visible' => true, ]); $lesson = AcademyLesson::query()->create([ 'title' => 'Course Lesson', 'slug' => 'course-lesson', 'excerpt' => 'Lesson in a course.', 'content' => 'Course lesson content', 'cover_image' => 'academy/lessons/covers/lesson-cover.webp', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subMinute(), ]); AcademyCourseLesson::query()->create([ 'course_id' => $course->id, 'section_id' => $section->id, 'lesson_id' => $lesson->id, 'order_num' => 0, 'is_required' => true, ]); $this->get(route('academy.courses.index')) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/CoursesIndex') ->where('items.data.0.slug', 'ai-foundations') ->where('seo.json_ld.0.@type', 'CollectionPage') ->where('seo.json_ld.0.mainEntity.@type', 'ItemList') ->where('seo.json_ld.0.mainEntity.itemListElement.0.item.@type', 'Course') ->where('seo.json_ld.0.mainEntity.itemListElement.0.item.name', 'AI Foundations') ->where('seo.json_ld.1.@type', 'BreadcrumbList')); $this->get(route('academy.courses.show', ['course' => $course->slug])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/CoursesShow') ->where('course.slug', 'ai-foundations') ->where('course.cover_image', 'academy/lessons/covers/course-cover.webp') ->where('course.teaser_image', 'academy/lessons/covers/course-teaser.webp') ->where('seo.json_ld.0.@type', 'ImageObject') ->where('seo.json_ld.0.license', route('terms-of-service')) ->where('seo.json_ld.1.@type', 'Course') ->where('seo.json_ld.1.name', 'AI Foundations — Skinbase Academy') ->where('seo.json_ld.1.isAccessibleForFree', true) ->where('seo.json_ld.1.educationalLevel', 'Beginner') ->where('seo.json_ld.1.hasCourseInstance.0.name', 'Course Lesson') ->where('seo.json_ld.2.@type', 'BreadcrumbList') ->where('seo.json_ld.2.itemListElement.1.name', 'Academy') ->where('seo.json_ld.2.itemListElement.2.name', 'Courses') ->where('sections.0.slug', 'getting-started') ->where('sections.0.lessons.0.slug', 'course-lesson') ->where('sections.0.lessons.0.order_num', 0) ->where('sections.0.lessons.0.cover_image', 'academy/lessons/covers/lesson-cover.webp')); } public function test_course_start_redirects_to_first_lesson_and_creates_enrollment(): void { $user = User::factory()->create(); $course = AcademyCourse::query()->create([ 'title' => 'Workflow Course', 'slug' => 'workflow-course', 'excerpt' => 'A workflow course.', 'access_level' => 'free', 'difficulty' => 'beginner', 'status' => 'published', 'published_at' => now()->subMinute(), ]); $lesson = AcademyLesson::query()->create([ 'title' => 'First Workflow Lesson', 'slug' => 'first-workflow-lesson', 'content' => 'Start here', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subMinute(), ]); AcademyCourseLesson::query()->create([ 'course_id' => $course->id, 'lesson_id' => $lesson->id, 'order_num' => 0, 'is_required' => true, ]); $this->actingAs($user) ->post(route('academy.courses.start', ['course' => $course->slug])) ->assertRedirect(route('academy.courses.lessons.show', ['course' => $course->slug, 'lesson' => $lesson->slug])); $this->assertDatabaseHas('academy_course_enrollments', [ 'user_id' => $user->id, 'course_id' => $course->id, 'status' => AcademyCourseEnrollment::STATUS_ACTIVE, ]); $this->actingAs($user) ->get(route('academy.courses.lessons.show', ['course' => $course->slug, 'lesson' => $lesson->slug])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/Show') ->where('courseContext.slug', 'workflow-course') ->where('courseContext.completePayload.course_id', $course->id)); } public function test_course_show_exposes_consistent_global_step_numbers_for_outline(): void { $course = AcademyCourse::query()->create([ 'title' => 'Ordered Course', 'slug' => 'ordered-course', 'excerpt' => 'Check course step order.', 'access_level' => 'free', 'difficulty' => 'beginner', 'status' => 'published', 'published_at' => now()->subMinute(), ]); $section = AcademyCourseSection::query()->create([ 'course_id' => $course->id, 'title' => 'Module One', 'slug' => 'module-one', 'order_num' => 0, 'is_visible' => true, ]); $introLesson = AcademyLesson::query()->create([ 'title' => 'Intro Lesson', 'slug' => 'intro-lesson', 'content' => 'Intro content', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'lesson_number' => 3, 'active' => true, 'published_at' => now()->subMinute(), ]); $sectionLesson = AcademyLesson::query()->create([ 'title' => 'Section Lesson', 'slug' => 'section-lesson', 'content' => 'Section content', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'lesson_number' => 1, 'active' => true, 'published_at' => now()->subMinute(), ]); AcademyCourseLesson::query()->create([ 'course_id' => $course->id, 'lesson_id' => $introLesson->id, 'order_num' => 0, 'is_required' => true, ]); AcademyCourseLesson::query()->create([ 'course_id' => $course->id, 'section_id' => $section->id, 'lesson_id' => $sectionLesson->id, 'order_num' => 0, 'is_required' => true, ]); $this->get(route('academy.courses.show', ['course' => $course->slug])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/CoursesShow') ->where('unsectionedLessons.0.title', 'Intro Lesson') ->where('unsectionedLessons.0.course_step_number', 1) ->where('unsectionedLessons.0.course_step_label', 'Step 01') ->where('sections.0.lessons.0.title', 'Section Lesson') ->where('sections.0.lessons.0.course_step_number', 2) ->where('sections.0.lessons.0.course_step_label', 'Step 02') ->where('unsectionedLessons.0.formatted_lesson_number', 'Lesson 03') ->where('sections.0.lessons.0.formatted_lesson_number', 'Lesson 01')); } public function test_course_show_marks_completed_lessons_for_authenticated_user(): void { $user = User::factory()->create(); $course = AcademyCourse::query()->create([ 'title' => 'Guided Course', 'slug' => 'guided-course', 'excerpt' => 'Track outline completion.', 'access_level' => 'free', 'difficulty' => 'beginner', 'status' => 'published', 'published_at' => now()->subMinute(), ]); $section = AcademyCourseSection::query()->create([ 'course_id' => $course->id, 'title' => 'Module One', 'slug' => 'module-one', 'order_num' => 0, 'is_visible' => true, ]); $lesson = AcademyLesson::query()->create([ 'title' => 'Completed Lesson', 'slug' => 'completed-lesson', 'content' => 'Lesson content', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subMinute(), ]); AcademyCourseLesson::query()->create([ 'course_id' => $course->id, 'section_id' => $section->id, 'lesson_id' => $lesson->id, 'order_num' => 0, 'is_required' => true, ]); AcademyLessonProgress::query()->create([ 'user_id' => $user->id, 'lesson_id' => $lesson->id, 'completed_at' => now(), ]); $this->actingAs($user) ->get(route('academy.courses.show', ['course' => $course->slug])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/CoursesShow') ->where('course.progress.completedRequired', 1) ->where('sections.0.lessons.0.completed', true)); } public function test_standalone_lesson_show_exposes_related_courses(): void { $lesson = AcademyLesson::query()->create([ 'title' => 'Shared Lesson', 'slug' => 'shared-lesson', 'content' => 'Shared content', 'article_cover_image' => 'academy/lessons/covers/shared-lesson.webp', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subMinute(), ]); $course = AcademyCourse::query()->create([ 'title' => 'Related Course', 'slug' => 'related-course', 'excerpt' => 'Course tied to this lesson.', 'access_level' => 'free', 'difficulty' => 'beginner', 'status' => 'published', 'published_at' => now()->subMinute(), ]); AcademyCourseLesson::query()->create([ 'course_id' => $course->id, 'lesson_id' => $lesson->id, 'order_num' => 0, 'is_required' => true, ]); $this->get(route('academy.lessons.show', ['slug' => $lesson->slug])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/Show') ->where('seo.json_ld.0.@type', 'ImageObject') ->where('seo.json_ld.0.creditText', 'Skinbase') ->where('seo.json_ld.1.@type', 'Article') ->where('seo.json_ld.1.headline', 'Shared Lesson — Skinbase Academy') ->where('seo.json_ld.1.articleSection', 'Academy') ->where('seo.json_ld.2.@type', 'BreadcrumbList') ->where('relatedCourses.0.slug', 'related-course')); } public function test_course_lesson_show_exposes_article_breadcrumb_and_image_license_schema(): void { $course = AcademyCourse::query()->create([ 'title' => 'AI Art Basics', 'slug' => 'ai-art-basics', 'excerpt' => 'Course excerpt', 'access_level' => 'free', 'difficulty' => 'beginner', 'status' => 'published', 'published_at' => now()->subMinute(), ]); $section = AcademyCourseSection::query()->create([ 'course_id' => $course->id, 'title' => 'Foundations', 'slug' => 'foundations', 'order_num' => 0, 'is_visible' => true, ]); $lesson = AcademyLesson::query()->create([ 'title' => 'What Is AI-Assisted Digital Art', 'slug' => 'what-is-ai-assisted-digital-art', 'excerpt' => 'Structured data test lesson.', 'content' => 'Lesson body', 'article_cover_image' => 'academy/lessons/covers/ai-assist.webp', 'tags' => ['ai-art', 'workflow'], 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subMinute(), ]); AcademyCourseLesson::query()->create([ 'course_id' => $course->id, 'section_id' => $section->id, 'lesson_id' => $lesson->id, 'order_num' => 0, 'is_required' => true, ]); $this->get(route('academy.courses.lessons.show', ['course' => $course->slug, 'lesson' => $lesson->slug])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/Show') ->where('seo.json_ld.0.@type', 'ImageObject') ->where('seo.json_ld.0.license', route('terms-of-service')) ->where('seo.json_ld.0.creditText', 'Skinbase') ->where('seo.json_ld.1.@type', 'Article') ->where('seo.json_ld.1.headline', 'What Is AI-Assisted Digital Art — AI Art Basics') ->where('seo.json_ld.1.articleSection', 'AI Art Basics') ->where('seo.json_ld.1.keywords.0', 'ai-art') ->where('seo.json_ld.2.@type', 'BreadcrumbList') ->where('seo.json_ld.2.itemListElement.3.name', 'AI Art Basics') ->where('seo.json_ld.2.itemListElement.4.name', 'What Is AI-Assisted Digital Art')); } public function test_free_lesson_is_visible_to_guest(): void { $lesson = AcademyLesson::query()->create([ 'title' => 'Free Lesson', 'slug' => 'free-lesson', 'excerpt' => 'Visible to guests.', 'content' => 'Free lesson content', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'article_cover_image' => 'academy/lessons/covers/article-cover.webp', 'tags' => ['workflow', 'publishing'], 'active' => true, 'published_at' => now()->subMinute(), ]); $this->get(route('academy.lessons.show', ['slug' => $lesson->slug])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/Show') ->where('item.locked', false) ->where('item.content', 'Free lesson content') ->where('item.article_cover_image', 'academy/lessons/covers/article-cover.webp') ->where('item.tags.0', 'workflow') ->where('item.tags.1', 'publishing') ->where('item.article_cover_image_url', fn (?string $value) => is_string($value) && str_contains($value, 'academy/lessons/covers/article-cover.webp'))); } public function test_creator_lesson_is_locked_for_regular_user(): void { $lesson = AcademyLesson::query()->create([ 'title' => 'Creator Lesson', 'slug' => 'creator-lesson', 'excerpt' => 'Preview only', 'content' => 'Creator only lesson content', 'difficulty' => 'intermediate', 'access_level' => 'creator', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subMinute(), ]); $user = User::factory()->create(); $this->actingAs($user) ->get(route('academy.lessons.show', ['slug' => $lesson->slug])) ->assertOk() ->assertDontSee('Creator only lesson content') ->assertInertia(fn (AssertableInertia $page) => $page ->where('item.locked', true) ->where('item.content', null)); } public function test_pro_lesson_is_locked_for_creator_user(): void { $lesson = AcademyLesson::query()->create([ 'title' => 'Pro Lesson', 'slug' => 'pro-lesson', 'excerpt' => 'Pro preview', 'content' => 'Pro only lesson content', 'difficulty' => 'pro', 'access_level' => 'pro', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subMinute(), ]); $creator = User::factory()->create(['role' => 'academy_creator']); $this->actingAs($creator) ->get(route('academy.lessons.show', ['slug' => $lesson->slug])) ->assertOk() ->assertDontSee('Pro only lesson content') ->assertInertia(fn (AssertableInertia $page) => $page ->where('item.locked', true) ->where('item.content', null)); } public function test_premium_prompt_full_text_is_not_exposed_to_unauthorized_users(): void { $prompt = AcademyPromptTemplate::query()->create([ 'title' => 'Creator Prompt', 'slug' => 'creator-prompt', '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(), ]); $this->get(route('academy.prompts.show', ['slug' => $prompt->slug])) ->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.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')); $this->withHeaders([ 'X-Inertia' => 'true', 'X-Requested-With' => 'XMLHttpRequest', 'X-Inertia-Version' => $version, ])->get(route('academy.prompts.show', ['slug' => $prompt->slug])) ->assertOk() ->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 SETTINGS STRING'); } public function test_authorized_user_can_view_premium_prompt(): void { $prompt = AcademyPromptTemplate::query()->create([ 'title' => 'Creator Prompt', 'slug' => 'creator-prompt-allowed', 'excerpt' => 'Full prompt visible.', 'prompt' => 'VISIBLE PREMIUM PROMPT', 'negative_prompt' => 'VISIBLE NEGATIVE PROMPT', 'tool_notes' => [[ 'provider' => 'ChatGPT', 'model_name' => '4o Image', 'image_path' => 'academy/lessons/body/cc/dd/chatgpt-comparison.webp', 'settings' => 'ChatGPT image generation in 16:9 with cinematic mode.', 'best_for' => 'Fast ideation and art direction.', 'score' => 8, 'active' => true, ]], '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() ->assertSee('VISIBLE PREMIUM PROMPT') ->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('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 { $lesson = AcademyLesson::query()->create([ 'title' => 'Free Lesson With Comparison', 'slug' => 'free-lesson-with-comparison', 'excerpt' => 'Visible to guests.', 'content' => 'Free lesson content', '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' => 'Same Prompt, Different AI Models', 'payload' => [ 'title' => 'Same Prompt, Different AI Models', 'intro' => 'We used the same prompt in multiple tools.', 'prompt' => 'A peaceful fantasy forest wallpaper.', 'negative_prompt' => 'text, watermark', 'aspect_ratio' => '16:9', 'criteria' => ['Composition', 'Lighting'], ], 'sort_order' => 0, 'active' => true, ]); AcademyAiComparisonResult::query()->create([ 'lesson_block_id' => $block->id, 'provider' => 'OpenAI', 'model_name' => 'ChatGPT Images', 'image_path' => 'academy/lessons/body/aa/bb/example.webp', 'strengths' => 'Strong composition', 'score' => 9, 'sort_order' => 0, 'active' => true, ]); AcademyAiComparisonResult::query()->create([ 'lesson_block_id' => $block->id, 'provider' => 'Google', 'model_name' => 'Gemini', 'image_path' => 'academy/lessons/body/aa/bb/example-2.webp', 'score' => 7, 'sort_order' => 1, 'active' => false, ]); $this->get(route('academy.lessons.show', ['slug' => $lesson->slug])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/Show') ->where('item.blocks.0.payload.prompt', 'A peaceful fantasy forest wallpaper.') ->has('item.blocks.0.comparison_results', 1) ->where('item.blocks.0.comparison_results.0.model_name', 'ChatGPT Images')); } public function test_public_lesson_with_sparse_ai_comparison_block_still_renders_payload(): void { $lesson = AcademyLesson::query()->create([ 'title' => 'Sparse Comparison Lesson', 'slug' => 'sparse-comparison-lesson', 'excerpt' => 'Sparse block test.', 'content' => 'Free lesson content', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subMinute(), ]); AcademyLessonBlock::query()->create([ 'lesson_id' => $lesson->id, 'type' => 'ai_comparison', 'title' => 'Prompt only block', 'payload' => [ 'title' => 'Prompt only block', 'prompt' => 'A fantasy forest at sunrise.', ], 'sort_order' => 0, 'active' => true, ]); $this->get(route('academy.lessons.show', ['slug' => $lesson->slug])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->has('item.blocks', 1) ->where('item.blocks.0.payload.prompt', 'A fantasy forest at sunrise.') ->has('item.blocks.0.comparison_results', 0)); } public function test_lessons_index_sorts_by_course_order_and_exposes_lesson_labels(): void { AcademyLesson::query()->create([ 'title' => 'Later Lesson', 'slug' => 'later-lesson', 'lesson_number' => 2, 'course_order' => 2, 'series_name' => 'AI Art Basics', 'content' => 'Later content', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subDays(3), ]); AcademyLesson::query()->create([ 'title' => 'First Lesson', 'slug' => 'first-lesson', 'lesson_number' => 1, 'course_order' => 1, 'series_name' => 'AI Art Basics', 'content' => 'First content', 'difficulty' => 'beginner', 'access_level' => 'free', 'article_cover_image' => 'academy/lessons/covers/shared-lesson.webp', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subMinute(), ]); $this->get(route('academy.lessons.index')) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/List') ->where('items.data.0.slug', 'first-lesson') ->where('items.data.0.formatted_lesson_number', 'Lesson 01') ->where('items.data.0.lesson_label', 'AI Art Basics · Lesson 01') ->where('items.data.1.slug', 'later-lesson')); } public function test_lesson_show_exposes_ordered_previous_and_next_navigation_within_series(): void { $publishMoment = Carbon::parse('2026-01-01 10:00:00'); $previous = AcademyLesson::query()->create([ 'title' => 'Lesson One', 'slug' => 'lesson-one', 'lesson_number' => 1, 'course_order' => 1, 'series_name' => 'AI Art Basics', 'content' => 'Lesson one body', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => $publishMoment->copy(), ]); $current = AcademyLesson::query()->create([ 'title' => 'Lesson Two', 'slug' => 'lesson-two', 'lesson_number' => 2, 'course_order' => 2, 'series_name' => 'AI Art Basics', 'content' => 'Lesson two body', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => $publishMoment->copy()->addMinute(), ]); $next = AcademyLesson::query()->create([ 'title' => 'Lesson Three', 'slug' => 'lesson-three', 'lesson_number' => 3, 'course_order' => 3, 'series_name' => 'AI Art Basics', 'content' => 'Lesson three body', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => $publishMoment->copy()->addMinutes(2), ]); AcademyLesson::query()->create([ 'title' => 'Other Series Lesson', 'slug' => 'other-series-lesson', 'lesson_number' => 99, 'course_order' => 99, 'series_name' => 'Other Series', 'content' => 'Other series body', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => $publishMoment->copy()->addMinutes(3), ]); $this->get(route('academy.lessons.show', ['slug' => $current->slug])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/Show') ->where('item.lesson_label', 'AI Art Basics · Lesson 02') ->where('previousLesson.slug', $previous->slug) ->where('nextLesson.slug', $next->slug)); } public function test_lesson_show_falls_back_to_category_navigation_when_series_name_is_missing(): void { $categoryId = DB::table('academy_categories')->insertGetId([ 'type' => 'lesson', 'name' => 'Prompting Basics', 'slug' => 'prompting-basics', 'order_num' => 1, 'active' => true, 'created_at' => now(), 'updated_at' => now(), ]); AcademyLesson::query()->create([ 'title' => 'Category Lesson One', 'slug' => 'category-lesson-one', 'category_id' => $categoryId, 'lesson_number' => 1, 'course_order' => 1, 'content' => 'One', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subDays(2), ]); $current = AcademyLesson::query()->create([ 'title' => 'Category Lesson Two', 'slug' => 'category-lesson-two', 'category_id' => $categoryId, 'lesson_number' => 2, 'course_order' => 2, 'content' => 'Two', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subDay(), ]); $this->get(route('academy.lessons.show', ['slug' => $current->slug])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->where('previousLesson.slug', 'category-lesson-one') ->where('nextLesson', null)); } public function test_lesson_payload_hides_label_when_lesson_number_is_missing(): void { $lesson = AcademyLesson::query()->create([ 'title' => 'Unnumbered Lesson', 'slug' => 'unnumbered-lesson', 'series_name' => 'AI Art Basics', 'content' => 'Lesson body', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subMinute(), ]); $this->get(route('academy.lessons.show', ['slug' => $lesson->slug])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->where('item.formatted_lesson_number', null) ->where('item.lesson_label', null)); } public function test_logged_in_user_can_mark_lesson_completed(): void { $lesson = AcademyLesson::query()->create([ 'title' => 'Trackable Lesson', 'slug' => 'trackable-lesson', 'content' => 'Track this lesson', 'difficulty' => 'beginner', 'access_level' => 'free', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subMinute(), ]); $user = User::factory()->create(); $this->actingAs($user) ->postJson(route('academy.lessons.complete', ['lesson' => $lesson->id])) ->assertOk() ->assertJsonPath('completed', true); $this->assertDatabaseHas('academy_lesson_progress', [ 'lesson_id' => $lesson->id, 'user_id' => $user->id, ]); } public function test_logged_in_user_can_save_and_unsave_prompt(): void { $prompt = AcademyPromptTemplate::query()->create([ 'title' => 'Free Prompt', 'slug' => 'free-prompt', 'prompt' => 'Save me', 'difficulty' => 'beginner', 'access_level' => 'free', 'active' => true, 'published_at' => now()->subMinute(), ]); $user = User::factory()->create(); $this->actingAs($user) ->postJson(route('academy.prompts.save', ['prompt' => $prompt->id])) ->assertOk() ->assertJsonPath('saved', true); $this->assertDatabaseHas('academy_saved_prompts', [ 'prompt_template_id' => $prompt->id, 'user_id' => $user->id, ]); $this->actingAs($user) ->deleteJson(route('academy.prompts.unsave', ['prompt' => $prompt->id])) ->assertOk() ->assertJsonPath('saved', false); $this->assertDatabaseMissing('academy_saved_prompts', [ 'prompt_template_id' => $prompt->id, 'user_id' => $user->id, ]); } public function test_prompt_library_index_exposes_breadcrumbs_and_discovery_payloads(): void { $featured = AcademyPromptTemplate::query()->create([ 'title' => 'Featured Prompt', 'slug' => 'featured-prompt', 'excerpt' => 'Featured prompt excerpt.', 'prompt' => 'Featured prompt body', 'difficulty' => 'beginner', 'access_level' => 'free', 'featured' => true, 'active' => true, 'published_at' => now()->subMinute(), ]); $popular = AcademyPromptTemplate::query()->create([ 'title' => 'Popular Prompt', 'slug' => 'popular-prompt', 'excerpt' => 'Popular prompt excerpt.', 'prompt' => 'Popular prompt body', 'difficulty' => 'intermediate', 'access_level' => 'free', 'active' => true, 'published_at' => now()->subMinutes(2), ]); AcademyContentMetricDaily::query()->create([ 'date' => now()->toDateString(), 'content_type' => 'academy_prompt', 'content_id' => $popular->id, 'views' => 42, 'prompt_copies' => 9, 'popularity_score' => 88.5, ]); Cache::flush(); $this->get(route('academy.prompts.index')) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/List') ->where('breadcrumbs.0.label', 'Academy') ->where('breadcrumbs.0.href', route('academy.index')) ->where('breadcrumbs.1.label', 'Prompt Library') ->where('coursesUrl', route('academy.courses.index')) ->where('packsUrl', route('academy.packs.index')) ->where('featuredPrompts.0.slug', $featured->slug) ->where('featuredPrompts.0.spotlight.eyebrow', 'Featured pick') ->where('popularPrompts.0.slug', $popular->slug) ->where('popularPrompts.0.spotlight.eyebrow', '9 copies this month') ->where('academyAccess.signedIn', false) ->where('academyAccess.status', 'guest') ->where('academyAccess.billingUrl', route('academy.pricing'))); } public function test_prompt_library_index_exposes_current_access_summary_for_grace_period_subscription(): void { config()->set('academy_billing.plans', [ 'creator_monthly' => [ 'tier' => 'creator', 'stripe_price_id' => 'price_creator_test', ], ]); $user = User::factory()->create(); $subscription = Subscription::query()->create([ 'user_id' => $user->id, 'type' => 'academy', 'stripe_id' => 'sub_creator_test', 'stripe_status' => 'active', 'stripe_price' => 'price_creator_test', 'quantity' => 1, 'ends_at' => now()->addDays(12), ]); SubscriptionItem::query()->create([ 'subscription_id' => $subscription->id, 'stripe_id' => 'si_creator_test', 'stripe_product' => 'prod_creator_test', 'stripe_price' => 'price_creator_test', 'quantity' => 1, ]); $this->actingAs($user) ->get(route('academy.prompts.index')) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/List') ->where('academyAccess.signedIn', true) ->where('academyAccess.tier', 'creator') ->where('academyAccess.tierLabel', 'Creator') ->where('academyAccess.status', 'grace_period') ->where('academyAccess.statusLabel', 'Cancels soon') ->where('academyAccess.dateLabel', 'Access ends') ->where('academyAccess.renewsAutomatically', false) ->where('academyAccess.source', 'subscription') ->where('academyAccess.billingUrl', route('academy.billing.account')) ->where('academyAccess.expiresAt', $subscription->ends_at?->toISOString())); } public function test_popular_prompts_page_displays_ranked_prompt_payloads(): void { $featured = AcademyPromptTemplate::query()->create([ 'title' => 'Featured Prompt', 'slug' => 'featured-popular-page-prompt', 'excerpt' => 'Featured prompt excerpt.', 'prompt' => 'Featured prompt body', 'difficulty' => 'beginner', 'access_level' => 'free', 'featured' => true, 'active' => true, 'published_at' => now()->subMinute(), ]); $topPrompt = AcademyPromptTemplate::query()->create([ 'title' => 'Top Prompt', 'slug' => 'top-prompt', 'excerpt' => 'Top prompt excerpt.', 'prompt' => 'Top prompt body', 'difficulty' => 'advanced', 'access_level' => 'free', 'active' => true, 'published_at' => now()->subMinutes(2), ]); $runnerUpPrompt = AcademyPromptTemplate::query()->create([ 'title' => 'Runner Up Prompt', 'slug' => 'runner-up-prompt', 'excerpt' => 'Runner up prompt excerpt.', 'prompt' => 'Runner up prompt body', 'difficulty' => 'intermediate', 'access_level' => 'free', 'active' => true, 'published_at' => now()->subMinutes(3), ]); AcademyContentMetricDaily::query()->create([ 'date' => now()->toDateString(), 'content_type' => 'academy_prompt', 'content_id' => $topPrompt->id, 'views' => 74, 'prompt_copies' => 11, 'popularity_score' => 128.7, ]); AcademyContentMetricDaily::query()->create([ 'date' => now()->toDateString(), 'content_type' => 'academy_prompt', 'content_id' => $runnerUpPrompt->id, 'views' => 48, 'prompt_copies' => 5, 'popularity_score' => 91.4, ]); Cache::flush(); $this->get(route('academy.prompts.popular')) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/List') ->where('promptView', 'popular') ->where('popularPeriod.value', '30d') ->where('breadcrumbs.2.label', 'Popular Prompts') ->where('promptLibraryUrl', route('academy.prompts.index')) ->where('items.data.0.slug', $topPrompt->slug) ->where('items.data.0.ranking.rank', 1) ->where('items.data.0.ranking.prompt_copies', 11) ->where('items.data.1.slug', $runnerUpPrompt->slug) ->where('items.data.1.ranking.rank', 2) ->where('popularPeriods.0.value', '7d') ->where('popularPeriods.1.active', true) ->where('featuredPrompts.0.slug', $featured->slug)); } public function test_popular_prompts_page_can_filter_to_last_seven_days(): void { $recentPrompt = AcademyPromptTemplate::query()->create([ 'title' => 'Recent Prompt', 'slug' => 'recent-prompt', 'excerpt' => 'Recent prompt excerpt.', 'prompt' => 'Recent prompt body', 'difficulty' => 'advanced', 'access_level' => 'free', 'active' => true, 'published_at' => now()->subMinute(), ]); $olderPrompt = AcademyPromptTemplate::query()->create([ 'title' => 'Older Prompt', 'slug' => 'older-prompt', 'excerpt' => 'Older prompt excerpt.', 'prompt' => 'Older prompt body', 'difficulty' => 'beginner', 'access_level' => 'free', 'active' => true, 'published_at' => now()->subMinutes(2), ]); AcademyContentMetricDaily::query()->create([ 'date' => now()->toDateString(), 'content_type' => 'academy_prompt', 'content_id' => $recentPrompt->id, 'views' => 31, 'prompt_copies' => 7, 'popularity_score' => 79.5, ]); AcademyContentMetricDaily::query()->create([ 'date' => now()->subDays(20)->toDateString(), 'content_type' => 'academy_prompt', 'content_id' => $olderPrompt->id, 'views' => 200, 'prompt_copies' => 22, 'popularity_score' => 240.1, ]); Cache::flush(); $this->get(route('academy.prompts.popular', ['period' => '7d'])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/List') ->where('popularPeriod.value', '7d') ->where('popularPeriod.label', '7 days') ->where('items.data.0.slug', $recentPrompt->slug) ->where('items.data.0.spotlight.eyebrow', '7 copies in the last 7 days') ->missing('items.data.1') ->where('popularPeriods.0.active', true) ->where('popularPeriods.1.active', false)); } public function test_prompt_pack_index_does_not_include_nested_prompts_until_pack_detail(): void { $prompt = AcademyPromptTemplate::query()->create([ 'title' => 'Pack Prompt', 'slug' => 'pack-prompt', 'excerpt' => 'Prompt excerpt.', 'prompt' => 'Pack prompt body', 'difficulty' => 'beginner', 'access_level' => 'free', 'active' => true, 'published_at' => now()->subMinute(), ]); $pack = AcademyPromptPack::query()->create([ 'title' => 'Starter Prompt Pack', 'slug' => 'starter-prompt-pack', 'excerpt' => 'Pack excerpt.', 'description' => 'Pack description.', 'access_level' => 'free', 'active' => true, 'published_at' => now()->subMinute(), ]); $pack->prompts()->attach($prompt->id, ['order_num' => 0]); $this->get(route('academy.packs.index')) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Academy/List') ->where('items.data.0.slug', 'starter-prompt-pack') ->where('items.data.0.prompts', []) ->where('analytics.contentType', 'academy_prompt_pack_library') ); } public function test_logged_in_user_can_submit_artwork_to_active_challenge(): void { $user = User::factory()->create(); $artwork = Artwork::factory()->create([ 'user_id' => $user->id, 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinute(), ]); $challenge = AcademyChallenge::query()->create([ 'title' => 'Open Challenge', 'slug' => 'open-challenge', 'access_level' => 'free', 'status' => 'active', 'active' => true, 'starts_at' => now()->subHour(), 'ends_at' => now()->addDay(), ]); $this->actingAs($user) ->post(route('academy.challenges.submit.store', ['slug' => $challenge->slug]), [ 'artwork_id' => $artwork->id, 'prompt_used' => 'Prompt used', 'workflow_notes' => 'Workflow notes', 'ai_tool_used' => 'ComfyUI', 'is_ai_generated' => true, 'is_ai_assisted' => true, ]) ->assertRedirect(route('academy.challenges.show', ['slug' => $challenge->slug])); $this->assertDatabaseHas('academy_challenge_submissions', [ 'challenge_id' => $challenge->id, 'user_id' => $user->id, 'artwork_id' => $artwork->id, ]); } public function test_guest_cannot_view_creator_or_pro_lesson_content(): void { $creatorLesson = AcademyLesson::query()->create([ 'title' => 'Creator Lesson', 'slug' => 'guest-creator-lesson', 'excerpt' => 'Creator preview', 'content' => 'CREATOR SECRET LESSON BODY', 'difficulty' => 'advanced', 'access_level' => 'creator', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subMinute(), ]); $proLesson = AcademyLesson::query()->create([ 'title' => 'Pro Lesson', 'slug' => 'guest-pro-lesson', 'excerpt' => 'Pro preview', 'content' => 'PRO SECRET LESSON BODY', 'difficulty' => 'pro', 'access_level' => 'pro', 'lesson_type' => 'article', 'active' => true, 'published_at' => now()->subMinute(), ]); foreach ([$creatorLesson, $proLesson] as $lesson) { $this->get(route('academy.lessons.show', ['slug' => $lesson->slug])) ->assertOk() ->assertDontSee($lesson->content) ->assertInertia(fn (AssertableInertia $page) => $page ->where('item.locked', true) ->where('item.content', null)); } } public function test_logged_in_free_user_cannot_view_creator_or_pro_prompt_content(): void { $creatorPrompt = AcademyPromptTemplate::query()->create([ 'title' => 'Creator Prompt', 'slug' => 'free-user-creator-prompt', 'excerpt' => 'Creator preview.', 'prompt' => 'CREATOR PREMIUM PROMPT', 'negative_prompt' => 'CREATOR PREMIUM NEGATIVE', 'difficulty' => 'beginner', 'access_level' => 'creator', 'active' => true, 'published_at' => now()->subMinute(), ]); $proPrompt = AcademyPromptTemplate::query()->create([ 'title' => 'Pro Prompt', 'slug' => 'free-user-pro-prompt', 'excerpt' => 'Pro preview.', 'prompt' => 'PRO PREMIUM PROMPT', 'negative_prompt' => 'PRO PREMIUM NEGATIVE', 'difficulty' => 'pro', 'access_level' => 'pro', 'active' => true, 'published_at' => now()->subMinute(), ]); $user = User::factory()->create(); foreach ([$creatorPrompt, $proPrompt] as $prompt) { $this->actingAs($user) ->get(route('academy.prompts.show', ['slug' => $prompt->slug])) ->assertOk() ->assertDontSee($prompt->prompt) ->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)); } } public function test_creator_user_can_view_creator_prompt_but_not_pro_prompt(): void { $creatorPrompt = AcademyPromptTemplate::query()->create([ 'title' => 'Creator Prompt', 'slug' => 'creator-visible-prompt', 'excerpt' => 'Creator prompt.', 'prompt' => 'CREATOR ACCESS PROMPT', 'negative_prompt' => 'CREATOR ACCESS NEGATIVE', 'difficulty' => 'intermediate', 'access_level' => 'creator', 'active' => true, 'published_at' => now()->subMinute(), ]); $proPrompt = AcademyPromptTemplate::query()->create([ 'title' => 'Pro Prompt', 'slug' => 'creator-locked-pro-prompt', 'excerpt' => 'Pro prompt.', 'prompt' => 'PRO ONLY ACCESS PROMPT', 'negative_prompt' => 'PRO ONLY ACCESS NEGATIVE', 'difficulty' => 'pro', 'access_level' => 'pro', 'active' => true, 'published_at' => now()->subMinute(), ]); $creator = User::factory()->create(['role' => 'academy_creator']); $this->actingAs($creator) ->get(route('academy.prompts.show', ['slug' => $creatorPrompt->slug])) ->assertOk() ->assertSee('CREATOR ACCESS PROMPT') ->assertInertia(fn (AssertableInertia $page) => $page ->where('item.locked', false) ->where('item.prompt', 'CREATOR ACCESS PROMPT')); $this->actingAs($creator) ->get(route('academy.prompts.show', ['slug' => $proPrompt->slug])) ->assertOk() ->assertDontSee('PRO ONLY ACCESS PROMPT') ->assertInertia(fn (AssertableInertia $page) => $page ->where('item.locked', true) ->where('item.prompt', null)); } public function test_pro_and_admin_users_can_view_pro_prompt(): void { $prompt = AcademyPromptTemplate::query()->create([ 'title' => 'Pro Prompt', 'slug' => 'pro-visible-prompt', 'excerpt' => 'Visible to pro and admin.', 'prompt' => 'VISIBLE TO PRO AND ADMIN', 'negative_prompt' => 'VISIBLE NEGATIVE TO PRO AND ADMIN', 'difficulty' => 'pro', 'access_level' => 'pro', 'active' => true, 'published_at' => now()->subMinute(), ]); foreach ([ User::factory()->create(['role' => 'academy_pro']), User::factory()->create(['role' => 'admin']), ] as $user) { $this->actingAs($user) ->get(route('academy.prompts.show', ['slug' => $prompt->slug])) ->assertOk() ->assertSee('VISIBLE TO PRO AND ADMIN') ->assertInertia(fn (AssertableInertia $page) => $page ->where('item.locked', false) ->where('item.prompt', 'VISIBLE TO PRO AND ADMIN')); } } public function test_challenge_routes_are_hidden_when_challenges_are_disabled(): void { config(['academy.challenges_enabled' => false]); $this->get('/academy/challenges')->assertNotFound(); } public function test_checkout_returns_payment_disabled_response_when_payments_are_disabled(): void { config(['academy.payments_enabled' => false]); $user = User::factory()->create(); $this->actingAs($user) ->postJson(route('academy.checkout', ['plan' => 'creator-monthly'])) ->assertStatus(423) ->assertJsonPath('code', 'academy_payments_disabled'); } public function test_duplicate_prompt_save_is_idempotent(): void { $prompt = AcademyPromptTemplate::query()->create([ 'title' => 'Free Prompt', 'slug' => 'idempotent-save-prompt', 'prompt' => 'Save me twice', 'difficulty' => 'beginner', 'access_level' => 'free', 'active' => true, 'published_at' => now()->subMinute(), ]); $user = User::factory()->create(); $this->actingAs($user)->postJson(route('academy.prompts.save', ['prompt' => $prompt->id]))->assertOk(); $this->actingAs($user)->postJson(route('academy.prompts.save', ['prompt' => $prompt->id]))->assertOk(); $this->assertSame(1, DB::table('academy_saved_prompts')->where('user_id', $user->id)->where('prompt_template_id', $prompt->id)->count()); } public function test_duplicate_challenge_submission_updates_existing_record_instead_of_creating_duplicate(): void { $user = User::factory()->create(); $artwork = Artwork::factory()->create([ 'user_id' => $user->id, 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinute(), ]); $challenge = AcademyChallenge::query()->create([ 'title' => 'Duplicate Safe Challenge', 'slug' => 'duplicate-safe-challenge', 'access_level' => 'free', 'status' => 'active', 'active' => true, 'starts_at' => now()->subHour(), 'ends_at' => now()->addDay(), ]); $payload = [ 'artwork_id' => $artwork->id, 'prompt_used' => 'Prompt one', 'workflow_notes' => 'Workflow one', 'ai_tool_used' => 'ComfyUI', 'is_ai_generated' => true, 'is_ai_assisted' => true, ]; $this->actingAs($user)->post(route('academy.challenges.submit.store', ['slug' => $challenge->slug]), $payload)->assertRedirect(); $payload['workflow_notes'] = 'Workflow two'; $this->actingAs($user)->post(route('academy.challenges.submit.store', ['slug' => $challenge->slug]), $payload)->assertRedirect(); $this->assertSame(1, DB::table('academy_challenge_submissions')->where('challenge_id', $challenge->id)->where('user_id', $user->id)->where('artwork_id', $artwork->id)->count()); $this->assertDatabaseHas('academy_challenge_submissions', [ 'challenge_id' => $challenge->id, 'user_id' => $user->id, 'artwork_id' => $artwork->id, 'workflow_notes' => 'Workflow two', ]); } }