foundation(); Sanctum::actingAs($designer); $assetId = $this->post('/api/v1/assets', ['file' => UploadedFile::fake()->image('cover.jpg', 800, 450)], ['Accept' => 'application/json']) ->assertCreated()->json('data.id'); $this->patchJson('/api/v1/courses/'.$course->getKey(), ['coverAssetId' => $assetId]) ->assertOk()->assertJsonPath('data.cover.assetId', $assetId); $this->getJson('/api/v1/courses')->assertOk()->assertJsonPath('data.0.cover.assetId', $assetId); $this->getJson('/api/v1/courses/'.$course->getKey())->assertOk()->assertJsonPath('data.course.cover.assetId', $assetId); $this->deleteJson('/api/v1/assets/'.$assetId)->assertUnprocessable()->assertJsonValidationErrors('asset'); } public function test_blank_course_creation_is_atomic_and_list_is_searchable_filterable_and_safe(): void { $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); Sanctum::actingAs($designer); $courseId = $this->postJson('/api/v1/courses', [ 'title' => 'ایمنی محیط کار', 'description' => 'دوره پایه', 'language' => 'fa', 'difficulty' => 'beginner', ])->assertCreated()->json('data.id'); $version = CourseVersion::query()->where('course_id', $courseId)->firstOrFail(); $this->assertSame(1, $version->version_number); $this->assertSame(CourseVersionStatus::Draft, $version->status); $this->assertDatabaseCount('course_modules', 1); $this->assertDatabaseCount('lessons', 1); $response = $this->getJson('/api/v1/courses?search=ایمنی&status=draft&sort=title&direction=asc') ->assertOk()->assertJsonPath('meta.total', 1)->assertJsonPath('data.0.id', $courseId) ->assertJsonPath('data.0.versionNumber', 1)->assertJsonPath('data.0.moduleCount', 1) ->assertJsonPath('data.0.lessonCount', 1); $this->assertArrayNotHasKey('settings', $response->json('data.0')); } public function test_workspace_exposes_ordered_structure_history_and_draft_metadata_updates(): void { [$designer, $course, $version, $module, $lesson] = $this->foundation(); Sanctum::actingAs($designer); $this->patchJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}", [ 'title' => 'Safety Updated', 'description' => 'New description', 'language' => 'en', ])->assertOk()->assertJsonPath('data.title', 'Safety Updated')->assertJsonPath('data.settings.language', 'en'); $lesson->update(['settings' => ['durationMinutes' => 12, 'description' => 'PPE basics', 'status' => 'ready', 'prerequisites' => ['Introduction']]]); Block::query()->create([ 'organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'lesson_id' => $lesson->getKey(), 'type' => 'video', 'schema_version' => 1, 'data' => ['assetId' => 'video-1'], 'position' => 1, 'revision' => 1, ]); $this->getJson('/api/v1/courses/'.$course->getKey()) ->assertOk()->assertJsonPath('data.course.title', 'Safety Updated') ->assertJsonPath('data.version.id', $version->getKey()) ->assertJsonPath('data.modules.0.id', $module->getKey()) ->assertJsonPath('data.modules.0.lessons.0.id', $lesson->getKey()) ->assertJsonPath('data.modules.0.lessons.0.contentType', 'video') ->assertJsonPath('data.modules.0.lessons.0.durationMinutes', 12) ->assertJsonPath('data.modules.0.lessons.0.description', 'PPE basics') ->assertJsonPath('data.modules.0.lessons.0.assetCount', 1) ->assertJsonPath('data.modules.0.lessons.0.prerequisites.0', 'Introduction') ->assertJsonPath('data.versions.0.number', 1); } public function test_designer_can_bookmark_and_archive_a_course(): void { [$designer, $course, $version] = $this->foundation(); Sanctum::actingAs($designer); $this->postJson('/api/v1/courses/'.$course->getKey().'/bookmark')->assertOk()->assertJsonPath('data.bookmarked', true); $this->getJson('/api/v1/courses')->assertOk()->assertJsonPath('data.0.bookmarked', true)->assertJsonPath('data.0.versionId', $version->getKey()); $this->postJson('/api/v1/courses/'.$course->getKey().'/bookmark')->assertOk()->assertJsonPath('data.bookmarked', false); $this->postJson('/api/v1/courses/'.$course->getKey().'/archive')->assertOk()->assertJsonPath('data.status', 'archived'); $this->getJson('/api/v1/courses')->assertOk()->assertJsonCount(0, 'data'); $this->getJson('/api/v1/courses?status=archived')->assertOk()->assertJsonPath('data.0.id', $course->getKey()); } public function test_designer_can_manage_and_reorder_modules_and_lessons_with_contiguous_positions(): void { [$designer, , $version, $firstModule, $firstLesson] = $this->foundation(); Sanctum::actingAs($designer); $secondModule = $this->postJson("/api/v1/course-versions/{$version->getKey()}/modules", ['title' => 'Advanced'])->assertCreated()->json('data.id'); $secondLesson = $this->postJson("/api/v1/course-modules/{$firstModule->getKey()}/lessons", ['title' => 'Hazards'])->assertCreated()->json('data.id'); $this->putJson("/api/v1/course-versions/{$version->getKey()}/modules/order", ['ids' => [$secondModule, $firstModule->getKey()]])->assertOk(); $this->putJson("/api/v1/course-modules/{$firstModule->getKey()}/lessons/order", ['ids' => [$secondLesson, $firstLesson->getKey()]])->assertOk(); $this->patchJson('/api/v1/lessons/'.$secondLesson, ['moduleId' => $secondModule, 'title' => 'Moved hazards'])->assertOk()->assertJsonPath('data.position', 1); $this->assertSame([1, 2], CourseModule::query()->where('course_version_id', $version->getKey())->orderBy('position')->pluck('position')->all()); $this->assertSame([1], Lesson::query()->where('course_module_id', $firstModule->getKey())->pluck('position')->all()); $this->deleteJson('/api/v1/lessons/'.$firstLesson->getKey())->assertNoContent(); $this->deleteJson('/api/v1/course-modules/'.$firstModule->getKey())->assertNoContent(); $this->assertSame([1], CourseModule::query()->where('course_version_id', $version->getKey())->pluck('position')->all()); } public function test_published_structure_is_immutable_and_fork_copies_content_once(): void { [$designer, $course, $version, $module, $lesson] = $this->foundation(); Block::query()->create([ 'organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'lesson_id' => $lesson->getKey(), 'type' => 'heading', 'schema_version' => 1, 'data' => ['text' => 'Hello', 'level' => 2], 'position' => 1, 'revision' => 4, ]); $version->update(['status' => CourseVersionStatus::Published, 'published_at' => now()]); Sanctum::actingAs($designer); $this->patchJson('/api/v1/course-modules/'.$module->getKey(), ['title' => 'Forbidden'])->assertUnprocessable()->assertJsonValidationErrors('version'); $draftId = $this->postJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/fork") ->assertCreated()->assertJsonPath('data.number', 2)->assertJsonPath('data.status', 'draft')->json('data.id'); $this->postJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/fork") ->assertCreated()->assertJsonPath('data.id', $draftId); $this->assertDatabaseCount('course_versions', 2); $this->assertDatabaseHas('course_versions', ['id' => $draftId, 'source_version_id' => $version->getKey()]); $this->assertSame(1, CourseModule::query()->where('course_version_id', $draftId)->count()); $this->assertSame(1, Lesson::query()->where('course_version_id', $draftId)->count()); $copied = Block::query()->where('course_version_id', $draftId)->firstOrFail(); $this->assertSame(1, $copied->revision); $this->assertNotSame($lesson->getKey(), $copied->lesson_id); } public function test_version_history_is_paginated_comparable_and_keeps_published_versions_read_only(): void { [$designer, $course, $version, $module] = $this->foundation(); $version->update(['status' => CourseVersionStatus::Published, 'published_at' => now(), 'published_by' => $designer->getKey()]); Sanctum::actingAs($designer); $draftId = $this->postJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/fork")->assertCreated()->json('data.id'); CourseModule::query()->where('course_version_id', $draftId)->where('position', $module->position)->update(['title' => 'Updated basics']); $this->getJson("/api/v1/courses/{$course->getKey()}/versions?perPage=1") ->assertOk()->assertJsonPath('data.summary.total', 2)->assertJsonPath('data.summary.published', 1) ->assertJsonPath('data.summary.drafts', 1)->assertJsonPath('data.items.0.id', $draftId) ->assertJsonPath('data.items.0.actor.name', $designer->name)->assertJsonPath('data.meta.lastPage', 2); $this->getJson("/api/v1/courses/{$course->getKey()}/versions/compare?ids[]={$version->getKey()}&ids[]={$draftId}") ->assertOk()->assertJsonPath('data.compatible', true)->assertJsonPath('data.categories.0.key', 'structure') ->assertJsonPath('data.categories.0.changed', true); $this->patchJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}", ['title' => 'Forbidden'])->assertUnprocessable(); } public function test_module_and_lesson_duplication_copy_nested_draft_content(): void { [$designer, , $version, $module, $lesson] = $this->foundation(); Block::query()->create([ 'organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'lesson_id' => $lesson->getKey(), 'type' => 'heading', 'schema_version' => 1, 'data' => ['text' => 'Source', 'level' => 2], 'position' => 1, 'revision' => 7, ]); Sanctum::actingAs($designer); $moduleCopy = $this->postJson("/api/v1/course-modules/{$module->getKey()}/duplicate") ->assertCreated()->assertJsonPath('data.position', 2)->json('data.id'); $this->assertSame(1, Lesson::query()->where('course_module_id', $moduleCopy)->count()); $copiedLessonIds = Lesson::query()->where('course_module_id', $moduleCopy)->pluck('id'); $this->assertSame(1, Block::query()->whereIn('lesson_id', $copiedLessonIds)->count()); $lessonCopy = $this->postJson("/api/v1/lessons/{$lesson->getKey()}/duplicate") ->assertCreated()->assertJsonPath('data.position', 2)->json('data.id'); $copiedBlock = Block::query()->where('lesson_id', $lessonCopy)->firstOrFail(); $this->assertSame(1, $copiedBlock->revision); $this->assertSame(['text' => 'Source', 'level' => 2], $copiedBlock->data); } public function test_locked_structure_rejects_mutation_and_can_be_explicitly_unlocked(): void { [$designer, , $version, $module, $lesson] = $this->foundation(); Sanctum::actingAs($designer); $this->patchJson("/api/v1/course-modules/{$module->getKey()}", ['locked' => true])->assertOk()->assertJsonPath('data.locked', true); $this->postJson("/api/v1/course-modules/{$module->getKey()}/lessons", ['title' => 'No'])->assertUnprocessable()->assertJsonValidationErrors('locked'); $this->patchJson("/api/v1/lessons/{$lesson->getKey()}", ['title' => 'No'])->assertUnprocessable()->assertJsonValidationErrors('locked'); $this->postJson("/api/v1/course-modules/{$module->getKey()}/duplicate")->assertUnprocessable()->assertJsonValidationErrors('locked'); $this->putJson("/api/v1/course-versions/{$version->getKey()}/modules/order", ['ids' => [$module->getKey()]])->assertUnprocessable()->assertJsonValidationErrors('locked'); $this->deleteJson("/api/v1/course-modules/{$module->getKey()}")->assertUnprocessable()->assertJsonValidationErrors('locked'); $this->patchJson("/api/v1/course-modules/{$module->getKey()}", ['locked' => false])->assertOk()->assertJsonPath('data.locked', false); $this->patchJson("/api/v1/lessons/{$lesson->getKey()}", ['locked' => true])->assertOk(); $this->deleteJson("/api/v1/lessons/{$lesson->getKey()}")->assertUnprocessable()->assertJsonValidationErrors('locked'); } public function test_course_resources_are_tenant_scoped_and_manager_cannot_author(): void { [$designer, $course, $version, $module, $lesson] = $this->foundation(); $foreignDesigner = User::factory()->create(['role' => UserRole::CourseDesigner]); Sanctum::actingAs($foreignDesigner); $this->getJson('/api/v1/courses/'.$course->getKey())->assertNotFound(); $this->patchJson('/api/v1/course-modules/'.$module->getKey(), ['title' => 'Foreign'])->assertNotFound(); $this->patchJson('/api/v1/lessons/'.$lesson->getKey(), ['title' => 'Foreign'])->assertNotFound(); $this->postJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/fork")->assertNotFound(); $manager = User::factory()->for($designer->organization)->create(['role' => UserRole::Manager]); Sanctum::actingAs($manager); $this->getJson('/api/v1/courses')->assertForbidden(); $this->getJson("/api/v1/courses/{$course->getKey()}/versions")->assertForbidden(); $this->postJson('/api/v1/courses', ['title' => 'No', 'language' => 'fa'])->assertForbidden(); } /** @return array{User, Course, CourseVersion, CourseModule, Lesson} */ private function foundation(): array { $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); $course = Course::query()->create(['organization_id' => $designer->organization_id, 'title' => 'Safety', 'slug' => 'safety', 'status' => 'draft', 'created_by' => $designer->getKey()]); $version = CourseVersion::query()->create(['organization_id' => $designer->organization_id, 'course_id' => $course->getKey(), 'created_by' => $designer->getKey(), 'version_number' => 1, 'status' => CourseVersionStatus::Draft, 'title' => 'Safety', 'settings' => ['language' => 'fa']]); $module = CourseModule::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'title' => 'Basics', 'position' => 1]); $lesson = Lesson::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'course_module_id' => $module->getKey(), 'title' => 'PPE', 'position' => 1]); return [$designer, $course, $version, $module, $lesson]; } }