create(['role' => UserRole::CourseDesigner]); Sanctum::actingAs($designer); $this->post('/api/v1/assets', ['file' => UploadedFile::fake()->create('training.mp4', 128 * 1024, 'video/mp4')], ['Accept' => 'application/json']) ->assertCreated() ->assertJsonPath('data.kind', 'video') ->assertJsonPath('data.size', 128 * 1024 * 1024); } public function test_designer_uploads_private_asset_lists_it_and_reads_signed_content(): void { Storage::fake('local'); $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); Sanctum::actingAs($designer); $response = $this->post('/api/v1/assets', ['file' => UploadedFile::fake()->image('safety.png', 800, 600), 'altText' => 'Safety worker'], ['Accept' => 'application/json']) ->assertCreated()->assertJsonPath('data.kind', 'image')->assertJsonPath('data.usageCount', 0); $assetId = $response->json('data.id'); $this->assertStringStartsWith('/api/v1/assets/', $response->json('data.contentUrl')); $this->assertDatabaseHas('assets', ['id' => $assetId, 'organization_id' => $designer->organization_id, 'original_name' => 'safety.png']); $this->getJson('/api/v1/assets?kind=image&search=safety')->assertOk()->assertJsonPath('data.0.id', $assetId); $this->getJson("/api/v1/assets/{$assetId}") ->assertOk() ->assertJsonPath('data.id', $assetId) ->assertJsonPath('data.name', 'safety.png'); $this->get($response->json('data.contentUrl'))->assertOk()->assertHeader('content-type', 'image/png'); } public function test_duplicate_upload_is_deduplicated_and_used_draft_asset_can_be_detached_and_deleted(): void { Storage::fake('local'); [$designer, $course, $version, $lesson] = $this->foundation(); Sanctum::actingAs($designer); $file = UploadedFile::fake()->createWithContent('manual.pdf', '%PDF-1.4 same content'); $assetId = $this->post('/api/v1/assets', ['file' => $file], ['Accept' => 'application/json'])->assertCreated()->json('data.id'); $this->post('/api/v1/assets', ['file' => UploadedFile::fake()->createWithContent('copy.pdf', '%PDF-1.4 same content')], ['Accept' => 'application/json'])->assertOk()->assertJsonPath('data.id', $assetId); $this->assertDatabaseCount('assets', 1); $blockId = $this->postJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/lessons/{$lesson->getKey()}/blocks", [ 'type' => 'document', 'schemaVersion' => 1, 'data' => ['assetId' => $assetId, 'title' => 'Manual', 'description' => ''], ])->assertCreated()->json('data.id'); $this->getJson('/api/v1/assets')->assertOk()->assertJsonPath('data.0.usageCount', 1); $this->deleteJson("/api/v1/assets/{$assetId}")->assertUnprocessable()->assertJsonValidationErrors('asset'); $this->deleteJson("/api/v1/assets/{$assetId}?detach=1")->assertNoContent(); $this->assertDatabaseMissing('assets', ['id' => $assetId]); $this->assertEquals( ['title' => 'Manual', 'assetId' => null, 'description' => null], Block::query()->findOrFail($blockId)->data, ); } public function test_asset_validation_authorization_and_tenant_references_are_enforced(): void { Storage::fake('local'); [$designer, $course, $version, $lesson] = $this->foundation(); Sanctum::actingAs($designer); $this->post('/api/v1/assets', ['file' => UploadedFile::fake()->create('payload.exe', 10, 'application/x-msdownload')], ['Accept' => 'application/json'])->assertUnprocessable()->assertJsonValidationErrors('file'); $other = User::factory()->create(['role' => UserRole::CourseDesigner]); $asset = Asset::query()->create(['organization_id' => $other->organization_id, 'uploaded_by' => $other->getKey(), 'kind' => 'image', 'original_name' => 'foreign.png', 'disk' => 'local', 'path' => 'foreign.png', 'mime_type' => 'image/png', 'size' => 10, 'sha256' => str_repeat('a', 64)]); $this->postJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/lessons/{$lesson->getKey()}/blocks", [ 'type' => 'image', 'schemaVersion' => 1, 'data' => ['assetId' => $asset->getKey(), 'url' => null, 'alt' => 'Foreign', 'caption' => '', 'decorative' => false], ])->assertUnprocessable()->assertJsonValidationErrors('data.assetId'); $this->deleteJson("/api/v1/assets/{$asset->getKey()}")->assertNotFound(); $this->getJson("/api/v1/assets/{$asset->getKey()}")->assertNotFound(); $manager = User::factory()->for($designer->organization)->create(['role' => UserRole::Manager]); Sanctum::actingAs($manager); $this->getJson('/api/v1/assets')->assertForbidden(); } /** @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' => 'Assets', 'slug' => 'assets', 'created_by' => $designer->getKey()]); $version = CourseVersion::query()->create(['organization_id' => $designer->organization_id, 'course_id' => $course->getKey(), 'version_number' => 1, 'status' => CourseVersionStatus::Draft, 'title' => 'Assets']); $module = CourseModule::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'title' => 'Module', 'position' => 1]); $lesson = Lesson::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'course_module_id' => $module->getKey(), 'title' => 'Lesson', 'position' => 1]); return [$designer, $course, $version, $lesson]; } }