feat(db): D19 P4 — DATABASE_URL engine selection + Postgres integration suite

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 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 5f9f961d20
commit 95df5cb5b9

@ -7,6 +7,7 @@ import { apiRouter } from './api'
import { maybePullAwsCosts } from './aws-costs' import { maybePullAwsCosts } from './aws-costs'
import { pollBounces } from './bounces' import { pollBounces } from './bounces'
import { openDb, type DB } from './db' import { openDb, type DB } from './db'
import { openPgDb } from './db-pg'
import { renderPdf } from './pdf' import { renderPdf } from './pdf'
import { getClient } from './repos-clients' import { getClient } from './repos-clients'
import { validateShare } from './repos-shares' 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<http.Server> { export async function startServer(port: number, opts: { scheduler?: boolean } = {}): Promise<http.Server> {
const app = express() 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 await seedIfEmpty(db) // first boot prints the owner password once
app.locals['db'] = db app.locals['db'] = db
app.use(express.json({ limit: '2mb' })) app.use(express.json({ limit: '2mb' }))

@ -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)
})
Loading…
Cancel
Save