// apps/hq/test/migrate-onboarding-templates.test.ts — Client Detail redesign task 5: one-time // migration swapping seeded modules (SMS/RTGS/MobileApp) from the old generic onboarding // checklist onto their new composed (front + per-module tail) templates, carrying mapped // ticks (done/done_on/payment_id). import { describe, it, expect, beforeEach } from 'vitest' import { openDb, type DB } from '../src/db' import { seedIfEmpty } from '../src/seed' import { createClient } from '../src/repos-clients' import { createModule, assignModule } from '../src/repos-modules' import { migrateOnboardingTemplates } from '../src/migrate-onboarding-templates' import { listProjectMilestones, TAIL_FALLBACK } from '../src/repos-milestones' const FRONT_KEYS = ['enquiry', 'visit_meeting', 'quotation_sent', 'quotation_approved'] describe('onboarding template migration', () => { let db: DB beforeEach(async () => { db = openDb(':memory:'); await seedIfEmpty(db) }) async function makeSmsClientModule(): Promise { const client = await createClient(db, 'u1', { name: 'Kothavara SCB', stateCode: '32' }) const mod = await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' }) const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' }) // assignModule auto-seeds the NEW composed template (task 2/4) — wipe it so the test // can plant the OLD generic rows a legacy project would actually have on disk. await db.run(`DELETE FROM project_milestone WHERE client_module_id=?`, cm.id) return cm.id } async function seedOldGenericRows(cmId: string): Promise { const rows: [string, number, string | null][] = [ ['advance_paid', 0, null], ['setup_done', 0, null], ['installed', 1, '2026-03-25'], ['go_live', 1, '2026-04-02'], ['balance_paid', 0, null], ['amc_start', 0, null], ] for (const [i, r] of rows.entries()) { await db.run( `INSERT INTO project_milestone (id,client_module_id,key,label,done,done_on,sort) VALUES (?,?,?,?,?,?,?)`, `m${i}`, cmId, r[0], String(r[0]), r[1], r[2], i, ) } } it('swaps an SMS project to the composed front+tail list carrying installed+go_live ticks', async () => { const cmId = await makeSmsClientModule() await seedOldGenericRows(cmId) const res = await migrateOnboardingTemplates(db) expect(res.projects).toBe(1) expect(res.carried).toBe(2) // installed -> installation, go_live -> go_live expect(res.dropped).toBe(0) // the two dropped keys (setup_done, amc_start) were both undone const ms = await listProjectMilestones(db, cmId) expect(ms.map((m) => m.key)).toEqual([ ...FRONT_KEYS, 'document_collection', 'dlt_registration', 'account_creation', 'installation', 'testing', 'training', 'go_live', 'payment_received', ]) const installation = ms.find((m) => m.key === 'installation')! expect(installation.done).toBe(true) expect(installation.doneOn).toBe('2026-03-25') expect(installation.paymentId).toBeNull() const goLive = ms.find((m) => m.key === 'go_live')! expect(goLive.done).toBe(true) expect(goLive.doneOn).toBe('2026-04-02') // untouched (never ticked) steps stay pending, including the newly-prepended front expect(ms.find((m) => m.key === 'enquiry')!.done).toBe(false) expect(ms.find((m) => m.key === 'quotation_approved')!.done).toBe(false) expect(ms.find((m) => m.key === 'document_collection')!.done).toBe(false) expect(ms.find((m) => m.key === 'payment_received')!.done).toBe(false) // old setup_done / amc_start gone entirely expect(ms.some((m) => m.key === 'setup_done' || m.key === 'amc_start')).toBe(false) // audited const audits = await db.all<{ entity_id: string; after_json: string | null }>( `SELECT entity_id, after_json FROM audit_log WHERE action='migrate_onboarding'`, ) expect(audits).toHaveLength(1) expect(audits[0]!.entity_id).toBe(cmId) expect(audits[0]!.after_json).toContain('installation') }) it('carries a done payment_id along with the mapped tick (advance_paid -> payment_received)', async () => { const cmId = await makeSmsClientModule() const client = await db.get<{ client_id: string }>( `SELECT client_id FROM client_module WHERE id=?`, cmId, ) const paymentId = 'pay-1' await db.run( `INSERT INTO payment (id, client_id, received_on, mode, amount_paise, created_by, created_at) VALUES (?, ?, '2026-03-01', 'bank', 500000, 'u1', '2026-03-01T00:00:00.000Z')`, paymentId, client!.client_id, ) await db.run( `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, payment_id, sort) VALUES (?,?,?,?,?,?,?,?)`, 'm0', cmId, 'advance_paid', 'advance_paid', 1, '2026-03-01', paymentId, 0, ) await migrateOnboardingTemplates(db) const ms = await listProjectMilestones(db, cmId) const paymentReceived = ms.find((m) => m.key === 'payment_received')! expect(paymentReceived.done).toBe(true) expect(paymentReceived.doneOn).toBe('2026-03-01') expect(paymentReceived.paymentId).toBe(paymentId) }) it('balance_paid also maps to payment_received', async () => { const cmId = await makeSmsClientModule() await db.run( `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort) VALUES (?,?,?,?,?,?,?)`, 'm0', cmId, 'balance_paid', 'balance_paid', 1, '2026-04-10', 0, ) await migrateOnboardingTemplates(db) const ms = await listProjectMilestones(db, cmId) const paymentReceived = ms.find((m) => m.key === 'payment_received')! expect(paymentReceived.done).toBe(true) expect(paymentReceived.doneOn).toBe('2026-04-10') }) // The interim shape shipped mid-redesign split the payment step in two // (advance_payment / balance_payment) before it was collapsed into one // `payment_received` step. A DB written by that build must migrate too. it('maps the interim advance_payment key (with its payment link) to payment_received', async () => { const cmId = await makeSmsClientModule() const client = await db.get<{ client_id: string }>( `SELECT client_id FROM client_module WHERE id=?`, cmId, ) const paymentId = 'pay-interim' await db.run( `INSERT INTO payment (id, client_id, received_on, mode, amount_paise, created_by, created_at) VALUES (?, ?, '2026-06-01', 'bank', 250000, 'u1', '2026-06-01T00:00:00.000Z')`, paymentId, client!.client_id, ) await db.run( `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, payment_id, sort) VALUES (?,?,?,?,?,?,?,?)`, 'm0', cmId, 'advance_payment', 'Advance payment', 1, '2026-06-01', paymentId, 0, ) const res = await migrateOnboardingTemplates(db) expect(res.carried).toBe(1) expect(res.dropped).toBe(0) const ms = await listProjectMilestones(db, cmId) expect(ms.some((m) => m.key === 'advance_payment')).toBe(false) const paymentReceived = ms.find((m) => m.key === 'payment_received')! expect(paymentReceived.done).toBe(true) expect(paymentReceived.doneOn).toBe('2026-06-01') expect(paymentReceived.paymentId).toBe(paymentId) }) it('maps the interim balance_payment key to payment_received', async () => { const cmId = await makeSmsClientModule() await db.run( `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort) VALUES (?,?,?,?,?,?,?)`, 'm0', cmId, 'balance_payment', 'Balance payment', 1, '2026-06-20', 0, ) const res = await migrateOnboardingTemplates(db) expect(res.dropped).toBe(0) const ms = await listProjectMilestones(db, cmId) expect(ms.some((m) => m.key === 'balance_payment')).toBe(false) const paymentReceived = ms.find((m) => m.key === 'payment_received')! expect(paymentReceived.done).toBe(true) expect(paymentReceived.doneOn).toBe('2026-06-20') }) it('carries a quotation-step document link across the swap', async () => { const cmId = await makeSmsClientModule() const client = await db.get<{ client_id: string }>( `SELECT client_id FROM client_module WHERE id=?`, cmId, ) const documentId = 'doc-1' await db.run( `INSERT INTO document (id, doc_type, fy, client_id, doc_date, taxable_paise, cgst_paise, sgst_paise, igst_paise, round_off_paise, payable_paise, payload, created_by, created_at) VALUES (?, 'QUOTATION', '26-27', ?, '2026-05-01', 100000, 9000, 9000, 0, 0, 118000, '{"lines":[]}', 'u1', '2026-05-01T00:00:00.000Z')`, documentId, client!.client_id, ) await db.run( `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, document_id, sort) VALUES (?,?,?,?,?,?,?,?)`, 'm0', cmId, 'quotation_sent', 'Quotation sent', 1, '2026-05-01', documentId, 0, ) await migrateOnboardingTemplates(db) const quotationSent = (await listProjectMilestones(db, cmId)).find((m) => m.key === 'quotation_sent')! expect(quotationSent.done).toBe(true) expect(quotationSent.documentId).toBe(documentId) }) it('is idempotent (second run changes nothing new)', async () => { const cmId = await makeSmsClientModule() await seedOldGenericRows(cmId) const first = await migrateOnboardingTemplates(db) expect(first.projects).toBe(1) const afterFirst = await listProjectMilestones(db, cmId) const second = await migrateOnboardingTemplates(db) expect(second.projects).toBe(0) expect(second.carried).toBe(0) expect(second.dropped).toBe(0) const afterSecond = await listProjectMilestones(db, cmId) expect(afterSecond).toEqual(afterFirst) // no extra audit rows written on the no-op second pass const audits = await db.all<{ n: number }>( `SELECT COUNT(*) AS n FROM audit_log WHERE action='migrate_onboarding'`, ) expect((audits[0] as unknown as { n: number }).n).toBe(1) }) it('counts ticked dropped keys (unmapped or not in new template)', async () => { const cmId = await makeSmsClientModule() // Insert old generic rows with two ticked keys that will be dropped: // - amc_start: unmapped (not in KEY_MAP) // - setup_done: maps to 'setup_configuration', but SMS template doesn't have it // Plus one carried key (installed -> installation) to verify carried count too. const rows: [string, number, string | null][] = [ ['amc_start', 1, '2026-02-15'], // ticked, unmapped -> dropped ['setup_done', 1, '2026-03-01'], // ticked, maps to setup_configuration but SMS template lacks it -> dropped ['installed', 1, '2026-03-25'], // ticked, maps to installation which IS in SMS template -> carried ['advance_paid', 0, null], ['go_live', 0, null], ['balance_paid', 0, null], ] for (const [i, r] of rows.entries()) { await db.run( `INSERT INTO project_milestone (id,client_module_id,key,label,done,done_on,sort) VALUES (?,?,?,?,?,?,?)`, `m${i}`, cmId, r[0], String(r[0]), r[1], r[2], i, ) } const res = await migrateOnboardingTemplates(db) expect(res.projects).toBe(1) expect(res.carried).toBe(1) // only installed -> installation expect(res.dropped).toBe(2) // amc_start (unmapped) + setup_done (not in SMS template) // Verify the dropped keys are gone from the migrated template const ms = await listProjectMilestones(db, cmId) expect(ms.map((m) => m.key)).toEqual([ ...FRONT_KEYS, 'document_collection', 'dlt_registration', 'account_creation', 'installation', 'testing', 'training', 'go_live', 'payment_received', ]) expect(ms.some((m) => m.key === 'amc_start' || m.key === 'setup_done')).toBe(false) // Verify the carried installation tick is preserved const installation = ms.find((m) => m.key === 'installation')! expect(installation.done).toBe(true) expect(installation.doneOn).toBe('2026-03-25') }) it('leaves a module without a per-module template untouched', async () => { const client = await createClient(db, 'u1', { name: 'Other Society', stateCode: '32' }) const mod = await createModule(db, 'u1', { code: 'NOTEMPLATE', name: 'No Template Module' }) const cm = await assignModule(db, 'u1', { clientId: client.id, moduleId: mod.id, kind: 'yearly' }) // This module has no project.milestone_template:NOTEMPLATE setting, so ensureProjectMilestones // composed the front with the code TAIL_FALLBACK (no global 'project.milestone_template' is // seeded any more either). const before = await listProjectMilestones(db, cm.id) expect(before.map((m) => m.key)).toEqual([...FRONT_KEYS, ...TAIL_FALLBACK.map((t) => t.key)]) const res = await migrateOnboardingTemplates(db) expect(res.projects).toBe(0) const after = await listProjectMilestones(db, cm.id) expect(after).toEqual(before) }) // Final whole-branch review FIX 3: an ad-hoc project-specific step (`+ Add step` / // addProjectMilestone, `custom_*` key) must survive the swap — ticked or not — instead of // being silently deleted because it's in neither templateKeys nor KEY_MAP. it('preserves ad-hoc custom steps (both ticked and un-ticked), appended after the template, with state intact', async () => { const cmId = await makeSmsClientModule() await seedOldGenericRows(cmId) // baseline so the project isn't already "identical" (forces a rebuild) await db.run( `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort) VALUES (?,?,?,?,?,?,?)`, 'custom-ticked', cmId, 'custom_ab12cd34', 'AWS region confirmed', 1, '2026-05-01', 10, ) await db.run( `INSERT INTO project_milestone (id, client_module_id, key, label, done, done_on, sort) VALUES (?,?,?,?,?,?,?)`, 'custom-unticked', cmId, 'custom_ef56gh78', 'Client-specific step', 0, null, 11, ) const res = await migrateOnboardingTemplates(db) expect(res.preserved).toBe(2) // Neither custom row counts as dropped, even though the ticked one is done=1. expect(res.dropped).toBe(0) const ms = await listProjectMilestones(db, cmId) const ticked = ms.find((m) => m.key === 'custom_ab12cd34') expect(ticked).toMatchObject({ label: 'AWS region confirmed', done: true, doneOn: '2026-05-01' }) const unticked = ms.find((m) => m.key === 'custom_ef56gh78') expect(unticked).toMatchObject({ label: 'Client-specific step', done: false, doneOn: null }) // Appended after the whole template block, sort continuing past it (deterministic order: // the ticked one was inserted before the un-ticked one). expect(ms.slice(-2).map((m) => m.key)).toEqual(['custom_ab12cd34', 'custom_ef56gh78']) // Reflected in the audit's `after` payload. const audits = await db.all<{ after_json: string | null }>( `SELECT after_json FROM audit_log WHERE action='migrate_onboarding' AND entity_id=?`, cmId, ) expect(audits).toHaveLength(1) expect(audits[0]!.after_json).toContain('custom_ab12cd34') expect(audits[0]!.after_json).toContain('custom_ef56gh78') }) it('a known old-generic key with no forward mapping (amc_start) is still dropped, not treated as custom', async () => { const cmId = await makeSmsClientModule() await seedOldGenericRows(cmId) // includes ticked 'installed'/'go_live' and un-ticked 'amc_start' const res = await migrateOnboardingTemplates(db) expect(res.preserved).toBe(0) const ms = await listProjectMilestones(db, cmId) expect(ms.some((m) => m.key === 'amc_start')).toBe(false) }) })