// apps/hq/test/milestone-document.test.ts — Client Detail redesign task 7: the quotation // step (`quotation_sent`) can link the quotation / proforma document that was actually sent. import express from 'express' import { describe, it, expect, afterAll } from 'vitest' import { openDb } from '../src/db' import { seedIfEmpty } from '../src/seed' import { apiRouter } from '../src/api' import { createStaff } from '../src/auth' import { createClient } from '../src/repos-clients' import { createModule, setPrice, assignModule } from '../src/repos-modules' import { createDraft } from '../src/repos-documents' import { setMilestone, listProjectMilestones } from '../src/repos-milestones' const KEY = '11'.repeat(32) async function world() { const db = openDb(':memory:') await seedIfEmpty(db) const c = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' }) const m = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2020-01-01' }) const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) const quote = async (clientId: string) => createDraft(db, 'u1', { docType: 'QUOTATION', clientId, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], }) return { db, clientId: c.id, cmId: cm.id, quote } } const step = async (db: Awaited>['db'], cmId: string) => (await listProjectMilestones(db, cmId)).find((s) => s.key === 'quotation_sent')! describe('milestone document_id link (quotation step)', () => { it('linking a quotation stores document_id on the ticked step', async () => { const { db, clientId, cmId, quote } = await world() const doc = await quote(clientId) await setMilestone(db, 'u1', cmId, 'quotation_sent', true, '2026-05-01', undefined, doc.id) const s = await step(db, cmId) expect(s.done).toBe(true) expect(s.doneOn).toBe('2026-05-01') expect(s.documentId).toBe(doc.id) }) it('rejects a document that belongs to a different client', async () => { const { db, cmId, quote } = await world() const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' }) const foreign = await quote(other.id) await expect( setMilestone(db, 'u1', cmId, 'quotation_sent', true, undefined, undefined, foreign.id), ).rejects.toThrow(/document/i) // …and nothing was written: the step is still pending. const s = await step(db, cmId) expect(s.done).toBe(false) expect(s.documentId).toBeNull() }) it('unticking clears document_id along with done_on', async () => { const { db, clientId, cmId, quote } = await world() const doc = await quote(clientId) await setMilestone(db, 'u1', cmId, 'quotation_sent', true, undefined, undefined, doc.id) expect((await step(db, cmId)).documentId).toBe(doc.id) await setMilestone(db, 'u1', cmId, 'quotation_sent', false) const s = await step(db, cmId) expect(s.done).toBe(false) expect(s.doneOn).toBeNull() expect(s.documentId).toBeNull() }) it('ticking without a documentId leaves the link null (opt-in)', async () => { const { db, cmId } = await world() await setMilestone(db, 'u1', cmId, 'quotation_sent', true) const s = await step(db, cmId) expect(s.done).toBe(true) expect(s.documentId).toBeNull() }) it('audits the link in the same transaction as the tick', async () => { const { db, clientId, cmId, quote } = await world() const doc = await quote(clientId) await setMilestone(db, 'u1', cmId, 'quotation_sent', true, undefined, undefined, doc.id) const audits = await db.all<{ after_json: string | null }>( `SELECT after_json FROM audit_log WHERE action='set_milestone'`, ) expect(audits).toHaveLength(1) expect(audits[0]!.after_json).toContain(doc.id) }) }) describe('milestone route accepts documentId', () => { const servers: { close: () => void }[] = [] afterAll(() => { for (const s of servers) s.close() }) it('POST /client-modules/:id/milestones threads documentId through', async () => { const { db, clientId, cmId, quote } = await world() await createStaff(db, { email: 't@t.in', displayName: 'T', role: 'staff', password: 'password-1' }) const app = express(); app.use(express.json()); app.locals['db'] = db app.use('/api', apiRouter(db, { keyHex: KEY })) const server = app.listen(0); servers.push(server) const base = `http://localhost:${(server.address() as { port: number }).port}/api` const token = ((await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 't@t.in', password: 'password-1' }), })).json()) as { token: string }).token const H = { 'content-type': 'application/json', authorization: `Bearer ${token}` } const doc = await quote(clientId) const out = await (await fetch(`${base}/client-modules/${cmId}/milestones`, { method: 'POST', headers: H, body: JSON.stringify({ key: 'quotation_sent', done: true, documentId: doc.id }), })).json() as { ok: boolean; milestones: { key: string; documentId: string | null }[] } expect(out.ok).toBe(true) expect(out.milestones.find((m) => m.key === 'quotation_sent')!.documentId).toBe(doc.id) // A foreign document is a 400, not a silent link. const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' }) const foreign = await quote(other.id) const bad = await fetch(`${base}/client-modules/${cmId}/milestones`, { method: 'POST', headers: H, body: JSON.stringify({ key: 'quotation_sent', done: true, documentId: foreign.id }), }) expect(bad.status).toBe(400) }) })