comments implement

This commit is contained in:
vadim committed 2026-10-01 12:02:03 +03:00
1 parent 9e0bef2527
commit 4b6ecfbf5d
41 files changed
+1350 -13

No files matched your search

@@ -0,0 +1,76 @@
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);
});
});
});
@@ -0,0 +1,129 @@
import { CommentContent } from '../value-objects/comment-content.vo.js';
import { CommentForbiddenException } from '../exceptions/comment-forbidden.exception.js';
interface CommentProps {
id: string | null;
content: CommentContent;
issueId: number;
/** null — автор удалил аккаунт */
authorId: string | null;
editedAt: Date | null;
deletedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}
/**
* Комментарий к задаче. Удаление мягкое: запись остаётся (deletedAt),
* чтобы в ленте можно было показать «комментарий удалён».
*
* `id` (uuid v7) генерируется базой, поэтому у нового комментария
* он равен null до сохранения в репозитории.
*/
export class Comment {
private constructor(private readonly props: CommentProps) {}
/** Фабричный метод для создания НОВОГО комментария */
static create(params: { issueId: number; authorId: string; content: string }): Comment {
const now = new Date();
return new Comment({
id: null,
content: CommentContent.create(params.content),
issueId: params.issueId,
authorId: params.authorId,
editedAt: null,
deletedAt: null,
createdAt: now,
updatedAt: now,
});
}
/** Восстановление сущности из персистентного слоя */
static reconstitute(props: {
id: string;
content: string;
issueId: number;
authorId: string | null;
editedAt: Date | null;
deletedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}): Comment {
return new Comment({
...props,
content: CommentContent.create(props.content),
});
}
get id(): string | null {
return this.props.id;
}
get content(): string {
return this.props.content.value;
}
get issueId(): number {
return this.props.issueId;
}
get authorId(): string | null {
return this.props.authorId;
}
get editedAt(): Date | null {
return this.props.editedAt;
}
get deletedAt(): Date | null {
return this.props.deletedAt;
}
get isDeleted(): boolean {
return this.props.deletedAt !== null;
}
get createdAt(): Date {
return this.props.createdAt;
}
get updatedAt(): Date {
return this.props.updatedAt;
}
/** Меняет текст. Возвращает false, если текст не изменился */
edit(content: string, userId: string): boolean {
this.assertCanModify(userId);
const next = CommentContent.create(content);
if (next.equals(this.props.content)) {
return false;
}
const now = new Date();
this.props.content = next;
this.props.editedAt = now;
this.props.updatedAt = now;
return true;
}
delete(userId: string): void {
this.assertCanModify(userId);
const now = new Date();
this.props.deletedAt = now;
this.props.updatedAt = now;
}
/** Править и удалять комментарий может только его автор, и только пока он не удалён */
private assertCanModify(userId: string): void {
if (this.isDeleted) {
throw new Error('Комментарий уже удалён');
}
if (this.props.authorId !== userId) {
throw new CommentForbiddenException(this.props.id ?? 'new');
}
}
}
@@ -0,0 +1,6 @@
export class CommentForbiddenException extends Error {
constructor(id: string) {
super(`Нет прав на изменение комментария id=${id}`);
this.name = 'CommentForbiddenException';
}
}
@@ -0,0 +1,6 @@
export class CommentNotFoundException extends Error {
constructor(id: string) {
super(`Комментарий с id=${id} не найден`);
this.name = 'CommentNotFoundException';
}
}
@@ -0,0 +1,16 @@
import { Comment } from '../entities/comment.entity.js';
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
export const COMMENT_REPOSITORY = Symbol('COMMENT_REPOSITORY');
export interface ICommentRepository {
/** Возвращает и мягко удалённые комментарии — решение принимает вызывающий */
findById(id: string): Promise<Comment | null>;
/**
* Комментарии задачи от старых к новым, включая удалённые.
* Курсорная пагинация: cursor — id последнего полученного комментария.
*/
findByIssue(params: { issueId: number; limit: number; cursor?: string }): Promise<{ list: Comment[]; nextCursor: string | null }>;
create(comment: Comment): Promise<Comment>;
update(comment: Comment): Promise<void>;
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { CommentContent } from './comment-content.vo.js';
describe('CommentContent', () => {
it('trims the value', () => {
expect(CommentContent.create(' hello ').value).toBe('hello');
});
it('rejects empty content', () => {
expect(() => CommentContent.create(' ')).toThrow('Комментарий не может быть пустым');
});
it('rejects content longer than the limit', () => {
expect(() => CommentContent.create('a'.repeat(CommentContent.MAX_LENGTH + 1))).toThrow(
`Комментарий не может превышать ${CommentContent.MAX_LENGTH} символов`,
);
});
});
@@ -0,0 +1,27 @@
export class CommentContent {
static readonly MAX_LENGTH = 10000;
private constructor(private readonly _value: string) {}
static create(value: string): CommentContent {
const trimmed = value?.trim();
if (!trimmed) {
throw new Error('Комментарий не может быть пустым');
}
if (trimmed.length > CommentContent.MAX_LENGTH) {
throw new Error(`Комментарий не может превышать ${CommentContent.MAX_LENGTH} символов`);
}
return new CommentContent(trimmed);
}
get value(): string {
return this._value;
}
equals(other: CommentContent): boolean {
return this._value === other.value;
}
}