fix(backend): resolve the body datum placeholder from at, not UtcNow (RB-29)
LetterHtml.Render already receives the letter's instant and uses it for the letterhead date. The body's "datum" placeholder resolved through ResolveAuto, which ignored that instant and read the wall clock instead. This is not a shipped bug today, because every current caller passes Now() at render time. It becomes one the moment Render runs with a historical instant (an archive re-render, a back-dated letter): the letterhead and the body would then disagree within one document. Thread the existing "at" parameter down through RenderParagraphs and RenderNode into ResolveAuto's "datum" case. Render's own signature, and every call site, stays unchanged. Add two tests with a fixed historical "at": one pins the body's rendered date to the expected Dutch string, the other asserts the letterhead date and the body date agree. Both fail red against the old code, showing today's date instead of the pinned one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -56,7 +56,7 @@ public static class LetterHtml
|
||||
{
|
||||
sb.Append("<section><h3>").Append(Enc(section.Title)).Append("</h3>");
|
||||
foreach (var block in section.Blocks)
|
||||
RenderParagraphs(sb, block.Content.Paragraphs, defs);
|
||||
RenderParagraphs(sb, block.Content.Paragraphs, defs, at);
|
||||
sb.Append("</section>");
|
||||
}
|
||||
sb.Append("</div>");
|
||||
@@ -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<ParagraphDto> paragraphs, IReadOnlyDictionary<string, PlaceholderDefDto> defs)
|
||||
StringBuilder sb, IReadOnlyList<ParagraphDto> paragraphs, IReadOnlyDictionary<string, PlaceholderDefDto> 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 ? "<p>" : "<li>");
|
||||
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 ? "</p>" : "</li>");
|
||||
}
|
||||
if (openList is not null) sb.Append(openList == "bullet" ? "</ul>" : "</ol>");
|
||||
}
|
||||
|
||||
private static void RenderNode(
|
||||
StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary<string, PlaceholderDefDto> defs)
|
||||
StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary<string, PlaceholderDefDto> 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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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<LetterBlockDto>
|
||||
{
|
||||
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, "<dt>Datum</dt><dd>([^<]+)</dd>").Groups[1].Value;
|
||||
|
||||
private static string ExtractBodyDatumParagraph(string html)
|
||||
{
|
||||
var bodyStart = html.IndexOf("<div class=\"letter__body\">", StringComparison.Ordinal);
|
||||
var bodyEnd = html.IndexOf("<div class=\"letter__signature\">", StringComparison.Ordinal);
|
||||
var body = html[bodyStart..bodyEnd];
|
||||
return Regex.Match(body, "<p>([^<]+)</p>").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()
|
||||
{
|
||||
|
||||
@@ -101,7 +101,7 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita
|
||||
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** |
|
||||
@@ -130,7 +130,7 @@ Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authorita
|
||||
| **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-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 |
|
||||
|
||||
@@ -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<ParagraphDto> paragraphs, IReadOnlyDictionary<string, PlaceholderDefDto> defs,
|
||||
string at)
|
||||
{
|
||||
// ... unchanged body, forwards `at` to RenderNode ...
|
||||
}
|
||||
|
||||
private static void RenderNode(
|
||||
StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary<string, PlaceholderDefDto> 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 `<dd>` 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.
|
||||
Reference in New Issue
Block a user