diff --git a/backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs b/backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs index fffb309..8ddc879 100644 --- a/backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs +++ b/backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs @@ -56,7 +56,7 @@ public static class LetterHtml { sb.Append("

").Append(Enc(section.Title)).Append("

"); foreach (var block in section.Blocks) - RenderParagraphs(sb, block.Content.Paragraphs, defs); + RenderParagraphs(sb, block.Content.Paragraphs, defs, at); sb.Append("
"); } sb.Append(""); @@ -89,7 +89,8 @@ public static class LetterHtml private const string RecipientPlaceholder = "Adres van de geadresseerde\n(wordt ingevuld bij verzending)"; private static void RenderParagraphs( - StringBuilder sb, IReadOnlyList paragraphs, IReadOnlyDictionary defs) + StringBuilder sb, IReadOnlyList paragraphs, IReadOnlyDictionary defs, + string at) { string? openList = null; foreach (var para in paragraphs) @@ -101,14 +102,14 @@ public static class LetterHtml openList = para.List; } sb.Append(openList is null ? "

" : "

  • "); - foreach (var node in para.Nodes) RenderNode(sb, node, defs); + foreach (var node in para.Nodes) RenderNode(sb, node, defs, at); sb.Append(openList is null ? "

    " : "
  • "); } if (openList is not null) sb.Append(openList == "bullet" ? "" : ""); } private static void RenderNode( - StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary defs) + StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary defs, string at) { switch (node.Type) { @@ -122,7 +123,7 @@ public static class LetterHtml var key = node.Key ?? ""; var def = defs.GetValueOrDefault(key); var label = def?.Label ?? key; - sb.Append(def is { AutoResolvable: true } ? Enc(ResolveAuto(key, label)) : Enc($"[NOG IN TE VULLEN: {label}]")); + sb.Append(def is { AutoResolvable: true } ? Enc(ResolveAuto(key, label, at)) : Enc($"[NOG IN TE VULLEN: {label}]")); break; } } @@ -131,11 +132,11 @@ public static class LetterHtml // single demo applicant (SeedData.Registration — no per-brief resolved value is // ever stored, see the class doc above). Falls back to the label itself for any // other auto-resolvable key, mirroring the FE canvas' own `sampleFor` fallback. - private static string ResolveAuto(string key, string label) => key switch + private static string ResolveAuto(string key, string label, string at) => key switch { "naam_zorgverlener" => SeedData.Registration.Naam, "big_nummer" => SeedData.Registration.BigNummer, - "datum" => FormatDatumNl(DateTimeOffset.UtcNow.ToString("o")), + "datum" => FormatDatumNl(at), _ => label, }; diff --git a/backend/tests/BigRegister.Tests/LetterHtmlTests.cs b/backend/tests/BigRegister.Tests/LetterHtmlTests.cs index a6415c4..3d59f71 100644 --- a/backend/tests/BigRegister.Tests/LetterHtmlTests.cs +++ b/backend/tests/BigRegister.Tests/LetterHtmlTests.cs @@ -80,6 +80,44 @@ public class LetterHtmlTests private static readonly string GoldenPath = Path.Combine(AppContext.BaseDirectory, "LetterHtml.golden.html"); + // A minimal brief whose body renders the "datum" placeholder — the golden-file + // fixture above never uses it in the body, only in the letterhead, so it cannot + // exercise ResolveAuto's "datum" case (TE-007). + private static BriefEntity FixtureBriefWithDatumInBody() => new() + { + BriefId = "datum-brief-1", + Owner = "golden", + Beroep = "arts", + TemplateId = "besluit-arts", + DrafterId = BriefStore.DrafterId, + Placeholders = new[] + { + new PlaceholderDefDto("datum", "Datum", true), + }, + Sections = new() + { + new("kern", "Kern van het besluit", true, new List + { + new("freeText", "kern-1", new RichTextBlockDto(new[] + { + new ParagraphDto(new[] { new RichTextNodeDto("placeholder", Key: "datum") }), + })), + }), + }, + Status = new BriefStatusDto("draft"), + }; + + private static string ExtractLetterheadDate(string html) => + Regex.Match(html, "
    Datum
    ([^<]+)
    ").Groups[1].Value; + + private static string ExtractBodyDatumParagraph(string html) + { + var bodyStart = html.IndexOf("
    ", StringComparison.Ordinal); + var bodyEnd = html.IndexOf("
    ", StringComparison.Ordinal); + var body = html[bodyStart..bodyEnd]; + return Regex.Match(body, "

    ([^<]+)

    ").Groups[1].Value; + } + [Fact] public void Render_matches_the_golden_file() { @@ -88,6 +126,29 @@ public class LetterHtmlTests Assert.Equal(golden, html); } + [Fact] + public void Render_resolves_the_body_datum_placeholder_from_the_given_at_not_the_wall_clock() + { + const string historicalAt = "2019-03-14T08:00:00.0000000+00:00"; + + var html = LetterHtml.Render(FixtureBriefWithDatumInBody(), Template, historicalAt, watermark: false); + + Assert.Equal("14 maart 2019", ExtractBodyDatumParagraph(html)); + } + + [Fact] + public void Render_keeps_the_letterhead_date_and_the_body_datum_in_agreement_for_a_historical_at() + { + // A historical `at` (an archive re-render, a back-dated letter) is the case + // where the letterhead and the body datum placeholder could disagree within + // one document, if the body still read the wall clock (TE-007). + const string historicalAt = "2019-03-14T08:00:00.0000000+00:00"; + + var html = LetterHtml.Render(FixtureBriefWithDatumInBody(), Template, historicalAt, watermark: false); + + Assert.Equal(ExtractLetterheadDate(html), ExtractBodyDatumParagraph(html)); + } + [Fact] public void Every_letter_prefixed_class_exists_in_letter_css() { diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md index fe54364..6d55727 100644 --- a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -100,41 +100,41 @@ deployed first_, not _must ship together_. Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative 16-row "Compliance review required" list, carries it — regardless of priority. -| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | -| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | -| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | -| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | -| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | -| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | -| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | -| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | -| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | -| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | -| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | -| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | -| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | -| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | -| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | -| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | open | -| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | -| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | -| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | -| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | ----------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | open | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | open | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | implemented | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | open | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | open | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | open | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | open | --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-29.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-29.md new file mode 100644 index 0000000..e5181c4 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-29.md @@ -0,0 +1,125 @@ +# RB-29 — Thread `at` through `LetterHtml.ResolveAuto`'s `datum` case + +Status: **implemented** · 2026-08-27 · Source findings: `02-testability.md` TE-007 · +`99-backlog.md` RB-29 + +## What was wrong + +`LetterHtml.Render(BriefEntity brief, OrgTemplateDto template, string at, bool watermark)` +(`backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs`) already took the letter's +instant and used it correctly for the letterhead date +(`sb.Append(Enc(FormatDatumNl(at)))`). The body's `datum` placeholder resolved through +the private `ResolveAuto(string key, string label)`, which ignored `at` and called +`FormatDatumNl(DateTimeOffset.UtcNow.ToString("o"))` instead — a pure `Domain/` rule +class reading the wall clock. `ResolveAuto` is reached only through the private chain +`RenderNode` ← `RenderParagraphs` ← `Render`, so no caller outside this file could pin +the value a test would see. + +The ticket read as filed against the current code: `Render`'s signature, the letterhead's +correct use of `at`, and `ResolveAuto`'s `UtcNow` read were all exactly as TE-007 +described (line numbers had moved — CC around the file has grown since the finding was +written — but the code shape had not). One thing TE-007 named as the visible symptom +also checked out: `LetterHtmlTests.cs` already declares a +`new PlaceholderDefDto("datum", "Datum", true)` in its golden-file fixture, but no +`RichTextNodeDto` in that fixture's `Sections` actually references the `datum` key in +the body — it is declared but never rendered there, so the existing golden-file test +could not have caught this even if it asserted on dates (which it does not either). + +## What changed + +| File | Change | +| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs` | `ResolveAuto(string key, string label)` → `ResolveAuto(string key, string label, string at)`; `"datum" => FormatDatumNl(at)`. `at` threaded down through the two private call sites in the chain: `RenderParagraphs` and `RenderNode` both gained an `at` parameter, passed from `Render`'s own `at`. | +| `backend/tests/BigRegister.Tests/LetterHtmlTests.cs` | New fixture `FixtureBriefWithDatumInBody()` — a minimal brief whose body actually references the `datum` placeholder (the golden fixture never does). Two new `[Fact]`s (see below) plus two small extraction helpers. | +| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-29 status cell: `open` → `implemented`. | + +`Render`'s own signature is unchanged — TE-007's "zero public API change, zero +call-site change" held exactly. `Program.cs:697`, `:708`, and `BriefStore.cs:120` (the +three callers) needed no edit. + +## What the fix looks like + +```csharp +private static void RenderParagraphs( + StringBuilder sb, IReadOnlyList paragraphs, IReadOnlyDictionary defs, + string at) +{ + // ... unchanged body, forwards `at` to RenderNode ... +} + +private static void RenderNode( + StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary defs, string at) +{ + // ... unchanged body, forwards `at` to ResolveAuto ... +} + +private static string ResolveAuto(string key, string label, string at) => key switch +{ + "naam_zorgverlener" => SeedData.Registration.Naam, + "big_nummer" => SeedData.Registration.BigNummer, + "datum" => FormatDatumNl(at), + _ => label, +}; +``` + +Both call sites already had `at` in scope (`Render`'s own parameter), so this is a pure +threading change — no new state, no new dependency. + +## Tests added + +TE-007 named the exact gap: the golden-file fixture declares the `datum` placeholder but +never renders it in the body, so no existing assertion could catch a body/letterhead +mismatch. A new fixture and two focused tests close it: + +1. **`Render_resolves_the_body_datum_placeholder_from_the_given_at_not_the_wall_clock`** — + renders `FixtureBriefWithDatumInBody()` with a fixed historical `at` + (`2019-03-14T08:00:00.0000000+00:00`) and asserts the body's rendered paragraph is the + exact string `"14 maart 2019"`. A test using today's date would have passed before + and after the fix and proven nothing — this one pins a date nowhere near "now", so it + fails whenever the resolver reads the wall clock instead of `at`. +2. **`Render_keeps_the_letterhead_date_and_the_body_datum_in_agreement_for_a_historical_at`** + — same fixture and historical `at`, asserts the letterhead `
    ` date and the body's + rendered `datum` paragraph are equal. This is TE-007's stated payoff: not a shipped + bug today (every current caller passes `Now()` at render time, so the two dates always + coincided even with the bug present), but a latent one — the moment `Render` is ever + called with a historical `at` (re-rendering an archive, back-dating a letter), the + letterhead and body would disagree within a single document. This test is the one + that would have caught that. + +Both tests use the repo's one date formatter (`FormatDatumNl`, already used by both call +sites under test) only indirectly, through the literal expected string `"14 maart +2019"` — no second hand-rolled `ToString` format was introduced in the test file either. + +## Verification + +- **Verified red without the fix.** Reverted only the `ResolveAuto` expression (via + `Edit`, not `git checkout`) back to + `"datum" => FormatDatumNl(DateTimeOffset.UtcNow.ToString("o"))`, leaving the new + tests and the threaded signatures in place. Ran the two new tests: + ``` + Render_resolves_the_body_datum_placeholder_from_the_given_at_not_the_wall_clock [FAIL] + Assert.Equal() Failure: Strings differ + Expected: "14 maart 2019" + Actual: "27 augustus 2026" + + Render_keeps_the_letterhead_date_and_the_body_datum_in_agreement_for_a_historical_at [FAIL] + Assert.Equal() Failure: Strings differ + Expected: "14 maart 2019" + Actual: "27 augustus 2026" + ``` + Both failures show the body rendering the run's actual wall-clock date (today, + 2026-08-27) instead of the pinned historical `at` — the precise defect TE-007 + describes. Restored the fix with a second `Edit` and re-ran: all 4 tests in + `LetterHtmlTests` green (2 pre-existing + 2 new). +- `grep -n "UtcNow\|DateTime.Now\|DateTime.Today\|DateTimeOffset.Now" +backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs` — no matches. No ambient + clock read remains anywhere in the file. +- `npm run ci` (foreground, no background/Monitor): see result reported alongside this + ticket. + +## What this ticket did not touch + +`LetterHtml.cs`'s overall structure and CC (TE-007 records it at 21, the third-highest +in the backend) are unchanged — reducing that is out of scope for this ticket, per its +own text. `Data/BriefStore.cs` and any `BriefRules.cs` file were not touched — a +concurrent ticket owns that file.