fix(milestones): reject front/tail key collisions; dedupe on collision; stamp installed/trained dates

Final whole-branch review FIX 1 + FIX 5.

- setFrontTemplate / setModuleTail now reject a key that collides across the
  front/tail halves (checked against the default tail, every configured
  per-module tail, and the shared front respectively) instead of letting
  milestoneTemplate silently compose a duplicate-key list.
- ensureProjectMilestones adds each inserted key to its own `existing` set
  inside the loop, so a template that still ends up with a duplicate key
  (a pre-existing bad setting) is deduped instead of hitting the
  UNIQUE (client_module_id, key) violation a second time.
- GET /client-modules/:id/milestones now wraps ensureProjectMilestones +
  listProjectMilestones in try/catch -> 400, matching its POST sibling,
  instead of an unhandled async rejection that hung the request.
- advanceStatusForStep now stamps client_module.installed_on / trained_on
  from the triggering milestone's own done_on the first time status
  advances into 'installed' / 'trained' (never overwriting an existing
  date), replacing the UI date-writers the Client Detail redesign removed
  without a replacement. Reflected in the same audit row as the status
  change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main
Thomas Joise 1 day ago
parent 7494f53e2f
commit e8e2a202c1

@ -746,8 +746,12 @@ export function apiRouter(
if ((await getClientModule(db, id)) === null) {
res.status(404).json({ ok: false, error: 'Client module not found' }); return
}
await ensureProjectMilestones(db, id) // lazily seed for projects created before D22 (imports)
res.json({ ok: true, milestones: await listProjectMilestones(db, id) })
try {
await ensureProjectMilestones(db, id) // lazily seed for projects created before D22 (imports)
res.json({ ok: true, milestones: await listProjectMilestones(db, id) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.post('/client-modules/:id/milestones', requireAuth, async (req, res) => {
const id = String(req.params['id'] ?? '')

@ -37,14 +37,37 @@ const STEP_STATUS: Record<string, string> = {
quotation_approved: 'ordered', installation: 'installed', training: 'trained', go_live: 'live',
}
async function advanceStatusForStep(db: DB, userId: string, clientModuleId: string, key: string): Promise<void> {
/**
* Also replaces the removed UI date-writers (Client Detail redesign dropped `RowDate` /
* the summary-table date columns without a replacement): the first time status crosses
* into 'installed' / 'trained', stamp client_module.installed_on / trained_on from the
* triggering milestone's own done_on never overwriting a date already on file (e.g. one
* set by the APEX importer or a direct edit).
*/
async function advanceStatusForStep(
db: DB, userId: string, clientModuleId: string, key: string, doneOn: string | null,
): Promise<void> {
const target = STEP_STATUS[key]
if (target === undefined) return
const cm = await db.get<{ status: string }>(`SELECT status FROM client_module WHERE id=?`, clientModuleId)
const cm = await db.get<{ status: string; installed_on: string | null; trained_on: string | null }>(
`SELECT status, installed_on, trained_on FROM client_module WHERE id=?`, clientModuleId)
if (cm === undefined) return
if ((STATUS_RANK[cm.status] ?? 0) >= (STATUS_RANK[target] ?? 0)) return // never downgrade
await db.run(`UPDATE client_module SET status=? WHERE id=?`, target, clientModuleId)
await writeAudit(db, userId, 'update', 'client_module', clientModuleId, { status: cm.status }, { status: target })
const sets = ['status=?']
const args: unknown[] = [target]
const before: Record<string, unknown> = { status: cm.status }
const after: Record<string, unknown> = { status: target }
if (target === 'installed' && cm.installed_on === null && doneOn !== null) {
sets.push('installed_on=?'); args.push(doneOn)
before['installedOn'] = null; after['installedOn'] = doneOn
}
if (target === 'trained' && cm.trained_on === null && doneOn !== null) {
sets.push('trained_on=?'); args.push(doneOn)
before['trainedOn'] = null; after['trainedOn'] = doneOn
}
args.push(clientModuleId)
await db.run(`UPDATE client_module SET ${sets.join(', ')} WHERE id=?`, ...args)
await writeAudit(db, userId, 'update', 'client_module', clientModuleId, before, after)
}
/** The shared lead-in every module's checklist starts with (code default for the "front"). */
@ -140,16 +163,46 @@ export function normalizeTemplateSteps(rawSteps: unknown): TemplateEntry[] {
return out
}
/** Owner editor: replace the shared front template. Config-over-code, audited via `setSetting`. */
/** Every module code with its OWN configured tail setting (not the global fallback). */
async function configuredTailCodes(db: DB): Promise<string[]> {
const rows = await db.all<{ key: string }>(`SELECT key FROM setting WHERE key LIKE 'project.milestone_template:%'`)
return rows.map((r) => r.key.slice('project.milestone_template:'.length))
}
/** Throws a clear 400-able error if any of `frontKeys` collides with a key in `tail`.
* `tailLabel` identifies the tail in the message (a module code, or 'default'). */
function rejectCollision(frontKeys: Set<string>, tail: TemplateEntry[], tailLabel: string): void {
const hit = tail.find((t) => frontKeys.has(t.key))
if (hit !== undefined) {
throw new Error(`Step key "${hit.key}" is used by both the front and the ${tailLabel} tail template — keys must be unique across the whole checklist`)
}
}
/**
* Owner editor: replace the shared front template. Config-over-code, audited via `setSetting`.
* The front is composed with EVERY module's tail ([...front, ...tail]), so a front key must
* not collide with any tail currently in play: the global/default tail, and every module that
* has its own configured tail.
*/
export async function setFrontTemplate(db: DB, userId: string, rawSteps: unknown): Promise<TemplateEntry[]> {
const steps = normalizeTemplateSteps(rawSteps)
const frontKeys = new Set(steps.map((s) => s.key))
rejectCollision(frontKeys, await getModuleTail(db, ''), 'default')
for (const code of await configuredTailCodes(db)) {
rejectCollision(frontKeys, await getModuleTail(db, code), code)
}
await setSetting(db, userId, 'project.milestone_template.front', JSON.stringify(steps))
return steps
}
/** Owner editor: replace one module's tail template. Config-over-code, audited via `setSetting`. */
/**
* Owner editor: replace one module's tail template. Config-over-code, audited via `setSetting`.
* This tail is always composed after the shared front, so its keys must not collide with it.
*/
export async function setModuleTail(db: DB, userId: string, moduleCode: string, rawSteps: unknown): Promise<TemplateEntry[]> {
const steps = normalizeTemplateSteps(rawSteps)
const frontKeys = new Set((await getFrontTemplate(db)).map((f) => f.key))
rejectCollision(frontKeys, steps, moduleCode || 'default')
await setSetting(db, userId, `project.milestone_template:${moduleCode}`, JSON.stringify(steps))
return steps
}
@ -177,6 +230,10 @@ export async function ensureProjectMilestones(db: DB, clientModuleId: string): P
VALUES (?, ?, ?, ?, 0, NULL, ?)`,
uuidv7(), clientModuleId, t.key, t.label, sort,
)
// A template with a front/tail key collision (owner-editor bug, or a legacy setting
// written before that was validated) would otherwise INSERT the same
// (client_module_id, key) twice and violate the UNIQUE constraint mid-loop.
existing.add(t.key)
}
sort++
}
@ -244,7 +301,7 @@ export async function setMilestone(
)
await writeAudit(db, userId, 'set_milestone', 'client_module', clientModuleId, undefined,
{ key, done, doneOn: stamp, paymentId: linkedPayment, documentId: linkedDocument })
if (done) await advanceStatusForStep(db, userId, clientModuleId, key)
if (done) await advanceStatusForStep(db, userId, clientModuleId, key, stamp)
return listProjectMilestones(db, clientModuleId)
})
}

@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createClient } from '../src/repos-clients'
import { createModule, assignModule, getClientModule } from '../src/repos-modules'
import { createModule, assignModule, getClientModule, updateClientModule } from '../src/repos-modules'
import { setMilestone } from '../src/repos-milestones'
async function world() {
@ -50,3 +50,62 @@ describe('milestone → status auto-drive (D22 status line)', () => {
expect((await getClientModule(db, cmId))!.status).toBe('live')
})
})
// Final whole-branch review FIX 5: installedOn / trainedOn became write-only once the UI's
// RowDate / summary-table date writers were removed with nothing to replace them — the
// milestone-driven status advance is now that replacement.
describe('milestone-driven installed_on / trained_on stamping (FIX 5)', () => {
it("ticking 'installation' (which advances status to installed) stamps installed_on from the milestone's done_on", async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
const cm = await getClientModule(db, cmId)
expect(cm!.status).toBe('installed')
expect(cm!.installedOn).toBe('2026-03-25')
})
it("ticking 'training' (which advances status to trained) stamps trained_on from the milestone's done_on", async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'training', true, '2026-05-02')
const cm = await getClientModule(db, cmId)
expect(cm!.status).toBe('trained')
expect(cm!.trainedOn).toBe('2026-05-02')
})
it('never overwrites an installed_on already on file (e.g. set by import / direct edit)', async () => {
const { db, cmId } = await world()
await updateClientModule(db, 'u1', cmId, { installedOn: '2025-01-01' })
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
const cm = await getClientModule(db, cmId)
expect(cm!.status).toBe('installed') // status still advances
expect(cm!.installedOn).toBe('2025-01-01') // date untouched
})
it('re-ticking after an untick does not move an already-stamped installed_on', async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
await setMilestone(db, 'u1', cmId, 'installation', false)
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-04-01')
expect((await getClientModule(db, cmId))!.installedOn).toBe('2026-03-25')
})
it('leaves installed_on / trained_on untouched for a step with no date-column mapping (e.g. go_live)', async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'go_live', true, '2026-06-01')
const cm = await getClientModule(db, cmId)
expect(cm!.status).toBe('live')
expect(cm!.installedOn).toBeNull()
expect(cm!.trainedOn).toBeNull()
})
it('the status-advance audit payload reflects the installed_on stamp', async () => {
const { db, cmId } = await world()
await setMilestone(db, 'u1', cmId, 'installation', true, '2026-03-25')
const audits = await db.all<{ after_json: string | null }>(
`SELECT after_json FROM audit_log WHERE entity='client_module' AND entity_id=? AND action='update' ORDER BY id`,
cmId,
)
const stamped = audits.find((a) => (a.after_json ?? '').includes('installedOn'))
expect(stamped).toBeDefined()
expect(JSON.parse(stamped!.after_json!)).toMatchObject({ status: 'installed', installedOn: '2026-03-25' })
})
})

@ -67,6 +67,34 @@ describe('project milestones (D22)', () => {
expect(await listProjectMilestones(db, 'imported-cm')).toHaveLength(10)
})
// Final whole-branch review FIX 1(b): setFrontTemplate/setModuleTail now reject a
// cross-half key collision, but a template can still end up with a duplicate key some
// other way (e.g. a setting row written directly, or one that predates that guard) —
// ensureProjectMilestones must not crash with a UNIQUE (client_module_id, key) violation
// or insert the same key twice; it must dedupe within its own insert loop.
it('ensureProjectMilestones tolerates a front/tail key collision in the resolved template', async () => {
const { db, c1, m } = await world()
// Force a front/tail collision by writing the settings directly (bypassing the
// now-guarded setFrontTemplate/setModuleTail). world() ran seedIfEmpty, which already
// seeded the front row, so UPDATE it rather than INSERT.
await db.run(`UPDATE setting SET value=? WHERE key='project.milestone_template.front'`,
JSON.stringify([{ key: 'enquiry', label: 'Enquiry received' }, { key: 'installed', label: 'Front installed' }]))
await db.run(`INSERT INTO setting (key, value) VALUES ('project.milestone_template', ?)`,
JSON.stringify([{ key: 'installed', label: 'Tail installed' }, { key: 'go_live', label: 'Go-live' }]))
await db.run(
`INSERT INTO client_module (id, client_id, module_id, status, kind, edition) VALUES (?, ?, ?, 'quoted', 'yearly', 'standard')`,
'colliding-cm', c1.id, m.id,
)
await expect(ensureProjectMilestones(db, 'colliding-cm')).resolves.toBeUndefined()
const ms = await listProjectMilestones(db, 'colliding-cm')
// Only ONE row for the colliding key ('installed'), not two.
expect(ms.filter((mm) => mm.key === 'installed')).toHaveLength(1)
expect(ms.map((mm) => mm.key)).toEqual(['enquiry', 'installed', 'go_live'])
// Re-running stays safe and adds nothing new.
await ensureProjectMilestones(db, 'colliding-cm')
expect(await listProjectMilestones(db, 'colliding-cm')).toHaveLength(3)
})
it('boards the whole book and drills into who is pending a milestone', async () => {
const { db, c1, p1, p2 } = await world()
await setMilestone(db, 'u1', p1.id, 'advance_paid', true)

@ -0,0 +1,224 @@
// 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<string, string>; staffH: Record<string, string> }> {
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<Record<string, string>> => {
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)
})
})
Loading…
Cancel
Save