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.
114 lines
5.7 KiB
TypeScript
114 lines
5.7 KiB
TypeScript
// apps/hq/test/milestone-link-preservation.test.ts — re-ticking an already-done step must not
|
|
// wipe the payment / document link it already carries.
|
|
//
|
|
// The bug: setMilestone wrote `payment_id`/`document_id` as `done ? linked : null`, so any tick
|
|
// that didn't resend the ids cleared them. bulkSetMilestone routes through setMilestone and
|
|
// never passes ids at all (there is one key for many projects), so a single bulk re-tick — the
|
|
// onboarding-cleanup tool — silently cut every project's step loose from the payment or
|
|
// quotation it had been linked to one at a time. Unticking must still clear both.
|
|
import { describe, it, expect } from 'vitest'
|
|
import { openDb } from '../src/db'
|
|
import { seedIfEmpty } from '../src/seed'
|
|
import { createClient } from '../src/repos-clients'
|
|
import { createModule, setPrice, assignModule } from '../src/repos-modules'
|
|
import { createDraft } from '../src/repos-documents'
|
|
import { recordPayment } from '../src/repos-payments'
|
|
import { bulkSetMilestone, listProjectMilestones, setMilestone } from '../src/repos-milestones'
|
|
|
|
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 step = async (key: string) => (await listProjectMilestones(db, cm.id)).find((s) => s.key === key)!
|
|
return { db, clientId: c.id, moduleId: m.id, cmId: cm.id, step }
|
|
}
|
|
|
|
describe('bulk re-tick preserves the payment link (5a)', () => {
|
|
it('tick with a payment link → bulk re-tick keeps it → bulk untick clears it', async () => {
|
|
const { db, clientId, cmId, step } = await world()
|
|
const pay = await recordPayment(db, 'u1', {
|
|
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
|
|
})
|
|
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
|
|
expect((await step('payment_received')).paymentId).toBe(pay.payment.id)
|
|
|
|
// The bulk tool carries no ids — the stored link must survive.
|
|
const res = await bulkSetMilestone(db, 'u1', [cmId], 'payment_received', true, '2026-06-01')
|
|
expect(res.updated).toBe(1)
|
|
const after = await step('payment_received')
|
|
expect(after.done).toBe(true)
|
|
expect(after.paymentId).toBe(pay.payment.id)
|
|
|
|
// Unticking still cuts the link loose.
|
|
await bulkSetMilestone(db, 'u1', [cmId], 'payment_received', false)
|
|
const cleared = await step('payment_received')
|
|
expect(cleared.done).toBe(false)
|
|
expect(cleared.doneOn).toBeNull()
|
|
expect(cleared.paymentId).toBeNull()
|
|
})
|
|
|
|
it('a single re-tick that sends no ids keeps the link too', async () => {
|
|
const { db, clientId, cmId, step } = await world()
|
|
const pay = await recordPayment(db, 'u1', {
|
|
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
|
|
})
|
|
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
|
|
await setMilestone(db, 'u1', cmId, 'payment_received', true, '2026-06-01')
|
|
const s = await step('payment_received')
|
|
expect(s.doneOn).toBe('2026-06-01') // the date the caller did send still moves
|
|
expect(s.paymentId).toBe(pay.payment.id)
|
|
})
|
|
|
|
it('an explicit empty id is still an unlink, not a preserve', async () => {
|
|
const { db, clientId, cmId, step } = await world()
|
|
const pay = await recordPayment(db, 'u1', {
|
|
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
|
|
})
|
|
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
|
|
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, '')
|
|
expect((await step('payment_received')).paymentId).toBeNull()
|
|
})
|
|
|
|
it('the preserved link is what the audit row reports', async () => {
|
|
const { db, clientId, cmId } = await world()
|
|
const pay = await recordPayment(db, 'u1', {
|
|
clientId, receivedOn: '2026-05-01', mode: 'bank', amountPaise: 1000_00,
|
|
})
|
|
await setMilestone(db, 'u1', cmId, 'payment_received', true, undefined, pay.payment.id)
|
|
await bulkSetMilestone(db, 'u1', [cmId], 'payment_received', true, '2026-06-01')
|
|
const audits = await db.all<{ after_json: string | null }>(
|
|
`SELECT after_json FROM audit_log WHERE action='set_milestone' ORDER BY id`,
|
|
)
|
|
expect(JSON.parse(audits.at(-1)!.after_json!)).toMatchObject({ paymentId: pay.payment.id })
|
|
})
|
|
})
|
|
|
|
describe('bulk re-tick preserves the document link (5a)', () => {
|
|
it('tick with a quotation link → bulk re-tick keeps it → untick clears it', async () => {
|
|
const { db, clientId, moduleId, cmId, step } = await world()
|
|
const doc = await createDraft(db, 'u1', {
|
|
docType: 'QUOTATION', clientId, lines: [{ moduleId, qty: 1, kind: 'yearly' }],
|
|
})
|
|
await setMilestone(db, 'u1', cmId, 'quotation_sent', true, '2026-05-01', undefined, doc.id)
|
|
expect((await step('quotation_sent')).documentId).toBe(doc.id)
|
|
|
|
await bulkSetMilestone(db, 'u1', [cmId], 'quotation_sent', true, '2026-06-01')
|
|
expect((await step('quotation_sent')).documentId).toBe(doc.id)
|
|
|
|
await bulkSetMilestone(db, 'u1', [cmId], 'quotation_sent', false)
|
|
expect((await step('quotation_sent')).documentId).toBeNull()
|
|
})
|
|
|
|
it('a step that was never done still starts with no link (nothing to preserve)', async () => {
|
|
const { db, cmId, step } = await world()
|
|
await bulkSetMilestone(db, 'u1', [cmId], 'quotation_sent', true, '2026-06-01')
|
|
const s = await step('quotation_sent')
|
|
expect(s.done).toBe(true)
|
|
expect(s.documentId).toBeNull()
|
|
expect(s.paymentId).toBeNull()
|
|
})
|
|
})
|