chore: commit remaining workspace changes
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyCourseLesson;
|
||||
use App\Models\AcademyCourseSection;
|
||||
use App\Models\AcademyLesson;
|
||||
|
||||
it('syncs the foundations course idempotently and skips missing lessons', function (): void {
|
||||
AcademyLesson::query()->create([
|
||||
'title' => 'What Is AI-Assisted Digital Art',
|
||||
'slug' => 'what-is-ai-assisted-digital-art',
|
||||
'content' => 'Intro lesson body',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
AcademyLesson::query()->create([
|
||||
'title' => 'Prompting Basics For Skinbase Creators',
|
||||
'slug' => 'prompting-basics-for-skinbase-creators',
|
||||
'content' => 'Prompt basics body',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'active' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->artisan('academy:courses:sync-foundations')
|
||||
->expectsOutputToContain('Skipped missing lesson [ai-ethics-and-skinbase-upload-rules].')
|
||||
->expectsOutput('AI-Assisted Digital Art Foundations course synced.')
|
||||
->assertSuccessful();
|
||||
|
||||
$course = AcademyCourse::query()->where('slug', 'ai-assisted-digital-art-foundations')->first();
|
||||
|
||||
expect($course)->not->toBeNull()
|
||||
->and($course?->status)->toBe('published')
|
||||
->and($course?->is_featured)->toBeTrue();
|
||||
|
||||
expect(AcademyCourseSection::query()->where('course_id', $course->id)->count())->toBe(4);
|
||||
expect(AcademyCourseLesson::query()->where('course_id', $course->id)->count())->toBe(2);
|
||||
expect($course->fresh()->lessons_count_cache)->toBe(2);
|
||||
|
||||
$this->artisan('academy:courses:sync-foundations')->assertSuccessful();
|
||||
|
||||
expect(AcademyCourse::query()->where('slug', 'ai-assisted-digital-art-foundations')->count())->toBe(1);
|
||||
expect(AcademyCourseSection::query()->where('course_id', $course->id)->count())->toBe(4);
|
||||
expect(AcademyCourseLesson::query()->where('course_id', $course->id)->count())->toBe(2);
|
||||
});
|
||||
@@ -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([
|
||||
|
||||
@@ -9,8 +9,11 @@ use App\Models\AcademyAiComparisonResult;
|
||||
use App\Models\AcademyCategory;
|
||||
use App\Models\AcademyChallenge;
|
||||
use App\Models\AcademyChallengeSubmission;
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Models\AcademyCourseLesson;
|
||||
use App\Models\AcademyLesson;
|
||||
use App\Models\AcademyLessonBlock;
|
||||
use App\Models\AcademyPromptTemplate;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -38,6 +41,7 @@ final class AcademyAdminTest extends TestCase
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Admin/Academy/Dashboard')
|
||||
->where('stats.courses', 0)
|
||||
->where('stats.lessons', 0)
|
||||
->where('stats.prompts', 0));
|
||||
}
|
||||
@@ -90,6 +94,7 @@ final class AcademyAdminTest extends TestCase
|
||||
|
||||
foreach ([
|
||||
'/moderation/academy/dashboard',
|
||||
'/moderation/academy/courses',
|
||||
'/moderation/academy/categories',
|
||||
'/moderation/academy/lessons',
|
||||
'/moderation/academy/prompts',
|
||||
@@ -102,6 +107,373 @@ final class AcademyAdminTest extends TestCase
|
||||
}
|
||||
}
|
||||
|
||||
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' => '<p>Body</p>',
|
||||
'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' => '<p>Body</p>',
|
||||
'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' => '<p>Body</p>',
|
||||
'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' => '<p>Body</p>',
|
||||
'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' => '<p>Body</p>',
|
||||
'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_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' => '<p>Body</p>',
|
||||
'difficulty' => 'beginner',
|
||||
'access_level' => 'free',
|
||||
'lesson_type' => 'article',
|
||||
'reading_minutes' => 5,
|
||||
'active' => true,
|
||||
]);
|
||||
$optionalLesson = AcademyLesson::query()->create([
|
||||
'title' => 'Optional Lesson',
|
||||
'slug' => 'optional-lesson',
|
||||
'content' => '<p>Body</p>',
|
||||
'difficulty' => 'beginner',
|
||||
'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,
|
||||
]);
|
||||
|
||||
$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));
|
||||
}
|
||||
|
||||
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' => '<h2>What you will learn</h2><p>Prompt structure, workflow cleanup, and publication readiness.</p>',
|
||||
'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('<h2>What you will learn</h2>', (string) $course->description);
|
||||
$this->assertTrue((bool) $course->is_featured);
|
||||
$this->assertNotNull($course->published_at);
|
||||
}
|
||||
|
||||
public function test_admin_category_update_clears_academy_cache(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
@@ -146,6 +518,8 @@ final class AcademyAdminTest extends TestCase
|
||||
'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,
|
||||
@@ -183,6 +557,287 @@ final class AcademyAdminTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
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('<h1>Cleaner scene direction</h1>', (string) $lesson->content);
|
||||
$this->assertStringContainsString('<strong>specific nouns</strong>', (string) $lesson->content);
|
||||
$this->assertStringContainsString('<li>State the subject</li>', (string) $lesson->content);
|
||||
}
|
||||
|
||||
public function test_admin_can_store_lesson_numbering_fields(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
|
||||
$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' => '<p>Lesson body.</p>',
|
||||
'tags' => ['workflow', '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(['workflow', 'academy'], $lesson->fresh()->tags);
|
||||
}
|
||||
|
||||
public function test_admin_lesson_reading_time_is_calculated_from_content(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$body = '<p>' . implode(' ', array_fill(0, 420, 'prompt')) . '</p>';
|
||||
|
||||
$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' => '<p>Lesson body.</p>',
|
||||
'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' => '<p>Body</p>',
|
||||
'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' => '<p>Body</p>',
|
||||
'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' => '<p>Body</p>',
|
||||
'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' => '<p>Lesson body.</p>',
|
||||
'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_add_ai_comparison_result_to_existing_lesson(): void
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
@@ -258,6 +913,107 @@ final class AcademyAdminTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
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' => '<p>Old body</p>',
|
||||
'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('<h2>Prompt checklist</h2>', (string) $lesson->content);
|
||||
$this->assertStringContainsString('<ol>', (string) $lesson->content);
|
||||
$this->assertStringContainsString('<blockquote>', (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 = '<h2>Final note</h2><p><strong>Prompting</strong> is a creative skill.</p><ul><li>Keep the best result</li><li>Prepare it</li><li>Present it with care</li></ul>';
|
||||
|
||||
$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']);
|
||||
|
||||
@@ -23,17 +23,19 @@ use Klevze\ControlPanel\Models\Admin\AdminVerification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createControlPanelAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$admin->forceFill([
|
||||
'isAdmin' => true,
|
||||
'activated' => true,
|
||||
])->save();
|
||||
if (! function_exists('createControlPanelAdmin')) {
|
||||
function createControlPanelAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$admin->forceFill([
|
||||
'isAdmin' => true,
|
||||
'activated' => true,
|
||||
])->save();
|
||||
|
||||
AdminVerification::createForUser($admin->fresh());
|
||||
AdminVerification::createForUser($admin->fresh());
|
||||
|
||||
return $admin->fresh();
|
||||
return $admin->fresh();
|
||||
}
|
||||
}
|
||||
|
||||
it('loads the cpad moderation list and detail screens for admins', function (): void {
|
||||
|
||||
@@ -10,8 +10,6 @@ use App\Services\AiBiography\AiBiographyInputBuilder;
|
||||
use App\Services\AiBiography\AiBiographyService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
use Klevze\ControlPanel\Core\Structs\MenuRootItem;
|
||||
use Klevze\ControlPanel\Framework\Core\Menu as ControlPanelMenu;
|
||||
use Klevze\ControlPanel\Models\Admin\AdminVerification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -77,17 +75,17 @@ it('renders the ai biography admin index with records and stats', function (): v
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('cp.ai-biography.index'))
|
||||
->get(route('admin.cp.ai-biography.index'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('Moderation/AiBiographyAdmin')
|
||||
->component('Admin/AiBiography')
|
||||
->where('stats.total_records', 2)
|
||||
->where('stats.needs_review', 1)
|
||||
->where('stats.failed', 1)
|
||||
->where('records.data.0.user.username', 'bioadmin')
|
||||
->where('records.data.0.status', CreatorAiBiography::STATUS_FAILED)
|
||||
->where('records.data.1.status', CreatorAiBiography::STATUS_NEEDS_REVIEW)
|
||||
->where('endpoints.rebuildPattern', route('cp.ai-biography.rebuild', ['user' => '__USER__'])));
|
||||
->where('endpoints.rebuildPattern', route('admin.cp.ai-biography.rebuild', ['user' => '__USER__'])));
|
||||
});
|
||||
|
||||
it('allows controlpanel-only admins to open the ai biography admin page', function (): void {
|
||||
@@ -102,30 +100,11 @@ it('allows controlpanel-only admins to open the ai biography admin page', functi
|
||||
});
|
||||
|
||||
it('registers the ai biography entry in the cpad artworks menu', function (): void {
|
||||
$admin = aiBiographyAdminUser();
|
||||
$creator = User::factory()->create(['username' => 'menubio']);
|
||||
biographyRecord($creator, [
|
||||
'needs_review' => true,
|
||||
'status' => CreatorAiBiography::STATUS_NEEDS_REVIEW,
|
||||
]);
|
||||
$artworksPlugin = file_get_contents(base_path('packages/klevze/Plugins/Artworks/ServiceProvider.php'));
|
||||
$adminLayout = file_get_contents(base_path('resources/js/Layouts/AdminLayout.jsx'));
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->get(route('cp.ai-biography.index'))
|
||||
->assertOk();
|
||||
|
||||
$sidebarMenu = collect(app(ControlPanelMenu::class)->getSidebarMenu());
|
||||
|
||||
$artworksRoot = $sidebarMenu
|
||||
->first(fn ($item): bool => $item instanceof MenuRootItem && $item->getName() === 'Artworks');
|
||||
|
||||
expect($artworksRoot)->toBeInstanceOf(MenuRootItem::class);
|
||||
|
||||
$aiBiographyItem = collect($artworksRoot->getItems())
|
||||
->first(fn ($item): bool => str_starts_with((string) ($item->name ?? ''), 'AI Biographies'));
|
||||
|
||||
expect($aiBiographyItem)->not->toBeNull()
|
||||
->and($aiBiographyItem->mainRoute)->toBe('cp.ai-biography.index')
|
||||
->and($aiBiographyItem->icon)->toBe('fa-solid fa-feather-pointed');
|
||||
expect($artworksPlugin)->toContain('admin.cp.ai-biography.index')
|
||||
->and($adminLayout)->toContain("/moderation/ai-biography");
|
||||
});
|
||||
|
||||
it('rebuilds an existing active biography through the admin surface', function (): void {
|
||||
@@ -158,7 +137,7 @@ it('rebuilds an existing active biography through the admin surface', function (
|
||||
app()->instance(AiBiographyService::class, new AiBiographyService(new AiBiographyInputBuilder(), $generator));
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->postJson(route('cp.ai-biography.rebuild', ['user' => $creator->id]))
|
||||
->postJson(route('admin.cp.ai-biography.rebuild', ['user' => $creator->id]))
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true)
|
||||
->assertJsonPath('message', 'Biography rebuild completed.');
|
||||
@@ -176,7 +155,7 @@ it('allows admins to approve flag and toggle visibility on biography records', f
|
||||
]);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->postJson(route('cp.ai-biography.approve', ['biography' => $record->id]))
|
||||
->postJson(route('admin.cp.ai-biography.approve', ['biography' => $record->id]))
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
@@ -184,21 +163,21 @@ it('allows admins to approve flag and toggle visibility on biography records', f
|
||||
->and($record->fresh()->status)->toBe(CreatorAiBiography::STATUS_APPROVED);
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->postJson(route('cp.ai-biography.hide', ['biography' => $record->id]))
|
||||
->postJson(route('admin.cp.ai-biography.hide', ['biography' => $record->id]))
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
expect($record->fresh()->is_hidden)->toBeTrue();
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->postJson(route('cp.ai-biography.show', ['biography' => $record->id]))
|
||||
->postJson(route('admin.cp.ai-biography.show', ['biography' => $record->id]))
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
expect($record->fresh()->is_hidden)->toBeFalse();
|
||||
|
||||
$this->actingAs($admin)->actingAs($admin, 'controlpanel')
|
||||
->postJson(route('cp.ai-biography.flag', ['biography' => $record->id]))
|
||||
->postJson(route('admin.cp.ai-biography.flag', ['biography' => $record->id]))
|
||||
->assertOk()
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@ use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Testing\AssertableInertia;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
@@ -50,15 +52,90 @@ it('renders JSON-LD structured data on published artwork page', function () {
|
||||
$tagB->id => ['source' => 'user', 'confidence' => 0.8],
|
||||
]);
|
||||
|
||||
$html = view('artworks.show', ['artwork' => $artwork])->render();
|
||||
$artwork->load(['user', 'tags', 'categories.contentType']);
|
||||
|
||||
expect($html)
|
||||
->toContain('application/ld+json')
|
||||
expect(json_encode(app(SeoFactory::class)->artwork(
|
||||
$artwork,
|
||||
[
|
||||
'md' => ['url' => 'https://files.skinbase.org/md/schema-ready.webp', 'width' => 600, 'height' => 400],
|
||||
'lg' => ['url' => 'https://files.skinbase.org/lg/schema-ready.webp', 'width' => 1200, 'height' => 800],
|
||||
'xl' => ['url' => 'https://files.skinbase.org/xl/schema-ready.webp', 'width' => 2400, 'height' => 1600],
|
||||
],
|
||||
route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]),
|
||||
)->toArray()['json_ld'], JSON_UNESCAPED_SLASHES))
|
||||
->toContain('"@type":"ImageObject"')
|
||||
->toContain('"name":"Schema Ready Artwork"')
|
||||
->toContain('"keywords":["neon","city"]');
|
||||
});
|
||||
|
||||
it('builds artwork seo data with breadcrumb and image-license metadata', function () {
|
||||
$user = User::factory()->create(['name' => 'Schema Breadcrumb Author']);
|
||||
|
||||
$contentType = ContentType::create([
|
||||
'name' => 'Photography',
|
||||
'slug' => 'photography',
|
||||
'description' => 'Photography content',
|
||||
]);
|
||||
|
||||
$parentCategory = Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Nature',
|
||||
'slug' => 'nature',
|
||||
'description' => 'Nature works',
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$category = Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => $parentCategory->id,
|
||||
'name' => 'Forest',
|
||||
'slug' => 'forest',
|
||||
'description' => 'Forest works',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Licensed Artwork',
|
||||
'slug' => 'licensed-artwork',
|
||||
'description' => 'Artwork description for breadcrumb schema test.',
|
||||
'published_at' => now()->subMinute(),
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$artwork->categories()->attach($category->id);
|
||||
$artwork->load(['user', 'tags', 'categories.contentType', 'categories.parent.contentType']);
|
||||
$artwork->setAttribute('license_url', 'https://skinbase.org/licenses/custom-license');
|
||||
|
||||
$seo = app(SeoFactory::class)->artwork(
|
||||
$artwork,
|
||||
[
|
||||
'md' => ['url' => 'https://files.skinbase.org/md/licensed.webp', 'width' => 600, 'height' => 400],
|
||||
'lg' => ['url' => 'https://files.skinbase.org/lg/licensed.webp', 'width' => 1200, 'height' => 800],
|
||||
'xl' => ['url' => 'https://files.skinbase.org/xl/licensed.webp', 'width' => 2400, 'height' => 1600],
|
||||
],
|
||||
route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]),
|
||||
[
|
||||
['name' => 'Photography', 'url' => url('/photography')],
|
||||
['name' => 'Nature', 'url' => url('/photography/nature')],
|
||||
['name' => 'Forest', 'url' => url('/photography/nature/forest')],
|
||||
['name' => 'Licensed Artwork', 'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug])],
|
||||
],
|
||||
)->toArray();
|
||||
|
||||
expect(json_encode($seo['json_ld'], JSON_UNESCAPED_SLASHES))
|
||||
->toContain('"@type":"ImageObject"')
|
||||
->toContain('"creditText":"Schema Breadcrumb Author"')
|
||||
->toContain('"license":"https://skinbase.org/licenses/custom-license"')
|
||||
->toContain('"@type":"BreadcrumbList"')
|
||||
->toContain('"name":"Photography"')
|
||||
->toContain('"name":"Forest"');
|
||||
});
|
||||
|
||||
it('renders JSON-LD via routed artwork show endpoint', function () {
|
||||
$user = User::factory()->create(['name' => 'Schema Route Author']);
|
||||
|
||||
@@ -109,9 +186,16 @@ it('renders JSON-LD via routed artwork show endpoint', function () {
|
||||
|
||||
$response = $this->get($url);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('application/ld+json', false);
|
||||
$response->assertSee('"@type":"ImageObject"', false);
|
||||
$response->assertSee('"name":"Schema Route Artwork"', false);
|
||||
$response->assertSee('"keywords":["route-tag"]', false);
|
||||
$response->assertOk()
|
||||
->assertInertia(fn (AssertableInertia $page) => $page
|
||||
->component('ArtworkPage')
|
||||
->where('seo.title', fn (string $title): bool => str_contains($title, 'Schema Route Artwork'))
|
||||
->where('seo.json_ld', function ($schemas): bool {
|
||||
$json = json_encode($schemas, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return str_contains($json, '"@type":"ImageObject"')
|
||||
&& str_contains($json, '"@type":"BreadcrumbList"')
|
||||
&& str_contains($json, '"name":"Schema Route Artwork"')
|
||||
&& str_contains($json, '"keywords":["route-tag"]');
|
||||
}));
|
||||
});
|
||||
|
||||
83
tests/Feature/BrowseGalleryStructuredDataTest.php
Normal file
83
tests/Feature/BrowseGalleryStructuredDataTest.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('renders content-type landing pages with collection, image gallery, breadcrumb, and item list structured data', function (): void {
|
||||
$pages = [
|
||||
[
|
||||
'name' => 'Wallpapers',
|
||||
'slug' => 'wallpapers',
|
||||
'description' => 'Discover desktop and mobile wallpapers from the Skinbase creative community.',
|
||||
],
|
||||
[
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins',
|
||||
'description' => 'Browse classic and modern skins from the Skinbase creative community.',
|
||||
],
|
||||
[
|
||||
'name' => 'Digital Art',
|
||||
'slug' => 'digital-art',
|
||||
'description' => 'Explore digital art from the Skinbase creative community.',
|
||||
],
|
||||
[
|
||||
'name' => 'Other',
|
||||
'slug' => 'other',
|
||||
'description' => 'Discover other creative works shared on Skinbase.',
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($pages as $page) {
|
||||
ContentType::query()->create($page);
|
||||
|
||||
$html = $this->get('/' . $page['slug'])
|
||||
->assertOk()
|
||||
->getContent();
|
||||
|
||||
expect($html)
|
||||
->toContain('application/ld+json')
|
||||
->toContain('CollectionPage')
|
||||
->toContain('ImageGallery')
|
||||
->toContain('BreadcrumbList')
|
||||
->toContain('ItemList')
|
||||
->toContain($page['name'])
|
||||
->toContain('/explore');
|
||||
}
|
||||
});
|
||||
|
||||
it('renders category discovery pages with collection, image gallery, breadcrumb, and item list structured data', function (): void {
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Wallpapers',
|
||||
'slug' => 'wallpapers',
|
||||
'description' => 'Discover desktop and mobile wallpapers from the Skinbase creative community.',
|
||||
]);
|
||||
|
||||
$category = Category::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Fantasy',
|
||||
'slug' => 'fantasy',
|
||||
'description' => 'Fantasy wallpapers and scenes.',
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$html = $this->get('/wallpapers/' . $category->slug)
|
||||
->assertOk()
|
||||
->getContent();
|
||||
|
||||
expect($html)
|
||||
->toContain('application/ld+json')
|
||||
->toContain('CollectionPage')
|
||||
->toContain('ImageGallery')
|
||||
->toContain('BreadcrumbList')
|
||||
->toContain('ItemList')
|
||||
->toContain('Fantasy')
|
||||
->toContain('/explore')
|
||||
->toContain('/wallpapers');
|
||||
});
|
||||
@@ -15,17 +15,19 @@ use Klevze\ControlPanel\Framework\Core\Menu as ControlPanelMenu;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function createControlPanelAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$admin->forceFill([
|
||||
'isAdmin' => true,
|
||||
'activated' => true,
|
||||
])->save();
|
||||
if (! function_exists('createControlPanelAdmin')) {
|
||||
function createControlPanelAdmin(): User
|
||||
{
|
||||
$admin = User::factory()->create(['role' => 'admin']);
|
||||
$admin->forceFill([
|
||||
'isAdmin' => true,
|
||||
'activated' => true,
|
||||
])->save();
|
||||
|
||||
AdminVerification::createForUser($admin->fresh());
|
||||
AdminVerification::createForUser($admin->fresh());
|
||||
|
||||
return $admin->fresh();
|
||||
return $admin->fresh();
|
||||
}
|
||||
}
|
||||
|
||||
function adminArtwork(array $attributes = []): Artwork
|
||||
|
||||
194
tests/Feature/ForumDiscussionStructuredDataTest.php
Normal file
194
tests/Feature/ForumDiscussionStructuredDataTest.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use App\Models\User;
|
||||
use cPad\Plugins\Forum\Models\ForumBoard;
|
||||
use cPad\Plugins\Forum\Models\ForumCategory;
|
||||
use cPad\Plugins\Forum\Models\ForumPost;
|
||||
use cPad\Plugins\Forum\Models\ForumTopic;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->withoutMiddleware(HandleInertiaRequests::class);
|
||||
});
|
||||
|
||||
it('renders discussion forum structured data on forum topic pages', function (): void {
|
||||
$author = User::query()->create([
|
||||
'username' => 'forumauthor',
|
||||
'username_changed_at' => now()->subDays(120),
|
||||
'last_username_change_at' => now()->subDays(120),
|
||||
'onboarding_step' => 'complete',
|
||||
'name' => 'Forum Author',
|
||||
'email' => 'forumauthor@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$replier = User::query()->create([
|
||||
'username' => 'forumreplier',
|
||||
'username_changed_at' => now()->subDays(120),
|
||||
'last_username_change_at' => now()->subDays(120),
|
||||
'onboarding_step' => 'complete',
|
||||
'name' => 'Forum Replier',
|
||||
'email' => 'forumreplier@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$category = ForumCategory::query()->create([
|
||||
'name' => 'Forum SEO',
|
||||
'title' => 'Forum SEO',
|
||||
'slug' => 'forum-seo',
|
||||
'description' => 'SEO discussion category',
|
||||
'is_active' => true,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$board = ForumBoard::query()->create([
|
||||
'category_id' => $category->id,
|
||||
'title' => 'Technical SEO',
|
||||
'slug' => 'technical-seo',
|
||||
'description' => 'Technical SEO board',
|
||||
'is_active' => true,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$topic = ForumTopic::query()->create([
|
||||
'board_id' => $board->id,
|
||||
'user_id' => $author->id,
|
||||
'title' => 'Structured data for forums',
|
||||
'slug' => 'structured-data-for-forums',
|
||||
'views' => 42,
|
||||
'replies_count' => 1,
|
||||
'last_post_at' => now(),
|
||||
]);
|
||||
|
||||
ForumPost::query()->create([
|
||||
'thread_id' => $topic->id,
|
||||
'topic_id' => $topic->id,
|
||||
'user_id' => $author->id,
|
||||
'content' => 'Original post body about Google discussion structured data.',
|
||||
'created_at' => now()->subHour(),
|
||||
'updated_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$reply = ForumPost::query()->create([
|
||||
'thread_id' => $topic->id,
|
||||
'topic_id' => $topic->id,
|
||||
'user_id' => $replier->id,
|
||||
'content' => 'Reply with implementation details for the forum page.',
|
||||
'created_at' => now()->subMinutes(15),
|
||||
'updated_at' => now()->subMinutes(15),
|
||||
]);
|
||||
|
||||
$response = $this->get(route('forum.topic.show', ['topic' => $topic->slug]));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('application/ld+json', false)
|
||||
->assertSee('DiscussionForumPosting', false)
|
||||
->assertSee('itemtype="https://schema.org/DiscussionForumPosting"', false)
|
||||
->assertSee('itemprop="comment"', false)
|
||||
->assertSee('itemtype="https://schema.org/Comment"', false)
|
||||
->assertSee('itemprop="headline"', false)
|
||||
->assertSee('itemprop="mainEntityOfPage"', false)
|
||||
->assertSee('Structured data for forums', false)
|
||||
->assertSee('Original post body about Google discussion structured data.', false)
|
||||
->assertSee('Reply with implementation details for the forum page.', false)
|
||||
->assertSee(route('forum.topic.show', ['topic' => $topic->slug]) . '#post-' . $reply->id, false)
|
||||
->assertSee(route('profile.show', ['username' => 'forumauthor']), false)
|
||||
->assertSee(route('profile.show', ['username' => 'forumreplier']), false);
|
||||
});
|
||||
|
||||
it('renders item list microdata on forum board pages', function (): void {
|
||||
$author = User::query()->create([
|
||||
'username' => 'boardauthor',
|
||||
'username_changed_at' => now()->subDays(120),
|
||||
'last_username_change_at' => now()->subDays(120),
|
||||
'onboarding_step' => 'complete',
|
||||
'name' => 'Board Author',
|
||||
'email' => 'boardauthor@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$category = ForumCategory::query()->create([
|
||||
'name' => 'Forum Boards',
|
||||
'title' => 'Forum Boards',
|
||||
'slug' => 'forum-boards',
|
||||
'description' => 'Board category',
|
||||
'is_active' => true,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$board = ForumBoard::query()->create([
|
||||
'category_id' => $category->id,
|
||||
'title' => 'Board Microdata',
|
||||
'slug' => 'board-microdata',
|
||||
'description' => 'Board microdata description',
|
||||
'is_active' => true,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$topic = ForumTopic::query()->create([
|
||||
'board_id' => $board->id,
|
||||
'user_id' => $author->id,
|
||||
'title' => 'Board topic title',
|
||||
'slug' => 'board-topic-title',
|
||||
'replies_count' => 2,
|
||||
'created_at' => now()->subHour(),
|
||||
'last_post_at' => now(),
|
||||
]);
|
||||
|
||||
ForumPost::query()->create([
|
||||
'thread_id' => $topic->id,
|
||||
'topic_id' => $topic->id,
|
||||
'user_id' => $author->id,
|
||||
'content' => 'Board topic opening post content.',
|
||||
'created_at' => now()->subHour(),
|
||||
'updated_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$this->get(route('forum.board.show', ['boardSlug' => $board->slug]))
|
||||
->assertOk()
|
||||
->assertSee('itemtype="https://schema.org/CollectionPage"', false)
|
||||
->assertSee('itemtype="https://schema.org/ItemList"', false)
|
||||
->assertSee('itemtype="https://schema.org/ListItem"', false)
|
||||
->assertSee('itemtype="https://schema.org/DiscussionForumPosting"', false)
|
||||
->assertSee('itemprop="datePublished"', false)
|
||||
->assertSee('Board topic title', false)
|
||||
->assertSee(route('forum.topic.show', ['topic' => $topic->slug]), false)
|
||||
->assertSee(route('profile.show', ['username' => 'boardauthor']), false);
|
||||
});
|
||||
|
||||
it('renders item list microdata on forum section pages', function (): void {
|
||||
$category = ForumCategory::query()->create([
|
||||
'name' => 'Photography',
|
||||
'title' => 'Photography',
|
||||
'slug' => 'photography-section-microdata',
|
||||
'description' => 'Photography category',
|
||||
'is_active' => true,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$board = ForumBoard::query()->create([
|
||||
'category_id' => $category->id,
|
||||
'title' => 'Photography Board',
|
||||
'slug' => 'photography-board-microdata',
|
||||
'description' => 'Photography board description',
|
||||
'is_active' => true,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$this->get(route('forum.category.show', ['categorySlug' => $category->slug]))
|
||||
->assertOk()
|
||||
->assertSee('itemtype="https://schema.org/CollectionPage"', false)
|
||||
->assertSee('itemtype="https://schema.org/ItemList"', false)
|
||||
->assertSee('itemtype="https://schema.org/ListItem"', false)
|
||||
->assertSee(route('forum.category.show', ['categorySlug' => $category->slug]), false)
|
||||
->assertSee(route('forum.board.show', ['boardSlug' => $board->slug]), false)
|
||||
->assertSee('Photography boards', false)
|
||||
->assertSee('Photography Board', false);
|
||||
});
|
||||
@@ -313,6 +313,9 @@ it('renders structured data for public news pages', function (): void {
|
||||
->assertOk()
|
||||
->assertSee('NewsArticle', false)
|
||||
->assertSee('ImageObject', false)
|
||||
->assertSee('creditText', false)
|
||||
->assertSee(route('terms-of-service'), false)
|
||||
->assertSee('acquireLicensePage', false)
|
||||
->assertSee('BreadcrumbList', false);
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ declare(strict_types=1);
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\Artwork;
|
||||
|
||||
beforeEach(function () {
|
||||
foreach (['wallpapers', 'skins', 'photography', 'other'] as $slug) {
|
||||
@@ -168,6 +169,20 @@ it('legacy /category route falls back to /categories and preserves query string
|
||||
->assertStatus(301);
|
||||
});
|
||||
|
||||
it('legacy rate.php artwork route redirects to the canonical artwork URL with 301', function () {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'title' => 'Legacy Rated Artwork',
|
||||
'slug' => 'legacy-rated-artwork',
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$this->get('/rate.php?skins=' . $artwork->id)
|
||||
->assertRedirect(route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]))
|
||||
->assertStatus(301);
|
||||
});
|
||||
|
||||
it('GET /today-in-history redirects to /discover/on-this-day with 301', function () {
|
||||
$this->get('/today-in-history')->assertRedirect('/discover/on-this-day')->assertStatus(301);
|
||||
});
|
||||
|
||||
41
tests/Unit/AcademyCoursesSitemapBuilderTest.php
Normal file
41
tests/Unit/AcademyCoursesSitemapBuilderTest.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AcademyCourse;
|
||||
use App\Services\Sitemaps\Builders\AcademyCoursesSitemapBuilder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(Tests\TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('builds sitemap entries only for published academy courses', function (): void {
|
||||
config()->set('app.url', 'http://skinbase26.test');
|
||||
config()->set('academy.enabled', true);
|
||||
|
||||
AcademyCourse::query()->create([
|
||||
'title' => 'Published Academy Course',
|
||||
'slug' => 'published-academy-course',
|
||||
'excerpt' => 'Visible in sitemap.',
|
||||
'access_level' => 'free',
|
||||
'difficulty' => 'beginner',
|
||||
'status' => 'published',
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
AcademyCourse::query()->create([
|
||||
'title' => 'Draft Academy Course',
|
||||
'slug' => 'draft-academy-course',
|
||||
'excerpt' => 'Hidden from sitemap.',
|
||||
'access_level' => 'free',
|
||||
'difficulty' => 'beginner',
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
$items = app(AcademyCoursesSitemapBuilder::class)->items();
|
||||
$locations = array_map(static fn ($item) => $item->loc, $items);
|
||||
|
||||
expect($locations)
|
||||
->toContain(url('/academy/courses'))
|
||||
->toContain(url('/academy/courses/published-academy-course'))
|
||||
->not->toContain(url('/academy/courses/draft-academy-course'));
|
||||
});
|
||||
Reference in New Issue
Block a user