Add extensive controller and model tests
This commit is contained in:
@@ -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"
|
||||
|
||||
284
tests/Feature/AttachmentControllerTest.php
Normal file
284
tests/Feature/AttachmentControllerTest.php
Normal 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]);
|
||||
});
|
||||
114
tests/Feature/AttachmentExtensionControllerTest.php
Normal file
114
tests/Feature/AttachmentExtensionControllerTest.php
Normal 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.']);
|
||||
});
|
||||
136
tests/Feature/AttachmentGroupControllerTest.php
Normal file
136
tests/Feature/AttachmentGroupControllerTest.php
Normal 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,
|
||||
]);
|
||||
});
|
||||
46
tests/Feature/AuditLogControllerTest.php
Normal file
46
tests/Feature/AuditLogControllerTest.php
Normal 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',
|
||||
]);
|
||||
});
|
||||
180
tests/Feature/AuthControllerTest.php
Normal file
180
tests/Feature/AuthControllerTest.php
Normal 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);
|
||||
});
|
||||
111
tests/Feature/ForumControllerTest.php
Normal file
111
tests/Feature/ForumControllerTest.php
Normal 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,
|
||||
]);
|
||||
});
|
||||
13
tests/Feature/I18nControllerTest.php
Normal file
13
tests/Feature/I18nControllerTest.php
Normal 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);
|
||||
});
|
||||
23
tests/Feature/InstallerControllerTest.php
Normal file
23
tests/Feature/InstallerControllerTest.php
Normal 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('/');
|
||||
});
|
||||
48
tests/Feature/PortalControllerTest.php
Normal file
48
tests/Feature/PortalControllerTest.php
Normal 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']);
|
||||
});
|
||||
209
tests/Feature/PostControllerTest.php
Normal file
209
tests/Feature/PostControllerTest.php
Normal 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,
|
||||
]);
|
||||
});
|
||||
92
tests/Feature/PostThankControllerTest.php
Normal file
92
tests/Feature/PostThankControllerTest.php
Normal 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',
|
||||
]);
|
||||
});
|
||||
23
tests/Feature/PreviewControllerTest.php
Normal file
23
tests/Feature/PreviewControllerTest.php
Normal 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']);
|
||||
});
|
||||
107
tests/Feature/RankControllerTest.php
Normal file
107
tests/Feature/RankControllerTest.php
Normal 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]);
|
||||
});
|
||||
92
tests/Feature/RoleControllerTest.php
Normal file
92
tests/Feature/RoleControllerTest.php
Normal 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]);
|
||||
});
|
||||
89
tests/Feature/SettingControllerTest.php
Normal file
89
tests/Feature/SettingControllerTest.php
Normal 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',
|
||||
]);
|
||||
});
|
||||
85
tests/Feature/StatsControllerTest.php
Normal file
85
tests/Feature/StatsControllerTest.php
Normal 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,
|
||||
]);
|
||||
});
|
||||
13
tests/Feature/SystemStatusControllerTest.php
Normal file
13
tests/Feature/SystemStatusControllerTest.php
Normal 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);
|
||||
});
|
||||
29
tests/Feature/SystemUpdateControllerTest.php
Normal file
29
tests/Feature/SystemUpdateControllerTest.php
Normal 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.']);
|
||||
});
|
||||
248
tests/Feature/ThreadControllerTest.php
Normal file
248
tests/Feature/ThreadControllerTest.php
Normal 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,
|
||||
]);
|
||||
});
|
||||
97
tests/Feature/UploadControllerTest.php
Normal file
97
tests/Feature/UploadControllerTest.php
Normal 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'));
|
||||
});
|
||||
287
tests/Feature/UserControllerTest.php
Normal file
287
tests/Feature/UserControllerTest.php
Normal 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();
|
||||
});
|
||||
63
tests/Feature/UserSettingControllerTest.php
Normal file
63
tests/Feature/UserSettingControllerTest.php
Normal 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',
|
||||
]);
|
||||
});
|
||||
107
tests/Feature/VersionCheckControllerTest.php
Normal file
107
tests/Feature/VersionCheckControllerTest.php
Normal 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.',
|
||||
]);
|
||||
});
|
||||
16
tests/Feature/VersionControllerTest.php
Normal file
16
tests/Feature/VersionControllerTest.php
Normal 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,
|
||||
]);
|
||||
});
|
||||
5
tests/Pest.php
Normal file
5
tests/Pest.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(Tests\TestCase::class, RefreshDatabase::class)->in('Feature', 'Unit');
|
||||
33
tests/Unit/AttachmentGroupModelTest.php
Normal file
33
tests/Unit/AttachmentGroupModelTest.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AttachmentExtension;
|
||||
use App\Models\AttachmentGroup;
|
||||
|
||||
it('exposes attachment group hierarchy and extensions', function (): void {
|
||||
$parent = AttachmentGroup::create([
|
||||
'name' => 'Parent',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$child = AttachmentGroup::create([
|
||||
'name' => 'Child',
|
||||
'parent_id' => $parent->id,
|
||||
'position' => 1,
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$extension = AttachmentExtension::create([
|
||||
'extension' => 'png',
|
||||
'attachment_group_id' => $parent->id,
|
||||
'allowed_mimes' => ['image/png'],
|
||||
]);
|
||||
|
||||
$parent->load(['children', 'extensions']);
|
||||
$child->load('parent');
|
||||
|
||||
expect($parent->children->first()->id)->toBe($child->id);
|
||||
expect($parent->extensions->first()->id)->toBe($extension->id);
|
||||
expect($child->parent?->id)->toBe($parent->id);
|
||||
});
|
||||
72
tests/Unit/AttachmentModelTest.php
Normal file
72
tests/Unit/AttachmentModelTest.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Attachment;
|
||||
use App\Models\AttachmentExtension;
|
||||
use App\Models\AttachmentGroup;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
|
||||
it('exposes attachment relationships', function (): void {
|
||||
$category = Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => 'Post',
|
||||
]);
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Docs',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$extension = AttachmentExtension::create([
|
||||
'extension' => 'pdf',
|
||||
'attachment_group_id' => $group->id,
|
||||
'allowed_mimes' => ['application/pdf'],
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => $post->id,
|
||||
'attachment_extension_id' => $extension->id,
|
||||
'attachment_group_id' => $group->id,
|
||||
'user_id' => $user->id,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/posts/'.$post->id.'/file.pdf',
|
||||
'original_name' => 'file.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$attachment->load(['thread', 'post', 'extension', 'group', 'user']);
|
||||
|
||||
expect($attachment->thread?->id)->toBe($thread->id);
|
||||
expect($attachment->post?->id)->toBe($post->id);
|
||||
expect($attachment->extension()->first()?->id)->toBe($extension->id);
|
||||
expect($attachment->group()->first()?->id)->toBe($group->id);
|
||||
expect($attachment->user?->id)->toBe($user->id);
|
||||
});
|
||||
214
tests/Unit/AttachmentThumbnailServiceBranchesTest.php
Normal file
214
tests/Unit/AttachmentThumbnailServiceBranchesTest.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services {
|
||||
if (!isset($GLOBALS['attachment_thumbnail_overrides'])) {
|
||||
$GLOBALS['attachment_thumbnail_overrides'] = [
|
||||
'force_imagecreatetruecolor_fail' => false,
|
||||
'force_imagejpeg_fail' => false,
|
||||
'force_imagecreatefromjpeg_fail' => false,
|
||||
'fake_getimagesize' => null,
|
||||
];
|
||||
}
|
||||
|
||||
if (!function_exists(__NAMESPACE__ . '\\imagecreatetruecolor')) {
|
||||
function imagecreatetruecolor($width, $height)
|
||||
{
|
||||
if (!empty($GLOBALS['attachment_thumbnail_overrides']['force_imagecreatetruecolor_fail'])) {
|
||||
return false;
|
||||
}
|
||||
return \imagecreatetruecolor($width, $height);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists(__NAMESPACE__ . '\\imagecreatefromjpeg')) {
|
||||
function imagecreatefromjpeg($path)
|
||||
{
|
||||
if (!empty($GLOBALS['attachment_thumbnail_overrides']['force_imagecreatefromjpeg_fail'])) {
|
||||
return false;
|
||||
}
|
||||
return \imagecreatefromjpeg($path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists(__NAMESPACE__ . '\\getimagesize')) {
|
||||
function getimagesize($path)
|
||||
{
|
||||
$override = $GLOBALS['attachment_thumbnail_overrides']['fake_getimagesize'] ?? null;
|
||||
if ($override !== null) {
|
||||
return $override;
|
||||
}
|
||||
return \getimagesize($path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists(__NAMESPACE__ . '\\imagejpeg')) {
|
||||
function imagejpeg($image, $to = null, $quality = null)
|
||||
{
|
||||
if (!empty($GLOBALS['attachment_thumbnail_overrides']['force_imagejpeg_fail'])) {
|
||||
return false;
|
||||
}
|
||||
if ($quality === null) {
|
||||
return \imagejpeg($image, $to);
|
||||
}
|
||||
return \imagejpeg($image, $to, $quality);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use App\Models\Attachment;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Thread;
|
||||
use App\Services\AttachmentThumbnailService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
if (!function_exists('imagewebp')) {
|
||||
function imagewebp($image, $to = null, $quality = null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function (): void {
|
||||
$GLOBALS['attachment_thumbnail_overrides'] = [
|
||||
'force_imagecreatetruecolor_fail' => false,
|
||||
'force_imagejpeg_fail' => false,
|
||||
'force_imagecreatefromjpeg_fail' => false,
|
||||
'fake_getimagesize' => null,
|
||||
];
|
||||
});
|
||||
|
||||
it('uses misc scope for attachments without thread or post', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
|
||||
$image = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||
$path = 'attachments/misc/photo.jpg';
|
||||
Storage::disk('local')->put($path, file_get_contents($image->getPathname()));
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'original_name' => 'photo.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 1234,
|
||||
]);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$result = $service->createForAttachment($attachment, true);
|
||||
|
||||
expect($result)->not->toBeNull();
|
||||
expect($result['path'])->toContain('attachments/misc/thumbs/');
|
||||
});
|
||||
|
||||
it('returns null when image dimensions are zero', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
|
||||
$GLOBALS['attachment_thumbnail_overrides']['fake_getimagesize'] = [0, 0, IMAGETYPE_JPEG];
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||
|
||||
$result = $service->createForUpload($file, 'threads/1', 'local');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when thumbnail image creation fails', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
|
||||
$GLOBALS['attachment_thumbnail_overrides']['force_imagecreatetruecolor_fail'] = true;
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||
|
||||
$result = $service->createForUpload($file, 'threads/1', 'local');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when renderer fails to encode', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
|
||||
$GLOBALS['attachment_thumbnail_overrides']['force_imagejpeg_fail'] = true;
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||
|
||||
$result = $service->createForUpload($file, 'threads/1', 'local');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('handles webp branch in image loader', function (): void {
|
||||
$service = new AttachmentThumbnailService();
|
||||
$ref = new ReflectionMethod($service, 'createImageFromFile');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$temp = tempnam(sys_get_temp_dir(), 'webp');
|
||||
file_put_contents($temp, 'not-a-real-webp');
|
||||
|
||||
$result = $ref->invoke($service, $temp, 'image/webp');
|
||||
expect($result === null || $result === false)->toBeTrue();
|
||||
|
||||
unlink($temp);
|
||||
});
|
||||
|
||||
it('handles webp branch in renderer when available', function (): void {
|
||||
$service = new AttachmentThumbnailService();
|
||||
$ref = new ReflectionMethod($service, 'renderImageBinary');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$image = \imagecreatetruecolor(10, 10);
|
||||
$data = $ref->invoke($service, $image, 'image/webp', 80);
|
||||
|
||||
expect($data === null || is_string($data))->toBeTrue();
|
||||
imagedestroy($image);
|
||||
});
|
||||
|
||||
it('returns default when setting is missing', function (): void {
|
||||
$service = new AttachmentThumbnailService();
|
||||
$ref = new ReflectionMethod($service, 'settingBool');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$result = $ref->invoke($service, 'attachments.missing_flag', true);
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
}
|
||||
446
tests/Unit/AttachmentThumbnailServiceTest.php
Normal file
446
tests/Unit/AttachmentThumbnailServiceTest.php
Normal file
@@ -0,0 +1,446 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Services\AttachmentThumbnailService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
it('returns null for non-images', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->create('document.txt', 10, 'text/plain');
|
||||
|
||||
$result = $service->createForUpload($file, 'misc', 'local');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('creates thumbnail for large images', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_quality'], ['value' => '80']);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||
|
||||
$result = $service->createForUpload($file, 'threads/10', 'local');
|
||||
|
||||
expect($result)->not->toBeNull();
|
||||
expect($result)->toHaveKeys(['path', 'mime', 'size']);
|
||||
|
||||
Storage::disk('local')->assertExists($result['path']);
|
||||
});
|
||||
|
||||
it('skips thumbnail when disabled or too small', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'false']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '300']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '300']);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->image('small.jpg', 100, 100);
|
||||
|
||||
$result = $service->createForUpload($file, 'threads/1', 'local');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('skips thumbnail when image is within size limits', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '300']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '300']);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->image('small.jpg', 100, 100);
|
||||
|
||||
$result = $service->createForUpload($file, 'threads/1', 'local');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when max dimensions are invalid', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '0']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '0']);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||
|
||||
$result = $service->createForUpload($file, 'threads/1', 'local');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid image payloads', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->create('bad.jpg', 10, 'image/jpeg');
|
||||
|
||||
$result = $service->createForUpload($file, 'threads/1', 'local');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('creates thumbnail for existing attachments', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
|
||||
$category = \App\Models\Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = \App\Models\Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
$thread = \App\Models\Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$image = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||
$path = "attachments/threads/{$thread->id}/photo.jpg";
|
||||
Storage::disk('local')->put($path, file_get_contents($image->getPathname()));
|
||||
|
||||
$attachment = \App\Models\Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'original_name' => 'photo.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 1234,
|
||||
]);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$result = $service->createForAttachment($attachment, true);
|
||||
|
||||
expect($result)->not->toBeNull();
|
||||
Storage::disk('local')->assertExists($result['path']);
|
||||
});
|
||||
|
||||
it('returns null for attachments without stored files', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$category = \App\Models\Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = \App\Models\Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
$thread = \App\Models\Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$attachment = \App\Models\Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => "attachments/threads/{$thread->id}/missing.jpg",
|
||||
'original_name' => 'missing.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$result = $service->createForAttachment($attachment, true);
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when thumbnail already exists and not forcing', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$category = \App\Models\Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = \App\Models\Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
$thread = \App\Models\Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$attachment = \App\Models\Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => "attachments/threads/{$thread->id}/photo.jpg",
|
||||
'thumbnail_path' => "attachments/threads/{$thread->id}/thumbs/existing.jpg",
|
||||
'original_name' => 'photo.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put($attachment->thumbnail_path, 'thumb');
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$result = $service->createForAttachment($attachment, false);
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-image attachments', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$category = \App\Models\Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = \App\Models\Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
$thread = \App\Models\Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$attachment = \App\Models\Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => "attachments/threads/{$thread->id}/doc.pdf",
|
||||
'original_name' => 'doc.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$result = $service->createForAttachment($attachment, true);
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('uses post scope when creating thumbnails', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
|
||||
$category = \App\Models\Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = \App\Models\Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
$thread = \App\Models\Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = \App\Models\Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Post',
|
||||
]);
|
||||
|
||||
$image = UploadedFile::fake()->image('photo.png', 800, 600);
|
||||
$path = "attachments/posts/{$post->id}/photo.png";
|
||||
Storage::disk('local')->put($path, file_get_contents($image->getPathname()));
|
||||
|
||||
$attachment = \App\Models\Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => $post->id,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'original_name' => 'photo.png',
|
||||
'extension' => 'png',
|
||||
'mime_type' => 'image/png',
|
||||
'size_bytes' => 1234,
|
||||
]);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$result = $service->createForAttachment($attachment, true);
|
||||
|
||||
expect($result)->not->toBeNull();
|
||||
expect($result['path'])->toContain("attachments/posts/{$post->id}/thumbs/");
|
||||
});
|
||||
|
||||
it('returns null when mime is unsupported even if image data is valid', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
|
||||
$category = \App\Models\Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = \App\Models\Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
$thread = \App\Models\Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$image = UploadedFile::fake()->image('photo.png', 800, 600);
|
||||
$path = "attachments/threads/{$thread->id}/photo.png";
|
||||
Storage::disk('local')->put($path, file_get_contents($image->getPathname()));
|
||||
|
||||
$attachment = \App\Models\Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'original_name' => 'photo.png',
|
||||
'extension' => 'png',
|
||||
'mime_type' => 'image/avif',
|
||||
'size_bytes' => 1234,
|
||||
]);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$result = $service->createForAttachment($attachment, true);
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('creates thumbnails for gif images', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
if (!function_exists('imagegif')) {
|
||||
$this->markTestSkipped('GIF support not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->image('photo.gif', 800, 600);
|
||||
|
||||
$result = $service->createForUpload($file, 'threads/1', 'local');
|
||||
|
||||
expect($result)->not->toBeNull();
|
||||
Storage::disk('local')->assertExists($result['path']);
|
||||
});
|
||||
66
tests/Unit/AuditLoggerTest.php
Normal file
66
tests/Unit/AuditLoggerTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\User;
|
||||
use App\Services\AuditLogger;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
it('creates audit log with actor and subject', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$subject = User::factory()->create();
|
||||
|
||||
$request = Request::create('/api/test', 'POST');
|
||||
$request->headers->set('User-Agent', 'phpunit');
|
||||
$request->server->set('REMOTE_ADDR', '127.0.0.1');
|
||||
|
||||
$logger = new AuditLogger();
|
||||
$result = $logger->log(
|
||||
$request,
|
||||
'user.updated',
|
||||
$subject,
|
||||
['field' => 'name'],
|
||||
$user
|
||||
);
|
||||
|
||||
expect($result)->toBeInstanceOf(AuditLog::class);
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
'user_id' => $user->id,
|
||||
'action' => 'user.updated',
|
||||
'subject_type' => $subject::class,
|
||||
'subject_id' => $subject->id,
|
||||
'ip_address' => '127.0.0.1',
|
||||
'user_agent' => 'phpunit',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles missing user', function (): void {
|
||||
$request = Request::create('/api/test', 'POST');
|
||||
$request->headers->set('User-Agent', 'phpunit');
|
||||
$request->server->set('REMOTE_ADDR', '127.0.0.1');
|
||||
|
||||
$logger = new AuditLogger();
|
||||
$result = $logger->log($request, 'system.ping');
|
||||
|
||||
expect($result)->toBeInstanceOf(AuditLog::class);
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
'action' => 'system.ping',
|
||||
'user_id' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null when logging fails', function (): void {
|
||||
AuditLog::creating(function () {
|
||||
throw new RuntimeException('fail');
|
||||
});
|
||||
|
||||
$request = Request::create('/api/test', 'POST');
|
||||
$request->headers->set('User-Agent', 'phpunit');
|
||||
$request->server->set('REMOTE_ADDR', '127.0.0.1');
|
||||
|
||||
$logger = new AuditLogger();
|
||||
$result = $logger->log($request, 'system.fail');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
|
||||
AuditLog::flushEventListeners();
|
||||
});
|
||||
34
tests/Unit/FortifyServiceProviderTest.php
Normal file
34
tests/Unit/FortifyServiceProviderTest.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\FortifyServiceProvider;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Session\Store;
|
||||
use Illuminate\Session\ArraySessionHandler;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
it('registers rate limiters for login and two-factor', function (): void {
|
||||
(new FortifyServiceProvider(app()))->boot();
|
||||
|
||||
$loginLimiter = RateLimiter::limiter('login');
|
||||
$twoFactorLimiter = RateLimiter::limiter('two-factor');
|
||||
|
||||
$request = Request::create('/login', 'POST', [
|
||||
Fortify::username() => 'Test@Example.com',
|
||||
]);
|
||||
$request->server->set('REMOTE_ADDR', '127.0.0.1');
|
||||
$request->setLaravelSession(new Store('test', new ArraySessionHandler(60)));
|
||||
$request->session()->put('login.id', 'login-id');
|
||||
|
||||
$loginLimit = $loginLimiter($request);
|
||||
|
||||
expect($loginLimit)->toBeInstanceOf(Limit::class);
|
||||
expect($loginLimit->maxAttempts)->toBe(5);
|
||||
expect($loginLimit->key)->toBe(Str::transliterate('test@example.com|127.0.0.1'));
|
||||
|
||||
$twoFactorLimit = $twoFactorLimiter($request);
|
||||
expect($twoFactorLimit)->toBeInstanceOf(Limit::class);
|
||||
expect($twoFactorLimit->maxAttempts)->toBe(5);
|
||||
});
|
||||
74
tests/Unit/ForumModelTest.php
Normal file
74
tests/Unit/ForumModelTest.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
it('exposes forum relationships and latest helpers', function (): void {
|
||||
$category = Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$forum = Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 2,
|
||||
]);
|
||||
|
||||
$child = Forum::create([
|
||||
'name' => 'Child',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $forum->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$threadOld = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Old Thread',
|
||||
'body' => 'Old',
|
||||
'created_at' => Carbon::now()->subDays(2),
|
||||
]);
|
||||
|
||||
$threadNew = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'New Thread',
|
||||
'body' => 'New',
|
||||
'created_at' => Carbon::now()->subDay(),
|
||||
]);
|
||||
|
||||
$postOld = Post::create([
|
||||
'thread_id' => $threadOld->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => 'Old post',
|
||||
'created_at' => Carbon::now()->subDays(2),
|
||||
]);
|
||||
|
||||
$postNew = Post::create([
|
||||
'thread_id' => $threadNew->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => 'New post',
|
||||
'created_at' => Carbon::now()->subDay(),
|
||||
]);
|
||||
|
||||
$forum->load(['parent', 'children', 'threads', 'posts', 'latestThread', 'latestPost']);
|
||||
|
||||
expect($forum->parent?->id)->toBe($category->id);
|
||||
expect($forum->children->first()->id)->toBe($child->id);
|
||||
expect($forum->threads)->toHaveCount(2);
|
||||
expect($forum->posts)->toHaveCount(2);
|
||||
expect($forum->latestThread?->id)->toBe($threadNew->id);
|
||||
expect($forum->latestPost?->id)->toBe($postNew->id);
|
||||
});
|
||||
65
tests/Unit/PostModelTest.php
Normal file
65
tests/Unit/PostModelTest.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Attachment;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostThank;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
|
||||
it('exposes post relationships', function (): void {
|
||||
$category = Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => 'Post body',
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => $post->id,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => $user->id,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/posts/'.$post->id.'/file.pdf',
|
||||
'original_name' => 'file.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$thank = PostThank::create([
|
||||
'post_id' => $post->id,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$post->load(['thread', 'user', 'attachments', 'thanks']);
|
||||
|
||||
expect($post->thread->id)->toBe($thread->id);
|
||||
expect($post->user->id)->toBe($user->id);
|
||||
expect($post->attachments->first()->id)->toBe($attachment->id);
|
||||
expect($post->thanks->first()->id)->toBe($thank->id);
|
||||
});
|
||||
22
tests/Unit/RankModelTest.php
Normal file
22
tests/Unit/RankModelTest.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Rank;
|
||||
use App\Models\User;
|
||||
|
||||
it('relates ranks to users', function (): void {
|
||||
$rank = Rank::create([
|
||||
'name' => 'Gold',
|
||||
'badge_type' => 'text',
|
||||
'badge_text' => 'G',
|
||||
'color' => '#ffaa00',
|
||||
]);
|
||||
|
||||
$user = User::factory()->create([
|
||||
'rank_id' => $rank->id,
|
||||
]);
|
||||
|
||||
$rank->load('users');
|
||||
|
||||
expect($rank->users)->toHaveCount(1);
|
||||
expect($rank->users->first()->id)->toBe($user->id);
|
||||
});
|
||||
226
tests/Unit/ThreadControllerUnitTest.php
Normal file
226
tests/Unit/ThreadControllerUnitTest.php
Normal file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\ThreadController;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\AttachmentGroup;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use App\Models\Role;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
function makeForumForThreadController(): Forum
|
||||
{
|
||||
$category = Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
return Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
it('returns unauthorized for update when no user', function (): void {
|
||||
$controller = new ThreadController();
|
||||
$forum = makeForumForThreadController();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$request = Request::create("/api/threads/{$thread->id}", 'PATCH', [
|
||||
'title' => 'New',
|
||||
]);
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->update($request, $thread);
|
||||
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('returns unauthorized for updateSolved when no user', function (): void {
|
||||
$controller = new ThreadController();
|
||||
$forum = makeForumForThreadController();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$request = Request::create("/api/threads/{$thread->id}/solved", 'PATCH', [
|
||||
'solved' => true,
|
||||
]);
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->updateSolved($request, $thread);
|
||||
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('serializes threads with attachments, group colors, and inline images', function (): void {
|
||||
Storage::fake('public');
|
||||
|
||||
$forum = makeForumForThreadController();
|
||||
$role = Role::create(['name' => 'ROLE_HELPER', 'color' => '#ff0000']);
|
||||
$user = User::factory()->create([
|
||||
'avatar_path' => 'avatars/u.png',
|
||||
'location' => 'Somewhere',
|
||||
]);
|
||||
$user->roles()->attach($role);
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => '1']);
|
||||
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'See [attachment]image.png[/attachment]',
|
||||
]);
|
||||
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => 'Reply',
|
||||
]);
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Images',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => $group->id,
|
||||
'user_id' => $user->id,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/image.png',
|
||||
'thumbnail_path' => 'attachments/threads/'.$thread->id.'/thumbs/image.png',
|
||||
'original_name' => 'image.png',
|
||||
'extension' => 'png',
|
||||
'mime_type' => 'image/png',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$thread->load(['user.roles', 'attachments.group', 'latestPost']);
|
||||
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'serializeThread');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$payload = $ref->invoke($controller, $thread);
|
||||
|
||||
expect($payload['user_avatar_url'])->not->toBeNull();
|
||||
expect($payload['user_group_color'])->toBe('#ff0000');
|
||||
expect($payload['attachments'][0]['group']['name'])->toBe('Images');
|
||||
expect($payload['attachments'][0]['thumbnail_url'])->toContain('/thumbnail');
|
||||
expect($payload['body_html'])->toContain('<img');
|
||||
expect($payload['last_post_id'])->toBe($post->id);
|
||||
});
|
||||
|
||||
it('replaces attachment tags with links when inline images disabled', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => '0']);
|
||||
|
||||
$forum = makeForumForThreadController();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'See [attachment]doc.pdf[/attachment]',
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/doc.pdf',
|
||||
'original_name' => 'doc.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$result = $ref->invoke($controller, $thread->body, collect([$attachment]));
|
||||
|
||||
expect($result)->toContain('[url=');
|
||||
expect($result)->toContain('doc.pdf');
|
||||
});
|
||||
|
||||
it('returns body unchanged when no attachments are present', function (): void {
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$result = $ref->invoke($controller, 'No attachments', []);
|
||||
|
||||
expect($result)->toBe('No attachments');
|
||||
});
|
||||
|
||||
it('returns original tag when attachment name does not match', function (): void {
|
||||
$forum = makeForumForThreadController();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'See [attachment]missing.pdf[/attachment]',
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/doc.pdf',
|
||||
'original_name' => 'doc.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$result = $ref->invoke($controller, $thread->body, collect([$attachment]));
|
||||
|
||||
expect($result)->toContain('[attachment]missing.pdf[/attachment]');
|
||||
});
|
||||
|
||||
it('returns body unchanged when attachment map is empty', function (): void {
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$attachment = new Attachment([
|
||||
'original_name' => '',
|
||||
]);
|
||||
|
||||
$result = $ref->invoke($controller, 'Body', collect([$attachment]));
|
||||
|
||||
expect($result)->toBe('Body');
|
||||
});
|
||||
73
tests/Unit/ThreadModelTest.php
Normal file
73
tests/Unit/ThreadModelTest.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Attachment;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
it('casts solved flag and exposes relationships', function (): void {
|
||||
$category = Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$forum = Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
'solved' => 1,
|
||||
]);
|
||||
|
||||
$oldPost = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => 'Old post',
|
||||
'created_at' => Carbon::now()->subDay(),
|
||||
]);
|
||||
|
||||
$newPost = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => 'New post',
|
||||
'created_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => $user->id,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/file.pdf',
|
||||
'original_name' => 'file.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$thread->load(['forum', 'user', 'posts', 'attachments', 'latestPost']);
|
||||
|
||||
expect($thread->solved)->toBeTrue();
|
||||
expect($thread->forum->id)->toBe($forum->id);
|
||||
expect($thread->user->id)->toBe($user->id);
|
||||
expect($thread->posts)->toHaveCount(2);
|
||||
expect($thread->attachments->first()->id)->toBe($attachment->id);
|
||||
expect($thread->latestPost->id)->toBe($newPost->id);
|
||||
});
|
||||
14
tests/Unit/UploadControllerUnitTest.php
Normal file
14
tests/Unit/UploadControllerUnitTest.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\UploadController;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
it('returns unauthorized when storeAvatar has no user', function (): void {
|
||||
$controller = new UploadController();
|
||||
$request = Request::create('/api/user/avatar', 'POST');
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->storeAvatar($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
81
tests/Unit/UserControllerUnitTest.php
Normal file
81
tests/Unit/UserControllerUnitTest.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\UserController;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
it('returns unauthenticated for me when no user', function (): void {
|
||||
$controller = new UserController();
|
||||
$request = Request::create('/api/user/me', 'GET');
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->me($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('returns unauthenticated for updateMe when no user', function (): void {
|
||||
$controller = new UserController();
|
||||
$request = Request::create('/api/user/me', 'PATCH', [
|
||||
'location' => 'Test',
|
||||
]);
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->updateMe($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('trims blank location to null in updateMe', function (): void {
|
||||
$controller = new UserController();
|
||||
$user = User::factory()->create(['location' => 'Somewhere']);
|
||||
|
||||
$request = Request::create('/api/user/me', 'PATCH', [
|
||||
'location' => ' ',
|
||||
]);
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
$response = $controller->updateMe($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$user->refresh();
|
||||
expect($user->location)->toBeNull();
|
||||
});
|
||||
|
||||
it('resolves avatar urls when present', function (): void {
|
||||
Storage::fake('public');
|
||||
|
||||
$controller = new UserController();
|
||||
$user = User::factory()->create([
|
||||
'avatar_path' => 'avatars/test.png',
|
||||
]);
|
||||
|
||||
$response = $controller->profile($user);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
expect($response->getData(true)['avatar_url'])->not->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null group color when roles relation is null', function (): void {
|
||||
$controller = new UserController();
|
||||
$user = User::factory()->create();
|
||||
$user->setRelation('roles', null);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$result = $ref->invoke($controller, $user);
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('normalizes empty and raw role names', function (): void {
|
||||
$controller = new UserController();
|
||||
$ref = new ReflectionMethod($controller, 'normalizeRoleName');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, ''))->toBe('ROLE_');
|
||||
expect($ref->invoke($controller, 'moderator'))->toBe('ROLE_MODERATOR');
|
||||
});
|
||||
Reference in New Issue
Block a user