You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
122 lines
6.3 KiB
TypeScript
122 lines
6.3 KiB
TypeScript
// 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)
|
|
})
|