- 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.
203 lines
10 KiB
TypeScript
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' })
|
|
})
|
|
})
|