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

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

View File

@@ -0,0 +1,284 @@
<?php
use App\Models\Attachment;
use App\Models\AttachmentExtension;
use App\Models\AttachmentGroup;
use App\Models\Forum;
use App\Models\Post;
use App\Models\Thread;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
function makeThreadForAttachments(?User $owner = null): Thread
{
$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,
]);
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]);
});

View File

@@ -0,0 +1,114 @@
<?php
use App\Models\Attachment;
use App\Models\AttachmentExtension;
use App\Models\AttachmentGroup;
use App\Models\Role;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
function makeAdminForAttachmentExtensions(): User
{
$admin = User::factory()->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.']);
});

View File

@@ -0,0 +1,136 @@
<?php
use App\Models\Attachment;
use App\Models\AttachmentExtension;
use App\Models\AttachmentGroup;
use App\Models\Role;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
function makeAdminForAttachmentGroups(): User
{
$admin = User::factory()->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,
]);
});

View File

@@ -0,0 +1,46 @@
<?php
use App\Models\AuditLog;
use App\Models\Role;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
it('requires authentication to list audit logs', function (): void {
$response = $this->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',
]);
});

View File

@@ -0,0 +1,180 @@
<?php
use App\Models\User;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\URL;
use Laravel\Sanctum\Sanctum;
it('registers a user with username and plainPassword', function (): void {
Notification::fake();
$response = $this->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);
});

View File

@@ -0,0 +1,111 @@
<?php
use App\Models\Forum;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
it('can filter forums by parent exists', function (): void {
$category = Forum::create([
'name' => '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,
]);
});

View File

@@ -0,0 +1,13 @@
<?php
it('returns translations for valid locale', function (): void {
$response = $this->getJson('/api/i18n/en');
$response->assertOk();
});
it('returns 404 for missing locale', function (): void {
$response = $this->getJson('/api/i18n/xx');
$response->assertStatus(404);
});

View File

@@ -0,0 +1,23 @@
<?php
it('redirects installer when env exists', function (): void {
$response = $this->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('/');
});

View File

@@ -0,0 +1,48 @@
<?php
use App\Models\Forum;
use App\Models\Post;
use App\Models\Thread;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
it('returns portal summary payload', function (): void {
$user = User::factory()->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']);
});

View File

@@ -0,0 +1,209 @@
<?php
use App\Models\Forum;
use App\Models\Post;
use App\Models\PostThank;
use App\Models\Role;
use App\Models\Thread;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
function makeThread(): Thread
{
$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,
]);
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,
]);
});

View File

@@ -0,0 +1,92 @@
<?php
use App\Models\Forum;
use App\Models\Post;
use App\Models\PostThank;
use App\Models\Thread;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
function makeThanksThread(): Thread
{
$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,
]);
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',
]);
});

View File

@@ -0,0 +1,23 @@
<?php
it('renders bbcode preview', function (): void {
$user = \App\Models\User::factory()->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']);
});

View File

@@ -0,0 +1,107 @@
<?php
use App\Models\Rank;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
function makeAdminForRanks(): User
{
$admin = User::factory()->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]);
});

View File

@@ -0,0 +1,92 @@
<?php
use App\Models\Role;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
function makeAdminForRoles(): User
{
$admin = User::factory()->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]);
});

View File

@@ -0,0 +1,89 @@
<?php
use App\Models\Role;
use App\Models\Setting;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
function makeAdminUser(): User
{
$admin = User::factory()->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',
]);
});

View File

@@ -0,0 +1,85 @@
<?php
use App\Models\Attachment;
use App\Models\Forum;
use App\Models\Post;
use App\Models\Thread;
use App\Models\User;
use Illuminate\Support\Facades\Storage;
it('returns forum statistics summary', function (): void {
Storage::fake('public');
$user = User::factory()->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,
]);
});

View File

@@ -0,0 +1,13 @@
<?php
use App\Models\User;
use Laravel\Sanctum\Sanctum;
it('forbids system status for non-admins', function (): void {
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->getJson('/api/system/status');
$response->assertStatus(403);
});

View File

@@ -0,0 +1,29 @@
<?php
use App\Models\Role;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
it('forbids system update for non-admins', function (): void {
$user = User::factory()->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.']);
});

View File

@@ -0,0 +1,248 @@
<?php
use App\Models\Forum;
use App\Models\Role;
use App\Models\Thread;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
function makeForum(): Forum
{
$category = Forum::create([
'name' => 'Category',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
return Forum::create([
'name' => 'Forum',
'description' => null,
'type' => 'forum',
'parent_id' => $category->id,
'position' => 1,
]);
}
it('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,
]);
});

View File

@@ -0,0 +1,97 @@
<?php
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\Sanctum;
it('requires authentication for avatar upload', function (): void {
$response = $this->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'));
});

View File

@@ -0,0 +1,287 @@
<?php
use App\Models\Rank;
use App\Models\Role;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
function makeAdmin(): User
{
$admin = User::factory()->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();
});

View File

@@ -0,0 +1,63 @@
<?php
use App\Models\User;
use App\Models\UserSetting;
use Laravel\Sanctum\Sanctum;
it('lists user settings with optional key filter', function (): void {
$user = User::factory()->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',
]);
});

View File

@@ -0,0 +1,107 @@
<?php
use App\Models\Setting;
use Illuminate\Support\Facades\Http;
function setGiteaEnv(?string $owner, ?string $repo, ?string $apiBase = null, ?string $token = null): void
{
$pairs = [
'GITEA_OWNER' => $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.',
]);
});

View File

@@ -0,0 +1,16 @@
<?php
use App\Models\Setting;
it('returns version and build info', function (): void {
Setting::updateOrCreate(['key' => '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,
]);
});