import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createAuth } from '../auth/options'; import { createDb } from '../db/index'; import { migrateToLatest } from '../db/migrator'; import { seed } from '../db/seed'; import { createApp, type AppHandle } from '../http/app'; import type { AppDeps } from '../http/context'; import { type Env, parseEnv } from '../lib/env'; import { createHandlers, createPoller, type Poller } from '../modules/jobs'; import type { OcrProvider } from '../modules/documents/ocr'; import { createChannels, type Channels } from '../modules/notifications'; import { createStorage, type StorageDriver } from '../modules/storage'; export const TEST_ENV: Record = { NODE_ENV: 'test', DATABASE_URL: 'sqlite::memory:', BETTER_AUTH_SECRET: 'test-secret-that-is-long-enough-32chars', BETTER_AUTH_URL: 'http://localhost:3000', APP_PUBLIC_URL: 'http://localhost:3000', }; export interface HarnessOptions { env?: Record; /** Stand in for the Anthropic call, so OCR paths are testable without a key. */ ocr?: OcrProvider | null; storage?: StorageDriver; channels?: Channels; /** Freezes the clock, so seeded dates and dashboard figures are deterministic. */ now?: Date; } export interface Harness extends AppHandle { deps: AppDeps; env: Env; storage: StorageDriver; channels: Channels; /** The clock the harness was built with, frozen when one was supplied. */ now: () => Date; /** Drains the job queue. Tests call this instead of waiting on the interval. */ runJobs: () => Promise; poller: Poller; close: () => Promise; /** Signs in a seeded account and returns the cookie header for later requests. */ signIn: (email: string, password: string) => Promise; } export async function createHarness(options: HarnessOptions = {}): Promise { const parsed = parseEnv({ ...TEST_ENV, ...(options.env ?? {}) }); if (!parsed.ok || !parsed.env) throw new Error(parsed.message); const env = parsed.env; const handle = createDb(env.DATABASE_URL); await migrateToLatest(handle, env); const auth = createAuth({ db: handle.db, dialect: handle.dialect, env, sendOtp: async () => undefined }); await seed(handle, auth, options.now ? { now: options.now } : {}); const channels = options.channels ?? createChannels(env); const now = options.now ? () => options.now as Date : () => new Date(); const storage = options.storage ?? createStorage({ ...env, STORAGE_DRIVER: 'local', STORAGE_LOCAL_PATH: mkdtempSync(join(tmpdir(), 'impuestos-test-')) }); const ocr = options.ocr ?? null; const ingest = { db: handle.db, storage, ocrEnabled: ocr !== null }; const deps: AppDeps = { env, handle, auth, storage, ingest }; const appHandle = createApp(deps); const poller = createPoller({ db: handle.db, dialect: handle.dialect, handlers: createHandlers({ db: handle.db, storage, ocr, channels, now }), intervalMs: env.JOBS_POLL_INTERVAL_MS, staleMinutes: env.JOBS_STALE_MINUTES, // Tests queue exactly the work they mean to exercise. scheduleSweeps: false, now, }); return { ...appHandle, deps, env, storage, channels, now, poller, runJobs: () => poller.runOnce(), close: async () => { await poller.stop(); await handle.close(); }, signIn: async (email, password) => { const response = await appHandle.app.request('/api/auth/sign-in/email', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }), }); if (!response.ok) throw new Error(`sign in failed: ${response.status} ${await response.text()}`); const cookie = response.headers.get('set-cookie'); if (!cookie) throw new Error('sign in returned no cookie'); return cookie.split(';')[0] ?? ''; }, }; }