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:
104
server/test/helpers.ts
Normal file
104
server/test/helpers.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user