feat: initial release of relaticle/comments

Filament comments package with:
- Polymorphic commenting on any Eloquent model
- Threaded replies with configurable depth
- @mentions with autocomplete and user search
- Emoji reactions with toggle and who-reacted tooltips
- File attachments via Livewire uploads
- Reply and mention notifications via Filament notification system
- Thread subscriptions for notification control
- Real-time broadcasting (opt-in Echo) with polling fallback
- Dark mode support
- CommentsAction, CommentsTableAction, CommentsEntry for Filament integration
- 204 tests, 421 assertions
This commit is contained in:
manukminasyan
2026-03-26 23:02:56 +04:00
commit 29fcbd8aec
88 changed files with 6581 additions and 0 deletions

View File

View File

@@ -0,0 +1,19 @@
<?php
namespace Relaticle\Comments\Tests\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Relaticle\Comments\Tests\Models\Post;
class PostFactory extends Factory
{
protected $model = Post::class;
/** @return array<string, mixed> */
public function definition(): array
{
return [
'title' => fake()->sentence(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Relaticle\Comments\Tests\Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Relaticle\Comments\Tests\Models\User;
class UserFactory extends Factory
{
protected $model = User::class;
/** @return array<string, mixed> */
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'password' => '$2y$04$x7FnGBxMRzQmMJDhKuOi6eLGOlIhWQOGl.IWxCNasFliYJXARljqe',
];
}
}

0
tests/Feature/.gitkeep Normal file
View File

View File

@@ -0,0 +1,310 @@
<?php
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Relaticle\Comments\Comment;
use Relaticle\Comments\CommentAttachment;
use Relaticle\Comments\Config;
use Relaticle\Comments\Livewire\CommentItem;
use Relaticle\Comments\Livewire\Comments;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('creates comment with file attachment via Livewire component', function () {
Storage::fake('public');
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
$file = UploadedFile::fake()->image('photo.jpg', 100, 100);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Comment with attachment</p>')
->set('attachments', [$file])
->call('addComment')
->assertSet('newComment', '')
->assertSet('attachments', []);
expect(Comment::count())->toBe(1);
expect(CommentAttachment::count())->toBe(1);
});
it('stores attachment with correct metadata', function () {
Storage::fake('public');
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
$file = UploadedFile::fake()->image('vacation.jpg', 200, 200)->size(512);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Vacation photos</p>')
->set('attachments', [$file])
->call('addComment');
$attachment = CommentAttachment::first();
$comment = Comment::first();
expect($attachment->original_name)->toBe('vacation.jpg')
->and($attachment->mime_type)->toBe('image/jpeg')
->and($attachment->size)->toBeGreaterThan(0)
->and($attachment->disk)->toBe('public')
->and($attachment->comment_id)->toBe($comment->id)
->and($attachment->file_path)->toStartWith("comments/attachments/{$comment->id}/");
});
it('stores file on configured disk at comments/attachments/{comment_id}/ path', function () {
Storage::fake('public');
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
$file = UploadedFile::fake()->image('test.png', 50, 50);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>File path test</p>')
->set('attachments', [$file])
->call('addComment');
$attachment = CommentAttachment::first();
Storage::disk('public')->assertExists($attachment->file_path);
expect($attachment->file_path)->toContain("comments/attachments/{$attachment->comment_id}/");
});
it('displays image attachment thumbnail in comment item view', function () {
Storage::fake('public');
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Image comment</p>',
]);
$file = UploadedFile::fake()->image('photo.jpg', 100, 100);
$path = $file->store("comments/attachments/{$comment->id}", 'public');
CommentAttachment::create([
'comment_id' => $comment->id,
'file_path' => $path,
'original_name' => 'photo.jpg',
'mime_type' => 'image/jpeg',
'size' => $file->getSize(),
'disk' => 'public',
]);
$comment->load('attachments');
$this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $comment])
->assertSeeHtml('max-h-[200px]')
->assertSeeHtml('photo.jpg');
});
it('displays non-image attachment as download link', function () {
Storage::fake('public');
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>PDF comment</p>',
]);
$file = UploadedFile::fake()->create('document.pdf', 2048, 'application/pdf');
$path = $file->store("comments/attachments/{$comment->id}", 'public');
CommentAttachment::create([
'comment_id' => $comment->id,
'file_path' => $path,
'original_name' => 'document.pdf',
'mime_type' => 'application/pdf',
'size' => $file->getSize(),
'disk' => 'public',
]);
$comment->load('attachments');
$this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $comment])
->assertSeeHtml('document.pdf')
->assertSeeHtml('download="document.pdf"');
});
it('rejects file exceeding max size', function () {
Storage::fake('public');
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
$oversizedFile = UploadedFile::fake()->create('big.pdf', Config::getAttachmentMaxSize() + 1, 'application/pdf');
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Oversized file</p>')
->set('attachments', [$oversizedFile])
->call('addComment')
->assertHasErrors('attachments.0');
expect(Comment::count())->toBe(0);
expect(CommentAttachment::count())->toBe(0);
});
it('rejects disallowed file type', function () {
Storage::fake('public');
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
$exeFile = UploadedFile::fake()->create('script.exe', 100, 'application/x-msdownload');
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Malicious file</p>')
->set('attachments', [$exeFile])
->call('addComment')
->assertHasErrors('attachments.0');
expect(Comment::count())->toBe(0);
expect(CommentAttachment::count())->toBe(0);
});
it('accepts allowed file types', function () {
Storage::fake('public');
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
$imageFile = UploadedFile::fake()->image('photo.jpg', 100, 100);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Valid file</p>')
->set('attachments', [$imageFile])
->call('addComment')
->assertHasNoErrors('attachments.0');
expect(Comment::count())->toBe(1);
expect(CommentAttachment::count())->toBe(1);
});
it('hides upload UI when attachments disabled', function () {
config(['comments.attachments.enabled' => false]);
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->assertDontSeeHtml('Attach files');
});
it('shows upload UI when attachments enabled', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->assertSeeHtml('Attach files');
});
it('creates comment with multiple file attachments', function () {
Storage::fake('public');
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
$file1 = UploadedFile::fake()->image('photo1.jpg', 100, 100);
$file2 = UploadedFile::fake()->create('notes.pdf', 512, 'application/pdf');
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Multiple files</p>')
->set('attachments', [$file1, $file2])
->call('addComment');
expect(Comment::count())->toBe(1);
expect(CommentAttachment::count())->toBe(2);
$attachments = CommentAttachment::all();
expect($attachments->pluck('original_name')->toArray())
->toContain('photo1.jpg')
->toContain('notes.pdf');
});
it('creates reply with file attachment via CommentItem component', function () {
Storage::fake('public');
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Parent comment</p>',
]);
$this->actingAs($user);
$file = UploadedFile::fake()->image('reply-photo.png', 80, 80);
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startReply')
->set('replyBody', '<p>Reply with attachment</p>')
->set('replyAttachments', [$file])
->call('addReply')
->assertSet('isReplying', false)
->assertSet('replyBody', '')
->assertSet('replyAttachments', []);
$reply = Comment::where('parent_id', $comment->id)->first();
expect($reply)->not->toBeNull();
expect($reply->attachments)->toHaveCount(1);
expect($reply->attachments->first()->original_name)->toBe('reply-photo.png');
});
it('removes attachment from pending list before submission', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
$file1 = UploadedFile::fake()->image('photo1.jpg', 50, 50);
$file2 = UploadedFile::fake()->image('photo2.jpg', 50, 50);
$component = Livewire::test(Comments::class, ['model' => $post])
->set('attachments', [$file1, $file2]);
expect($component->get('attachments'))->toHaveCount(2);
$component->call('removeAttachment', 0);
expect($component->get('attachments'))->toHaveCount(1);
});

View File

@@ -0,0 +1,187 @@
<?php
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Events\CommentCreated;
use Relaticle\Comments\Events\CommentDeleted;
use Relaticle\Comments\Events\CommentReacted;
use Relaticle\Comments\Events\CommentUpdated;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('CommentCreated event implements ShouldBroadcast', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$event = new CommentCreated($comment);
expect($event)->toBeInstanceOf(ShouldBroadcast::class);
});
it('CommentUpdated event implements ShouldBroadcast', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$event = new CommentUpdated($comment);
expect($event)->toBeInstanceOf(ShouldBroadcast::class);
});
it('CommentDeleted event implements ShouldBroadcast', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$event = new CommentDeleted($comment);
expect($event)->toBeInstanceOf(ShouldBroadcast::class);
});
it('CommentReacted event implements ShouldBroadcast', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$event = new CommentReacted($comment, $user, 'thumbs_up', 'added');
expect($event)->toBeInstanceOf(ShouldBroadcast::class);
});
it('broadcastOn returns PrivateChannel with correct channel name', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$event = new CommentCreated($comment);
$channels = $event->broadcastOn();
expect($channels)->toBeArray()
->and($channels[0])->toBeInstanceOf(PrivateChannel::class)
->and($channels[0]->name)->toBe("private-comments.{$post->getMorphClass()}.{$post->id}");
});
it('broadcastWhen returns false when broadcasting is disabled', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$event = new CommentCreated($comment);
expect($event->broadcastWhen())->toBeFalse();
});
it('broadcastWhen returns true when broadcasting is enabled', function () {
config()->set('comments.broadcasting.enabled', true);
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$event = new CommentCreated($comment);
expect($event->broadcastWhen())->toBeTrue();
});
it('broadcastWith returns array with comment_id for CommentCreated', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$event = new CommentCreated($comment);
$data = $event->broadcastWith();
expect($data)->toBeArray()
->toHaveKey('comment_id', $comment->id)
->toHaveKey('commentable_type', $post->getMorphClass())
->toHaveKey('commentable_id', $post->id);
});
it('broadcastWith returns array with comment_id, reaction, and action for CommentReacted', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$event = new CommentReacted($comment, $user, 'thumbs_up', 'added');
$data = $event->broadcastWith();
expect($data)->toBeArray()
->toHaveKey('comment_id', $comment->id)
->toHaveKey('reaction', 'thumbs_up')
->toHaveKey('action', 'added');
});
it('uses custom channel prefix from config in broadcastOn', function () {
config()->set('comments.broadcasting.channel_prefix', 'custom-prefix');
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$event = new CommentCreated($comment);
$channels = $event->broadcastOn();
expect($channels[0]->name)->toBe("private-custom-prefix.{$post->getMorphClass()}.{$post->id}");
});

View File

@@ -0,0 +1,197 @@
<?php
use Relaticle\Comments\Comment;
use Relaticle\Comments\CommentAttachment;
use Relaticle\Comments\Config;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('creates a comment attachment with all metadata fields', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Test comment</p>',
]);
$attachment = CommentAttachment::create([
'comment_id' => $comment->id,
'file_path' => 'comments/attachments/1/photo.jpg',
'original_name' => 'photo.jpg',
'mime_type' => 'image/jpeg',
'size' => 2048,
'disk' => 'public',
]);
expect($attachment)->toBeInstanceOf(CommentAttachment::class)
->and($attachment->file_path)->toBe('comments/attachments/1/photo.jpg')
->and($attachment->original_name)->toBe('photo.jpg')
->and($attachment->mime_type)->toBe('image/jpeg')
->and($attachment->size)->toBe(2048)
->and($attachment->disk)->toBe('public');
});
it('belongs to a comment via comment() relationship', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Test</p>',
]);
$attachment = CommentAttachment::create([
'comment_id' => $comment->id,
'file_path' => 'comments/attachments/1/test.png',
'original_name' => 'test.png',
'mime_type' => 'image/png',
'size' => 1024,
'disk' => 'public',
]);
expect($attachment->comment)->toBeInstanceOf(Comment::class)
->and($attachment->comment->id)->toBe($comment->id);
});
it('has attachments() hasMany relationship on Comment', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Test</p>',
]);
CommentAttachment::create([
'comment_id' => $comment->id,
'file_path' => 'comments/attachments/1/file1.png',
'original_name' => 'file1.png',
'mime_type' => 'image/png',
'size' => 2048,
'disk' => 'public',
]);
CommentAttachment::create([
'comment_id' => $comment->id,
'file_path' => 'comments/attachments/1/file2.pdf',
'original_name' => 'file2.pdf',
'mime_type' => 'application/pdf',
'size' => 5120,
'disk' => 'public',
]);
expect($comment->attachments)->toHaveCount(2)
->and($comment->attachments->first())->toBeInstanceOf(CommentAttachment::class);
});
it('cascade deletes attachments when comment is force deleted', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Test</p>',
]);
CommentAttachment::create([
'comment_id' => $comment->id,
'file_path' => 'comments/attachments/1/photo.jpg',
'original_name' => 'photo.jpg',
'mime_type' => 'image/jpeg',
'size' => 1024,
'disk' => 'public',
]);
expect(CommentAttachment::where('comment_id', $comment->id)->count())->toBe(1);
$comment->forceDelete();
expect(CommentAttachment::where('comment_id', $comment->id)->count())->toBe(0);
});
it('correctly identifies image and non-image mime types via isImage()', function (string $mimeType, bool $expected) {
$attachment = new CommentAttachment(['mime_type' => $mimeType]);
expect($attachment->isImage())->toBe($expected);
})->with([
'image/jpeg is image' => ['image/jpeg', true],
'image/png is image' => ['image/png', true],
'image/gif is image' => ['image/gif', true],
'image/webp is image' => ['image/webp', true],
'application/pdf is not image' => ['application/pdf', false],
'text/plain is not image' => ['text/plain', false],
]);
it('formats bytes into human-readable size via formattedSize()', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Test</p>',
]);
$attachment = CommentAttachment::create([
'comment_id' => $comment->id,
'file_path' => 'comments/attachments/1/file.pdf',
'original_name' => 'file.pdf',
'mime_type' => 'application/pdf',
'size' => 1024,
'disk' => 'public',
]);
expect($attachment->formattedSize())->toContain('KB');
});
it('returns default attachment disk as public', function () {
expect(Config::getAttachmentDisk())->toBe('public');
});
it('returns default attachment max size as 10240', function () {
expect(Config::getAttachmentMaxSize())->toBe(10240);
});
it('returns default allowed attachment types', function () {
$allowedTypes = Config::getAttachmentAllowedTypes();
expect($allowedTypes)->toBeArray()
->toContain('image/jpeg')
->toContain('image/png')
->toContain('application/pdf');
});
it('respects custom config overrides for attachment settings', function () {
config(['comments.attachments.disk' => 's3']);
config(['comments.attachments.max_size' => 5120]);
config(['comments.attachments.allowed_types' => ['image/png']]);
expect(Config::getAttachmentDisk())->toBe('s3')
->and(Config::getAttachmentMaxSize())->toBe(5120)
->and(Config::getAttachmentAllowedTypes())->toBe(['image/png']);
});
it('reports attachments as enabled by default', function () {
expect(Config::areAttachmentsEnabled())->toBeTrue();
});
it('respects disabled attachments config', function () {
config(['comments.attachments.enabled' => false]);
expect(Config::areAttachmentsEnabled())->toBeFalse();
});

View File

@@ -0,0 +1,124 @@
<?php
use Illuminate\Support\Facades\Event;
use Livewire\Livewire;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Events\CommentCreated;
use Relaticle\Comments\Events\CommentDeleted;
use Relaticle\Comments\Events\CommentUpdated;
use Relaticle\Comments\Livewire\CommentItem;
use Relaticle\Comments\Livewire\Comments;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('fires CommentCreated event when adding a comment', function () {
Event::fake([CommentCreated::class]);
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>New comment</p>')
->call('addComment');
Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($post) {
return $event->comment->body === '<p>New comment</p>'
&& $event->commentable->id === $post->id;
});
});
it('fires CommentUpdated event when editing a comment', function () {
Event::fake([CommentUpdated::class]);
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Original</p>',
]);
$this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit')
->set('editBody', '<p>Edited</p>')
->call('saveEdit');
Event::assertDispatched(CommentUpdated::class, function (CommentUpdated $event) use ($comment) {
return $event->comment->id === $comment->id;
});
});
it('fires CommentDeleted event when deleting a comment', function () {
Event::fake([CommentDeleted::class]);
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('deleteComment');
Event::assertDispatched(CommentDeleted::class, function (CommentDeleted $event) use ($comment) {
return $event->comment->id === $comment->id;
});
});
it('fires CommentCreated event when adding a reply', function () {
Event::fake([CommentCreated::class]);
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startReply')
->set('replyBody', '<p>Reply text</p>')
->call('addReply');
Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($comment) {
return $event->comment->parent_id === $comment->id
&& $event->comment->body === '<p>Reply text</p>';
});
});
it('carries correct comment and commentable in event payload', function () {
Event::fake([CommentCreated::class]);
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Payload test</p>')
->call('addComment');
Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($post, $user) {
return $event->comment instanceof Comment
&& $event->commentable->id === $post->id
&& $event->comment->user_id === $user->id;
});
});

View File

@@ -0,0 +1,262 @@
<?php
use Livewire\Livewire;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Livewire\CommentItem;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('allows author to start and save edit on their comment', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Original body</p>',
]);
$this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit')
->assertSet('isEditing', true)
->assertSet('editBody', '<p>Original body</p>')
->set('editBody', '<p>Updated body</p>')
->call('saveEdit')
->assertSet('isEditing', false)
->assertSet('editBody', '');
$comment->refresh();
expect($comment->body)->toBe('<p>Updated body</p>');
expect($comment->isEdited())->toBeTrue();
});
it('marks edited comment with edited indicator', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Original</p>',
]);
$this->actingAs($user);
expect($comment->isEdited())->toBeFalse();
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit')
->set('editBody', '<p>Changed</p>')
->call('saveEdit');
$comment->refresh();
expect($comment->isEdited())->toBeTrue();
expect($comment->edited_at)->not->toBeNull();
});
it('prevents non-author from editing a comment', function () {
$author = User::factory()->create();
$otherUser = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(),
'user_type' => $author->getMorphClass(),
'body' => '<p>Author comment</p>',
]);
$this->actingAs($otherUser);
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit')
->assertForbidden();
});
it('allows author to delete their own comment', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('deleteComment');
expect(Comment::find($comment->id))->toBeNull();
expect(Comment::withTrashed()->find($comment->id)->trashed())->toBeTrue();
});
it('preserves replies when parent comment is deleted', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$attrs = [
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
];
$parent = Comment::factory()->create($attrs);
$reply = Comment::factory()->withParent($parent)->create($attrs);
$this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $parent])
->call('deleteComment');
expect(Comment::withTrashed()->find($parent->id)->trashed())->toBeTrue();
expect(Comment::find($reply->id))->not->toBeNull();
expect(Comment::find($reply->id)->trashed())->toBeFalse();
});
it('prevents non-author from deleting a comment', function () {
$author = User::factory()->create();
$otherUser = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(),
'user_type' => $author->getMorphClass(),
]);
$this->actingAs($otherUser);
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('deleteComment')
->assertForbidden();
});
it('allows user to reply to a comment', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startReply')
->assertSet('isReplying', true)
->set('replyBody', '<p>My reply</p>')
->call('addReply')
->assertSet('isReplying', false)
->assertSet('replyBody', '');
$reply = Comment::where('parent_id', $comment->id)->first();
expect($reply)->not->toBeNull();
expect($reply->body)->toBe('<p>My reply</p>');
expect($reply->user_id)->toBe($user->id);
expect($reply->commentable_id)->toBe($post->id);
});
it('respects max depth for replies', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$attrs = [
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
];
config(['comments.threading.max_depth' => 1]);
$level0 = Comment::factory()->create($attrs);
$level1 = Comment::factory()->withParent($level0)->create($attrs);
$this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $level1])
->call('startReply')
->assertSet('isReplying', false);
});
it('resets state when cancelling edit', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Some body</p>',
]);
$this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit')
->assertSet('isEditing', true)
->call('cancelEdit')
->assertSet('isEditing', false)
->assertSet('editBody', '');
});
it('resets state when cancelling reply', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startReply')
->assertSet('isReplying', true)
->set('replyBody', '<p>Draft reply</p>')
->call('cancelReply')
->assertSet('isReplying', false)
->assertSet('replyBody', '');
});
it('loads all replies within a thread eagerly', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$attrs = [
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
];
$parent = Comment::factory()->create($attrs);
Comment::factory()->count(3)->withParent($parent)->create($attrs);
$parentWithReplies = Comment::with('replies.user')->find($parent->id);
$this->actingAs($user);
$component = Livewire::test(CommentItem::class, ['comment' => $parentWithReplies]);
expect($component->instance()->comment->replies)->toHaveCount(3);
});

View File

@@ -0,0 +1,108 @@
<?php
use Illuminate\Database\QueryException;
use Relaticle\Comments\Comment;
use Relaticle\Comments\CommentReaction;
use Relaticle\Comments\Events\CommentReacted;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('belongs to a comment via comment() relationship', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Test</p>',
]);
$reaction = CommentReaction::create([
'comment_id' => $comment->id,
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'reaction' => 'thumbs_up',
]);
expect($reaction->comment)->toBeInstanceOf(Comment::class)
->and($reaction->comment->id)->toBe($comment->id);
});
it('belongs to a user via polymorphic user() relationship', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Test</p>',
]);
$reaction = CommentReaction::create([
'comment_id' => $comment->id,
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'reaction' => 'heart',
]);
expect($reaction->user)->toBeInstanceOf(User::class)
->and($reaction->user->id)->toBe($user->id);
});
it('prevents duplicate reactions with unique constraint', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Test</p>',
]);
CommentReaction::create([
'comment_id' => $comment->id,
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'reaction' => 'thumbs_up',
]);
expect(fn () => CommentReaction::create([
'comment_id' => $comment->id,
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'reaction' => 'thumbs_up',
]))->toThrow(QueryException::class);
});
it('carries comment, user, reaction key, and action in CommentReacted event', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Test</p>',
]);
$event = new CommentReacted(
comment: $comment,
user: $user,
reaction: 'heart',
action: 'added',
);
expect($event->comment)->toBeInstanceOf(Comment::class)
->and($event->comment->id)->toBe($comment->id)
->and($event->user)->toBeInstanceOf(User::class)
->and($event->user->id)->toBe($user->id)
->and($event->reaction)->toBe('heart')
->and($event->action)->toBe('added');
});

View File

@@ -0,0 +1,107 @@
<?php
use Relaticle\Comments\Comment;
use Relaticle\Comments\CommentSubscription;
use Relaticle\Comments\Config;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('has commentable morphTo relationship', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$subscription = CommentSubscription::create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
expect($subscription->commentable)->toBeInstanceOf(Post::class)
->and($subscription->commentable->id)->toBe($post->id);
});
it('has user morphTo relationship', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$subscription = CommentSubscription::create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
expect($subscription->user)->toBeInstanceOf(User::class)
->and($subscription->user->id)->toBe($user->id);
});
it('returns database as default notification channel', function () {
expect(Config::getNotificationChannels())->toBe(['database']);
});
it('returns custom channels when configured', function () {
config()->set('comments.notifications.channels', ['database', 'mail']);
expect(Config::getNotificationChannels())->toBe(['database', 'mail']);
});
it('returns true for shouldAutoSubscribe by default', function () {
expect(Config::shouldAutoSubscribe())->toBeTrue();
});
it('returns false for shouldAutoSubscribe when configured', function () {
config()->set('comments.subscriptions.auto_subscribe', false);
expect(Config::shouldAutoSubscribe())->toBeFalse();
});
it('checks if user is subscribed to a commentable via isSubscribed()', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
expect(CommentSubscription::isSubscribed($post, $user))->toBeFalse();
CommentSubscription::create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
expect(CommentSubscription::isSubscribed($post, $user))->toBeTrue();
});
it('creates subscription via subscribe() static method', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
CommentSubscription::subscribe($post, $user);
expect(CommentSubscription::isSubscribed($post, $user))->toBeTrue();
});
it('removes subscription via unsubscribe() static method', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
CommentSubscription::subscribe($post, $user);
CommentSubscription::unsubscribe($post, $user);
expect(CommentSubscription::isSubscribed($post, $user))->toBeFalse();
});
it('is idempotent when subscribing twice', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
CommentSubscription::subscribe($post, $user);
CommentSubscription::subscribe($post, $user);
expect(CommentSubscription::where([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
])->count())->toBe(1);
});

View File

@@ -0,0 +1,197 @@
<?php
use Carbon\Carbon;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('can be created with factory', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(),
'user_id' => $user->id,
]);
expect($comment)->toBeInstanceOf(Comment::class);
expect($comment->body)->toBeString();
expect($comment->commentable_id)->toBe($post->id);
expect($comment->user_id)->toBe($user->id);
});
it('belongs to a commentable model via morphTo', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(),
'user_id' => $user->id,
]);
expect($comment->commentable)->toBeInstanceOf(Post::class);
expect($comment->commentable->id)->toBe($post->id);
});
it('belongs to a user via morphTo', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(),
'user_id' => $user->id,
]);
expect($comment->user)->toBeInstanceOf(User::class);
expect($comment->user->id)->toBe($user->id);
});
it('supports threading with parent and replies', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$parent = Comment::factory()->create([
'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(),
'user_id' => $user->id,
]);
$reply = Comment::factory()->withParent($parent)->create([
'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(),
'user_id' => $user->id,
]);
expect($reply->parent->id)->toBe($parent->id);
expect($parent->replies)->toHaveCount(1);
expect($parent->replies->first()->id)->toBe($reply->id);
});
it('identifies top-level vs reply comments', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$topLevel = Comment::factory()->create([
'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(),
'user_id' => $user->id,
]);
$reply = Comment::factory()->withParent($topLevel)->create([
'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(),
'user_id' => $user->id,
]);
expect($topLevel->isTopLevel())->toBeTrue();
expect($topLevel->isReply())->toBeFalse();
expect($reply->isReply())->toBeTrue();
expect($reply->isTopLevel())->toBeFalse();
});
it('calculates depth correctly', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$attrs = [
'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(),
'user_id' => $user->id,
];
$level0 = Comment::factory()->create($attrs);
$level1 = Comment::factory()->withParent($level0)->create($attrs);
$level2 = Comment::factory()->withParent($level1)->create($attrs);
expect($level0->depth())->toBe(0);
expect($level1->depth())->toBe(1);
expect($level2->depth())->toBe(2);
});
it('checks canReply based on max depth', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$attrs = [
'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(),
'user_id' => $user->id,
];
$level0 = Comment::factory()->create($attrs);
$level1 = Comment::factory()->withParent($level0)->create($attrs);
$level2 = Comment::factory()->withParent($level1)->create($attrs);
expect($level0->canReply())->toBeTrue();
expect($level1->canReply())->toBeTrue();
expect($level2->canReply())->toBeFalse();
});
it('supports soft deletes', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(),
'user_id' => $user->id,
]);
$comment->delete();
expect(Comment::find($comment->id))->toBeNull();
expect(Comment::withTrashed()->find($comment->id))->not->toBeNull();
expect(Comment::withTrashed()->find($comment->id)->trashed())->toBeTrue();
});
it('tracks edited state', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(),
'user_id' => $user->id,
]);
expect($comment->isEdited())->toBeFalse();
$edited = Comment::factory()->edited()->create([
'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(),
'user_id' => $user->id,
]);
expect($edited->isEdited())->toBeTrue();
expect($edited->edited_at)->toBeInstanceOf(Carbon::class);
});
it('detects when it has replies', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$attrs = [
'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(),
'user_id' => $user->id,
];
$parent = Comment::factory()->create($attrs);
expect($parent->hasReplies())->toBeFalse();
Comment::factory()->withParent($parent)->create($attrs);
expect($parent->hasReplies())->toBeTrue();
});

View File

@@ -0,0 +1,62 @@
<?php
use Relaticle\Comments\Comment;
use Relaticle\Comments\Filament\Actions\CommentsAction;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('can be instantiated via make', function () {
$action = CommentsAction::make('comments');
expect($action)->toBeInstanceOf(CommentsAction::class);
});
it('has the correct default name', function () {
$action = CommentsAction::make('comments');
expect($action->getName())->toBe('comments');
});
it('configures as a slide-over', function () {
$action = CommentsAction::make('comments');
expect($action->isModalSlideOver())->toBeTrue();
});
it('has a chat bubble icon', function () {
$action = CommentsAction::make('comments');
expect($action->getIcon())->toBe('heroicon-o-chat-bubble-left-right');
});
it('has modal content configured', function () {
$action = CommentsAction::make('comments');
expect($action->hasModalContent())->toBeTrue();
});
it('shows badge with comment count when comments exist', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
Comment::factory()->count(3)->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$action = CommentsAction::make('comments');
$action->record($post);
expect($action->getBadge())->toBe(3);
});
it('returns null badge when no comments exist', function () {
$post = Post::factory()->create();
$action = CommentsAction::make('comments');
$action->record($post);
expect($action->getBadge())->toBeNull();
});

View File

@@ -0,0 +1,185 @@
<?php
use Livewire\Livewire;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Livewire\Comments;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('allows authenticated user to create a comment on a post', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Hello World</p>')
->call('addComment')
->assertSet('newComment', '');
expect(Comment::count())->toBe(1);
expect(Comment::first()->body)->toBe('<p>Hello World</p>');
});
it('associates new comment with the authenticated user', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Test</p>')
->call('addComment');
$comment = Comment::first();
expect($comment->user_id)->toBe($user->id);
expect($comment->user_type)->toBe($user->getMorphClass());
expect($comment->commentable_id)->toBe($post->id);
expect($comment->commentable_type)->toBe($post->getMorphClass());
});
it('requires authentication to create a comment', function () {
$post = Post::factory()->create();
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Hello</p>')
->call('addComment')
->assertForbidden();
});
it('validates that comment body is not empty', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '')
->call('addComment')
->assertHasErrors('newComment');
expect(Comment::count())->toBe(0);
});
it('paginates top-level comments with load more', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
config(['comments.pagination.per_page' => 5]);
Comment::factory()->count(12)->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$this->actingAs($user);
$component = Livewire::test(Comments::class, ['model' => $post]);
expect($component->get('loadedCount'))->toBe(5);
$component->call('loadMore');
expect($component->get('loadedCount'))->toBe(10);
$component->call('loadMore');
expect($component->get('loadedCount'))->toBe(15);
});
it('hides load more button when all comments are loaded', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
config(['comments.pagination.per_page' => 10]);
Comment::factory()->count(5)->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->assertDontSee('Load more comments');
});
it('toggles sort direction between asc and desc', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->assertSet('sortDirection', 'asc')
->call('toggleSort')
->assertSet('sortDirection', 'desc')
->call('toggleSort')
->assertSet('sortDirection', 'asc');
});
it('returns comments in correct sort order via computed property', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$older = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Older comment</p>',
'created_at' => now()->subHour(),
]);
$newer = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Newer comment</p>',
'created_at' => now(),
]);
$this->actingAs($user);
$component = Livewire::test(Comments::class, ['model' => $post]);
$comments = $component->instance()->comments();
expect($comments->first()->id)->toBe($older->id);
expect($comments->last()->id)->toBe($newer->id);
$component->call('toggleSort');
$comments = $component->instance()->comments();
expect($comments->first()->id)->toBe($newer->id);
expect($comments->last()->id)->toBe($older->id);
});
it('displays total comment count', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
Comment::factory()->count(3)->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->assertSee('Comments (3)');
});
it('hides comment form for guests', function () {
$post = Post::factory()->create();
Livewire::test(Comments::class, ['model' => $post])
->assertDontSee('Write a comment...');
});

View File

@@ -0,0 +1,21 @@
<?php
use Relaticle\Comments\Filament\Infolists\Components\CommentsEntry;
it('can be instantiated via make', function () {
$entry = CommentsEntry::make('comments');
expect($entry)->toBeInstanceOf(CommentsEntry::class);
});
it('has the correct view path', function () {
$entry = CommentsEntry::make('comments');
expect($entry->getView())->toBe('comments::filament.infolists.components.comments-entry');
});
it('defaults to full column span', function () {
$entry = CommentsEntry::make('comments');
expect($entry->getColumnSpan('default'))->toBe('full');
});

View File

@@ -0,0 +1,41 @@
<?php
use Relaticle\Comments\Comment;
use Relaticle\Comments\Filament\Actions\CommentsTableAction;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('can be instantiated via make', function () {
$action = CommentsTableAction::make('comments');
expect($action)->toBeInstanceOf(CommentsTableAction::class);
});
it('configures as a slide-over', function () {
$action = CommentsTableAction::make('comments');
expect($action->isModalSlideOver())->toBeTrue();
});
it('has modal content configured', function () {
$action = CommentsTableAction::make('comments');
expect($action->hasModalContent())->toBeTrue();
});
it('shows badge with comment count for the record', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
Comment::factory()->count(5)->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$action = CommentsTableAction::make('comments');
$action->record($post);
expect($action->getBadge())->toBe(5);
});

View File

@@ -0,0 +1,172 @@
<?php
use Livewire\Livewire;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Livewire\Comments;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('strips script tags from comment body on create', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Hello</p><script>alert(1)</script>',
]);
expect($comment->body)->not->toContain('<script>');
expect($comment->body)->toContain('<p>Hello</p>');
});
it('strips event handler attributes from comment body', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<img onerror="alert(1)" src="x">',
]);
expect($comment->body)->not->toContain('onerror');
expect($comment->body)->toContain('src="x"');
});
it('strips style tags from comment body', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Hi</p><style>body{display:none}</style>',
]);
expect($comment->body)->not->toContain('<style>');
expect($comment->body)->toContain('<p>Hi</p>');
});
it('strips iframe tags from comment body', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Hi</p><iframe src="evil.com"></iframe>',
]);
expect($comment->body)->not->toContain('<iframe');
expect($comment->body)->toContain('<p>Hi</p>');
});
it('preserves safe HTML formatting through sanitization', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$safeHtml = '<p>Hello <strong>bold</strong> and <em>italic</em> text</p>'
.'<a href="https://example.com">link</a>'
.'<ul><li>item one</li><li>item two</li></ul>'
.'<pre><code>echo "hello";</code></pre>'
.'<blockquote>quoted text</blockquote>'
.'<h1>heading one</h1>'
.'<h2>heading two</h2>';
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => $safeHtml,
]);
expect($comment->body)->toContain('<strong>bold</strong>');
expect($comment->body)->toContain('<em>italic</em>');
expect($comment->body)->toContain('<a href="https://example.com">link</a>');
expect($comment->body)->toContain('<ul>');
expect($comment->body)->toContain('<li>item one</li>');
expect($comment->body)->toContain('<pre><code>');
expect($comment->body)->toContain('<blockquote>quoted text</blockquote>');
expect($comment->body)->toContain('<h1>heading one</h1>');
expect($comment->body)->toContain('<h2>heading two</h2>');
});
it('sanitizes comment body on update', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Clean content</p>',
]);
$comment->update([
'body' => '<p>Updated</p><script>document.cookie</script>',
]);
$comment->refresh();
expect($comment->body)->not->toContain('<script>');
expect($comment->body)->toContain('<p>Updated</p>');
});
it('strips javascript protocol from link href', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<a href="javascript:alert(1)">click me</a>',
]);
expect($comment->body)->not->toContain('javascript:');
expect($comment->body)->toContain('click me');
});
it('strips onclick handler from elements', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<div onclick="alert(1)">click me</div>',
]);
expect($comment->body)->not->toContain('onclick');
expect($comment->body)->toContain('click me');
});
it('sanitizes content submitted through livewire component', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Hello</p><script>alert("xss")</script>')
->call('addComment');
$comment = Comment::first();
expect($comment->body)->not->toContain('<script>');
expect($comment->body)->toContain('<p>Hello</p>');
});

View File

@@ -0,0 +1,77 @@
<?php
use Relaticle\Comments\Comment;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('provides comments relationship on commentable model', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
Comment::factory()->count(3)->create([
'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(),
'user_id' => $user->id,
]);
expect($post->comments)->toHaveCount(3);
expect($post->comments->first())->toBeInstanceOf(Comment::class);
});
it('provides topLevelComments excluding replies', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$attrs = [
'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(),
'user_id' => $user->id,
];
$topLevel = Comment::factory()->create($attrs);
Comment::factory()->withParent($topLevel)->create($attrs);
expect($post->comments()->count())->toBe(2);
expect($post->topLevelComments()->count())->toBe(1);
expect($post->topLevelComments->first()->id)->toBe($topLevel->id);
});
it('provides comment count', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
expect($post->commentCount())->toBe(0);
Comment::factory()->count(5)->create([
'commentable_type' => $post->getMorphClass(),
'commentable_id' => $post->id,
'user_type' => $user->getMorphClass(),
'user_id' => $user->id,
]);
expect($post->commentCount())->toBe(5);
});
it('scopes comments to the specific commentable', function () {
$user = User::factory()->create();
$post1 = Post::factory()->create();
$post2 = Post::factory()->create();
Comment::factory()->count(3)->create([
'commentable_type' => $post1->getMorphClass(),
'commentable_id' => $post1->id,
'user_type' => $user->getMorphClass(),
'user_id' => $user->id,
]);
Comment::factory()->count(2)->create([
'commentable_type' => $post2->getMorphClass(),
'commentable_id' => $post2->id,
'user_type' => $user->getMorphClass(),
'user_id' => $user->id,
]);
expect($post1->commentCount())->toBe(3);
expect($post2->commentCount())->toBe(2);
});

View File

@@ -0,0 +1,90 @@
<?php
use Livewire\Livewire;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Livewire\CommentItem;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('renders mention with styled span', function () {
$user = User::factory()->create();
$alice = User::factory()->create(['name' => 'Alice']);
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>@Alice said hi</p>',
]);
$comment->mentions()->attach($alice->id, ['user_type' => $alice->getMorphClass()]);
$rendered = $comment->renderBodyWithMentions();
expect($rendered)->toContain('comment-mention');
expect($rendered)->toContain('@Alice</span>');
});
it('renders multiple mentions with styled spans', function () {
$user = User::factory()->create();
$alice = User::factory()->create(['name' => 'Alice']);
$bob = User::factory()->create(['name' => 'Bob']);
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>@Alice and @Bob</p>',
]);
$comment->mentions()->attach($alice->id, ['user_type' => $alice->getMorphClass()]);
$comment->mentions()->attach($bob->id, ['user_type' => $bob->getMorphClass()]);
$rendered = $comment->renderBodyWithMentions();
expect($rendered)->toContain('@Alice</span>');
expect($rendered)->toContain('@Bob</span>');
expect($rendered)->toContain('comment-mention');
});
it('does not style non-mentioned @text', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>@ghost is not here</p>',
]);
$rendered = $comment->renderBodyWithMentions();
expect($rendered)->not->toContain('comment-mention');
});
it('renders comment-mention class in Livewire component', function () {
$user = User::factory()->create();
$alice = User::factory()->create(['name' => 'Alice']);
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Hello @Alice</p>',
]);
$comment->mentions()->attach($alice->id, ['user_type' => $alice->getMorphClass()]);
$this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $comment])
->assertSeeHtml('comment-mention');
});

View File

@@ -0,0 +1,223 @@
<?php
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Contracts\MentionResolver;
use Relaticle\Comments\Events\UserMentioned;
use Relaticle\Comments\Mentions\DefaultMentionResolver;
use Relaticle\Comments\Mentions\MentionParser;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('parses @username from plain text body', function () {
User::factory()->create(['name' => 'john']);
User::factory()->create(['name' => 'jane']);
$parser = app(MentionParser::class);
$result = $parser->parse('Hello @john and @jane');
expect($result)->toHaveCount(2);
});
it('parses @username from HTML body', function () {
$john = User::factory()->create(['name' => 'john']);
$parser = app(MentionParser::class);
$result = $parser->parse('<p>Hello @john</p>');
expect($result)->toHaveCount(1);
expect($result->first())->toBe($john->id);
});
it('returns empty collection when no mentions', function () {
$parser = app(MentionParser::class);
$result = $parser->parse('Hello world');
expect($result)->toBeEmpty();
});
it('returns empty collection for non-existent users', function () {
$parser = app(MentionParser::class);
$result = $parser->parse('Hello @ghostuser');
expect($result)->toBeEmpty();
});
it('handles duplicate @mentions', function () {
User::factory()->create(['name' => 'john']);
$parser = app(MentionParser::class);
$result = $parser->parse('@john said hi @john');
expect($result)->toHaveCount(1);
});
it('stores mentions in comment_mentions table on create', function () {
$user = User::factory()->create();
$john = User::factory()->create(['name' => 'john']);
$jane = User::factory()->create(['name' => 'jane']);
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Hello @john and @jane</p>',
]);
app(MentionParser::class)->syncMentions($comment);
expect($comment->mentions()->count())->toBe(2);
expect($comment->mentions->pluck('id')->sort()->values()->all())
->toBe(collect([$john->id, $jane->id])->sort()->values()->all());
});
it('dispatches UserMentioned event for each mentioned user', function () {
Event::fake([UserMentioned::class]);
$user = User::factory()->create();
$john = User::factory()->create(['name' => 'john']);
$jane = User::factory()->create(['name' => 'jane']);
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Hello @john and @jane</p>',
]);
app(MentionParser::class)->syncMentions($comment);
Event::assertDispatched(UserMentioned::class, 2);
Event::assertDispatched(UserMentioned::class, function (UserMentioned $event) use ($comment, $john) {
return $event->comment->id === $comment->id
&& $event->mentionedUser->id === $john->id;
});
Event::assertDispatched(UserMentioned::class, function (UserMentioned $event) use ($comment, $jane) {
return $event->comment->id === $comment->id
&& $event->mentionedUser->id === $jane->id;
});
});
it('only dispatches UserMentioned for newly added mentions on update', function () {
Event::fake([UserMentioned::class]);
$user = User::factory()->create();
$john = User::factory()->create(['name' => 'john']);
$jane = User::factory()->create(['name' => 'jane']);
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Hello @john</p>',
]);
app(MentionParser::class)->syncMentions($comment);
Event::assertDispatched(UserMentioned::class, 1);
Event::fake([UserMentioned::class]);
$comment->update(['body' => '<p>Hello @john and @jane</p>']);
app(MentionParser::class)->syncMentions($comment->fresh());
Event::assertDispatched(UserMentioned::class, 1);
Event::assertDispatched(UserMentioned::class, function (UserMentioned $event) use ($jane) {
return $event->mentionedUser->id === $jane->id;
});
});
it('removes mentions from pivot when user removed from body', function () {
$user = User::factory()->create();
$john = User::factory()->create(['name' => 'john']);
$jane = User::factory()->create(['name' => 'jane']);
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Hello @john and @jane</p>',
]);
app(MentionParser::class)->syncMentions($comment);
expect($comment->mentions()->count())->toBe(2);
$comment->update(['body' => '<p>Hello @john</p>']);
app(MentionParser::class)->syncMentions($comment->fresh());
$comment->refresh();
expect($comment->mentions()->count())->toBe(1);
expect($comment->mentions->first()->id)->toBe($john->id);
});
it('uses configured MentionResolver', function () {
$customResolver = new class implements MentionResolver
{
public function search(string $query): Collection
{
return collect();
}
public function resolveByNames(array $names): Collection
{
return collect();
}
};
$this->app->instance(MentionResolver::class, $customResolver);
$parser = app(MentionParser::class);
$result = $parser->parse('@someuser');
expect($result)->toBeEmpty();
});
it('searches users by name prefix', function () {
User::factory()->create(['name' => 'John Doe']);
User::factory()->create(['name' => 'Jane Doe']);
User::factory()->create(['name' => 'Bob Smith']);
$resolver = app(DefaultMentionResolver::class);
$results = $resolver->search('Jo');
expect($results)->toHaveCount(1);
expect($results->first()->name)->toBe('John Doe');
});
it('limits search results to configured max', function () {
for ($i = 1; $i <= 10; $i++) {
User::factory()->create(['name' => "Test User {$i}"]);
}
config(['comments.mentions.max_results' => 3]);
$resolver = app(DefaultMentionResolver::class);
$results = $resolver->search('Test');
expect($results)->toHaveCount(3);
});
it('resolves users by exact name', function () {
$john = User::factory()->create(['name' => 'John Doe']);
User::factory()->create(['name' => 'Jane Doe']);
$resolver = app(DefaultMentionResolver::class);
$results = $resolver->resolveByNames(['John Doe']);
expect($results)->toHaveCount(1);
expect($results->first()->id)->toBe($john->id);
});

View File

@@ -0,0 +1,57 @@
<?php
use Relaticle\Comments\Contracts\MentionResolver;
use Relaticle\Comments\Mentions\DefaultMentionResolver;
use Relaticle\Comments\Tests\Models\User;
it('resolves the default mention resolver from the container', function () {
$resolver = app(MentionResolver::class);
expect($resolver)->toBeInstanceOf(DefaultMentionResolver::class);
});
it('searches users by name prefix', function () {
User::factory()->create(['name' => 'john']);
User::factory()->create(['name' => 'joe']);
User::factory()->create(['name' => 'alice']);
$resolver = app(MentionResolver::class);
$results = $resolver->search('jo');
expect($results)->toHaveCount(2)
->each->toBeInstanceOf(User::class);
});
it('limits search results to configured max', function () {
for ($i = 0; $i < 10; $i++) {
User::factory()->create(['name' => "john{$i}"]);
}
config()->set('comments.mentions.max_results', 3);
$resolver = new DefaultMentionResolver;
$results = $resolver->search('john');
expect($results)->toHaveCount(3);
});
it('resolves users by exact names', function () {
$john = User::factory()->create(['name' => 'john']);
$jane = User::factory()->create(['name' => 'jane']);
User::factory()->create(['name' => 'alice']);
$resolver = app(MentionResolver::class);
$users = $resolver->resolveByNames(['john', 'jane']);
expect($users)->toHaveCount(2);
expect($users->pluck('id')->all())
->toContain($john->id)
->toContain($jane->id);
});
it('returns empty collection for unknown names', function () {
$resolver = app(MentionResolver::class);
$users = $resolver->resolveByNames(['nobody', 'nonexistent']);
expect($users)->toBeEmpty();
});

View File

@@ -0,0 +1,111 @@
<?php
use Livewire\Livewire;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Livewire\CommentItem;
use Relaticle\Comments\Livewire\Comments;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('returns matching users for search query', function () {
$alice = User::factory()->create(['name' => 'Alice']);
User::factory()->create(['name' => 'Bob']);
$post = Post::factory()->create();
$this->actingAs($alice);
$component = Livewire::test(Comments::class, ['model' => $post]);
$results = $component->instance()->searchUsers('Ali');
expect($results)->toHaveCount(1);
expect($results[0])->toMatchArray([
'id' => $alice->id,
'name' => 'Alice',
]);
expect($results[0])->toHaveKey('avatar_url');
});
it('returns empty array for empty query', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
$component = Livewire::test(Comments::class, ['model' => $post]);
$results = $component->instance()->searchUsers('');
expect($results)->toBeEmpty();
});
it('returns empty array for no matches', function () {
$user = User::factory()->create(['name' => 'Alice']);
$post = Post::factory()->create();
$this->actingAs($user);
$component = Livewire::test(Comments::class, ['model' => $post]);
$results = $component->instance()->searchUsers('zzz');
expect($results)->toBeEmpty();
});
it('limits search results to configured max', function () {
$user = User::factory()->create(['name' => 'Admin']);
$post = Post::factory()->create();
for ($i = 1; $i <= 10; $i++) {
User::factory()->create(['name' => "Test User {$i}"]);
}
config(['comments.mentions.max_results' => 3]);
$this->actingAs($user);
$component = Livewire::test(Comments::class, ['model' => $post]);
$results = $component->instance()->searchUsers('Test');
expect($results)->toHaveCount(3);
});
it('stores mentions when creating comment with @mention', function () {
$user = User::factory()->create();
$alice = User::factory()->create(['name' => 'Alice']);
$post = Post::factory()->create();
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Hey @Alice check this</p>')
->call('addComment');
$comment = Comment::first();
expect($comment->mentions)->toHaveCount(1);
expect($comment->mentions->first()->id)->toBe($alice->id);
});
it('stores mentions when editing comment with @mention', function () {
$user = User::factory()->create();
$bob = User::factory()->create(['name' => 'Bob']);
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Original comment</p>',
]);
$this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit')
->set('editBody', '<p>Updated @Bob</p>')
->call('saveEdit');
$comment->refresh();
expect($comment->mentions)->toHaveCount(1);
expect($comment->mentions->first()->id)->toBe($bob->id);
});

View File

@@ -0,0 +1,232 @@
<?php
use Illuminate\Support\Facades\Notification;
use Relaticle\Comments\Comment;
use Relaticle\Comments\CommentSubscription;
use Relaticle\Comments\Events\CommentCreated;
use Relaticle\Comments\Events\UserMentioned;
use Relaticle\Comments\Listeners\SendCommentRepliedNotification;
use Relaticle\Comments\Listeners\SendUserMentionedNotification;
use Relaticle\Comments\Notifications\CommentRepliedNotification;
use Relaticle\Comments\Notifications\UserMentionedNotification;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('sends CommentRepliedNotification to parent comment author when reply is created', function () {
Notification::fake();
$parentAuthor = User::factory()->create();
$replyAuthor = User::factory()->create();
$post = Post::factory()->create();
CommentSubscription::subscribe($post, $parentAuthor);
$parentComment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $parentAuthor->getKey(),
'user_type' => $parentAuthor->getMorphClass(),
'body' => '<p>Parent comment</p>',
]);
$reply = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $replyAuthor->getKey(),
'user_type' => $replyAuthor->getMorphClass(),
'parent_id' => $parentComment->id,
'body' => '<p>A reply</p>',
]);
$listener = new SendCommentRepliedNotification;
$listener->handle(new CommentCreated($reply));
Notification::assertSentTo($parentAuthor, CommentRepliedNotification::class);
});
it('does NOT send reply notification for top-level comments', function () {
Notification::fake();
$author = User::factory()->create();
$subscriber = User::factory()->create();
$post = Post::factory()->create();
CommentSubscription::subscribe($post, $subscriber);
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(),
'user_type' => $author->getMorphClass(),
'body' => '<p>Top-level comment</p>',
]);
$listener = new SendCommentRepliedNotification;
$listener->handle(new CommentCreated($comment));
Notification::assertNothingSent();
});
it('does NOT send reply notification to the reply author', function () {
Notification::fake();
$user = User::factory()->create();
$post = Post::factory()->create();
CommentSubscription::subscribe($post, $user);
$parentComment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>My comment</p>',
]);
$reply = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'parent_id' => $parentComment->id,
'body' => '<p>My own reply</p>',
]);
$listener = new SendCommentRepliedNotification;
$listener->handle(new CommentCreated($reply));
Notification::assertNotSentTo($user, CommentRepliedNotification::class);
});
it('sends UserMentionedNotification when a user is mentioned', function () {
Notification::fake();
$author = User::factory()->create();
$mentioned = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(),
'user_type' => $author->getMorphClass(),
'body' => '<p>Hey @someone</p>',
]);
$listener = new SendUserMentionedNotification;
$listener->handle(new UserMentioned($comment, $mentioned));
Notification::assertSentTo($mentioned, UserMentionedNotification::class);
});
it('does NOT send mention notification to the comment author', function () {
Notification::fake();
$author = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(),
'user_type' => $author->getMorphClass(),
'body' => '<p>Hey @myself</p>',
]);
$listener = new SendUserMentionedNotification;
$listener->handle(new UserMentioned($comment, $author));
Notification::assertNotSentTo($author, UserMentionedNotification::class);
});
it('does NOT send reply notification to unsubscribed user', function () {
Notification::fake();
$author = User::factory()->create();
$unsubscribedUser = User::factory()->create();
$post = Post::factory()->create();
CommentSubscription::subscribe($post, $unsubscribedUser);
CommentSubscription::unsubscribe($post, $unsubscribedUser);
$parentComment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $unsubscribedUser->getKey(),
'user_type' => $unsubscribedUser->getMorphClass(),
'body' => '<p>Original</p>',
]);
$reply = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(),
'user_type' => $author->getMorphClass(),
'parent_id' => $parentComment->id,
'body' => '<p>Reply</p>',
]);
$listener = new SendCommentRepliedNotification;
$listener->handle(new CommentCreated($reply));
Notification::assertNotSentTo($unsubscribedUser, CommentRepliedNotification::class);
});
it('auto-subscribes the comment author when creating a comment', function () {
Notification::fake();
$author = User::factory()->create();
$post = Post::factory()->create();
expect(CommentSubscription::isSubscribed($post, $author))->toBeFalse();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(),
'user_type' => $author->getMorphClass(),
'body' => '<p>My comment</p>',
]);
$listener = new SendCommentRepliedNotification;
$listener->handle(new CommentCreated($comment));
expect(CommentSubscription::isSubscribed($post, $author))->toBeTrue();
});
it('suppresses all notifications when notifications are disabled via config', function () {
Notification::fake();
config()->set('comments.notifications.enabled', false);
$author = User::factory()->create();
$subscriber = User::factory()->create();
$mentioned = User::factory()->create();
$post = Post::factory()->create();
CommentSubscription::subscribe($post, $subscriber);
$parentComment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $subscriber->getKey(),
'user_type' => $subscriber->getMorphClass(),
'body' => '<p>Original</p>',
]);
$reply = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(),
'user_type' => $author->getMorphClass(),
'parent_id' => $parentComment->id,
'body' => '<p>Reply</p>',
]);
$replyListener = new SendCommentRepliedNotification;
$replyListener->handle(new CommentCreated($reply));
$mentionListener = new SendUserMentionedNotification;
$mentionListener->handle(new UserMentioned($reply, $mentioned));
Notification::assertNothingSent();
});

View File

@@ -0,0 +1,343 @@
<?php
use Illuminate\Support\Facades\Notification;
use Relaticle\Comments\Comment;
use Relaticle\Comments\CommentSubscription;
use Relaticle\Comments\Config;
use Relaticle\Comments\Events\CommentCreated;
use Relaticle\Comments\Events\UserMentioned;
use Relaticle\Comments\Listeners\SendCommentRepliedNotification;
use Relaticle\Comments\Listeners\SendUserMentionedNotification;
use Relaticle\Comments\Notifications\CommentRepliedNotification;
use Relaticle\Comments\Notifications\UserMentionedNotification;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('returns correct via channels from config for CommentRepliedNotification', function () {
config()->set('comments.notifications.channels', ['database', 'mail']);
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Hello</p>',
]);
$notification = new CommentRepliedNotification($comment);
expect($notification->via($user))->toBe(['database', 'mail']);
});
it('returns toDatabase array with comment data for CommentRepliedNotification', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>This is a reply body</p>',
]);
$notification = new CommentRepliedNotification($comment);
$data = $notification->toDatabase($user);
expect($data)->toHaveKeys(['comment_id', 'commentable_type', 'commentable_id', 'commenter_name', 'body'])
->and($data['comment_id'])->toBe($comment->id)
->and($data['commentable_type'])->toBe($post->getMorphClass())
->and($data['commentable_id'])->toBe($post->id)
->and($data['commenter_name'])->toBe($user->getCommentName());
});
it('returns correct via channels from config for UserMentionedNotification', function () {
config()->set('comments.notifications.channels', ['database']);
$user = User::factory()->create();
$mentionedBy = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $mentionedBy->getKey(),
'user_type' => $mentionedBy->getMorphClass(),
'body' => '<p>Hey @someone</p>',
]);
$notification = new UserMentionedNotification($comment, $mentionedBy);
expect($notification->via($user))->toBe(['database']);
});
it('returns toDatabase array with mention data for UserMentionedNotification', function () {
$mentioner = User::factory()->create();
$mentioned = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $mentioner->getKey(),
'user_type' => $mentioner->getMorphClass(),
'body' => '<p>Hey @mentioned</p>',
]);
$notification = new UserMentionedNotification($comment, $mentioner);
$data = $notification->toDatabase($mentioned);
expect($data)->toHaveKeys(['comment_id', 'commentable_type', 'commentable_id', 'mentioner_name', 'body'])
->and($data['comment_id'])->toBe($comment->id)
->and($data['mentioner_name'])->toBe($mentioner->getCommentName());
});
it('sends notification to subscribers when reply comment is created', function () {
Notification::fake();
$author = User::factory()->create();
$subscriber = User::factory()->create();
$post = Post::factory()->create();
CommentSubscription::subscribe($post, $subscriber);
$parentComment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $subscriber->getKey(),
'user_type' => $subscriber->getMorphClass(),
'body' => '<p>Original comment</p>',
]);
$reply = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(),
'user_type' => $author->getMorphClass(),
'parent_id' => $parentComment->id,
'body' => '<p>Reply to original</p>',
]);
$listener = new SendCommentRepliedNotification;
$listener->handle(new CommentCreated($reply));
Notification::assertSentTo($subscriber, CommentRepliedNotification::class);
});
it('does NOT send notification for top-level comments', function () {
Notification::fake();
$author = User::factory()->create();
$subscriber = User::factory()->create();
$post = Post::factory()->create();
CommentSubscription::subscribe($post, $subscriber);
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(),
'user_type' => $author->getMorphClass(),
'body' => '<p>Top-level comment</p>',
]);
$listener = new SendCommentRepliedNotification;
$listener->handle(new CommentCreated($comment));
Notification::assertNothingSent();
});
it('does NOT notify the reply author themselves', function () {
Notification::fake();
$user = User::factory()->create();
$post = Post::factory()->create();
CommentSubscription::subscribe($post, $user);
$parentComment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>My comment</p>',
]);
$reply = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'parent_id' => $parentComment->id,
'body' => '<p>My own reply</p>',
]);
$listener = new SendCommentRepliedNotification;
$listener->handle(new CommentCreated($reply));
Notification::assertNotSentTo($user, CommentRepliedNotification::class);
});
it('auto-subscribes comment author to the thread', function () {
Notification::fake();
$author = User::factory()->create();
$post = Post::factory()->create();
expect(CommentSubscription::isSubscribed($post, $author))->toBeFalse();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(),
'user_type' => $author->getMorphClass(),
'body' => '<p>Comment</p>',
]);
$listener = new SendCommentRepliedNotification;
$listener->handle(new CommentCreated($comment));
expect(CommentSubscription::isSubscribed($post, $author))->toBeTrue();
});
it('only notifies subscribed users for reply notifications', function () {
Notification::fake();
$author = User::factory()->create();
$subscriber = User::factory()->create();
$nonSubscriber = User::factory()->create();
$post = Post::factory()->create();
CommentSubscription::subscribe($post, $subscriber);
$parentComment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $subscriber->getKey(),
'user_type' => $subscriber->getMorphClass(),
'body' => '<p>Original</p>',
]);
$reply = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(),
'user_type' => $author->getMorphClass(),
'parent_id' => $parentComment->id,
'body' => '<p>Reply</p>',
]);
$listener = new SendCommentRepliedNotification;
$listener->handle(new CommentCreated($reply));
Notification::assertSentTo($subscriber, CommentRepliedNotification::class);
Notification::assertNotSentTo($nonSubscriber, CommentRepliedNotification::class);
});
it('sends mention notification to mentioned user', function () {
Notification::fake();
$author = User::factory()->create();
$mentioned = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(),
'user_type' => $author->getMorphClass(),
'body' => '<p>Hey @mentioned</p>',
]);
$event = new UserMentioned($comment, $mentioned);
$listener = new SendUserMentionedNotification;
$listener->handle($event);
Notification::assertSentTo($mentioned, UserMentionedNotification::class);
});
it('does NOT send mention notification to the comment author', function () {
Notification::fake();
$author = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(),
'user_type' => $author->getMorphClass(),
'body' => '<p>Hey @myself</p>',
]);
$event = new UserMentioned($comment, $author);
$listener = new SendUserMentionedNotification;
$listener->handle($event);
Notification::assertNotSentTo($author, UserMentionedNotification::class);
});
it('auto-subscribes mentioned user to the thread', function () {
Notification::fake();
$author = User::factory()->create();
$mentioned = User::factory()->create();
$post = Post::factory()->create();
expect(CommentSubscription::isSubscribed($post, $mentioned))->toBeFalse();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(),
'user_type' => $author->getMorphClass(),
'body' => '<p>Hey @mentioned</p>',
]);
$event = new UserMentioned($comment, $mentioned);
$listener = new SendUserMentionedNotification;
$listener->handle($event);
expect(CommentSubscription::isSubscribed($post, $mentioned))->toBeTrue();
});
it('does not send notifications when notifications are disabled', function () {
Notification::fake();
config()->set('comments.notifications.enabled', false);
$author = User::factory()->create();
$subscriber = User::factory()->create();
$mentioned = User::factory()->create();
$post = Post::factory()->create();
CommentSubscription::subscribe($post, $subscriber);
$parentComment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $subscriber->getKey(),
'user_type' => $subscriber->getMorphClass(),
'body' => '<p>Original</p>',
]);
$reply = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $author->getKey(),
'user_type' => $author->getMorphClass(),
'parent_id' => $parentComment->id,
'body' => '<p>Reply</p>',
]);
$replyListener = new SendCommentRepliedNotification;
$replyListener->handle(new CommentCreated($reply));
$mentionEvent = new UserMentioned($reply, $mentioned);
$mentionListener = new SendUserMentionedNotification;
$mentionListener->handle($mentionEvent);
Notification::assertNothingSent();
});

View File

@@ -0,0 +1,33 @@
<?php
use Relaticle\Comments\Config;
it('returns false for isBroadcastingEnabled by default', function () {
expect(Config::isBroadcastingEnabled())->toBeFalse();
});
it('returns true for isBroadcastingEnabled when config overridden', function () {
config()->set('comments.broadcasting.enabled', true);
expect(Config::isBroadcastingEnabled())->toBeTrue();
});
it('returns comments as default broadcast channel prefix', function () {
expect(Config::getBroadcastChannelPrefix())->toBe('comments');
});
it('returns custom broadcast channel prefix when overridden', function () {
config()->set('comments.broadcasting.channel_prefix', 'my-app-comments');
expect(Config::getBroadcastChannelPrefix())->toBe('my-app-comments');
});
it('returns 10s as default polling interval', function () {
expect(Config::getPollingInterval())->toBe('10s');
});
it('returns custom polling interval when overridden', function () {
config()->set('comments.polling.interval', '30s');
expect(Config::getPollingInterval())->toBe('30s');
});

View File

@@ -0,0 +1,334 @@
<?php
use Illuminate\Support\Facades\Event;
use Livewire\Livewire;
use Relaticle\Comments\Comment;
use Relaticle\Comments\CommentReaction;
use Relaticle\Comments\Config;
use Relaticle\Comments\Events\CommentReacted;
use Relaticle\Comments\Livewire\Reactions;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('adds a reaction when user clicks an emoji', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$this->actingAs($user);
Livewire::test(Reactions::class, ['comment' => $comment])
->call('toggleReaction', 'thumbs_up');
expect(CommentReaction::where([
'comment_id' => $comment->id,
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'reaction' => 'thumbs_up',
])->exists())->toBeTrue();
});
it('removes a reaction when toggling same emoji', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
CommentReaction::create([
'comment_id' => $comment->id,
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'reaction' => 'thumbs_up',
]);
$this->actingAs($user);
Livewire::test(Reactions::class, ['comment' => $comment])
->call('toggleReaction', 'thumbs_up');
expect(CommentReaction::where([
'comment_id' => $comment->id,
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'reaction' => 'thumbs_up',
])->exists())->toBeFalse();
});
it('fires CommentReacted event with added action', function () {
Event::fake([CommentReacted::class]);
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$this->actingAs($user);
Livewire::test(Reactions::class, ['comment' => $comment])
->call('toggleReaction', 'thumbs_up');
Event::assertDispatched(CommentReacted::class, function (CommentReacted $event) use ($comment, $user) {
return $event->comment->id === $comment->id
&& $event->user->getKey() === $user->getKey()
&& $event->reaction === 'thumbs_up'
&& $event->action === 'added';
});
});
it('fires CommentReacted event with removed action', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
CommentReaction::create([
'comment_id' => $comment->id,
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'reaction' => 'heart',
]);
Event::fake([CommentReacted::class]);
$this->actingAs($user);
Livewire::test(Reactions::class, ['comment' => $comment])
->call('toggleReaction', 'heart');
Event::assertDispatched(CommentReacted::class, function (CommentReacted $event) use ($comment, $user) {
return $event->comment->id === $comment->id
&& $event->user->getKey() === $user->getKey()
&& $event->reaction === 'heart'
&& $event->action === 'removed';
});
});
it('returns correct reaction summary with counts', function () {
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$user3 = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user1->getKey(),
'user_type' => $user1->getMorphClass(),
]);
CommentReaction::create([
'comment_id' => $comment->id,
'user_id' => $user1->getKey(),
'user_type' => $user1->getMorphClass(),
'reaction' => 'thumbs_up',
]);
CommentReaction::create([
'comment_id' => $comment->id,
'user_id' => $user2->getKey(),
'user_type' => $user2->getMorphClass(),
'reaction' => 'thumbs_up',
]);
CommentReaction::create([
'comment_id' => $comment->id,
'user_id' => $user3->getKey(),
'user_type' => $user3->getMorphClass(),
'reaction' => 'thumbs_up',
]);
CommentReaction::create([
'comment_id' => $comment->id,
'user_id' => $user1->getKey(),
'user_type' => $user1->getMorphClass(),
'reaction' => 'heart',
]);
$this->actingAs($user1);
$component = Livewire::test(Reactions::class, ['comment' => $comment]);
$summary = $component->instance()->reactionSummary;
expect($summary)->toHaveCount(2);
$thumbsUp = collect($summary)->firstWhere('reaction', 'thumbs_up');
expect($thumbsUp['count'])->toBe(3);
expect($thumbsUp['names'])->toHaveCount(3);
$heart = collect($summary)->firstWhere('reaction', 'heart');
expect($heart['count'])->toBe(1);
});
it('requires authentication to react', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
Livewire::test(Reactions::class, ['comment' => $comment])
->call('toggleReaction', 'thumbs_up');
expect(CommentReaction::count())->toBe(0);
});
it('allows multiple reaction types from same user', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$this->actingAs($user);
$component = Livewire::test(Reactions::class, ['comment' => $comment]);
$component->call('toggleReaction', 'thumbs_up');
$component->call('toggleReaction', 'heart');
expect(CommentReaction::where([
'comment_id' => $comment->id,
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'reaction' => 'thumbs_up',
])->exists())->toBeTrue();
expect(CommentReaction::where([
'comment_id' => $comment->id,
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'reaction' => 'heart',
])->exists())->toBeTrue();
});
it('allows same reaction from multiple users', function () {
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user1->getKey(),
'user_type' => $user1->getMorphClass(),
]);
$this->actingAs($user1);
Livewire::test(Reactions::class, ['comment' => $comment])
->call('toggleReaction', 'thumbs_up');
$this->actingAs($user2);
Livewire::test(Reactions::class, ['comment' => $comment])
->call('toggleReaction', 'thumbs_up');
expect(CommentReaction::where([
'comment_id' => $comment->id,
'reaction' => 'thumbs_up',
])->count())->toBe(2);
});
it('rejects invalid reaction keys', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$this->actingAs($user);
Livewire::test(Reactions::class, ['comment' => $comment])
->call('toggleReaction', 'invalid_emoji');
expect(CommentReaction::count())->toBe(0);
});
it('marks reacted_by_user correctly in summary', function () {
$userA = User::factory()->create();
$userB = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $userA->getKey(),
'user_type' => $userA->getMorphClass(),
]);
CommentReaction::create([
'comment_id' => $comment->id,
'user_id' => $userA->getKey(),
'user_type' => $userA->getMorphClass(),
'reaction' => 'thumbs_up',
]);
$this->actingAs($userA);
$summaryA = Livewire::test(Reactions::class, ['comment' => $comment])
->instance()->reactionSummary;
$thumbsUpA = collect($summaryA)->firstWhere('reaction', 'thumbs_up');
expect($thumbsUpA['reacted_by_user'])->toBeTrue();
$this->actingAs($userB);
$summaryB = Livewire::test(Reactions::class, ['comment' => $comment])
->instance()->reactionSummary;
$thumbsUpB = collect($summaryB)->firstWhere('reaction', 'thumbs_up');
expect($thumbsUpB['reacted_by_user'])->toBeFalse();
});
it('returns configured emoji set from config', function () {
$emojiSet = Config::getReactionEmojiSet();
expect($emojiSet)->toBeArray();
expect($emojiSet)->toHaveKey('thumbs_up');
expect($emojiSet)->toHaveKey('heart');
expect($emojiSet)->toHaveKey('celebrate');
expect($emojiSet)->toHaveKey('laugh');
expect($emojiSet)->toHaveKey('thinking');
expect($emojiSet)->toHaveKey('sad');
});
it('returns allowed reaction keys from config', function () {
$allowed = Config::getAllowedReactions();
expect($allowed)->toBeArray();
expect($allowed)->toContain('thumbs_up');
expect($allowed)->toContain('heart');
expect($allowed)->toContain('celebrate');
expect($allowed)->toContain('laugh');
expect($allowed)->toContain('thinking');
expect($allowed)->toContain('sad');
});

View File

@@ -0,0 +1,141 @@
<?php
use Livewire\Livewire;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Config;
use Relaticle\Comments\Livewire\CommentItem;
use Relaticle\Comments\Livewire\Comments;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('creates a comment with rich HTML content preserved', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
$html = '<p>Hello <strong>bold</strong> and <em>italic</em> world</p>';
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', $html)
->call('addComment');
$comment = Comment::first();
expect($comment->body)->toContain('<strong>bold</strong>');
expect($comment->body)->toContain('<em>italic</em>');
});
it('pre-fills editBody with existing comment HTML when starting edit', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$originalHtml = '<p>Hello <strong>world</strong></p>';
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => $originalHtml,
]);
$this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit')
->assertSet('editBody', $originalHtml);
});
it('saves edited HTML content through edit form', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Original</p>',
]);
$this->actingAs($user);
$updatedHtml = '<p>Updated with <strong>bold</strong> and <a href="https://example.com">a link</a></p>';
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit')
->set('editBody', $updatedHtml)
->call('saveEdit');
$comment->refresh();
expect($comment->body)->toContain('<strong>bold</strong>');
expect($comment->body)->toContain('<a href="https://example.com">a link</a>');
});
it('creates reply with rich HTML content', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
]);
$this->actingAs($user);
$replyHtml = '<p>Reply with <em>emphasis</em> and <code>inline code</code></p>';
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startReply')
->set('replyBody', $replyHtml)
->call('addReply');
$reply = Comment::where('parent_id', $comment->id)->first();
expect($reply)->not->toBeNull();
expect($reply->body)->toContain('<em>emphasis</em>');
expect($reply->body)->toContain('<code>inline code</code>');
});
it('renders comment body with fi-prose class', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>Styled comment</p>',
]);
$this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $comment])
->assertSeeHtml('fi-prose');
});
it('returns editor toolbar configuration as nested array', function () {
$toolbar = Config::getEditorToolbar();
expect($toolbar)->toBeArray();
expect($toolbar)->not->toBeEmpty();
expect($toolbar[0])->toBeArray();
expect($toolbar[0])->toContain('bold');
expect($toolbar[0])->toContain('italic');
});
it('uses custom toolbar config when overridden', function () {
config(['comments.editor.toolbar' => [
['bold', 'italic'],
]]);
$toolbar = Config::getEditorToolbar();
expect($toolbar)->toHaveCount(1);
expect($toolbar[0])->toBe(['bold', 'italic']);
});

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Eloquent\Relations\Relation;
use Relaticle\Comments\Comment;
use Relaticle\Comments\Config;
it('registers the config file', function () {
expect(config('comments'))->toBeArray();
expect(config('comments.threading.max_depth'))->toBe(2);
expect(config('comments.pagination.per_page'))->toBe(10);
});
it('resolves the comment model from config', function () {
expect(Config::getCommentModel())->toBe(Comment::class);
});
it('resolves the comment table from config', function () {
expect(Config::getCommentTable())->toBe('comments');
});
it('resolves max depth from config', function () {
expect(Config::getMaxDepth())->toBe(2);
});
it('registers the morph map for comment', function () {
$map = Relation::morphMap();
expect($map)->toHaveKey('comment');
expect($map['comment'])->toBe(Comment::class);
});
it('creates the comments table via migration', function () {
expect(Schema::hasTable('comments'))->toBeTrue();
expect(Schema::hasColumns('comments', [
'id', 'commentable_type', 'commentable_id',
'user_type', 'user_id', 'parent_id', 'body',
'edited_at', 'deleted_at', 'created_at', 'updated_at',
]))->toBeTrue();
});

View File

@@ -0,0 +1,83 @@
<?php
use Livewire\Livewire;
use Relaticle\Comments\CommentSubscription;
use Relaticle\Comments\Livewire\Comments;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('subscribes user when toggling from unsubscribed state', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
expect(CommentSubscription::isSubscribed($post, $user))->toBeFalse();
Livewire::test(Comments::class, ['model' => $post])
->call('toggleSubscription');
expect(CommentSubscription::isSubscribed($post, $user))->toBeTrue();
});
it('unsubscribes user when toggling from subscribed state', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
CommentSubscription::subscribe($post, $user);
$this->actingAs($user);
expect(CommentSubscription::isSubscribed($post, $user))->toBeTrue();
Livewire::test(Comments::class, ['model' => $post])
->call('toggleSubscription');
expect(CommentSubscription::isSubscribed($post, $user))->toBeFalse();
});
it('returns true for isSubscribed computed when user is subscribed', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
CommentSubscription::subscribe($post, $user);
$this->actingAs($user);
$component = Livewire::test(Comments::class, ['model' => $post]);
expect($component->instance()->isSubscribed())->toBeTrue();
});
it('returns false for isSubscribed computed when user is not subscribed', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
$component = Livewire::test(Comments::class, ['model' => $post]);
expect($component->instance()->isSubscribed())->toBeFalse();
});
it('renders Subscribed text for subscribed user', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
CommentSubscription::subscribe($post, $user);
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->assertSee('Subscribed');
});
it('renders Subscribe text for unsubscribed user', function () {
$user = User::factory()->create();
$post = Post::factory()->create();
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->assertSee('Subscribe');
});

View File

@@ -0,0 +1,25 @@
<?php
use Relaticle\Comments\Comment;
use Relaticle\Comments\Events\UserMentioned;
use Relaticle\Comments\Tests\Models\Post;
use Relaticle\Comments\Tests\Models\User;
it('carries correct comment and mentioned user in payload', function () {
$user = User::factory()->create();
$mentionedUser = User::factory()->create(['name' => 'john']);
$post = Post::factory()->create();
$comment = Comment::factory()->create([
'commentable_id' => $post->id,
'commentable_type' => $post->getMorphClass(),
'user_id' => $user->getKey(),
'user_type' => $user->getMorphClass(),
'body' => '<p>@john</p>',
]);
$event = new UserMentioned($comment, $mentionedUser);
expect($event->comment)->toBe($comment)
->and($event->mentionedUser)->toBe($mentionedUser);
});

0
tests/Models/.gitkeep Normal file
View File

24
tests/Models/Post.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
namespace Relaticle\Comments\Tests\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Relaticle\Comments\Concerns\HasComments;
use Relaticle\Comments\Contracts\Commentable;
use Relaticle\Comments\Tests\Database\Factories\PostFactory;
class Post extends Model implements Commentable
{
use HasComments;
use HasFactory;
protected $table = 'posts';
protected $fillable = ['title'];
protected static function newFactory(): PostFactory
{
return PostFactory::new();
}
}

26
tests/Models/User.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace Relaticle\Comments\Tests\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Relaticle\Comments\Concerns\IsCommenter;
use Relaticle\Comments\Contracts\Commenter;
use Relaticle\Comments\Tests\Database\Factories\UserFactory;
class User extends Authenticatable implements Commenter
{
use HasFactory;
use IsCommenter;
use Notifiable;
protected $table = 'users';
protected $fillable = ['name', 'email', 'password'];
protected static function newFactory(): UserFactory
{
return UserFactory::new();
}
}

6
tests/Pest.php Normal file
View File

@@ -0,0 +1,6 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Relaticle\Comments\Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class)->in('Feature');

133
tests/TestCase.php Normal file
View File

@@ -0,0 +1,133 @@
<?php
namespace Relaticle\Comments\Tests;
use Filament\FilamentServiceProvider;
use Filament\Support\SupportServiceProvider;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Livewire\LivewireServiceProvider;
use Livewire\Mechanisms\DataStore;
use Orchestra\Testbench\TestCase as Orchestra;
use Relaticle\Comments\CommentsServiceProvider;
use Relaticle\Comments\Tests\Models\User;
abstract class TestCase extends Orchestra
{
protected function setUp(): void
{
parent::setUp();
$this->app->singleton(DataStore::class);
}
/** @return array<int, class-string> */
protected function getPackageProviders($app): array
{
return [
LivewireServiceProvider::class,
SupportServiceProvider::class,
FilamentServiceProvider::class,
CommentsServiceProvider::class,
];
}
protected function defineDatabaseMigrations(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email');
$table->string('password');
$table->timestamps();
});
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->timestamps();
});
Schema::create(config('comments.tables.comments', 'comments'), function (Blueprint $table) {
$table->id();
$table->morphs('commentable');
$table->morphs('user');
$table->foreignId('parent_id')
->nullable()
->constrained(config('comments.tables.comments', 'comments'))
->cascadeOnDelete();
$table->text('body');
$table->timestamp('edited_at')->nullable();
$table->softDeletes();
$table->timestamps();
$table->index(['commentable_type', 'commentable_id', 'parent_id']);
});
Schema::create('comment_mentions', function (Blueprint $table) {
$table->id();
$table->foreignId('comment_id')
->constrained(config('comments.tables.comments', 'comments'))
->cascadeOnDelete();
$table->morphs('user');
$table->timestamps();
$table->unique(['comment_id', 'user_id', 'user_type']);
});
Schema::create('comment_reactions', function (Blueprint $table) {
$table->id();
$table->foreignId('comment_id')
->constrained(config('comments.tables.comments', 'comments'))
->cascadeOnDelete();
$table->morphs('user');
$table->string('reaction');
$table->timestamps();
$table->unique(['comment_id', 'user_id', 'user_type', 'reaction']);
});
Schema::create('comment_attachments', function (Blueprint $table) {
$table->id();
$table->foreignId('comment_id')
->constrained(config('comments.tables.comments', 'comments'))
->cascadeOnDelete();
$table->string('file_path');
$table->string('original_name');
$table->string('mime_type');
$table->unsignedBigInteger('size');
$table->string('disk');
$table->timestamps();
});
Schema::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->morphs('notifiable');
$table->text('data');
$table->timestamp('read_at')->nullable();
$table->timestamps();
});
Schema::create('comment_subscriptions', function (Blueprint $table) {
$table->id();
$table->morphs('commentable');
$table->morphs('user');
$table->timestamp('created_at')->nullable();
$table->unique(['commentable_type', 'commentable_id', 'user_type', 'user_id'], 'comment_subscriptions_unique');
});
}
protected function getEnvironmentSetUp($app): void
{
$app['config']->set('app.key', 'base64:'.base64_encode(random_bytes(32)));
$app['config']->set('database.default', 'testing');
$app['config']->set('database.connections.testing', [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
]);
$app['config']->set('comments.commenter.model', User::class);
}
}