Add attachments to comments

- 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>
This commit is contained in:
vadimandClaude Opus 5.5 committed 2026-10-02 09:18:08 +03:00
1 parent 423afa658d
commit 60fb39f98f
28 files changed
+522 -69

No files matched your search

@@ -10,10 +10,13 @@ import { UpdateCommentCommand } from './update-comment.command.js';
import {
buildComment,
buildIssue,
mockAuthorExpander,
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;
@@ -21,16 +24,19 @@ describe('UpdateCommentUseCase', () => {
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),
mockAuthorExpander(),
mockCommentExpander(),
commentEventsService,
fileAccess,
);
received = [];
commentEventsService.onEvents().subscribe((event) => received.push(event));
@@ -82,4 +88,25 @@ describe('UpdateCommentUseCase', () => {
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();
});
});