feat(hq): POST /documents/preview — permissive live preview, persists nothing
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
90df8b0e9f
commit
268a7469d3
@ -0,0 +1,97 @@
|
||||
// apps/hq/test/preview-route.test.ts
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import express from 'express'
|
||||
import { openDb } from '../src/db'
|
||||
import { seedIfEmpty } from '../src/seed'
|
||||
import { createStaff } from '../src/auth'
|
||||
import { createClient } from '../src/repos-clients'
|
||||
import { createModule, setPrice } from '../src/repos-modules'
|
||||
import { apiRouter } from '../src/api'
|
||||
|
||||
function appWith() {
|
||||
const db = openDb(':memory:'); seedIfEmpty(db) // company.state_code=32, GST18
|
||||
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
|
||||
const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' })
|
||||
const kar = createClient(db, 'u1', { name: 'BLR Co', code: 'BLR', stateCode: '29' })
|
||||
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
|
||||
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
|
||||
const noPrice = createModule(db, 'u1', { code: 'ANALYTICS', name: 'Analytics' })
|
||||
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
|
||||
const server = app.listen(0)
|
||||
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
|
||||
return { db, server, base, c, kar, m, noPrice }
|
||||
}
|
||||
|
||||
describe('POST /documents/preview', () => {
|
||||
const ctx = appWith()
|
||||
afterAll(() => ctx.server.close())
|
||||
const login = async () =>
|
||||
(await (await fetch(`${ctx.base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }) })).json() as any).token
|
||||
const preview = async (token: string | null, body: unknown) => {
|
||||
const res = await fetch(`${ctx.base}/documents/preview`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return { status: res.status, json: await res.json() as any }
|
||||
}
|
||||
const save = async (token: string, body: unknown) => {
|
||||
const res = await fetch(`${ctx.base}/documents`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify(body),
|
||||
})
|
||||
return { status: res.status, json: await res.json() as any }
|
||||
}
|
||||
|
||||
it('requires auth', async () => {
|
||||
expect((await preview(null, { docType: 'QUOTATION', clientId: ctx.c.id, lines: [] })).status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns real letterhead HTML + server totals for a priced line', async () => {
|
||||
const token = await login()
|
||||
const out = await preview(token, { docType: 'INVOICE', clientId: ctx.c.id, lines: [{ moduleId: ctx.m.id, qty: 1, kind: 'yearly' }] })
|
||||
expect(out.status).toBe(200)
|
||||
expect(out.json.ok).toBe(true)
|
||||
expect(out.json.html).toContain('TAX INVOICE')
|
||||
expect(out.json.html).toContain('DRAFT') // unsaved → docNo prints DRAFT
|
||||
expect(out.json.totals.payablePaise).toBe(11_800_00)
|
||||
expect(out.json.totals.cgstPaise).toBe(900_00)
|
||||
expect(out.json.warnings).toEqual([])
|
||||
// Persisted nothing:
|
||||
expect((ctx.db.prepare(`SELECT COUNT(*) AS n FROM document`).get() as any).n).toBe(0)
|
||||
expect((ctx.db.prepare(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='document'`).get() as any).n).toBe(0)
|
||||
})
|
||||
|
||||
it('shows the IGST split live for an out-of-state client', async () => {
|
||||
const token = await login()
|
||||
const out = await preview(token, { docType: 'INVOICE', clientId: ctx.kar.id, lines: [{ moduleId: ctx.m.id, qty: 1, kind: 'yearly' }] })
|
||||
expect(out.json.totals.igstPaise).toBe(1_800_00)
|
||||
expect(out.json.totals.cgstPaise).toBe(0)
|
||||
})
|
||||
|
||||
it('PERMISSIVE preview vs STRICT save diverge on a price gap', async () => {
|
||||
const token = await login()
|
||||
const body = { docType: 'QUOTATION', clientId: ctx.c.id, lines: [{ moduleId: ctx.noPrice.id, qty: 1, kind: 'yearly' }] }
|
||||
const pv = await preview(token, body)
|
||||
expect(pv.status).toBe(200)
|
||||
expect(pv.json.totals.payablePaise).toBe(0) // forgiven at ₹0
|
||||
expect(pv.json.warnings.some((w: string) => /ANALYTICS/.test(w))).toBe(true)
|
||||
const sv = await save(token, body)
|
||||
expect(sv.status).toBe(400) // save refuses
|
||||
expect(sv.json.error).toMatch(/No price for module ANALYTICS/)
|
||||
})
|
||||
|
||||
it('previews with no client picked — placeholder letterhead, GST total still authoritative', async () => {
|
||||
const token = await login()
|
||||
const out = await preview(token, { docType: 'QUOTATION', clientId: '', lines: [{ moduleId: ctx.m.id, qty: 1, kind: 'yearly' }] })
|
||||
expect(out.status).toBe(200)
|
||||
expect(out.json.html).toContain('— pick a client —')
|
||||
expect(out.json.totals.payablePaise).toBe(11_800_00) // 18% invariant
|
||||
expect(out.json.warnings.some((w: string) => /client/i.test(w))).toBe(true)
|
||||
})
|
||||
|
||||
it('empty composer previews an empty table at ₹0', async () => {
|
||||
const token = await login()
|
||||
const out = await preview(token, { docType: 'QUOTATION', clientId: ctx.c.id, lines: [] })
|
||||
expect(out.status).toBe(200)
|
||||
expect(out.json.totals.payablePaise).toBe(0)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue