From 95df5cb5b99fa8bb5a2c25061765508de68355b9 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 18:08:42 +0530 Subject: [PATCH] =?UTF-8?q?feat(db):=20D19=20P4=20=E2=80=94=20DATABASE=5FU?= =?UTF-8?q?RL=20engine=20selection=20+=20Postgres=20integration=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startServer picks Postgres when DATABASE_URL is set, SQLite file otherwise. New pg-engine.test.ts (opt-in via HQ_PG_TEST_URL, wipes its target schema): migrations from zero, invoice issue with series numbering + due-date stamp, paisa-exact payment to paid, exactly-once reminder upsert via ON CONFLICT, savepoint-nested rollback (incl. the mixed dbPassword+patch write), and a full HTTP boot smoke (health/login/clients) on the postgres engine. Verified locally against PostgreSQL 17: 5/5. Default suite (sqlite) 346/346, typecheck clean. Co-Authored-By: Claude Fable 5 --- apps/hq/src/server.ts | 6 +- apps/hq/test/pg-engine.test.ts | 121 +++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 apps/hq/test/pg-engine.test.ts diff --git a/apps/hq/src/server.ts b/apps/hq/src/server.ts index c96d0f2..a6f43f1 100644 --- a/apps/hq/src/server.ts +++ b/apps/hq/src/server.ts @@ -7,6 +7,7 @@ import { apiRouter } from './api' import { maybePullAwsCosts } from './aws-costs' import { pollBounces } from './bounces' import { openDb, type DB } from './db' +import { openPgDb } from './db-pg' import { renderPdf } from './pdf' import { getClient } from './repos-clients' import { validateShare } from './repos-shares' @@ -154,7 +155,10 @@ export function mountPublicShare(app: express.Express, db: DB, deps: PublicShare export async function startServer(port: number, opts: { scheduler?: boolean } = {}): Promise { const app = express() - const db = openDb(process.env['HQ_DATA_DIR']) + // D19 engine selection: DATABASE_URL → Postgres (production); absent → SQLite file. + const pgUrl = process.env['DATABASE_URL'] ?? '' + const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) + if (pgUrl !== '') console.log('[db] engine: postgres') await seedIfEmpty(db) // first boot prints the owner password once app.locals['db'] = db app.use(express.json({ limit: '2mb' })) diff --git a/apps/hq/test/pg-engine.test.ts b/apps/hq/test/pg-engine.test.ts new file mode 100644 index 0000000..ab3747c --- /dev/null +++ b/apps/hq/test/pg-engine.test.ts @@ -0,0 +1,121 @@ +// apps/hq/test/pg-engine.test.ts — D19 P4: the same repos, on REAL Postgres. +// Opt-in: set HQ_PG_TEST_URL (e.g. postgres://hq:hq_dev@localhost:5432/hq_test). +// The target database is WIPED each run (drop schema public cascade) — point it +// at a dedicated test database, never at hq/production. +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { openPgDb } from '../src/db-pg' +import type { DB } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createStaff, login } from '../src/auth' +import { createClient, listClients, setClientDbPassword, updateClient } from '../src/repos-clients' +import { createModule, setPrice } from '../src/repos-modules' +import { createDraft, getDocument, issueDocument } from '../src/repos-documents' +import { outstandingPaise, recordPayment } from '../src/repos-payments' +import { upsertReminder } from '../src/repos-reminders' +import { startServer } from '../src/server' + +const URL_ = process.env['HQ_PG_TEST_URL'] ?? '' + +describe.skipIf(URL_ === '')('Postgres engine (D19) — core flows on the real thing', () => { + let db: DB + + beforeAll(async () => { + // Fresh schema every run: migrations must build the world from zero. + const wipe = await openPgDb(URL_) // may apply migrations to a stale schema first + await wipe.exec(`DROP SCHEMA public CASCADE; CREATE SCHEMA public;`) + await wipe.close() + db = await openPgDb(URL_) + await seedIfEmpty(db) + await createStaff(db, { email: 'pg@test.in', displayName: 'PG', role: 'owner', password: 'pg-password-1' }) + }, 30_000) + + afterAll(async () => { await db.close() }) + + it('is the postgres engine with migrations recorded', async () => { + expect(db.engine).toBe('postgres') + const migs = await db.all<{ id: string }>(`SELECT id FROM schema_migrations ORDER BY id`) + expect(migs.map((m) => m.id)).toContain('001-init') + }) + + it('walks quotation-less invoice → issue (series + due date) → payment → paid, in integer paise', async () => { + const staffId = (await db.get<{ id: string }>(`SELECT id FROM staff_user WHERE email='pg@test.in'`))!.id + const c = await createClient(db, staffId, { name: 'PG Bank', stateCode: '32' }) + const m = await createModule(db, staffId, { code: 'CBS', name: 'Core Banking' }) + await setPrice(db, staffId, { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) + const draft = await createDraft(db, staffId, { + docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], + }) + const inv = await issueDocument(db, staffId, draft.id) + expect(inv.docNo).toMatch(/^INV\//) // series numbering works on pg + expect(typeof inv.payablePaise).toBe('number') // bigint arrives as a JS number + expect(inv.payablePaise).toBe(11_800_00) // 10,000 + 18% GST, to the paisa + expect(inv.dueDate).not.toBeNull() // D18 terms stamp fires on pg too + + await recordPayment(db, staffId, { + clientId: c.id, receivedOn: inv.docDate, mode: 'bank', amountPaise: 11_800_00, + allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }], + }) + expect(await outstandingPaise(db, inv.id)).toBe(0) + expect((await getDocument(db, inv.id))!.status).toBe('paid') + }) + + it('reminder upsert is exactly-once via ON CONFLICT', async () => { + const c = (await listClients(db))[0]! + const first = await upsertReminder(db, { ruleKind: 'follow_up', subjectId: 'pg-x', duePeriod: '2026-07', clientId: c.id }) + const second = await upsertReminder(db, { ruleKind: 'follow_up', subjectId: 'pg-x', duePeriod: '2026-07', clientId: c.id }) + expect(first.created).toBe(true) + expect(second.created).toBe(false) + expect(second.id).toBe(first.id) + }) + + it('rolls back atomically, with savepoint-nested inner transactions', async () => { + // Outer txn commits; a caught inner failure rolls back only its savepoint. + await db.transaction(async () => { + await db.run(`INSERT INTO setting (key, value) VALUES ('pg.outer', '1') ON CONFLICT (key) DO NOTHING`) + await db.transaction(async () => { + await db.run(`INSERT INTO setting (key, value) VALUES ('pg.inner', '1') ON CONFLICT (key) DO NOTHING`) + throw new Error('inner boom') + }).catch(() => undefined) + }) + expect(await db.get(`SELECT value FROM setting WHERE key='pg.outer'`)).toBeDefined() + expect(await db.get(`SELECT value FROM setting WHERE key='pg.inner'`)).toBeUndefined() + + // A failing write mid-transaction leaves nothing behind (PATCH-style mixed write). + const staffId = (await db.get<{ id: string }>(`SELECT id FROM staff_user WHERE email='pg@test.in'`))!.id + const c = (await listClients(db))[0]! + await expect(db.transaction(async () => { + await setClientDbPassword(db, staffId, c.id, 'secret-pw', '11'.repeat(32)) + await updateClient(db, staffId, c.id, { gstin: 'NOT-A-GSTIN' }) // throws + })).rejects.toThrow(/GSTIN/i) + const row = (await db.get<{ db_password_enc: string | null }>( + `SELECT db_password_enc FROM client WHERE id=?`, c.id, + ))! + expect(row.db_password_enc).toBeNull() // the password write rolled back with it + }) + + it('boots the HTTP server on DATABASE_URL and serves real requests', async () => { + const prev = process.env['DATABASE_URL'] + process.env['DATABASE_URL'] = URL_ + const server = await startServer(0) + try { + const port = (server.address() as { port: number }).port + const base = `http://localhost:${port}/api` + const health = await (await fetch(`${base}/health`)).json() as { ok: boolean } + expect(health.ok).toBe(true) + const auth = await (await fetch(`${base}/auth/login`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: 'pg@test.in', password: 'pg-password-1' }), + })).json() as { ok: boolean; token: string } + expect(auth.ok).toBe(true) + const clients = await (await fetch(`${base}/clients`, { + headers: { authorization: `Bearer ${auth.token}` }, + })).json() as { ok: boolean; clients: unknown[] } + expect(clients.ok).toBe(true) + expect(clients.clients.length).toBeGreaterThanOrEqual(1) + } finally { + server.close() + if (prev === undefined) delete process.env['DATABASE_URL'] + else process.env['DATABASE_URL'] = prev + } + }, 30_000) +})