Files
ulitka/src/modules/comment/application/use-cases/update-comment/update-comment.use-case.spec.ts
T
vadimandClaude Opus 5.5 60fb39f98f 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>
2026-10-02 09:18:08 +03:00

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();
});
});