Files
open-safetylog-ehs/server/routes/kiosks.test.ts
T
jayjennings 3e964d1176
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
feat(i18n): add internationalization support with English and Spanish translations
feat(kiosk): implement kiosk report submission functionality with offline support

test(outbox): add tests for outbox functionality including report submission and error handling

feat(outbox): create outbox management for queued reports with IndexedDB persistence

feat(uuid): add UUID generation utility for client IDs

feat(qr): implement QR code generation for reporting links by site and area

feat(kiosk): create Kiosk page for reporting incidents without an account
2026-10-06 11:38:40 -07:00

125 lines
6.3 KiB
TypeScript

// oxlint-disable typescript/no-explicit-any -- tests read arbitrary JSON
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { createTestContext, hoursAgo } from '../test/helpers.ts'
let ctx: Awaited<ReturnType<typeof createTestContext>>
let north: { id: string }
let south: { id: string }
const json = (res: Response): Promise<any> => res.json()
const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52])
/** Submits like the kiosk page: multipart with the token header. */
function kioskReport(token: string, report: Record<string, unknown>, files: File[] = []) {
const form = new FormData()
form.append('report', JSON.stringify(report))
for (const file of files) form.append('files', file)
return ctx.app.request('/api/kiosk/reports', { method: 'POST', headers: { 'x-kiosk-token': token, origin: 'http://safety.test' }, body: form })
}
const report = (overrides: Record<string, unknown> = {}) => ({ title: 'Leaking valve', type: 'hazard', severity: 'medium', occurredAt: hoursAgo(1), siteId: north.id, ...overrides })
beforeAll(async () => {
ctx = await createTestContext()
north = await ctx.createSite('North')
south = await ctx.createSite('South')
})
afterAll(() => ctx.close())
describe('reporting links', () => {
it('are managed by safety managers', async () => {
const supervisor = await ctx.createUser('supervisor')
expect((await supervisor.post('/api/kiosks', { name: 'Canteen tablet' })).status).toBe(403)
const res = await ctx.admin.post('/api/kiosks', { name: 'Canteen tablet' })
expect(res.status).toBe(201)
expect(res.body.kiosk.token).toMatch(/^[\w-]{32}$/)
expect((await ctx.admin.get('/api/kiosks')).body.kiosks.map((k: { name: string }) => k.name)).toContain('Canteen tablet')
})
it('let anyone with the token file a report, with photos, and nothing more', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Gate 2' })).body
const config = await ctx.app.request('/api/kiosk/config', { headers: { 'x-kiosk-token': kiosk.token } })
expect(await json(config)).toMatchObject({ organization: { name: 'Northstar Co.' }, kiosk: { name: 'Gate 2' } })
const res = await kioskReport(kiosk.token, report({ reporterName: 'Luis' }), [new File([PNG], 'valve.png', { type: 'image/png' })])
expect(res.status).toBe(201)
const { reference, failedFiles } = await json(res)
expect(reference).toMatch(/^INC-\d{4}$/)
expect(failedFiles).toEqual([])
const number = Number(reference.slice(4))
const incident = (await ctx.admin.get(`/api/incidents/${number}`)).body.incident
expect(incident).toMatchObject({ channel: 'kiosk', kiosk: { name: 'Gate 2' }, reporterName: 'Luis', reporter: null, anonymous: false })
expect(incident.attachments).toHaveLength(1)
// The token is not a session
expect((await ctx.app.request('/api/incidents', { headers: { 'x-kiosk-token': kiosk.token } })).status).toBe(401)
})
it('treat reports without a name as anonymous', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Anon' })).body
const { reference } = await json(await kioskReport(kiosk.token, report()))
const incident = (await ctx.admin.get(`/api/incidents/${Number(reference.slice(4))}`)).body.incident
expect(incident).toMatchObject({ anonymous: true, reporterName: null })
})
it('stop working when revoked', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Old tablet' })).body
await ctx.admin.patch(`/api/kiosks/${kiosk.id}`, { revoked: true })
expect((await kioskReport(kiosk.token, report())).status).toBe(401)
expect((await kioskReport('made-up-token', report())).status).toBe(401)
})
it('can be limited to one site', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'North only', siteId: north.id })).body
const config = await json(await ctx.app.request('/api/kiosk/config', { headers: { 'x-kiosk-token': kiosk.token } }))
expect(config.sites.map((s: { id: string }) => s.id)).toEqual([north.id])
expect((await kioskReport(kiosk.token, report({ siteId: south.id }))).status).toBe(400)
})
it('validate reports', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Validation' })).body
const res = await kioskReport(kiosk.token, report({ type: 'explosion' }))
expect(res.status).toBe(400)
expect((await json(res)).issues[0].path).toBe('type')
})
it('are rate limited', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Busy' })).body
for (let i = 0; i < 30; i++) expect((await kioskReport(kiosk.token, report({ severity: 'low' }))).status).toBe(201)
expect((await kioskReport(kiosk.token, report({ severity: 'low' }))).status).toBe(429)
})
})
describe('resubmitting the same report (offline retries)', () => {
it('returns the original instead of creating a duplicate', async () => {
const reporter = await ctx.createUser('reporter')
const clientId = crypto.randomUUID()
const first = await reporter.post('/api/incidents', { ...report(), clientId })
const again = await reporter.post('/api/incidents', { ...report(), clientId })
expect(first.status).toBe(201)
expect(again.status).toBe(200)
expect(again.body.incident.number).toBe(first.body.incident.number)
})
it('holds under simultaneous retries', async () => {
const reporter = await ctx.createUser('reporter', 'racer@example.com')
const clientId = crypto.randomUUID()
const results = await Promise.all([1, 2, 3].map(() => reporter.post('/api/incidents', { ...report(), clientId })))
expect(new Set(results.map((r) => r.body.incident.number)).size).toBe(1)
})
it('does not attach photos twice from a kiosk retry', async () => {
const { kiosk } = (await ctx.admin.post('/api/kiosks', { name: 'Retry' })).body
const clientId = crypto.randomUUID()
const file = () => new File([PNG], 'p.png', { type: 'image/png' })
const a = await json(await kioskReport(kiosk.token, report({ clientId }), [file()]))
const b = await kioskReport(kiosk.token, report({ clientId }), [file()])
expect(b.status).toBe(200)
expect((await json(b)).reference).toBe(a.reference)
const incident = (await ctx.admin.get(`/api/incidents/${Number(a.reference.slice(4))}`)).body.incident
expect(incident.attachments).toHaveLength(1)
})
})