From 306d002221bca5e32207a40cdd4c035d3c7215ce Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Tue, 18 Aug 2026 20:25:17 +0200 Subject: [PATCH] docs(test): generated living behaviour spec + FE/BE seam drift check (WP-71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gherkin/Cucumber was considered and rejected for business-readable BDD scenarios: step-binding by runtime string match undoes the compile-time guarantees WP-70 just added, and needs two frameworks for .NET+TS with no non-technical co-author in view. Instead scripts/gen-behaviour-spec.mjs (modeled on the existing gen-snippets.mjs) extracts every describe/it and [Fact]/[Theory] name straight from the real suites into libs/shared/docs/behaviour-spec.mdx, gated for drift in CI exactly like gen-snippets/gen-api — the page can never diverge from the tests because it's generated from them, and test names stay the single source of truth. scripts/check-seam.sh guards the one FE/BE rule duplication most likely to silently diverge: IntakePolicy.cs's ScholingThreshold vs intake.machine.ts's SCHOLING_THRESHOLD_DEFAULT, two unlinked literals pinned separately in each side's own tests but never against each other. package.json/CI wiring for both (gen:behaviour-spec, check:seam) shipped in the prior commit alongside the typecheck gate, since all three touch the same few config files. Co-Authored-By: Claude Sonnet 5 --- .prettierignore | 1 + libs/shared/docs/behaviour-spec.mdx | 1093 +++++++++++++++++++++++++++ scripts/check-seam.sh | 33 + scripts/gen-behaviour-spec.mjs | 218 ++++++ 4 files changed, 1345 insertions(+) create mode 100644 libs/shared/docs/behaviour-spec.mdx create mode 100755 scripts/check-seam.sh create mode 100644 scripts/gen-behaviour-spec.mjs diff --git a/.prettierignore b/.prettierignore index 710a602..deed558 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,6 +11,7 @@ package-lock.json documentation.json libs/shared/src/infrastructure/api-client.ts apps/ssp/src/app/showcase/snippets.generated.ts +libs/shared/docs/behaviour-spec.mdx # Vendored design system (CIBG Huisstijl) public/cibg-huisstijl/ diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx new file mode 100644 index 0000000..52edd58 --- /dev/null +++ b/libs/shared/docs/behaviour-spec.mdx @@ -0,0 +1,1093 @@ +{/* GENERATED by `npm run gen:behaviour-spec` (scripts/gen-behaviour-spec.mjs) — do not +edit. Every bullet below is a real `it()` title or backend test method name, extracted +verbatim from the suite. The team rejected Cucumber/Gherkin for BDD scenarios (runtime string +matching undoes the compile-time guarantees WP-70 bought, and needs two frameworks for +.NET+TS) — this page is the replacement: business-readable documentation generated FROM test +names, so it can never drift from what the suite actually asserts. A test name changing (or a +test being added/removed) is the only way this page changes; hand-editing it is pointless, +the next `npm run gen:behaviour-spec` overwrites it. */} + +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Behaviour spec + +_Generated by `npm run gen:behaviour-spec` — do not hand-edit; the next generation +overwrites this page. See [BDD](?path=/docs/foundations-bdd--docs) for how these names are +written, and [Testing strategy](?path=/docs/foundations-testing-strategy--docs) for what gets +tested where._ + +Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test +method name (backend), read as a sentence. Nothing here is hand-written prose: this page +**is** the suite, reshaped for a business reader. 401 frontend behaviours across +8 contexts; 206 backend behaviours across 33 test +classes. + +## Frontend (by context) + +### auth + +#### authGuard + +- allows an authenticated user +- redirects an anonymous user to /login +- allows an authenticated user +- redirects an anonymous user to /login + +#### capabilityGuard + +- waits for /me, then allows an entitled admin +- sends an authenticated-but-unentitled user to /dashboard (not a login loop) +- redirects an anonymous user to /login without waiting for caps +- waits for /me, then allows an entitled admin +- sends an authenticated-but-unentitled user to /dashboard (not a login loop) +- redirects an anonymous user to /login without waiting for caps + +#### isAuthenticated + +- narrows a present session to Session +- reports no session as not authenticated +- narrows a present session to Session +- reports no session as not authenticated + +### behandeling + +#### besluit reduce + +- SetField updates the draft while editing +- Submit with no besluit chosen stays Editing and reports a field error +- Submit Afwijzen without a toelichting stays Editing and reports a field error +- Submit Goedkeuren with no toelichting moves to Submitting (optional there) +- Submit Afwijzen with a toelichting moves to Submitting with the trimmed value +- SubmitConfirmed maps Submitting to Submitted +- SubmitFailed maps Submitting to Failed with the error +- Retry re-submits a failure +- Reset returns to the initial editing state + +#### detailRows + +- lists soort/status/referentie/eigenaar/ingediend +- a reden row is present only for Afgewezen and MeerInfoGevraagd + +#### parseBeoordelingStatus + +- parses each tag with its required fields +- rejects a malformed status + +#### parseBeoordelingView + +- maps a valid DTO to domain +- rejects a malformed view +- defaults an absent documenten list to empty + +#### parseWerkvoorraad + +- parses a list and fails fast on a bad element + +#### parseWerkvoorraadItem + +- parses Ingediend and InBehandeling +- rejects a case whose status is not an open queue tag +- rejects a missing owner, bad type, and non-objects + +#### statusLabel + +- labels every tag distinctly + +#### werkvoorraadRow + +- heading is the type, subtitle carries the owner BSN +- status line carries the status label, reference and submit date +- manual review is called out distinctly from an automatic InBehandeling +- a missing submit date leaves no dangling separator + +### beheer + +#### StamdataStore undo/redo (WP-32) + +- records a cell edit, undoes and redoes it +- records addRow and undoes it +- clears history when switching table + +#### activeOn (valid-time, half-open [van, tot)) + +- includes a row whose window covers the date +- excludes a row before its geldigVan +- excludes on the geldigTot boundary (half-open) + +#### changeCounts (diff against the loaded snapshot, by key) + +- counts an added key +- counts a removed key +- counts an edited cell + +#### parseStamdataTable + +- maps schema + rows and turns a null cell into empty text +- falls back to text for an unknown column type +- rejects a response with no columns + +#### rowErrors / isValid (format only) + +- flags a blank key +- flags a missing geldigVan on a temporal table +- flags geldigTot on or before geldigVan +- passes a well-formed row + +#### stamdata-editor reduce + +- Loaded snapshots original independently of rows +- RowAdded appends an empty row shaped by the schema +- RowRemoved drops the row at the index +- edit messages are ignored unless loaded +- LoadFailed and Loading transition regardless of prior state + +#### toJson (draft → file shape) + +- reconstructs an open-ended geldigTot as null and pretty-prints +- coerces a number column + +### brief + +#### BriefStore action state (Idle | Busy | Failed) + +- is Busy synchronously once a transition starts +- settles to Idle on a successful transition +- goes Busy then Failed on a failing transition, surfacing the error +- a subsequent successful transition clears a prior Failed state + +#### BriefStore rejection diff + +- captures the rejected letter and diffs a subsequent edit against it + +#### BriefStore undo/redo history + +- starts with nothing to undo +- records an edit and makes it undoable +- undo reverts the edit and enables redo +- redo reapplies the undone edit +- a no-op edit does not clear the redo future +- a new edit clears the redo future +- caps history at 50 snapshots + +#### BriefStore.flushPending (CanDeactivate guard / beforeunload) + +- flushes a pending debounced edit immediately and clears the pending flag +- is a no-op when no edit is pending + +#### BriefStore.previewLetter + +- opens the composed letter in a new tab on success +- surfaces the error without opening a tab on failure + +#### BriefStore.revealBigNummer (PRD-0002 §5c) + +- swaps the masked value for the revealed one on success +- keeps the value masked and surfaces the error on failure + +#### besluitGuidance + +- positief: counts inserted passages, no reden needed (positief has no redenen) +- negatief without a reden: flags that a reden must be chosen +- negatief with a reden: no longer flags, and the reason passage is counted + +#### brief selectors + +- unresolvedPlaceholders returns deduped manual keys only (auto excluded) +- allDiagnostics flattens across sections and blocks +- canSubmit is false when a required section is empty, true otherwise + +#### brief.adapter parse boundary + +- parses a well-formed view into the domain unions +- rejects a view whose case context is missing or malformed +- parses the org template and drops a null logoDocumentId +- rejects a view whose org template is missing or malformed +- rejects a view whose decisions are missing or malformed +- narrows node variants and rejects unknown ones +- rejects a status DTO missing its required fields +- reads section.locked (default false) and paragraph.list +- rejects a library passage with an unknown scope +- rejects a passage block missing provenance + +#### brief.machine reduce + +- BriefLoaded moves loading to loaded +- BriefLoadFailed moves loading to failed with the reason +- Seed sets the state directly +- BesluitSelected composes the kern: the besluit passages, in reading order, as frozen local blocks +- BesluitSelected swaps the passages when the selection changes, keeping free text +- BesluitSelected deep-copies content — later library mutation does not leak in +- FreeTextBlockAdded appends an empty free-text block +- BlockContentEdited replaces content and marks a passage block edited +- BlockMovedWithinSection reorders blocks within a section +- BlockRemoved drops a block from a section +- edits to a locked section are no-ops (besluit, free-text, content, remove, move) +- edits are no-ops once submitted (status invariant) +- editing a rejected letter reopens it to draft +- Submitted fires only from draft and only when required sections are filled +- approve fires only from submitted +- reject fires from submitted, carrying comments +- send fires only from approved +- a status transition replaces decisions with the fresh server value + +#### diffBlocks + +- marks added, removed, changed and unchanged by blockId +- changedBlocks drops unchanged and keeps added/removed/changed + +#### inferSelection + +- round-trips a positief selection +- round-trips a negatief selection with redenen (in order) +- an empty kern (nothing chosen) infers no besluit +- ignores free-text blocks and unknown passage ids + +#### lintPlaceholders + +- clean content (auto-resolvable, fillable, current key) yields no diagnostics +- flags an unknown key as an error +- flags a not-fillable key as an error +- flags a deprecated key as a warning +- flags a manual placeholder as unresolved-at-send (warning) +- flags raw braces in text as malformed (paste safety net) +- returns diagnostics in document order across paragraphs/nodes +- severityOf maps each code to its policy + +#### org-template.machine + +- DraftLoaded moves to loaded with the draft, clean +- LoadFailed carries the reason +- FieldEdited edits the draft and marks dirty +- MarginEdited edits one edge and marks dirty +- DraftSaved clears dirty when the saved draft is the current one +- DraftSaved keeps dirty when an edit landed during the save round-trip +- edits are no-ops in non-loaded states +- a completed logo upload sets logoDocumentId + dirty +- removing the logo clears logoDocumentId + dirty +- DraftLoaded (sub-org switch) keeps the loaded logo category, drops uploads + +#### parseOrgTemplateAdminView + +- parses a well-formed admin view +- rejects a missing draft +- rejects a missing count field +- rejects a malformed history entry + +#### passagesForBesluit + +- positief = shared intro + the positief passage, no negatief/reason passages +- negatief without redenen = intro + negatief base, but no reason-specific passages +- negatief with a reden ticked includes that reason-specific passage only +- preserves library order (= reading order) +- never offers non-kern passages + +#### redenenFor + +- derives reason checkboxes (code + label) from the negatief reason passages +- positief has no reason-specific redenen + +### herregistratie + +#### IntakeWizardComponent + +- renders each field group as its own grey <fieldset> + +#### STEPS (fixed) and inline questions + +- always has the same three steps +- reveals the buitenland detail questions inline only when worked abroad +- reveals the scholing question only when NL-hours are below the threshold +- uses the (server-owned) threshold passed in, not a hardcoded constant + +#### herregistratie hasProgress + +- is false for a fresh form +- is true once a field is filled or the user advances + +#### intake acceptance journeys + +- high uren, no buitenland werk: no scholing question, straight through to Submitted +- low uren requires the scholing question, and punten only once scholing is followed +- buitenland gewerkt requires land and hours abroad before advancing +- gaNaarStap corrects an earlier answer without losing later ones +- SetPolicy (server-owned threshold) can turn an already-answered uren into one that now requires scholing + +#### intake hasProgress + +- is false for a fresh questionnaire +- is true once an answer is given or the user advances + +#### intake-policy.adapter parse boundary + +- parses a well-formed policy +- rejects a missing or non-numeric threshold + +#### navigation + +- Next is a no-op (sets an error) when the current step is invalid +- Next advances once the step is valid +- editing an answer leaves the cursor fixed (steps never collapse) +- Back never goes below the first step +- gaNaarStap jumps back to an earlier step, clearing errors +- gaNaarStap ignores a same/forward jump and jumps outside Answering + +#### reduce (message-driven happy path) + +- drives abroad branch end to end + +#### reduce (message-driven) + +- drives the full happy path via messages +- blocks submit until required documents are satisfied +- SubmitFailed then Retry returns to Submitting with the same data +- Seed mounts an arbitrary state + +#### submit + +- reaches Submitting ONLY with valid answers +- punten is required only when aanvullende scholing was gevolgd +- low hours requires the scholing answer before submit +- resolve maps Submitting to Submitted on a successful submit +- resolve maps Submitting to Failed on a failed submit + +#### wizard.machine + +- next advances only when step 1 parses +- next advances step 2 → 3 only when punten parses +- submit reaches Submitting ONLY from step 3 with fully valid data +- next requires BOTH step-1 fields (uren and jaren) +- back steps down one (3 → 2 → 1) and is a no-op from step 1 +- resolve maps a successful Submitting to Submitted +- resolve maps a failing Submitting to Failed +- gaNaarStap jumps back to an earlier step, clearing errors +- gaNaarStap ignores a same/forward jump and jumps outside Editing + +### registratie + +#### AdminCasesStore + +- loads and parses the cross-owner list +- deletes optimistically and confirms via the admin endpoint +- rolls back the removal when the delete fails + +#### STEPS (fixed) + +- always has the same three steps + +#### adres origin (BRP vs handmatig) + +- prefillAdres flags origin brp +- editing a prefilled address field flips origin to handmatig +- typing an address with no BRP prefill yields handmatig +- editing the e-mail field does not change the address origin +- a manually entered address still submits (only manual diploma is gated) + +#### big-register.adapter parse boundary + +- parses known aantekening types +- rejects an unknown type + +#### blockActions + +- a Concept can be resumed or cancelled +- an in-behandeling aanvraag only exposes its documents +- ingediend and meer-info-gevraagd behave like in-behandeling +- resolved aanvragen have no actions + +#### change-request reduce + +- SetField updates the draft while editing +- Submit with an invalid draft stays Editing and reports field errors +- Submit with a valid draft moves to Submitting with parsed (normalised) data +- SubmitConfirmed maps Submitting to Submitted with the referentie +- SubmitFailed maps Submitting to Failed with the error +- Retry re-submits a failure +- Reset returns to the initial editing state + +#### createDraftSync + +- coalesces rapid snapshot changes into ONE debounced sync of the latest value +- a trailing change after the debounce fires schedules its own sync + +#### createDraftSync › flushPending (CanDeactivate guard / beforeunload) + +- hasPendingSave reflects an armed debounce timer +- flushPending writes the pending draft immediately, before the debounce fires +- flushPending is a no-op when nothing is pending + +#### createDraftSync › submit + +- resolves ok with the server response on success +- folds a rejected submit into a Result error, never throwing +- recovers from a create conflict by adopting the existing Concept (WP-35) + +#### createSubmitChangeRequest + +- resolves ok with the referentie on success +- folds a rejected call into a Result error, never throwing +- surfaces a ProblemDetails detail message when the server rejects with one + +#### detailRows + +- lists soort/waarvoor/status/referentie/ingediend, plus reason when rejected +- reference falls back to em dash for a Concept + +#### hasProgress + +- is false for a fresh wizard +- ignores an auto-prefilled BRP address at step 0 +- is true once the user advances, picks correspondence/diploma, or is past step 0 + +#### inline document upload (beroep step) + +- routes Upload messages through the upload reducer +- blocks the beroep step until a required category is satisfied +- includes delivery refs in the submitted data + +#### kiesDiploma + +- derives the beroep from the chosen diploma and flags origin duo + +#### manual diploma fallback + +- KiesHandmatig flags handmatig with the maximal question set and no beroep yet +- requires a declared beroep + all maximal questions before submit + +#### navigation + +- Next is a no-op (sets errors) when the adres step is invalid +- Next advances once the adres step is valid +- requires a valid e-mail only when the channel is email +- beroep step requires a chosen diploma +- Back never goes below the first step and preserves the draft +- GaNaarStap only jumps backwards + +#### parseAanvraagStatus + +- parses each tag with its required fields +- rejects a missing status, unknown tag, and wrong-typed fields + +#### parseApplicationSummary + +- maps a valid DTO to domain +- rejects a bad type and non-objects + +#### parseApplications / parseApplicationDetail + +- parses a list and fails fast on a bad element +- carries the opaque draft through detail + +#### parseBigNummer + +- accepts exactly 11 digits, trimming whitespace +- rejects wrong length or non-digits + +#### parseBrpAddress (trust boundary) + +- accepts a found address +- accepts "geen adres" (gevonden: false) as a valid outcome +- rejects malformed responses + +#### parseDashboardView (trust boundary) + +- maps a valid response into a DashboardView +- rejects malformed responses instead of trusting them + +#### parseDuoLookup (trust boundary) + +- maps a valid lookup (diplomas + manual fallback) +- accepts an empty diploma list (forces manual entry) +- rejects malformed responses + +#### parseEmail + +- accepts a well-formed address and trims it +- rejects malformed addresses + +#### parsePostcode + +- normalises to "1234 AB" (uppercase, single space, trimmed) +- rejects malformed postcodes + +#### parseTelefoonnummer + +- accepts a 10-digit number starting 0 and strips formatting +- normalises a +31 prefix to a leading 0 +- rejects a too-short number, a non-0 start, and junk + +#### parseUren + +- accepts non-negative whole numbers, including 0 +- rejects empty, negative, and non-integer input + +#### policy questions (geldigheidsvragen) + +- a diploma with questions blocks Next until they are answered +- validateAll keeps only the answers to the questions that applied + +#### reduce (message-driven happy path) + +- adres and correspondentie set, Next advances from adres to beroep +- diploma chosen, Next advances from beroep to controle +- Submit moves a complete Invullen draft to Indienen +- SubmitConfirmed moves Indienen to Ingediend +- SubmitFailed moves Indienen to Mislukt +- Retry returns Mislukt to Indienen with the same data + +#### registration.policy + +- only an active registration within the window is eligible +- struck-off / suspended registrations are never eligible +- statusColor is total over the union +- a well-formed status is always consistent +- a Geregistreerd status without its herregistratieDatum is inconsistent + +#### submit + +- stays in Invullen when the draft is incomplete (no diploma) +- reaches Indienen with a complete, valid draft, carrying its data +- resolve maps Indienen to Ingediend with the referentie +- resolve maps Indienen to Mislukt on a failed submit + +#### submittedRow + +- heading is the type, subtitle is the purpose +- status line carries the status label, reference and submit date +- manual review adds a note +- rejection adds its reason +- meer-info-gevraagd adds its reason, like a rejection + +#### tasksFromProfile + +- offers herregistratie when the server says eligible, with the formatted deadline +- offers nothing when the server says not eligible +- surfaces a notice for a suspended registration (independent of eligibility) +- surfaces a notice for a struck-off registration + +### shared + +#### BackgroundUploadsReturned + +- resolves each in-flight upload to complete or failed + +#### CategoriesLoadFailed / BackgroundSyncAvailability + +- records the error +- flips background sync availability + +#### CategoriesLoaded + +- defaults every category to digital without clobbering existing choices +- clears a prior categoriesError +- drops uploads + channel choices for categories that disappear + +#### DeliveryChannelChanged + +- switching to post removes that category uploads +- rejects post for a category that does not allow it +- switching back to digital starts clean + +#### FileRejected + +- stores a per-category message + +#### FileSelected + +- queues a new upload and clears any rejection for that category +- ignores selection for an unknown category +- ignores selection for a category set to post-delivery +- single-file category: a new selection replaces the existing upload +- multiple category: selections accumulate + +#### PendingSaves registry + +- hasPending is true only while some registered owner has a pending write +- unregister removes an owner so it no longer counts +- flushAll flushes only the pending owners +- flushAll awaits every owner and swallows a rejected flush + +#### RemoteData combinators + +- map only touches Success +- map2 precedence: Failure > Loading > Success + +#### createDebouncedSave + +- flushes after the delay when canSave is true +- does not schedule when canSave is false +- coalesces rapid schedules into a single flush +- flushPending runs the save immediately and clears; no-op when idle +- cancel drops a scheduled save without running it + +#### createHistory + +- starts empty; undo/redo are no-ops +- records pre-edit snapshots, then undoes and redoes through them +- record() clears the redo stack (no dead redo after a fresh edit) +- caps the stack depth +- clear() empties both stacks + +#### createStore + +- applies the pure update on dispatch +- dispatch from inside an effect does not self-loop + +#### delete flow (optimistic, revertible) + +- UploadDeleteRequested keeps the documentId for revert +- UploadDeleteComplete removes the upload +- UploadDeleteFailed reverts to complete with the original documentId + +#### deliveryRefs + +- emits documentId for completed digital uploads and channel for post +- omits digital categories with no completed upload + +#### flushPendingGuard + +- flushes then allows navigation when a write is pending +- allows navigation immediately when nothing is pending + +#### formatDatumNl + +- formats a Date in long Dutch form +- formats an ISO string the same way +- is empty-safe: undefined, null, and empty string all yield the empty string +- returns empty for an unparseable string rather than "Invalid Date" + +#### httpClientFetch + +- sends the pending idempotency key as a header for a write, not a fresh one per attempt +- retries a failing GET twice before giving up +- never retries a failing write + +#### inFlight + +- returns only queued/uploading uploads + +#### localeLinks (nl at root, en under /en/) + +- an nl route (no prefix) links nl to the bare path, en under /en, marks active +- an en route strips the /en prefix for the nl target (deep path, en active) +- keeps query + hash on both targets +- the root maps nl → / and en → /en/ + +#### machineRemoteData + +- maps loading → Loading +- maps failed → Failure carrying an Error with the reason +- maps loaded → Success carrying the whole loaded state + +#### parseBsn (elfproef) + +- accepts a valid BSN (passes the elfproef) +- rejects a 9-digit number that fails the elfproef +- rejects wrong length / non-digits / all zeros + +#### parseMe (trust boundary) + +- parses a known capability list +- parses an empty list (drafter — no capabilities) +- recognizes the admin org-template capability (WP-23) +- recognizes the behandelportal besluit capability (WP-66) +- drops unrecognized capability strings instead of rejecting the response +- rejects malformed responses instead of trusting them + +#### pii maskers + +- maskBsn keeps the last 3 digits +- maskTail keeps the requested tail length +- masks the whole value when it is not longer than the kept tail + +#### problemDetail + +- extracts the detail from an RFC-7807 ProblemDetails +- falls back when there is no detail + +#### problemFieldErrors (G4 seam) + +- maps a ValidationProblemDetails errors dict to first-message-per-field +- returns {} when there is no errors envelope (the current backend shape) + +#### rejectReason + +- rejects a disallowed type +- rejects an oversized file +- accepts a valid file +- allows any type when the category lists none + +#### rich-text + +- emptyBlock is one empty paragraph and reads as empty +- isBlockEmpty is false when any placeholder or non-blank text exists +- placeholderKeysIn walks in document order, keeping duplicates +- deepCopyBlock is an independent value copy (frozen snapshot) + +#### rich-text DOM boundary + +- round-trips text, marks, placeholders, line breaks and multiple paragraphs +- round-trips an empty paragraph (filler <br> is not a line break) +- renders a placeholder as a non-editable chip carrying its key and label +- reads combined marks in canonical order regardless of nesting +- round-trips bullet and numbered lists mixed with paragraphs +- groups consecutive same-kind list lines into one <ul>/<ol> +- adjacentChip finds a chip next to a collapsed caret so Backspace/Delete can remove it +- marks auto-resolvable vs manual chips with data-auto for styling + +#### roleInterceptor + +- leaves an unrelated endpoint untouched + +#### runSubmit + +- folds a resolved call into ok(value) +- maps a ProblemDetails rejection to err(detail) +- falls back when the rejection has no detail + +#### satisfaction helpers + +- a post-delivery choice satisfies a required category +- an active upload satisfies a required category +- a failed upload does not satisfy a required category +- requiredCategoriesSatisfied ignores optional categories + +#### scenario (dev mechanism) + +- reads a valid ?scenario= from the URL and persists it for the tab +- falls back to default when nothing is set or the value is invalid +- setScenario persists the chosen scenario + +#### stripDevParams (WP-37) + +- removes ?scenario and ?role so the stored dev value wins on reload +- keeps unrelated query params and the path/hash +- is a no-op when neither param is present + +#### upload lifecycle messages + +- queued → progress → complete +- failed then retried returns to queued +- UploadRemoved drops the upload + +#### withIdempotencyKey / currentIdempotencyKey + +- threads the key to every read made inside the wrapped fn +- clears the key once the wrapped fn settles +- falls back to a generated uuid-shaped key when none is pending + +### showcase + +#### highlightTs + +- wraps keywords, strings and comments in the styling spans +- escapes HTML metacharacters so the [innerHTML] sink is safe +- treats the whole // tail as one comment (keywords after // are not re-highlighted) + +## Backend (by test class) + +### AdminCasesTests + +- Admin lists every case with its owner +- Non admin is forbidden +- Admin can delete a submitted case +- Deleting a missing case is not found + +### ApplicationRuleTests + +- AanvraagStatusTag covers the published lifecycle + +### ApplicationTests + +- Create then list shows a concept with step progress +- Draft sync is readable back from detail +- Submit duo registratie is in behandeling and auto +- Submit handmatig registratie succeeds as manual case +- Submit herregistratie with zero uren is afgewezen +- Submitting twice conflicts +- Creating a second concept of the same type conflicts +- A concept of a different type is allowed +- A new concept is allowed once the previous one is submitted +- Cancel concept removes it +- Cancel submitted aanvraag conflicts +- Applications are scoped to the caller bsn +- Submitting a foreign documentId is rejected and leaves it deletable by its owner +- Draft sync with a foreign documentId is rejected +- AutoApprovable flips to goedgekeurd after the window +- Manual case never auto advances + +### AuthzAuditTests + +- A denied admin action is recorded +- A reveal attempt is recorded +- The audit schema carries no pii + +### AuthzTests + +- Drafter may not approve or reject even when submitted +- Approver may approve and reject a different drafters letter +- Send is not role gated +- Decisions CanEdit true for drafter in editable statuses +- Decisions CanApprove requires submitted status and approver role +- Decisions CanSend requires approved status only +- RoleCapabilities are empty for drafter and the three brief capabilities for approver +- CanRevealBigNummer only for the case drafter behandelaar +- Decisions CanRevealBigNummer is status independent +- CanBeoordelen true for a medewerker with the behandelaar rol +- CanBeoordelen false for a medewerker without it +- CanBeoordelen false for a zorgverlener regardless of role + +### BeoordelingIdMismatchTests + +- Besluit resolves by referentie when the case id differs from the local aanvraag id + +### BeoordelingRuleTests + +- Only open statuses are decidable +- Only a non approval requires a toelichting +- A terminal decision refuses any further besluit +- MeerInfoOpvragen is not terminal a further besluit is still legal + +### BeoordelingTests + +- Detail shows status documents and a masked owner +- Concept and unknown id are not found +- Zorgverlener is forbidden even with admin role +- Medewerker without behandelaar rol is forbidden +- Goedkeuren advances status to Goedgekeurd +- Afwijzen requires a toelichting +- MeerInfoOpvragen is still decidable afterwards +- Already decided case rejects a further besluit +- Concurrent besluiten on the same aanvraag yield exactly one success +- Unknown id 404s +- Zorgverlener is forbidden from deciding + +### BesluitLifecycleTests + +- A terminal besluit is frozen +- MeerInfoGevraagd can be decided again +- A recorded decision wins over the auto approve computation +- Afwijzen requires a toelichting + +### BriefEndpointTests + +- Get creates a draft with expected sections locked and empty +- Get offers only global and arts scoped besluit tagged passages +- Get joins the case context with the BIG nummer masked +- Reveal returns the unmasked BIG nummer for the drafter with step up +- Reveal is forbidden without the step up +- Reveal is forbidden for a role without the capability +- Save is drafter only +- Submit blocks on empty required section +- Submit succeeds when required sections filled +- Drafter cannot approve own letter but a different reviewer can +- Reject returns comments +- Editing a rejected letter reopens it to draft +- Send only from approved +- Decisions on the view mirror the acting principal and live status +- Me returns no capabilities for drafter and the brief set for approver +- Reset recreates a fresh draft with locked prefilled sections + +### DiplomaRuleTests + +- Profession is derived from program +- English diploma requires dutch proficiency +- Dutch diploma has no policy questions +- Manual diploma gets maximal set +- Manual professions match known programs + +### DocumentRuleTests + +- Rejects unknown category +- Rejects disallowed type +- Rejects oversized file +- Accepts valid file +- First load has no diploma upload +- Manual diploma needs a diploma upload +- Duo diploma skips diploma upload +- Confirmed dutch proficiency requires taalvaardigheid proof +- Unconfirmed proficiency requires no taalvaardigheid proof +- Find resolves taalvaardigheid for upload validation + +### EndpointTests + +- DashboardView computes eligibility decision +- Notes returns seeded aantekeningen +- Brp returns address +- Duo lookup carries server decided questions and professions +- IntakePolicy returns scholing threshold +- Registration with duo diploma succeeds +- Registration with manual diploma is rejected with problem details +- Zero hours submission is rejected +- Worked hours submission succeeds +- Change request with valid phone succeeds +- Change request with bad phone is rejected +- Health endpoint is ok +- Correlation id supplied by the caller is echoed back +- Correlation id is generated when the caller omits it +- Categories are server owned config +- Upload then status reports complete for known localId +- Upload content is served back with its type inline for pdf +- Upload content 404 for unknown document +- Upload rejects wrong type +- User delete succeeds then 404 +- User delete blocked with 409 once linked to submission +- Admin delete requires admin role +- Audit log records upload and delete metadata only + +### FeatureFlagTests + +- The catalog has unique keys +- Get flags returns the catalog +- Toggling is admin only and an unknown key is 404 +- Closing inschrijving blocks a registratie then reopening allows it + +### HerregistratieRuleTests + +- Eligible within window +- Not eligible before window +- Eligible on window boundary +- Suspended is not eligible +- Status consistency invariant + +### IdempotencyTests + +- Replaying the same idempotency key returns the same reference not a new one +- Different idempotency keys are independent submissions +- A rejected submission replays the same rejection not a retry + +### LetterHtmlTests + +- Render matches the golden file +- Every letter prefixed class exists in letter css + +### NotificatieTests + +- Correct shared secret is accepted and recorded +- Wrong secret is rejected and recorded +- Missing authorization header is rejected + +### OpenZaakDocumentSourceTests + +- Upload registers an eio in drc and persists its url locally +- Upload falls back to openbaar for a category absent from the confidentialiteit table +- Upload keeps the local document when the category has no configured informatieobjecttype +- Upload keeps the local document and does not throw when drc rejects it +- LinkToZaak posts a zaakinformatieobject per document once a zaak exists +- LinkToZaak makes no zgw call when the local source created no zaak + +### OpenZaakIntegrationTests + +- Admin cases returns the seeded zaak mapped through real HTTP and JWT + +### OpenZaakZaakSourceTests + +- Follows pagination maps the internal aanvraag type and sends bearer token +- ListCases throws when a zaak zaaktype has no configured aanvraag type +- ListMyCases filters by the callers bsn +- CreateZaak posts zaak status and rol and maps the result back +- CreateZaak throws when the aanvraag type has no configured zaaktype +- RecordBesluit posts the last statustype with besluit and toelichting +- RecordBesluit creates a resultaat before posting the eindstatus +- RecordBesluit does nothing when the aanvraag has no zaak +- RecordBesluit throws when the aanvraag type has no configured zaaktype +- CreateZaak retries a transient failure and then succeeds +- CreateZaak gives up after three attempts on a persistent transient failure +- CreateZaak does not retry a permanent rejection + +### OrgTemplateEndpointTests + +- Admin endpoints are admin only +- Publish increments the version +- Publish appends to the version history +- Publish counts the unsent briefs it affects +- Save draft validates margins +- Save draft round trips the edited values +- Rollback copies an old version into the draft without rewriting history +- Sent brief keeps its pinned template after a republish +- Unsent brief follows a republish +- Me returns the orgtemplate capability for admin +- Admin cannot slip into the brief review flow + +### OrgTemplateRuleTests + +- Accepts a complete draft within the margin bounds +- Rejects a missing organisation name +- Rejects a missing signature name +- Rejects a margin outside the allowed range +- Accepts margins on the boundary + +### PreviewEndpointTests + +- Preview of an unsent brief renders live with a watermark +- Preview of a sent brief serves the archive unchanged after a republish +- Proefbrief is admin only +- Proefbrief renders the draft template with a watermark + +### StamdataEndpointTests + +- Stamdata reads are admin only +- Table list exposes the reflected schema +- Table returns all rows without a peildatum +- Peildatum before the seed windows hides every row +- Unknown table is 404 + +### StamdataValidationTests + +- Every declared reference into stamdata resolves +- Profession map has no blank programs or professions +- Manual professions are non empty and distinct +- Every policy question has an id and wording +- Manual question set has distinct ids +- Every catalog table is valid + +### StubIdentityProviderTests + +- No headers resolves to the seeded citizen as a drafter +- X role maps to the principal role +- X subject overrides the default bsn +- No headers resolves a zorgverlener kind +- X medewerker resolves a medewerker with the default behandelaar rol +- X medewerker takes precedence over x subject +- Empty x medewerker falls through to the zorgverlener default +- X rollen parses known tokens and drops unknown ones +- X role still applies to a medewerker + +### SubmissionRuleTests + +- Manual diploma is rejected +- Duo diploma is accepted +- Zero hours is rejected +- Worked hours are accepted +- Phone change is validated + +### WerkvoorraadTests + +- Behandelaar sees submitted cases in the queue +- Queue excludes concepts +- Zorgverlener is forbidden even with admin role +- Medewerker without behandelaar rol is forbidden +- Me reports the capability only for a behandelaar + +### ZgwDiagnosticHandlerTests + +- Passes request and response through unchanged + +### ZgwDivergenceTests + +- Submit with a failing zgw flags the divergence instead of diverging silently +- Submit with a healthy zgw leaves no divergence flag + +### ZgwTokenProviderTests + +- Mints a three part jwt with the required claims +- Mint with a caller carries that citizen not the static config identity +- Mint with a medewerker caller uses the medewerkerId as user id +- Signature verifies with the shared secret + +### ZgwZaakMapperTests + +- Maps url identity zaaktype and open status +- Closed zaak maps to goedgekeurd +- Uuid extracts trailing segment ignoring trailing slash diff --git a/scripts/check-seam.sh b/scripts/check-seam.sh new file mode 100755 index 0000000..7ce848c --- /dev/null +++ b/scripts/check-seam.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# WP-71 (Track E): fail if the backend's scholing-threshold policy default and the frontend's +# offline fallback default drift apart. ADR-0001's "config value" shape means the backend is +# the authority (GET /intake/policy) and the FE only keeps SCHOLING_THRESHOLD_DEFAULT as an +# offline/first-paint fallback (intake.machine.ts) — but the two literals are otherwise +# unlinked, so nothing stops them silently diverging. This is a cheap grep-based tripwire, not +# a build-time link between the two languages. +set -uo pipefail + +BACKEND_FILE='backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs' +FRONTEND_FILE='apps/ssp/src/app/herregistratie/domain/intake.machine.ts' + +backend_value=$(grep -oE 'ScholingThreshold\s*=\s*[0-9]+' "$BACKEND_FILE" | grep -oE '[0-9]+$') +frontend_value=$(grep -oE 'SCHOLING_THRESHOLD_DEFAULT\s*=\s*[0-9]+' "$FRONTEND_FILE" | grep -oE '[0-9]+$') + +if [ -z "$backend_value" ]; then + echo "FAIL: could not find IntakePolicy.ScholingThreshold in $BACKEND_FILE" + exit 1 +fi +if [ -z "$frontend_value" ]; then + echo "FAIL: could not find SCHOLING_THRESHOLD_DEFAULT in $FRONTEND_FILE" + exit 1 +fi + +if [ "$backend_value" != "$frontend_value" ]; then + echo "FAIL: FE/BE seam drift on the scholing threshold default" + echo " $BACKEND_FILE: ScholingThreshold = $backend_value" + echo " $FRONTEND_FILE: SCHOLING_THRESHOLD_DEFAULT = $frontend_value" + echo 'Both literals represent the same intake policy default (ADR-0001 config value) and must match.' + exit 1 +fi + +echo "OK: scholing threshold default matches on both sides ($backend_value)" diff --git a/scripts/gen-behaviour-spec.mjs b/scripts/gen-behaviour-spec.mjs new file mode 100644 index 0000000..6013922 --- /dev/null +++ b/scripts/gen-behaviour-spec.mjs @@ -0,0 +1,218 @@ +#!/usr/bin/env node +// Generate a business-readable "behaviour spec" page FROM real test names (WP-71, Track D). +// The team considered Cucumber/Gherkin for BDD scenarios and rejected it (runtime string +// matching undoes the compile-time guarantees WP-70 just bought, and needs two frameworks for +// .NET+TS). Instead: test names ARE the spec — this script only extracts and formats them, so +// the page can never drift from the suite. Mirrors the gen-snippets.mjs pattern (pure Node, +// reads real source files, writes ONE generated file, checked for drift in CI the same way). +// Run: `npm run gen:behaviour-spec`. +import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; + +const EXCLUDED_DIRS = new Set(['node_modules', 'dist', 'coverage', 'bin', 'obj', '.git']); + +/** Recursively collect files under `dir` matching `pattern`, skipping excluded directories. */ +function walk(dir, pattern) { + const out = []; + for (const entry of readdirSync(dir)) { + if (EXCLUDED_DIRS.has(entry)) continue; + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) out.push(...walk(full, pattern)); + else if (pattern.test(entry)) out.push(full); + } + return out.sort(); +} + +// --------------------------------------------------------------------------- +// Frontend: apps/**/*.spec.ts + libs/**/*.spec.ts — describe()/it() pairs. +// --------------------------------------------------------------------------- + +const QUOTED = `(?:'([^']*)'|"([^"]*)"|` + '`([^`]*)`)'; +const DESCRIBE_RE = new RegExp(`\\bdescribe(?:\\.\\w+)?\\(\\s*${QUOTED}`); +const IT_RE = new RegExp(`\\bit(?:\\.\\w+)?\\(\\s*${QUOTED}`); + +/** Which app/context folder a spec file belongs to, for grouping (registratie, brief, …). */ +function feContextFor(path) { + const norm = path.split(sep).join('/'); + const appMatch = norm.match(/^apps\/(?:ssp|behandelportal)\/src\/app\/([^/]+)\//); + if (appMatch) return appMatch[1]; + const libMatch = norm.match(/^libs\/([^/]+)\/src\//); + if (libMatch) return libMatch[1]; + return 'other'; +} + +/** + * Extract { describePath: string[], text: string } for every `it(...)` in a spec file, using + * a brace-depth stack to track nested `describe(...)` blocks (a line-scan, not a TS parser — + * this repo's spec files are one describe/it call per line, same precedent as gen-snippets.mjs). + */ +function extractSpecBehaviours(source) { + const lines = source.split('\n'); + let depth = 0; + const stack = []; // { name, depth } + const results = []; + for (const line of lines) { + if (/^\s*\/\//.test(line)) continue; // skip commented-out lines + const dm = line.match(DESCRIBE_RE); + const im = !dm && line.match(IT_RE); + if (dm) { + stack.push({ name: dm[1] ?? dm[2] ?? dm[3], depth }); + } else if (im) { + results.push({ describePath: stack.map((s) => s.name), text: im[1] ?? im[2] ?? im[3] }); + } + const open = (line.match(/{/g) || []).length; + const close = (line.match(/}/g) || []).length; + depth += open - close; + while (stack.length && depth <= stack[stack.length - 1].depth) stack.pop(); + } + return results; +} + +const feSpecFiles = [...walk('apps', /\.spec\.ts$/), ...walk('libs', /\.spec\.ts$/)]; + +/** @type {Map>} context -> describe-block label -> it() texts */ +const feBehaviour = new Map(); +for (const file of feSpecFiles) { + const context = feContextFor(file); + const relPath = relative('.', file).split(sep).join('/'); + const behaviours = extractSpecBehaviours(readFileSync(file, 'utf8')); + for (const { describePath, text } of behaviours) { + const label = describePath.length ? describePath.join(' › ') : `(${relPath})`; + if (!feBehaviour.has(context)) feBehaviour.set(context, new Map()); + const byLabel = feBehaviour.get(context); + if (!byLabel.has(label)) byLabel.set(label, []); + byLabel.get(label).push(text); + } +} + +// --------------------------------------------------------------------------- +// Backend: backend/tests/BigRegister.Tests/**/*.cs — [Fact]/[Theory] methods. +// --------------------------------------------------------------------------- + +const CLASS_RE = /^\s*(?:public|internal)\s+(?:sealed\s+)?class\s+(\w+)/; +const FACT_OR_THEORY_RE = /^\s*\[(?:Fact|Theory)\b/; +const METHOD_RE = /\b(?:void|Task(?:<[^>]*>)?)\s+(\w+)\s*\(/; + +/** PascalCase_snake_sentence method name -> readable sentence (just spaces for underscores). */ +function toSentence(methodName) { + return methodName.replace(/_/g, ' '); +} + +/** Extract { className, sentence } for every [Fact]/[Theory]-attributed method in a .cs file. */ +function extractCsBehaviours(source) { + const lines = source.split('\n'); + let currentClass = null; + const results = []; + for (let i = 0; i < lines.length; i++) { + const cm = lines[i].match(CLASS_RE); + if (cm) { + currentClass = cm[1]; + continue; + } + if (!FACT_OR_THEORY_RE.test(lines[i])) continue; + // Skip any further attribute lines (e.g. [InlineData(...)] rows on a [Theory]) and blank + // lines to reach the method declaration itself. + let j = i + 1; + while (j < lines.length && (/^\s*\[/.test(lines[j]) || /^\s*$/.test(lines[j]))) j++; + const mm = lines[j] && lines[j].match(METHOD_RE); + if (mm && currentClass) results.push({ className: currentClass, sentence: toSentence(mm[1]) }); + } + return results; +} + +const csFiles = walk('backend/tests/BigRegister.Tests', /\.cs$/); + +/** @type {Map} class name -> sentences */ +const beBehaviour = new Map(); +for (const file of csFiles) { + for (const { className, sentence } of extractCsBehaviours(readFileSync(file, 'utf8'))) { + if (!beBehaviour.has(className)) beBehaviour.set(className, []); + beBehaviour.get(className).push(sentence); + } +} + +// --------------------------------------------------------------------------- +// Emit libs/shared/docs/behaviour-spec.mdx +// --------------------------------------------------------------------------- + +// MDX parses markdown as JSX-in-Markdown: a bare ``/`{expr}` in test-name text (e.g. +// "renders each field group as its own grey
") would otherwise be read as JSX and +// fail the build. Test names are data, not markup — escape them before embedding. +function mdxEscape(text) { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\{/g, '{') + .replace(/\}/g, '}'); +} + +function renderFeSection(context) { + const byLabel = feBehaviour.get(context); + const labels = [...byLabel.keys()].sort(); + const blocks = labels.map((label) => { + const items = byLabel + .get(label) + .map((t) => `- ${mdxEscape(t)}`) + .join('\n'); + return `#### ${mdxEscape(label)}\n\n${items}`; + }); + return `### ${mdxEscape(context)}\n\n${blocks.join('\n\n')}`; +} + +function renderBeSection(className) { + const items = beBehaviour + .get(className) + .map((t) => `- ${mdxEscape(t)}`) + .join('\n'); + return `### ${mdxEscape(className)}\n\n${items}`; +} + +const feContexts = [...feBehaviour.keys()].sort(); +const feCount = feContexts.reduce((n, c) => n + [...feBehaviour.get(c).values()].flat().length, 0); +const beClasses = [...beBehaviour.keys()].sort(); +const beCount = beClasses.reduce((n, c) => n + beBehaviour.get(c).length, 0); + +const feSections = feContexts.map(renderFeSection).join('\n\n'); +const beSections = beClasses.map(renderBeSection).join('\n\n'); + +const mdx = `{/* GENERATED by \`npm run gen:behaviour-spec\` (scripts/gen-behaviour-spec.mjs) — do not +edit. Every bullet below is a real \`it()\` title or backend test method name, extracted +verbatim from the suite. The team rejected Cucumber/Gherkin for BDD scenarios (runtime string +matching undoes the compile-time guarantees WP-70 bought, and needs two frameworks for +.NET+TS) — this page is the replacement: business-readable documentation generated FROM test +names, so it can never drift from what the suite actually asserts. A test name changing (or a +test being added/removed) is the only way this page changes; hand-editing it is pointless, +the next \`npm run gen:behaviour-spec\` overwrites it. */} + +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Behaviour spec + +_Generated by \`npm run gen:behaviour-spec\` — do not hand-edit; the next generation +overwrites this page. See [BDD](?path=/docs/foundations-bdd--docs) for how these names are +written, and [Testing strategy](?path=/docs/foundations-testing-strategy--docs) for what gets +tested where._ + +Every bullet below is a real test name from the suite — an \`it()\` title (frontend) or a test +method name (backend), read as a sentence. Nothing here is hand-written prose: this page +**is** the suite, reshaped for a business reader. ${feCount} frontend behaviours across +${feContexts.length} contexts; ${beCount} backend behaviours across ${beClasses.length} test +classes. + +## Frontend (by context) + +${feSections} + +## Backend (by test class) + +${beSections} +`; + +writeFileSync('libs/shared/docs/behaviour-spec.mdx', mdx); +console.log( + `wrote libs/shared/docs/behaviour-spec.mdx (${feCount} frontend behaviours in ${feContexts.length} contexts, ${beCount} backend behaviours in ${beClasses.length} classes)`, +);