Foundation: single arm64 container (Hono + SQLite via Drizzle, React + Vite), local sign-in with long-lived sessions and passkeys, roles (read-only, full access, admin), nightly and pre-upgrade backups, light and dark themes, date-based version numbers, and the ideas lightbulb behind IDEAS_ENABLED. Money in: accounts with balances, CSV import with bank detection, a column mapping pop-up for unknown layouts, duplicate checks, transfer pairing and undo; one-tap review with learned rules; activity with search, splits, tags, notes and cash entries. Statement files are blocked from commits by a pre-commit hook; tests use synthetic fixtures only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
105 lines
3.6 KiB
TypeScript
105 lines
3.6 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);
|
|
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;
|
|
}
|