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) => 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(); }); });