feat(domain): ApproveRegistration use case + temp /registrations/{id}/approve endpoint (refs #75)

Loads the registration, asks the ACL to set its zaak's final status (new
IAclClient.ApproveZaakAsync → POST /statussen), then advances the aggregate to
INGESCHREVEN. Idempotent: a repeated approval is a no-op. Adds HTTP-adapter tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 16:50:45 +02:00
parent 66fe1e5f6f
commit 3e0d2f9b11
5 changed files with 89 additions and 0 deletions

View File

@@ -23,6 +23,7 @@ builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService
builder.Services.AddHttpClient<IAclClient, AclHttpClient>(); builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
builder.Services.AddScoped<SubmitRegistration>(); builder.Services.AddScoped<SubmitRegistration>();
builder.Services.AddScoped<ApproveRegistration>();
builder.Services.AddScoped<OpenZaakWorker>(); builder.Services.AddScoped<OpenZaakWorker>();
builder.Services.AddScoped<OpenZaakJobProcessor>(); 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)); 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). // 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) => app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
{ {

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

View File

@@ -25,6 +25,12 @@ public interface IWorkflowClient
public interface IAclClient public interface IAclClient
{ {
Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default); 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> /// <summary>

View File

@@ -22,7 +22,18 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli
return new Uri(opened.ZaakUrl); 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 OpenZaakRequest([property: JsonPropertyName("bsn")] string Bsn);
private sealed record OpenZaakResponse([property: JsonPropertyName("zaakUrl")] string ZaakUrl); private sealed record OpenZaakResponse([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
private sealed record SetStatusRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
} }

View File

@@ -41,4 +41,27 @@ public class AclHttpClientTests
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.OpenZaakAsync("123456782")); var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.OpenZaakAsync("123456782"));
Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase); 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")));
}
} }