Files
Spendlight/server/test/helpers.ts
Chris Olson 4ecd64132b Milestone 2: the full picture (20261007.12)
Budgets: one amount per category for every month with any month set on its
own, filled from recent spending in one step and copied into the next year.
Home shows what's left to spend, year pacing, insights, balances and upcoming
charges. Insights covers year pacing, money in and out, budget against actual,
categories by month, merchants, balances over time and tags.

Subscriptions and bills are found across every account (rhythm and price
matching plus a built-in catalog), confirmed with one tap, and watched for
price changes, missed charges, charges after cancelling, double billing,
renewals and trials. Alerts are worked out again whenever the data changes.

Also: category, rule, tag and alert settings; Excel and CSV export from a
small built-in writer; the AI module designed in and switched off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:59:17 -04:00

106 lines
3.7 KiB
TypeScript

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<typeof createApp>;
dir: string;
cleanup(): void;
}
export async function makeApp(env: Record<string, string> = {}): Promise<TestApp> {
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<string, string>();
constructor(
private t: TestApp,
private base = 'http://localhost',
private extraHeaders: Record<string, string> = {},
) {}
async call(method: string, path: string, body?: unknown, headers: Record<string, string> = {}) {
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<string, string>) => this.call('GET', path, undefined, headers);
post = (path: string, body: unknown = {}, headers?: Record<string, string>) => this.call('POST', path, body, headers);
patch = (path: string, body: unknown, headers?: Record<string, string>) => this.call('PATCH', path, body, headers);
put = (path: string, body: unknown, headers?: Record<string, string>) => this.call('PUT', path, body, headers);
del = (path: string, headers?: Record<string, string>) => 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;
}