221 خطوط
13 KiB
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];
|
|
}
|
|
}
|