import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { createTestContext, daysFromToday, hoursAgo } from '../test/helpers.ts' let ctx: Awaited> let siteId: string let reporter: Awaited> let supervisor: Awaited> let manager: Awaited> async function newIncident(severity = 'high') { const res = await reporter.post('/api/incidents', { title: 'Guard missing on press', type: 'hazard', severity, occurredAt: hoursAgo(1), siteId }) return res.body.incident.number as number } const move = (client: typeof supervisor, number: number, to: string, note?: string) => client.post(`/api/incidents/${number}/transitions`, { to, note }) beforeAll(async () => { ctx = await createTestContext() siteId = (await ctx.createSite()).id reporter = await ctx.createUser('reporter') supervisor = await ctx.createUser('supervisor') manager = await ctx.createUser('safety_manager') }) afterAll(() => ctx.close()) describe('incident workflow', () => { it('runs an incident from report to close', async () => { const number = await newIncident() expect((await move(reporter, number, 'triage')).status).toBe(403) let res = await move(supervisor, number, 'triage', 'Looking into it') expect(res.body.incident.status).toBe('triage') expect(res.body.incident.transitions.map((t: { to: string }) => t.to)).toEqual(['investigation', 'actions', 'closed']) // High severity can't be closed straight from triage expect((await move(supervisor, number, 'closed')).body.error).toMatch(/must be investigated/) await move(supervisor, number, 'investigation') // Need a root cause before moving on expect((await move(supervisor, number, 'actions')).status).toBe(409) res = await supervisor.put(`/api/incidents/${number}/investigation`, { method: 'five_whys', summary: 'Guard removed for maintenance and not refitted', leadId: supervisor.user.id }) expect(res.body.incident.investigation).toMatchObject({ method: 'five_whys', lead: { id: supervisor.user.id } }) res = await supervisor.post(`/api/incidents/${number}/root-causes`, { category: 'process', description: 'No lockout checklist for guard removal', whys: ['Guard was off', 'Maintenance removed it', 'No step to refit it'], }) const rootCauseId = res.body.incident.rootCauses[0].id await move(supervisor, number, 'actions') // Need finished actions before verification expect((await move(supervisor, number, 'verification')).body.error).toMatch(/at least one corrective action/) res = await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Add guard refit step to maintenance checklist', assigneeId: reporter.user.id, dueDate: daysFromToday(7), rootCauseId, }) const action = res.body.incident.actions[0] expect(action).toMatchObject({ status: 'open', assignee: { id: reporter.user.id }, overdue: false }) expect((await move(supervisor, number, 'verification')).body.error).toMatch(/must be done/) // The assignee (a reporter) can see the incident and report progress, but not edit the action expect((await reporter.get(`/api/incidents/${number}`)).status).toBe(200) expect((await reporter.patch(`/api/actions/${action.id}`, { title: 'Changed' })).status).toBe(403) expect((await reporter.patch(`/api/actions/${action.id}`, { status: 'verified' })).status).toBe(403) res = await reporter.patch(`/api/actions/${action.id}`, { status: 'done' }) expect(res.body.action.completedAt).not.toBeNull() await move(supervisor, number, 'verification') expect((await move(supervisor, number, 'closed')).status).toBe(403) expect((await move(manager, number, 'closed')).body.error).toMatch(/Verify every corrective action/) expect((await supervisor.patch(`/api/actions/${action.id}`, { status: 'verified' })).status).toBe(403) res = await manager.patch(`/api/actions/${action.id}`, { status: 'verified' }) expect(res.body.action.verifiedBy).toMatchObject({ id: manager.user.id }) res = await move(manager, number, 'closed') expect(res.body.incident).toMatchObject({ status: 'closed' }) expect(res.body.incident.closedAt).not.toBeNull() // The timeline tells the whole story const timeline = (await supervisor.get(`/api/incidents/${number}/timeline`)).body.timeline expect(timeline.map((entry: { action: string }) => entry.action)).toEqual([ 'create', 'status', 'status', 'investigation.update', 'root_cause.add', 'status', 'action.add', 'action.update', 'status', 'action.update', 'status', ]) // The note on the first transition became a comment const comments = (await supervisor.get(`/api/incidents/${number}/comments`)).body.comments expect(comments[0]).toMatchObject({ body: 'Looking into it', author: { id: supervisor.user.id } }) // Reopening is for safety managers expect((await move(supervisor, number, 'investigation')).status).toBe(403) expect((await move(manager, number, 'investigation')).body.incident.closedAt).toBeNull() }) it('lets low-severity incidents close straight from triage', async () => { const number = await newIncident('low') await move(supervisor, number, 'triage') expect((await move(supervisor, number, 'closed')).body.incident.status).toBe('closed') }) it('rejects transitions that skip steps', async () => { const number = await newIncident() expect((await move(manager, number, 'closed')).status).toBe(409) }) it('edits and removes root causes', async () => { const number = await newIncident() let res = await supervisor.post(`/api/incidents/${number}/root-causes`, { category: 'equipment', description: 'Worn belt' }) const id = res.body.incident.rootCauses[0].id res = await supervisor.patch(`/api/root-causes/${id}`, { whys: ['Belt slipped', 'Not inspected'] }) expect(res.body.incident.rootCauses[0].whys).toEqual(['Belt slipped', 'Not inspected']) res = await supervisor.delete(`/api/root-causes/${id}`) expect(res.body.incident.rootCauses).toEqual([]) expect((await reporter.delete(`/api/root-causes/${id}`)).status).toBe(403) }) }) describe('comments', () => { it('lets anyone who can see an incident comment on it', async () => { const number = await newIncident() expect((await reporter.post(`/api/incidents/${number}/comments`, { body: 'It happened again today' })).status).toBe(201) const other = await ctx.createUser('reporter', 'outsider@example.com') expect((await other.post(`/api/incidents/${number}/comments`, { body: 'Hi' })).status).toBe(404) expect((await reporter.post(`/api/incidents/${number}/comments`, { body: ' ' })).status).toBe(400) }) }) describe('corrective actions', () => { it('lists my open and overdue actions and all actions for supervisors', async () => { const number = await newIncident() await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Late one', assigneeId: manager.user.id, dueDate: daysFromToday(-2) }) await supervisor.post(`/api/incidents/${number}/actions`, { title: 'On time', assigneeId: manager.user.id, dueDate: daysFromToday(10) }) const mine = (await manager.get('/api/actions')).body.actions expect(mine.map((a: { title: string }) => a.title)).toEqual(['Late one', 'On time']) expect(mine[0].overdue).toBe(true) const overdue = (await manager.get('/api/actions?filter=overdue')).body.actions expect(overdue.map((a: { title: string }) => a.title)).toEqual(['Late one']) expect((await reporter.get('/api/actions?scope=all')).status).toBe(403) expect((await supervisor.get('/api/actions?scope=all')).body.actions.length).toBeGreaterThanOrEqual(2) }) it('emails the assignee', async () => { const number = await newIncident() await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Replace mat', assigneeId: manager.user.id }) await ctx.notifier.flush() expect(ctx.mailer.last(manager.user.email)?.subject).toBe('Action assigned: Replace mat') }) it('sends one daily reminder per assignee for due and overdue actions', async () => { const assignee = await ctx.createUser('reporter', 'reminders@example.com') const number = await newIncident() await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Overdue fix', assigneeId: assignee.user.id, dueDate: daysFromToday(-1) }) await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Due tomorrow', assigneeId: assignee.user.id, dueDate: daysFromToday(1) }) await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Next month', assigneeId: assignee.user.id, dueDate: daysFromToday(30) }) await ctx.notifier.sendDueReminders() await ctx.notifier.flush() const reminders = ctx.mailer.sent.filter((mail) => mail.to === assignee.user.email && mail.subject !== 'Action assigned: Overdue fix') const digest = reminders.find((mail) => mail.subject === '1 overdue corrective action') expect(digest?.text).toContain('Overdue fix') expect(digest?.text).toContain('Due tomorrow') expect(digest?.text).not.toContain('Next month') const before = ctx.mailer.sent.length await ctx.notifier.sendDueReminders() await ctx.notifier.flush() expect(ctx.mailer.sent.length).toBe(before) }) it("can't be added to a closed incident", async () => { const number = await newIncident('low') await move(supervisor, number, 'triage') await move(supervisor, number, 'closed') expect((await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Too late' })).status).toBe(409) }) }) describe('partial updates', () => { it('leave omitted fields alone', async () => { const number = await newIncident() let res = await supervisor.post(`/api/incidents/${number}/root-causes`, { category: 'people', description: 'Rushed', whys: ['Late shipment'] }) const id = res.body.incident.rootCauses[0].id res = await supervisor.patch(`/api/root-causes/${id}`, { description: 'Rushed to meet shipment' }) expect(res.body.incident.rootCauses[0]).toMatchObject({ description: 'Rushed to meet shipment', whys: ['Late shipment'] }) res = await supervisor.post(`/api/incidents/${number}/actions`, { title: 'Preventive check', kind: 'preventive' }) const actionId = res.body.incident.actions[0].id const updated = await supervisor.patch(`/api/actions/${actionId}`, { status: 'in_progress' }) expect(updated.body.action).toMatchObject({ kind: 'preventive', status: 'in_progress' }) }) })