229 خطوط
15 KiB
PHP
229 خطوط
15 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Courses;
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Courses\Domain\Block;
|
|
use App\Modules\Courses\Domain\Course;
|
|
use App\Modules\Courses\Domain\CourseModule;
|
|
use App\Modules\Courses\Domain\CourseVersion;
|
|
use App\Modules\Courses\Domain\Enums\CourseVersionStatus;
|
|
use App\Modules\Courses\Domain\Lesson;
|
|
use App\Modules\Identity\Domain\Enums\UserRole;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Laravel\Sanctum\Sanctum;
|
|
use Tests\TestCase;
|
|
|
|
class CourseManagementTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
public function test_designer_selects_private_image_as_course_cover_and_usage_is_protected(): void
|
|
{
|
|
Storage::fake('local');
|
|
[$designer, $course] = $this->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];
|
|
}
|
|
}
|