Add extensive controller and model tests
All checks were successful
CI/CD Pipeline / test (push) Successful in 10s
CI/CD Pipeline / deploy (push) Successful in 25s

This commit is contained in:
2026-02-07 22:14:42 +01:00
parent 9c60a8944e
commit 160430e128
39 changed files with 3941 additions and 1 deletions

View File

@@ -0,0 +1,33 @@
<?php
use App\Models\AttachmentExtension;
use App\Models\AttachmentGroup;
it('exposes attachment group hierarchy and extensions', function (): void {
$parent = AttachmentGroup::create([
'name' => 'Parent',
'max_size_kb' => 100,
'is_active' => true,
]);
$child = AttachmentGroup::create([
'name' => 'Child',
'parent_id' => $parent->id,
'position' => 1,
'max_size_kb' => 100,
'is_active' => true,
]);
$extension = AttachmentExtension::create([
'extension' => 'png',
'attachment_group_id' => $parent->id,
'allowed_mimes' => ['image/png'],
]);
$parent->load(['children', 'extensions']);
$child->load('parent');
expect($parent->children->first()->id)->toBe($child->id);
expect($parent->extensions->first()->id)->toBe($extension->id);
expect($child->parent?->id)->toBe($parent->id);
});

View File

@@ -0,0 +1,72 @@
<?php
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;
it('exposes attachment relationships', function (): void {
$category = Forum::create([
'name' => '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,
]);
$user = User::factory()->create();
$thread = Thread::create([
'forum_id' => $forum->id,
'user_id' => $user->id,
'title' => 'Thread',
'body' => 'Body',
]);
$post = Post::create([
'thread_id' => $thread->id,
'user_id' => $user->id,
'body' => 'Post',
]);
$group = AttachmentGroup::create([
'name' => 'Docs',
'max_size_kb' => 100,
'is_active' => true,
]);
$extension = AttachmentExtension::create([
'extension' => 'pdf',
'attachment_group_id' => $group->id,
'allowed_mimes' => ['application/pdf'],
]);
$attachment = Attachment::create([
'thread_id' => $thread->id,
'post_id' => $post->id,
'attachment_extension_id' => $extension->id,
'attachment_group_id' => $group->id,
'user_id' => $user->id,
'disk' => 'local',
'path' => 'attachments/posts/'.$post->id.'/file.pdf',
'original_name' => 'file.pdf',
'extension' => 'pdf',
'mime_type' => 'application/pdf',
'size_bytes' => 10,
]);
$attachment->load(['thread', 'post', 'extension', 'group', 'user']);
expect($attachment->thread?->id)->toBe($thread->id);
expect($attachment->post?->id)->toBe($post->id);
expect($attachment->extension()->first()?->id)->toBe($extension->id);
expect($attachment->group()->first()?->id)->toBe($group->id);
expect($attachment->user?->id)->toBe($user->id);
});

View File

@@ -0,0 +1,214 @@
<?php
namespace App\Services {
if (!isset($GLOBALS['attachment_thumbnail_overrides'])) {
$GLOBALS['attachment_thumbnail_overrides'] = [
'force_imagecreatetruecolor_fail' => false,
'force_imagejpeg_fail' => false,
'force_imagecreatefromjpeg_fail' => false,
'fake_getimagesize' => null,
];
}
if (!function_exists(__NAMESPACE__ . '\\imagecreatetruecolor')) {
function imagecreatetruecolor($width, $height)
{
if (!empty($GLOBALS['attachment_thumbnail_overrides']['force_imagecreatetruecolor_fail'])) {
return false;
}
return \imagecreatetruecolor($width, $height);
}
}
if (!function_exists(__NAMESPACE__ . '\\imagecreatefromjpeg')) {
function imagecreatefromjpeg($path)
{
if (!empty($GLOBALS['attachment_thumbnail_overrides']['force_imagecreatefromjpeg_fail'])) {
return false;
}
return \imagecreatefromjpeg($path);
}
}
if (!function_exists(__NAMESPACE__ . '\\getimagesize')) {
function getimagesize($path)
{
$override = $GLOBALS['attachment_thumbnail_overrides']['fake_getimagesize'] ?? null;
if ($override !== null) {
return $override;
}
return \getimagesize($path);
}
}
if (!function_exists(__NAMESPACE__ . '\\imagejpeg')) {
function imagejpeg($image, $to = null, $quality = null)
{
if (!empty($GLOBALS['attachment_thumbnail_overrides']['force_imagejpeg_fail'])) {
return false;
}
if ($quality === null) {
return \imagejpeg($image, $to);
}
return \imagejpeg($image, $to, $quality);
}
}
}
namespace {
use App\Models\Attachment;
use App\Models\Forum;
use App\Models\Post;
use App\Models\Setting;
use App\Models\Thread;
use App\Services\AttachmentThumbnailService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
if (!function_exists('imagewebp')) {
function imagewebp($image, $to = null, $quality = null)
{
return false;
}
}
beforeEach(function (): void {
$GLOBALS['attachment_thumbnail_overrides'] = [
'force_imagecreatetruecolor_fail' => false,
'force_imagejpeg_fail' => false,
'force_imagecreatefromjpeg_fail' => false,
'fake_getimagesize' => null,
];
});
it('uses misc scope for attachments without thread or post', function (): void {
if (!function_exists('imagecreatetruecolor')) {
$this->markTestSkipped('GD extension not available.');
}
Storage::fake('local');
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
$image = UploadedFile::fake()->image('photo.jpg', 800, 600);
$path = 'attachments/misc/photo.jpg';
Storage::disk('local')->put($path, file_get_contents($image->getPathname()));
$attachment = Attachment::create([
'thread_id' => null,
'post_id' => null,
'attachment_extension_id' => null,
'attachment_group_id' => null,
'user_id' => null,
'disk' => 'local',
'path' => $path,
'original_name' => 'photo.jpg',
'extension' => 'jpg',
'mime_type' => 'image/jpeg',
'size_bytes' => 1234,
]);
$service = new AttachmentThumbnailService();
$result = $service->createForAttachment($attachment, true);
expect($result)->not->toBeNull();
expect($result['path'])->toContain('attachments/misc/thumbs/');
});
it('returns null when image dimensions are zero', function (): void {
Storage::fake('local');
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
$GLOBALS['attachment_thumbnail_overrides']['fake_getimagesize'] = [0, 0, IMAGETYPE_JPEG];
$service = new AttachmentThumbnailService();
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
$result = $service->createForUpload($file, 'threads/1', 'local');
expect($result)->toBeNull();
});
it('returns null when thumbnail image creation fails', function (): void {
if (!function_exists('imagecreatetruecolor')) {
$this->markTestSkipped('GD extension not available.');
}
Storage::fake('local');
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
$GLOBALS['attachment_thumbnail_overrides']['force_imagecreatetruecolor_fail'] = true;
$service = new AttachmentThumbnailService();
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
$result = $service->createForUpload($file, 'threads/1', 'local');
expect($result)->toBeNull();
});
it('returns null when renderer fails to encode', function (): void {
if (!function_exists('imagecreatetruecolor')) {
$this->markTestSkipped('GD extension not available.');
}
Storage::fake('local');
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
$GLOBALS['attachment_thumbnail_overrides']['force_imagejpeg_fail'] = true;
$service = new AttachmentThumbnailService();
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
$result = $service->createForUpload($file, 'threads/1', 'local');
expect($result)->toBeNull();
});
it('handles webp branch in image loader', function (): void {
$service = new AttachmentThumbnailService();
$ref = new ReflectionMethod($service, 'createImageFromFile');
$ref->setAccessible(true);
$temp = tempnam(sys_get_temp_dir(), 'webp');
file_put_contents($temp, 'not-a-real-webp');
$result = $ref->invoke($service, $temp, 'image/webp');
expect($result === null || $result === false)->toBeTrue();
unlink($temp);
});
it('handles webp branch in renderer when available', function (): void {
$service = new AttachmentThumbnailService();
$ref = new ReflectionMethod($service, 'renderImageBinary');
$ref->setAccessible(true);
$image = \imagecreatetruecolor(10, 10);
$data = $ref->invoke($service, $image, 'image/webp', 80);
expect($data === null || is_string($data))->toBeTrue();
imagedestroy($image);
});
it('returns default when setting is missing', function (): void {
$service = new AttachmentThumbnailService();
$ref = new ReflectionMethod($service, 'settingBool');
$ref->setAccessible(true);
$result = $ref->invoke($service, 'attachments.missing_flag', true);
expect($result)->toBeTrue();
});
}

View File

@@ -0,0 +1,446 @@
<?php
use App\Models\Setting;
use App\Services\AttachmentThumbnailService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
it('returns null for non-images', function (): void {
Storage::fake('local');
$service = new AttachmentThumbnailService();
$file = UploadedFile::fake()->create('document.txt', 10, 'text/plain');
$result = $service->createForUpload($file, 'misc', 'local');
expect($result)->toBeNull();
});
it('creates thumbnail for large images', function (): void {
if (!function_exists('imagecreatetruecolor')) {
$this->markTestSkipped('GD extension not available.');
}
Storage::fake('local');
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_quality'], ['value' => '80']);
$service = new AttachmentThumbnailService();
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
$result = $service->createForUpload($file, 'threads/10', 'local');
expect($result)->not->toBeNull();
expect($result)->toHaveKeys(['path', 'mime', 'size']);
Storage::disk('local')->assertExists($result['path']);
});
it('skips thumbnail when disabled or too small', function (): void {
if (!function_exists('imagecreatetruecolor')) {
$this->markTestSkipped('GD extension not available.');
}
Storage::fake('local');
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'false']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '300']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '300']);
$service = new AttachmentThumbnailService();
$file = UploadedFile::fake()->image('small.jpg', 100, 100);
$result = $service->createForUpload($file, 'threads/1', 'local');
expect($result)->toBeNull();
});
it('skips thumbnail when image is within size limits', function (): void {
if (!function_exists('imagecreatetruecolor')) {
$this->markTestSkipped('GD extension not available.');
}
Storage::fake('local');
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '300']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '300']);
$service = new AttachmentThumbnailService();
$file = UploadedFile::fake()->image('small.jpg', 100, 100);
$result = $service->createForUpload($file, 'threads/1', 'local');
expect($result)->toBeNull();
});
it('returns null when max dimensions are invalid', function (): void {
if (!function_exists('imagecreatetruecolor')) {
$this->markTestSkipped('GD extension not available.');
}
Storage::fake('local');
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '0']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '0']);
$service = new AttachmentThumbnailService();
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
$result = $service->createForUpload($file, 'threads/1', 'local');
expect($result)->toBeNull();
});
it('returns null for invalid image payloads', function (): void {
Storage::fake('local');
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
$service = new AttachmentThumbnailService();
$file = UploadedFile::fake()->create('bad.jpg', 10, 'image/jpeg');
$result = $service->createForUpload($file, 'threads/1', 'local');
expect($result)->toBeNull();
});
it('creates thumbnail for existing attachments', function (): void {
if (!function_exists('imagecreatetruecolor')) {
$this->markTestSkipped('GD extension not available.');
}
Storage::fake('local');
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
$category = \App\Models\Forum::create([
'name' => 'Category',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$forum = \App\Models\Forum::create([
'name' => 'Forum',
'description' => null,
'type' => 'forum',
'parent_id' => $category->id,
'position' => 1,
]);
$thread = \App\Models\Thread::create([
'forum_id' => $forum->id,
'user_id' => null,
'title' => 'Thread',
'body' => 'Body',
]);
$image = UploadedFile::fake()->image('photo.jpg', 800, 600);
$path = "attachments/threads/{$thread->id}/photo.jpg";
Storage::disk('local')->put($path, file_get_contents($image->getPathname()));
$attachment = \App\Models\Attachment::create([
'thread_id' => $thread->id,
'post_id' => null,
'attachment_extension_id' => null,
'attachment_group_id' => null,
'user_id' => null,
'disk' => 'local',
'path' => $path,
'original_name' => 'photo.jpg',
'extension' => 'jpg',
'mime_type' => 'image/jpeg',
'size_bytes' => 1234,
]);
$service = new AttachmentThumbnailService();
$result = $service->createForAttachment($attachment, true);
expect($result)->not->toBeNull();
Storage::disk('local')->assertExists($result['path']);
});
it('returns null for attachments without stored files', function (): void {
Storage::fake('local');
$category = \App\Models\Forum::create([
'name' => 'Category',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$forum = \App\Models\Forum::create([
'name' => 'Forum',
'description' => null,
'type' => 'forum',
'parent_id' => $category->id,
'position' => 1,
]);
$thread = \App\Models\Thread::create([
'forum_id' => $forum->id,
'user_id' => null,
'title' => 'Thread',
'body' => 'Body',
]);
$attachment = \App\Models\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.jpg",
'original_name' => 'missing.jpg',
'extension' => 'jpg',
'mime_type' => 'image/jpeg',
'size_bytes' => 10,
]);
$service = new AttachmentThumbnailService();
$result = $service->createForAttachment($attachment, true);
expect($result)->toBeNull();
});
it('returns null when thumbnail already exists and not forcing', function (): void {
Storage::fake('local');
$category = \App\Models\Forum::create([
'name' => 'Category',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$forum = \App\Models\Forum::create([
'name' => 'Forum',
'description' => null,
'type' => 'forum',
'parent_id' => $category->id,
'position' => 1,
]);
$thread = \App\Models\Thread::create([
'forum_id' => $forum->id,
'user_id' => null,
'title' => 'Thread',
'body' => 'Body',
]);
$attachment = \App\Models\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}/photo.jpg",
'thumbnail_path' => "attachments/threads/{$thread->id}/thumbs/existing.jpg",
'original_name' => 'photo.jpg',
'extension' => 'jpg',
'mime_type' => 'image/jpeg',
'size_bytes' => 10,
]);
Storage::disk('local')->put($attachment->thumbnail_path, 'thumb');
$service = new AttachmentThumbnailService();
$result = $service->createForAttachment($attachment, false);
expect($result)->toBeNull();
});
it('returns null for non-image attachments', function (): void {
Storage::fake('local');
$category = \App\Models\Forum::create([
'name' => 'Category',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$forum = \App\Models\Forum::create([
'name' => 'Forum',
'description' => null,
'type' => 'forum',
'parent_id' => $category->id,
'position' => 1,
]);
$thread = \App\Models\Thread::create([
'forum_id' => $forum->id,
'user_id' => null,
'title' => 'Thread',
'body' => 'Body',
]);
$attachment = \App\Models\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}/doc.pdf",
'original_name' => 'doc.pdf',
'extension' => 'pdf',
'mime_type' => 'application/pdf',
'size_bytes' => 10,
]);
$service = new AttachmentThumbnailService();
$result = $service->createForAttachment($attachment, true);
expect($result)->toBeNull();
});
it('uses post scope when creating thumbnails', function (): void {
if (!function_exists('imagecreatetruecolor')) {
$this->markTestSkipped('GD extension not available.');
}
Storage::fake('local');
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
$category = \App\Models\Forum::create([
'name' => 'Category',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$forum = \App\Models\Forum::create([
'name' => 'Forum',
'description' => null,
'type' => 'forum',
'parent_id' => $category->id,
'position' => 1,
]);
$thread = \App\Models\Thread::create([
'forum_id' => $forum->id,
'user_id' => null,
'title' => 'Thread',
'body' => 'Body',
]);
$post = \App\Models\Post::create([
'thread_id' => $thread->id,
'user_id' => null,
'body' => 'Post',
]);
$image = UploadedFile::fake()->image('photo.png', 800, 600);
$path = "attachments/posts/{$post->id}/photo.png";
Storage::disk('local')->put($path, file_get_contents($image->getPathname()));
$attachment = \App\Models\Attachment::create([
'thread_id' => null,
'post_id' => $post->id,
'attachment_extension_id' => null,
'attachment_group_id' => null,
'user_id' => null,
'disk' => 'local',
'path' => $path,
'original_name' => 'photo.png',
'extension' => 'png',
'mime_type' => 'image/png',
'size_bytes' => 1234,
]);
$service = new AttachmentThumbnailService();
$result = $service->createForAttachment($attachment, true);
expect($result)->not->toBeNull();
expect($result['path'])->toContain("attachments/posts/{$post->id}/thumbs/");
});
it('returns null when mime is unsupported even if image data is valid', function (): void {
if (!function_exists('imagecreatetruecolor')) {
$this->markTestSkipped('GD extension not available.');
}
Storage::fake('local');
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
$category = \App\Models\Forum::create([
'name' => 'Category',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$forum = \App\Models\Forum::create([
'name' => 'Forum',
'description' => null,
'type' => 'forum',
'parent_id' => $category->id,
'position' => 1,
]);
$thread = \App\Models\Thread::create([
'forum_id' => $forum->id,
'user_id' => null,
'title' => 'Thread',
'body' => 'Body',
]);
$image = UploadedFile::fake()->image('photo.png', 800, 600);
$path = "attachments/threads/{$thread->id}/photo.png";
Storage::disk('local')->put($path, file_get_contents($image->getPathname()));
$attachment = \App\Models\Attachment::create([
'thread_id' => $thread->id,
'post_id' => null,
'attachment_extension_id' => null,
'attachment_group_id' => null,
'user_id' => null,
'disk' => 'local',
'path' => $path,
'original_name' => 'photo.png',
'extension' => 'png',
'mime_type' => 'image/avif',
'size_bytes' => 1234,
]);
$service = new AttachmentThumbnailService();
$result = $service->createForAttachment($attachment, true);
expect($result)->toBeNull();
});
it('creates thumbnails for gif images', function (): void {
if (!function_exists('imagecreatetruecolor')) {
$this->markTestSkipped('GD extension not available.');
}
if (!function_exists('imagegif')) {
$this->markTestSkipped('GIF support not available.');
}
Storage::fake('local');
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
$service = new AttachmentThumbnailService();
$file = UploadedFile::fake()->image('photo.gif', 800, 600);
$result = $service->createForUpload($file, 'threads/1', 'local');
expect($result)->not->toBeNull();
Storage::disk('local')->assertExists($result['path']);
});

View File

@@ -0,0 +1,66 @@
<?php
use App\Models\AuditLog;
use App\Models\User;
use App\Services\AuditLogger;
use Illuminate\Http\Request;
it('creates audit log with actor and subject', function (): void {
$user = User::factory()->create();
$subject = User::factory()->create();
$request = Request::create('/api/test', 'POST');
$request->headers->set('User-Agent', 'phpunit');
$request->server->set('REMOTE_ADDR', '127.0.0.1');
$logger = new AuditLogger();
$result = $logger->log(
$request,
'user.updated',
$subject,
['field' => 'name'],
$user
);
expect($result)->toBeInstanceOf(AuditLog::class);
$this->assertDatabaseHas('audit_logs', [
'user_id' => $user->id,
'action' => 'user.updated',
'subject_type' => $subject::class,
'subject_id' => $subject->id,
'ip_address' => '127.0.0.1',
'user_agent' => 'phpunit',
]);
});
it('handles missing user', function (): void {
$request = Request::create('/api/test', 'POST');
$request->headers->set('User-Agent', 'phpunit');
$request->server->set('REMOTE_ADDR', '127.0.0.1');
$logger = new AuditLogger();
$result = $logger->log($request, 'system.ping');
expect($result)->toBeInstanceOf(AuditLog::class);
$this->assertDatabaseHas('audit_logs', [
'action' => 'system.ping',
'user_id' => null,
]);
});
it('returns null when logging fails', function (): void {
AuditLog::creating(function () {
throw new RuntimeException('fail');
});
$request = Request::create('/api/test', 'POST');
$request->headers->set('User-Agent', 'phpunit');
$request->server->set('REMOTE_ADDR', '127.0.0.1');
$logger = new AuditLogger();
$result = $logger->log($request, 'system.fail');
expect($result)->toBeNull();
AuditLog::flushEventListeners();
});

View File

@@ -0,0 +1,34 @@
<?php
use App\Providers\FortifyServiceProvider;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Session\Store;
use Illuminate\Session\ArraySessionHandler;
use Laravel\Fortify\Fortify;
it('registers rate limiters for login and two-factor', function (): void {
(new FortifyServiceProvider(app()))->boot();
$loginLimiter = RateLimiter::limiter('login');
$twoFactorLimiter = RateLimiter::limiter('two-factor');
$request = Request::create('/login', 'POST', [
Fortify::username() => 'Test@Example.com',
]);
$request->server->set('REMOTE_ADDR', '127.0.0.1');
$request->setLaravelSession(new Store('test', new ArraySessionHandler(60)));
$request->session()->put('login.id', 'login-id');
$loginLimit = $loginLimiter($request);
expect($loginLimit)->toBeInstanceOf(Limit::class);
expect($loginLimit->maxAttempts)->toBe(5);
expect($loginLimit->key)->toBe(Str::transliterate('test@example.com|127.0.0.1'));
$twoFactorLimit = $twoFactorLimiter($request);
expect($twoFactorLimit)->toBeInstanceOf(Limit::class);
expect($twoFactorLimit->maxAttempts)->toBe(5);
});

View File

@@ -0,0 +1,74 @@
<?php
use App\Models\Forum;
use App\Models\Post;
use App\Models\Thread;
use App\Models\User;
use Illuminate\Support\Carbon;
it('exposes forum relationships and latest helpers', function (): void {
$category = Forum::create([
'name' => 'Category',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$forum = Forum::create([
'name' => 'Forum',
'description' => null,
'type' => 'forum',
'parent_id' => $category->id,
'position' => 2,
]);
$child = Forum::create([
'name' => 'Child',
'description' => null,
'type' => 'forum',
'parent_id' => $forum->id,
'position' => 1,
]);
$user = User::factory()->create();
$threadOld = Thread::create([
'forum_id' => $forum->id,
'user_id' => $user->id,
'title' => 'Old Thread',
'body' => 'Old',
'created_at' => Carbon::now()->subDays(2),
]);
$threadNew = Thread::create([
'forum_id' => $forum->id,
'user_id' => $user->id,
'title' => 'New Thread',
'body' => 'New',
'created_at' => Carbon::now()->subDay(),
]);
$postOld = Post::create([
'thread_id' => $threadOld->id,
'user_id' => $user->id,
'body' => 'Old post',
'created_at' => Carbon::now()->subDays(2),
]);
$postNew = Post::create([
'thread_id' => $threadNew->id,
'user_id' => $user->id,
'body' => 'New post',
'created_at' => Carbon::now()->subDay(),
]);
$forum->load(['parent', 'children', 'threads', 'posts', 'latestThread', 'latestPost']);
expect($forum->parent?->id)->toBe($category->id);
expect($forum->children->first()->id)->toBe($child->id);
expect($forum->threads)->toHaveCount(2);
expect($forum->posts)->toHaveCount(2);
expect($forum->latestThread?->id)->toBe($threadNew->id);
expect($forum->latestPost?->id)->toBe($postNew->id);
});

View File

@@ -0,0 +1,65 @@
<?php
use App\Models\Attachment;
use App\Models\Forum;
use App\Models\Post;
use App\Models\PostThank;
use App\Models\Thread;
use App\Models\User;
it('exposes post relationships', function (): void {
$category = Forum::create([
'name' => '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,
]);
$user = User::factory()->create();
$thread = Thread::create([
'forum_id' => $forum->id,
'user_id' => $user->id,
'title' => 'Thread',
'body' => 'Body',
]);
$post = Post::create([
'thread_id' => $thread->id,
'user_id' => $user->id,
'body' => 'Post body',
]);
$attachment = Attachment::create([
'thread_id' => null,
'post_id' => $post->id,
'attachment_extension_id' => null,
'attachment_group_id' => null,
'user_id' => $user->id,
'disk' => 'local',
'path' => 'attachments/posts/'.$post->id.'/file.pdf',
'original_name' => 'file.pdf',
'extension' => 'pdf',
'mime_type' => 'application/pdf',
'size_bytes' => 10,
]);
$thank = PostThank::create([
'post_id' => $post->id,
'user_id' => $user->id,
]);
$post->load(['thread', 'user', 'attachments', 'thanks']);
expect($post->thread->id)->toBe($thread->id);
expect($post->user->id)->toBe($user->id);
expect($post->attachments->first()->id)->toBe($attachment->id);
expect($post->thanks->first()->id)->toBe($thank->id);
});

View File

@@ -0,0 +1,22 @@
<?php
use App\Models\Rank;
use App\Models\User;
it('relates ranks to users', function (): void {
$rank = Rank::create([
'name' => 'Gold',
'badge_type' => 'text',
'badge_text' => 'G',
'color' => '#ffaa00',
]);
$user = User::factory()->create([
'rank_id' => $rank->id,
]);
$rank->load('users');
expect($rank->users)->toHaveCount(1);
expect($rank->users->first()->id)->toBe($user->id);
});

View File

@@ -0,0 +1,226 @@
<?php
use App\Http\Controllers\ThreadController;
use App\Models\Attachment;
use App\Models\AttachmentGroup;
use App\Models\Forum;
use App\Models\Post;
use App\Models\Thread;
use App\Models\User;
use App\Models\Role;
use App\Models\Setting;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
function makeForumForThreadController(): 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('returns unauthorized for update when no user', function (): void {
$controller = new ThreadController();
$forum = makeForumForThreadController();
$thread = Thread::create([
'forum_id' => $forum->id,
'user_id' => null,
'title' => 'Thread',
'body' => 'Body',
]);
$request = Request::create("/api/threads/{$thread->id}", 'PATCH', [
'title' => 'New',
]);
$request->setUserResolver(fn () => null);
$response = $controller->update($request, $thread);
expect($response->getStatusCode())->toBe(401);
});
it('returns unauthorized for updateSolved when no user', function (): void {
$controller = new ThreadController();
$forum = makeForumForThreadController();
$thread = Thread::create([
'forum_id' => $forum->id,
'user_id' => null,
'title' => 'Thread',
'body' => 'Body',
]);
$request = Request::create("/api/threads/{$thread->id}/solved", 'PATCH', [
'solved' => true,
]);
$request->setUserResolver(fn () => null);
$response = $controller->updateSolved($request, $thread);
expect($response->getStatusCode())->toBe(401);
});
it('serializes threads with attachments, group colors, and inline images', function (): void {
Storage::fake('public');
$forum = makeForumForThreadController();
$role = Role::create(['name' => 'ROLE_HELPER', 'color' => '#ff0000']);
$user = User::factory()->create([
'avatar_path' => 'avatars/u.png',
'location' => 'Somewhere',
]);
$user->roles()->attach($role);
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => '1']);
$thread = Thread::create([
'forum_id' => $forum->id,
'user_id' => $user->id,
'title' => 'Thread',
'body' => 'See [attachment]image.png[/attachment]',
]);
$post = Post::create([
'thread_id' => $thread->id,
'user_id' => $user->id,
'body' => 'Reply',
]);
$group = AttachmentGroup::create([
'name' => 'Images',
'max_size_kb' => 100,
'is_active' => true,
]);
$attachment = Attachment::create([
'thread_id' => $thread->id,
'post_id' => null,
'attachment_extension_id' => null,
'attachment_group_id' => $group->id,
'user_id' => $user->id,
'disk' => 'local',
'path' => 'attachments/threads/'.$thread->id.'/image.png',
'thumbnail_path' => 'attachments/threads/'.$thread->id.'/thumbs/image.png',
'original_name' => 'image.png',
'extension' => 'png',
'mime_type' => 'image/png',
'size_bytes' => 10,
]);
$thread->load(['user.roles', 'attachments.group', 'latestPost']);
$controller = new ThreadController();
$ref = new ReflectionMethod($controller, 'serializeThread');
$ref->setAccessible(true);
$payload = $ref->invoke($controller, $thread);
expect($payload['user_avatar_url'])->not->toBeNull();
expect($payload['user_group_color'])->toBe('#ff0000');
expect($payload['attachments'][0]['group']['name'])->toBe('Images');
expect($payload['attachments'][0]['thumbnail_url'])->toContain('/thumbnail');
expect($payload['body_html'])->toContain('<img');
expect($payload['last_post_id'])->toBe($post->id);
});
it('replaces attachment tags with links when inline images disabled', function (): void {
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => '0']);
$forum = makeForumForThreadController();
$thread = Thread::create([
'forum_id' => $forum->id,
'user_id' => null,
'title' => 'Thread',
'body' => 'See [attachment]doc.pdf[/attachment]',
]);
$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.'/doc.pdf',
'original_name' => 'doc.pdf',
'extension' => 'pdf',
'mime_type' => 'application/pdf',
'size_bytes' => 10,
]);
$controller = new ThreadController();
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
$ref->setAccessible(true);
$result = $ref->invoke($controller, $thread->body, collect([$attachment]));
expect($result)->toContain('[url=');
expect($result)->toContain('doc.pdf');
});
it('returns body unchanged when no attachments are present', function (): void {
$controller = new ThreadController();
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
$ref->setAccessible(true);
$result = $ref->invoke($controller, 'No attachments', []);
expect($result)->toBe('No attachments');
});
it('returns original tag when attachment name does not match', function (): void {
$forum = makeForumForThreadController();
$thread = Thread::create([
'forum_id' => $forum->id,
'user_id' => null,
'title' => 'Thread',
'body' => 'See [attachment]missing.pdf[/attachment]',
]);
$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.'/doc.pdf',
'original_name' => 'doc.pdf',
'extension' => 'pdf',
'mime_type' => 'application/pdf',
'size_bytes' => 10,
]);
$controller = new ThreadController();
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
$ref->setAccessible(true);
$result = $ref->invoke($controller, $thread->body, collect([$attachment]));
expect($result)->toContain('[attachment]missing.pdf[/attachment]');
});
it('returns body unchanged when attachment map is empty', function (): void {
$controller = new ThreadController();
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
$ref->setAccessible(true);
$attachment = new Attachment([
'original_name' => '',
]);
$result = $ref->invoke($controller, 'Body', collect([$attachment]));
expect($result)->toBe('Body');
});

View File

@@ -0,0 +1,73 @@
<?php
use App\Models\Attachment;
use App\Models\Forum;
use App\Models\Post;
use App\Models\Thread;
use App\Models\User;
use Illuminate\Support\Carbon;
it('casts solved flag and exposes relationships', function (): void {
$category = Forum::create([
'name' => '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,
]);
$user = User::factory()->create();
$thread = Thread::create([
'forum_id' => $forum->id,
'user_id' => $user->id,
'title' => 'Thread',
'body' => 'Body',
'solved' => 1,
]);
$oldPost = Post::create([
'thread_id' => $thread->id,
'user_id' => $user->id,
'body' => 'Old post',
'created_at' => Carbon::now()->subDay(),
]);
$newPost = Post::create([
'thread_id' => $thread->id,
'user_id' => $user->id,
'body' => 'New post',
'created_at' => Carbon::now(),
]);
$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' => 10,
]);
$thread->load(['forum', 'user', 'posts', 'attachments', 'latestPost']);
expect($thread->solved)->toBeTrue();
expect($thread->forum->id)->toBe($forum->id);
expect($thread->user->id)->toBe($user->id);
expect($thread->posts)->toHaveCount(2);
expect($thread->attachments->first()->id)->toBe($attachment->id);
expect($thread->latestPost->id)->toBe($newPost->id);
});

View File

@@ -0,0 +1,14 @@
<?php
use App\Http\Controllers\UploadController;
use Illuminate\Http\Request;
it('returns unauthorized when storeAvatar has no user', function (): void {
$controller = new UploadController();
$request = Request::create('/api/user/avatar', 'POST');
$request->setUserResolver(fn () => null);
$response = $controller->storeAvatar($request);
expect($response->getStatusCode())->toBe(401);
});

View File

@@ -0,0 +1,81 @@
<?php
use App\Http\Controllers\UserController;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
it('returns unauthenticated for me when no user', function (): void {
$controller = new UserController();
$request = Request::create('/api/user/me', 'GET');
$request->setUserResolver(fn () => null);
$response = $controller->me($request);
expect($response->getStatusCode())->toBe(401);
});
it('returns unauthenticated for updateMe when no user', function (): void {
$controller = new UserController();
$request = Request::create('/api/user/me', 'PATCH', [
'location' => 'Test',
]);
$request->setUserResolver(fn () => null);
$response = $controller->updateMe($request);
expect($response->getStatusCode())->toBe(401);
});
it('trims blank location to null in updateMe', function (): void {
$controller = new UserController();
$user = User::factory()->create(['location' => 'Somewhere']);
$request = Request::create('/api/user/me', 'PATCH', [
'location' => ' ',
]);
$request->setUserResolver(fn () => $user);
$response = $controller->updateMe($request);
expect($response->getStatusCode())->toBe(200);
$user->refresh();
expect($user->location)->toBeNull();
});
it('resolves avatar urls when present', function (): void {
Storage::fake('public');
$controller = new UserController();
$user = User::factory()->create([
'avatar_path' => 'avatars/test.png',
]);
$response = $controller->profile($user);
expect($response->getStatusCode())->toBe(200);
expect($response->getData(true)['avatar_url'])->not->toBeNull();
});
it('returns null group color when roles relation is null', function (): void {
$controller = new UserController();
$user = User::factory()->create();
$user->setRelation('roles', null);
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
$ref->setAccessible(true);
$result = $ref->invoke($controller, $user);
expect($result)->toBeNull();
});
it('normalizes empty and raw role names', function (): void {
$controller = new UserController();
$ref = new ReflectionMethod($controller, 'normalizeRoleName');
$ref->setAccessible(true);
expect($ref->invoke($controller, ''))->toBe('ROLE_');
expect($ref->invoke($controller, 'moderator'))->toBe('ROLE_MODERATOR');
});