Tickets get a start date next to their due date, both editable on the ticket page. The Hoja de ruta tab lays the epics out over the months as bars filled with their progress, with sprints and releases as landmarks and a line for today. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
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> = {}): 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',
|
|
);
|
|
});
|
|
});
|
|
});
|