Milestones 0 and 1: foundation and money in (20261007.10)

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>
This commit is contained in:
2026-10-07 19:52:07 -04:00
commit c3cf0f3eb8
147 changed files with 21713 additions and 0 deletions

104
server/test/helpers.ts Normal file
View File

@@ -0,0 +1,104 @@
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;
}