- 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>
123 lines
4.1 KiB
TypeScript
123 lines
4.1 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { Comment } from './comment.entity.js';
|
|
import { CommentForbiddenException } from '../exceptions/comment-forbidden.exception.js';
|
|
|
|
const buildComment = (overrides: Partial<{ authorId: string | null; deletedAt: Date | null }> = {}) =>
|
|
Comment.reconstitute({
|
|
id: 'comment-1',
|
|
content: 'Original',
|
|
issueId: 1,
|
|
authorId: overrides.authorId === undefined ? 'user-1' : overrides.authorId,
|
|
editedAt: null,
|
|
deletedAt: overrides.deletedAt ?? null,
|
|
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
});
|
|
|
|
describe('Comment', () => {
|
|
describe('create', () => {
|
|
it('creates a new comment without id and edit/delete marks', () => {
|
|
const comment = Comment.create({ issueId: 1, authorId: 'user-1', content: ' Hello ' });
|
|
|
|
expect(comment.id).toBeNull();
|
|
expect(comment.content).toBe('Hello');
|
|
expect(comment.editedAt).toBeNull();
|
|
expect(comment.isDeleted).toBe(false);
|
|
});
|
|
|
|
it('rejects empty content', () => {
|
|
expect(() => Comment.create({ issueId: 1, authorId: 'user-1', content: '' })).toThrow();
|
|
});
|
|
});
|
|
|
|
describe('edit', () => {
|
|
it('changes content and sets editedAt', () => {
|
|
const comment = buildComment();
|
|
|
|
expect(comment.edit('Changed', 'user-1')).toBe(true);
|
|
expect(comment.content).toBe('Changed');
|
|
expect(comment.editedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it('is a no-op when content is the same', () => {
|
|
const comment = buildComment();
|
|
|
|
expect(comment.edit(' Original ', 'user-1')).toBe(false);
|
|
expect(comment.editedAt).toBeNull();
|
|
});
|
|
|
|
it('forbids editing by someone other than the author', () => {
|
|
expect(() => buildComment().edit('Changed', 'user-2')).toThrow(CommentForbiddenException);
|
|
});
|
|
|
|
it('forbids editing when the author account is deleted', () => {
|
|
expect(() => buildComment({ authorId: null }).edit('Changed', 'user-1')).toThrow(CommentForbiddenException);
|
|
});
|
|
|
|
it('forbids editing a deleted comment', () => {
|
|
expect(() => buildComment({ deletedAt: new Date() }).edit('Changed', 'user-1')).toThrow();
|
|
});
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('soft-deletes the comment', () => {
|
|
const comment = buildComment();
|
|
|
|
comment.delete('user-1');
|
|
|
|
expect(comment.isDeleted).toBe(true);
|
|
expect(comment.deletedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it('forbids deletion by someone other than the author', () => {
|
|
expect(() => buildComment().delete('user-2')).toThrow(CommentForbiddenException);
|
|
});
|
|
});
|
|
|
|
describe('attachments', () => {
|
|
it('creates a comment with deduplicated files', () => {
|
|
const comment = Comment.create({ issueId: 1, authorId: 'user-1', content: 'Hi', fileIds: ['a', 'b', 'a'] });
|
|
|
|
expect(comment.fileIds).toEqual(['a', 'b']);
|
|
});
|
|
|
|
it('limits the number of files', () => {
|
|
const fileIds = Array.from({ length: Comment.MAX_ATTACHMENTS + 1 }, (_, i) => `f${i}`);
|
|
|
|
expect(() => Comment.create({ issueId: 1, authorId: 'user-1', content: 'Hi', fileIds })).toThrow();
|
|
});
|
|
|
|
it('treats a changed file set as an edit, even with the same text', () => {
|
|
const comment = buildComment();
|
|
|
|
expect(comment.edit('Original', 'user-1', ['a'])).toBe(true);
|
|
expect(comment.fileIds).toEqual(['a']);
|
|
expect(comment.editedAt).toBeInstanceOf(Date);
|
|
});
|
|
|
|
it('keeps files when fileIds is not passed', () => {
|
|
const comment = buildComment();
|
|
comment.edit('Original', 'user-1', ['a']);
|
|
|
|
expect(comment.edit('Changed', 'user-1')).toBe(true);
|
|
expect(comment.fileIds).toEqual(['a']);
|
|
});
|
|
|
|
it('reports no change for the same text and files', () => {
|
|
const comment = buildComment();
|
|
comment.edit('Original', 'user-1', ['a', 'b']);
|
|
|
|
expect(comment.edit('Original', 'user-1', ['a', 'b'])).toBe(false);
|
|
});
|
|
|
|
it('drops files on delete so GC can collect them', () => {
|
|
const comment = buildComment();
|
|
comment.edit('Original', 'user-1', ['a']);
|
|
|
|
comment.delete('user-1');
|
|
|
|
expect(comment.fileIds).toEqual([]);
|
|
});
|
|
});
|
|
});
|