chore: commit remaining workspace changes

This commit is contained in:
2026-05-08 21:51:29 +02:00
parent 8d108b8a76
commit ff96ef796e
97 changed files with 18020 additions and 2196 deletions

View File

@@ -8,13 +8,19 @@ use App\Http\Middleware\ConditionalValidateCsrfToken;
use App\Http\Middleware\HandleInertiaRequests;
use App\Models\AcademyAiComparisonResult;
use App\Models\AcademyChallenge;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseEnrollment;
use App\Models\AcademyCourseLesson;
use App\Models\AcademyCourseSection;
use App\Models\AcademyLesson;
use App\Models\AcademyLessonBlock;
use App\Models\AcademyLessonProgress;
use App\Models\AcademyPromptTemplate;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia;
use Tests\TestCase;
@@ -45,6 +51,367 @@ final class AcademyFeatureTest extends TestCase
$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
@@ -57,6 +424,8 @@ final class AcademyFeatureTest extends TestCase
'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(),
]);
@@ -66,7 +435,11 @@ final class AcademyFeatureTest extends TestCase
->assertInertia(fn (AssertableInertia $page) => $page
->component('Academy/Show')
->where('item.locked', false)
->where('item.content', 'Free lesson content'));
->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
@@ -166,6 +539,15 @@ final class AcademyFeatureTest extends TestCase
'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,
@@ -180,7 +562,11 @@ final class AcademyFeatureTest extends TestCase
->assertSee('VISIBLE PREMIUM PROMPT')
->assertInertia(fn (AssertableInertia $page) => $page
->where('item.locked', false)
->where('item.prompt', 'VISIBLE PREMIUM PROMPT'));
->where('item.prompt', 'VISIBLE PREMIUM PROMPT')
->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));
}
public function test_public_lesson_payload_includes_active_ai_comparison_block_and_hides_inactive_results(): void
@@ -273,6 +659,184 @@ final class AcademyFeatureTest extends TestCase
->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([