// apps/hq/test/onboarding-template-routes.test.ts — final whole-branch review FIX 4: the // owner onboarding-template editor surface (normalizeTemplateSteps / setFrontTemplate / // setModuleTail / getFrontTemplate / getModuleTail + the four routes) had no direct test // coverage. Also exercises the cross-half (front <-> tail) key collision rejection added by // FIX 1, at both the repo level and over HTTP. import express from 'express' import { describe, it, expect, afterAll } from 'vitest' import { openDb, type DB } from '../src/db' import { seedIfEmpty } from '../src/seed' import { apiRouter } from '../src/api' import { createStaff } from '../src/auth' import { createModule } from '../src/repos-modules' import { normalizeTemplateSteps, setFrontTemplate, setModuleTail, getFrontTemplate, getModuleTail, FRONT_FALLBACK, TAIL_FALLBACK, } from '../src/repos-milestones' const KEY = '33'.repeat(32) describe('normalizeTemplateSteps (pure validation)', () => { it('rejects a non-array or empty body', () => { expect(() => normalizeTemplateSteps(undefined)).toThrow(/at least one/i) expect(() => normalizeTemplateSteps('nope')).toThrow(/at least one/i) expect(() => normalizeTemplateSteps([])).toThrow(/at least one/i) }) it('rejects a blank label', () => { expect(() => normalizeTemplateSteps([{ label: ' ' }])).toThrow(/label/i) expect(() => normalizeTemplateSteps([{ key: 'a', label: 'A' }, { label: '' }])).toThrow(/label/i) }) it('rejects a duplicate explicit key', () => { expect(() => normalizeTemplateSteps([ { key: 'dup', label: 'First' }, { key: 'dup', label: 'Second' }, ])).toThrow(/Duplicate step key/) }) it('derives a key by slugifying the label when none is given, bumping on collision', () => { const out = normalizeTemplateSteps([ { label: 'Site Visit!' }, { label: 'site visit' }, { label: 'Site -- Visit' }, ]) expect(out).toEqual([ { key: 'site_visit', label: 'Site Visit!' }, { key: 'site_visit_2', label: 'site visit' }, { key: 'site_visit_3', label: 'Site -- Visit' }, ]) }) it('a key derived from a label collides with a later EXPLICIT key of the same value -> rejected', () => { expect(() => normalizeTemplateSteps([{ label: 'Go Live' }, { key: 'go_live', label: 'Go-live (explicit)' }])) .toThrow(/Duplicate step key/) }) }) describe('setFrontTemplate / setModuleTail / getFrontTemplate / getModuleTail (repo level)', () => { it('getFrontTemplate / getModuleTail fall back to the code defaults on an empty DB', async () => { const db = openDb(':memory:') expect(await getFrontTemplate(db)).toEqual(FRONT_FALLBACK) expect(await getModuleTail(db, 'SMS')).toEqual(TAIL_FALLBACK) }) it('setFrontTemplate rejects a key colliding with the default tail', async () => { const db = openDb(':memory:') await expect(setFrontTemplate(db, 'u1', [{ key: 'installed', label: 'Site installed' }])) .rejects.toThrow(/used by both the front/i) }) it('setFrontTemplate rejects a key colliding with an already-configured per-module tail', async () => { const db = openDb(':memory:') await setModuleTail(db, 'u1', 'SMS', [{ key: 'kickoff', label: 'Kickoff call' }]) await expect(setFrontTemplate(db, 'u1', [{ key: 'kickoff', label: 'Kickoff' }])) .rejects.toThrow(/SMS/) // The front setting was never written — still the fallback. expect(await getFrontTemplate(db)).toEqual(FRONT_FALLBACK) }) it('setModuleTail rejects a key colliding with the shared front', async () => { const db = openDb(':memory:') await expect(setModuleTail(db, 'u1', 'SMS', [{ key: 'enquiry', label: 'Enquiry (dup)' }])) .rejects.toThrow(/front/i) }) it('setModuleTail rejects a key colliding with a CUSTOM front (not just the code fallback)', async () => { const db = openDb(':memory:') await setFrontTemplate(db, 'u1', [{ key: 'lead_in', label: 'Lead in' }]) await expect(setModuleTail(db, 'u1', 'SMS', [{ key: 'lead_in', label: 'Also lead in' }])) .rejects.toThrow(/front/i) }) it('a non-colliding edit round-trips through the getters and is audited', async () => { const db = openDb(':memory:') const front = [{ key: 'lead_in', label: 'Lead in' }] const tail = [{ key: 'kickoff', label: 'Kickoff call' }, { key: 'wrapup', label: 'Wrap up' }] await setFrontTemplate(db, 'u1', front) await setModuleTail(db, 'u1', 'SMS', tail) expect(await getFrontTemplate(db)).toEqual(front) expect(await getModuleTail(db, 'SMS')).toEqual(tail) // A different, unconfigured module still gets the code fallback tail. expect(await getModuleTail(db, 'RTGS')).toEqual(TAIL_FALLBACK) const audited = await db.all<{ n: number }>( `SELECT COUNT(*) AS n FROM audit_log WHERE entity='setting' AND entity_id LIKE 'project.milestone_template%'`, ) expect((audited[0] as unknown as { n: number }).n).toBe(2) }) }) describe('onboarding-template routes (owner-only editor)', () => { const servers: { close: () => void }[] = [] afterAll(() => { for (const s of servers) s.close() }) async function httpWorld(): Promise<{ db: DB; base: string; ownerH: Record; staffH: Record }> { const db = openDb(':memory:') await seedIfEmpty(db) await createStaff(db, { email: 'owner2@t.in', displayName: 'Owner2', role: 'owner', password: 'owner-pass-1' }) await createStaff(db, { email: 'staff@t.in', displayName: 'Staff', role: 'staff', password: 'staff-pass-1' }) await createModule(db, 'u1', { code: 'SMS', name: 'Bulk SMS' }) // seedIfEmpty pre-seeds a `project.milestone_template:SMS` setting (the shipped SMS // pipeline), so use an UNconfigured module for tests that assert the code TAIL_FALLBACK. await createModule(db, 'u1', { code: 'WIDGET', name: 'Unconfigured Widget' }) 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 login = async (email: string, password: string): Promise> => { const token = ((await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }), })).json()) as { token: string }).token return { 'content-type': 'application/json', authorization: `Bearer ${token}` } } return { db, base, ownerH: await login('owner2@t.in', 'owner-pass-1'), staffH: await login('staff@t.in', 'staff-pass-1') } } it('GET /onboarding-template/front: owner only, serves the fallback by default', async () => { const { base, ownerH, staffH } = await httpWorld() const denied = await fetch(`${base}/onboarding-template/front`, { headers: staffH }) expect(denied.status).toBe(403) const out = await (await fetch(`${base}/onboarding-template/front`, { headers: ownerH })).json() as { ok: boolean; steps: { key: string; label: string }[] } expect(out.ok).toBe(true) expect(out.steps).toEqual(FRONT_FALLBACK) }) it('PUT /onboarding-template/front: owner only; rejects blank label / duplicate key; round-trips on GET', async () => { const { base, ownerH, staffH } = await httpWorld() const denied = await fetch(`${base}/onboarding-template/front`, { method: 'PUT', headers: staffH, body: JSON.stringify({ steps: [{ key: 'a', label: 'A' }] }), }) expect(denied.status).toBe(403) const blank = await fetch(`${base}/onboarding-template/front`, { method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'a', label: ' ' }] }), }) expect(blank.status).toBe(400) const dup = await fetch(`${base}/onboarding-template/front`, { method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'a', label: 'A' }, { key: 'a', label: 'A2' }] }), }) expect(dup.status).toBe(400) const newFront = [{ label: 'Lead In!' }, { key: 'confirmed', label: 'Confirmed' }] const put = await fetch(`${base}/onboarding-template/front`, { method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: newFront }), }) expect(put.status).toBe(200) const putBody = await put.json() as { steps: { key: string; label: string }[] } expect(putBody.steps[0]).toEqual({ key: 'lead_in', label: 'Lead In!' }) const reGet = await (await fetch(`${base}/onboarding-template/front`, { headers: ownerH })).json() as { steps: { key: string; label: string }[] } expect(reGet.steps).toEqual(putBody.steps) }) it('GET/PUT /modules/:code/onboarding-template: 404 on an unknown module code', async () => { const { base, ownerH } = await httpWorld() const getMissing = await fetch(`${base}/modules/NOPE/onboarding-template`, { headers: ownerH }) expect(getMissing.status).toBe(404) const putMissing = await fetch(`${base}/modules/NOPE/onboarding-template`, { method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'a', label: 'A' }] }), }) expect(putMissing.status).toBe(404) }) it('GET/PUT /modules/:code/onboarding-template: owner only, round-trips a valid edit', async () => { const { base, ownerH, staffH } = await httpWorld() const denied = await fetch(`${base}/modules/WIDGET/onboarding-template`, { headers: staffH }) expect(denied.status).toBe(403) const before = await (await fetch(`${base}/modules/WIDGET/onboarding-template`, { headers: ownerH })).json() as { steps: { key: string; label: string }[] } expect(before.steps).toEqual(TAIL_FALLBACK) const newTail = [{ key: 'kickoff', label: 'Kickoff call' }, { key: 'wrapup', label: 'Wrap up' }] const put = await fetch(`${base}/modules/WIDGET/onboarding-template`, { method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: newTail }), }) expect(put.status).toBe(200) const after = await (await fetch(`${base}/modules/WIDGET/onboarding-template`, { headers: ownerH })).json() as { steps: { key: string; label: string }[] } expect(after.steps).toEqual(newTail) }) it('cross-half collision (FIX 1) is rejected with 400 over HTTP, both directions', async () => { const { base, ownerH } = await httpWorld() // Front step whose key collides with the (fallback) SMS tail's 'installed'... use the // module tail directly since SMS starts from TAIL_FALLBACK (has 'installed'). const frontCollide = await fetch(`${base}/onboarding-template/front`, { method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'installed', label: 'Installed' }] }), }) expect(frontCollide.status).toBe(400) const frontCollideBody = await frontCollide.json() as { ok: boolean; error: string } expect(frontCollideBody.error).toMatch(/used by both the front/i) // Tail step whose key collides with the shared front's 'enquiry'. const tailCollide = await fetch(`${base}/modules/SMS/onboarding-template`, { method: 'PUT', headers: ownerH, body: JSON.stringify({ steps: [{ key: 'enquiry', label: 'Enquiry (dup)' }] }), }) expect(tailCollide.status).toBe(400) const tailCollideBody = await tailCollide.json() as { ok: boolean; error: string } expect(tailCollideBody.error).toMatch(/front/i) }) })