diff --git a/backend/tests/BigRegister.Tests/Acceptance/BesluitLifecycleTests.cs b/backend/tests/BigRegister.Tests/Acceptance/BesluitLifecycleTests.cs
new file mode 100644
index 0000000..a02b795
--- /dev/null
+++ b/backend/tests/BigRegister.Tests/Acceptance/BesluitLifecycleTests.cs
@@ -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;
+
+///
+/// Behaviour-level tests for the besluit lifecycle (WP-65b/66/68), built through the
+/// type-state builder (WP-70) rather than the full wizard/upload dance
+/// uses — a fixture that's already Submitted (or already
+/// Decided) is a two-line Given, not fifteen. Each test persists its own Given-built
+/// straight into the isolated per-class SQLite file (no HTTP round trip
+/// needed to create it) and exercises the real write path from there.
+///
+public class BesluitLifecycleTests(TestWebApplicationFactory factory) : IClassFixture
+{
+ // 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 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())!;
+ Assert.Equal("Afgewezen", body.Status.Tag);
+ }
+}
diff --git a/backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs b/backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs
new file mode 100644
index 0000000..5759672
--- /dev/null
+++ b/backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs
@@ -0,0 +1,160 @@
+using System.Threading;
+using BigRegister.Api.Data;
+using BigRegister.Domain.Applications;
+using BigRegister.Domain.Beoordeling;
+
+namespace BigRegister.Tests.Builders;
+
+/// Fixture identities test builders share across the suite.
+public static class TestIdentities
+{
+ /// The default owner for a builder-made — matches
+ /// (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;
+}
+
+///
+/// Type-state test-data builder for (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 .
+///
+/// ponytail: 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 + 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.
+///
+public static class Given
+{
+ /// A fresh, unsubmitted wizard draft — step 0 of 0 until
+ /// says otherwise, exactly what ApplicationStore.CreateConcept hands back.
+ public static ConceptAanvraag Concept(string type = "registratie", string owner = TestIdentities.DemoBsn) =>
+ new(type, owner);
+}
+
+/// A not-yet-submitted aanvraag. The only next step is — there
+/// is deliberately no Decided here, since only a submitted aanvraag can be decided.
+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 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
+ /// ApplicationStore.Submit), so Aanvraag.StatusAt's Referentie! 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,
+ };
+}
+
+/// A submitted aanvraag, open for a behandelaar's decision. The only next step is
+/// — there is no way back to ConceptAanvraag.
+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;
+ }
+
+ /// Records a behandelaar's decision — reusing ,
+ /// the SAME rule production's besluit endpoint runs, rather than restating it here where it
+ /// could quietly drift. Throws for an Afwijzen/MeerInfoOpvragen
+ /// with a null/blank — exactly what that endpoint rejects with
+ /// a 400, just caught here at fixture-build time instead.
+ 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,
+ };
+}
+
+/// A submitted aanvraag with a behandelaar's decision already recorded. Terminal in the
+/// builder too — there's nothing past , matching Goedgekeurd/Afgewezen being
+/// terminal in the domain (); a fixture that needs a
+/// SECOND besluit (the MeerInfoGevraagd "still decidable" case) builds fresh from
+/// again, exactly as a real second request would.
+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;
+ }
+}
diff --git a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs
index 0db555b..7c3f0c1 100644
--- a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs
+++ b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs
@@ -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(() => 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(() => 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);
}
diff --git a/backend/tests/BigRegister.Tests/RuleTests.cs b/backend/tests/BigRegister.Tests/RuleTests.cs
index c0cde38..59699dd 100644
--- a/backend/tests/BigRegister.Tests/RuleTests.cs
+++ b/backend/tests/BigRegister.Tests/RuleTests.cs
@@ -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));
}
}