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.
225 lines
11 KiB
TypeScript
225 lines
11 KiB
TypeScript
// 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)
|
|
})
|
|
})
|