diff --git a/composer.json b/composer.json index f235878..1107bd1 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,9 @@ "laravel/sail": "^1.41", "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.6", - "phpunit/phpunit": "^11.5.3", + "pestphp/pest": "^4.0", + "pestphp/pest-plugin-laravel": "^4.0", + "phpunit/phpunit": "^12.3", "squizlabs/php_codesniffer": "^4.0" }, "autoload": { @@ -59,6 +61,7 @@ "@php artisan config:clear --ansi", "@php artisan test" ], + "test:coverage": "./vendor/bin/pest --coverage", "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" diff --git a/tests/Feature/AttachmentControllerTest.php b/tests/Feature/AttachmentControllerTest.php new file mode 100644 index 0000000..39159ec --- /dev/null +++ b/tests/Feature/AttachmentControllerTest.php @@ -0,0 +1,284 @@ + '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]); +}); diff --git a/tests/Feature/AttachmentExtensionControllerTest.php b/tests/Feature/AttachmentExtensionControllerTest.php new file mode 100644 index 0000000..dc587b7 --- /dev/null +++ b/tests/Feature/AttachmentExtensionControllerTest.php @@ -0,0 +1,114 @@ +create(); + $role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']); + $admin->roles()->attach($role); + + return $admin; +} + +it('lists extensions for admins', function (): void { + $admin = makeAdminForAttachmentExtensions(); + $group = AttachmentGroup::create(['name' => 'General', 'max_size_kb' => 100, 'is_active' => true]); + AttachmentExtension::create([ + 'extension' => 'pdf', + 'attachment_group_id' => $group->id, + ]); + + Sanctum::actingAs($admin); + $response = $this->getJson('/api/attachment-extensions'); + + $response->assertOk(); + $response->assertJsonFragment(['extension' => 'pdf']); +}); + +it('lists public extensions for active groups', function (): void { + $active = AttachmentGroup::create(['name' => 'Active', 'max_size_kb' => 100, 'is_active' => true]); + $inactive = AttachmentGroup::create(['name' => 'Inactive', 'max_size_kb' => 100, 'is_active' => false]); + + AttachmentExtension::create([ + 'extension' => 'png', + 'attachment_group_id' => $active->id, + ]); + + AttachmentExtension::create([ + 'extension' => 'exe', + 'attachment_group_id' => $inactive->id, + ]); + + $response = $this->getJson('/api/attachment-extensions/public'); + + $response->assertOk(); + $response->assertJsonFragment(['png']); + $response->assertJsonMissing(['exe']); +}); + +it('creates extensions as admin and normalizes extension', function (): void { + $admin = makeAdminForAttachmentExtensions(); + $group = AttachmentGroup::create(['name' => 'Docs', 'max_size_kb' => 100, 'is_active' => true]); + + Sanctum::actingAs($admin); + $response = $this->postJson('/api/attachment-extensions', [ + 'extension' => '.PDF', + 'attachment_group_id' => $group->id, + 'allowed_mimes' => ['application/pdf'], + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['extension' => 'pdf']); +}); + +it('updates extensions as admin', function (): void { + $admin = makeAdminForAttachmentExtensions(); + $group = AttachmentGroup::create(['name' => 'Images', 'max_size_kb' => 100, 'is_active' => true]); + $ext = AttachmentExtension::create([ + 'extension' => 'png', + 'attachment_group_id' => null, + ]); + + Sanctum::actingAs($admin); + $response = $this->patchJson("/api/attachment-extensions/{$ext->id}", [ + 'attachment_group_id' => $group->id, + 'allowed_mimes' => ['image/png'], + ]); + + $response->assertOk(); + $response->assertJsonFragment(['attachment_group_id' => $group->id]); +}); + +it('prevents deleting extensions in use', function (): void { + $admin = makeAdminForAttachmentExtensions(); + $ext = AttachmentExtension::create([ + 'extension' => 'pdf', + 'attachment_group_id' => null, + ]); + + Attachment::create([ + 'thread_id' => null, + 'post_id' => null, + 'attachment_extension_id' => $ext->id, + 'attachment_group_id' => null, + 'user_id' => null, + 'disk' => 'local', + 'path' => 'attachments/misc/file.pdf', + 'original_name' => 'file.pdf', + 'extension' => 'pdf', + 'mime_type' => 'application/pdf', + 'size_bytes' => 10, + ]); + + Sanctum::actingAs($admin); + $response = $this->deleteJson("/api/attachment-extensions/{$ext->id}"); + + $response->assertStatus(422); + $response->assertJsonFragment(['message' => 'Extension is in use.']); +}); diff --git a/tests/Feature/AttachmentGroupControllerTest.php b/tests/Feature/AttachmentGroupControllerTest.php new file mode 100644 index 0000000..2cde14b --- /dev/null +++ b/tests/Feature/AttachmentGroupControllerTest.php @@ -0,0 +1,136 @@ +create(); + $role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']); + $admin->roles()->attach($role); + + return $admin; +} + +it('lists attachment groups for admins', function (): void { + $admin = makeAdminForAttachmentGroups(); + AttachmentGroup::create(['name' => 'General', 'max_size_kb' => 10, 'is_active' => true]); + + Sanctum::actingAs($admin); + $response = $this->getJson('/api/attachment-groups'); + + $response->assertOk(); + $response->assertJsonFragment(['name' => 'General']); +}); + +it('creates attachment groups as admin', function (): void { + $admin = makeAdminForAttachmentGroups(); + Sanctum::actingAs($admin); + + $response = $this->postJson('/api/attachment-groups', [ + 'name' => 'Images', + 'parent_id' => null, + 'max_size_kb' => 1024, + 'is_active' => true, + ]); + + $response->assertStatus(201); + $response->assertJsonFragment(['name' => 'Images']); +}); + +it('updates attachment groups as admin', function (): void { + $admin = makeAdminForAttachmentGroups(); + $group = AttachmentGroup::create([ + 'name' => 'Docs', + 'parent_id' => null, + 'position' => 1, + 'max_size_kb' => 100, + 'is_active' => true, + ]); + + Sanctum::actingAs($admin); + $response = $this->patchJson("/api/attachment-groups/{$group->id}", [ + 'name' => 'Docs Updated', + 'parent_id' => null, + 'max_size_kb' => 200, + 'is_active' => false, + ]); + + $response->assertOk(); + $response->assertJsonFragment(['name' => 'Docs Updated', 'is_active' => false]); +}); + +it('prevents deleting groups with extensions or attachments', function (): void { + $admin = makeAdminForAttachmentGroups(); + $group = AttachmentGroup::create([ + 'name' => 'Protected', + 'max_size_kb' => 100, + 'is_active' => true, + ]); + + AttachmentExtension::create([ + 'extension' => 'pdf', + 'attachment_group_id' => $group->id, + ]); + + Sanctum::actingAs($admin); + $response = $this->deleteJson("/api/attachment-groups/{$group->id}"); + + $response->assertStatus(422); + $response->assertJsonFragment(['message' => 'Attachment group has extensions.']); + + $group2 = AttachmentGroup::create([ + 'name' => 'InUse', + 'max_size_kb' => 100, + 'is_active' => true, + ]); + Attachment::create([ + 'thread_id' => null, + 'post_id' => null, + 'attachment_extension_id' => null, + 'attachment_group_id' => $group2->id, + 'user_id' => null, + 'disk' => 'local', + 'path' => 'attachments/misc/file.pdf', + 'original_name' => 'file.pdf', + 'extension' => 'pdf', + 'mime_type' => 'application/pdf', + 'size_bytes' => 10, + ]); + + $response = $this->deleteJson("/api/attachment-groups/{$group2->id}"); + $response->assertStatus(422); + $response->assertJsonFragment(['message' => 'Attachment group is in use.']); +}); + +it('reorders attachment groups', function (): void { + $admin = makeAdminForAttachmentGroups(); + $first = AttachmentGroup::create([ + 'name' => 'First', + 'position' => 1, + 'max_size_kb' => 100, + 'is_active' => true, + ]); + $second = AttachmentGroup::create([ + 'name' => 'Second', + 'position' => 2, + 'max_size_kb' => 100, + 'is_active' => true, + ]); + + Sanctum::actingAs($admin); + $response = $this->postJson('/api/attachment-groups/reorder', [ + 'parentId' => null, + 'orderedIds' => [$second->id, $first->id], + ]); + + $response->assertOk(); + $this->assertDatabaseHas('attachment_groups', [ + 'id' => $second->id, + 'position' => 1, + ]); +}); diff --git a/tests/Feature/AuditLogControllerTest.php b/tests/Feature/AuditLogControllerTest.php new file mode 100644 index 0000000..d267985 --- /dev/null +++ b/tests/Feature/AuditLogControllerTest.php @@ -0,0 +1,46 @@ +getJson('/api/audit-logs'); + + $response->assertStatus(401); +}); + +it('forbids non-admin audit log access', function (): void { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/audit-logs'); + + $response->assertStatus(403); +}); + +it('lists audit logs for admins', function (): void { + $admin = User::factory()->create(); + $role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']); + $admin->roles()->attach($role); + + $log = AuditLog::create([ + 'user_id' => $admin->id, + 'action' => 'test.action', + 'subject_type' => null, + 'subject_id' => null, + 'metadata' => ['foo' => 'bar'], + 'ip_address' => '127.0.0.1', + 'user_agent' => 'test', + ]); + + Sanctum::actingAs($admin); + $response = $this->getJson('/api/audit-logs'); + + $response->assertOk(); + $response->assertJsonFragment([ + 'id' => $log->id, + 'action' => 'test.action', + ]); +}); diff --git a/tests/Feature/AuthControllerTest.php b/tests/Feature/AuthControllerTest.php new file mode 100644 index 0000000..4df86ae --- /dev/null +++ b/tests/Feature/AuthControllerTest.php @@ -0,0 +1,180 @@ +postJson('/api/register', [ + 'username' => 'NewUser', + 'email' => 'newuser@example.com', + 'plainPassword' => 'Password123!', + ]); + + $response->assertOk(); + $response->assertJsonStructure(['user_id', 'email', 'message']); + + $this->assertDatabaseHas('users', [ + 'email' => 'newuser@example.com', + 'name' => 'NewUser', + 'name_canonical' => 'newuser', + ]); + + $user = User::where('email', 'newuser@example.com')->firstOrFail(); + Notification::assertSentTo($user, VerifyEmail::class); +}); + +it('rejects invalid login credentials', function (): void { + $user = User::factory()->create([ + 'password' => Hash::make('Password123!'), + ]); + + $response = $this->postJson('/api/login', [ + 'login' => $user->email, + 'password' => 'wrong-password', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['login']); +}); + +it('blocks login for unverified email', function (): void { + $user = User::factory()->unverified()->create([ + 'password' => Hash::make('Password123!'), + ]); + + $response = $this->postJson('/api/login', [ + 'login' => $user->email, + 'password' => 'Password123!', + ]); + + $response->assertStatus(403); + $response->assertJsonFragment(['message' => 'Email not verified.']); +}); + +it('logs in with username', function (): void { + $user = User::factory()->create([ + 'name' => 'TestUser', + 'name_canonical' => 'testuser', + 'password' => Hash::make('Password123!'), + ]); + + $response = $this->postJson('/api/login', [ + 'login' => 'TestUser', + 'password' => 'Password123!', + ]); + + $response->assertOk(); + $response->assertJsonStructure(['token', 'user_id', 'email', 'roles']); +}); + +it('validates forgot password requests', function (): void { + $response = $this->postJson('/api/forgot-password', []); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['email']); +}); + +it('sends a reset link for valid email', function (): void { + $user = User::factory()->create([ + 'email' => 'reset@example.com', + ]); + + $response = $this->postJson('/api/forgot-password', [ + 'email' => $user->email, + ]); + + $response->assertOk(); + $response->assertJsonStructure(['message']); +}); + +it('resets a password with a valid token', function (): void { + $user = User::factory()->create([ + 'email' => 'reset2@example.com', + 'password' => Hash::make('OldPassword123!'), + ]); + + $token = Password::createToken($user); + + $response = $this->postJson('/api/reset-password', [ + 'email' => $user->email, + 'password' => 'NewPassword123!', + 'password_confirmation' => 'NewPassword123!', + 'token' => $token, + ]); + + $response->assertOk(); + $response->assertJsonStructure(['message']); + + $user->refresh(); + expect(Hash::check('NewPassword123!', $user->password))->toBeTrue(); +}); + +it('verifies email and redirects to login', function (): void { + $user = User::factory()->unverified()->create(); + + $hash = sha1($user->getEmailForVerification()); + + $url = URL::signedRoute('verification.verify', [ + 'id' => $user->id, + 'hash' => $hash, + ]); + + $response = $this->get($url); + + $response->assertRedirect('/login'); + $user->refresh(); + expect($user->hasVerifiedEmail())->toBeTrue(); +}); + +it('updates password for authenticated users', function (): void { + $user = User::factory()->create([ + 'password' => Hash::make('OldPass123!'), + ]); + + Sanctum::actingAs($user); + + $response = $this->postJson('/api/user/password', [ + 'current_password' => 'OldPass123!', + 'password' => 'NewPass123!', + 'password_confirmation' => 'NewPass123!', + ]); + + $response->assertOk(); + $response->assertJsonFragment(['message' => 'Password updated.']); + + $user->refresh(); + expect(Hash::check('NewPass123!', $user->password))->toBeTrue(); +}); + +it('rejects password update with wrong current password', function (): void { + $user = User::factory()->create([ + 'password' => Hash::make('OldPass123!'), + ]); + + Sanctum::actingAs($user); + + $response = $this->postJson('/api/user/password', [ + 'current_password' => 'WrongPass123!', + 'password' => 'NewPass123!', + 'password_confirmation' => 'NewPass123!', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['current_password']); +}); + +it('logs out authenticated users', function (): void { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/logout'); + + $response->assertStatus(204); +}); diff --git a/tests/Feature/ForumControllerTest.php b/tests/Feature/ForumControllerTest.php new file mode 100644 index 0000000..fe8d2fc --- /dev/null +++ b/tests/Feature/ForumControllerTest.php @@ -0,0 +1,111 @@ + 'Category 1', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + + $forum = Forum::create([ + 'name' => 'Forum A', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $category->id, + 'position' => 1, + ]); + + $response = $this->getJson('/api/forums?parent[exists]=false'); + $response->assertOk(); + $response->assertJsonCount(1); + $response->assertJsonFragment(['id' => $category->id]); + + $response = $this->getJson('/api/forums?parent[exists]=true'); + $response->assertOk(); + $response->assertJsonCount(1); + $response->assertJsonFragment(['id' => $forum->id]); +}); + +it('rejects forum without category parent', function (): void { + Sanctum::actingAs(User::factory()->create()); + + $response = $this->postJson('/api/forums', [ + 'name' => 'Bad Forum', + 'type' => 'forum', + 'parent' => null, + ]); + + $response->assertStatus(422); + $response->assertJsonFragment(['message' => 'Forums must belong to a category.']); +}); + +it('rejects non-category parent', function (): void { + Sanctum::actingAs(User::factory()->create()); + + $category = Forum::create([ + 'name' => 'Category', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + + $parent = Forum::create([ + 'name' => 'Not Category', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $category->id, + 'position' => 1, + ]); + + $response = $this->postJson('/api/forums', [ + 'name' => 'Child Forum', + 'type' => 'forum', + 'parent' => "/api/forums/{$parent->id}", + ]); + + $response->assertStatus(422); + $response->assertJsonFragment(['message' => 'Parent must be a category.']); +}); + +it('reorders positions within parent scope', function (): void { + Sanctum::actingAs(User::factory()->create()); + + $first = Forum::create([ + 'name' => 'Cat A', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + + $second = Forum::create([ + 'name' => 'Cat B', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 2, + ]); + + $response = $this->postJson('/api/forums/reorder', [ + 'parentId' => null, + 'orderedIds' => [$second->id, $first->id], + ]); + + $response->assertOk(); + + $this->assertDatabaseHas('forums', [ + 'id' => $second->id, + 'position' => 1, + ]); + $this->assertDatabaseHas('forums', [ + 'id' => $first->id, + 'position' => 2, + ]); +}); diff --git a/tests/Feature/I18nControllerTest.php b/tests/Feature/I18nControllerTest.php new file mode 100644 index 0000000..95e7934 --- /dev/null +++ b/tests/Feature/I18nControllerTest.php @@ -0,0 +1,13 @@ +getJson('/api/i18n/en'); + + $response->assertOk(); +}); + +it('returns 404 for missing locale', function (): void { + $response = $this->getJson('/api/i18n/xx'); + + $response->assertStatus(404); +}); diff --git a/tests/Feature/InstallerControllerTest.php b/tests/Feature/InstallerControllerTest.php new file mode 100644 index 0000000..28e5157 --- /dev/null +++ b/tests/Feature/InstallerControllerTest.php @@ -0,0 +1,23 @@ +get('/install'); + + $response->assertRedirect('/'); +}); + +it('blocks installer post when env exists', function (): void { + $response = $this->post('/install', [ + 'app_url' => 'https://example.com', + 'db_host' => '127.0.0.1', + 'db_port' => 3306, + 'db_database' => 'test', + 'db_username' => 'user', + 'db_password' => 'pass', + 'admin_name' => 'Admin', + 'admin_email' => 'admin@example.com', + 'admin_password' => 'Password123!', + ]); + + $response->assertRedirect('/'); +}); diff --git a/tests/Feature/PortalControllerTest.php b/tests/Feature/PortalControllerTest.php new file mode 100644 index 0000000..bda2d16 --- /dev/null +++ b/tests/Feature/PortalControllerTest.php @@ -0,0 +1,48 @@ +create(); + + $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, + ]); + + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => $user->id, + 'title' => 'Thread', + 'body' => 'Body', + ]); + + Post::create([ + 'thread_id' => $thread->id, + 'user_id' => $user->id, + 'body' => 'Reply', + ]); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/portal/summary'); + + $response->assertOk(); + $response->assertJsonStructure(['forums', 'threads', 'stats', 'profile']); + $response->assertJsonFragment(['name' => 'Forum']); + $response->assertJsonFragment(['title' => 'Thread']); +}); diff --git a/tests/Feature/PostControllerTest.php b/tests/Feature/PostControllerTest.php new file mode 100644 index 0000000..3b23f6f --- /dev/null +++ b/tests/Feature/PostControllerTest.php @@ -0,0 +1,209 @@ + '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' => null, + 'title' => 'Thread Title', + 'body' => 'Thread Body', + ]); +} + +it('creates a post in a thread', function (): void { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $thread = makeThread(); + + $response = $this->postJson('/api/posts', [ + 'body' => 'First reply', + 'thread' => "/api/threads/{$thread->id}", + ]); + + $response->assertStatus(201); + $response->assertJsonFragment([ + 'body' => 'First reply', + 'thread' => "/api/threads/{$thread->id}", + ]); + + $this->assertDatabaseHas('posts', [ + 'thread_id' => $thread->id, + 'user_id' => $user->id, + 'body' => 'First reply', + ]); +}); + +it('validates required fields when creating posts', function (): void { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/posts', []); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['body', 'thread']); +}); + +it('enforces post update permissions', function (): void { + $thread = makeThread(); + $owner = User::factory()->create(); + $other = User::factory()->create(); + + $post = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => $owner->id, + 'body' => 'Original body', + ]); + + Sanctum::actingAs($other); + $response = $this->patchJson("/api/posts/{$post->id}", [ + 'body' => 'Hacked body', + ]); + + $response->assertStatus(403); + + Sanctum::actingAs($owner); + $response = $this->patchJson("/api/posts/{$post->id}", [ + 'body' => 'Owner update', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('posts', [ + 'id' => $post->id, + 'body' => 'Owner update', + ]); + + $admin = User::factory()->create(); + $role = Role::create(['name' => 'ROLE_ADMIN', 'color' => '#111111']); + $admin->roles()->attach($role); + + Sanctum::actingAs($admin); + $response = $this->patchJson("/api/posts/{$post->id}", [ + 'body' => 'Admin update', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('posts', [ + 'id' => $post->id, + 'body' => 'Admin update', + ]); +}); + +it('requires authentication to update a post', function (): void { + $thread = makeThread(); + $post = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => null, + 'body' => 'Original body', + ]); + + $response = $this->patchJson("/api/posts/{$post->id}", [ + 'body' => 'Updated body', + ]); + + $response->assertStatus(401); +}); + +it('deletes a post and tracks deleted_by', function (): void { + $thread = makeThread(); + $user = User::factory()->create(); + + $post = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => $user->id, + 'body' => 'To be deleted', + ]); + + Sanctum::actingAs($user); + $response = $this->deleteJson("/api/posts/{$post->id}"); + + $response->assertStatus(204); + + $this->assertSoftDeleted('posts', [ + 'id' => $post->id, + ]); + + $this->assertDatabaseHas('posts', [ + 'id' => $post->id, + 'deleted_by' => $user->id, + ]); +}); + +it('filters posts by thread', function (): void { + $threadA = makeThread(); + $threadB = makeThread(); + + $postA = Post::create([ + 'thread_id' => $threadA->id, + 'user_id' => null, + 'body' => 'Post A', + ]); + + Post::create([ + 'thread_id' => $threadB->id, + 'user_id' => null, + 'body' => 'Post B', + ]); + + $response = $this->getJson("/api/posts?thread=/api/threads/{$threadA->id}"); + + $response->assertOk(); + $response->assertJsonCount(1); + $response->assertJsonFragment([ + 'id' => $postA->id, + 'body' => 'Post A', + ]); +}); + +it('allows users to thank and unthank posts', function (): void { + $thread = makeThread(); + $author = User::factory()->create(); + $thanker = User::factory()->create(); + + $post = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => $author->id, + 'body' => 'Helpful answer', + ]); + + Sanctum::actingAs($thanker); + $response = $this->postJson("/api/posts/{$post->id}/thanks"); + + $response->assertStatus(201); + $this->assertDatabaseHas('post_thanks', [ + 'post_id' => $post->id, + 'user_id' => $thanker->id, + ]); + + $response = $this->deleteJson("/api/posts/{$post->id}/thanks"); + + $response->assertStatus(204); + $this->assertDatabaseMissing('post_thanks', [ + 'post_id' => $post->id, + 'user_id' => $thanker->id, + ]); +}); diff --git a/tests/Feature/PostThankControllerTest.php b/tests/Feature/PostThankControllerTest.php new file mode 100644 index 0000000..15a7b7f --- /dev/null +++ b/tests/Feature/PostThankControllerTest.php @@ -0,0 +1,92 @@ + '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' => null, + 'title' => 'Thanks Thread', + 'body' => 'Thread Body', + ]); +} + +it('lists thanks given by a user', function (): void { + $thread = makeThanksThread(); + $author = User::factory()->create(['name' => 'Author']); + $thanker = User::factory()->create(['name' => 'ThanksGiver']); + + $post = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => $author->id, + 'body' => 'Helpful post', + ]); + + $thank = PostThank::create([ + 'post_id' => $post->id, + 'user_id' => $thanker->id, + ]); + + Sanctum::actingAs($thanker); + $response = $this->getJson("/api/user/{$thanker->id}/thanks/given"); + + $response->assertOk(); + $response->assertJsonFragment([ + 'id' => $thank->id, + 'post_id' => $post->id, + 'thread_id' => $thread->id, + 'thread_title' => 'Thanks Thread', + 'post_author_name' => 'Author', + ]); +}); + +it('lists thanks received for a user', function (): void { + $thread = makeThanksThread(); + $author = User::factory()->create(['name' => 'Author']); + $thanker = User::factory()->create(['name' => 'ThanksGiver']); + + $post = Post::create([ + 'thread_id' => $thread->id, + 'user_id' => $author->id, + 'body' => 'Helpful post', + ]); + + $thank = PostThank::create([ + 'post_id' => $post->id, + 'user_id' => $thanker->id, + ]); + + Sanctum::actingAs($author); + $response = $this->getJson("/api/user/{$author->id}/thanks/received"); + + $response->assertOk(); + $response->assertJsonFragment([ + 'id' => $thank->id, + 'post_id' => $post->id, + 'thread_id' => $thread->id, + 'thread_title' => 'Thanks Thread', + 'thanker_name' => 'ThanksGiver', + ]); +}); diff --git a/tests/Feature/PreviewControllerTest.php b/tests/Feature/PreviewControllerTest.php new file mode 100644 index 0000000..aa972e2 --- /dev/null +++ b/tests/Feature/PreviewControllerTest.php @@ -0,0 +1,23 @@ +create(); + \Laravel\Sanctum\Sanctum::actingAs($user); + + $response = $this->postJson('/api/preview', [ + 'body' => '[b]Hello[/b]', + ]); + + $response->assertOk(); + $response->assertJsonStructure(['html']); +}); + +it('validates preview body', function (): void { + $user = \App\Models\User::factory()->create(); + \Laravel\Sanctum\Sanctum::actingAs($user); + + $response = $this->postJson('/api/preview', []); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['body']); +}); diff --git a/tests/Feature/RankControllerTest.php b/tests/Feature/RankControllerTest.php new file mode 100644 index 0000000..f6b35ba --- /dev/null +++ b/tests/Feature/RankControllerTest.php @@ -0,0 +1,107 @@ +create(); + $role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']); + $admin->roles()->attach($role); + + return $admin; +} + +it('lists ranks for authenticated users', function (): void { + $user = User::factory()->create(); + Rank::create(['name' => 'Bronze']); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/ranks'); + + $response->assertOk(); + $response->assertJsonFragment(['name' => 'Bronze']); +}); + +it('creates ranks as admin', function (): void { + $admin = makeAdminForRanks(); + Sanctum::actingAs($admin); + + $response = $this->postJson('/api/ranks', [ + 'name' => 'Silver', + 'badge_type' => 'text', + 'badge_text' => 'S', + 'color' => '#abcdef', + ]); + + $response->assertStatus(201); + $response->assertJsonFragment([ + 'name' => 'Silver', + 'badge_text' => 'S', + ]); +}); + +it('updates ranks and clears badge images when switching to text', function (): void { + Storage::fake('public'); + + $admin = makeAdminForRanks(); + $rank = Rank::create([ + 'name' => 'Gold', + 'badge_type' => 'image', + 'badge_text' => null, + 'badge_image_path' => 'rank-badges/old.png', + 'color' => '#ffaa00', + ]); + + Storage::disk('public')->put('rank-badges/old.png', 'old'); + + Sanctum::actingAs($admin); + $response = $this->patchJson("/api/ranks/{$rank->id}", [ + 'name' => 'Gold', + 'badge_type' => 'text', + 'badge_text' => 'G', + 'color' => '#ffaa00', + ]); + + $response->assertOk(); + Storage::disk('public')->assertMissing('rank-badges/old.png'); +}); + +it('uploads a rank badge image', function (): void { + Storage::fake('public'); + + $admin = makeAdminForRanks(); + $rank = Rank::create(['name' => 'Platinum']); + + Sanctum::actingAs($admin); + $response = $this->postJson("/api/ranks/{$rank->id}/badge-image", [ + 'file' => UploadedFile::fake()->image('badge.png', 50, 50), + ]); + + $response->assertOk(); + $response->assertJsonFragment(['badge_type' => 'image']); +}); + +it('deletes ranks as admin', function (): void { + Storage::fake('public'); + + $admin = makeAdminForRanks(); + $rank = Rank::create([ + 'name' => 'ToDelete', + 'badge_type' => 'image', + 'badge_image_path' => 'rank-badges/delete.png', + ]); + + Storage::disk('public')->put('rank-badges/delete.png', 'old'); + + Sanctum::actingAs($admin); + $response = $this->deleteJson("/api/ranks/{$rank->id}"); + + $response->assertStatus(204); + Storage::disk('public')->assertMissing('rank-badges/delete.png'); + $this->assertDatabaseMissing('ranks', ['id' => $rank->id]); +}); diff --git a/tests/Feature/RoleControllerTest.php b/tests/Feature/RoleControllerTest.php new file mode 100644 index 0000000..90696dc --- /dev/null +++ b/tests/Feature/RoleControllerTest.php @@ -0,0 +1,92 @@ +create(); + $role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']); + $admin->roles()->attach($role); + + return $admin; +} + +it('forbids non-admin role access', function (): void { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/roles'); + + $response->assertStatus(403); +}); + +it('creates normalized roles as admin', function (): void { + $admin = makeAdminForRoles(); + Sanctum::actingAs($admin); + + $response = $this->postJson('/api/roles', [ + 'name' => 'moderator', + 'color' => '#abcdef', + ]); + + $response->assertStatus(201); + $response->assertJsonFragment([ + 'name' => 'ROLE_MODERATOR', + 'color' => '#abcdef', + ]); + + $this->assertDatabaseHas('roles', [ + 'name' => 'ROLE_MODERATOR', + ]); +}); + +it('prevents renaming core roles', function (): void { + $admin = makeAdminForRoles(); + $core = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']); + + Sanctum::actingAs($admin); + $response = $this->patchJson("/api/roles/{$core->id}", [ + 'name' => 'ROLE_SUPER', + 'color' => '#123456', + ]); + + $response->assertStatus(422); + $response->assertJsonFragment(['message' => 'Core roles cannot be renamed.']); +}); + +it('prevents deleting core roles', function (): void { + $admin = makeAdminForRoles(); + $core = Role::firstOrCreate(['name' => 'ROLE_USER'], ['color' => '#111111']); + + Sanctum::actingAs($admin); + $response = $this->deleteJson("/api/roles/{$core->id}"); + + $response->assertStatus(422); + $response->assertJsonFragment(['message' => 'Core roles cannot be deleted.']); +}); + +it('prevents deleting roles assigned to users', function (): void { + $admin = makeAdminForRoles(); + $role = Role::create(['name' => 'ROLE_HELPER', 'color' => '#222222']); + $user = User::factory()->create(); + $user->roles()->attach($role); + + Sanctum::actingAs($admin); + $response = $this->deleteJson("/api/roles/{$role->id}"); + + $response->assertStatus(422); + $response->assertJsonFragment(['message' => 'Role is assigned to users.']); +}); + +it('deletes non-core roles without assignments', function (): void { + $admin = makeAdminForRoles(); + $role = Role::create(['name' => 'ROLE_CUSTOM', 'color' => '#333333']); + + Sanctum::actingAs($admin); + $response = $this->deleteJson("/api/roles/{$role->id}"); + + $response->assertStatus(204); + $this->assertDatabaseMissing('roles', ['id' => $role->id]); +}); diff --git a/tests/Feature/SettingControllerTest.php b/tests/Feature/SettingControllerTest.php new file mode 100644 index 0000000..cee0450 --- /dev/null +++ b/tests/Feature/SettingControllerTest.php @@ -0,0 +1,89 @@ +create(); + $role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']); + $admin->roles()->attach($role); + + return $admin; +} + +it('lists settings and supports key filtering', function (): void { + Setting::create(['key' => 'site.name', 'value' => 'SpeedBB']); + Setting::create(['key' => 'site.tagline', 'value' => 'Fast']); + + $response = $this->getJson('/api/settings'); + + $response->assertOk(); + $payload = $response->json(); + expect($payload)->toBeArray(); + expect(count($payload))->toBeGreaterThanOrEqual(2); + $response->assertJsonFragment(['key' => 'site.name', 'value' => 'SpeedBB']); + $response->assertJsonFragment(['key' => 'site.tagline', 'value' => 'Fast']); + + $response = $this->getJson('/api/settings?key=site.name'); + $response->assertOk(); + $response->assertJsonCount(1); + $response->assertJsonFragment(['key' => 'site.name', 'value' => 'SpeedBB']); +}); + +it('forbids non-admin setting creation', function (): void { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/settings', [ + 'key' => 'site.name', + 'value' => 'SpeedBB', + ]); + + $response->assertStatus(403); +}); + +it('creates or updates settings as admin', function (): void { + $admin = makeAdminUser(); + Sanctum::actingAs($admin); + + $response = $this->postJson('/api/settings', [ + 'key' => 'site.name', + 'value' => 'SpeedBB', + ]); + + $response->assertOk(); + $response->assertJsonFragment(['key' => 'site.name', 'value' => 'SpeedBB']); + + $response = $this->postJson('/api/settings', [ + 'key' => 'site.name', + 'value' => 'SpeedBB 2', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('settings', [ + 'key' => 'site.name', + 'value' => 'SpeedBB 2', + ]); +}); + +it('bulk stores settings as admin', function (): void { + $admin = makeAdminUser(); + Sanctum::actingAs($admin); + + $response = $this->postJson('/api/settings/bulk', [ + 'settings' => [ + ['key' => 'site.name', 'value' => 'SpeedBB'], + ['key' => 'site.tagline', 'value' => 'Fast'], + ], + ]); + + $response->assertOk(); + $response->assertJsonCount(2); + $this->assertDatabaseHas('settings', [ + 'key' => 'site.tagline', + 'value' => 'Fast', + ]); +}); diff --git a/tests/Feature/StatsControllerTest.php b/tests/Feature/StatsControllerTest.php new file mode 100644 index 0000000..52c13bf --- /dev/null +++ b/tests/Feature/StatsControllerTest.php @@ -0,0 +1,85 @@ +create(); + $forum = Forum::create([ + 'name' => 'Category', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + $child = Forum::create([ + 'name' => 'Forum', + 'description' => null, + 'type' => 'forum', + 'parent_id' => $forum->id, + 'position' => 1, + ]); + + $thread = Thread::create([ + 'forum_id' => $child->id, + 'user_id' => $user->id, + 'title' => 'Thread', + 'body' => 'Body', + ]); + + Post::create([ + 'thread_id' => $thread->id, + 'user_id' => $user->id, + 'body' => 'Post', + ]); + + 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' => 123, + ]); + + $response = $this->getJson('/api/stats'); + + $response->assertOk(); + $response->assertJsonStructure([ + 'threads', + 'posts', + 'users', + 'attachments', + 'board_started_at', + 'attachments_size_bytes', + 'avatar_directory_size_bytes', + 'database_size_bytes', + 'database_server', + 'gzip_compression', + 'php_version', + 'orphan_attachments', + 'board_version', + 'posts_per_day', + 'topics_per_day', + 'users_per_day', + 'attachments_per_day', + ]); + + $response->assertJsonFragment([ + 'threads' => 1, + 'users' => 1, + 'attachments' => 1, + 'attachments_size_bytes' => 123, + ]); +}); diff --git a/tests/Feature/SystemStatusControllerTest.php b/tests/Feature/SystemStatusControllerTest.php new file mode 100644 index 0000000..8cb943a --- /dev/null +++ b/tests/Feature/SystemStatusControllerTest.php @@ -0,0 +1,13 @@ +create(); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/system/status'); + + $response->assertStatus(403); +}); diff --git a/tests/Feature/SystemUpdateControllerTest.php b/tests/Feature/SystemUpdateControllerTest.php new file mode 100644 index 0000000..a5126fb --- /dev/null +++ b/tests/Feature/SystemUpdateControllerTest.php @@ -0,0 +1,29 @@ +create(); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/system/update'); + + $response->assertStatus(403); +}); + +it('returns validation error when gitea config is missing', function (): void { + putenv('GITEA_OWNER='); + putenv('GITEA_REPO='); + + $admin = User::factory()->create(); + $role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']); + $admin->roles()->attach($role); + + Sanctum::actingAs($admin); + $response = $this->postJson('/api/system/update'); + + $response->assertStatus(422); + $response->assertJsonFragment(['message' => 'Missing Gitea configuration.']); +}); diff --git a/tests/Feature/ThreadControllerTest.php b/tests/Feature/ThreadControllerTest.php new file mode 100644 index 0000000..346aa24 --- /dev/null +++ b/tests/Feature/ThreadControllerTest.php @@ -0,0 +1,248 @@ + '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('creates a thread inside a forum', function (): void { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $forum = makeForum(); + + $response = $this->postJson('/api/threads', [ + 'title' => 'First Thread', + 'body' => 'Hello world', + 'forum' => "/api/forums/{$forum->id}", + ]); + + $response->assertStatus(201); + $response->assertJsonFragment([ + 'title' => 'First Thread', + 'forum' => "/api/forums/{$forum->id}", + ]); + + $this->assertDatabaseHas('threads', [ + 'forum_id' => $forum->id, + 'user_id' => $user->id, + 'title' => 'First Thread', + ]); +}); + +it('rejects creating threads in a category', function (): void { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $category = Forum::create([ + 'name' => 'Category Only', + 'description' => null, + 'type' => 'category', + 'parent_id' => null, + 'position' => 1, + ]); + + $response = $this->postJson('/api/threads', [ + 'title' => 'Nope', + 'body' => 'Not allowed', + 'forum' => "/api/forums/{$category->id}", + ]); + + $response->assertStatus(422); + $response->assertJsonFragment(['message' => 'Threads can only be created inside forums.']); +}); + +it('requires authentication to update a thread', function (): void { + $forum = makeForum(); + $owner = User::factory()->create(); + + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => $owner->id, + 'title' => 'Original', + 'body' => 'Body', + ]); + + $response = $this->patchJson("/api/threads/{$thread->id}", [ + 'title' => 'Updated', + 'body' => 'Updated body', + ]); + + $response->assertStatus(401); +}); + +it('enforces thread update permissions', function (): void { + $forum = makeForum(); + $owner = User::factory()->create(); + $other = User::factory()->create(); + + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => $owner->id, + 'title' => 'Original', + 'body' => 'Body', + ]); + + Sanctum::actingAs($other); + $response = $this->patchJson("/api/threads/{$thread->id}", [ + 'title' => 'Updated', + 'body' => 'Updated body', + ]); + + $response->assertStatus(403); + + Sanctum::actingAs($owner); + $response = $this->patchJson("/api/threads/{$thread->id}", [ + 'title' => 'Owner Update', + 'body' => 'Owner body', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('threads', [ + 'id' => $thread->id, + 'title' => 'Owner Update', + 'body' => 'Owner body', + ]); + + $admin = User::factory()->create(); + $role = Role::create(['name' => 'ROLE_ADMIN', 'color' => '#111111']); + $admin->roles()->attach($role); + + Sanctum::actingAs($admin); + $response = $this->patchJson("/api/threads/{$thread->id}", [ + 'title' => 'Admin Update', + 'body' => 'Admin body', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('threads', [ + 'id' => $thread->id, + 'title' => 'Admin Update', + 'body' => 'Admin body', + ]); +}); + +it('enforces solved status permissions', function (): void { + $forum = makeForum(); + $owner = User::factory()->create(); + $other = User::factory()->create(); + + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => $owner->id, + 'title' => 'Original', + 'body' => 'Body', + 'solved' => false, + ]); + + Sanctum::actingAs($other); + $response = $this->patchJson("/api/threads/{$thread->id}/solved", [ + 'solved' => true, + ]); + + $response->assertStatus(403); + + Sanctum::actingAs($owner); + $response = $this->patchJson("/api/threads/{$thread->id}/solved", [ + 'solved' => true, + ]); + + $response->assertOk(); + $this->assertDatabaseHas('threads', [ + 'id' => $thread->id, + 'solved' => 1, + ]); +}); + +it('filters threads by forum', function (): void { + $forumA = makeForum(); + $forumB = makeForum(); + + $threadA = Thread::create([ + 'forum_id' => $forumA->id, + 'user_id' => null, + 'title' => 'Thread A', + 'body' => 'Body A', + ]); + + Thread::create([ + 'forum_id' => $forumB->id, + 'user_id' => null, + 'title' => 'Thread B', + 'body' => 'Body B', + ]); + + $response = $this->getJson("/api/threads?forum=/api/forums/{$forumA->id}"); + + $response->assertOk(); + $response->assertJsonCount(1); + $response->assertJsonFragment([ + 'id' => $threadA->id, + 'title' => 'Thread A', + ]); +}); + +it('increments views count when showing a thread', function (): void { + $forum = makeForum(); + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => null, + 'title' => 'Viewed Thread', + 'body' => 'Body', + 'views_count' => 0, + ]); + + $response = $this->getJson("/api/threads/{$thread->id}"); + + $response->assertOk(); + $response->assertJsonFragment([ + 'id' => $thread->id, + 'views_count' => 1, + ]); + + $thread->refresh(); + expect($thread->views_count)->toBe(1); +}); + +it('soft deletes a thread and tracks deleted_by', function (): void { + $forum = makeForum(); + $user = User::factory()->create(); + $thread = Thread::create([ + 'forum_id' => $forum->id, + 'user_id' => $user->id, + 'title' => 'Delete Me', + 'body' => 'Body', + ]); + + Sanctum::actingAs($user); + $response = $this->deleteJson("/api/threads/{$thread->id}"); + + $response->assertStatus(204); + $this->assertSoftDeleted('threads', [ + 'id' => $thread->id, + ]); + $this->assertDatabaseHas('threads', [ + 'id' => $thread->id, + 'deleted_by' => $user->id, + ]); +}); diff --git a/tests/Feature/UploadControllerTest.php b/tests/Feature/UploadControllerTest.php new file mode 100644 index 0000000..be5f056 --- /dev/null +++ b/tests/Feature/UploadControllerTest.php @@ -0,0 +1,97 @@ +postJson('/api/user/avatar', [ + 'file' => UploadedFile::fake()->image('avatar.jpg', 100, 100), + ]); + + $response->assertStatus(401); +}); + +it('uploads avatars for authenticated users', function (): void { + Storage::fake('public'); + + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/user/avatar', [ + 'file' => UploadedFile::fake()->image('avatar.jpg', 100, 100), + ]); + + $response->assertOk(); + $path = $response->json('path'); + + Storage::disk('public')->assertExists($path); +}); + +it('replaces existing avatar when uploading a new one', function (): void { + Storage::fake('public'); + + $user = User::factory()->create([ + 'avatar_path' => 'avatars/old.png', + ]); + Storage::disk('public')->put('avatars/old.png', 'old'); + + Sanctum::actingAs($user); + $response = $this->postJson('/api/user/avatar', [ + 'file' => UploadedFile::fake()->image('avatar.jpg', 100, 100), + ]); + + $response->assertOk(); + Storage::disk('public')->assertMissing('avatars/old.png'); +}); + +it('forbids logo uploads for non-admins', function (): void { + Storage::fake('public'); + + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/uploads/logo', [ + 'file' => UploadedFile::fake()->image('logo.png', 200, 200), + ]); + + $response->assertStatus(403); +}); + +it('forbids favicon uploads for non-admins', function (): void { + Storage::fake('public'); + + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/uploads/favicon', [ + 'file' => UploadedFile::fake()->image('favicon.png', 32, 32), + ]); + + $response->assertStatus(403); +}); + +it('uploads logos and favicons as admin', function (): void { + Storage::fake('public'); + + $admin = User::factory()->create(); + $role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']); + $admin->roles()->attach($role); + + Sanctum::actingAs($admin); + $logo = $this->postJson('/api/uploads/logo', [ + 'file' => UploadedFile::fake()->image('logo.png', 200, 200), + ]); + + $logo->assertOk(); + Storage::disk('public')->assertExists($logo->json('path')); + + $favicon = $this->postJson('/api/uploads/favicon', [ + 'file' => UploadedFile::fake()->image('favicon.png', 32, 32), + ]); + + $favicon->assertOk(); + Storage::disk('public')->assertExists($favicon->json('path')); +}); diff --git a/tests/Feature/UserControllerTest.php b/tests/Feature/UserControllerTest.php new file mode 100644 index 0000000..8f7b540 --- /dev/null +++ b/tests/Feature/UserControllerTest.php @@ -0,0 +1,287 @@ +create(); + $role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']); + $admin->roles()->attach($role); + + return $admin; +} + +it('requires authentication to list users', function (): void { + $response = $this->getJson('/api/users'); + + $response->assertStatus(401); +}); + +it('lists users with roles and group color', function (): void { + $admin = makeAdmin(); + $role = Role::firstOrCreate(['name' => 'ROLE_MOD'], ['color' => '#ff0000']); + $user = User::factory()->create(['name' => 'Alice']); + $user->roles()->attach($role); + + Sanctum::actingAs($admin); + $response = $this->getJson('/api/users'); + + $response->assertOk(); + $response->assertJsonFragment([ + 'id' => $user->id, + 'name' => 'Alice', + 'group_color' => '#ff0000', + ]); +}); + +it('returns current user profile from me endpoint', function (): void { + $user = User::factory()->create(['name' => 'Me']); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/user/me'); + + $response->assertOk(); + $response->assertJsonFragment([ + 'id' => $user->id, + 'name' => 'Me', + 'email' => $user->email, + ]); +}); + +it('rejects unauthenticated me requests', function (): void { + $response = $this->getJson('/api/user/me'); + + $response->assertStatus(401); + $response->assertJsonFragment(['message' => 'Unauthenticated.']); +}); + +it('returns user profile details', function (): void { + $viewer = User::factory()->create(); + $target = User::factory()->create(['name' => 'ProfileUser']); + + Sanctum::actingAs($viewer); + $response = $this->getJson("/api/user/profile/{$target->id}"); + + $response->assertOk(); + $response->assertJsonFragment([ + 'id' => $target->id, + 'name' => 'ProfileUser', + ]); +}); + +it('updates user location via updateMe', function (): void { + $user = User::factory()->create(['location' => null]); + + Sanctum::actingAs($user); + $response = $this->patchJson('/api/user/me', [ + 'location' => ' New York ', + ]); + + $response->assertOk(); + $response->assertJsonFragment([ + 'id' => $user->id, + 'location' => 'New York', + ]); + + $user->refresh(); + expect($user->location)->toBe('New York'); +}); + +it('rejects updateMe when unauthenticated', function (): void { + $response = $this->patchJson('/api/user/me', [ + 'location' => 'Somewhere', + ]); + + $response->assertStatus(401); + $response->assertJsonFragment(['message' => 'Unauthenticated.']); +}); + +it('clears location when updateMe receives blank value', function (): void { + $user = User::factory()->create(['location' => 'Somewhere']); + + Sanctum::actingAs($user); + $response = $this->patchJson('/api/user/me', [ + 'location' => ' ', + ]); + + $response->assertOk(); + $response->assertJsonFragment([ + 'id' => $user->id, + 'location' => null, + ]); + + $user->refresh(); + expect($user->location)->toBeNull(); +}); + +it('forbids non-admin rank updates', function (): void { + $user = User::factory()->create(); + $target = User::factory()->create(); + $rank = Rank::create(['name' => 'Silver']); + + Sanctum::actingAs($user); + $response = $this->patchJson("/api/users/{$target->id}/rank", [ + 'rank_id' => $rank->id, + ]); + + $response->assertStatus(403); +}); + +it('forbids founder rank updates by non-founder admin', function (): void { + $admin = makeAdmin(); + $founderRole = Role::firstOrCreate(['name' => 'ROLE_FOUNDER'], ['color' => '#111111']); + $founder = User::factory()->create(); + $founder->roles()->attach($founderRole); + $rank = Rank::create(['name' => 'Founder Rank']); + + Sanctum::actingAs($admin); + $response = $this->patchJson("/api/users/{$founder->id}/rank", [ + 'rank_id' => $rank->id, + ]); + + $response->assertStatus(403); +}); + +it('allows admins to update user rank', function (): void { + $admin = makeAdmin(); + $target = User::factory()->create(); + $rank = Rank::create(['name' => 'Gold']); + + Sanctum::actingAs($admin); + $response = $this->patchJson("/api/users/{$target->id}/rank", [ + 'rank_id' => $rank->id, + ]); + + $response->assertOk(); + $response->assertJsonPath('id', $target->id); + $response->assertJsonPath('rank.id', $rank->id); + $response->assertJsonPath('rank.name', 'Gold'); + + $target->refresh(); + expect($target->rank_id)->toBe($rank->id); +}); + +it('rejects update without admin role', function (): void { + $user = User::factory()->create(); + $target = User::factory()->create(); + + Sanctum::actingAs($user); + $response = $this->patchJson("/api/users/{$target->id}", [ + 'name' => 'New Name', + 'email' => 'new@example.com', + 'rank_id' => null, + ]); + + $response->assertStatus(403); +}); + +it('forbids updating founder user when actor is not founder', function (): void { + $admin = makeAdmin(); + $founderRole = Role::firstOrCreate(['name' => 'ROLE_FOUNDER'], ['color' => '#111111']); + $founder = User::factory()->create(); + $founder->roles()->attach($founderRole); + + Sanctum::actingAs($admin); + $response = $this->patchJson("/api/users/{$founder->id}", [ + 'name' => 'New Name', + 'email' => 'new@example.com', + 'rank_id' => null, + ]); + + $response->assertStatus(403); +}); + +it('rejects assigning founder role for non-founder admin', function (): void { + $admin = makeAdmin(); + $target = User::factory()->create(); + Role::firstOrCreate(['name' => 'ROLE_FOUNDER'], ['color' => '#111111']); + + Sanctum::actingAs($admin); + $response = $this->patchJson("/api/users/{$target->id}", [ + 'name' => 'New Name', + 'email' => 'new@example.com', + 'rank_id' => null, + 'roles' => ['ROLE_FOUNDER'], + ]); + + $response->assertStatus(403); + $response->assertJsonFragment(['message' => 'Forbidden']); +}); + +it('rejects duplicate canonical names', function (): void { + $admin = makeAdmin(); + User::factory()->create([ + 'name' => 'Dupe', + 'name_canonical' => 'dupe', + 'email' => 'dupe@example.com', + ]); + $target = User::factory()->create([ + 'name' => 'Other', + 'name_canonical' => 'other', + 'email' => 'other@example.com', + ]); + + Sanctum::actingAs($admin); + $response = $this->patchJson("/api/users/{$target->id}", [ + 'name' => 'Dupe', + 'email' => 'other@example.com', + 'rank_id' => null, + ]); + + $response->assertStatus(422); + $response->assertJsonFragment(['message' => 'Name already exists.']); +}); + +it('normalizes roles and updates group color', function (): void { + $admin = makeAdmin(); + $target = User::factory()->create([ + 'name' => 'Target', + 'email' => 'target@example.com', + ]); + + Role::firstOrCreate(['name' => 'ROLE_MOD'], ['color' => '#00ff00']); + + Sanctum::actingAs($admin); + $response = $this->patchJson("/api/users/{$target->id}", [ + 'name' => 'Target', + 'email' => 'target@example.com', + 'rank_id' => null, + 'roles' => ['ROLE_MOD'], + ]); + + $response->assertOk(); + $response->assertJsonFragment(['group_color' => '#00ff00']); +}); + +it('updates user name and email as admin', function (): void { + $admin = makeAdmin(); + $target = User::factory()->create([ + 'name' => 'Old Name', + 'email' => 'old@example.com', + 'email_verified_at' => now(), + ]); + Role::firstOrCreate(['name' => 'ROLE_MOD'], ['color' => '#00aa00']); + + Sanctum::actingAs($admin); + $response = $this->patchJson("/api/users/{$target->id}", [ + 'name' => 'New Name', + 'email' => 'new@example.com', + 'rank_id' => null, + 'roles' => ['ROLE_MOD'], + ]); + + $response->assertOk(); + $response->assertJsonFragment([ + 'id' => $target->id, + 'name' => 'New Name', + 'email' => 'new@example.com', + ]); + + $target->refresh(); + expect($target->name)->toBe('New Name'); + expect($target->email)->toBe('new@example.com'); + expect($target->email_verified_at)->toBeNull(); +}); diff --git a/tests/Feature/UserSettingControllerTest.php b/tests/Feature/UserSettingControllerTest.php new file mode 100644 index 0000000..f7ad09c --- /dev/null +++ b/tests/Feature/UserSettingControllerTest.php @@ -0,0 +1,63 @@ +create(); + $other = User::factory()->create(); + + UserSetting::create([ + 'user_id' => $user->id, + 'key' => 'editor', + 'value' => ['theme' => 'dark'], + ]); + + UserSetting::create([ + 'user_id' => $user->id, + 'key' => 'notifications', + 'value' => ['email' => true], + ]); + + UserSetting::create([ + 'user_id' => $other->id, + 'key' => 'editor', + 'value' => ['theme' => 'light'], + ]); + + Sanctum::actingAs($user); + $response = $this->getJson('/api/user-settings'); + + $response->assertOk(); + $response->assertJsonCount(2); + + $response = $this->getJson('/api/user-settings?key=editor'); + $response->assertOk(); + $response->assertJsonCount(1); + $response->assertJsonFragment(['key' => 'editor']); +}); + +it('creates or updates user settings', function (): void { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/user-settings', [ + 'key' => 'editor', + 'value' => ['theme' => 'dark'], + ]); + + $response->assertOk(); + $response->assertJsonFragment(['key' => 'editor']); + + $response = $this->postJson('/api/user-settings', [ + 'key' => 'editor', + 'value' => ['theme' => 'light'], + ]); + + $response->assertOk(); + $this->assertDatabaseHas('user_settings', [ + 'user_id' => $user->id, + 'key' => 'editor', + ]); +}); diff --git a/tests/Feature/VersionCheckControllerTest.php b/tests/Feature/VersionCheckControllerTest.php new file mode 100644 index 0000000..28f2016 --- /dev/null +++ b/tests/Feature/VersionCheckControllerTest.php @@ -0,0 +1,107 @@ + $owner, + 'GITEA_REPO' => $repo, + 'GITEA_API_BASE' => $apiBase, + 'GITEA_TOKEN' => $token, + ]; + + foreach ($pairs as $key => $value) { + if ($value === null || $value === '') { + putenv("{$key}="); + unset($_ENV[$key], $_SERVER[$key]); + } else { + putenv("{$key}={$value}"); + $_ENV[$key] = $value; + $_SERVER[$key] = $value; + } + } +} + +it('returns error when gitea config missing', function (): void { + setGiteaEnv(null, null); + + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.0.0']); + + $response = $this->getJson('/api/version/check'); + + $response->assertOk(); + $response->assertJsonFragment([ + 'current_version' => '1.0.0', + 'latest_tag' => null, + ]); +}); + +it('checks latest release and reports status', function (): void { + setGiteaEnv('acme', 'speedbb', 'https://git.example.test/api/v1', 'secrettoken'); + + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + Setting::updateOrCreate(['key' => 'build'], ['value' => '7']); + + Http::fake([ + 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ + 'tag_name' => 'v1.2.3', + ], 200), + ]); + + $response = $this->getJson('/api/version/check'); + + $response->assertOk(); + $response->assertJsonFragment([ + 'current_version' => '1.2.3', + 'latest_tag' => 'v1.2.3', + 'is_latest' => true, + ]); + + Http::assertSent(function ($request) { + return $request->hasHeader('Authorization', 'token secrettoken'); + }); +}); + +it('handles failed release responses', function (): void { + setGiteaEnv('acme', 'speedbb', 'https://git.example.test/api/v1'); + + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + + Http::fake([ + 'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([ + 'message' => 'oops', + ], 500), + ]); + + $response = $this->getJson('/api/version/check'); + + $response->assertOk(); + $response->assertJsonFragment([ + 'current_version' => '1.2.3', + 'latest_tag' => null, + 'is_latest' => null, + 'error' => 'Release check failed: 500', + ]); +}); + +it('handles release check exceptions', function (): void { + setGiteaEnv('acme', 'speedbb', 'https://git.example.test/api/v1'); + + Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']); + + Http::fake(function () { + throw new RuntimeException('boom'); + }); + + $response = $this->getJson('/api/version/check'); + + $response->assertOk(); + $response->assertJsonFragment([ + 'current_version' => '1.2.3', + 'latest_tag' => null, + 'is_latest' => null, + 'error' => 'Version check failed.', + ]); +}); diff --git a/tests/Feature/VersionControllerTest.php b/tests/Feature/VersionControllerTest.php new file mode 100644 index 0000000..f484ced --- /dev/null +++ b/tests/Feature/VersionControllerTest.php @@ -0,0 +1,16 @@ + 'version'], ['value' => '1.2.3']); + Setting::updateOrCreate(['key' => 'build'], ['value' => '42']); + + $response = $this->getJson('/api/version'); + + $response->assertOk(); + $response->assertJsonFragment([ + 'version' => '1.2.3', + 'build' => 42, + ]); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..2d14440 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,5 @@ +in('Feature', 'Unit'); diff --git a/tests/Unit/AttachmentGroupModelTest.php b/tests/Unit/AttachmentGroupModelTest.php new file mode 100644 index 0000000..1e4dcc6 --- /dev/null +++ b/tests/Unit/AttachmentGroupModelTest.php @@ -0,0 +1,33 @@ + '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); +}); diff --git a/tests/Unit/AttachmentModelTest.php b/tests/Unit/AttachmentModelTest.php new file mode 100644 index 0000000..b73fa08 --- /dev/null +++ b/tests/Unit/AttachmentModelTest.php @@ -0,0 +1,72 @@ + '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); +}); diff --git a/tests/Unit/AttachmentThumbnailServiceBranchesTest.php b/tests/Unit/AttachmentThumbnailServiceBranchesTest.php new file mode 100644 index 0000000..bb81c56 --- /dev/null +++ b/tests/Unit/AttachmentThumbnailServiceBranchesTest.php @@ -0,0 +1,214 @@ + 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(); + }); +} diff --git a/tests/Unit/AttachmentThumbnailServiceTest.php b/tests/Unit/AttachmentThumbnailServiceTest.php new file mode 100644 index 0000000..2e454bd --- /dev/null +++ b/tests/Unit/AttachmentThumbnailServiceTest.php @@ -0,0 +1,446 @@ +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']); +}); diff --git a/tests/Unit/AuditLoggerTest.php b/tests/Unit/AuditLoggerTest.php new file mode 100644 index 0000000..779ccc1 --- /dev/null +++ b/tests/Unit/AuditLoggerTest.php @@ -0,0 +1,66 @@ +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(); +}); diff --git a/tests/Unit/FortifyServiceProviderTest.php b/tests/Unit/FortifyServiceProviderTest.php new file mode 100644 index 0000000..ed6edef --- /dev/null +++ b/tests/Unit/FortifyServiceProviderTest.php @@ -0,0 +1,34 @@ +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); +}); diff --git a/tests/Unit/ForumModelTest.php b/tests/Unit/ForumModelTest.php new file mode 100644 index 0000000..4a7e37e --- /dev/null +++ b/tests/Unit/ForumModelTest.php @@ -0,0 +1,74 @@ + '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); +}); diff --git a/tests/Unit/PostModelTest.php b/tests/Unit/PostModelTest.php new file mode 100644 index 0000000..bdb4b8a --- /dev/null +++ b/tests/Unit/PostModelTest.php @@ -0,0 +1,65 @@ + '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); +}); diff --git a/tests/Unit/RankModelTest.php b/tests/Unit/RankModelTest.php new file mode 100644 index 0000000..8e9bc10 --- /dev/null +++ b/tests/Unit/RankModelTest.php @@ -0,0 +1,22 @@ + '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); +}); diff --git a/tests/Unit/ThreadControllerUnitTest.php b/tests/Unit/ThreadControllerUnitTest.php new file mode 100644 index 0000000..3846e7a --- /dev/null +++ b/tests/Unit/ThreadControllerUnitTest.php @@ -0,0 +1,226 @@ + '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('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'); +}); diff --git a/tests/Unit/ThreadModelTest.php b/tests/Unit/ThreadModelTest.php new file mode 100644 index 0000000..a5d0fce --- /dev/null +++ b/tests/Unit/ThreadModelTest.php @@ -0,0 +1,73 @@ + '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); +}); diff --git a/tests/Unit/UploadControllerUnitTest.php b/tests/Unit/UploadControllerUnitTest.php new file mode 100644 index 0000000..8d510a2 --- /dev/null +++ b/tests/Unit/UploadControllerUnitTest.php @@ -0,0 +1,14 @@ +setUserResolver(fn () => null); + + $response = $controller->storeAvatar($request); + + expect($response->getStatusCode())->toBe(401); +}); diff --git a/tests/Unit/UserControllerUnitTest.php b/tests/Unit/UserControllerUnitTest.php new file mode 100644 index 0000000..6f9c16b --- /dev/null +++ b/tests/Unit/UserControllerUnitTest.php @@ -0,0 +1,81 @@ +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'); +});