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' => '

One

'], ])->json('data.id'); $this->patchJson("/api/v1/blocks/{$blockId}", [ 'expectedRevision' => 1, 'data' => ['html' => '

Two

'], ])->assertOk()->assertJsonPath('data.revision', 2); $this->patchJson("/api/v1/blocks/{$blockId}", [ 'expectedRevision' => 1, 'data' => ['html' => '

Stale

'], ])->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 = '

Guide

Use safe steps.

badgood'; $response = $this->postJson($this->blocksUrl($course, $version, $lesson), [ 'type' => 'text', 'schemaVersion' => 1, 'data' => ['html' => $html], ])->assertCreated(); $stored = $response->json('data.data.html'); $this->assertStringContainsString('

Guide

', $stored); $this->assertStringContainsString('safe', $stored); $this->assertStringContainsString('href="https://example.test"', $stored); $this->assertStringNotContainsString('onclick', $stored); $this->assertStringNotContainsString('javascript:', $stored); $this->assertStringNotContainsString('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]; } }