S-09b: Approval flow — temp admin endpoint + status transition to projection (#77)
All checks were successful
All checks were successful
## What & why S-09b (#75, split from #10) — the **approval flow** that completes the walking skeleton. A behandelaar can now approve a submitted registration; the entry flips from `INGEDIEND` to `INGESCHREVEN` in the public register. Flow: `POST /registrations/{id}/approve` (domain) → ACL sets the zaak eindstatus (ZGW `/statussen`) → OpenZaak → NRC → event-subscriber → projection → openbaar. ## Changes (bottom-up, each red→green TDD) - **Domain** — `RegistrationStatus.Ingeschreven` + `Registration.Approve()` (guards: opened zaak, only from INGEDIEND); `ApproveRegistration` use case (idempotent) + temp `POST /registrations/{id}/approve` endpoint; `IAclClient.ApproveZaakAsync`. - **ACL** — resolves the zaaktype's **eindstatus** from the catalogus (`isEindstatus` / highest volgnummer) and POSTs a ZGW status; exposed as `POST /statussen`. Unit + real-OpenZaak integration test. - **Event-subscriber** — binds NRC `hoofdObject`, projects a `status`/`create` as `INGESCHREVEN` keyed on the zaak (updates the existing row), **without reading OpenZaak** (§8.1). Retains the ZGW `resource` in the log (new column + EF migration) so a rebuild reproduces the status. - **e2e** — extended: submit → public INGEDIEND → approve → public INGESCHREVEN. - **Docs** — ADR-0011 (the two non-obvious decisions + the walking-skeleton assumption) + demo note. ## Key decisions (see ADR-0011) - **ACL discovers the eindstatus** (chosen over injecting a statustype URL): no new config/seed plumbing, domain stays ZGW-ignorant. - **Any post-creation status-set ⇒ INGESCHREVEN**: in the walking skeleton the only status ever set after creation is the approval, and the subscriber may not read ZGW — documented to tighten when more transitions arrive (S-12+). ## Verification - All .NET unit suites green locally (domain 47, acl 11, event-subscriber 14, bff 16, acceptance 7); Release build + `dotnet format` clean. - No new compose config (the eindstatus-discovery approach avoided it). - The real-OpenZaak integration test (ACL status-set) and the full submit→approve→visible e2e run in CI `verify-stack` (live NRC→projection + selectielijst egress, not reproducible locally). closes #75 Reviewed-on: #77
This commit was merged in pull request #77.
This commit is contained in:
@@ -23,6 +23,7 @@ builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService
|
||||
builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
|
||||
|
||||
builder.Services.AddScoped<SubmitRegistration>();
|
||||
builder.Services.AddScoped<ApproveRegistration>();
|
||||
builder.Services.AddScoped<OpenZaakWorker>();
|
||||
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
||||
|
||||
@@ -42,6 +43,18 @@ app.MapPost("/registrations", async (SubmitRegistrationRequest body, SubmitRegis
|
||||
return Results.Accepted($"/registrations/{id}", new RegistrationResponse(id.ToString(), RegistrationStatus.Ingediend.ToString(), null));
|
||||
});
|
||||
|
||||
// Temporary admin endpoint (S-09b): approve a registration — the behandelaar's decision, until the
|
||||
// behandel-portal exists (S-12). The zaak's final status is set via the ACL, which flows back over
|
||||
// NRC to the projection, making the entry publicly visible as INGESCHREVEN. Idempotent.
|
||||
app.MapPost("/registrations/{id}/approve", async (string id, ApproveRegistration approve, CancellationToken ct) =>
|
||||
{
|
||||
if (!Guid.TryParse(id, out var guid))
|
||||
return Results.NotFound();
|
||||
|
||||
await approve.HandleAsync(new ApproveRegistrationCommand(new RegistrationId(guid)), 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) =>
|
||||
{
|
||||
|
||||
36
services/domain/Big.Application/ApproveRegistration.cs
Normal file
36
services/domain/Big.Application/ApproveRegistration.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Application;
|
||||
|
||||
/// <summary>A behandelaar's decision to approve a registration, in domain language.</summary>
|
||||
public sealed record ApproveRegistrationCommand(RegistrationId RegistrationId);
|
||||
|
||||
/// <summary>
|
||||
/// The approve use case (S-09b): set the registration's zaak to its final status via the ACL (§8.1),
|
||||
/// then advance the aggregate to INGESCHREVEN. Idempotent — a redelivered or repeated approval of an
|
||||
/// already-approved registration is a no-op, so the ACL is not asked to set the status twice. The
|
||||
/// zaak status is the projection's source of truth (it flows back over NRC); the aggregate transition
|
||||
/// keeps the domain's own view consistent.
|
||||
/// </summary>
|
||||
public sealed class ApproveRegistration(IRegistrationStore store, IAclClient acl)
|
||||
{
|
||||
public async Task HandleAsync(ApproveRegistrationCommand command, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
var registration = await store.GetAsync(command.RegistrationId, ct)
|
||||
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to approve.");
|
||||
|
||||
// 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();
|
||||
await store.SaveAsync(registration, ct);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,12 @@ public interface IWorkflowClient
|
||||
public interface IAclClient
|
||||
{
|
||||
Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Record the approval on the given zaak. The ACL translates this to the ZGW concept — setting
|
||||
/// the zaak's final status — which OpenZaak notifies over NRC; the domain never names statustypen.
|
||||
/// </summary>
|
||||
Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -64,4 +64,23 @@ public sealed class Registration
|
||||
|
||||
ZaakUrl = zaakUrl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances
|
||||
/// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.Ingeschreven"/>.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void Approve()
|
||||
{
|
||||
if (ZaakUrl is null)
|
||||
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.");
|
||||
|
||||
Status = RegistrationStatus.Ingeschreven;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
namespace Big.Domain;
|
||||
|
||||
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. The walking
|
||||
/// skeleton knows only <see cref="Ingediend"/>; withdrawal, beoordeling and herregistratie
|
||||
/// states arrive in their own slices (Iteration 2+).</summary>
|
||||
/// skeleton knows <see cref="Ingediend"/> and the terminal <see cref="Ingeschreven"/>; withdrawal,
|
||||
/// beoordeling and herregistratie states arrive in their own slices (Iteration 2+).</summary>
|
||||
public enum RegistrationStatus
|
||||
{
|
||||
/// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary>
|
||||
Ingediend,
|
||||
|
||||
/// <summary>Approved: entered in the register. Terminal in the walking skeleton (S-09b).</summary>
|
||||
Ingeschreven,
|
||||
}
|
||||
|
||||
@@ -22,7 +22,18 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli
|
||||
return new Uri(opened.ZaakUrl);
|
||||
}
|
||||
|
||||
public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||
|
||||
using var response = await http.PostAsJsonAsync(
|
||||
new Uri(options.BaseUrl, "statussen"), new SetStatusRequest(zaakUrl.ToString()), ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private sealed record OpenZaakRequest([property: JsonPropertyName("bsn")] string Bsn);
|
||||
|
||||
private sealed record OpenZaakResponse([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
|
||||
|
||||
private sealed record SetStatusRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
|
||||
}
|
||||
|
||||
@@ -41,4 +41,37 @@ public class AclHttpClientTests
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.OpenZaakAsync("123456782"));
|
||||
Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approves_a_zaak_by_posting_its_url_to_statussen()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.NoContent));
|
||||
|
||||
await client.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
|
||||
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
||||
Assert.Equal("http://acl/statussen", capture.Seen.RequestUri!.ToString());
|
||||
Assert.Contains("\"zaakUrl\":\"http://openzaak/zaken/api/v1/zaken/abc\"", capture.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_throws_when_the_acl_rejects_the_request()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.BadGateway));
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => client.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approve_rejects_a_null_zaak_url_without_sending_a_request()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.NoContent));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => client.ApproveZaakAsync(null!));
|
||||
Assert.Null(capture.Seen);
|
||||
}
|
||||
}
|
||||
|
||||
89
services/domain/Big.Tests/ApproveRegistrationTests.cs
Normal file
89
services/domain/Big.Tests/ApproveRegistrationTests.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
public class ApproveRegistrationTests
|
||||
{
|
||||
private static Registration WithZaak(string bsn = "123456782")
|
||||
{
|
||||
var registration = Registration.Submit(bsn);
|
||||
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
|
||||
return registration;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approving_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 ApproveRegistration(store, acl);
|
||||
|
||||
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
|
||||
|
||||
var saved = await store.GetAsync(registration.Id);
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, saved!.Status);
|
||||
Assert.Equal(FakeAclClient.DefaultZaakUrl, acl.ApprovedZaakUrl);
|
||||
Assert.Equal(1, acl.ApproveCallCount);
|
||||
// The approved aggregate is persisted (not just mutated in memory).
|
||||
Assert.Equal(1, store.SaveCount);
|
||||
}
|
||||
|
||||
[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 ApproveRegistration(store, acl);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
|
||||
Assert.Equal(0, acl.ApproveCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approving_an_unknown_registration_throws_and_does_not_call_the_acl()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var handler = new ApproveRegistration(store, acl);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => handler.HandleAsync(new ApproveRegistrationCommand(RegistrationId.New())));
|
||||
Assert.Contains("No registration", ex.Message);
|
||||
Assert.Equal(0, acl.ApproveCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approving_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 ApproveRegistration(store, acl);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => handler.HandleAsync(new ApproveRegistrationCommand(registration.Id)));
|
||||
Assert.Contains("no zaak", ex.Message);
|
||||
Assert.Equal(0, acl.ApproveCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Re_approving_an_already_ingeschreven_registration_is_idempotent()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var handler = new ApproveRegistration(store, acl);
|
||||
|
||||
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
|
||||
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
|
||||
|
||||
// The second approval is a no-op: the ACL is not asked to set the status again.
|
||||
Assert.Equal(1, acl.ApproveCallCount);
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status);
|
||||
}
|
||||
}
|
||||
@@ -52,10 +52,20 @@ internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
|
||||
public string? OpenedForBsn { get; private set; }
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public Uri? ApprovedZaakUrl { get; private set; }
|
||||
public int ApproveCallCount { get; private set; }
|
||||
|
||||
public Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default)
|
||||
{
|
||||
CallCount++;
|
||||
OpenedForBsn = bsn;
|
||||
return Task.FromResult(_zaakUrl);
|
||||
}
|
||||
|
||||
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
{
|
||||
ApproveCallCount++;
|
||||
ApprovedZaakUrl = zaakUrl;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,4 +87,38 @@ public class RegistrationTests
|
||||
Assert.Contains("/zaken/abc", ex.Message);
|
||||
Assert.Contains("/zaken/other", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approving_a_registration_with_a_zaak_sets_it_ingeschreven()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
|
||||
registration.Approve();
|
||||
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approving_before_the_zaak_is_opened_is_rejected()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve());
|
||||
|
||||
Assert.Contains("no zaak", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(RegistrationStatus.Ingediend, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approving_an_already_ingeschreven_registration_is_rejected()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
registration.Approve();
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve());
|
||||
Assert.Contains("only an INGEDIEND", ex.Message);
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user