test: close illegal-state escape hatches in spec type-safety (WP-71)

ESLint blanket-exempted every *.spec.ts from the any ban, and no gate
type-checked spec files at all (ng test is transpile-only), so a wrong
cast in a test could never fail the build. 76 `as any` + 12 `as
Extract<>` state-narrowing casts in the three biggest wizard specs read
one variant's fields off a whole-union value: if the reducer returned
the wrong variant, the assertion silently read undefined instead of
failing.

expectTag(state, tag) (libs/shared/src/testing/expect-tag.ts) asserts
and narrows in one call, replacing every one of those casts. Removes
the spec-file any exemption, adds `npm run typecheck` (tsc --noEmit
over each project's tsconfig.spec.json) to CI, and forbids production
code from importing libs/shared/src/testing via dependency-cruiser.
Backend: AanvraagBuilder now models ZaakUrl (closing the last
post-Build() mutation) and guards AtStep; null-forgiving `!` on
endpoint assertions replaced with Assert.NotNull so a null DTO fails by
name, not NullReferenceException.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-18 20:24:53 +02:00
co-authored by Claude Sonnet 5
parent 66224b1644
commit b937e55ad3
21 changed files with 536 additions and 286 deletions
@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest';
import { expectTag } from '@shared/testing/expect-tag';
import { OrgTemplate, OrgTemplateAdminView } from './org-template';
import { OrgTemplateState, reduce } from './org-template.machine';
import { DocumentCategory } from '@shared/upload/upload.machine';
@@ -40,9 +41,7 @@ const logoCategory: DocumentCategory = {
describe('org-template.machine', () => {
it('DraftLoaded moves to loaded with the draft, clean', () => {
const s = loaded();
expect(s.tag).toBe('loaded');
if (s.tag !== 'loaded') return;
const s = expectTag(loaded(), 'loaded');
expect(s.draft.orgName).toBe('CIBG');
expect(s.subOrgId).toBe('cibg-registers');
expect(s.unsentBriefs).toBe(2);
@@ -55,33 +54,44 @@ describe('org-template.machine', () => {
});
it('FieldEdited edits the draft and marks dirty', () => {
const s = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' });
expect(s.tag === 'loaded' && s.draft.orgName).toBe('CIBG Nieuw');
expect(s.tag === 'loaded' && s.dirty).toBe(true);
const s = expectTag(
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' }),
'loaded',
);
expect(s.draft.orgName).toBe('CIBG Nieuw');
expect(s.dirty).toBe(true);
});
it('MarginEdited edits one edge and marks dirty', () => {
const s = reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 });
expect(s.tag === 'loaded' && s.draft.margins.topMm).toBe(40);
expect(s.tag === 'loaded' && s.draft.margins.leftMm).toBe(20);
expect(s.tag === 'loaded' && s.dirty).toBe(true);
const s = expectTag(
reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 }),
'loaded',
);
expect(s.draft.margins.topMm).toBe(40);
expect(s.draft.margins.leftMm).toBe(20);
expect(s.dirty).toBe(true);
});
it('DraftSaved clears dirty when the saved draft is the current one', () => {
const edited = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' });
const savedDraft = edited.tag === 'loaded' ? edited.draft : template;
const s = reduce(edited, { tag: 'DraftSaved', savedDraft });
expect(s.tag === 'loaded' && s.dirty).toBe(false);
expect(s.tag === 'loaded' && s.draft.orgName).toBe('X');
const edited = expectTag(
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
'loaded',
);
const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'loaded');
expect(s.dirty).toBe(false);
expect(s.draft.orgName).toBe('X');
});
it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => {
const editing = reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' });
const savedDraft = editing.tag === 'loaded' ? editing.draft : template;
const editing = expectTag(
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
'loaded',
);
const savedDraft = editing.draft;
// a further edit changes the draft reference before the save resolves
const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' });
const s = reduce(raced, { tag: 'DraftSaved', savedDraft });
expect(s.tag === 'loaded' && s.dirty).toBe(true);
const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'loaded');
expect(s.dirty).toBe(true);
});
it('edits are no-ops in non-loaded states', () => {
@@ -107,12 +117,15 @@ describe('org-template.machine', () => {
fileSizeMb: 0.1,
},
});
const done = reduce(selected, {
tag: 'Upload',
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
});
expect(done.tag === 'loaded' && done.draft.logoDocumentId).toBe('doc-1');
expect(done.tag === 'loaded' && done.dirty).toBe(true);
const done = expectTag(
reduce(selected, {
tag: 'Upload',
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
}),
'loaded',
);
expect(done.draft.logoDocumentId).toBe('doc-1');
expect(done.dirty).toBe(true);
});
it('removing the logo clears logoDocumentId + dirty', () => {
@@ -120,12 +133,15 @@ describe('org-template.machine', () => {
tag: 'Upload',
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
});
const removed = reduce(withLogo, {
tag: 'Upload',
msg: { type: 'UploadRemoved', localId: 'a' },
});
expect(removed.tag === 'loaded' && removed.draft.logoDocumentId).toBeUndefined();
expect(removed.tag === 'loaded' && removed.dirty).toBe(true);
const removed = expectTag(
reduce(withLogo, {
tag: 'Upload',
msg: { type: 'UploadRemoved', localId: 'a' },
}),
'loaded',
);
expect(removed.draft.logoDocumentId).toBeUndefined();
expect(removed.dirty).toBe(true);
});
it('DraftLoaded (sub-org switch) keeps the loaded logo category, drops uploads', () => {
@@ -133,12 +149,15 @@ describe('org-template.machine', () => {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [logoCategory] },
});
const switched = reduce(withCat, {
tag: 'DraftLoaded',
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
});
expect(switched.tag === 'loaded' && switched.upload.categories).toHaveLength(1);
expect(switched.tag === 'loaded' && switched.upload.uploads).toHaveLength(0);
expect(switched.tag === 'loaded' && switched.subOrgId).toBe('cibg-vakbekwaamheid');
const switched = expectTag(
reduce(withCat, {
tag: 'DraftLoaded',
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
}),
'loaded',
);
expect(switched.upload.categories).toHaveLength(1);
expect(switched.upload.uploads).toHaveLength(0);
expect(switched.subOrgId).toBe('cibg-vakbekwaamheid');
});
});