Add comprehensive test coverage and update notes
Some checks failed
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Failing after 15s

This commit is contained in:
2026-02-08 19:04:12 +01:00
parent 160430e128
commit 88e4a70f88
43 changed files with 6114 additions and 520 deletions

View File

@@ -0,0 +1,555 @@
<?php
use App\Http\Controllers\AttachmentController;
use App\Models\Attachment;
use App\Models\AttachmentExtension;
use App\Models\AttachmentGroup;
use App\Models\Forum;
use App\Models\Post;
use App\Models\Thread;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpKernel\Exception\HttpException;
function makeForumForAttachments(): Forum
{
$category = Forum::create([
'name' => '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();
});