import { BusinessRuleError, ForbiddenError, ValidationError, } from '@task-stream/api/shared/domain'; import { IssueCreatedEvent } from '../events/issue-created.event'; import { IssueParentChangedEvent } from '../events/issue-parent-changed.event'; import { IssueStatusChangedEvent } from '../events/issue-status-changed.event'; import { IssueFactory } from '../factories/issue.factory'; import { replay } from '../testing'; import { Issue } from './issue'; import { ParentCandidate } from './issue-hierarchy'; import { mentionedUserIds, withoutMentionMarkup } from './mentions'; import { IssueLinkType, IssueStatus, IssueType } from './issue.enums'; const factory = new IssueFactory(); const newIssue = () => factory.create({ projectId: 'p-1', projectKey: 'TS', number: 7, title: ' Login button misaligned ', type: IssueType.UI_CHANGE, reporterId: 'u-qa', figmaUrl: 'https://www.figma.com/file/abc', labels: ['UI', 'ui ', 'login'], }); const moveTo = (issue: Issue, ...statuses: IssueStatus[]) => statuses.forEach((s) => issue.changeStatus('u-dev', s, 'because')); describe('Issue', () => { it('is created in BACKLOG with a project-scoped key and a creation event', () => { const issue = newIssue(); const events = issue.getUncommittedEvents(); expect(issue.key).toBe('TS-7'); expect(issue.status).toBe(IssueStatus.BACKLOG); expect(issue.title).toBe('Login button misaligned'); expect(issue.labels).toEqual(['ui', 'login']); expect(events).toHaveLength(1); expect(events[0]).toBeInstanceOf(IssueCreatedEvent); }); it('rejects non-Figma design links', () => { expect(() => factory.create({ projectId: 'p-1', projectKey: 'TS', number: 1, title: 'x', type: IssueType.UI_CHANGE, reporterId: 'u', figmaUrl: 'https://evil.com/x', }), ).toThrow(ValidationError); }); it('follows the QA workflow', () => { const issue = newIssue(); moveTo( issue, IssueStatus.TODO, IssueStatus.IN_PROGRESS, IssueStatus.READY_FOR_QA, IssueStatus.IN_QA, ); issue.changeStatus( 'u-qa', IssueStatus.QA_FAILED, 'Still 2px off on Safari', ); moveTo( issue, IssueStatus.IN_PROGRESS, IssueStatus.READY_FOR_QA, IssueStatus.IN_QA, IssueStatus.DONE, ); expect(issue.status).toBe(IssueStatus.DONE); }); it('forbids skipping QA and requires a reason when QA fails', () => { const issue = newIssue(); moveTo(issue, IssueStatus.TODO); expect(() => issue.changeStatus('u', IssueStatus.DONE)).toThrow( BusinessRuleError, ); moveTo( issue, IssueStatus.IN_PROGRESS, IssueStatus.READY_FOR_QA, IssueStatus.IN_QA, ); expect(() => issue.changeStatus('u-qa', IssueStatus.QA_FAILED, ' '), ).toThrow(/reason/); }); it('links tickets once and never to itself', () => { const issue = newIssue(); issue.linkTo('u', IssueLinkType.DEPENDS_ON, { id: 'i-be', key: 'BE-3' }); expect(() => issue.linkTo('u', IssueLinkType.DEPENDS_ON, { id: 'i-be', key: 'BE-3' }), ).toThrow(BusinessRuleError); expect(() => issue.linkTo('u', IssueLinkType.RELATES_TO, { id: issue.id, key: 'TS-7', }), ).toThrow(BusinessRuleError); issue.unlink('u', IssueLinkType.DEPENDS_ON, 'i-be'); expect(issue.links).toHaveLength(0); }); it('records events only for real changes', () => { const issue = newIssue(); issue.uncommit(); issue.updateDetails('u', { title: 'Login button misaligned', labels: ['login', 'ui'], }); issue.updateDetails('u', { priority: issue.priority }); expect(issue.getUncommittedEvents()).toHaveLength(1); }); it('is rebuilt from its event history (event sourcing)', () => { const issue = newIssue(); moveTo(issue, IssueStatus.TODO, IssueStatus.IN_PROGRESS); issue.assign('u-qa', 'u-dev'); issue.linkTo('u', IssueLinkType.TESTS, { id: 'i-2', key: 'TS-2' }); const { rebuilt, history } = replay(issue, Issue); expect(history.map((e) => e.type)).toEqual([ 'IssueCreatedEvent', 'IssueStatusChangedEvent', 'IssueStatusChangedEvent', 'IssueAssignedEvent', 'IssueLinkedEvent', ]); expect(history[1].data).toBeInstanceOf(IssueStatusChangedEvent); expect(rebuilt.version.value).toBe(5); expect(rebuilt).toMatchObject({ key: 'TS-7', status: IssueStatus.IN_PROGRESS, assigneeId: 'u-dev', labels: ['ui', 'login'], links: [ { type: IssueLinkType.TESTS, targetIssueId: 'i-2', targetKey: 'TS-2' }, ], }); expect(rebuilt.getUncommittedEvents()).toHaveLength(0); }); describe('attachments', () => { const file = (id: string, uploadedBy = 'u-qa') => ({ id, fileName: 'C:\\Users\\qa\\captura login.png', mimeType: 'image/png', size: 2048, uploadedBy, uploadedAt: '2026-10-01T10:00:00.000Z', commentId: null, }); it('records a file with its name cleaned up, once', () => { const issue = newIssue(); issue.attach('u-qa', file('a-1')); issue.attach('u-qa', file('a-1')); expect(issue.attachments).toHaveLength(1); expect(issue.attachments[0].fileName).toBe('captura login.png'); }); it('lets the uploader or a manager remove it, nobody else', () => { const issue = newIssue(); issue.attach('u-qa', file('a-1')); expect(() => issue.detach('u-dev', 'a-1', false)).toThrow(ForbiddenError); issue.detach('u-dev', 'a-1', true); expect(issue.attachments).toEqual([]); expect(() => issue.detach('u-qa', 'a-1', true)).toThrow( BusinessRuleError, ); }); it('rejects empty files', () => { expect(() => newIssue().attach('u-qa', { ...file('a-1'), size: 0 }), ).toThrow(ValidationError); }); it('survives a replay', () => { const issue = newIssue(); issue.attach('u-qa', file('a-1')); issue.attach('u-qa', file('a-2')); issue.detach('u-qa', 'a-1', false); const { rebuilt } = replay(issue, Issue); expect(rebuilt.attachments.map((a) => a.id)).toEqual(['a-2']); }); }); describe('followers', () => { it('follows and unfollows once, and survives a replay', () => { const issue = newIssue(); issue.watch('u-dev', 'u-dev'); issue.watch('u-dev', 'u-dev'); issue.watch('u-pm', 'u-pm'); issue.unwatch('u-dev', 'u-dev'); issue.unwatch('u-dev', 'u-dev'); expect(issue.watchers).toEqual(['u-pm']); expect(replay(issue, Issue).rebuilt.watchers).toEqual(['u-pm']); expect(issue.getUncommittedEvents()).toHaveLength(4); }); }); describe('hierarchy', () => { const parent = (over: Partial = {}): ParentCandidate => ({ id: 'epic-1', key: 'TS-1', projectId: 'p-1', ancestorIds: [], ...over, }); it('hangs from a parent, moves and leaves, and survives a replay', () => { const issue = newIssue(); issue.setParent('u-pm', parent()); issue.setParent('u-pm', parent()); issue.setParent('u-pm', parent({ id: 'epic-2', key: 'TS-2' })); issue.setParent('u-pm', null); issue.setParent('u-pm', parent({ id: 'epic-3', key: 'TS-3' })); const changes = issue .getUncommittedEvents() .filter((e) => e instanceof IssueParentChangedEvent); expect(changes.map((e) => [e.parentId, e.previousParentId])).toEqual([ ['epic-1', null], ['epic-2', 'epic-1'], [null, 'epic-2'], ['epic-3', null], ]); expect(replay(issue, Issue).rebuilt.parentId).toBe('epic-3'); }); it('refuses itself, another project, loops and a fourth level', () => { const issue = newIssue(); expect(() => issue.setParent('u', parent({ id: issue.id }))).toThrow( BusinessRuleError, ); expect(() => issue.setParent('u', parent({ projectId: 'p-2' }))).toThrow( /another project/, ); expect(() => issue.setParent('u', parent({ ancestorIds: [issue.id] })), ).toThrow(/loop/); // Under a ticket that hangs from an epic: it becomes a subtask. expect(() => issue.setParent('u', parent({ ancestorIds: ['t-1'] })), ).not.toThrow(); expect(() => issue.setParent('u', parent({ id: 'x', ancestorIds: ['t-1', 'e-1'] })), ).toThrow(/at most 2 levels/); // A ticket with subtasks cannot go below a ticket that has a parent. expect(() => issue.setParent('u', parent({ id: 'y', ancestorIds: ['e-1'] }), 1), ).toThrow(/at most 2 levels/); }); }); describe('dates', () => { it('plans a start and a due date, never the wrong way round', () => { const issue = newIssue(); issue.updateDetails('u', { startDate: '2026-11-02', dueDate: '2026-11-20', }); expect([issue.startDate, issue.dueDate]).toEqual([ '2026-11-02', '2026-11-20', ]); expect(() => issue.updateDetails('u', { startDate: '2026-12-01' }), ).toThrow(ValidationError); issue.updateDetails('u', { dueDate: null }); issue.updateDetails('u', { startDate: '2026-12-01' }); expect(replay(issue, Issue).rebuilt.startDate).toBe('2026-12-01'); }); }); describe('backlog order', () => { it('moves between two tickets, to the top, and survives a replay', () => { const issue = newIssue(); const other = (number: number) => factory.create({ projectId: 'p-1', projectKey: 'TS', number, title: `#${number}`, type: IssueType.TASK, reporterId: 'u', }); const [one, two] = [other(1), other(2)]; issue.moveBetween('u-pm', one, two); expect(issue.backlogRank > one.backlogRank).toBe(true); expect(issue.backlogRank < two.backlogRank).toBe(true); issue.moveBetween('u-pm', null, one); expect(issue.backlogRank < one.backlogRank).toBe(true); expect(replay(issue, Issue).rebuilt.rank).toBe(issue.rank); expect(() => issue.moveBetween('u-pm', issue, null)).toThrow( BusinessRuleError, ); }); }); describe('checklist', () => { it('adds, ticks, rewords and removes steps, and survives a replay', () => { const issue = newIssue(); issue.addChecklistItem('u-dev', 'c-1', ' Write the migration '); issue.addChecklistItem('u-dev', 'c-2', 'Update the docs'); issue.addChecklistItem('u-dev', 'c-1', 'Duplicate id is ignored'); issue.updateChecklistItem('u-dev', 'c-1', { done: true }); issue.updateChecklistItem('u-dev', 'c-1', { done: true, text: 'Write the migration', }); issue.updateChecklistItem('u-dev', 'c-2', { text: 'Update the README' }); issue.removeChecklistItem('u-dev', 'c-2'); expect(issue.checklist).toEqual([ { id: 'c-1', text: 'Write the migration', done: true }, ]); expect(replay(issue, Issue).rebuilt.checklist).toEqual(issue.checklist); // created + 2 added + ticked + reworded + removed expect(issue.getUncommittedEvents()).toHaveLength(6); }); it('refuses empty steps and unknown items', () => { const issue = newIssue(); expect(() => issue.addChecklistItem('u', 'c-1', ' ')).toThrow( ValidationError, ); expect(() => issue.removeChecklistItem('u', 'nope')).toThrow( BusinessRuleError, ); }); }); describe('mentions', () => { it('reads who a text mentions, once each', () => { const text = 'Hola @[Ana García](u-ana) y @[Bob](u-bob), @[Ana García](u-ana) otra vez. @sinmarca'; expect(mentionedUserIds(text)).toEqual(['u-ana', 'u-bob']); expect(withoutMentionMarkup(text)).toBe( 'Hola @Ana García y @Bob, @Ana García otra vez. @sinmarca', ); }); }); });