- CommentAttachment link table (comment <-> file) + migration - fileIds in create/update comment; update replaces the whole set, synced in one transaction with the comment - CommentDto.attachments; empty for deleted comments - Soft delete drops attachments so GC can collect the files - Max 10 files per comment, ordered by upload time (uuid v7) - Registers comments as a file owner - Rename CommentAuthorExpanderService -> CommentExpanderService (expands authors and files) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
113 lines
4.8 KiB
TypeScript
113 lines
4.8 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { ICommentRepository } from '../../../domain/repositories/comment.repository.interface.js';
|
|
import type { IIssueRepository } from '../../../../issue/domain/repositories/issue.repository.interface.js';
|
|
import { CommentNotFoundException } from '../../../domain/exceptions/comment-not-found.exception.js';
|
|
import { CommentForbiddenException } from '../../../domain/exceptions/comment-forbidden.exception.js';
|
|
import { CommentEventsService } from '../../services/comment-events.service.js';
|
|
import { CommentAccessService } from '../../services/comment-access.service.js';
|
|
import { UpdateCommentUseCase } from './update-comment.use-case.js';
|
|
import { UpdateCommentCommand } from './update-comment.command.js';
|
|
import {
|
|
buildComment,
|
|
buildIssue,
|
|
mockCommentExpander,
|
|
mockCommentRepository,
|
|
mockFileAccess,
|
|
mockIssueRepository,
|
|
} from '../test-helpers.js';
|
|
import { FileNotFoundException } from '../../../../file/domain/exceptions/file-not-found.exception.js';
|
|
import type { FileAccessService } from '../../../../file/application/services/file-access.service.js';
|
|
|
|
describe('UpdateCommentUseCase', () => {
|
|
let commentRepository: ICommentRepository;
|
|
let issueRepository: IIssueRepository;
|
|
let commentEventsService: CommentEventsService;
|
|
let useCase: UpdateCommentUseCase;
|
|
let received: unknown[];
|
|
let fileAccess: FileAccessService;
|
|
|
|
beforeEach(() => {
|
|
commentRepository = mockCommentRepository();
|
|
issueRepository = mockIssueRepository();
|
|
commentEventsService = new CommentEventsService();
|
|
fileAccess = mockFileAccess({ gone: new FileNotFoundException('gone') });
|
|
useCase = new UpdateCommentUseCase(
|
|
commentRepository,
|
|
new CommentAccessService(commentRepository, issueRepository),
|
|
mockCommentExpander(),
|
|
commentEventsService,
|
|
fileAccess,
|
|
);
|
|
received = [];
|
|
commentEventsService.onEvents().subscribe((event) => received.push(event));
|
|
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
|
|
});
|
|
|
|
it('updates the content and emits comment.updated', async () => {
|
|
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
|
|
|
|
const dto = await useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Changed'));
|
|
|
|
expect(dto.content).toBe('Changed');
|
|
expect(dto.editedAt).toBeInstanceOf(Date);
|
|
expect(commentRepository.update).toHaveBeenCalledTimes(1);
|
|
expect(received).toEqual([{ type: 'comment.updated', comment: dto }]);
|
|
});
|
|
|
|
it('skips saving and events when content did not change', async () => {
|
|
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
|
|
|
|
await useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Original'));
|
|
|
|
expect(commentRepository.update).not.toHaveBeenCalled();
|
|
expect(received).toEqual([]);
|
|
});
|
|
|
|
it('throws CommentForbiddenException for a non-author', async () => {
|
|
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
|
|
|
|
await expect(useCase.execute(new UpdateCommentCommand('comment-1', 'user-2', 'Changed'))).rejects.toThrow(
|
|
CommentForbiddenException,
|
|
);
|
|
expect(commentRepository.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws CommentNotFoundException for a deleted comment', async () => {
|
|
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment({ deletedAt: new Date() }));
|
|
|
|
await expect(useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Changed'))).rejects.toThrow(
|
|
CommentNotFoundException,
|
|
);
|
|
});
|
|
|
|
it('throws CommentNotFoundException when the issue is not visible to the user', async () => {
|
|
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
|
|
vi.mocked(issueRepository.findById).mockResolvedValue(null);
|
|
|
|
await expect(useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Changed'))).rejects.toThrow(
|
|
CommentNotFoundException,
|
|
);
|
|
});
|
|
|
|
it('replaces attachments, checking only newly added files', async () => {
|
|
const comment = buildComment();
|
|
comment.edit('Original', 'user-1', ['kept', 'removed']);
|
|
vi.mocked(commentRepository.findById).mockResolvedValue(comment);
|
|
|
|
await useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Original', ['kept', 'new']));
|
|
|
|
expect(fileAccess.getAttachableFile).toHaveBeenCalledExactlyOnceWith('new', 'user-1');
|
|
expect(vi.mocked(commentRepository.update).mock.calls[0]![0].fileIds).toEqual(['kept', 'new']);
|
|
expect(received).toHaveLength(1);
|
|
});
|
|
|
|
it('does not save when an added file is gone', async () => {
|
|
vi.mocked(commentRepository.findById).mockResolvedValue(buildComment());
|
|
|
|
await expect(
|
|
useCase.execute(new UpdateCommentCommand('comment-1', 'user-1', 'Original', ['gone'])),
|
|
).rejects.toThrow(FileNotFoundException);
|
|
expect(commentRepository.update).not.toHaveBeenCalled();
|
|
});
|
|
});
|