Files
accounted/tests/pg/booking-template-pack-slug.pg.test.ts
Jakob Wennberg ff864ad3db feat(packs): sync system templates from packs/ instead of the frozen migration (#1390)
Phase 2b. The packs become the source of truth; the table stays the query
surface, so the existing read path (one RLS query returning system + company +
team templates) and every template id are untouched.

pack_slug is the stable upsert key (migration 20260803230000, backfilled onto
all 26 seeded rows). Matching on name instead would have meant that correcting a
Swedish label looks like a new template, and because
booking_template_usage.template_id is ON DELETE CASCADE, retiring the old row
would silently wipe every company's "recently used" history for it. For the same
reason a removed pack DEACTIVATES its row rather than deleting it: the read path
already filters is_active, so it leaves the picker while usage history survives.

The sync fails closed. A pack that will not parse or validate aborts the whole
run with zero writes, because a database left in a state no commit of the repo
describes is worse than a stale one. An empty catalogue is treated as a broken
deploy (packs/ not bundled) rather than an instruction to retire every system
template.

Runs as a daily cron rather than at boot: boot-time work would have every
serverless instance racing to write the same rows, and would re-apply a bad
catalogue continuously instead of once a day where it is visible. Idempotent, so
a database already matching the packs performs zero writes.

Three database guards, each covered by tests/pg/booking-template-pack-slug.pg.test.ts:
a partial unique index (one pack, one template), a format CHECK mirroring
PACK_SLUG_RE so the database refuses what the loader would, and a CHECK keeping
pack_slug off company templates, where it would shadow the pack it collides with.

Verified the upgrade path locally the way the pg-upgrade job will run it: base
schema, seeded fixture company with posted verifikat, an existing company
template, then this migration alone. 26 slugs backfilled, company template
intact, upgrade assertions pass. This is that job's first real migration.

Payloads are spelled out rather than spread so the phantom-column guard can
check them, and docker/crontab.* are regenerated for the new vercel.json entry.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:55:59 +02:00

98 lines
3.8 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getPool } from './setup'
import { seedCompany } from './fixtures'
/**
* `booking_template_library.pack_slug` (migration 20260803230000).
*
* The column is the stable upsert key that lets system templates be synced from
* packs/*.yaml instead of living as rows frozen inside migration 20260413160000.
* Its three guards each stop a specific way the sync could corrupt the
* catalogue, so each is asserted here rather than trusted.
*/
describe('booking_template_library.pack_slug', () => {
it('backfilled every seeded system template', async () => {
const { rows } = await getPool().query(
`SELECT count(*) FILTER (WHERE pack_slug IS NULL) AS missing, count(*) AS total
FROM public.booking_template_library WHERE is_system`,
)
// A system template without a slug would be invisible to the sync and would
// silently persist as a duplicate of whatever pack replaced it.
expect(Number(rows[0].missing)).toBe(0)
expect(Number(rows[0].total)).toBeGreaterThan(0)
})
it('holds a unique slug per system template', async () => {
const { rows } = await getPool().query(
`SELECT pack_slug, count(*) AS n FROM public.booking_template_library
WHERE pack_slug IS NOT NULL GROUP BY pack_slug HAVING count(*) > 1`,
)
expect(rows).toEqual([])
})
it('rejects a second row claiming an existing slug', async () => {
const existing = await getPool().query(
`SELECT pack_slug FROM public.booking_template_library WHERE pack_slug IS NOT NULL LIMIT 1`,
)
const slug = existing.rows[0].pack_slug as string
await expect(
getPool().query(
`INSERT INTO public.booking_template_library
(name, description, category, entity_type, is_system, lines, pack_slug)
VALUES ('Dubblett', '', 'other', 'all', TRUE, '[]'::jsonb, $1)`,
[slug],
),
).rejects.toThrow(/btl_pack_slug_unique/)
})
it('rejects a slug that the pack loader would refuse', async () => {
for (const bad of ['Bad_Slug', 'trailing-', '-leading', 'double--dash', 'ÅÄÖ']) {
await expect(
getPool().query(
`INSERT INTO public.booking_template_library
(name, description, category, entity_type, is_system, lines, pack_slug)
VALUES ('Ogiltig', '', 'other', 'all', TRUE, '[]'::jsonb, $1)`,
[bad],
),
bad,
).rejects.toThrow(/btl_pack_slug_format/)
}
})
it('refuses a pack_slug on a COMPANY template, which would shadow the pack', async () => {
// Scoped to a real company so the pre-existing "company or team or system"
// CHECK is satisfied and pack_slug is genuinely the constraint under test.
const { companyId, userId } = await seedCompany()
await expect(
getPool().query(
`INSERT INTO public.booking_template_library
(id, company_id, created_by, name, description, category, entity_type,
is_system, lines, pack_slug)
VALUES ($1, $2, $3, 'Kapad mall', '', 'other', 'all', FALSE, '[]'::jsonb, 'kapad-mall')`,
[randomUUID(), companyId, userId],
),
).rejects.toThrow(/btl_pack_slug_system_only/)
})
it('still allows a company template with no slug', async () => {
const { companyId, userId } = await seedCompany()
const id = randomUUID()
await getPool().query(
`INSERT INTO public.booking_template_library
(id, company_id, created_by, name, description, category, entity_type, is_system, lines)
VALUES ($1, $2, $3, 'Egen mall', '', 'other', 'all', FALSE, '[]'::jsonb)`,
[id, companyId, userId],
)
const { rows } = await getPool().query(
`SELECT pack_slug FROM public.booking_template_library WHERE id = $1`,
[id],
)
expect(rows[0].pack_slug).toBeNull()
})
})