Files
task-stream/libs/api/tracker/domain/src/lib/project/project-settings.spec.ts
Josep Piguillem 33c593521e feat: statuses and ticket types take any colour from a colour picker
Settings shows a native colour picker (the palette as suggestions)
instead of the list of eleven names. The API takes a palette name, as
before, or any #rrggbb; picking a palette colour still saves its name,
so it keeps its hand-made dark version. A free colour gets one variant
per theme, computed with the `color` library (moved towards white or
black until it contrasts 3:1 with the surface) and applied with
light-dark(): a pale yellow darkens on white, a navy lightens on dark.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:08:47 +02:00

477 lines
16 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 { Issue } from '../issue/issue';
import { Project } from './project';
import { ProjectRole } from './project-role';
import { IssueUpdatedEvent } from '../events/issue-updated.event';
import {
CustomFieldType,
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('takes a palette colour or any #rrggbb, and nothing else', () => {
const project = newProject();
const settings = defaultProjectSettings();
settings.statuses[0].color = '#7c3aed';
settings.issueTypes[0].color = '#0EA5E9';
project.updateSettings('owner', settings);
expect(project.workflow.status(settings.statuses[0].id)?.color).toBe(
'#7c3aed',
);
for (const color of ['purple', '#abc', 'url(x)', '#7c3aedff']) {
const bad = defaultProjectSettings();
bad.statuses[0].color = color as never;
expect(() => project.updateSettings('owner', bad)).toThrow(
/Color no válido/,
);
}
});
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 a template per type, only when it says something', () => {
const project = newProject();
const settings = defaultProjectSettings();
settings.issueTypes[0].template = {
description: ' ## Pasos para reproducir ',
checklist: [' Probar en Safari ', ''],
};
settings.issueTypes[1].template = { description: ' ', checklist: [] };
project.updateSettings('owner', settings);
expect(project.workflow.templateOf('BUG')).toEqual({
description: '## Pasos para reproducir',
checklist: ['Probar en Safari'],
});
expect(project.workflow.templateOf('FEATURE')).toBeNull();
const tooLong = defaultProjectSettings();
tooLong.issueTypes[0].template = {
description: null,
checklist: Array.from({ length: 21 }, (_, i) => `Paso ${i}`),
};
expect(() => project.updateSettings('owner', tooLong)).toThrow(/plantilla/);
});
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/);
});
describe('custom fields', () => {
const withFields = (): ProjectSettings => ({
...defaultProjectSettings(),
customFields: [
{
id: 'browser',
name: ' Navegador ',
type: CustomFieldType.SELECT,
options: ['Chrome', ' Safari ', 'Chrome', ''],
archived: false,
},
{
id: 'COST',
name: 'Coste',
type: CustomFieldType.NUMBER,
options: ['ignored'],
archived: false,
},
{
id: 'GO_LIVE',
name: 'Salida',
type: CustomFieldType.DATE,
options: [],
archived: false,
},
{
id: 'CLIENT',
name: 'Cliente',
type: CustomFieldType.TEXT,
options: [],
archived: false,
},
],
});
const newIssue = (project: Project) =>
new IssueFactory().create(
{
projectId: project.id,
projectKey: 'WEB',
number: 1,
title: 'Pago roto',
type: 'BUG',
reporterId: 'dev',
customFields: { BROWSER: 'Safari', COST: '1,5' },
},
project.workflow,
);
it('are normalised and kept; saving them unchanged is no change', () => {
const project = newProject();
project.updateSettings('owner', withFields());
expect(project.workflow.customFields[0]).toEqual({
id: 'BROWSER',
name: 'Navegador',
type: CustomFieldType.SELECT,
options: ['Chrome', 'Safari'],
archived: false,
});
expect(project.workflow.customFields[1].options).toEqual([]);
const before = project.getUncommittedEvents().length;
project.updateSettings('owner', structuredClone(project.settings));
// Clients that do not know about them leave them as they are.
const { customFields: _absent, ...older } = structuredClone(
project.settings,
);
project.updateSettings('owner', older);
expect(project.getUncommittedEvents()).toHaveLength(before);
expect(project.workflow.customFields).toHaveLength(4);
});
it('cannot be removed, change type or be a SELECT without options', () => {
const project = newProject();
project.updateSettings('owner', withFields());
const removed = { ...withFields(), customFields: [] };
expect(() => project.updateSettings('owner', removed)).toThrow(
/archívalo/i,
);
const retyped = withFields();
retyped.customFields![1].type = CustomFieldType.TEXT;
expect(() => project.updateSettings('owner', retyped)).toThrow(
/tipo no se puede cambiar/,
);
const empty = withFields();
empty.customFields![0].options = [' '];
expect(() => project.updateSettings('owner', empty)).toThrow(/opciones/);
});
it('check what a ticket holds, by type', () => {
const project = newProject();
project.updateSettings('owner', withFields());
const issue = newIssue(project);
expect(issue.customFields).toEqual({ BROWSER: 'Safari', COST: '1.5' });
const workflow = project.workflow;
const set = (customFields: Record<string, string | number | null>) =>
issue.updateDetails('dev', { customFields }, workflow);
expect(() => set({ BROWSER: 'Edge' })).toThrow(/no option/);
expect(() => set({ COST: 'mucho' })).toThrow(/number/);
expect(() => set({ GO_LIVE: '20/10/2026' })).toThrow(/date/);
expect(() => set({ CLIENT: 'x'.repeat(501) })).toThrow(/500/);
expect(() => set({ NOPE: 'x' })).toThrow(ValidationError);
set({ GO_LIVE: '2026-11-02', COST: 3, BROWSER: null });
const event = issue.getUncommittedEvents().at(-1) as IssueUpdatedEvent;
// The whole map, sorted: the projection just sets it.
expect(event.changes).toEqual({
customFields: { COST: '3', GO_LIVE: '2026-11-02' },
});
expect(Object.keys(event.changes.customFields!)).toEqual([
'COST',
'GO_LIVE',
]);
// Same values again: nothing happens.
const count = issue.getUncommittedEvents().length;
set({ COST: '3' });
expect(issue.getUncommittedEvents()).toHaveLength(count);
});
it('archived ones can be cleared but not set', () => {
const project = newProject();
project.updateSettings('owner', withFields());
const issue = newIssue(project);
const archived = withFields();
archived.customFields![0].archived = true;
project.updateSettings('owner', archived);
expect(() =>
issue.updateDetails(
'dev',
{ customFields: { BROWSER: 'Chrome' } },
project.workflow,
),
).toThrow(BusinessRuleError);
issue.updateDetails(
'dev',
{ customFields: { BROWSER: '' } },
project.workflow,
);
expect(issue.customFields).toEqual({ COST: '1.5' });
// Replaying the events gives the same ticket.
expect(replay(issue, Issue).rebuilt.customFields).toEqual({
COST: '1.5',
});
});
});
it('holds a ticket with an unticked checklist out of DONE, if asked', () => {
const project = newProject();
const issue = new IssueFactory().create(
{
projectId: project.id,
projectKey: 'WEB',
number: 1,
title: 'Pago',
type: 'TASK',
reporterId: 'dev',
},
project.workflow,
);
issue.addChecklistItem('dev', 'a', 'Probar en Safari');
issue.addChecklistItem('dev', 'b', 'Probar en Chrome');
for (const to of [
'TODO',
'IN_PROGRESS',
'IN_REVIEW',
'READY_FOR_QA',
'IN_QA',
])
issue.changeStatus('dev', to, null, project.workflow);
// Off by default: nothing changes for projects that do not ask.
const done = () =>
issue.changeStatus('dev', 'DONE', null, project.workflow);
expect(project.workflow.checklistBeforeDone).toBe(false);
project.updateSettings('owner', {
...defaultProjectSettings(),
checklistBeforeDone: true,
});
expect(done).toThrow(/quedan 2 pasos/);
// A client that does not know about it keeps it on.
project.updateSettings('owner', defaultProjectSettings());
expect(project.workflow.checklistBeforeDone).toBe(true);
issue.updateChecklistItem('dev', 'a', { done: true });
expect(done).toThrow(/queda 1 paso/);
// An owner or admin may close it anyway.
issue.changeStatus('owner', 'DONE', null, project.workflow, true);
expect(issue.status).toBe('DONE');
project.updateSettings('owner', {
...defaultProjectSettings(),
checklistBeforeDone: false,
});
expect(project.workflow.checklistBeforeDone).toBe(false);
});
it('keeps teams of members, assigns tickets to them and expands their mentions', () => {
const project = newProject();
project.updateSettings('owner', {
...defaultProjectSettings(),
teams: [
{
id: 'front',
name: ' Frontend ',
memberIds: ['dev', 'dev', 'stranger'],
archived: false,
},
],
});
// Normalised, and only people of the project.
expect(project.workflow.teams).toEqual([
{ id: 'FRONT', name: 'Frontend', memberIds: ['dev'], archived: false },
]);
// Absent: unchanged; removing one: refused (archive it instead).
project.updateSettings('owner', defaultProjectSettings());
expect(project.workflow.teams).toHaveLength(1);
expect(() =>
project.updateSettings('owner', {
...defaultProjectSettings(),
teams: [],
}),
).toThrow(/archívalo/);
expect(
project.workflow.mentionedPeople(['owner', 'team-FRONT', 'team-NOPE']),
).toEqual(['owner', 'dev']);
const issue = new IssueFactory().create(
{
projectId: project.id,
projectKey: 'WEB',
number: 1,
title: 'Botón',
type: 'TASK',
reporterId: 'dev',
team: 'FRONT',
},
project.workflow,
);
expect(issue.team).toBe('FRONT');
expect(() =>
issue.updateDetails('dev', { team: 'NOPE' }, project.workflow),
).toThrow(ValidationError);
issue.updateDetails('dev', { team: null }, project.workflow);
expect(issue.team).toBeNull();
});
});