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.
183 lines
8.5 KiB
TypeScript
183 lines
8.5 KiB
TypeScript
// 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 per-module 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 } from '../src/repos-milestones'
|
|
|
|
describe('onboarding template migration', () => {
|
|
let db: DB
|
|
beforeEach(async () => { db = openDb(':memory:'); await seedIfEmpty(db) })
|
|
|
|
async function makeSmsClientModule(): Promise<string> {
|
|
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 per-module 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<void> {
|
|
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 9-step 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([
|
|
'document_collection', 'dlt_registration', 'account_creation', 'installation',
|
|
'testing', 'training', 'go_live', 'advance_payment', 'balance_payment',
|
|
])
|
|
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
|
|
expect(ms.find((m) => m.key === 'document_collection')!.done).toBe(false)
|
|
expect(ms.find((m) => m.key === 'advance_payment')!.done).toBe(false)
|
|
expect(ms.find((m) => m.key === 'balance_payment')!.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', 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 advance = ms.find((m) => m.key === 'advance_payment')!
|
|
expect(advance.done).toBe(true)
|
|
expect(advance.doneOn).toBe('2026-03-01')
|
|
expect(advance.paymentId).toBe(paymentId)
|
|
})
|
|
|
|
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([
|
|
'document_collection', 'dlt_registration', 'account_creation', 'installation',
|
|
'testing', 'training', 'go_live', 'advance_payment', 'balance_payment',
|
|
])
|
|
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
|
|
// fell back to the global generic template — the OLD generic rows themselves.
|
|
const before = await listProjectMilestones(db, cm.id)
|
|
expect(before.map((m) => m.key)).toEqual([
|
|
'advance_paid', 'setup_done', 'installed', 'go_live', 'balance_paid', 'amc_start',
|
|
])
|
|
|
|
const res = await migrateOnboardingTemplates(db)
|
|
expect(res.projects).toBe(0)
|
|
|
|
const after = await listProjectMilestones(db, cm.id)
|
|
expect(after).toEqual(before)
|
|
})
|
|
})
|