'Category', 'description' => null, 'type' => 'category', 'parent_id' => null, 'position' => 1, ]); return Forum::create([ 'name' => 'Forum', 'description' => null, 'type' => 'forum', 'parent_id' => $category->id, 'position' => 1, ]); } it('store returns unauthorized without user', function (): void { $controller = new AttachmentController(); $request = Request::create('/api/attachments', 'POST'); $request->setUserResolver(fn () => null); $response = $controller->store($request); expect($response->getStatusCode())->toBe(401); }); it('store returns file missing when file not provided', function (): void { $controller = new AttachmentController(); $user = User::factory()->create(); $request = Request::create('/api/attachments', 'POST', [ 'thread' => '/api/threads/1', ]); $request->setUserResolver(fn () => $user); try { $controller->store($request); $this->fail('Expected ValidationException not thrown.'); } catch (Illuminate\Validation\ValidationException $e) { expect($e->errors())->toHaveKey('file'); } }); it('store returns file missing when request file is null after validation', function (): void { $controller = new AttachmentController(); $user = User::factory()->create(); $forum = makeForumForAttachments(); $thread = Thread::create([ 'forum_id' => $forum->id, 'user_id' => $user->id, 'title' => 'Thread', 'body' => 'Body', ]); $request = Mockery::mock(Request::class)->makePartial(); $request->shouldReceive('user')->andReturn($user); $request->shouldReceive('validate')->andReturn([ 'thread' => "/api/threads/{$thread->id}", 'post' => null, 'file' => 'ignored', ]); $request->shouldReceive('file')->andReturn(null); $response = $controller->store($request); expect($response->getStatusCode())->toBe(422); }); it('store rejects disallowed extension and mime and size', function (): void { Storage::fake('local'); $controller = new AttachmentController(); $user = User::factory()->create(); $forum = makeForumForAttachments(); $thread = Thread::create([ 'forum_id' => $forum->id, 'user_id' => $user->id, 'title' => 'Thread', 'body' => 'Body', ]); $group = AttachmentGroup::create([ 'name' => 'Docs', 'max_size_kb' => 1, 'is_active' => false, ]); $ext = AttachmentExtension::create([ 'extension' => 'pdf', 'attachment_group_id' => $group->id, 'allowed_mimes' => ['application/pdf'], ]); $file = UploadedFile::fake()->create('doc.pdf', 2, 'application/pdf'); $request = Request::create('/api/attachments', 'POST', [ 'thread' => "/api/threads/{$thread->id}", ], [], ['file' => $file]); $request->setUserResolver(fn () => $user); $response = $controller->store($request); expect($response->getStatusCode())->toBe(422); $group->is_active = true; $group->save(); $response = $controller->store($request); expect($response->getStatusCode())->toBe(422); $group->max_size_kb = 1000; $group->save(); $ext->allowed_mimes = ['image/png']; $ext->save(); $response = $controller->store($request); expect($response->getStatusCode())->toBe(422); $ext->allowed_mimes = ['application/pdf']; $ext->save(); $group->max_size_kb = 0; $group->save(); $response = $controller->store($request); expect($response->getStatusCode())->toBe(422); }); it('store returns forbidden when user cannot attach to post', function (): void { Storage::fake('local'); $controller = new AttachmentController(); $owner = User::factory()->create(); $viewer = User::factory()->create(); $forum = makeForumForAttachments(); $thread = Thread::create([ 'forum_id' => $forum->id, 'user_id' => $owner->id, 'title' => 'Thread', 'body' => 'Body', ]); $post = Post::create([ 'thread_id' => $thread->id, 'user_id' => $owner->id, 'body' => 'Post', ]); $group = AttachmentGroup::create([ 'name' => 'Images', 'max_size_kb' => 100, 'is_active' => true, ]); AttachmentExtension::create([ 'extension' => 'png', 'attachment_group_id' => $group->id, 'allowed_mimes' => ['image/png'], ]); $file = UploadedFile::fake()->image('photo.png'); $request = Request::create('/api/attachments', 'POST', [ 'post' => "/api/posts/{$post->id}", ], [], ['file' => $file]); $request->setUserResolver(fn () => $viewer); $response = $controller->store($request); expect($response->getStatusCode())->toBe(403); }); it('index filters by post id', function (): void { $controller = new AttachmentController(); $forum = makeForumForAttachments(); $thread = Thread::create([ 'forum_id' => $forum->id, 'user_id' => null, 'title' => 'Thread', 'body' => 'Body', ]); $postA = Post::create([ 'thread_id' => $thread->id, 'user_id' => null, 'body' => 'Post A', ]); $postB = Post::create([ 'thread_id' => $thread->id, 'user_id' => null, 'body' => 'Post B', ]); Attachment::create([ 'thread_id' => null, 'post_id' => $postA->id, 'attachment_extension_id' => null, 'attachment_group_id' => null, 'user_id' => null, 'disk' => 'local', 'path' => 'attachments/posts/'.$postA->id.'/a.txt', 'original_name' => 'a.txt', 'extension' => 'txt', 'mime_type' => 'text/plain', 'size_bytes' => 1, ]); Attachment::create([ 'thread_id' => null, 'post_id' => $postB->id, 'attachment_extension_id' => null, 'attachment_group_id' => null, 'user_id' => null, 'disk' => 'local', 'path' => 'attachments/posts/'.$postB->id.'/b.txt', 'original_name' => 'b.txt', 'extension' => 'txt', 'mime_type' => 'text/plain', 'size_bytes' => 1, ]); $request = Request::create('/api/attachments', 'GET', [ 'post' => "/api/posts/{$postA->id}", ]); $response = $controller->index($request); expect($response->getStatusCode())->toBe(200); $payload = $response->getData(true); expect(count($payload))->toBe(1); expect($payload[0]['post_id'])->toBe($postA->id); }); it('show returns not found when attachment is not viewable', function (): void { $controller = new AttachmentController(); $attachment = new Attachment([ 'disk' => 'local', 'path' => 'missing', ]); $attachment->setRawAttributes(['id' => 1, 'deleted_at' => now()]); $response = $controller->show($attachment); expect($response->getStatusCode())->toBe(404); }); it('show returns attachment when viewable', function (): void { $controller = new AttachmentController(); $forum = makeForumForAttachments(); $thread = Thread::create([ 'forum_id' => $forum->id, 'user_id' => null, 'title' => 'Thread', 'body' => 'Body', ]); $attachment = Attachment::create([ 'thread_id' => $thread->id, 'post_id' => null, 'attachment_extension_id' => null, 'attachment_group_id' => null, 'user_id' => null, 'disk' => 'local', 'path' => 'attachments/threads/'.$thread->id.'/file.pdf', 'original_name' => 'file.pdf', 'extension' => 'pdf', 'mime_type' => 'application/pdf', 'size_bytes' => 10, ]); $response = $controller->show($attachment); expect($response->getStatusCode())->toBe(200); }); it('download aborts when file missing or not viewable', function (): void { Storage::fake('local'); $controller = new AttachmentController(); $forum = makeForumForAttachments(); $thread = Thread::create([ 'forum_id' => $forum->id, 'user_id' => null, 'title' => 'Thread', 'body' => 'Body', ]); $attachment = Attachment::create([ 'thread_id' => $thread->id, 'post_id' => null, 'attachment_extension_id' => null, 'attachment_group_id' => null, 'user_id' => null, 'disk' => 'local', 'path' => 'attachments/threads/'.$thread->id.'/missing.pdf', 'original_name' => 'missing.pdf', 'extension' => 'pdf', 'mime_type' => 'application/pdf', 'size_bytes' => 10, ]); expect(fn () => $controller->download($attachment))->toThrow(HttpException::class); }); it('download aborts when attachment is not viewable', function (): void { Storage::fake('local'); $controller = new AttachmentController(); $attachment = Attachment::create([ 'thread_id' => null, 'post_id' => null, 'attachment_extension_id' => null, 'attachment_group_id' => null, 'user_id' => null, 'disk' => 'local', 'path' => 'attachments/threads/1/missing.pdf', 'original_name' => 'missing.pdf', 'extension' => 'pdf', 'mime_type' => 'application/pdf', 'size_bytes' => 10, ]); expect(fn () => $controller->download($attachment))->toThrow(HttpException::class); }); it('thumbnail aborts when missing path or file', function (): void { Storage::fake('local'); $controller = new AttachmentController(); $forum = makeForumForAttachments(); $thread = Thread::create([ 'forum_id' => $forum->id, 'user_id' => null, 'title' => 'Thread', 'body' => 'Body', ]); $attachment = Attachment::create([ 'thread_id' => $thread->id, 'post_id' => null, 'attachment_extension_id' => null, 'attachment_group_id' => null, 'user_id' => null, 'disk' => 'local', 'path' => 'attachments/threads/'.$thread->id.'/file.pdf', 'original_name' => 'file.pdf', 'extension' => 'pdf', 'mime_type' => 'application/pdf', 'size_bytes' => 10, ]); expect(fn () => $controller->thumbnail($attachment))->toThrow(HttpException::class); $attachment->thumbnail_path = 'attachments/threads/'.$thread->id.'/thumb.jpg'; $attachment->save(); expect(fn () => $controller->thumbnail($attachment))->toThrow(HttpException::class); }); it('thumbnail aborts when attachment is not viewable', function (): void { Storage::fake('local'); $controller = new AttachmentController(); $attachment = Attachment::create([ 'thread_id' => null, 'post_id' => null, 'attachment_extension_id' => null, 'attachment_group_id' => null, 'user_id' => null, 'disk' => 'local', 'path' => 'attachments/threads/1/file.pdf', 'thumbnail_path' => 'attachments/threads/1/thumb.jpg', 'original_name' => 'file.pdf', 'extension' => 'pdf', 'mime_type' => 'application/pdf', 'size_bytes' => 10, ]); expect(fn () => $controller->thumbnail($attachment))->toThrow(HttpException::class); }); it('destroy returns unauthorized or forbidden', function (): void { $controller = new AttachmentController(); $attachment = new Attachment(['user_id' => 999]); $request = Request::create('/api/attachments/1', 'DELETE'); $request->setUserResolver(fn () => null); $response = $controller->destroy($request, $attachment); expect($response->getStatusCode())->toBe(401); $user = User::factory()->create(); $request->setUserResolver(fn () => $user); $response = $controller->destroy($request, $attachment); expect($response->getStatusCode())->toBe(403); }); it('private helpers cover parse and match branches', function (): void { $controller = new AttachmentController(); $refThread = new ReflectionMethod($controller, 'parseThreadId'); $refThread->setAccessible(true); $refPost = new ReflectionMethod($controller, 'parsePostId'); $refPost->setAccessible(true); $refMatch = new ReflectionMethod($controller, 'matchesAllowed'); $refMatch->setAccessible(true); $refResolve = new ReflectionMethod($controller, 'resolveExtension'); $refResolve->setAccessible(true); expect($refThread->invoke($controller, null))->toBeNull(); expect($refThread->invoke($controller, '/threads/12'))->toBe(12); expect($refThread->invoke($controller, '5'))->toBe(5); expect($refThread->invoke($controller, 'abc'))->toBeNull(); expect($refPost->invoke($controller, null))->toBeNull(); expect($refPost->invoke($controller, '/posts/7'))->toBe(7); expect($refPost->invoke($controller, '9'))->toBe(9); expect($refPost->invoke($controller, 'nope'))->toBeNull(); expect($refMatch->invoke($controller, 'image/png', null))->toBeTrue(); expect($refMatch->invoke($controller, 'image/png', []))->toBeTrue(); expect($refMatch->invoke($controller, 'image/png', ['image/jpeg']))->toBeFalse(); expect($refMatch->invoke($controller, 'image/png', ['image/png']))->toBeTrue(); expect($refResolve->invoke($controller, ''))->toBeNull(); }); it('canViewAttachment handles trashed and missing parents', function (): void { $controller = new AttachmentController(); $forum = makeForumForAttachments(); $thread = Thread::create([ 'forum_id' => $forum->id, 'user_id' => null, 'title' => 'Thread', 'body' => 'Body', ]); $attachment = Attachment::create([ 'thread_id' => $thread->id, 'post_id' => null, 'attachment_extension_id' => null, 'attachment_group_id' => null, 'user_id' => null, 'disk' => 'local', 'path' => 'attachments/threads/'.$thread->id.'/file.pdf', 'original_name' => 'file.pdf', 'extension' => 'pdf', 'mime_type' => 'application/pdf', 'size_bytes' => 10, ]); $refView = new ReflectionMethod($controller, 'canViewAttachment'); $refView->setAccessible(true); expect($refView->invoke($controller, $attachment))->toBeTrue(); $attachment->delete(); expect($refView->invoke($controller, $attachment))->toBeFalse(); $attachment->restore(); $thread->delete(); expect($refView->invoke($controller, $attachment))->toBeFalse(); $thread->restore(); $post = Post::create([ 'thread_id' => $thread->id, 'user_id' => null, 'body' => 'Post', ]); $attachment->post_id = $post->id; $attachment->thread_id = null; $attachment->save(); expect($refView->invoke($controller, $attachment))->toBeTrue(); $post->delete(); expect($refView->invoke($controller, $attachment))->toBeFalse(); $attachment->post_id = null; $attachment->thread_id = null; $attachment->save(); expect($refView->invoke($controller, $attachment))->toBeFalse(); }); it('serializeAttachment returns thumbnail_url null when missing', function (): void { $controller = new AttachmentController(); $attachment = new Attachment([ 'id' => 1, 'thread_id' => null, 'post_id' => null, 'extension' => 'pdf', 'original_name' => 'file.pdf', 'mime_type' => 'application/pdf', 'size_bytes' => 10, ]); $ref = new ReflectionMethod($controller, 'serializeAttachment'); $ref->setAccessible(true); $payload = $ref->invoke($controller, $attachment); expect($payload['thumbnail_url'])->toBeNull(); expect($payload['is_image'])->toBeFalse(); }); it('serializeAttachment includes thumbnail url when present', function (): void { $controller = new AttachmentController(); $attachment = new Attachment([ 'id' => 2, 'thread_id' => null, 'post_id' => null, 'extension' => 'png', 'original_name' => 'file.png', 'mime_type' => 'image/png', 'thumbnail_path' => 'thumbs/file.png', 'size_bytes' => 10, ]); $ref = new ReflectionMethod($controller, 'serializeAttachment'); $ref->setAccessible(true); $payload = $ref->invoke($controller, $attachment); expect($payload['thumbnail_url'])->toContain('/thumbnail'); expect($payload['is_image'])->toBeTrue(); }); it('canManageAttachments handles null user and admin', function (): void { $controller = new AttachmentController(); $ref = new ReflectionMethod($controller, 'canManageAttachments'); $ref->setAccessible(true); expect($ref->invoke($controller, null, 1))->toBeFalse(); $admin = User::factory()->create(); $role = \App\Models\Role::create(['name' => 'ROLE_ADMIN']); $admin->roles()->attach($role); expect($ref->invoke($controller, $admin, 999))->toBeTrue(); });