import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createApp } from '../src/app'; import { loadConfig } from '../src/config'; import { createContext, type AppContext } from '../src/context'; import { silentLogger } from '../src/log'; export interface TestApp { ctx: AppContext; app: ReturnType; dir: string; cleanup(): void; } export async function makeApp(env: Record = {}): Promise { const dir = mkdtempSync(join(tmpdir(), 'spendlight-test-')); const config = loadConfig({ DATA_DIR: join(dir, 'data'), BACKUP_DIR: join(dir, 'backups'), WEB_DIST: join(dir, 'web'), APP_VERSION: '20261007.01', ...env, }); const ctx = await createContext(config, { log: silentLogger }); const app = createApp(ctx); return { ctx, app, dir, cleanup() { ctx.sqlite.close(); rmSync(dir, { recursive: true, force: true }); }, }; } /** A tiny browser: keeps cookies between calls and sends JSON. */ export class Client { private cookies = new Map(); constructor( private t: TestApp, private base = 'http://localhost', private extraHeaders: Record = {}, ) {} async call(method: string, path: string, body?: unknown, headers: Record = {}) { const res = await this.t.app.request(`${this.base}${path}`, { method, headers: { ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...(this.cookies.size ? { Cookie: [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; ') } : {}), ...this.extraHeaders, ...headers, }, body: body !== undefined ? JSON.stringify(body) : undefined, }); for (const line of res.headers.getSetCookie()) { const [pair] = line.split(';'); const [name, ...rest] = pair!.split('='); const value = rest.join('='); if (!value || /max-age=0/i.test(line)) this.cookies.delete(name!); else this.cookies.set(name!, value); } const text = await res.text(); let json: any = undefined; try { json = JSON.parse(text); } catch { /* not JSON */ } return { status: res.status, json, text, headers: res.headers }; } /** Send a file the way the browser's file picker does. */ async upload(path: string, fileName: string, content: string) { const form = new FormData(); form.append('file', new File([content], fileName, { type: 'text/csv' })); const res = await this.t.app.request(`${this.base}${path}`, { method: 'POST', headers: this.cookies.size ? { Cookie: [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; ') } : {}, body: form, }); const text = await res.text(); return { status: res.status, json: JSON.parse(text), text }; } get = (path: string, headers?: Record) => this.call('GET', path, undefined, headers); post = (path: string, body: unknown = {}, headers?: Record) => this.call('POST', path, body, headers); patch = (path: string, body: unknown, headers?: Record) => this.call('PATCH', path, body, headers); del = (path: string, headers?: Record) => this.call('DELETE', path, undefined, headers); cookie(name: string) { return this.cookies.get(name); } } export async function setUpAdmin(t: TestApp) { const admin = new Client(t); const res = await admin.post('/api/v1/setup', { displayName: 'Chris', username: 'Chris', password: 'correct-horse' }); if (res.status !== 200) throw new Error(`setup failed: ${res.text}`); return admin; }