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
125 lines
6.3 KiB
TypeScript
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)
|
|
})
|
|
})
|