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)); } }