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.
123 lines
5.7 KiB
TypeScript
123 lines
5.7 KiB
TypeScript
// 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<ReturnType<typeof world>>['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)
|
|
})
|
|
})
|