import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createApp } from '../app.ts' import { loadConfig } from '../config.ts' import { openDatabase } from '../db/client.ts' import { MemoryMailer } from '../mail.ts' import { Notifier } from '../notifications.ts' import { FileStorage } from '../storage.ts' type App = ReturnType /** Minimal API client that keeps its session cookie between requests, like a browser. */ export class Client { cookie: string | undefined private readonly app: App constructor(app: App) { this.app = app } async request(method: string, path: string, body?: unknown) { const headers: Record = {} const isForm = body instanceof FormData if (body !== undefined && !isForm) headers['content-type'] = 'application/json' if (method !== 'GET') headers.origin = 'http://safety.test' // browsers send Origin on every non-GET request if (this.cookie) headers.cookie = this.cookie const payload = body === undefined ? undefined : isForm ? body : JSON.stringify(body) const res = await this.app.request(path, { method, headers, body: payload }) const setCookie = res.headers.get('set-cookie') if (setCookie) this.cookie = /max-age=0|expires=thu, 01 jan 1970/i.test(setCookie) ? undefined : setCookie.split(';')[0] const text = res.headers.get('content-type')?.includes('json') || res.status >= 400 ? await res.text() : '' if (!text && res.ok && res.status !== 204) return { status: res.status, body: Buffer.from(await res.arrayBuffer()), headers: res.headers } // oxlint-disable-next-line typescript/no-explicit-any -- tests read arbitrary JSON shapes const json: any = text ? JSON.parse(text) : null return { status: res.status, body: json, headers: res.headers } } get = (path: string) => this.request('GET', path) post = (path: string, body?: unknown) => this.request('POST', path, body ?? {}) put = (path: string, body: unknown) => this.request('PUT', path, body) patch = (path: string, body: unknown) => this.request('PATCH', path, body) delete = (path: string) => this.request('DELETE', path) } export const ADMIN = { organizationName: 'Northstar Co.', name: 'Avery Admin', email: 'admin@example.com', password: 'correct horse battery' } /** Fresh in-memory database and app, with the first-run setup already done and the admin signed in. */ export async function createTestContext() { const database = await openDatabase('memory://') const uploadDir = await mkdtemp(join(tmpdir(), 'safetylog-test-')) const config = { ...loadConfig({ PUBLIC_URL: 'http://safety.test' }), databaseUrl: 'memory://', uploadDir, maxUploadBytes: 1024 * 1024 } const mailer = new MemoryMailer() const notifier = new Notifier(database.db, mailer, config) const app = createApp({ db: database.db, config, notifier, storage: new FileStorage(uploadDir) }) const admin = new Client(app) const setup = await admin.post('/api/setup', ADMIN) if (setup.status !== 201) throw new Error(`setup failed: ${JSON.stringify(setup.body)}`) async function createUser(role: 'reporter' | 'supervisor' | 'safety_manager' | 'admin', email = `${role}@example.com`) { const password = 'a sufficiently long password' const created = await admin.post('/api/users', { name: `${role} user`, email, role, password }) if (created.status !== 201) throw new Error(`createUser failed: ${JSON.stringify(created.body)}`) const client = new Client(app) await client.post('/api/auth/login', { email, password }) return Object.assign(client, { user: created.body.user as { id: string; email: string } }) } async function createSite(name = 'North Campus') { const res = await admin.post('/api/sites', { name }) return res.body.site as { id: string; name: string } } async function close() { await notifier.flush() await database.close() await rm(uploadDir, { recursive: true, force: true }) } return { app, db: database.db, admin, mailer, notifier, config, anonymous: () => new Client(app), createUser, createSite, close } } export const hoursAgo = (hours: number) => new Date(Date.now() - hours * 3_600_000).toISOString() /** Pulls the token out of the link in an email body. */ export function tokenFrom(text: string | undefined): string { const match = /token=([\w-]+)/.exec(text ?? '') if (!match) throw new Error(`No token in: ${text}`) return match[1] } export const daysFromToday = (days: number) => { const date = new Date() date.setDate(date.getDate() + days) const pad = (n: number) => String(n).padStart(2, '0') return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` }