Add comprehensive test coverage and update notes
This commit is contained in:
555
tests/Unit/AttachmentControllerUnitTest.php
Normal file
555
tests/Unit/AttachmentControllerUnitTest.php
Normal file
@@ -0,0 +1,555 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AttachmentController;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\AttachmentExtension;
|
||||
use App\Models\AttachmentGroup;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
function makeForumForAttachments(): Forum
|
||||
{
|
||||
$category = Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
return Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
it('store returns unauthorized without user', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$request = Request::create('/api/attachments', 'POST');
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->store($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('store returns file missing when file not provided', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$request = Request::create('/api/attachments', 'POST', [
|
||||
'thread' => '/api/threads/1',
|
||||
]);
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
try {
|
||||
$controller->store($request);
|
||||
$this->fail('Expected ValidationException not thrown.');
|
||||
} catch (Illuminate\Validation\ValidationException $e) {
|
||||
expect($e->errors())->toHaveKey('file');
|
||||
}
|
||||
});
|
||||
|
||||
it('store returns file missing when request file is null after validation', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$user = User::factory()->create();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$request = Mockery::mock(Request::class)->makePartial();
|
||||
$request->shouldReceive('user')->andReturn($user);
|
||||
$request->shouldReceive('validate')->andReturn([
|
||||
'thread' => "/api/threads/{$thread->id}",
|
||||
'post' => null,
|
||||
'file' => 'ignored',
|
||||
]);
|
||||
$request->shouldReceive('file')->andReturn(null);
|
||||
|
||||
$response = $controller->store($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
});
|
||||
|
||||
it('store rejects disallowed extension and mime and size', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$controller = new AttachmentController();
|
||||
$user = User::factory()->create();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Docs',
|
||||
'max_size_kb' => 1,
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
$ext = AttachmentExtension::create([
|
||||
'extension' => 'pdf',
|
||||
'attachment_group_id' => $group->id,
|
||||
'allowed_mimes' => ['application/pdf'],
|
||||
]);
|
||||
|
||||
$file = UploadedFile::fake()->create('doc.pdf', 2, 'application/pdf');
|
||||
|
||||
$request = Request::create('/api/attachments', 'POST', [
|
||||
'thread' => "/api/threads/{$thread->id}",
|
||||
], [], ['file' => $file]);
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
$response = $controller->store($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
|
||||
$group->is_active = true;
|
||||
$group->save();
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
|
||||
$group->max_size_kb = 1000;
|
||||
$group->save();
|
||||
|
||||
$ext->allowed_mimes = ['image/png'];
|
||||
$ext->save();
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
|
||||
$ext->allowed_mimes = ['application/pdf'];
|
||||
$ext->save();
|
||||
|
||||
$group->max_size_kb = 0;
|
||||
$group->save();
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
});
|
||||
|
||||
it('store returns forbidden when user cannot attach to post', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$controller = new AttachmentController();
|
||||
$owner = User::factory()->create();
|
||||
$viewer = User::factory()->create();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $owner->id,
|
||||
'body' => 'Post',
|
||||
]);
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Images',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
AttachmentExtension::create([
|
||||
'extension' => 'png',
|
||||
'attachment_group_id' => $group->id,
|
||||
'allowed_mimes' => ['image/png'],
|
||||
]);
|
||||
|
||||
$file = UploadedFile::fake()->image('photo.png');
|
||||
|
||||
$request = Request::create('/api/attachments', 'POST', [
|
||||
'post' => "/api/posts/{$post->id}",
|
||||
], [], ['file' => $file]);
|
||||
$request->setUserResolver(fn () => $viewer);
|
||||
|
||||
$response = $controller->store($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('index filters by post id', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$postA = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Post A',
|
||||
]);
|
||||
$postB = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Post B',
|
||||
]);
|
||||
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => $postA->id,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/posts/'.$postA->id.'/a.txt',
|
||||
'original_name' => 'a.txt',
|
||||
'extension' => 'txt',
|
||||
'mime_type' => 'text/plain',
|
||||
'size_bytes' => 1,
|
||||
]);
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => $postB->id,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/posts/'.$postB->id.'/b.txt',
|
||||
'original_name' => 'b.txt',
|
||||
'extension' => 'txt',
|
||||
'mime_type' => 'text/plain',
|
||||
'size_bytes' => 1,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachments', 'GET', [
|
||||
'post' => "/api/posts/{$postA->id}",
|
||||
]);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$payload = $response->getData(true);
|
||||
expect(count($payload))->toBe(1);
|
||||
expect($payload[0]['post_id'])->toBe($postA->id);
|
||||
});
|
||||
|
||||
it('show returns not found when attachment is not viewable', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$attachment = new Attachment([
|
||||
'disk' => 'local',
|
||||
'path' => 'missing',
|
||||
]);
|
||||
$attachment->setRawAttributes(['id' => 1, 'deleted_at' => now()]);
|
||||
|
||||
$response = $controller->show($attachment);
|
||||
|
||||
expect($response->getStatusCode())->toBe(404);
|
||||
});
|
||||
|
||||
it('show returns attachment when viewable', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/file.pdf',
|
||||
'original_name' => 'file.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$response = $controller->show($attachment);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
});
|
||||
|
||||
it('download aborts when file missing or not viewable', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$controller = new AttachmentController();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/missing.pdf',
|
||||
'original_name' => 'missing.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
expect(fn () => $controller->download($attachment))->toThrow(HttpException::class);
|
||||
});
|
||||
|
||||
it('download aborts when attachment is not viewable', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$controller = new AttachmentController();
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/1/missing.pdf',
|
||||
'original_name' => 'missing.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
expect(fn () => $controller->download($attachment))->toThrow(HttpException::class);
|
||||
});
|
||||
|
||||
it('thumbnail aborts when missing path or file', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$controller = new AttachmentController();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/file.pdf',
|
||||
'original_name' => 'file.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
expect(fn () => $controller->thumbnail($attachment))->toThrow(HttpException::class);
|
||||
|
||||
$attachment->thumbnail_path = 'attachments/threads/'.$thread->id.'/thumb.jpg';
|
||||
$attachment->save();
|
||||
|
||||
expect(fn () => $controller->thumbnail($attachment))->toThrow(HttpException::class);
|
||||
});
|
||||
|
||||
it('thumbnail aborts when attachment is not viewable', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$controller = new AttachmentController();
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/1/file.pdf',
|
||||
'thumbnail_path' => 'attachments/threads/1/thumb.jpg',
|
||||
'original_name' => 'file.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
expect(fn () => $controller->thumbnail($attachment))->toThrow(HttpException::class);
|
||||
});
|
||||
|
||||
it('destroy returns unauthorized or forbidden', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$attachment = new Attachment(['user_id' => 999]);
|
||||
|
||||
$request = Request::create('/api/attachments/1', 'DELETE');
|
||||
$request->setUserResolver(fn () => null);
|
||||
$response = $controller->destroy($request, $attachment);
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$request->setUserResolver(fn () => $user);
|
||||
$response = $controller->destroy($request, $attachment);
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('private helpers cover parse and match branches', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
|
||||
$refThread = new ReflectionMethod($controller, 'parseThreadId');
|
||||
$refThread->setAccessible(true);
|
||||
$refPost = new ReflectionMethod($controller, 'parsePostId');
|
||||
$refPost->setAccessible(true);
|
||||
$refMatch = new ReflectionMethod($controller, 'matchesAllowed');
|
||||
$refMatch->setAccessible(true);
|
||||
$refResolve = new ReflectionMethod($controller, 'resolveExtension');
|
||||
$refResolve->setAccessible(true);
|
||||
|
||||
expect($refThread->invoke($controller, null))->toBeNull();
|
||||
expect($refThread->invoke($controller, '/threads/12'))->toBe(12);
|
||||
expect($refThread->invoke($controller, '5'))->toBe(5);
|
||||
expect($refThread->invoke($controller, 'abc'))->toBeNull();
|
||||
|
||||
expect($refPost->invoke($controller, null))->toBeNull();
|
||||
expect($refPost->invoke($controller, '/posts/7'))->toBe(7);
|
||||
expect($refPost->invoke($controller, '9'))->toBe(9);
|
||||
expect($refPost->invoke($controller, 'nope'))->toBeNull();
|
||||
|
||||
expect($refMatch->invoke($controller, 'image/png', null))->toBeTrue();
|
||||
expect($refMatch->invoke($controller, 'image/png', []))->toBeTrue();
|
||||
expect($refMatch->invoke($controller, 'image/png', ['image/jpeg']))->toBeFalse();
|
||||
expect($refMatch->invoke($controller, 'image/png', ['image/png']))->toBeTrue();
|
||||
|
||||
expect($refResolve->invoke($controller, ''))->toBeNull();
|
||||
});
|
||||
|
||||
it('canViewAttachment handles trashed and missing parents', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/file.pdf',
|
||||
'original_name' => 'file.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$refView = new ReflectionMethod($controller, 'canViewAttachment');
|
||||
$refView->setAccessible(true);
|
||||
|
||||
expect($refView->invoke($controller, $attachment))->toBeTrue();
|
||||
|
||||
$attachment->delete();
|
||||
expect($refView->invoke($controller, $attachment))->toBeFalse();
|
||||
|
||||
$attachment->restore();
|
||||
$thread->delete();
|
||||
expect($refView->invoke($controller, $attachment))->toBeFalse();
|
||||
|
||||
$thread->restore();
|
||||
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Post',
|
||||
]);
|
||||
$attachment->post_id = $post->id;
|
||||
$attachment->thread_id = null;
|
||||
$attachment->save();
|
||||
expect($refView->invoke($controller, $attachment))->toBeTrue();
|
||||
|
||||
$post->delete();
|
||||
expect($refView->invoke($controller, $attachment))->toBeFalse();
|
||||
|
||||
$attachment->post_id = null;
|
||||
$attachment->thread_id = null;
|
||||
$attachment->save();
|
||||
expect($refView->invoke($controller, $attachment))->toBeFalse();
|
||||
});
|
||||
|
||||
it('serializeAttachment returns thumbnail_url null when missing', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
|
||||
$attachment = new Attachment([
|
||||
'id' => 1,
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'extension' => 'pdf',
|
||||
'original_name' => 'file.pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'serializeAttachment');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$payload = $ref->invoke($controller, $attachment);
|
||||
|
||||
expect($payload['thumbnail_url'])->toBeNull();
|
||||
expect($payload['is_image'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('serializeAttachment includes thumbnail url when present', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
|
||||
$attachment = new Attachment([
|
||||
'id' => 2,
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'extension' => 'png',
|
||||
'original_name' => 'file.png',
|
||||
'mime_type' => 'image/png',
|
||||
'thumbnail_path' => 'thumbs/file.png',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'serializeAttachment');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$payload = $ref->invoke($controller, $attachment);
|
||||
|
||||
expect($payload['thumbnail_url'])->toContain('/thumbnail');
|
||||
expect($payload['is_image'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('canManageAttachments handles null user and admin', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$ref = new ReflectionMethod($controller, 'canManageAttachments');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, null, 1))->toBeFalse();
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$role = \App\Models\Role::create(['name' => 'ROLE_ADMIN']);
|
||||
$admin->roles()->attach($role);
|
||||
|
||||
expect($ref->invoke($controller, $admin, 999))->toBeTrue();
|
||||
});
|
||||
174
tests/Unit/AttachmentExtensionControllerUnitTest.php
Normal file
174
tests/Unit/AttachmentExtensionControllerUnitTest.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AttachmentExtensionController;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\AttachmentExtension;
|
||||
use App\Models\AttachmentGroup;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
function makeAdminUserForExtensions(): User
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$role = Role::create(['name' => 'ROLE_ADMIN']);
|
||||
$admin->roles()->attach($role);
|
||||
return $admin;
|
||||
}
|
||||
|
||||
it('index returns forbidden for non admin', function (): void {
|
||||
$controller = new AttachmentExtensionController();
|
||||
$request = Request::create('/api/attachment-extensions', 'GET');
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('store update destroy return forbidden for non admin', function (): void {
|
||||
$controller = new AttachmentExtensionController();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$store = Request::create('/api/attachment-extensions', 'POST', [
|
||||
'extension' => 'png',
|
||||
]);
|
||||
$store->setUserResolver(fn () => $user);
|
||||
$response = $controller->store($store);
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
|
||||
$extension = AttachmentExtension::create(['extension' => 'gif']);
|
||||
|
||||
$update = Request::create('/api/attachment-extensions/'.$extension->id, 'PATCH', [
|
||||
'allowed_mimes' => ['image/gif'],
|
||||
]);
|
||||
$update->setUserResolver(fn () => $user);
|
||||
$response = $controller->update($update, $extension);
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
|
||||
$destroy = Request::create('/api/attachment-extensions/'.$extension->id, 'DELETE');
|
||||
$destroy->setUserResolver(fn () => $user);
|
||||
$response = $controller->destroy($destroy, $extension);
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('store rejects invalid or duplicate extension', function (): void {
|
||||
$controller = new AttachmentExtensionController();
|
||||
$admin = makeAdminUserForExtensions();
|
||||
|
||||
$request = Request::create('/api/attachment-extensions', 'POST', [
|
||||
'extension' => '.',
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
|
||||
AttachmentExtension::create(['extension' => 'png']);
|
||||
|
||||
$request = Request::create('/api/attachment-extensions', 'POST', [
|
||||
'extension' => 'PNG',
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
});
|
||||
|
||||
it('store and update serialize group info', function (): void {
|
||||
$controller = new AttachmentExtensionController();
|
||||
$admin = makeAdminUserForExtensions();
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Images',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-extensions', 'POST', [
|
||||
'extension' => 'png',
|
||||
'attachment_group_id' => $group->id,
|
||||
'allowed_mimes' => ['image/png'],
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(201);
|
||||
|
||||
$extension = AttachmentExtension::query()->where('extension', 'png')->firstOrFail();
|
||||
|
||||
$request = Request::create('/api/attachment-extensions/'.$extension->id, 'PATCH', [
|
||||
'allowed_mimes' => ['image/png', 'image/webp'],
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->update($request, $extension);
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$payload = $response->getData(true);
|
||||
expect($payload['group']['name'])->toBe('Images');
|
||||
});
|
||||
|
||||
it('destroy returns error when extension in use then succeeds', function (): void {
|
||||
$controller = new AttachmentExtensionController();
|
||||
$admin = makeAdminUserForExtensions();
|
||||
|
||||
$extension = AttachmentExtension::create(['extension' => 'pdf']);
|
||||
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => $extension->id,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/test.pdf',
|
||||
'original_name' => 'test.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-extensions/'.$extension->id, 'DELETE');
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->destroy($request, $extension);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
|
||||
Attachment::query()->delete();
|
||||
|
||||
$response = $controller->destroy($request, $extension);
|
||||
expect($response->getStatusCode())->toBe(204);
|
||||
});
|
||||
|
||||
it('public index only returns active grouped extensions', function (): void {
|
||||
$controller = new AttachmentExtensionController();
|
||||
|
||||
$activeGroup = AttachmentGroup::create([
|
||||
'name' => 'Active',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$inactiveGroup = AttachmentGroup::create([
|
||||
'name' => 'Inactive',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
AttachmentExtension::create([
|
||||
'extension' => 'png',
|
||||
'attachment_group_id' => $activeGroup->id,
|
||||
]);
|
||||
AttachmentExtension::create([
|
||||
'extension' => 'zip',
|
||||
'attachment_group_id' => $inactiveGroup->id,
|
||||
]);
|
||||
AttachmentExtension::create([
|
||||
'extension' => 'orphan',
|
||||
'attachment_group_id' => null,
|
||||
]);
|
||||
|
||||
$response = $controller->publicIndex();
|
||||
$payload = $response->getData(true);
|
||||
|
||||
expect($payload)->toBe(['png']);
|
||||
});
|
||||
277
tests/Unit/AttachmentGroupControllerUnitTest.php
Normal file
277
tests/Unit/AttachmentGroupControllerUnitTest.php
Normal file
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AttachmentGroupController;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\AttachmentExtension;
|
||||
use App\Models\AttachmentGroup;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
function makeAdminForGroupController(): User
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$role = Role::create(['name' => 'ROLE_ADMIN']);
|
||||
$admin->roles()->attach($role);
|
||||
return $admin;
|
||||
}
|
||||
|
||||
it('returns forbidden for non admin', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$index = Request::create('/api/attachment-groups', 'GET');
|
||||
$index->setUserResolver(fn () => $user);
|
||||
expect($controller->index($index)->getStatusCode())->toBe(403);
|
||||
|
||||
$store = Request::create('/api/attachment-groups', 'POST', [
|
||||
'name' => 'Images',
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$store->setUserResolver(fn () => $user);
|
||||
expect($controller->store($store)->getStatusCode())->toBe(403);
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Docs',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$update = Request::create('/api/attachment-groups/'.$group->id, 'PATCH', [
|
||||
'name' => 'Docs',
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$update->setUserResolver(fn () => $user);
|
||||
expect($controller->update($update, $group)->getStatusCode())->toBe(403);
|
||||
|
||||
$destroy = Request::create('/api/attachment-groups/'.$group->id, 'DELETE');
|
||||
$destroy->setUserResolver(fn () => $user);
|
||||
expect($controller->destroy($destroy, $group)->getStatusCode())->toBe(403);
|
||||
|
||||
$reorder = Request::create('/api/attachment-groups/reorder', 'POST', [
|
||||
'parentId' => null,
|
||||
'orderedIds' => [],
|
||||
]);
|
||||
$reorder->setUserResolver(fn () => $user);
|
||||
expect($controller->reorder($reorder)->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('stores group and rejects duplicates', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
$request = Request::create('/api/attachment-groups', 'POST', [
|
||||
'name' => 'Images',
|
||||
'parent_id' => null,
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(201);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
});
|
||||
|
||||
it('updates group and handles parent change position', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
$parentA = AttachmentGroup::create([
|
||||
'name' => 'Parent A',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$parentB = AttachmentGroup::create([
|
||||
'name' => 'Parent B',
|
||||
'position' => 2,
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Docs',
|
||||
'parent_id' => $parentA->id,
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-groups/'.$group->id, 'PATCH', [
|
||||
'name' => 'Docs',
|
||||
'parent_id' => $parentB->id,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => false,
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->update($request, $group);
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
});
|
||||
|
||||
it('update rejects duplicate group name', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
AttachmentGroup::create([
|
||||
'name' => 'Images',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Docs',
|
||||
'position' => 2,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-groups/'.$group->id, 'PATCH', [
|
||||
'name' => 'images',
|
||||
'parent_id' => null,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->update($request, $group);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
});
|
||||
|
||||
it('destroy returns errors for in-use group', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Images',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
AttachmentExtension::create([
|
||||
'extension' => 'png',
|
||||
'attachment_group_id' => $group->id,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-groups/'.$group->id, 'DELETE');
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->destroy($request, $group);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
|
||||
AttachmentExtension::query()->delete();
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => $group->id,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/test.txt',
|
||||
'original_name' => 'test.txt',
|
||||
'extension' => 'txt',
|
||||
'mime_type' => 'text/plain',
|
||||
'size_bytes' => 1,
|
||||
]);
|
||||
|
||||
$response = $controller->destroy($request, $group);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
});
|
||||
|
||||
it('destroy deletes empty group', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Empty',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-groups/'.$group->id, 'DELETE');
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->destroy($request, $group);
|
||||
expect($response->getStatusCode())->toBe(204);
|
||||
});
|
||||
|
||||
it('reorders groups with string parent id handling', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
$groupA = AttachmentGroup::create([
|
||||
'name' => 'A',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$groupB = AttachmentGroup::create([
|
||||
'name' => 'B',
|
||||
'position' => 2,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-groups/reorder', 'POST', [
|
||||
'parentId' => 'null',
|
||||
'orderedIds' => [$groupB->id, $groupA->id],
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->reorder($request);
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$groupA->refresh();
|
||||
$groupB->refresh();
|
||||
expect($groupB->position)->toBe(1);
|
||||
expect($groupA->position)->toBe(2);
|
||||
});
|
||||
|
||||
it('reorders groups with numeric parent id string', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
$parent = AttachmentGroup::create([
|
||||
'name' => 'Parent',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$groupA = AttachmentGroup::create([
|
||||
'name' => 'A',
|
||||
'parent_id' => $parent->id,
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$groupB = AttachmentGroup::create([
|
||||
'name' => 'B',
|
||||
'parent_id' => $parent->id,
|
||||
'position' => 2,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-groups/reorder', 'POST', [
|
||||
'parentId' => (string) $parent->id,
|
||||
'orderedIds' => [$groupB->id, $groupA->id],
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->reorder($request);
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
});
|
||||
|
||||
it('normalizeParentId handles empty and null values', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$ref = new ReflectionMethod($controller, 'normalizeParentId');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, ''))->toBeNull();
|
||||
expect($ref->invoke($controller, 'null'))->toBeNull();
|
||||
expect($ref->invoke($controller, null))->toBeNull();
|
||||
});
|
||||
55
tests/Unit/AuditLogControllerUnitTest.php
Normal file
55
tests/Unit/AuditLogControllerUnitTest.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AuditLogController;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
it('returns unauthorized when no user', function (): void {
|
||||
$controller = new AuditLogController();
|
||||
$request = Request::create('/api/audit-logs', 'GET');
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('returns forbidden when user is not admin', function (): void {
|
||||
$controller = new AuditLogController();
|
||||
$user = User::factory()->create();
|
||||
$request = Request::create('/api/audit-logs', 'GET');
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('returns logs for admin', function (): void {
|
||||
$controller = new AuditLogController();
|
||||
$admin = User::factory()->create();
|
||||
$role = Role::create(['name' => 'ROLE_ADMIN']);
|
||||
$admin->roles()->attach($role);
|
||||
|
||||
AuditLog::create([
|
||||
'action' => 'test.action',
|
||||
'subject_type' => 'post',
|
||||
'subject_id' => 1,
|
||||
'metadata' => ['foo' => 'bar'],
|
||||
'ip_address' => '127.0.0.1',
|
||||
'user_agent' => 'test',
|
||||
'user_id' => $admin->id,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/audit-logs', 'GET');
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$payload = $response->getData(true);
|
||||
expect($payload)->toHaveCount(1);
|
||||
expect($payload[0]['user']['roles'][0])->toBe('ROLE_ADMIN');
|
||||
});
|
||||
155
tests/Unit/BbcodeFormatterTest.php
Normal file
155
tests/Unit/BbcodeFormatterTest.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace s9e\TextFormatter {
|
||||
class Parser
|
||||
{
|
||||
public function parse(string $text): string
|
||||
{
|
||||
return '<r/>';
|
||||
}
|
||||
}
|
||||
|
||||
class Renderer
|
||||
{
|
||||
public function render(string $xml): string
|
||||
{
|
||||
return '<p>ok</p>';
|
||||
}
|
||||
}
|
||||
|
||||
class Configurator
|
||||
{
|
||||
public static bool $returnEmpty = false;
|
||||
public object $plugins;
|
||||
public object $tags;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->plugins = new class {
|
||||
public function load(string $name): object
|
||||
{
|
||||
return new class {
|
||||
public function addFromRepository(string $name): self
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
$this->tags = new class implements \ArrayAccess {
|
||||
public array $store = [];
|
||||
public function add($name)
|
||||
{
|
||||
$obj = new \stdClass();
|
||||
$this->store[$name] = $obj;
|
||||
return $obj;
|
||||
}
|
||||
public function offsetExists($offset): bool
|
||||
{
|
||||
return array_key_exists($offset, $this->store);
|
||||
}
|
||||
public function offsetGet($offset): mixed
|
||||
{
|
||||
return $this->store[$offset] ?? null;
|
||||
}
|
||||
public function offsetSet($offset, $value): void
|
||||
{
|
||||
$this->store[$offset] = $value;
|
||||
}
|
||||
public function offsetUnset($offset): void
|
||||
{
|
||||
unset($this->store[$offset]);
|
||||
}
|
||||
};
|
||||
|
||||
$this->tags['QUOTE'] = new \stdClass();
|
||||
}
|
||||
|
||||
public function finalize(): array
|
||||
{
|
||||
if (self::$returnEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'parser' => new Parser(),
|
||||
'renderer' => new Renderer(),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use App\Actions\BbcodeFormatter;
|
||||
|
||||
it('returns empty string for null and empty input', function (): void {
|
||||
expect(BbcodeFormatter::format(null))->toBe('');
|
||||
expect(BbcodeFormatter::format(''))->toBe('');
|
||||
});
|
||||
|
||||
it('formats bbcode content', function (): void {
|
||||
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Parser::class)
|
||||
->shouldReceive('parse')
|
||||
->andReturn('<r/>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
|
||||
->shouldReceive('render')
|
||||
->andReturn('<b>Bold</b>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$html = BbcodeFormatter::format('[b]Bold[/b]');
|
||||
|
||||
expect($html)->toContain('<b>');
|
||||
});
|
||||
|
||||
it('initializes parser and renderer when not set', function (): void {
|
||||
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(null);
|
||||
|
||||
$rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(null);
|
||||
|
||||
$html = BbcodeFormatter::format('[b]Bold[/b]');
|
||||
|
||||
expect($html)->toBeString();
|
||||
expect($parserProp->getValue())->not->toBeNull();
|
||||
expect($rendererProp->getValue())->not->toBeNull();
|
||||
});
|
||||
|
||||
it('throws when bbcode formatter cannot initialize', function (): void {
|
||||
\s9e\TextFormatter\Configurator::$returnEmpty = true;
|
||||
|
||||
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(null);
|
||||
|
||||
$rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(null);
|
||||
|
||||
try {
|
||||
BbcodeFormatter::format('test');
|
||||
$this->fail('Expected exception not thrown.');
|
||||
} catch (Throwable $e) {
|
||||
expect($e)->toBeInstanceOf(RuntimeException::class);
|
||||
} finally {
|
||||
\s9e\TextFormatter\Configurator::$returnEmpty = false;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
\Mockery::close();
|
||||
});
|
||||
}
|
||||
50
tests/Unit/ConsoleCommandTest.php
Normal file
50
tests/Unit/ConsoleCommandTest.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
it('version bump fails when no version', function (): void {
|
||||
Setting::where('key', 'version')->delete();
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('version bump fails when invalid version', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => 'bad']);
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('version set fails when invalid version', function (): void {
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:set', ['version' => 'bad']);
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('version fetch fails when no version', function (): void {
|
||||
Setting::where('key', 'version')->delete();
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:fetch');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('version release fails when missing config', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
putenv('GITEA_TOKEN');
|
||||
putenv('GITEA_OWNER');
|
||||
putenv('GITEA_REPO');
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('version release handles create failure', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
putenv('GITEA_TOKEN=token');
|
||||
putenv('GITEA_OWNER=owner');
|
||||
putenv('GITEA_REPO=repo');
|
||||
|
||||
Http::fake([
|
||||
'*' => Http::response([], 500),
|
||||
]);
|
||||
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
159
tests/Unit/CronRunCommandTest.php
Normal file
159
tests/Unit/CronRunCommandTest.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Attachment;
|
||||
use App\Services\AttachmentThumbnailService;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('local');
|
||||
});
|
||||
|
||||
it('skips non-image attachments', function (): void {
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/test.txt',
|
||||
'original_name' => 'test.txt',
|
||||
'extension' => 'txt',
|
||||
'mime_type' => 'text/plain',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('speedbb:cron');
|
||||
expect($exitCode)->toBe(0);
|
||||
});
|
||||
|
||||
it('counts missing files for images', function (): void {
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/missing.jpg',
|
||||
'original_name' => 'missing.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('speedbb:cron');
|
||||
expect($exitCode)->toBe(0);
|
||||
});
|
||||
|
||||
it('skips when thumbnail already exists', function (): void {
|
||||
Storage::disk('local')->put('attachments/photo.jpg', 'image');
|
||||
Storage::disk('local')->put('attachments/thumb.jpg', 'thumb');
|
||||
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/photo.jpg',
|
||||
'thumbnail_path' => 'attachments/thumb.jpg',
|
||||
'original_name' => 'photo.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('speedbb:cron');
|
||||
expect($exitCode)->toBe(0);
|
||||
});
|
||||
|
||||
it('creates thumbnails in dry run mode', function (): void {
|
||||
Storage::disk('local')->put('attachments/photo.jpg', 'image');
|
||||
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/photo.jpg',
|
||||
'original_name' => 'photo.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('speedbb:cron', ['--dry-run' => true]);
|
||||
expect($exitCode)->toBe(0);
|
||||
});
|
||||
|
||||
it('forces thumbnail regeneration and updates attachment when created', function (): void {
|
||||
Storage::disk('local')->put('attachments/photo.jpg', 'image');
|
||||
Storage::disk('local')->put('attachments/thumb-old.jpg', 'old');
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/photo.jpg',
|
||||
'thumbnail_path' => 'attachments/thumb-old.jpg',
|
||||
'original_name' => 'photo.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$service = Mockery::mock(AttachmentThumbnailService::class);
|
||||
$service->shouldReceive('createForAttachment')
|
||||
->once()
|
||||
->andReturn([
|
||||
'path' => 'attachments/thumb-new.jpg',
|
||||
'mime' => 'image/jpeg',
|
||||
'size' => 123,
|
||||
]);
|
||||
|
||||
app()->instance(AttachmentThumbnailService::class, $service);
|
||||
|
||||
$exitCode = Artisan::call('speedbb:cron', ['--force' => true]);
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
$attachment->refresh();
|
||||
expect($attachment->thumbnail_path)->toBe('attachments/thumb-new.jpg');
|
||||
expect($attachment->thumbnail_size_bytes)->toBe(123);
|
||||
});
|
||||
|
||||
it('skips when thumbnail creation fails', function (): void {
|
||||
Storage::disk('local')->put('attachments/photo.jpg', 'image');
|
||||
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/photo.jpg',
|
||||
'original_name' => 'photo.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$service = Mockery::mock(AttachmentThumbnailService::class);
|
||||
$service->shouldReceive('createForAttachment')->once()->andReturnNull();
|
||||
app()->instance(AttachmentThumbnailService::class, $service);
|
||||
|
||||
$exitCode = Artisan::call('speedbb:cron');
|
||||
expect($exitCode)->toBe(0);
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
Mockery::close();
|
||||
});
|
||||
114
tests/Unit/ForumControllerUnitTest.php
Normal file
114
tests/Unit/ForumControllerUnitTest.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\ForumController;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Role;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
|
||||
it('parseIriId handles null and numeric', function (): void {
|
||||
$controller = new ForumController();
|
||||
$ref = new ReflectionMethod($controller, 'parseIriId');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, null))->toBeNull();
|
||||
expect($ref->invoke($controller, '/forums/12'))->toBe(12);
|
||||
expect($ref->invoke($controller, '7'))->toBe(7);
|
||||
expect($ref->invoke($controller, 'abc'))->toBeNull();
|
||||
});
|
||||
|
||||
it('loadLastPostsByForum returns empty for no ids', function (): void {
|
||||
$controller = new ForumController();
|
||||
$ref = new ReflectionMethod($controller, 'loadLastPostsByForum');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, []))->toBe([]);
|
||||
});
|
||||
|
||||
it('resolveGroupColor returns null for missing roles', function (): void {
|
||||
$controller = new ForumController();
|
||||
$user = User::factory()->create();
|
||||
$user->setRelation('roles', null);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, null))->toBeNull();
|
||||
expect($ref->invoke($controller, $user))->toBeNull();
|
||||
});
|
||||
|
||||
it('resolveGroupColor returns first sorted role color', function (): void {
|
||||
$controller = new ForumController();
|
||||
$user = User::factory()->create();
|
||||
$roleB = Role::create(['name' => 'ROLE_B', 'color' => '#bbbbbb']);
|
||||
$roleA = Role::create(['name' => 'ROLE_A', 'color' => '#aaaaaa']);
|
||||
$user->roles()->attach([$roleB->id, $roleA->id]);
|
||||
$user->load('roles');
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, $user))->toBe('#aaaaaa');
|
||||
});
|
||||
|
||||
it('resolveGroupColor returns null when roles have no colors', function (): void {
|
||||
$controller = new ForumController();
|
||||
$user = User::factory()->create();
|
||||
$role = Role::create(['name' => 'ROLE_EMPTY', 'color' => null]);
|
||||
$user->roles()->attach($role);
|
||||
$user->load('roles');
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, $user))->toBeNull();
|
||||
});
|
||||
|
||||
it('loadLastPostsByForum returns latest post per forum', function (): void {
|
||||
$controller = new ForumController();
|
||||
$ref = new ReflectionMethod($controller, 'loadLastPostsByForum');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$category = Forum::create([
|
||||
'name' => 'Category U',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = Forum::create([
|
||||
'name' => 'Forum U',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$older = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Old',
|
||||
]);
|
||||
$newer = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'New',
|
||||
]);
|
||||
Post::whereKey($older->id)->update([
|
||||
'created_at' => now()->subDay(),
|
||||
'updated_at' => now()->subDay(),
|
||||
]);
|
||||
Post::whereKey($newer->id)->update([
|
||||
'created_at' => now()->addSeconds(10),
|
||||
'updated_at' => now()->addSeconds(10),
|
||||
]);
|
||||
|
||||
$result = $ref->invoke($controller, [$forum->id]);
|
||||
expect($result[$forum->id]->id)->toBe($newer->id);
|
||||
});
|
||||
146
tests/Unit/InstallerControllerTest.php
Normal file
146
tests/Unit/InstallerControllerTest.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\InstallerController;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
function withEnvBackup(callable $callback): void
|
||||
{
|
||||
$path = base_path('.env');
|
||||
$hadEnv = file_exists($path);
|
||||
$original = $hadEnv ? file_get_contents($path) : null;
|
||||
|
||||
try {
|
||||
$callback($path);
|
||||
} finally {
|
||||
if ($hadEnv) {
|
||||
file_put_contents($path, (string) $original);
|
||||
} elseif (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function installerRequest(array $overrides = []): Request
|
||||
{
|
||||
$db = config('database.connections.mysql');
|
||||
|
||||
$data = array_merge([
|
||||
'app_url' => 'https://example.test',
|
||||
'db_host' => $db['host'] ?? '127.0.0.1',
|
||||
'db_port' => $db['port'] ?? 3306,
|
||||
'db_database' => $db['database'] ?? 'tracer_speedBB_test',
|
||||
'db_username' => $db['username'] ?? 'root',
|
||||
'db_password' => $db['password'] ?? '',
|
||||
'admin_name' => 'Admin',
|
||||
'admin_email' => 'admin@example.com',
|
||||
'admin_password' => 'Password123!',
|
||||
], $overrides);
|
||||
|
||||
return Request::create('https://example.test/install', 'POST', $data);
|
||||
}
|
||||
|
||||
it('shows installer when env missing', function (): void {
|
||||
withEnvBackup(function (): void {
|
||||
if (file_exists(base_path('.env'))) {
|
||||
unlink(base_path('.env'));
|
||||
}
|
||||
|
||||
$controller = new InstallerController();
|
||||
$request = Request::create('https://example.test/install', 'GET');
|
||||
|
||||
$response = $controller->show($request);
|
||||
expect($response)->toBeInstanceOf(Illuminate\View\View::class);
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects installer when env exists', function (): void {
|
||||
withEnvBackup(function (): void {
|
||||
file_put_contents(base_path('.env'), "APP_KEY=base64:test\n");
|
||||
|
||||
$controller = new InstallerController();
|
||||
$request = Request::create('https://example.test/install', 'GET');
|
||||
|
||||
$response = $controller->show($request);
|
||||
expect($response)->toBeInstanceOf(Illuminate\Http\RedirectResponse::class);
|
||||
});
|
||||
});
|
||||
|
||||
it('store redirects when env exists', function (): void {
|
||||
withEnvBackup(function (): void {
|
||||
file_put_contents(base_path('.env'), "APP_KEY=base64:test\n");
|
||||
|
||||
$controller = new InstallerController();
|
||||
$request = installerRequest();
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response)->toBeInstanceOf(Illuminate\Http\RedirectResponse::class);
|
||||
});
|
||||
});
|
||||
|
||||
it('store handles db connection failure', function (): void {
|
||||
withEnvBackup(function (): void {
|
||||
if (file_exists(base_path('.env'))) {
|
||||
unlink(base_path('.env'));
|
||||
}
|
||||
|
||||
DB::shouldReceive('purge')->once();
|
||||
DB::shouldReceive('connection->getPdo')->andThrow(new RuntimeException('boom'));
|
||||
|
||||
$controller = new InstallerController();
|
||||
$request = installerRequest(['app_url' => 'https://example.test']);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response)->toBeInstanceOf(Illuminate\View\View::class);
|
||||
expect(file_exists(base_path('.env')))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
it('store handles migration failure', function (): void {
|
||||
withEnvBackup(function (): void {
|
||||
if (file_exists(base_path('.env'))) {
|
||||
unlink(base_path('.env'));
|
||||
}
|
||||
|
||||
DB::shouldReceive('purge')->once();
|
||||
DB::shouldReceive('connection->getPdo')->andReturn(true);
|
||||
Artisan::shouldReceive('call')->andReturn(1);
|
||||
|
||||
$controller = new InstallerController();
|
||||
$request = installerRequest();
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response)->toBeInstanceOf(Illuminate\View\View::class);
|
||||
expect(file_exists(base_path('.env')))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
it('store completes installation on success', function (): void {
|
||||
withEnvBackup(function (): void {
|
||||
if (file_exists(base_path('.env'))) {
|
||||
unlink(base_path('.env'));
|
||||
}
|
||||
|
||||
DB::shouldReceive('purge')->once();
|
||||
DB::shouldReceive('connection->getPdo')->andReturn(true);
|
||||
Artisan::shouldReceive('call')->andReturn(0);
|
||||
|
||||
$controller = new InstallerController();
|
||||
$request = installerRequest(['admin_email' => 'success@example.com']);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response)->toBeInstanceOf(Illuminate\View\View::class);
|
||||
|
||||
$user = User::where('email', 'success@example.com')->first();
|
||||
expect($user)->not->toBeNull();
|
||||
expect(Role::where('name', 'ROLE_ADMIN')->exists())->toBeTrue();
|
||||
expect(Role::where('name', 'ROLE_FOUNDER')->exists())->toBeTrue();
|
||||
|
||||
if (file_exists(base_path('.env'))) {
|
||||
unlink(base_path('.env'));
|
||||
}
|
||||
});
|
||||
});
|
||||
353
tests/Unit/PostControllerUnitTest.php
Normal file
353
tests/Unit/PostControllerUnitTest.php
Normal file
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\PostController;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\AttachmentGroup;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Rank;
|
||||
use App\Models\Role;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Parser::class)
|
||||
->shouldReceive('parse')
|
||||
->andReturn('<r/>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
|
||||
->shouldReceive('render')
|
||||
->andReturn('<p></p>')
|
||||
->getMock()
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
\Mockery::close();
|
||||
});
|
||||
|
||||
function makeForumForPostController(): 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 on update when no user', function (): void {
|
||||
$controller = new PostController();
|
||||
$forum = makeForumForPostController();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/posts/'.$post->id, 'PATCH', ['body' => 'x']);
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->update($request, $post);
|
||||
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('returns forbidden on update when user is not owner or admin', function (): void {
|
||||
$controller = new PostController();
|
||||
$forum = makeForumForPostController();
|
||||
$owner = User::factory()->create();
|
||||
$viewer = User::factory()->create();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $owner->id,
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/posts/'.$post->id, 'PATCH', ['body' => 'x']);
|
||||
$request->setUserResolver(fn () => $viewer);
|
||||
|
||||
$response = $controller->update($request, $post);
|
||||
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('updates post when user is owner', function (): void {
|
||||
$controller = new PostController();
|
||||
$forum = makeForumForPostController();
|
||||
$owner = User::factory()->create();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $owner->id,
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/posts/'.$post->id, 'PATCH', ['body' => 'Updated']);
|
||||
$request->setUserResolver(fn () => $owner);
|
||||
|
||||
$response = $controller->update($request, $post);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$post->refresh();
|
||||
expect($post->body)->toBe('Updated');
|
||||
});
|
||||
|
||||
it('parseIriId handles empty and numeric values', function (): void {
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'parseIriId');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, null))->toBeNull();
|
||||
expect($ref->invoke($controller, ''))->toBeNull();
|
||||
expect($ref->invoke($controller, '/threads/12'))->toBe(12);
|
||||
expect($ref->invoke($controller, '7'))->toBe(7);
|
||||
expect($ref->invoke($controller, 'abc'))->toBeNull();
|
||||
});
|
||||
|
||||
it('serializes posts with attachments and rank data', function (): void {
|
||||
$forum = makeForumForPostController();
|
||||
$role = Role::create(['name' => 'ROLE_MOD', 'color' => '#00ff00']);
|
||||
$rank = Rank::create(['name' => 'Gold', 'badge_image_path' => 'ranks/badge.png']);
|
||||
$user = User::factory()->create([
|
||||
'rank_id' => $rank->id,
|
||||
'avatar_path' => 'avatars/u.png',
|
||||
'location' => 'Here',
|
||||
]);
|
||||
$user->roles()->attach($role);
|
||||
|
||||
$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' => 'See [attachment]file.png[/attachment]',
|
||||
]);
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Images',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => $post->id,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => $group->id,
|
||||
'user_id' => $user->id,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/posts/'.$post->id.'/file.png',
|
||||
'thumbnail_path' => 'attachments/posts/'.$post->id.'/thumb.png',
|
||||
'original_name' => 'file.png',
|
||||
'extension' => 'png',
|
||||
'mime_type' => 'image/png',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$post->load(['user.rank', 'user.roles', 'attachments.group']);
|
||||
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'serializePost');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$payload = $ref->invoke($controller, $post);
|
||||
|
||||
expect($payload['user_rank_badge_url'])->not->toBeNull();
|
||||
expect($payload['user_group_color'])->toBe('#00ff00');
|
||||
expect($payload['attachments'][0]['group']['name'])->toBe('Images');
|
||||
expect($payload['attachments'][0]['thumbnail_url'])->toContain('/thumbnail');
|
||||
});
|
||||
|
||||
it('serializes posts with null user and no attachments', function (): void {
|
||||
$forum = makeForumForPostController();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'serializePost');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$payload = $ref->invoke($controller, $post);
|
||||
|
||||
expect($payload['user_avatar_url'])->toBeNull();
|
||||
expect($payload['user_rank_badge_url'])->toBeNull();
|
||||
expect($payload['user_group_color'])->toBeNull();
|
||||
expect($payload['attachments'])->toBe([]);
|
||||
});
|
||||
|
||||
it('replaceAttachmentTags handles inline images and links', function (): void {
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => '1']);
|
||||
|
||||
$attachment = new Attachment([
|
||||
'id' => 1,
|
||||
'original_name' => 'file.png',
|
||||
'mime_type' => 'image/png',
|
||||
'thumbnail_path' => null,
|
||||
]);
|
||||
|
||||
$body = 'See [attachment]file.png[/attachment]';
|
||||
$result = $ref->invoke($controller, $body, collect([$attachment]));
|
||||
expect($result)->toContain('[img]');
|
||||
|
||||
$attachment->thumbnail_path = 'thumb';
|
||||
$result = $ref->invoke($controller, $body, collect([$attachment]));
|
||||
expect($result)->toContain('[url=');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => '0']);
|
||||
$result = $ref->invoke($controller, $body, collect([$attachment]));
|
||||
expect($result)->toContain('[url=');
|
||||
|
||||
$result = $ref->invoke($controller, 'No match', collect([$attachment]));
|
||||
expect($result)->toContain('No match');
|
||||
});
|
||||
|
||||
it('replaceAttachmentTags returns original tag when attachment name missing in map', function (): void {
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => '1']);
|
||||
|
||||
$attachment = new Attachment([
|
||||
'id' => 2,
|
||||
'original_name' => 'actual.txt',
|
||||
'mime_type' => 'text/plain',
|
||||
]);
|
||||
|
||||
$body = 'See [attachment]missing.txt[/attachment]';
|
||||
$result = $ref->invoke($controller, $body, collect([$attachment]));
|
||||
|
||||
expect($result)->toBe($body);
|
||||
});
|
||||
|
||||
it('replaceAttachmentTags renders non-image attachments as links', function (): void {
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => 'yes']);
|
||||
|
||||
$attachment = new Attachment([
|
||||
'id' => 3,
|
||||
'original_name' => 'doc.txt',
|
||||
'mime_type' => 'text/plain',
|
||||
]);
|
||||
|
||||
$body = 'See [attachment]doc.txt[/attachment]';
|
||||
$result = $ref->invoke($controller, $body, collect([$attachment]));
|
||||
|
||||
expect($result)->toContain('[url=');
|
||||
expect($result)->toContain('doc.txt');
|
||||
});
|
||||
|
||||
it('replaceAttachmentTags returns body when no attachments or map empty', function (): void {
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, 'Body', []))->toBe('Body');
|
||||
|
||||
$attachment = new Attachment([
|
||||
'original_name' => '',
|
||||
]);
|
||||
expect($ref->invoke($controller, 'Body', collect([$attachment])))->toBe('Body');
|
||||
});
|
||||
|
||||
it('displayImagesInline defaults to true when missing setting', function (): void {
|
||||
Setting::where('key', 'attachments.display_images_inline')->delete();
|
||||
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'displayImagesInline');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller))->toBeTrue();
|
||||
});
|
||||
|
||||
it('displayImagesInline returns false for off values', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => 'off']);
|
||||
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'displayImagesInline');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller))->toBeFalse();
|
||||
});
|
||||
|
||||
it('resolveGroupColor returns null for missing roles', function (): void {
|
||||
$controller = new PostController();
|
||||
$user = User::factory()->create();
|
||||
$user->setRelation('roles', null);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, $user))->toBeNull();
|
||||
});
|
||||
|
||||
it('resolveGroupColor returns first sorted role color', function (): void {
|
||||
$controller = new PostController();
|
||||
$user = User::factory()->create();
|
||||
$roleB = Role::create(['name' => 'ROLE_B', 'color' => '#bbbbbb']);
|
||||
$roleA = Role::create(['name' => 'ROLE_A', 'color' => '#aaaaaa']);
|
||||
$user->roles()->attach([$roleB->id, $roleA->id]);
|
||||
$user->load('roles');
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, $user))->toBe('#aaaaaa');
|
||||
});
|
||||
36
tests/Unit/PostThankControllerUnitTest.php
Normal file
36
tests/Unit/PostThankControllerUnitTest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\PostThankController;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
it('returns unauthenticated when no user in store/destroy', function (): void {
|
||||
$controller = new PostThankController();
|
||||
$post = new Post([
|
||||
'id' => 1,
|
||||
]);
|
||||
$post->setRawAttributes(['id' => 1, 'thread_id' => 1, 'user_id' => null, 'body' => 'Post'], true);
|
||||
|
||||
$request = Request::create('/api/posts/'.$post->id.'/thanks', 'POST');
|
||||
$request->setUserResolver(fn () => null);
|
||||
$response = $controller->store($request, $post);
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
|
||||
$request = Request::create('/api/posts/'.$post->id.'/thanks', 'DELETE');
|
||||
$request->setUserResolver(fn () => null);
|
||||
$response = $controller->destroy($request, $post);
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('resolveGroupColor returns null for missing roles', function (): void {
|
||||
$controller = new PostThankController();
|
||||
$user = User::factory()->create();
|
||||
$user->setRelation('roles', null);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, $user))->toBeNull();
|
||||
expect($ref->invoke($controller, null))->toBeNull();
|
||||
});
|
||||
20
tests/Unit/ResetUserPasswordTest.php
Normal file
20
tests/Unit/ResetUserPasswordTest.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
it('resets user password after validation', function (): void {
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('OldPass123!'),
|
||||
]);
|
||||
|
||||
$action = new ResetUserPassword();
|
||||
$action->reset($user, [
|
||||
'password' => 'NewPass123!',
|
||||
'password_confirmation' => 'NewPass123!',
|
||||
]);
|
||||
|
||||
$user->refresh();
|
||||
expect(Hash::check('NewPass123!', $user->password))->toBeTrue();
|
||||
});
|
||||
63
tests/Unit/StatsControllerUnitTest.php
Normal file
63
tests/Unit/StatsControllerUnitTest.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\StatsController;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('returns null board version when no version is set', function (): void {
|
||||
Setting::where('key', 'version')->delete();
|
||||
Setting::where('key', 'build')->delete();
|
||||
|
||||
$controller = new StatsController();
|
||||
$response = $controller->__invoke();
|
||||
$payload = $response->getData(true);
|
||||
|
||||
expect($payload['board_version'])->toBeNull();
|
||||
});
|
||||
|
||||
it('handles stats edge cases without crashing', function (): void {
|
||||
Storage::fake('public');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.0.0']);
|
||||
Setting::updateOrCreate(['key' => 'build'], ['value' => '9']);
|
||||
|
||||
DB::shouldReceive('connection->getDriverName')->andReturn('sqlite');
|
||||
DB::shouldReceive('selectOne')->andThrow(new RuntimeException('db fail'));
|
||||
|
||||
$controller = new StatsController();
|
||||
$response = $controller->__invoke();
|
||||
|
||||
$payload = $response->getData(true);
|
||||
|
||||
expect($payload['database_size_bytes'])->toBeNull();
|
||||
expect($payload['database_server'])->toBeNull();
|
||||
expect($payload['board_version'])->toBe('1.0.0 (build 9)');
|
||||
expect($payload['orphan_attachments'])->toBeInt();
|
||||
});
|
||||
|
||||
it('returns null for database size and avatar size on exceptions', function (): void {
|
||||
DB::shouldReceive('connection->getDriverName')->andThrow(new RuntimeException('db fail'));
|
||||
|
||||
$controller = new StatsController();
|
||||
$refDb = new ReflectionMethod($controller, 'resolveDatabaseSize');
|
||||
$refDb->setAccessible(true);
|
||||
$refAvatar = new ReflectionMethod($controller, 'resolveAvatarDirectorySize');
|
||||
$refAvatar->setAccessible(true);
|
||||
|
||||
expect($refDb->invoke($controller))->toBeNull();
|
||||
\Illuminate\Support\Facades\Storage::shouldReceive('disk')->andThrow(new RuntimeException('disk fail'));
|
||||
expect($refAvatar->invoke($controller))->toBeNull();
|
||||
});
|
||||
|
||||
it('sums avatar directory size', function (): void {
|
||||
Storage::fake('public');
|
||||
Storage::disk('public')->put('avatars/a.png', 'a');
|
||||
Storage::disk('public')->put('avatars/b.png', 'bb');
|
||||
|
||||
$controller = new StatsController();
|
||||
$refAvatar = new ReflectionMethod($controller, 'resolveAvatarDirectorySize');
|
||||
$refAvatar->setAccessible(true);
|
||||
|
||||
expect($refAvatar->invoke($controller))->toBe(3);
|
||||
});
|
||||
225
tests/Unit/SystemStatusControllerUnitTest.php
Normal file
225
tests/Unit/SystemStatusControllerUnitTest.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\SystemStatusController;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
function withFakeBinForStatus(array $scripts, callable $callback): void
|
||||
{
|
||||
$dir = storage_path('app/test-bin-' . Str::random(6));
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0777, true);
|
||||
}
|
||||
|
||||
foreach ($scripts as $name => $body) {
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $name;
|
||||
file_put_contents($path, $body);
|
||||
chmod($path, 0755);
|
||||
}
|
||||
|
||||
$originalPath = getenv('PATH') ?: '';
|
||||
putenv("PATH={$dir}");
|
||||
$_ENV['PATH'] = $dir;
|
||||
$_SERVER['PATH'] = $dir;
|
||||
|
||||
try {
|
||||
$callback($dir);
|
||||
} finally {
|
||||
putenv("PATH={$originalPath}");
|
||||
$_ENV['PATH'] = $originalPath;
|
||||
$_SERVER['PATH'] = $originalPath;
|
||||
if (is_dir($dir)) {
|
||||
$items = scandir($dir);
|
||||
if (is_array($items)) {
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $item;
|
||||
if (is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
rmdir($dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('returns system status for admins', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'php' => "#!/bin/sh\nif [ \"$1\" = \"-r\" ]; then echo \"8.4.0\"; exit 0; fi\necho \"php\"\n",
|
||||
'composer' => "#!/bin/sh\necho \"composer 2.0.0\"\n",
|
||||
'node' => "#!/bin/sh\necho \"v20.0.0\"\n",
|
||||
'npm' => "#!/bin/sh\necho \"9.0.0\"\n",
|
||||
'tar' => "#!/bin/sh\necho \"tar 1.2.3\"\n",
|
||||
'rsync' => "#!/bin/sh\necho \"rsync 3.2.0\"\n",
|
||||
], function (string $dir): void {
|
||||
$admin = User::factory()->create();
|
||||
$role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']);
|
||||
$admin->roles()->attach($role);
|
||||
|
||||
$request = Request::create('/api/system/status', 'GET');
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$controller = new SystemStatusController();
|
||||
$response = $controller->__invoke($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$payload = $response->getData(true);
|
||||
|
||||
expect($payload)->toHaveKeys([
|
||||
'php',
|
||||
'php_default',
|
||||
'composer',
|
||||
'composer_version',
|
||||
'node',
|
||||
'node_version',
|
||||
'npm',
|
||||
'npm_version',
|
||||
'tar',
|
||||
'tar_version',
|
||||
'rsync',
|
||||
'rsync_version',
|
||||
'proc_functions',
|
||||
'storage_writable',
|
||||
'updates_writable',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('covers binary resolution edge cases', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'sh' => "#!/bin/sh\nexit 0\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refBinary = new ReflectionMethod($controller, 'resolveBinary');
|
||||
$refBinary->setAccessible(true);
|
||||
|
||||
expect($refBinary->invoke($controller, 'php'))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns php version when available', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'phpfake' => "#!/bin/sh\nif [ \"$1\" = \"-r\" ]; then echo \"8.4.1\"; exit 0; fi\nexit 0\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refPhp = new ReflectionMethod($controller, 'resolvePhpVersion');
|
||||
$refPhp->setAccessible(true);
|
||||
|
||||
$path = $dir . '/phpfake';
|
||||
expect($refPhp->invoke($controller, $path))->toBe('8.4.1');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null php version when command fails', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'phpfail' => "#!/bin/sh\nexit 1\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refPhp = new ReflectionMethod($controller, 'resolvePhpVersion');
|
||||
$refPhp->setAccessible(true);
|
||||
|
||||
$path = $dir . '/phpfail';
|
||||
expect($refPhp->invoke($controller, $path))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns binary version when regex matches', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'tool' => "#!/bin/sh\necho \"tool v1.2.3\"\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refVer = new ReflectionMethod($controller, 'resolveBinaryVersion');
|
||||
$refVer->setAccessible(true);
|
||||
|
||||
$path = $dir . '/tool';
|
||||
expect($refVer->invoke($controller, $path, ['--version']))->toBe('1.2.3');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when binary version output is empty', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'empty' => "#!/bin/sh\nexit 0\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refVer = new ReflectionMethod($controller, 'resolveBinaryVersion');
|
||||
$refVer->setAccessible(true);
|
||||
|
||||
$path = $dir . '/empty';
|
||||
expect($refVer->invoke($controller, $path, ['--version']))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when binary version output has no version', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'noversion' => "#!/bin/sh\necho \"tool version unknown\"\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refVer = new ReflectionMethod($controller, 'resolveBinaryVersion');
|
||||
$refVer->setAccessible(true);
|
||||
|
||||
$path = $dir . '/noversion';
|
||||
expect($refVer->invoke($controller, $path, ['--version']))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when binary version command fails', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'fail' => "#!/bin/sh\nexit 1\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refVer = new ReflectionMethod($controller, 'resolveBinaryVersion');
|
||||
$refVer->setAccessible(true);
|
||||
|
||||
$path = $dir . '/fail';
|
||||
expect($refVer->invoke($controller, $path, ['--version']))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array when readJson cannot read file', function (): void {
|
||||
$controller = new SystemStatusController();
|
||||
$ref = new ReflectionMethod($controller, 'readJson');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$path = sys_get_temp_dir() . '/missing.json';
|
||||
$result = $ref->invoke($controller, $path);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
it('returns empty array when readJson invalid', function (): void {
|
||||
$controller = new SystemStatusController();
|
||||
$ref = new ReflectionMethod($controller, 'readJson');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$path = sys_get_temp_dir() . '/invalid.json';
|
||||
file_put_contents($path, 'not-json');
|
||||
|
||||
$result = $ref->invoke($controller, $path);
|
||||
unlink($path);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
it('returns empty array when readJson cannot read contents', function (): void {
|
||||
$controller = new SystemStatusController();
|
||||
$ref = new ReflectionMethod($controller, 'readJson');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$path = storage_path('app/unreadable.json');
|
||||
file_put_contents($path, '{"a":1}');
|
||||
chmod($path, 0000);
|
||||
|
||||
$prev = set_error_handler(static fn () => true);
|
||||
$result = $ref->invoke($controller, $path);
|
||||
restore_error_handler();
|
||||
|
||||
chmod($path, 0644);
|
||||
unlink($path);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
164
tests/Unit/ThreadControllerBranchesTest.php
Normal file
164
tests/Unit/ThreadControllerBranchesTest.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\ThreadController;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Thread;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Parser::class)
|
||||
->shouldReceive('parse')
|
||||
->andReturn('<r/>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
|
||||
->shouldReceive('render')
|
||||
->andReturn('<p></p>')
|
||||
->getMock()
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
\Mockery::close();
|
||||
});
|
||||
|
||||
it('parseIriId returns null for empty values and non numeric', function (): void {
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'parseIriId');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, null))->toBeNull();
|
||||
expect($ref->invoke($controller, ''))->toBeNull();
|
||||
expect($ref->invoke($controller, 'abc'))->toBeNull();
|
||||
});
|
||||
|
||||
it('parseIriId parses forum iris and numeric values', function (): void {
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'parseIriId');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, '/api/forums/123'))->toBe(123);
|
||||
expect($ref->invoke($controller, '456'))->toBe(456);
|
||||
});
|
||||
|
||||
it('serializes thread with rank badge url when present', function (): void {
|
||||
Storage::fake('public');
|
||||
|
||||
$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,
|
||||
]);
|
||||
|
||||
$rank = \App\Models\Rank::create([
|
||||
'name' => 'Rank',
|
||||
'badge_image_path' => 'ranks/badge.png',
|
||||
]);
|
||||
|
||||
$user = \App\Models\User::factory()->create([
|
||||
'rank_id' => $rank->id,
|
||||
]);
|
||||
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$thread->load(['user.rank', 'attachments']);
|
||||
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'serializeThread');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$payload = $ref->invoke($controller, $thread);
|
||||
|
||||
expect($payload['user_rank_badge_url'])->not->toBeNull();
|
||||
});
|
||||
|
||||
it('replaces attachment tags with inline image without thumb', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => 'true']);
|
||||
|
||||
$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' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'See [attachment]image.jpg[/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.'/image.jpg',
|
||||
'original_name' => 'image.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'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('[img]');
|
||||
});
|
||||
|
||||
it('defaults to inline images when setting is missing', function (): void {
|
||||
Setting::where('key', 'attachments.display_images_inline')->delete();
|
||||
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'displayImagesInline');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller))->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns null group color when roles relation is null', function (): void {
|
||||
$controller = new ThreadController();
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$user->setRelation('roles', null);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, $user))->toBeNull();
|
||||
});
|
||||
@@ -12,6 +12,30 @@ use App\Models\Setting;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Parser::class)
|
||||
->shouldReceive('parse')
|
||||
->andReturn('<r/>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
|
||||
->shouldReceive('render')
|
||||
->andReturn('<p></p>')
|
||||
->getMock()
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
\Mockery::close();
|
||||
});
|
||||
|
||||
function makeForumForThreadController(): Forum
|
||||
{
|
||||
$category = Forum::create([
|
||||
@@ -130,7 +154,7 @@ it('serializes threads with attachments, group colors, and inline images', funct
|
||||
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['body_html'])->toContain('<p');
|
||||
expect($payload['last_post_id'])->toBe($post->id);
|
||||
});
|
||||
|
||||
|
||||
44
tests/Unit/UpdateUserPasswordTest.php
Normal file
44
tests/Unit/UpdateUserPasswordTest.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Fortify\UpdateUserPassword;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
it('updates password when current password matches', function (): void {
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('OldPass123!'),
|
||||
]);
|
||||
$this->actingAs($user);
|
||||
|
||||
$action = new UpdateUserPassword();
|
||||
|
||||
$action->update($user, [
|
||||
'current_password' => 'OldPass123!',
|
||||
'password' => 'NewPass123!',
|
||||
'password_confirmation' => 'NewPass123!',
|
||||
]);
|
||||
|
||||
$user->refresh();
|
||||
expect(Hash::check('NewPass123!', $user->password))->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects wrong current password', function (): void {
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('OldPass123!'),
|
||||
]);
|
||||
$this->actingAs($user);
|
||||
|
||||
$action = new UpdateUserPassword();
|
||||
|
||||
try {
|
||||
$action->update($user, [
|
||||
'current_password' => 'WrongPass',
|
||||
'password' => 'NewPass123!',
|
||||
'password_confirmation' => 'NewPass123!',
|
||||
]);
|
||||
$this->fail('Expected ValidationException not thrown.');
|
||||
} catch (ValidationException $e) {
|
||||
expect($e->errors())->toHaveKey('current_password');
|
||||
}
|
||||
});
|
||||
40
tests/Unit/UpdateUserProfileInformationTest.php
Normal file
40
tests/Unit/UpdateUserProfileInformationTest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||
use App\Models\User;
|
||||
|
||||
it('updates profile without email verification when email unchanged', function (): void {
|
||||
$user = User::factory()->create([
|
||||
'name' => 'Old',
|
||||
'email' => 'old@example.com',
|
||||
]);
|
||||
|
||||
$action = new UpdateUserProfileInformation();
|
||||
$action->update($user, [
|
||||
'name' => 'New Name',
|
||||
'email' => 'old@example.com',
|
||||
]);
|
||||
|
||||
$user->refresh();
|
||||
expect($user->name)->toBe('New Name');
|
||||
expect($user->name_canonical)->toBe('new name');
|
||||
expect($user->email)->toBe('old@example.com');
|
||||
});
|
||||
|
||||
it('resets verification and sends notification when email changes', function (): void {
|
||||
$user = User::factory()->create([
|
||||
'name' => 'Old',
|
||||
'email' => 'old@example.com',
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$action = new UpdateUserProfileInformation();
|
||||
$action->update($user, [
|
||||
'name' => 'New Name',
|
||||
'email' => 'new@example.com',
|
||||
]);
|
||||
|
||||
$user->refresh();
|
||||
expect($user->email)->toBe('new@example.com');
|
||||
expect($user->email_verified_at)->toBeNull();
|
||||
});
|
||||
122
tests/Unit/VersionBumpCommandTest.php
Normal file
122
tests/Unit/VersionBumpCommandTest.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands {
|
||||
if (!function_exists(__NAMESPACE__ . '\\file_get_contents')) {
|
||||
function file_get_contents($path): string|false
|
||||
{
|
||||
if (!empty($GLOBALS['version_bump_file_get_contents_false']) && str_ends_with($path, 'composer.json')) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($GLOBALS['version_fetch_file_get_contents_false']) && str_ends_with($path, 'composer.json')) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($GLOBALS['version_set_file_get_contents_false']) && str_ends_with($path, 'composer.json')) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($GLOBALS['version_release_file_get_contents_false']) && str_ends_with($path, 'CHANGELOG.md')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \file_get_contents($path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists(__NAMESPACE__ . '\\json_encode')) {
|
||||
function json_encode($value, int $flags = 0): string|false
|
||||
{
|
||||
if (!empty($GLOBALS['version_bump_json_encode_false'])) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($GLOBALS['version_fetch_json_encode_false']) && is_array($value) && array_key_exists('build', $value)) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($GLOBALS['version_set_json_encode_false'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \json_encode($value, $flags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
function withComposerBackup(callable $callback): void
|
||||
{
|
||||
$path = base_path('composer.json');
|
||||
$original = file_get_contents($path);
|
||||
|
||||
try {
|
||||
$callback($path, $original);
|
||||
} finally {
|
||||
if ($original !== false) {
|
||||
file_put_contents($path, $original);
|
||||
}
|
||||
$GLOBALS['version_bump_file_get_contents_false'] = false;
|
||||
$GLOBALS['version_bump_json_encode_false'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
it('bumps patch version and syncs composer metadata', function (): void {
|
||||
withComposerBackup(function (string $path): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.09-beta']);
|
||||
|
||||
$exitCode = Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
$setting = Setting::where('key', 'version')->value('value');
|
||||
expect($setting)->toBe('1.2.10-beta');
|
||||
|
||||
$data = json_decode((string) file_get_contents($path), true);
|
||||
expect($data['version'] ?? null)->toBe('1.2.10-beta');
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when composer.json cannot be decoded', function (): void {
|
||||
withComposerBackup(function (string $path): void {
|
||||
file_put_contents($path, 'not-json');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when composer.json is not readable', function (): void {
|
||||
withComposerBackup(function (string $path): void {
|
||||
chmod($path, 0000);
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(1);
|
||||
|
||||
chmod($path, 0644);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when file_get_contents returns false', function (): void {
|
||||
withComposerBackup(function (): void {
|
||||
$GLOBALS['version_bump_file_get_contents_false'] = true;
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when json_encode returns false', function (): void {
|
||||
withComposerBackup(function (): void {
|
||||
$GLOBALS['version_bump_json_encode_false'] = true;
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
125
tests/Unit/VersionFetchCommandTest.php
Normal file
125
tests/Unit/VersionFetchCommandTest.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands {
|
||||
if (!function_exists(__NAMESPACE__ . '\\file_get_contents')) {
|
||||
function file_get_contents($path): string|false
|
||||
{
|
||||
if (!empty($GLOBALS['version_fetch_file_get_contents_false']) && str_ends_with($path, 'composer.json')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \file_get_contents($path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists(__NAMESPACE__ . '\\json_encode')) {
|
||||
function json_encode($value, int $flags = 0): string|false
|
||||
{
|
||||
if (!empty($GLOBALS['version_fetch_json_encode_false']) && is_array($value) && array_key_exists('build', $value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \json_encode($value, $flags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
function withComposerBackupForFetch(callable $callback): void
|
||||
{
|
||||
$path = base_path('composer.json');
|
||||
$original = file_get_contents($path);
|
||||
|
||||
try {
|
||||
$callback($path, $original);
|
||||
} finally {
|
||||
if ($original !== false) {
|
||||
file_put_contents($path, $original);
|
||||
}
|
||||
$GLOBALS['version_fetch_file_get_contents_false'] = false;
|
||||
$GLOBALS['version_fetch_json_encode_false'] = false;
|
||||
$originalPath = $GLOBALS['version_fetch_path'] ?? null;
|
||||
if ($originalPath !== null) {
|
||||
putenv("PATH={$originalPath}");
|
||||
$_ENV['PATH'] = $originalPath;
|
||||
$_SERVER['PATH'] = $originalPath;
|
||||
unset($GLOBALS['version_fetch_path']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('fetches build count and syncs composer metadata', function (): void {
|
||||
withComposerBackupForFetch(function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:fetch');
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
$build = Setting::where('key', 'build')->value('value');
|
||||
expect(is_numeric($build))->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when build count cannot be resolved', function (): void {
|
||||
withComposerBackupForFetch(function (): void {
|
||||
$GLOBALS['version_fetch_path'] = getenv('PATH') ?: '';
|
||||
putenv('PATH=/nope');
|
||||
$_ENV['PATH'] = '/nope';
|
||||
$_SERVER['PATH'] = '/nope';
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:fetch');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when composer.json cannot be decoded', function (): void {
|
||||
withComposerBackupForFetch(function (string $path): void {
|
||||
file_put_contents($path, 'not-json');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:fetch');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when composer.json is not readable', function (): void {
|
||||
withComposerBackupForFetch(function (string $path): void {
|
||||
chmod($path, 0000);
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:fetch');
|
||||
expect($exitCode)->toBe(1);
|
||||
|
||||
chmod($path, 0644);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when file_get_contents returns false', function (): void {
|
||||
withComposerBackupForFetch(function (): void {
|
||||
$GLOBALS['version_fetch_file_get_contents_false'] = true;
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:fetch');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when json_encode returns false', function (): void {
|
||||
withComposerBackupForFetch(function (): void {
|
||||
$GLOBALS['version_fetch_json_encode_false'] = true;
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:fetch');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
183
tests/Unit/VersionReleaseCommandTest.php
Normal file
183
tests/Unit/VersionReleaseCommandTest.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands {
|
||||
if (!function_exists(__NAMESPACE__ . '\\file_get_contents')) {
|
||||
function file_get_contents($path): string|false
|
||||
{
|
||||
if (!empty($GLOBALS['version_release_file_get_contents_false']) && str_ends_with($path, 'CHANGELOG.md')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \file_get_contents($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use App\Console\Commands\VersionRelease;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
function withChangelogBackup(callable $callback): void
|
||||
{
|
||||
$path = base_path('CHANGELOG.md');
|
||||
$had = file_exists($path);
|
||||
$original = $had ? file_get_contents($path) : null;
|
||||
|
||||
try {
|
||||
$callback($path);
|
||||
} finally {
|
||||
if ($had) {
|
||||
file_put_contents($path, (string) $original);
|
||||
} elseif (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setGiteaEnvForRelease(): void
|
||||
{
|
||||
putenv('GITEA_TOKEN=token');
|
||||
putenv('GITEA_OWNER=owner');
|
||||
putenv('GITEA_REPO=repo');
|
||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
||||
putenv('GITEA_PRERELEASE=false');
|
||||
}
|
||||
|
||||
it('fails when version missing', function (): void {
|
||||
Setting::where('key', 'version')->delete();
|
||||
|
||||
$exitCode = Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('fails when gitea config missing', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
putenv('GITEA_TOKEN');
|
||||
putenv('GITEA_OWNER');
|
||||
putenv('GITEA_REPO');
|
||||
|
||||
$exitCode = Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('creates release successfully with changelog body', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
setGiteaEnvForRelease();
|
||||
|
||||
withChangelogBackup(function (string $path): void {
|
||||
file_put_contents($path, "# Changelog\n\n## 1.2.3\n- Added thing\n\n## 1.2.2\n- Old\n");
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases' => Http::response(['id' => 1], 201),
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
Http::assertSent(function ($request): bool {
|
||||
$payload = $request->data();
|
||||
return $payload['tag_name'] === 'v1.2.3'
|
||||
&& str_contains($payload['body'], 'Added thing');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when create response is error', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
setGiteaEnvForRelease();
|
||||
|
||||
Http::fake([
|
||||
'*' => Http::response([], 500),
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('fails when existing release cannot be fetched', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
setGiteaEnvForRelease();
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases' => Http::response([], 409),
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases/tags/v1.2.3' => Http::response([], 500),
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('fails when existing release has no id', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
setGiteaEnvForRelease();
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases' => Http::response([], 409),
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases/tags/v1.2.3' => Http::response(['id' => null], 200),
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('updates existing release when create conflicts', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
setGiteaEnvForRelease();
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases' => Http::response([], 409),
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases/tags/v1.2.3' => Http::response(['id' => 99], 200),
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases/99' => Http::response(['id' => 99], 200),
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(0);
|
||||
});
|
||||
|
||||
it('fails when updating existing release fails', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
setGiteaEnvForRelease();
|
||||
|
||||
Http::fake([
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases' => Http::response([], 422),
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases/tags/v1.2.3' => Http::response(['id' => 99], 200),
|
||||
'https://git.example.test/api/v1/repos/owner/repo/releases/99' => Http::response([], 500),
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('returns default changelog body when file missing', function (): void {
|
||||
withChangelogBackup(function (string $path): void {
|
||||
if (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
|
||||
$command = new VersionRelease();
|
||||
$ref = new ReflectionMethod($command, 'resolveChangelogBody');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$body = $ref->invoke($command, '1.2.3');
|
||||
expect($body)->toBe('See commit history for details.');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns default changelog body when read fails', function (): void {
|
||||
withChangelogBackup(function (string $path): void {
|
||||
file_put_contents($path, "# Changelog\n\n## 1.2.3\n- Something\n");
|
||||
$GLOBALS['version_release_file_get_contents_false'] = true;
|
||||
|
||||
$command = new VersionRelease();
|
||||
$ref = new ReflectionMethod($command, 'resolveChangelogBody');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$body = $ref->invoke($command, '1.2.3');
|
||||
expect($body)->toBe('See commit history for details.');
|
||||
|
||||
$GLOBALS['version_release_file_get_contents_false'] = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
111
tests/Unit/VersionSetCommandTest.php
Normal file
111
tests/Unit/VersionSetCommandTest.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands {
|
||||
if (!function_exists(__NAMESPACE__ . '\\file_get_contents')) {
|
||||
function file_get_contents($path): string|false
|
||||
{
|
||||
if (!empty($GLOBALS['version_set_file_get_contents_false']) && str_ends_with($path, 'composer.json')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \file_get_contents($path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists(__NAMESPACE__ . '\\json_encode')) {
|
||||
function json_encode($value, int $flags = 0): string|false
|
||||
{
|
||||
if (!empty($GLOBALS['version_set_json_encode_false'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \json_encode($value, $flags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
function withComposerBackupForSet(callable $callback): void
|
||||
{
|
||||
$path = base_path('composer.json');
|
||||
$original = file_get_contents($path);
|
||||
|
||||
try {
|
||||
$callback($path, $original);
|
||||
} finally {
|
||||
if ($original !== false) {
|
||||
file_put_contents($path, $original);
|
||||
}
|
||||
$GLOBALS['version_set_file_get_contents_false'] = false;
|
||||
$GLOBALS['version_set_json_encode_false'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
it('sets version when no current version', function (): void {
|
||||
withComposerBackupForSet(function (string $path): void {
|
||||
Setting::where('key', 'version')->delete();
|
||||
|
||||
$exitCode = Artisan::call('version:set', ['version' => '2.3.4']);
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
$setting = Setting::where('key', 'version')->value('value');
|
||||
expect($setting)->toBe('2.3.4');
|
||||
|
||||
$data = json_decode((string) file_get_contents($path), true);
|
||||
expect($data['version'] ?? null)->toBe('2.3.4');
|
||||
});
|
||||
});
|
||||
|
||||
it('updates version when current exists', function (): void {
|
||||
withComposerBackupForSet(function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.0.0']);
|
||||
|
||||
$exitCode = Artisan::call('version:set', ['version' => '1.0.1']);
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
$setting = Setting::where('key', 'version')->value('value');
|
||||
expect($setting)->toBe('1.0.1');
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when composer.json cannot be read', function (): void {
|
||||
withComposerBackupForSet(function (string $path): void {
|
||||
chmod($path, 0000);
|
||||
|
||||
$exitCode = Artisan::call('version:set', ['version' => '2.0.0']);
|
||||
expect($exitCode)->toBe(1);
|
||||
|
||||
chmod($path, 0644);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when composer.json cannot be decoded', function (): void {
|
||||
withComposerBackupForSet(function (string $path): void {
|
||||
file_put_contents($path, 'not-json');
|
||||
|
||||
$exitCode = Artisan::call('version:set', ['version' => '2.0.0']);
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when file_get_contents returns false', function (): void {
|
||||
withComposerBackupForSet(function (): void {
|
||||
$GLOBALS['version_set_file_get_contents_false'] = true;
|
||||
|
||||
$exitCode = Artisan::call('version:set', ['version' => '2.0.0']);
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when json_encode returns false', function (): void {
|
||||
withComposerBackupForSet(function (): void {
|
||||
$GLOBALS['version_set_json_encode_false'] = true;
|
||||
|
||||
$exitCode = Artisan::call('version:set', ['version' => '2.0.0']);
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user