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/); }); });