Each status can carry a WIP limit in the project settings; the board shows it next to the count, marks a column that goes over it and says so when a card is dropped there. The board can also be grouped in rows by person, epic, priority or type, remembered per browser. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
6.1 KiB
TypeScript
184 lines
6.1 KiB
TypeScript
import {
|
|
BusinessRuleError,
|
|
ForbiddenError,
|
|
ValidationError,
|
|
} from '@task-stream/api/shared/domain';
|
|
import { ProjectSettingsChangedEvent } from '../events/project-settings-changed.event';
|
|
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
|
import { IssueFactory } from '../factories/issue.factory';
|
|
import { ProjectFactory } from '../factories/project.factory';
|
|
import { replay } from '../testing';
|
|
import { Project } from './project';
|
|
import { ProjectRole } from './project-role';
|
|
import {
|
|
defaultProjectSettings,
|
|
ProjectSettings,
|
|
StatusCategory,
|
|
} from './project-settings';
|
|
|
|
const newProject = () => {
|
|
const project = new ProjectFactory().create({
|
|
key: 'WEB',
|
|
name: 'Web',
|
|
ownerId: 'owner',
|
|
});
|
|
project.addMember('owner', 'dev', ProjectRole.MEMBER);
|
|
return project;
|
|
};
|
|
|
|
/** Defaults plus a STAGING step between review and QA, and a SPIKE type. */
|
|
const withStaging = (): ProjectSettings => {
|
|
const settings = defaultProjectSettings();
|
|
settings.statuses.splice(4, 0, {
|
|
id: 'STAGING',
|
|
name: 'En staging',
|
|
category: StatusCategory.QA,
|
|
color: 'teal',
|
|
requiresReason: false,
|
|
archived: false,
|
|
});
|
|
settings.transitions['IN_REVIEW'] = ['STAGING'];
|
|
settings.transitions['STAGING'] = ['IN_QA', 'IN_PROGRESS'];
|
|
settings.issueTypes.push({
|
|
id: 'SPIKE',
|
|
name: 'Spike',
|
|
icon: 'light-bulb',
|
|
color: 'teal',
|
|
archived: false,
|
|
});
|
|
return settings;
|
|
};
|
|
|
|
describe('Project settings', () => {
|
|
it('starts with the default workflow, also when rebuilt from old events', () => {
|
|
const project = newProject();
|
|
expect(project.workflow.initialStatus()).toBe('BACKLOG');
|
|
expect(project.workflow.canTransition('IN_QA', 'QA_FAILED')).toBe(true);
|
|
|
|
// Projects created before settings existed have no `settings` in their event.
|
|
const { history } = replay(project, Project);
|
|
delete (history[0].data as unknown as { project: { settings?: unknown } })
|
|
.project.settings;
|
|
const old = new Project(project.id);
|
|
old.loadFromHistory(history);
|
|
expect(old.settings).toEqual(defaultProjectSettings());
|
|
});
|
|
|
|
it('lets owners and admins customise it, as an event that replays', () => {
|
|
const project = newProject();
|
|
project.updateSettings('owner', withStaging());
|
|
|
|
const event = project.getUncommittedEvents().at(-1);
|
|
expect(event).toBeInstanceOf(ProjectSettingsChangedEvent);
|
|
expect(project.workflow.allowedFrom('IN_REVIEW')).toEqual(['STAGING']);
|
|
|
|
const { rebuilt } = replay(project, Project);
|
|
expect(rebuilt.workflow.status('STAGING')?.category).toBe(
|
|
StatusCategory.QA,
|
|
);
|
|
expect(() => project.updateSettings('dev', withStaging())).toThrow(
|
|
ForbiddenError,
|
|
);
|
|
});
|
|
|
|
it('archives values instead of deleting them', () => {
|
|
const project = newProject();
|
|
const settings = defaultProjectSettings();
|
|
settings.areas = settings.areas.filter((a) => a.id !== 'MOBILE');
|
|
expect(() => project.updateSettings('owner', settings)).toThrow(
|
|
/archívalo/,
|
|
);
|
|
|
|
const archived = defaultProjectSettings();
|
|
archived.areas = archived.areas.map((a) =>
|
|
a.id === 'MOBILE' ? { ...a, archived: true } : a,
|
|
);
|
|
project.updateSettings('owner', archived);
|
|
expect(() => project.workflow.assertActiveArea('MOBILE')).toThrow(
|
|
BusinessRuleError,
|
|
);
|
|
});
|
|
|
|
it('keeps work-in-progress limits, from 1 to 999', () => {
|
|
const project = newProject();
|
|
const limited = defaultProjectSettings();
|
|
limited.statuses[2].wipLimit = 3;
|
|
project.updateSettings('owner', limited);
|
|
expect(project.workflow.wipLimit(limited.statuses[2].id)).toBe(3);
|
|
expect(project.workflow.wipLimit('TODO')).toBeNull();
|
|
// Saving the same again is no change.
|
|
const before = project.getUncommittedEvents().length;
|
|
project.updateSettings('owner', structuredClone(limited));
|
|
expect(project.getUncommittedEvents()).toHaveLength(before);
|
|
|
|
const zero = defaultProjectSettings();
|
|
zero.statuses[2].wipLimit = 0;
|
|
expect(() => project.updateSettings('owner', zero)).toThrow(/WIP/);
|
|
});
|
|
|
|
it('rejects settings that would break the board', () => {
|
|
const project = newProject();
|
|
const noStart = defaultProjectSettings();
|
|
noStart.statuses
|
|
.filter((s) => s.category === StatusCategory.TODO)
|
|
.forEach((s) => (s.archived = true));
|
|
expect(() => project.updateSettings('owner', noStart)).toThrow(
|
|
ValidationError,
|
|
);
|
|
|
|
const badTransition = defaultProjectSettings();
|
|
badTransition.transitions['TODO'] = ['NOPE'];
|
|
expect(() => project.updateSettings('owner', badTransition)).toThrow(
|
|
/no existe/,
|
|
);
|
|
|
|
const duplicate = defaultProjectSettings();
|
|
duplicate.issueTypes.push({ ...duplicate.issueTypes[0] });
|
|
expect(() => project.updateSettings('owner', duplicate)).toThrow(/id BUG/);
|
|
});
|
|
|
|
it('drives tickets: types, transitions and names in the history', () => {
|
|
const project = newProject();
|
|
project.updateSettings('owner', withStaging());
|
|
const workflow = project.workflow;
|
|
const issue = new IssueFactory().create(
|
|
{
|
|
projectId: project.id,
|
|
projectKey: 'WEB',
|
|
number: 1,
|
|
title: 'Investigar pagos',
|
|
type: 'SPIKE',
|
|
reporterId: 'dev',
|
|
},
|
|
workflow,
|
|
);
|
|
for (const to of ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'STAGING'])
|
|
issue.changeStatus('dev', to, null, workflow);
|
|
|
|
const last = issue.getUncommittedEvents().at(-1) as IssueStatusChangedEvent;
|
|
expect(last.meta).toEqual({
|
|
fromName: 'En revisión',
|
|
toName: 'En staging',
|
|
fromCategory: StatusCategory.IN_PROGRESS,
|
|
toCategory: StatusCategory.QA,
|
|
});
|
|
// The default IN_REVIEW → READY_FOR_QA was replaced by STAGING.
|
|
expect(() =>
|
|
issue.changeStatus('dev', 'READY_FOR_QA', null, workflow),
|
|
).toThrow(BusinessRuleError);
|
|
expect(() =>
|
|
new IssueFactory().create(
|
|
{
|
|
projectId: project.id,
|
|
projectKey: 'WEB',
|
|
number: 2,
|
|
title: 'x',
|
|
type: 'EPIC',
|
|
reporterId: 'dev',
|
|
},
|
|
workflow,
|
|
),
|
|
).toThrow(/Unknown ticket type/);
|
|
});
|
|
});
|