* feat(packs): konteringspaket as validated data files, ported losslessly The 26 system booking templates lived inside migration 20260413160000. Under the never-modify-a-shipped-migration rule that froze them: correcting a wrong BAS account or a Swedish typo needed a whole new migration, and nothing checked that a seeded account existed in the chart or that a template balanced. #1321 was exactly that failure with seeded chart names. They are now one YAML file per pattern under packs/, with a Zod contract and a CI gate. A correction becomes a one-line edit plus a green run. The port is proven lossless, not asserted. The test fixture was read out of a Postgres with all 548 migrations applied, so it is the exact JSONB production holds; lib/packs/__tests__/port-is-lossless.test.ts asserts the YAML reproduces it by value. Phase 2b can swap the seeded rows for the loader as a no-op. The gate checks what makes a pack CORRECT, not just well-formed, because #1321 was structurally valid and still wrong: every account must exist in BAS 2026, and every pack must balance at five probe amounts through the real applyTemplate() rather than a reimplementation. Account numbers validate through lib/invariants, so a pack cannot disagree with the API or the SIE importer about what an account number is. Doing that immediately found four pre-existing breakages in the shipped templates: loneutbetalning debits total 1.42x the amount against a 1.0 credit: it can never post periodiseringsfond-avsattning-ab account 2113 is not in BAS 2026 and is not periodiseringsfond-aterforing-ab seeded into any company chart preliminar-f-skatt-ef account 2012, same problem These are quarantined in KNOWN_BROKEN, not fixed and not hidden: a quarantined pack's findings are warnings, any NEW finding fails the build, and the validator fails if a quarantined pack turns out to be clean, so the list may only shrink. Each is a Swedish accounting content change to a user-facing template, which deserves its own review rather than riding along inside a file-format change. Five shipped descriptions contain em dashes, preserved verbatim and pinned by a test: a lossless port must not silently rewrite user-visible strings. js-yaml is promoted from a transitive dependency to a declared one (MIT, already in node_modules), so the catalogue does not depend on it by accident. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(deps): regenerate package-lock.json with npm 10 to match CI `npm ci` failed on every job with "Missing: @swc/helpers@0.5.23 from lock file". The lockfile was written by local npm 11.6.0; CI runs npm 10.8.2 on node 20, and npm 11 emits a tree npm 10 reads as out of sync. Regenerated with `npx npm@10 install --package-lock-only`, which cuts the diff from a sprawling rewrite down to the three entries this branch actually adds (js-yaml, @types/js-yaml, and the @swc/helpers entry npm 11 had dropped). Verified with `npx npm@10 ci --dry-run`. This is the documented gotcha for this repo: regenerate lockfiles with npx npm@10, never with a local npm 11. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
101 lines
3.7 KiB
TypeScript
101 lines
3.7 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import path from 'node:path'
|
|
import { loadPacks, packToLibraryRow, sortPacks } from '@/lib/packs/load'
|
|
import seeded from './fixtures/seeded-system-templates.json'
|
|
|
|
/**
|
|
* The port out of migration 20260413160000 must be LOSSLESS.
|
|
*
|
|
* The fixture is not hand-written: it was read out of a Postgres that had all
|
|
* 548 migrations applied, so it is exactly the JSONB the database holds today.
|
|
* If `packs/*.yaml` reproduces it byte for byte, then swapping the seeded rows
|
|
* for the pack files (phase 2b) is a no-op for every existing company.
|
|
*
|
|
* This is the test that makes the format change safe to ship. If it fails, the
|
|
* catalogue has drifted from production and the loader must not be switched on.
|
|
*/
|
|
|
|
interface SeededTemplate {
|
|
name: string
|
|
description: string
|
|
category: string
|
|
entity_type: string
|
|
lines: Array<Record<string, unknown>>
|
|
}
|
|
|
|
const ROOT = path.resolve(__dirname, '../../..')
|
|
|
|
/** Compare by value: jsonb does not preserve key order, so neither do we. */
|
|
function canonical(t: {
|
|
name: string
|
|
description: string
|
|
category: string
|
|
entity_type: string
|
|
lines: Array<Record<string, unknown>>
|
|
}): string {
|
|
return JSON.stringify({
|
|
name: t.name,
|
|
description: t.description,
|
|
category: t.category,
|
|
entity_type: t.entity_type,
|
|
lines: t.lines.map((l) =>
|
|
Object.fromEntries(Object.entries(l).sort(([a], [b]) => a.localeCompare(b))),
|
|
),
|
|
})
|
|
}
|
|
|
|
describe('pack catalogue is a lossless port of the seeded system templates', () => {
|
|
const { packs, errors } = loadPacks(ROOT)
|
|
|
|
it('every pack file parses and passes the schema', () => {
|
|
expect(errors, `pack load errors:\n${errors.map((e) => `${e.file}: ${e.message}`).join('\n')}`).toEqual([])
|
|
expect(packs.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('reproduces exactly the templates the migration seeds', () => {
|
|
const fromPacks = packs.map((p) => canonical(packToLibraryRow(p.pack))).sort()
|
|
const fromDb = (seeded as SeededTemplate[]).map(canonical).sort()
|
|
|
|
expect(fromPacks).toHaveLength(fromDb.length)
|
|
expect(fromPacks).toEqual(fromDb)
|
|
})
|
|
|
|
it('covers all 26 seeded templates, none added and none dropped', () => {
|
|
expect(packs).toHaveLength((seeded as SeededTemplate[]).length)
|
|
expect(packs).toHaveLength(26)
|
|
})
|
|
|
|
it('preserves shipped Swedish text verbatim, em dashes included', () => {
|
|
// Five seeded descriptions/names contain an em dash. The repo style rule
|
|
// forbids writing new ones, but a lossless port must not silently rewrite
|
|
// user-visible strings: changing them is a content decision, not a format
|
|
// one. This test pins that so a future cleanup is deliberate.
|
|
const packText = packs.map((p) => `${p.pack.meta.name} ${p.pack.meta.description}`).join('\n')
|
|
const dbText = (seeded as SeededTemplate[]).map((t) => `${t.name} ${t.description}`).join('\n')
|
|
|
|
const countEmDash = (s: string) => (s.match(/—/g) ?? []).length
|
|
expect(countEmDash(packText)).toBe(countEmDash(dbText))
|
|
expect(countEmDash(packText)).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
describe('catalogue invariants', () => {
|
|
const { packs } = loadPacks(ROOT)
|
|
|
|
it('has a unique slug per pack, matching its filename', () => {
|
|
const slugs = packs.map((p) => p.pack.meta.slug)
|
|
expect(new Set(slugs).size).toBe(slugs.length)
|
|
for (const p of packs) expect(p.fileSlug).toBe(p.pack.meta.slug)
|
|
})
|
|
|
|
it('has a unique meta.order, so gallery and docs can never disagree', () => {
|
|
const orders = packs.map((p) => p.pack.meta.order)
|
|
expect(new Set(orders).size).toBe(orders.length)
|
|
})
|
|
|
|
it('sorts deterministically by meta.order', () => {
|
|
const ordered = sortPacks(packs).map((p) => p.pack.meta.order)
|
|
expect(ordered).toEqual([...ordered].sort((a, b) => a - b))
|
|
})
|
|
})
|