Files
jayjennings 3f6dff5f24
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
feat: implement permissions and actions management
- Add permissions logic for status options in corrective actions.
- Create tests for permissions functionality.
- Implement queries for user authentication and actions management.
- Develop Actions page to display and manage corrective actions.
- Create authentication pages for login, setup, and password reset.
- Add IncidentDetail page to view incident details and associated actions.
- Implement Settings page for user management and password changes.
2026-10-06 11:20:44 -07:00

203 lines
10 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createTestContext, daysFromToday, hoursAgo } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
let siteId: string
let reporter: Awaited<ReturnType<typeof ctx.createUser>>
let supervisor: Awaited<ReturnType<typeof ctx.createUser>>
let manager: Awaited<ReturnType<typeof ctx.createUser>>
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' })
})
})