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.
sims-hq/apps/hq/test/convert-and-send.test.ts

195 lines
9.5 KiB
TypeScript

// apps/hq/test/convert-and-send.test.ts — Phases 7+8: one-click convert & send, proforma supersede (spec §8)
import express from 'express'
import { describe, it, expect, afterAll } from 'vitest'
import { openDb, type DB } 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 {
createDraft, getDocument, issueDocument, listDocuments, supersedeProforma,
} from '../src/repos-documents'
import { saveAccount } from '../src/repos-email'
import { encrypt } from '../src/crypto'
import { apiRouter } from '../src/api'
const KEY = '11'.repeat(32)
const fakePdf = async () => Buffer.from('%PDF-fake')
async function base(db: DB) {
await seedIfEmpty(db)
const c = await createClient(db, 'u1', {
name: 'Acme Bank', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }],
})
const m = await createModule(db, 'u1', { code: 'CORE', name: 'Core Banking' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
return { c, m }
}
// ---------- supersedeProforma (repo) ----------
describe('supersedeProforma', () => {
it('cancels an issued proforma (number stays consumed) and opens a linked draft carrying the payload', async () => {
const db = openDb(':memory:')
const { c, m } = await base(db)
const pi = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }],
})).id)
const fresh = await supersedeProforma(db, 'u1', pi.id)
const old = (await getDocument(db, pi.id))!
expect(old.status).toBe('cancelled')
expect(old.docNo).toBe(pi.docNo) // consumed number stays on the corpse
expect(fresh.docType).toBe('PROFORMA')
expect(fresh.status).toBe('draft')
expect(fresh.docNo).toBeNull()
expect(fresh.refDocId).toBe(pi.id)
expect(fresh.payload.lines).toEqual(pi.payload.lines) // carried verbatim
expect(fresh.payablePaise).toBe(pi.payablePaise)
})
it('supersedes an unissued proforma draft too (retired, no number involved)', async () => {
const db = openDb(':memory:')
const { c, m } = await base(db)
const draft = await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
})
const fresh = await supersedeProforma(db, 'u1', draft.id)
expect((await getDocument(db, draft.id))!.status).toBe('cancelled')
expect(fresh.refDocId).toBe(draft.id)
})
it('hard-rejects an INVOICE with credit-note guidance, and non-proformas generally', async () => {
const db = openDb(':memory:')
const { c, m } = await base(db)
const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
})).id)
await expect(supersedeProforma(db, 'u1', inv.id))
.rejects.toThrow(/immutable.*credit note/i)
const qt = await createDraft(db, 'u1', {
docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
})
await expect(supersedeProforma(db, 'u1', qt.id)).rejects.toThrow(/only proformas/i)
})
it('rejects an already-cancelled proforma and one with a live forward child', async () => {
const db = openDb(':memory:')
const { c, m } = await base(db)
const pi = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
})).id)
await supersedeProforma(db, 'u1', pi.id)
await expect(supersedeProforma(db, 'u1', pi.id)).rejects.toThrow(/already cancelled/i)
})
})
// ---------- POST /documents/:id/convert-and-send (route) ----------
async function appWith(fetchImpl: typeof fetch) {
const db = openDb(':memory:')
const { c, m } = await base(db)
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
await saveAccount(db, 'us@sims.com', encrypt('refresh-token', KEY))
const pi = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
})).id)
const app = express(); app.use(express.json()); app.locals['db'] = db
app.use('/api', apiRouter(db, { f: fetchImpl, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, fakePdf))
const server = app.listen(0)
const baseUrl = `http://localhost:${(server.address() as { port: number }).port}/api`
return { db, server, baseUrl, pi, c }
}
const okFetch = (async (url: string) =>
new Response(JSON.stringify(String(url).includes('/token') ? { access_token: 'at' } : { id: 'gmsg-1' }),
{ status: 200 })) as typeof fetch
const sendFailFetch = (async (url: string) =>
String(url).includes('/token')
? new Response(JSON.stringify({ access_token: 'at' }), { status: 200 })
: new Response('{}', { status: 500 })) as typeof fetch
async function loginAndPost(baseUrl: string, path: string, body?: unknown) {
const token = (await (await fetch(`${baseUrl}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }),
})).json() as { token: string }).token
const res = await fetch(`${baseUrl}${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify(body ?? {}),
})
return { status: res.status, json: await res.json() as Record<string, unknown> & { document?: { id: string } } }
}
describe('POST /documents/:id/convert-and-send', () => {
const servers: { close: () => void }[] = []
afterAll(() => { for (const s of servers) s.close() })
it('happy path: one call → issued INVOICE, proforma flipped invoiced, email logged sent, invoice marked sent', async () => {
const ctx = await appWith(okFetch); servers.push(ctx.server)
const out = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`)
expect(out.status).toBe(200)
const invoice = (await getDocument(ctx.db, out.json.document!.id))!
expect(invoice.docType).toBe('INVOICE')
expect(invoice.docNo).toMatch(/^INV\//)
expect(invoice.status).toBe('sent') // sendDocumentEmail flips draft→sent
expect(invoice.refDocId).toBe(ctx.pi.id)
expect((await getDocument(ctx.db, ctx.pi.id))!.status).toBe('invoiced')
const log = (await ctx.db.get<{ status: string; document_id: string; to_addr: string }>(
`SELECT status, document_id, to_addr FROM email_log ORDER BY id DESC LIMIT 1`,
))!
expect(log).toMatchObject({ status: 'sent', document_id: invoice.id, to_addr: 'ravi@acme.in' })
})
it('double-click cannot mint a second invoice: retry 400s, exactly one live INVOICE exists', async () => {
const ctx = await appWith(okFetch); servers.push(ctx.server)
await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`)
const again = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`)
expect(again.status).toBe(400)
const invoices = (await listDocuments(ctx.db, { type: 'INVOICE' })).documents.filter((d) => d.status !== 'cancelled')
expect(invoices).toHaveLength(1)
})
it('a failed send leaves the issued invoice intact and returns a warning', async () => {
const ctx = await appWith(sendFailFetch); servers.push(ctx.server)
const out = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`)
expect(out.status).toBe(200)
expect(String(out.json['warning'])).toMatch(/email failed/i)
const invoice = (await getDocument(ctx.db, out.json.document!.id))!
expect(invoice.docNo).toMatch(/^INV\//) // number assigned and kept
expect(invoice.status).toBe('draft') // send never happened; nothing rolled back
const log = (await ctx.db.get<{ status: string }>(`SELECT status FROM email_log ORDER BY id DESC LIMIT 1`))!
expect(log.status).toBe('failed')
})
it('guards fire BEFORE any write: gmail disconnected → 409 and no invoice created', async () => {
const ctx = await appWith(okFetch); servers.push(ctx.server)
await ctx.db.run(`DELETE FROM email_account`) // disconnect
const out = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`)
expect(out.status).toBe(409)
expect((await listDocuments(ctx.db, { type: 'INVOICE' })).documents).toHaveLength(0)
expect((await getDocument(ctx.db, ctx.pi.id))!.status).not.toBe('invoiced')
})
it('rejects non-proformas up front', async () => {
const ctx = await appWith(okFetch); servers.push(ctx.server)
const qt = await createDraft(ctx.db, 'u1', {
docType: 'QUOTATION',
clientId: ctx.c.id,
lines: [{ moduleId: (await listDocuments(ctx.db, {})).documents[0]!.payload.lines[0]!.itemId, qty: 1, kind: 'yearly' }],
})
const out = await loginAndPost(ctx.baseUrl, `/documents/${qt.id}/convert-and-send`)
expect(out.status).toBe(400)
})
it('POST /documents/:id/supersede round-trips over HTTP', async () => {
const ctx = await appWith(okFetch); servers.push(ctx.server)
const out = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/supersede`)
expect(out.status).toBe(200)
expect((await getDocument(ctx.db, ctx.pi.id))!.status).toBe('cancelled')
const fresh = (await getDocument(ctx.db, out.json.document!.id))!
expect(fresh.docType).toBe('PROFORMA')
expect(fresh.refDocId).toBe(ctx.pi.id)
})
})