diff --git a/services/domain/Big.Api/Program.cs b/services/domain/Big.Api/Program.cs index c180da8..eb26a17 100644 --- a/services/domain/Big.Api/Program.cs +++ b/services/domain/Big.Api/Program.cs @@ -24,6 +24,7 @@ builder.Services.AddHttpClient(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -55,6 +56,22 @@ app.MapPost("/registrations/{id}/approve", async (string id, ApproveRegistration return Results.NoContent(); }); +// The behandelaar's beoordeling (S-12): decide a registration goedkeuren (→ INGESCHREVEN, sets the +// zaak's final status via the ACL) or afwijzen (→ AFGEWEZEN). Idempotent. This is the domain contract +// the behandel-portal's decision reaches through the BFF; it supersedes the temporary /approve above, +// which is retired once the portal lands. +app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body, BeoordeelRegistratie beoordeel, CancellationToken ct) => +{ + if (!Guid.TryParse(id, out var guid)) + return Results.NotFound(); + + if (!Enum.TryParse(body.Besluit, ignoreCase: true, out var besluit)) + return Results.BadRequest(new { error = $"Unknown besluit '{body.Besluit}'. Expected 'goedkeuren' or 'afwijzen'." }); + + await beoordeel.HandleAsync(new BeoordeelRegistratieCommand(new RegistrationId(guid), besluit), ct); + return Results.NoContent(); +}); + // Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually). app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) => { @@ -72,6 +89,8 @@ await app.RunAsync(); public sealed record SubmitRegistrationRequest(string Bsn); +public sealed record DecideRequest(string Besluit); + public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl); public partial class Program; diff --git a/services/domain/Big.Application/BeoordeelRegistratie.cs b/services/domain/Big.Application/BeoordeelRegistratie.cs new file mode 100644 index 0000000..f0a384f --- /dev/null +++ b/services/domain/Big.Application/BeoordeelRegistratie.cs @@ -0,0 +1,57 @@ +using Big.Domain; + +namespace Big.Application; + +/// A behandelaar's beoordeling outcome, in domain language. +public enum BeoordelingsBesluit +{ + /// Approve — enter the registration in the register. + Goedkeuren, + + /// Reject — turn the registration down. + Afwijzen, +} + +/// A behandelaar's decision on a registration. +public sealed record BeoordeelRegistratieCommand(RegistrationId RegistrationId, BeoordelingsBesluit Besluit); + +/// +/// The beoordeling use case (S-12): apply a behandelaar's decision to a registration. +/// sets the zaak's final status via the ACL (§8.1) and +/// advances the aggregate to INGESCHREVEN; advances it to +/// AFGEWEZEN in the domain (propagating a rejection to the zaak, so the openbaar projection reflects +/// it, is a later sub-slice of S-12). Both decisions are idempotent — a repeated or redelivered +/// decision that matches the current terminal state is a no-op, so the ACL is not called twice. +/// +public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl) +{ + public async Task HandleAsync(BeoordeelRegistratieCommand command, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(command); + + var registration = await store.GetAsync(command.RegistrationId, ct) + ?? throw new InvalidOperationException($"No registration {command.RegistrationId} to decide."); + + switch (command.Besluit) + { + case BeoordelingsBesluit.Goedkeuren: + // A repeated approval is a no-op: don't set the zaak status a second time. + if (registration.Status == RegistrationStatus.Ingeschreven) + return; + if (registration.ZaakUrl is null) + throw new InvalidOperationException( + $"Registration {command.RegistrationId} has no zaak yet; it cannot be approved."); + await acl.ApproveZaakAsync(registration.ZaakUrl, ct); + registration.Approve(); + break; + + case BeoordelingsBesluit.Afwijzen: + if (registration.Status == RegistrationStatus.Afgewezen) + return; + registration.Reject(); + break; + } + + await store.SaveAsync(registration, ct); + } +} diff --git a/services/domain/Big.Domain/Registration.cs b/services/domain/Big.Domain/Registration.cs index be1f1ea..3aa620d 100644 --- a/services/domain/Big.Domain/Registration.cs +++ b/services/domain/Big.Domain/Registration.cs @@ -66,10 +66,28 @@ public sealed class Registration } /// - /// Approve the registration — the behandelaar's decision to enter it in the register. Advances - /// . - /// Requires an opened zaak (the approval sets that zaak's status via the ACL), and only a - /// submitted registration can be approved: re-approving is rejected as an invalid transition. + /// A behandelaar picks the registration up for beoordeling. Advances + /// . + /// Re-taking one already is a no-op (the same or + /// another behandelaar re-opens it); a decided registration can no longer be taken into behandeling. + /// + public void TakeIntoBehandeling() + { + if (Status == RegistrationStatus.InBehandeling) + return; + + if (Status != RegistrationStatus.Ingediend) + throw new InvalidOperationException( + $"Registration {Id} is {Status}; only an INGEDIEND registration can be taken into behandeling."); + + Status = RegistrationStatus.InBehandeling; + } + + /// + /// Approve the registration — the behandelaar's decision to enter it in the register. Advances a + /// submitted or in-behandeling registration to . + /// Requires an opened zaak (the approval sets that zaak's status via the ACL); a registration that + /// has already been decided cannot be approved again. /// public void Approve() { @@ -77,10 +95,27 @@ public sealed class Registration throw new InvalidOperationException( $"Registration {Id} has no zaak yet; it cannot be approved before its zaak is opened."); - if (Status != RegistrationStatus.Ingediend) - throw new InvalidOperationException( - $"Registration {Id} is {Status}; only an INGEDIEND registration can be approved."); - + RequireOpenForDecision(nameof(Approve)); Status = RegistrationStatus.Ingeschreven; } + + /// + /// Reject the registration — the behandelaar's decision not to enter it in the register. Advances a + /// submitted or in-behandeling registration to . Unlike + /// approval this needs no zaak: a registration can be rejected before or after its zaak is opened. + /// A registration that has already been decided cannot be rejected again. + /// + public void Reject() + { + RequireOpenForDecision(nameof(Reject)); + Status = RegistrationStatus.Afgewezen; + } + + // A decision is only valid while the registration is still open (INGEDIEND or IN_BEHANDELING). + private void RequireOpenForDecision(string decision) + { + if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling)) + throw new InvalidOperationException( + $"Registration {Id} is {Status}; only an INGEDIEND or IN_BEHANDELING registration can be decided ({decision})."); + } } diff --git a/services/domain/Big.Domain/RegistrationStatus.cs b/services/domain/Big.Domain/RegistrationStatus.cs index 60bb171..edae5a9 100644 --- a/services/domain/Big.Domain/RegistrationStatus.cs +++ b/services/domain/Big.Domain/RegistrationStatus.cs @@ -1,13 +1,20 @@ namespace Big.Domain; -/// The lifecycle states a moves through. The walking -/// skeleton knows and the terminal ; withdrawal, -/// beoordeling and herregistratie states arrive in their own slices (Iteration 2+). +/// The lifecycle states a moves through. Submission starts in +/// ; a behandelaar takes it and decides it into one +/// of the terminal states (approved) or (rejected). +/// Withdrawal and herregistratie states arrive in their own slices (S-11+). public enum RegistrationStatus { /// Submitted by the zorgprofessional; the registratie process has been started. Ingediend, - /// Approved: entered in the register. Terminal in the walking skeleton (S-09b). + /// Picked up by a behandelaar for beoordeling (S-12). + InBehandeling, + + /// Approved: entered in the register. Terminal. Ingeschreven, + + /// Rejected by the behandelaar. Terminal. + Afgewezen, } diff --git a/services/domain/Big.Tests/BeoordeelRegistratieTests.cs b/services/domain/Big.Tests/BeoordeelRegistratieTests.cs new file mode 100644 index 0000000..de51af2 --- /dev/null +++ b/services/domain/Big.Tests/BeoordeelRegistratieTests.cs @@ -0,0 +1,142 @@ +using Big.Application; +using Big.Domain; + +namespace Big.Tests; + +/// +/// The beoordeling use case (S-12): a behandelaar's decision on a registration. Goedkeuren sets the +/// zaak's final status via the ACL (§8.1) and marks the aggregate INGESCHREVEN; Afwijzen marks it +/// AFGEWEZEN in the domain (propagating a rejection to the zaak is a later sub-slice). Both are +/// idempotent so a repeated or redelivered decision is a no-op. +/// +public class BeoordeelRegistratieTests +{ + private static Registration WithZaak(string bsn = "123456782") + { + var registration = Registration.Submit(bsn); + registration.AttachZaak(FakeAclClient.DefaultZaakUrl); + return registration; + } + + [Fact] + public async Task Goedkeuren_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven() + { + var store = new FakeRegistrationStore(); + var acl = new FakeAclClient(); + var registration = WithZaak(); + store.Seed(registration); + var handler = new BeoordeelRegistratie(store, acl); + + await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)); + + var saved = await store.GetAsync(registration.Id); + Assert.Equal(RegistrationStatus.Ingeschreven, saved!.Status); + Assert.Equal(FakeAclClient.DefaultZaakUrl, acl.ApprovedZaakUrl); + Assert.Equal(1, acl.ApproveCallCount); + Assert.Equal(1, store.SaveCount); + } + + [Fact] + public async Task Afwijzen_marks_the_registration_afgewezen_without_calling_the_acl() + { + var store = new FakeRegistrationStore(); + var acl = new FakeAclClient(); + var registration = WithZaak(); + store.Seed(registration); + var handler = new BeoordeelRegistratie(store, acl); + + await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen)); + + var saved = await store.GetAsync(registration.Id); + Assert.Equal(RegistrationStatus.Afgewezen, saved!.Status); + Assert.Equal(0, acl.ApproveCallCount); + Assert.Equal(1, store.SaveCount); + } + + [Fact] + public async Task Goedkeuren_an_in_behandeling_registration_marks_it_ingeschreven() + { + var store = new FakeRegistrationStore(); + var acl = new FakeAclClient(); + var registration = WithZaak(); + registration.TakeIntoBehandeling(); + store.Seed(registration); + var handler = new BeoordeelRegistratie(store, acl); + + await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)); + + Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status); + } + + [Fact] + public async Task Rejects_a_null_command_without_touching_the_store_or_acl() + { + var store = new FakeRegistrationStore(); + var acl = new FakeAclClient(); + var handler = new BeoordeelRegistratie(store, acl); + + await Assert.ThrowsAsync(() => handler.HandleAsync(null!)); + Assert.Equal(0, acl.ApproveCallCount); + Assert.Equal(0, store.SaveCount); + } + + [Fact] + public async Task Deciding_an_unknown_registration_throws_and_does_not_call_the_acl() + { + var store = new FakeRegistrationStore(); + var acl = new FakeAclClient(); + var handler = new BeoordeelRegistratie(store, acl); + + var ex = await Assert.ThrowsAsync(() => + handler.HandleAsync(new BeoordeelRegistratieCommand(RegistrationId.New(), BeoordelingsBesluit.Goedkeuren))); + Assert.Contains("No registration", ex.Message); + Assert.Equal(0, acl.ApproveCallCount); + } + + [Fact] + public async Task Goedkeuren_before_the_zaak_is_opened_throws_without_calling_the_acl() + { + var store = new FakeRegistrationStore(); + var acl = new FakeAclClient(); + var registration = Registration.Submit("123456782"); // no zaak yet + store.Seed(registration); + var handler = new BeoordeelRegistratie(store, acl); + + var ex = await Assert.ThrowsAsync(() => + handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren))); + Assert.Contains("no zaak", ex.Message); + Assert.Equal(0, acl.ApproveCallCount); + } + + [Fact] + public async Task Re_goedkeuren_an_already_ingeschreven_registration_is_idempotent() + { + var store = new FakeRegistrationStore(); + var acl = new FakeAclClient(); + var registration = WithZaak(); + store.Seed(registration); + var handler = new BeoordeelRegistratie(store, acl); + + await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)); + await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)); + + Assert.Equal(1, acl.ApproveCallCount); + Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status); + } + + [Fact] + public async Task Re_afwijzen_an_already_afgewezen_registration_is_idempotent() + { + var store = new FakeRegistrationStore(); + var acl = new FakeAclClient(); + var registration = WithZaak(); + store.Seed(registration); + var handler = new BeoordeelRegistratie(store, acl); + + await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen)); + await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen)); + + Assert.Equal(1, store.SaveCount); + Assert.Equal(RegistrationStatus.Afgewezen, (await store.GetAsync(registration.Id))!.Status); + } +} diff --git a/services/domain/Big.Tests/RegistrationTests.cs b/services/domain/Big.Tests/RegistrationTests.cs index b069ecc..2221201 100644 --- a/services/domain/Big.Tests/RegistrationTests.cs +++ b/services/domain/Big.Tests/RegistrationTests.cs @@ -121,4 +121,100 @@ public class RegistrationTests Assert.Contains("only an INGEDIEND", ex.Message); Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status); } + + [Fact] + public void Taking_a_registration_into_behandeling_moves_it_to_in_behandeling() + { + var registration = Registration.Submit("123456782"); + + registration.TakeIntoBehandeling(); + + Assert.Equal(RegistrationStatus.InBehandeling, registration.Status); + } + + [Fact] + public void Re_taking_an_in_behandeling_registration_is_idempotent() + { + var registration = Registration.Submit("123456782"); + registration.TakeIntoBehandeling(); + + registration.TakeIntoBehandeling(); + + Assert.Equal(RegistrationStatus.InBehandeling, registration.Status); + } + + [Fact] + public void Taking_a_decided_registration_into_behandeling_is_rejected() + { + var registration = Registration.Submit("123456782"); + registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + registration.Approve(); + + var ex = Assert.Throws(() => registration.TakeIntoBehandeling()); + Assert.Contains("only an INGEDIEND", ex.Message); + Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status); + } + + [Fact] + public void Approving_an_in_behandeling_registration_with_a_zaak_sets_it_ingeschreven() + { + var registration = Registration.Submit("123456782"); + registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + registration.TakeIntoBehandeling(); + + registration.Approve(); + + Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status); + } + + [Fact] + public void Rejecting_a_registration_sets_it_afgewezen() + { + var registration = Registration.Submit("123456782"); + + registration.Reject(); + + Assert.Equal(RegistrationStatus.Afgewezen, registration.Status); + } + + [Fact] + public void Rejecting_needs_no_zaak() + { + // A registration can be turned down before its zaak is ever opened, so Reject must not require one. + var registration = Registration.Submit("123456782"); + + registration.Reject(); + + Assert.Equal(RegistrationStatus.Afgewezen, registration.Status); + Assert.Null(registration.ZaakUrl); + } + + [Fact] + public void Rejecting_an_in_behandeling_registration_sets_it_afgewezen() + { + var registration = Registration.Submit("123456782"); + registration.TakeIntoBehandeling(); + + registration.Reject(); + + Assert.Equal(RegistrationStatus.Afgewezen, registration.Status); + } + + [Fact] + public void Deciding_an_already_decided_registration_is_rejected() + { + var registration = Registration.Submit("123456782"); + registration.Reject(); + + var approveEx = Assert.Throws(() => + { + registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + registration.Approve(); + }); + Assert.Contains("IN_BEHANDELING", approveEx.Message); + + var rejectEx = Assert.Throws(() => registration.Reject()); + Assert.Contains("Afgewezen", rejectEx.Message); + Assert.Equal(RegistrationStatus.Afgewezen, registration.Status); + } } diff --git a/tests/acceptance/Features/EenRegistratieBeoordelen.feature b/tests/acceptance/Features/EenRegistratieBeoordelen.feature new file mode 100644 index 0000000..7c94fa6 --- /dev/null +++ b/tests/acceptance/Features/EenRegistratieBeoordelen.feature @@ -0,0 +1,24 @@ +# language: en +# Drives S-12 (#13). A behandelaar picks up a submitted registration for beoordeling and decides it: +# goedkeuren enters it in the register (INGESCHREVEN, the zaak's final status set via the ACL, §8.1), +# afwijzen turns it down (AFGEWEZEN). This scenario exercises the use cases against in-memory +# stand-ins for the ACL and the store; real Flowable user-task claim/complete arrives in a later +# sub-slice and is verified live. +Feature: Een registratie beoordelen + Als behandelaar wil ik een ingediende registratie beoordelen + zodat deze wordt ingeschreven of afgewezen. + + Scenario: Goedkeuren schrijft de registratie in via de ACL + Given a submitted registration with an opened zaak + When the behandelaar takes it into behandeling + Then the registration has status "INBEHANDELING" + When the behandelaar decides "goedkeuren" + Then the registration has status "INGESCHREVEN" + And the zaak's final status is set via the ACL + + Scenario: Afwijzen wijst de registratie af zonder de ACL + Given a submitted registration with an opened zaak + When the behandelaar takes it into behandeling + And the behandelaar decides "afwijzen" + Then the registration has status "AFGEWEZEN" + And the ACL is not asked to set the zaak status diff --git a/tests/acceptance/Steps/EenRegistratieBeoordelenSteps.cs b/tests/acceptance/Steps/EenRegistratieBeoordelenSteps.cs new file mode 100644 index 0000000..181951d --- /dev/null +++ b/tests/acceptance/Steps/EenRegistratieBeoordelenSteps.cs @@ -0,0 +1,58 @@ +using Acceptance.Support; +using Big.Application; +using Big.Domain; +using Reqnroll; +using Xunit; + +namespace Acceptance.Steps; + +/// Bindings for EenRegistratieBeoordelen.feature (S-12). Drives the TakeIntoBehandeling +/// transition and the BeoordeelRegistratie use case against in-memory ports; one instance per scenario. +/// Scoped to this feature so its "the registration has status" step does not clash with the identically +/// phrased (but differently-asserting) step in . +[Binding] +[Scope(Feature = "Een registratie beoordelen")] +public sealed class EenRegistratieBeoordelenSteps +{ + private readonly InMemoryAclClient _acl = new(); + private readonly InMemoryRegistrationStore _store = new(); + private RegistrationId _id; + + [Given("a submitted registration with an opened zaak")] + public async Task GivenASubmittedRegistrationWithAnOpenedZaak() + { + var registration = Registration.Submit("123456782"); + registration.AttachZaak(InMemoryAclClient.OpenedZaakUrl); + await _store.SaveAsync(registration); + _id = registration.Id; + } + + [When("the behandelaar takes it into behandeling")] + public async Task WhenTheBehandelaarTakesItIntoBehandeling() + { + var registration = await _store.GetAsync(_id); + registration!.TakeIntoBehandeling(); + await _store.SaveAsync(registration); + } + + [When("the behandelaar decides \"(.*)\"")] + public async Task WhenTheBehandelaarDecides(string besluit) + => await new BeoordeelRegistratie(_store, _acl).HandleAsync( + new BeoordeelRegistratieCommand(_id, Enum.Parse(besluit, ignoreCase: true))); + + [Then("the registration has status \"(.*)\"")] + public async Task ThenTheRegistrationHasStatus(string expected) + { + var registration = await _store.GetAsync(_id); + Assert.NotNull(registration); + Assert.Equal(expected, registration.Status.ToString().ToUpperInvariant()); + } + + [Then("the zaak's final status is set via the ACL")] + public void ThenTheZaakFinalStatusIsSetViaTheAcl() + => Assert.Equal(InMemoryAclClient.OpenedZaakUrl, _acl.ApprovedZaakUrl); + + [Then("the ACL is not asked to set the zaak status")] + public void ThenTheAclIsNotAskedToSetTheZaakStatus() + => Assert.Null(_acl.ApprovedZaakUrl); +}