'Category', 'description' => null, 'type' => 'category', 'parent_id' => null, 'position' => 1, ]); $forum = Forum::create([ 'name' => 'Forum', 'description' => null, 'type' => 'forum', 'parent_id' => $category->id, 'position' => 1, ]); return Thread::create([ 'forum_id' => $forum->id, 'user_id' => $owner?->id, 'title' => 'Attachment Thread', 'body' => 'Thread Body', ]); } function makeAttachmentConfig(string $extension = 'pdf', array $mimes = ['application/pdf']): AttachmentExtension { $group = AttachmentGroup::create([ 'name' => 'Docs', 'max_size_kb' => 25600, 'is_active' => true, ]); return AttachmentExtension::create([ 'extension' => $extension, 'attachment_group_id' => $group->id, 'allowed_mimes' => $mimes, ]); } it('requires authentication to upload attachments', function (): void { Storage::fake('local'); makeAttachmentConfig(); $response = $this->postJson('/api/attachments', [ 'thread' => '/api/threads/1', 'file' => UploadedFile::fake()->create('doc.pdf', 10, 'application/pdf'), ]); $response->assertStatus(401); }); it('rejects uploads without thread or post', function (): void { Storage::fake('local'); makeAttachmentConfig(); $user = User::factory()->create(); Sanctum::actingAs($user); $response = $this->postJson('/api/attachments', [ 'file' => UploadedFile::fake()->create('doc.pdf', 10, 'application/pdf'), ]); $response->assertStatus(422); $response->assertJsonFragment(['message' => 'Provide either thread or post.']); }); it('rejects uploads with both thread and post', function (): void { Storage::fake('local'); makeAttachmentConfig(); $user = User::factory()->create(); $thread = makeThreadForAttachments($user); $post = Post::create([ 'thread_id' => $thread->id, 'user_id' => $user->id, 'body' => 'Post', ]); Sanctum::actingAs($user); $response = $this->postJson('/api/attachments', [ 'thread' => "/api/threads/{$thread->id}", 'post' => "/api/posts/{$post->id}", 'file' => UploadedFile::fake()->create('doc.pdf', 10, 'application/pdf'), ]); $response->assertStatus(422); $response->assertJsonFragment(['message' => 'Provide either thread or post.']); }); it('forbids uploads when user is not owner', function (): void { Storage::fake('local'); makeAttachmentConfig(); $owner = User::factory()->create(); $other = User::factory()->create(); $thread = makeThreadForAttachments($owner); Sanctum::actingAs($other); $response = $this->postJson('/api/attachments', [ 'thread' => "/api/threads/{$thread->id}", 'file' => UploadedFile::fake()->create('doc.pdf', 10, 'application/pdf'), ]); $response->assertStatus(403); }); it('stores attachment for a thread', function (): void { Storage::fake('local'); makeAttachmentConfig(); $user = User::factory()->create(); $thread = makeThreadForAttachments($user); Sanctum::actingAs($user); $response = $this->postJson('/api/attachments', [ 'thread' => "/api/threads/{$thread->id}", 'file' => UploadedFile::fake()->create('doc.pdf', 10, 'application/pdf'), ]); $response->assertStatus(201); $attachmentId = $response->json('id'); $attachment = Attachment::findOrFail($attachmentId); $this->assertDatabaseHas('attachments', [ 'id' => $attachment->id, 'thread_id' => $thread->id, 'user_id' => $user->id, 'extension' => 'pdf', ]); Storage::disk('local')->assertExists($attachment->path); }); it('filters attachments by thread', function (): void { Storage::fake('local'); makeAttachmentConfig(); $user = User::factory()->create(); $threadA = makeThreadForAttachments($user); $threadB = makeThreadForAttachments($user); Sanctum::actingAs($user); $response = $this->postJson('/api/attachments', [ 'thread' => "/api/threads/{$threadA->id}", 'file' => UploadedFile::fake()->create('doc.pdf', 10, 'application/pdf'), ]); $response->assertStatus(201); $attachmentId = $response->json('id'); $this->postJson('/api/attachments', [ 'thread' => "/api/threads/{$threadB->id}", 'file' => UploadedFile::fake()->create('doc.pdf', 10, 'application/pdf'), ])->assertStatus(201); $response = $this->getJson("/api/attachments?thread=/api/threads/{$threadA->id}"); $response->assertOk(); $response->assertJsonCount(1); $response->assertJsonFragment(['id' => $attachmentId]); }); it('returns 404 when parent thread is deleted', function (): void { Storage::fake('local'); makeAttachmentConfig(); $user = User::factory()->create(); $thread = makeThreadForAttachments($user); Sanctum::actingAs($user); $response = $this->postJson('/api/attachments', [ 'thread' => "/api/threads/{$thread->id}", 'file' => UploadedFile::fake()->create('doc.pdf', 10, 'application/pdf'), ]); $response->assertStatus(201); $attachmentId = $response->json('id'); $thread->delete(); $this->getJson("/api/attachments/{$attachmentId}") ->assertStatus(404); }); it('downloads attachment file when available', function (): void { Storage::fake('local'); makeAttachmentConfig(); $user = User::factory()->create(); $thread = makeThreadForAttachments($user); $attachment = Attachment::create([ 'thread_id' => $thread->id, 'post_id' => null, 'attachment_extension_id' => null, 'attachment_group_id' => null, 'user_id' => $user->id, 'disk' => 'local', 'path' => 'attachments/threads/'.$thread->id.'/file.pdf', 'original_name' => 'file.pdf', 'extension' => 'pdf', 'mime_type' => 'application/pdf', 'size_bytes' => 4, ]); Storage::disk('local')->put($attachment->path, 'data'); $response = $this->get("/api/attachments/{$attachment->id}/download"); $response->assertOk(); $response->assertHeader('content-type', 'application/pdf'); }); it('serves attachment thumbnail when present', function (): void { Storage::fake('local'); makeAttachmentConfig(); $user = User::factory()->create(); $thread = makeThreadForAttachments($user); $attachment = Attachment::create([ 'thread_id' => $thread->id, 'post_id' => null, 'attachment_extension_id' => null, 'attachment_group_id' => null, 'user_id' => $user->id, 'disk' => 'local', 'path' => 'attachments/threads/'.$thread->id.'/file.pdf', 'thumbnail_path' => 'attachments/threads/'.$thread->id.'/thumbs/thumb.jpg', 'thumbnail_mime_type' => 'image/jpeg', 'thumbnail_size_bytes' => 4, 'original_name' => 'file.pdf', 'extension' => 'pdf', 'mime_type' => 'application/pdf', 'size_bytes' => 4, ]); Storage::disk('local')->put($attachment->path, 'data'); Storage::disk('local')->put($attachment->thumbnail_path, 'thumb'); $response = $this->get("/api/attachments/{$attachment->id}/thumbnail"); $response->assertOk(); $response->assertHeader('content-type', 'image/jpeg'); }); it('soft deletes attachments when owner requests', function (): void { Storage::fake('local'); makeAttachmentConfig(); $user = User::factory()->create(); $thread = makeThreadForAttachments($user); $attachment = Attachment::create([ 'thread_id' => $thread->id, 'post_id' => null, 'attachment_extension_id' => null, 'attachment_group_id' => null, 'user_id' => $user->id, 'disk' => 'local', 'path' => 'attachments/threads/'.$thread->id.'/file.pdf', 'original_name' => 'file.pdf', 'extension' => 'pdf', 'mime_type' => 'application/pdf', 'size_bytes' => 4, ]); Sanctum::actingAs($user); $response = $this->deleteJson("/api/attachments/{$attachment->id}"); $response->assertStatus(204); $this->assertSoftDeleted('attachments', ['id' => $attachment->id]); });