test(backend): type-state Aanvraag builder, illegal fixtures unrepresentable (WP-70)

RuleTests/OpenZaakZaakSourceTests hand-built Aanvraag fixtures by initializer,
keeping Submitted/Referentie/SubmittedAt/BesluitStatus consistent by hand. A
type-state builder (Given.Concept().Submitted().Decided()) makes an illegal
sequence a compile error instead, and delegates the toelichting-required rule
to the real BeoordelingRules so it can't drift from production. Adds
BesluitLifecycleTests covering the WP-68 besluit invariants end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-18 15:30:57 +02:00
co-authored by Claude Sonnet 5
parent 868fb55783
commit 2eea860efe
4 changed files with 293 additions and 47 deletions
@@ -0,0 +1,111 @@
using System.Net;
using System.Net.Http.Json;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Beoordeling;
using BigRegister.Tests.Builders;
namespace BigRegister.Tests.Acceptance;
/// <summary>
/// Behaviour-level tests for the besluit lifecycle (WP-65b/66/68), built through the
/// <see cref="Given"/> type-state builder (WP-70) rather than the full wizard/upload dance
/// <see cref="BeoordelingTests"/> uses — a fixture that's already Submitted (or already
/// Decided) is a two-line Given, not fifteen. Each test persists its own Given-built
/// <see cref="Aanvraag"/> straight into the isolated per-class SQLite file (no HTTP round trip
/// needed to create it) and exercises the real write path from there.
/// </summary>
public class BesluitLifecycleTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
// Booting the client (once, here) is what makes Db.ConnectionString point at THIS class's
// throwaway file and runs its migrations — see TestWebApplicationFactory's own docs.
private readonly HttpClient _client = factory.CreateClient();
private static void Persist(Aanvraag aanvraag)
{
using var db = Db.Create();
db.Applications.Add(aanvraag);
db.SaveChanges();
}
[Fact]
public void A_terminal_besluit_is_frozen()
{
// Given a case already decided Goedgekeurd — terminal, per BeoordelingRules.CanDecide.
var aanvraag = Given.Concept(type: "registratie").Submitted().Decided(Besluit.Goedkeuren).Build();
Persist(aanvraag);
// When a behandelaar tries to record a further besluit on it...
var (outcome, updated) = ApplicationStore.RecordBesluit(aanvraag.Id, Besluit.Afwijzen, "te laat", DateTimeOffset.UtcNow);
// Then the write is refused, and the original decision still stands.
Assert.Equal(ApplicationStore.RecordBesluitOutcome.Conflict, outcome);
Assert.Null(updated);
var stillGoedgekeurd = ApplicationStore.GetAny(aanvraag.Id)!.StatusAt(DateTimeOffset.UtcNow);
Assert.Equal(AanvraagStatusTag.Goedgekeurd, stillGoedgekeurd.Tag);
}
[Fact]
public void MeerInfoGevraagd_can_be_decided_again()
{
// Given a case a behandelaar sent back for more information — not terminal.
var aanvraag = Given.Concept(type: "registratie").Submitted().Decided(Besluit.MeerInfoOpvragen, "stuur een geldig diploma").Build();
Persist(aanvraag);
// When a further besluit is recorded on it...
var (outcome, updated) = ApplicationStore.RecordBesluit(aanvraag.Id, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow);
// Then, unlike a terminal decision, it succeeds and advances the status.
Assert.Equal(ApplicationStore.RecordBesluitOutcome.Ok, outcome);
Assert.Equal(AanvraagStatusTag.Goedgekeurd, updated!.StatusAt(DateTimeOffset.UtcNow).Tag);
}
[Fact]
public void A_recorded_decision_wins_over_the_auto_approve_computation()
{
// Given an auto-approvable submission that a behandelaar decides (Afwijzen) before the
// auto-approve window would otherwise have closed it as Goedgekeurd.
var aanvraag = Given.Concept(type: "registratie").Submitted(autoApprovable: true).Build();
Persist(aanvraag);
var (outcome, _) = ApplicationStore.RecordBesluit(aanvraag.Id, Besluit.Afwijzen, "diploma niet erkend", DateTimeOffset.UtcNow);
Assert.Equal(ApplicationStore.RecordBesluitOutcome.Ok, outcome);
// When the status is read long after the auto-approve window has passed — the instant an
// undecided auto-approvable case of the same shape WOULD read Goedgekeurd (see
// ApplicationTests.AutoApprovable_flips_to_goedgekeurd_after_the_window)...
var longAfterTheWindow = aanvraag.SubmittedAt!.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1);
var status = ApplicationStore.GetAny(aanvraag.Id)!.StatusAt(longAfterTheWindow);
// Then the recorded decision still wins — Afgewezen, never Goedgekeurd.
Assert.Equal(AanvraagStatusTag.Afgewezen, status.Tag);
}
private Task<HttpResponseMessage> PostBesluit(string id, object body)
{
var req = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/beoordeling/{id}/besluit") { Content = JsonContent.Create(body) };
req.Headers.Add("X-Medewerker", "medewerker-1");
return _client.SendAsync(req);
}
[Fact]
public async Task Afwijzen_requires_a_toelichting()
{
// Given an open, decidable case (no decision recorded yet).
var aanvraag = Given.Concept(type: "registratie").Submitted().Build();
Persist(aanvraag);
// When a behandelaar posts Afwijzen with no toelichting...
var missing = await PostBesluit(aanvraag.Id, new { besluit = "Afwijzen" });
// Then the request is rejected — the wire boundary enforces the same rule
// (BeoordelingRules.RequiresToelichting) the builder enforces for a built fixture.
Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode);
// And the identical request WITH a toelichting succeeds.
var withToelichting = await PostBesluit(aanvraag.Id, new { besluit = "Afwijzen", toelichting = "Diploma niet erkend" });
withToelichting.EnsureSuccessStatusCode();
var body = (await withToelichting.Content.ReadFromJsonAsync<RecordBesluitResponse>())!;
Assert.Equal("Afgewezen", body.Status.Tag);
}
}
@@ -0,0 +1,160 @@
using System.Threading;
using BigRegister.Api.Data;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Beoordeling;
namespace BigRegister.Tests.Builders;
/// <summary>Fixture identities test builders share across the suite.</summary>
public static class TestIdentities
{
/// The default owner for a builder-made <see cref="Aanvraag"/> — matches
/// <see cref="DocumentStore.DemoOwner"/> (the demo's only seeded user) so a fixture that
/// doesn't care about identity gets a realistic, elfproef-valid BSN for free.
public const string DemoBsn = DocumentStore.DemoOwner;
}
/// <summary>
/// Type-state test-data builder for <see cref="Aanvraag"/> (WP-70). "Build test data through the
/// same door production code uses" — a Concept can only ever become Submitted, and only a
/// Submitted aanvraag can be Decided, so the compiler refuses a fixture built through an illegal
/// path (e.g. deciding a still-Concept aanvraag) instead of that being a runtime assertion nobody
/// wrote. Start at <see cref="Given.Concept"/>.
///
/// ponytail: <see cref="Aanvraag"/> itself stays exactly what it always was — a mutable,
/// EF-backed bag with no invariants of its own (that's Data/ApplicationStore.cs's job in
/// production, via its own lock + <see cref="BeoordelingRules"/> checks). This builder does not
/// refactor it into an immutable aggregate; it's the one enforced DOOR through which TEST code
/// builds one, so the invariants a real request path enforces don't quietly go missing from a
/// fixture assembled by hand.
/// </summary>
public static class Given
{
/// A fresh, unsubmitted wizard draft — step 0 of 0 until <see cref="ConceptAanvraag.AtStep"/>
/// says otherwise, exactly what <c>ApplicationStore.CreateConcept</c> hands back.
public static ConceptAanvraag Concept(string type = "registratie", string owner = TestIdentities.DemoBsn) =>
new(type, owner);
}
/// <summary>A not-yet-submitted aanvraag. The only next step is <see cref="Submitted"/> — there
/// is deliberately no <c>Decided</c> here, since only a submitted aanvraag can be decided.</summary>
public sealed class ConceptAanvraag
{
private readonly string _type;
private readonly string _owner;
private int _stepIndex;
private int _stepCount;
internal ConceptAanvraag(string type, string owner)
{
_type = type;
_owner = owner;
}
/// The wizard's current position — step <paramref name="index"/> of <paramref name="of"/>.
public ConceptAanvraag AtStep(int index, int of)
{
_stepIndex = index;
_stepCount = of;
return this;
}
/// Submits the draft — always assigns a Referentie AND SubmittedAt together (mirrors
/// <c>ApplicationStore.Submit</c>), so <c>Aanvraag.StatusAt</c>'s <c>Referentie!</c> is honest
/// for every fixture built this way, never a null-ref waiting to happen.
public SubmittedAanvraag Submitted(bool autoApprovable = false) =>
new(_type, _owner, _stepIndex, _stepCount, autoApprovable);
public Aanvraag Build() => new()
{
Id = Guid.NewGuid().ToString(),
Type = _type,
Owner = _owner,
StepIndex = _stepIndex,
StepCount = _stepCount,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
};
}
/// <summary>A submitted aanvraag, open for a behandelaar's decision. The only next step is
/// <see cref="Decided"/> — there is no way back to <c>ConceptAanvraag</c>.</summary>
public sealed class SubmittedAanvraag
{
private static int _referentieSeq;
private readonly string _type;
private readonly string _owner;
private readonly int _stepIndex;
private readonly int _stepCount;
private readonly bool _autoApprovable;
private readonly string _referentie;
private readonly DateTimeOffset _submittedAt;
internal SubmittedAanvraag(string type, string owner, int stepIndex, int stepCount, bool autoApprovable)
{
_type = type;
_owner = owner;
_stepIndex = stepIndex;
_stepCount = stepCount;
_autoApprovable = autoApprovable;
// A plausible reference in SubmissionRules.NewReference's shape ("BIG-2026-" + a number) —
// sequential (not random) so a fixture's value is reproducible across a test run.
_referentie = $"BIG-2026-{Interlocked.Increment(ref _referentieSeq)}";
_submittedAt = DateTimeOffset.UtcNow;
}
/// <summary>Records a behandelaar's decision — reusing <see cref="BeoordelingRules.RequiresToelichting"/>,
/// the SAME rule production's besluit endpoint runs, rather than restating it here where it
/// could quietly drift. Throws <see cref="ArgumentException"/> for an Afwijzen/MeerInfoOpvragen
/// with a null/blank <paramref name="toelichting"/> — exactly what that endpoint rejects with
/// a 400, just caught here at fixture-build time instead.</summary>
public DecidedAanvraag Decided(Besluit besluit, string? toelichting = null)
{
if (BeoordelingRules.RequiresToelichting(besluit) && string.IsNullOrWhiteSpace(toelichting))
throw new ArgumentException($"{besluit} requires a toelichting.", nameof(toelichting));
return new DecidedAanvraag(this, besluit, toelichting);
}
public Aanvraag Build() => new()
{
Id = Guid.NewGuid().ToString(),
Type = _type,
Owner = _owner,
StepIndex = _stepIndex,
StepCount = _stepCount,
Submitted = true,
Referentie = _referentie,
AutoApprovable = _autoApprovable,
SubmittedAt = _submittedAt,
CreatedAt = _submittedAt,
UpdatedAt = _submittedAt,
};
}
/// <summary>A submitted aanvraag with a behandelaar's decision already recorded. Terminal in the
/// builder too — there's nothing past <see cref="Build"/>, matching Goedgekeurd/Afgewezen being
/// terminal in the domain (<see cref="BeoordelingRules.CanDecide"/>); a fixture that needs a
/// SECOND besluit (the MeerInfoGevraagd "still decidable" case) builds fresh from
/// <see cref="Given.Concept"/> again, exactly as a real second request would.</summary>
public sealed class DecidedAanvraag
{
private readonly SubmittedAanvraag _submitted;
private readonly Besluit _besluit;
private readonly string? _toelichting;
internal DecidedAanvraag(SubmittedAanvraag submitted, Besluit besluit, string? toelichting)
{
_submitted = submitted;
_besluit = besluit;
_toelichting = toelichting;
}
public Aanvraag Build()
{
var aanvraag = _submitted.Build();
aanvraag.BesluitStatus = _besluit;
aanvraag.BesluitToelichting = _toelichting;
return aanvraag;
}
}
@@ -3,6 +3,7 @@ using BigRegister.Api.Data;
using BigRegister.Api.Zgw;
using BigRegister.Domain.Applications;
using BigRegister.Domain.Authorization;
using BigRegister.Tests.Builders;
namespace BigRegister.Tests;
@@ -140,17 +141,12 @@ public class OpenZaakZaakSourceTests
ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
};
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var aanvraag = new Aanvraag
{
Id = "a1",
Type = "registratie",
Owner = "111222333",
Referentie = "BIG-2026-000123",
};
var aanvraag = Given.Concept(type: "registratie", owner: "111222333").Submitted().Build();
var caller = new ZorgverlenerCaller(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter);
var (referentie, status, zaakUrl) = source.CreateZaak(aanvraag, new DateTimeOffset(2026, 7, 28, 12, 0, 0, TimeSpan.Zero), caller);
// The stub echoes back its own (hardcoded) identificatie, same as a real OpenZaak response.
Assert.Equal("BIG-2026-000123", referentie);
Assert.Equal("InBehandeling", status.Tag);
Assert.Equal("BIG-2026-000123", status.Referentie);
@@ -160,7 +156,7 @@ public class OpenZaakZaakSourceTests
var zaakBody = handler.BodyOf($"{ZrcBase}/zaken");
Assert.Contains(zaaktypeUrl, zaakBody);
Assert.Contains("123443210", zaakBody);
Assert.Contains("BIG-2026-000123", zaakBody);
Assert.Contains(aanvraag.Referentie!, zaakBody);
// Status: points at the created zaak's URL and the resolved statustype.
var statusBody = handler.BodyOf($"{ZrcBase}/statussen");
@@ -180,7 +176,7 @@ public class OpenZaakZaakSourceTests
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var aanvraag = new Aanvraag { Id = "a1", Type = "unknown-type", Owner = "111222333", Referentie = "BIG-2026-000123" };
var aanvraag = Given.Concept(type: "unknown-type", owner: "111222333").Submitted().Build();
var caller = new ZorgverlenerCaller(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter);
Assert.Throws<InvalidOperationException>(() => source.CreateZaak(aanvraag, DateTimeOffset.UtcNow, caller));
@@ -217,14 +213,8 @@ public class OpenZaakZaakSourceTests
ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
};
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var aanvraag = new Aanvraag
{
Id = "a1",
Type = "registratie",
Owner = "111222333",
Referentie = "BIG-2026-000123",
ZaakUrl = $"{ZrcBase}/zaken/uuid-existing",
};
var aanvraag = Given.Concept(type: "registratie", owner: "111222333").Submitted().Build();
aanvraag.ZaakUrl = $"{ZrcBase}/zaken/uuid-existing";
var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter);
source.RecordBesluit(aanvraag, Besluit.Afwijzen, "onvolledig", DateTimeOffset.UtcNow, caller);
@@ -268,14 +258,8 @@ public class OpenZaakZaakSourceTests
ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
};
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var aanvraag = new Aanvraag
{
Id = "a1",
Type = "registratie",
Owner = "111222333",
Referentie = "BIG-2026-000123",
ZaakUrl = $"{ZrcBase}/zaken/uuid-existing",
};
var aanvraag = Given.Concept(type: "registratie", owner: "111222333").Submitted().Build();
aanvraag.ZaakUrl = $"{ZrcBase}/zaken/uuid-existing";
var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter);
source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller);
@@ -296,7 +280,8 @@ public class OpenZaakZaakSourceTests
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var aanvraag = new Aanvraag { Id = "a1", Type = "registratie", Owner = "111222333", ZaakUrl = null };
// ZaakUrl deliberately left unset — no builder call touches it, so it stays null.
var aanvraag = Given.Concept(type: "registratie", owner: "111222333").Submitted().Build();
var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter);
source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller);
@@ -310,7 +295,8 @@ public class OpenZaakZaakSourceTests
var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" };
var handler = new ZgwStubHandler(url => throw new InvalidOperationException($"no HTTP call expected, got {url}"));
var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var aanvraag = new Aanvraag { Id = "a1", Type = "unknown-type", Owner = "111222333", ZaakUrl = $"{ZrcBase}/zaken/uuid-existing" };
var aanvraag = Given.Concept(type: "unknown-type", owner: "111222333").Submitted().Build();
aanvraag.ZaakUrl = $"{ZrcBase}/zaken/uuid-existing";
var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter);
Assert.Throws<InvalidOperationException>(() => source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller));
@@ -331,7 +317,7 @@ public class OpenZaakZaakSourceTests
VerantwoordelijkeOrganisatie = "123443210",
ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl },
};
var aanvraag = new Aanvraag { Id = "a1", Type = "registratie", Owner = "111222333", Referentie = "BIG-2026-000123" };
var aanvraag = Given.Concept(type: "registratie", owner: "111222333").Submitted().Build();
var caller = new ZorgverlenerCaller(aanvraag.Owner, "Dr. Test", PrincipalRole.Drafter);
return (options, aanvraag, caller);
}
+8 -19
View File
@@ -1,10 +1,10 @@
using BigRegister.Api.Data;
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;
@@ -207,35 +207,24 @@ public class BeoordelingRuleTests
// 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.
private static Aanvraag Decided(Besluit besluit) => new()
{
Id = "x",
Type = "registratie",
Owner = "test",
Submitted = true,
Referentie = "BIG-2026-1",
SubmittedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
BesluitStatus = besluit,
BesluitToelichting = besluit == Besluit.Goedkeuren ? null : "toelichting",
};
// 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 tag = Decided(recorded).StatusAt(now).Tag!.Value;
Assert.False(BeoordelingRules.CanDecide(tag));
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 tag = Decided(Besluit.MeerInfoOpvragen).StatusAt(now).Tag!.Value;
Assert.True(BeoordelingRules.CanDecide(tag));
var aanvraag = Given.Concept(owner: "test").Submitted().Decided(Besluit.MeerInfoOpvragen, "toelichting").Build();
Assert.True(BeoordelingRules.CanDecide(aanvraag.StatusAt(now).Tag!.Value));
}
}