From 28c0a250e79b450a43dff2af0318031dd68435a1 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Tue, 18 Aug 2026 20:25:05 +0200 Subject: [PATCH] test(backend): split RuleTests.cs by aggregate, refresh DDD doc (WP-71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RuleTests.cs held five aggregates' rules as nested classes in one file, misaligned with Domain// and with the Acceptance/Builders/ folder convention WP-70 started. Split into Domain/RuleTests.cs (pure move — same names, same bodies, same count) plus a new ApplicationRuleTests.cs (the enum invariant moved out of the WebApplicationFactory-booting ApplicationTests.cs, since it's a pure Enum.GetNames check with no business needing a web host) and OrgTemplateRuleTests.cs (RejectDraft had no direct unit test before, only endpoint coverage). libs/shared/docs/layers.mdx still taught the pre-WP-67 shape (six contexts, no apps/libs split, enforcement via ESLint) — updated to the real monorepo structure and to dependency-cruiser as the actual enforcement mechanism. Adds specs for registration.policy.ts's isStatusConsistent (untested; its backend mirror is) and both apps' auth/domain/session.ts. Co-Authored-By: Claude Sonnet 5 --- .../src/app/auth/domain/session.spec.ts | 14 ++ apps/ssp/src/app/auth/domain/session.spec.ts | 14 ++ .../intake-wizard.component.spec.ts | 8 + .../domain/registration.policy.spec.ts | 26 +- .../Domain/ApplicationRuleTests.cs | 17 ++ .../Domain/BeoordelingRuleTests.cs | 51 ++++ .../Domain/DiplomaRuleTests.cs | 42 ++++ .../Domain/DocumentRuleTests.cs | 58 +++++ .../Domain/OrgTemplateRuleTests.cs | 43 ++++ .../Domain/RegistrationRuleTests.cs | 57 +++++ .../Domain/SubmissionRuleTests.cs | 30 +++ backend/tests/BigRegister.Tests/RuleTests.cs | 230 ------------------ libs/shared/docs/layers.mdx | 73 ++++-- 13 files changed, 409 insertions(+), 254 deletions(-) create mode 100644 apps/behandelportal/src/app/auth/domain/session.spec.ts create mode 100644 apps/ssp/src/app/auth/domain/session.spec.ts create mode 100644 backend/tests/BigRegister.Tests/Domain/ApplicationRuleTests.cs create mode 100644 backend/tests/BigRegister.Tests/Domain/BeoordelingRuleTests.cs create mode 100644 backend/tests/BigRegister.Tests/Domain/DiplomaRuleTests.cs create mode 100644 backend/tests/BigRegister.Tests/Domain/DocumentRuleTests.cs create mode 100644 backend/tests/BigRegister.Tests/Domain/OrgTemplateRuleTests.cs create mode 100644 backend/tests/BigRegister.Tests/Domain/RegistrationRuleTests.cs create mode 100644 backend/tests/BigRegister.Tests/Domain/SubmissionRuleTests.cs delete mode 100644 backend/tests/BigRegister.Tests/RuleTests.cs diff --git a/apps/behandelportal/src/app/auth/domain/session.spec.ts b/apps/behandelportal/src/app/auth/domain/session.spec.ts new file mode 100644 index 0000000..c46343d --- /dev/null +++ b/apps/behandelportal/src/app/auth/domain/session.spec.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; +import { isAuthenticated, Session } from './session'; + +const session: Session = { bsn: '19012345601', naam: 'Test' }; + +describe('isAuthenticated', () => { + it('narrows a present session to Session', () => { + expect(isAuthenticated(session)).toBe(true); + }); + + it('reports no session as not authenticated', () => { + expect(isAuthenticated(null)).toBe(false); + }); +}); diff --git a/apps/ssp/src/app/auth/domain/session.spec.ts b/apps/ssp/src/app/auth/domain/session.spec.ts new file mode 100644 index 0000000..c46343d --- /dev/null +++ b/apps/ssp/src/app/auth/domain/session.spec.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; +import { isAuthenticated, Session } from './session'; + +const session: Session = { bsn: '19012345601', naam: 'Test' }; + +describe('isAuthenticated', () => { + it('narrows a present session to Session', () => { + expect(isAuthenticated(session)).toBe(true); + }); + + it('reports no session as not authenticated', () => { + expect(isAuthenticated(null)).toBe(false); + }); +}); diff --git a/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.spec.ts b/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.spec.ts index d3ce1a8..69b0804 100644 --- a/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.spec.ts +++ b/apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.spec.ts @@ -10,6 +10,14 @@ import { IntakeState } from '@herregistratie/domain/intake.machine'; // wrapping is dropped, the inputs revert to bare white. The buitenland step with // buitenlandGewerkt='ja' has two groups (the question + the land/uren follow-up), so it // must render ≥2 fieldsets, each holding a form-group. +// +// SANCTIONED TestBed exception (CLAUDE.md "Testing": UI is normally exercised via Storybook, +// not heavy component tests): this pins the *count and nesting* of rendered DOM nodes for a +// specific machine state (2+
wrappers, each containing a .form-group), which is a +// structural/visual regression, not an accessibility one — a Storybook a11y (axe) run checks +// for accessibility violations on whatever markup is rendered, it does not assert that the +// markup takes this particular shape, so it would not catch the grouping silently collapsing +// back to bare inputs. const buitenlandJa: IntakeState = { tag: 'Answering', answers: { buitenlandGewerkt: 'ja' }, diff --git a/apps/ssp/src/app/registratie/domain/registration.policy.spec.ts b/apps/ssp/src/app/registratie/domain/registration.policy.spec.ts index bdab5b3..077d388 100644 --- a/apps/ssp/src/app/registratie/domain/registration.policy.spec.ts +++ b/apps/ssp/src/app/registratie/domain/registration.policy.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { Registration } from './registration'; -import { isHerregistratieEligible, statusColor } from './registration.policy'; +import { Registration, RegistrationStatus } from './registration'; +import { isHerregistratieEligible, isStatusConsistent, statusColor } from './registration.policy'; const reg = (status: Registration['status']): Registration => ({ bigNummer: '19012345601', @@ -38,4 +38,26 @@ describe('registration.policy', () => { expect(statusColor('Doorgehaald')).toContain('rood'); expect(statusColor('Geschorst')).toContain('oranje'); }); + + it('a well-formed status is always consistent', () => { + expect( + isStatusConsistent(reg({ tag: 'Geregistreerd', herregistratieDatum: '2027-01-01' }).status), + ).toBe(true); + expect( + isStatusConsistent( + reg({ tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'x' }).status, + ), + ).toBe(true); + expect( + isStatusConsistent(reg({ tag: 'Geschorst', geschorstTot: '2026-12-31', reden: 'x' }).status), + ).toBe(true); + }); + + it('a Geregistreerd status without its herregistratieDatum is inconsistent', () => { + // The union itself makes this unrepresentable through normal construction (every + // Geregistreerd literal must carry a herregistratieDatum) — only reachable here by + // bypassing the type system, the way malformed runtime/serialized data could. + const malformed = { tag: 'Geregistreerd' } as unknown as RegistrationStatus; + expect(isStatusConsistent(malformed)).toBe(false); + }); }); diff --git a/backend/tests/BigRegister.Tests/Domain/ApplicationRuleTests.cs b/backend/tests/BigRegister.Tests/Domain/ApplicationRuleTests.cs new file mode 100644 index 0000000..29a1a55 --- /dev/null +++ b/backend/tests/BigRegister.Tests/Domain/ApplicationRuleTests.cs @@ -0,0 +1,17 @@ +using BigRegister.Domain.Applications; + +namespace BigRegister.Tests.Domain; + +public class ApplicationRuleTests +{ + // WP-63: the published lifecycle (ADR-0002) must name exactly these five tags, in this + // order — ToStatusDto's string literals must keep matching Enum.ToString(), and Ingediend/ + // MeerInfoGevraagd (unreachable until WP-65 adds the behandelaar transition) stay defined. + [Fact] + public void AanvraagStatusTag_covers_the_published_lifecycle() + { + Assert.Equal( + new[] { "Ingediend", "InBehandeling", "MeerInfoGevraagd", "Goedgekeurd", "Afgewezen" }, + Enum.GetNames()); + } +} diff --git a/backend/tests/BigRegister.Tests/Domain/BeoordelingRuleTests.cs b/backend/tests/BigRegister.Tests/Domain/BeoordelingRuleTests.cs new file mode 100644 index 0000000..f9a6c95 --- /dev/null +++ b/backend/tests/BigRegister.Tests/Domain/BeoordelingRuleTests.cs @@ -0,0 +1,51 @@ +using BigRegister.Domain.Applications; +using BigRegister.Domain.Beoordeling; +using BigRegister.Tests.Builders; + +namespace BigRegister.Tests.Domain; + +public class BeoordelingRuleTests +{ + [Theory] + [InlineData(AanvraagStatusTag.Ingediend, true)] + [InlineData(AanvraagStatusTag.InBehandeling, true)] + [InlineData(AanvraagStatusTag.MeerInfoGevraagd, true)] + [InlineData(AanvraagStatusTag.Goedgekeurd, false)] + [InlineData(AanvraagStatusTag.Afgewezen, false)] + public void Only_open_statuses_are_decidable(AanvraagStatusTag tag, bool expected) => + Assert.Equal(expected, BeoordelingRules.CanDecide(tag)); + + // WP-68 (F6): the toelichting rule, moved here from an inline endpoint check. + [Theory] + [InlineData(Besluit.Goedkeuren, false)] + [InlineData(Besluit.Afwijzen, true)] + [InlineData(Besluit.MeerInfoOpvragen, true)] + public void Only_a_non_approval_requires_a_toelichting(Besluit besluit, bool expected) => + Assert.Equal(expected, BeoordelingRules.RequiresToelichting(besluit)); + + // WP-68 (T3): the transition table at the AGGREGATE level, not just against a bare tag — + // an Aanvraag whose BesluitStatus already records a terminal decision computes a terminal + // StatusAt, and CanDecide refuses a further besluit regardless of which one. Pins the + // domain statement "Afgewezen/Goedgekeurd → no further besluit" independent of the + // endpoint's own (integration-level) Already_decided_case_rejects_a_further_besluit. + // WP-70: built via Given, not a hand-rolled Aanvraag literal — Decided(Besluit.Afwijzen) with + // no toelichting simply couldn't compile as a fixture here. + [Theory] + [InlineData(Besluit.Goedkeuren)] + [InlineData(Besluit.Afwijzen)] + public void A_terminal_decision_refuses_any_further_besluit(Besluit recorded) + { + var now = DateTimeOffset.UtcNow; + var toelichting = recorded == Besluit.Goedkeuren ? null : "toelichting"; + var aanvraag = Given.Concept(owner: "test").Submitted().Decided(recorded, toelichting).Build(); + Assert.False(BeoordelingRules.CanDecide(aanvraag.StatusAt(now).Tag!.Value)); + } + + [Fact] + public void MeerInfoOpvragen_is_not_terminal_a_further_besluit_is_still_legal() + { + var now = DateTimeOffset.UtcNow; + var aanvraag = Given.Concept(owner: "test").Submitted().Decided(Besluit.MeerInfoOpvragen, "toelichting").Build(); + Assert.True(BeoordelingRules.CanDecide(aanvraag.StatusAt(now).Tag!.Value)); + } +} diff --git a/backend/tests/BigRegister.Tests/Domain/DiplomaRuleTests.cs b/backend/tests/BigRegister.Tests/Domain/DiplomaRuleTests.cs new file mode 100644 index 0000000..a6382cb --- /dev/null +++ b/backend/tests/BigRegister.Tests/Domain/DiplomaRuleTests.cs @@ -0,0 +1,42 @@ +using BigRegister.Domain.Diplomas; + +namespace BigRegister.Tests.Domain; + +public class DiplomaRuleTests +{ + private static Diploma Diploma(string opleiding, bool engelstalig) => + new("x", "naam", "instelling", 2011, opleiding, engelstalig); + + [Theory] + [InlineData("geneeskunde", "Arts")] + [InlineData("verpleegkunde", "Verpleegkundige")] + [InlineData("onbekend-programma", "Onbekend")] + public void Profession_is_derived_from_program(string opleiding, string expected) => + Assert.Equal(expected, DiplomaRules.ProfessionFor(Diploma(opleiding, false))); + + [Fact] + public void English_diploma_requires_dutch_proficiency() + { + var questions = DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: true)); + Assert.Single(questions); + Assert.Equal("nl-taalvaardigheid", questions[0].Id); + } + + [Fact] + public void Dutch_diploma_has_no_policy_questions() => + Assert.Empty(DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: false))); + + [Fact] + public void Manual_diploma_gets_maximal_set() + { + var questions = DiplomaRules.ManualQuestions(); + Assert.Equal(3, questions.Count); + Assert.Equal(new[] { "nl-taalvaardigheid", "diploma-erkend", "toelichting" }, + questions.Select(q => q.Id)); + } + + [Fact] + public void Manual_professions_match_known_programs() => + Assert.Equal(new[] { "Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts" }, + DiplomaRules.ManualProfessions()); +} diff --git a/backend/tests/BigRegister.Tests/Domain/DocumentRuleTests.cs b/backend/tests/BigRegister.Tests/Domain/DocumentRuleTests.cs new file mode 100644 index 0000000..9671088 --- /dev/null +++ b/backend/tests/BigRegister.Tests/Domain/DocumentRuleTests.cs @@ -0,0 +1,58 @@ +using BigRegister.Domain.Documents; + +namespace BigRegister.Tests.Domain; + +public class DocumentRuleTests +{ + [Fact] + public void Rejects_unknown_category() => + Assert.NotNull(DocumentRules.RejectUpload(null, "application/pdf", 1)); + + [Fact] + public void Rejects_disallowed_type() + { + var c = DocumentRules.Find("registratie", "diploma"); + Assert.NotNull(DocumentRules.RejectUpload(c, "text/plain", 1)); + } + + [Fact] + public void Rejects_oversized_file() + { + var c = DocumentRules.Find("registratie", "diploma"); + Assert.NotNull(DocumentRules.RejectUpload(c, "application/pdf", 11L * 1024 * 1024)); + } + + [Fact] + public void Accepts_valid_file() + { + var c = DocumentRules.Find("registratie", "diploma"); + Assert.Null(DocumentRules.RejectUpload(c, "application/pdf", 5L * 1024 * 1024)); + } + + private static IReadOnlyList Ids(string? herkomst, string? taalvaardigheid) => + DocumentRules.CategoriesFor("registratie", herkomst, taalvaardigheid).Select(c => c.CategoryId).ToList(); + + [Fact] + public void First_load_has_no_diploma_upload() => // no diploma chosen yet + Assert.Equal(new[] { "identiteit" }, Ids(null, null)); + + [Fact] + public void Manual_diploma_needs_a_diploma_upload() => + Assert.Equal(new[] { "diploma", "identiteit" }, Ids("handmatig", null)); + + [Fact] + public void Duo_diploma_skips_diploma_upload() => + Assert.DoesNotContain("diploma", Ids("duo", null)); + + [Fact] + public void Confirmed_dutch_proficiency_requires_taalvaardigheid_proof() => + Assert.Contains("taalvaardigheid", Ids("handmatig", "ja")); + + [Fact] + public void Unconfirmed_proficiency_requires_no_taalvaardigheid_proof() => + Assert.DoesNotContain("taalvaardigheid", Ids("handmatig", "nee")); + + [Fact] + public void Find_resolves_taalvaardigheid_for_upload_validation() => + Assert.NotNull(DocumentRules.Find("registratie", "taalvaardigheid")); +} diff --git a/backend/tests/BigRegister.Tests/Domain/OrgTemplateRuleTests.cs b/backend/tests/BigRegister.Tests/Domain/OrgTemplateRuleTests.cs new file mode 100644 index 0000000..1877c0e --- /dev/null +++ b/backend/tests/BigRegister.Tests/Domain/OrgTemplateRuleTests.cs @@ -0,0 +1,43 @@ +using BigRegister.Api.Contracts; +using BigRegister.Domain.Letters; + +namespace BigRegister.Tests.Domain; + +public class OrgTemplateRuleTests +{ + private static OrgTemplateDto Draft( + string orgName = "BIG-register", + string signatureName = "J. Jansen", + MarginsDto? margins = null) => + new( + SubOrgId: "registers", OrgName: orgName, ReturnAddress: "Postbus 1, Den Haag", + LogoDocumentId: null, FooterContact: "info@example.nl", FooterLegal: "KvK 12345678", + SignatureName: signatureName, SignatureRole: "Manager", SignatureClosing: "Met vriendelijke groet", + Margins: margins ?? new MarginsDto(20, 20, 20, 20)); + + [Fact] + public void Accepts_a_complete_draft_within_the_margin_bounds() => + Assert.Null(OrgTemplateRules.RejectDraft(Draft())); + + [Fact] + public void Rejects_a_missing_organisation_name() => + Assert.NotNull(OrgTemplateRules.RejectDraft(Draft(orgName: ""))); + + [Fact] + public void Rejects_a_missing_signature_name() => + Assert.NotNull(OrgTemplateRules.RejectDraft(Draft(signatureName: " "))); + + [Theory] + [InlineData(9, 20, 20, 20)] // top just under the minimum + [InlineData(20, 51, 20, 20)] // right just over the maximum + [InlineData(20, 20, 9, 20)] // bottom under the minimum + [InlineData(20, 20, 20, 51)] // left over the maximum + public void Rejects_a_margin_outside_the_allowed_range(int top, int right, int bottom, int left) => + Assert.NotNull(OrgTemplateRules.RejectDraft(Draft(margins: new MarginsDto(top, right, bottom, left)))); + + [Theory] + [InlineData(10, 10, 10, 10)] // the minimum, inclusive + [InlineData(50, 50, 50, 50)] // the maximum, inclusive + public void Accepts_margins_on_the_boundary(int top, int right, int bottom, int left) => + Assert.Null(OrgTemplateRules.RejectDraft(Draft(margins: new MarginsDto(top, right, bottom, left)))); +} diff --git a/backend/tests/BigRegister.Tests/Domain/RegistrationRuleTests.cs b/backend/tests/BigRegister.Tests/Domain/RegistrationRuleTests.cs new file mode 100644 index 0000000..091b6e0 --- /dev/null +++ b/backend/tests/BigRegister.Tests/Domain/RegistrationRuleTests.cs @@ -0,0 +1,57 @@ +using BigRegister.Domain.Registrations; + +namespace BigRegister.Tests.Domain; + +public class HerregistratieRuleTests +{ + private static Registration Active(DateOnly deadline) => new( + "19012345601", "Test", "Arts", + new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14), + new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline)); + + [Fact] + public void Eligible_within_window() + { + var (eligible, reason) = HerregistratieRule.Evaluate( + Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 6, 26)); + Assert.True(eligible); + Assert.Contains("12 maanden", reason); + } + + [Fact] + public void Not_eligible_before_window() + { + var (eligible, _) = HerregistratieRule.Evaluate( + Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2025, 1, 1)); + Assert.False(eligible); + } + + [Fact] + public void Eligible_on_window_boundary() + { + // window opens exactly 12 months before the deadline + var (eligible, _) = HerregistratieRule.Evaluate( + Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 3, 1)); + Assert.True(eligible); + } + + [Fact] + public void Suspended_is_not_eligible() + { + var reg = Active(new DateOnly(2027, 3, 1)) with + { + Status = new RegistrationStatus(StatusTag.Geschorst, GeschorstTot: new DateOnly(2027, 1, 1), Reden: "x"), + }; + var (eligible, _) = HerregistratieRule.Evaluate(reg, today: new DateOnly(2026, 6, 26)); + Assert.False(eligible); + } + + [Fact] + public void Status_consistency_invariant() + { + Assert.True(HerregistratieRule.IsStatusConsistent( + new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)))); + Assert.False(HerregistratieRule.IsStatusConsistent( + new RegistrationStatus(StatusTag.Geregistreerd))); + } +} diff --git a/backend/tests/BigRegister.Tests/Domain/SubmissionRuleTests.cs b/backend/tests/BigRegister.Tests/Domain/SubmissionRuleTests.cs new file mode 100644 index 0000000..2265864 --- /dev/null +++ b/backend/tests/BigRegister.Tests/Domain/SubmissionRuleTests.cs @@ -0,0 +1,30 @@ +using BigRegister.Domain.Submissions; + +namespace BigRegister.Tests.Domain; + +public class SubmissionRuleTests +{ + [Fact] + public void Manual_diploma_is_rejected() => + Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig")); + + [Fact] + public void Duo_diploma_is_accepted() => + Assert.Null(SubmissionRules.RejectRegistratie("duo")); + + [Fact] + public void Zero_hours_is_rejected() => + Assert.NotNull(SubmissionRules.RejectZeroUren(0)); + + [Fact] + public void Worked_hours_are_accepted() => + Assert.Null(SubmissionRules.RejectZeroUren(40)); + + [Theory] + [InlineData("0612345678", null)] // valid mobile + [InlineData("070 123 45 67", null)] // valid landline, formatting stripped + [InlineData("nope", "Voer een geldig telefoonnummer in, bijv. 0612345678.")] + [InlineData("12345", "Voer een geldig telefoonnummer in, bijv. 0612345678.")] + public void Phone_change_is_validated(string telefoon, string? expected) => + Assert.Equal(expected, SubmissionRules.RejectPhoneChange(telefoon)); +} diff --git a/backend/tests/BigRegister.Tests/RuleTests.cs b/backend/tests/BigRegister.Tests/RuleTests.cs deleted file mode 100644 index 59699dd..0000000 --- a/backend/tests/BigRegister.Tests/RuleTests.cs +++ /dev/null @@ -1,230 +0,0 @@ -using BigRegister.Domain.Applications; -using BigRegister.Domain.Beoordeling; -using BigRegister.Domain.Diplomas; -using BigRegister.Domain.Documents; -using BigRegister.Domain.Registrations; -using BigRegister.Domain.Submissions; -using BigRegister.Tests.Builders; - -namespace BigRegister.Tests; - -public class DocumentRuleTests -{ - [Fact] - public void Rejects_unknown_category() => - Assert.NotNull(DocumentRules.RejectUpload(null, "application/pdf", 1)); - - [Fact] - public void Rejects_disallowed_type() - { - var c = DocumentRules.Find("registratie", "diploma"); - Assert.NotNull(DocumentRules.RejectUpload(c, "text/plain", 1)); - } - - [Fact] - public void Rejects_oversized_file() - { - var c = DocumentRules.Find("registratie", "diploma"); - Assert.NotNull(DocumentRules.RejectUpload(c, "application/pdf", 11L * 1024 * 1024)); - } - - [Fact] - public void Accepts_valid_file() - { - var c = DocumentRules.Find("registratie", "diploma"); - Assert.Null(DocumentRules.RejectUpload(c, "application/pdf", 5L * 1024 * 1024)); - } - - private static IReadOnlyList Ids(string? herkomst, string? taalvaardigheid) => - DocumentRules.CategoriesFor("registratie", herkomst, taalvaardigheid).Select(c => c.CategoryId).ToList(); - - [Fact] - public void First_load_has_no_diploma_upload() => // no diploma chosen yet - Assert.Equal(new[] { "identiteit" }, Ids(null, null)); - - [Fact] - public void Manual_diploma_needs_a_diploma_upload() => - Assert.Equal(new[] { "diploma", "identiteit" }, Ids("handmatig", null)); - - [Fact] - public void Duo_diploma_skips_diploma_upload() => - Assert.DoesNotContain("diploma", Ids("duo", null)); - - [Fact] - public void Confirmed_dutch_proficiency_requires_taalvaardigheid_proof() => - Assert.Contains("taalvaardigheid", Ids("handmatig", "ja")); - - [Fact] - public void Unconfirmed_proficiency_requires_no_taalvaardigheid_proof() => - Assert.DoesNotContain("taalvaardigheid", Ids("handmatig", "nee")); - - [Fact] - public void Find_resolves_taalvaardigheid_for_upload_validation() => - Assert.NotNull(DocumentRules.Find("registratie", "taalvaardigheid")); -} - -public class HerregistratieRuleTests -{ - private static Registration Active(DateOnly deadline) => new( - "19012345601", "Test", "Arts", - new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14), - new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline)); - - [Fact] - public void Eligible_within_window() - { - var (eligible, reason) = HerregistratieRule.Evaluate( - Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 6, 26)); - Assert.True(eligible); - Assert.Contains("12 maanden", reason); - } - - [Fact] - public void Not_eligible_before_window() - { - var (eligible, _) = HerregistratieRule.Evaluate( - Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2025, 1, 1)); - Assert.False(eligible); - } - - [Fact] - public void Eligible_on_window_boundary() - { - // window opens exactly 12 months before the deadline - var (eligible, _) = HerregistratieRule.Evaluate( - Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 3, 1)); - Assert.True(eligible); - } - - [Fact] - public void Suspended_is_not_eligible() - { - var reg = Active(new DateOnly(2027, 3, 1)) with - { - Status = new RegistrationStatus(StatusTag.Geschorst, GeschorstTot: new DateOnly(2027, 1, 1), Reden: "x"), - }; - var (eligible, _) = HerregistratieRule.Evaluate(reg, today: new DateOnly(2026, 6, 26)); - Assert.False(eligible); - } - - [Fact] - public void Status_consistency_invariant() - { - Assert.True(HerregistratieRule.IsStatusConsistent( - new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)))); - Assert.False(HerregistratieRule.IsStatusConsistent( - new RegistrationStatus(StatusTag.Geregistreerd))); - } -} - -public class DiplomaRuleTests -{ - private static Diploma Diploma(string opleiding, bool engelstalig) => - new("x", "naam", "instelling", 2011, opleiding, engelstalig); - - [Theory] - [InlineData("geneeskunde", "Arts")] - [InlineData("verpleegkunde", "Verpleegkundige")] - [InlineData("onbekend-programma", "Onbekend")] - public void Profession_is_derived_from_program(string opleiding, string expected) => - Assert.Equal(expected, DiplomaRules.ProfessionFor(Diploma(opleiding, false))); - - [Fact] - public void English_diploma_requires_dutch_proficiency() - { - var questions = DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: true)); - Assert.Single(questions); - Assert.Equal("nl-taalvaardigheid", questions[0].Id); - } - - [Fact] - public void Dutch_diploma_has_no_policy_questions() => - Assert.Empty(DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: false))); - - [Fact] - public void Manual_diploma_gets_maximal_set() - { - var questions = DiplomaRules.ManualQuestions(); - Assert.Equal(3, questions.Count); - Assert.Equal(new[] { "nl-taalvaardigheid", "diploma-erkend", "toelichting" }, - questions.Select(q => q.Id)); - } - - [Fact] - public void Manual_professions_match_known_programs() => - Assert.Equal(new[] { "Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts" }, - DiplomaRules.ManualProfessions()); -} - -public class SubmissionRuleTests -{ - [Fact] - public void Manual_diploma_is_rejected() => - Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig")); - - [Fact] - public void Duo_diploma_is_accepted() => - Assert.Null(SubmissionRules.RejectRegistratie("duo")); - - [Fact] - public void Zero_hours_is_rejected() => - Assert.NotNull(SubmissionRules.RejectZeroUren(0)); - - [Fact] - public void Worked_hours_are_accepted() => - Assert.Null(SubmissionRules.RejectZeroUren(40)); - - [Theory] - [InlineData("0612345678", null)] // valid mobile - [InlineData("070 123 45 67", null)] // valid landline, formatting stripped - [InlineData("nope", "Voer een geldig telefoonnummer in, bijv. 0612345678.")] - [InlineData("12345", "Voer een geldig telefoonnummer in, bijv. 0612345678.")] - public void Phone_change_is_validated(string telefoon, string? expected) => - Assert.Equal(expected, SubmissionRules.RejectPhoneChange(telefoon)); -} - -public class BeoordelingRuleTests -{ - [Theory] - [InlineData(AanvraagStatusTag.Ingediend, true)] - [InlineData(AanvraagStatusTag.InBehandeling, true)] - [InlineData(AanvraagStatusTag.MeerInfoGevraagd, true)] - [InlineData(AanvraagStatusTag.Goedgekeurd, false)] - [InlineData(AanvraagStatusTag.Afgewezen, false)] - public void Only_open_statuses_are_decidable(AanvraagStatusTag tag, bool expected) => - Assert.Equal(expected, BeoordelingRules.CanDecide(tag)); - - // WP-68 (F6): the toelichting rule, moved here from an inline endpoint check. - [Theory] - [InlineData(Besluit.Goedkeuren, false)] - [InlineData(Besluit.Afwijzen, true)] - [InlineData(Besluit.MeerInfoOpvragen, true)] - public void Only_a_non_approval_requires_a_toelichting(Besluit besluit, bool expected) => - Assert.Equal(expected, BeoordelingRules.RequiresToelichting(besluit)); - - // WP-68 (T3): the transition table at the AGGREGATE level, not just against a bare tag — - // an Aanvraag whose BesluitStatus already records a terminal decision computes a terminal - // StatusAt, and CanDecide refuses a further besluit regardless of which one. Pins the - // domain statement "Afgewezen/Goedgekeurd → no further besluit" independent of the - // endpoint's own (integration-level) Already_decided_case_rejects_a_further_besluit. - // WP-70: built via Given, not a hand-rolled Aanvraag literal — Decided(Besluit.Afwijzen) with - // no toelichting simply couldn't compile as a fixture here. - [Theory] - [InlineData(Besluit.Goedkeuren)] - [InlineData(Besluit.Afwijzen)] - public void A_terminal_decision_refuses_any_further_besluit(Besluit recorded) - { - var now = DateTimeOffset.UtcNow; - var toelichting = recorded == Besluit.Goedkeuren ? null : "toelichting"; - var aanvraag = Given.Concept(owner: "test").Submitted().Decided(recorded, toelichting).Build(); - Assert.False(BeoordelingRules.CanDecide(aanvraag.StatusAt(now).Tag!.Value)); - } - - [Fact] - public void MeerInfoOpvragen_is_not_terminal_a_further_besluit_is_still_legal() - { - var now = DateTimeOffset.UtcNow; - var aanvraag = Given.Concept(owner: "test").Submitted().Decided(Besluit.MeerInfoOpvragen, "toelichting").Build(); - Assert.True(BeoordelingRules.CanDecide(aanvraag.StatusAt(now).Tag!.Value)); - } -} diff --git a/libs/shared/docs/layers.mdx b/libs/shared/docs/layers.mdx index 1197c68..393b245 100644 --- a/libs/shared/docs/layers.mdx +++ b/libs/shared/docs/layers.mdx @@ -8,30 +8,47 @@ This project is **domain-driven**: the code is organised first by **bounded cont (a business capability with its own language) and then by **layer** inside each context, with dependencies pointing inward. The Storybook sidebar is laid out to **be** that architecture, not just document it: **Foundations** (this curriculum) → **Design System** -(reusable, domain-free) → **Domein** (the six DDD contexts). If a component lives under a context's `ui/`, it's in Domein; everything else -in `shared/ui`/`shared/layout` is Design System. See [Atomic design](?path=/docs/foundations-atomic-design--docs) +(reusable, domain-free) → **Domein** (the app-local DDD contexts). If a component lives +under a context's `ui/`, it's in Domein; everything else in `libs/shared/ui`/`layout` +(or `libs/beheer/ui`) is Design System. See [Atomic design](?path=/docs/foundations-atomic-design--docs) for the Atoms → Molecules → Organisms → Templates ladder inside Design System. -## Six contexts, one direction +## Two apps, two shared libraries + +This is a **monorepo**: two Angular projects share one backend and one shared library. ``` -src/app/// +apps//src/app/// — app-local bounded context +libs//src// — cross-app library ``` -Contexts: `shared` (the base layer — depends on nothing), `auth`, `registratie`, -`herregistratie`, `brief` (letter-composition teaching slice), `showcase` (teaching page, -sanctioned to read every context — nothing imports it). +- `apps/ssp` — the Zorgverlener self-service portal. Contexts: `auth`, `registratie`, + `herregistratie`, `brief` (letter-composition teaching slice), `showcase` (teaching + page, sanctioned to read every context in its own app — nothing imports it). +- `apps/behandelportal` — the Behandelaar backoffice (ADR-0002). Contexts: `auth`, + `behandeling`. +- `libs/shared` — the design system + kernel + generated API client. No business logic. + The base layer: depends on nothing app- or context-specific. +- `libs/beheer` — the admin/stamdata context, used identically by both apps. -**Dependencies only point inward and in one declared direction between contexts:** +`auth` is deliberately **not** shared even though today it's near-identical in both +apps — ADR-0002 models Zorgverlener/Medewerker as different `Principal` variants with +different login flows, so the two copies are expected to diverge. + +**Dependencies only point inward, in one declared direction between contexts:** ``` -herregistratie → registratie → shared -auth → shared -brief → shared +herregistratie → registratie → libs/shared|beheer (ssp) +auth → libs/shared|beheer (ssp) +brief → libs/shared|beheer (ssp) +behandeling → libs/shared|beheer (behandelportal) +auth → libs/shared|beheer (behandelportal) ``` -Never the other way — `registratie` may not import `herregistratie`, and no context but -`shared` is imported by everyone. +Never the other way — `registratie` may not import `herregistratie`, no context but +`libs/shared`/`libs/beheer` is imported by everyone, an app may not import the other +app's source, and `libs/shared` may not depend on `libs/beheer` (shared stays the base, +beheer a peer leaf). ## Five layers, one direction @@ -48,13 +65,20 @@ reach data through an application store or command. ## This is enforced, not just written down -`eslint.config.mjs` fails the build on every rule above: +`npm run dep:check` (dependency-cruiser) fails the build on every rule above. One shared +rule _factory_ (`.dependency-cruiser.base.js`) is instantiated once per app — each app is +cruised separately against its own `tsconfig.json`, since `apps/ssp` and +`apps/behandelportal` each declare `@auth/*` pointing at a different physical directory +and a single merged tsconfig can't resolve both at once: -- `domain/` importing `@angular/*` at all (any context). -- `shared/` importing a feature context (`@auth/*`, `@registratie/*`, `@herregistratie/*`, - `@brief/*`) — the base layer depends on nothing. +- `domain/` importing `@angular/*` at all (any context, either app). +- `libs/shared` importing an app feature context, or `libs/beheer` — the base layer + depends on nothing. +- `libs/beheer` importing an app feature context. +- an app importing the other app's source directly. - `registratie/` importing `@herregistratie/*`/`@brief/*`, `auth/`/`brief/` importing a - sibling context — the cross-context direction above. + sibling context — the cross-context direction above, one `.dependency-cruiser..js` + per app. - `contracts/**` importing **anything** — not Angular, not an alias, not even a relative path (ADR-0001's wire seam has to stay a pure DTO shape). - `ui/**`/`layout/**` importing `*/infrastructure/*` — the anti-corruption boundary @@ -63,10 +87,15 @@ reach data through an application store or command. - The generated `ApiClient` imported as a value outside an `infrastructure/` adapter (type-only DTO imports are exempt — they grant no network access). -Two components get a documented exemption from the "nothing reaches across" rule: -`shared/ui/debug-state` (reads every root store, for the dev-only state panel) and -`showcase/` (reads every context, for side-by-side teaching pages). Both exemptions live -next to the rule they break, in `eslint.config.mjs`, so they can't rot silently. +`showcase` gets a documented exemption from the "nothing reaches across" rule +(`showcase: null` in `.dependency-cruiser.ssp.js`) — it reads every context in its own +app, for side-by-side teaching pages. The dev-only state panel (`apps/ssp/src/app/shell/debug-state`) +reads every root store too, but needs no such exemption: it lives outside any enumerated +context, so the per-context scoping rule never applies to it in the first place. + +`npm run lint` (`eslint.config.mjs`) is a separate gate — mainly the `any`-free rule — +and no longer carries the import-boundary rules above (moved to dependency-cruiser, +WP-38/WP-67, so they don't have to be hand-copied per context). ## The English/Dutch seam