New_Micro_Learning/backend/tests/Feature/Courses/CourseBuilderTest.php

221 خطوط
13 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 Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class CourseBuilderTest extends TestCase
{
use RefreshDatabase;
public function test_designer_can_read_registry_and_create_a_valid_block(): void
{
[$designer, $course, $version, $lesson] = $this->foundation();
Sanctum::actingAs($designer);
$this->getJson('/api/v1/block-registry')->assertOk()->assertJsonFragment(['type' => 'single_choice']);
$this->postJson($this->blocksUrl($course, $version, $lesson), [
'type' => 'heading', 'schemaVersion' => 1, 'data' => ['text' => 'Welcome', 'level' => 2],
])->assertCreated()->assertJsonPath('data.revision', 1)->assertJsonPath('data.position', 1);
}
public function test_registry_rejects_unknown_type_and_invalid_payload(): void
{
[$designer, $course, $version, $lesson] = $this->foundation();
Sanctum::actingAs($designer);
$this->postJson($this->blocksUrl($course, $version, $lesson), [
'type' => 'unknown', 'schemaVersion' => 1, 'data' => ['value' => true],
])->assertUnprocessable()->assertJsonValidationErrors('type');
$this->postJson($this->blocksUrl($course, $version, $lesson), [
'type' => 'heading', 'schemaVersion' => 1, 'data' => ['text' => '', 'level' => 9],
])->assertUnprocessable()->assertJsonValidationErrors(['text', 'level']);
$this->postJson($this->blocksUrl($course, $version, $lesson), [
'type' => 'heading', 'schemaVersion' => 1, 'data' => ['text' => 'Valid', 'level' => 2, 'script' => 'unknown'],
])->assertUnprocessable()->assertJsonValidationErrors('data.script');
}
public function test_registry_exposes_complete_phase_six_contract_and_defaults_validate(): void
{
[$designer, $course, $version, $lesson] = $this->foundation();
Sanctum::actingAs($designer);
$registry = $this->getJson('/api/v1/block-registry')->assertOk()->json('data');
$required = ['heading', 'text', 'quote', 'key_point', 'divider', 'button', 'image', 'gallery', 'video', 'audio', 'document', 'embed', 'flashcard', 'accordion', 'tabs', 'timeline', 'steps', 'process', 'checklist', 'section', 'columns', 'controlled_grid'];
$this->assertEqualsCanonicalizing($required, collect($registry)->whereNotIn('category', ['assessment'])->pluck('type')->all());
foreach ($registry as $definition) {
$this->assertArrayHasKey('webBehavior', $definition);
$this->assertArrayHasKey('exportCompatibility', $definition);
$this->postJson($this->blocksUrl($course, $version, $lesson), [
'type' => $definition['type'], 'schemaVersion' => $definition['schemaVersion'], 'data' => $definition['defaultData'],
])->assertCreated();
}
}
public function test_block_contract_tokens_persist_and_lock_prevents_mutation_until_unlock(): void
{
[$designer, $course, $version, $lesson] = $this->foundation();
Sanctum::actingAs($designer);
$block = $this->postJson($this->blocksUrl($course, $version, $lesson), [
'type' => 'quote', 'schemaVersion' => 1, 'data' => ['text' => 'Safe work', 'cite' => 'Team'],
'style' => ['alignment' => 'center', 'width' => 'narrow', 'spacing' => 'l', 'background' => 'accent', 'border' => 'subtle', 'radius' => 'l', 'fontSize' => 18, 'fontWeight' => 600, 'textColor' => '#1f2937', 'lineHeight' => 1.8, 'maxWidth' => 760, 'aspectRatio' => '16/9', 'objectFit' => 'cover'],
'behavior' => ['hidden' => false, 'completion' => 'view', 'animation' => 'fade', 'locked' => true],
'responsive' => ['mobileStack' => true, 'mobileOrder' => 'logical'],
'accessibility' => ['label' => 'Safety quote', 'decorative' => false],
])->assertCreated()->assertJsonPath('data.style.width', 'narrow')->assertJsonPath('data.behavior.locked', true)->json('data');
$this->patchJson("/api/v1/blocks/{$block['id']}", ['expectedRevision' => 1, 'data' => ['text' => 'Changed', 'cite' => 'Team']])->assertUnprocessable()->assertJsonValidationErrors('locked');
$this->putJson("/api/v1/lessons/{$lesson->getKey()}/blocks/order", ['blockIds' => [$block['id']]])->assertUnprocessable()->assertJsonValidationErrors('locked');
$this->patchJson("/api/v1/blocks/{$block['id']}", [
'expectedRevision' => 1, 'data' => $block['data'], 'style' => $block['style'],
'behavior' => [...$block['behavior'], 'locked' => false], 'responsive' => $block['responsive'], 'accessibility' => $block['accessibility'],
])->assertOk()->assertJsonPath('data.behavior.locked', false);
}
public function test_autosave_uses_optimistic_revision_control(): void
{
[$designer, $course, $version, $lesson] = $this->foundation();
Sanctum::actingAs($designer);
$blockId = $this->postJson($this->blocksUrl($course, $version, $lesson), [
'type' => 'text', 'schemaVersion' => 1, 'data' => ['html' => '<p>One</p>'],
])->json('data.id');
$this->patchJson("/api/v1/blocks/{$blockId}", [
'expectedRevision' => 1, 'data' => ['html' => '<p>Two</p>'],
])->assertOk()->assertJsonPath('data.revision', 2);
$this->patchJson("/api/v1/blocks/{$blockId}", [
'expectedRevision' => 1, 'data' => ['html' => '<p>Stale</p>'],
])->assertConflict()->assertJsonPath('error.code', 'revision_conflict');
}
public function test_rich_text_is_sanitized_before_storage_without_losing_supported_formatting(): void
{
[$designer, $course, $version, $lesson] = $this->foundation();
Sanctum::actingAs($designer);
$html = '<h2 onclick="alert(1)">Guide</h2><p>Use <strong>safe</strong> steps.</p><a href="javascript:alert(1)">bad</a><a href="https://example.test">good</a><script>alert(1)</script>';
$response = $this->postJson($this->blocksUrl($course, $version, $lesson), [
'type' => 'text', 'schemaVersion' => 1, 'data' => ['html' => $html],
])->assertCreated();
$stored = $response->json('data.data.html');
$this->assertStringContainsString('<h2>Guide</h2>', $stored);
$this->assertStringContainsString('<strong>safe</strong>', $stored);
$this->assertStringContainsString('href="https://example.test"', $stored);
$this->assertStringNotContainsString('onclick', $stored);
$this->assertStringNotContainsString('javascript:', $stored);
$this->assertStringNotContainsString('<script', $stored);
}
public function test_reorder_is_atomic_and_requires_the_complete_lesson_set(): void
{
[$designer, $course, $version, $lesson] = $this->foundation();
Sanctum::actingAs($designer);
$first = $this->createBlock($course, $version, $lesson, 'First');
$second = $this->createBlock($course, $version, $lesson, 'Second');
$this->putJson("/api/v1/lessons/{$lesson->getKey()}/blocks/order", ['blockIds' => [$second, $first]])
->assertOk()->assertJsonPath('data.blockIds.0', $second);
$this->assertSame([$second, $first], Block::query()->orderBy('position')->pluck('id')->all());
$this->putJson("/api/v1/lessons/{$lesson->getKey()}/blocks/order", ['blockIds' => [$first]])
->assertUnprocessable()->assertJsonValidationErrors('blockIds');
}
public function test_blocks_can_be_inserted_and_duplicated_without_position_gaps(): void
{
[$designer, $course, $version, $lesson] = $this->foundation();
Sanctum::actingAs($designer);
$first = $this->createBlock($course, $version, $lesson, 'First');
$third = $this->createBlock($course, $version, $lesson, 'Third');
$second = $this->postJson($this->blocksUrl($course, $version, $lesson), [
'type' => 'heading', 'schemaVersion' => 1, 'data' => ['text' => 'Second', 'level' => 2], 'insertionPosition' => 2,
])->assertCreated()->assertJsonPath('data.position', 2)->json('data.id');
$copy = $this->postJson("/api/v1/blocks/{$second}/duplicate")
->assertCreated()->assertJsonPath('data.position', 3)->assertJsonPath('data.revision', 1)->json('data.id');
$this->assertSame([$first, $second, $copy, $third], Block::query()->where('lesson_id', $lesson->getKey())->orderBy('position')->pluck('id')->all());
$this->assertSame([1, 2, 3, 4], Block::query()->where('lesson_id', $lesson->getKey())->orderBy('position')->pluck('position')->all());
}
public function test_structure_locks_protect_block_mutations_until_unlocked(): void
{
[$designer, $course, $version, $lesson] = $this->foundation();
Sanctum::actingAs($designer);
$block = $this->createBlock($course, $version, $lesson, 'Locked');
$this->patchJson("/api/v1/lessons/{$lesson->getKey()}", ['locked' => true])->assertOk()->assertJsonPath('data.locked', true);
$this->patchJson("/api/v1/blocks/{$block}", ['expectedRevision' => 1, 'data' => ['text' => 'No', 'level' => 2]])->assertUnprocessable()->assertJsonValidationErrors('locked');
$this->postJson("/api/v1/blocks/{$block}/duplicate")->assertUnprocessable()->assertJsonValidationErrors('locked');
$this->deleteJson("/api/v1/blocks/{$block}")->assertUnprocessable()->assertJsonValidationErrors('locked');
$this->patchJson("/api/v1/lessons/{$lesson->getKey()}", ['locked' => false])->assertOk()->assertJsonPath('data.locked', false);
$this->postJson("/api/v1/blocks/{$block}/duplicate")->assertCreated();
}
public function test_published_versions_are_readable_but_not_editable(): void
{
[$designer, $course, $version, $lesson] = $this->foundation();
Sanctum::actingAs($designer);
$version->update(['status' => CourseVersionStatus::Published, 'published_at' => now()]);
$this->getJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/lessons/{$lesson->getKey()}/builder")
->assertOk()->assertJsonPath('data.version.status', 'published');
$this->postJson($this->blocksUrl($course, $version, $lesson), [
'type' => 'heading', 'schemaVersion' => 1, 'data' => ['text' => 'No', 'level' => 2],
])->assertUnprocessable()->assertJsonValidationErrors('version');
}
public function test_builder_resources_are_tenant_isolated_and_manager_cannot_author(): void
{
[$designer, $course, $version, $lesson] = $this->foundation();
$other = User::factory()->create(['role' => UserRole::CourseDesigner]);
Sanctum::actingAs($other);
$this->getJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/lessons/{$lesson->getKey()}/builder")->assertNotFound();
$manager = User::factory()->create(['organization_id' => $designer->organization_id, 'role' => UserRole::Manager]);
Sanctum::actingAs($manager);
$this->getJson('/api/v1/block-registry')->assertForbidden();
}
private function createBlock(Course $course, CourseVersion $version, Lesson $lesson, string $text): string
{
return $this->postJson($this->blocksUrl($course, $version, $lesson), [
'type' => 'heading', 'schemaVersion' => 1, 'data' => ['text' => $text, 'level' => 2],
])->assertCreated()->json('data.id');
}
private function blocksUrl(Course $course, CourseVersion $version, Lesson $lesson): string
{
return "/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/lessons/{$lesson->getKey()}/blocks";
}
/** @return array{User, Course, CourseVersion, 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', 'created_by' => $designer->getKey()]);
$version = CourseVersion::query()->create(['organization_id' => $designer->organization_id, 'course_id' => $course->getKey(), 'version_number' => 1, 'status' => CourseVersionStatus::Draft, 'title' => 'Safety']);
$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, $lesson];
}
}