Compare commits

..

10 Commits

Author SHA1 Message Date
8badef02af docs(architecture): ADR-0011 approval status flow + demo note (refs #75)
Some checks failed
CI / lint (pull_request) Successful in 1m7s
CI / build (pull_request) Successful in 54s
CI / unit (pull_request) Successful in 1m4s
CI / frontend (pull_request) Successful in 2m0s
CI / mutation (pull_request) Failing after 1m54s
CI / verify-stack (pull_request) Failing after 5m49s
Records the two non-obvious decisions: the ACL resolves the zaaktype eindstatus
(domain stays ZGW-ignorant), and the event-subscriber projects INGESCHREVEN from
the notification alone (hoofdObject + any status-create), never reading OpenZaak.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:05:12 +02:00
d433bdee0e test(e2e): approve a submitted registration and assert public INGESCHREVEN (refs #75)
Extend the walking-skeleton happy path: after the entry is publicly visible as
INGEDIEND, approve it via the temporary admin endpoint (waiting for the zaak to be
opened first), then poll the openbaar register until it shows INGESCHREVEN —
covering submit → BFF → domain → ACL → NRC → projection → public visibility.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:03:50 +02:00
2cacedc469 feat(event-subscriber): project a status-set as INGESCHREVEN via hoofdObject (refs #75)
Bind the NRC hoofdObject, recognise a zaken/status/create notification, and key the
projection on the zaak (hoofdObject) so the status updates the existing INGEDIEND row
to INGESCHREVEN — without reading OpenZaak (§8.1). Retain the ZGW resource in the log
(new column + migration) so a rebuild reproduces the approved status. Updates the
acceptance fakes for the new IAclClient/IZaakGateway members.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:02:39 +02:00
b672132ed4 test(event-subscriber): project INGESCHREVEN from a status-set notification (refs #75)
A zaken/status/create notification (hoofdObject = the zaak) updates the zaak's row to
INGESCHREVEN, and a rebuild reproduces it. Red: Notification has no hoofdObject/
IsZaakStatusSet and RegistrationStatus.Ingeschreven doesn't exist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:02:39 +02:00
b697b0d865 feat(acl): set a zaak to its eindstatus via ZGW + POST /statussen endpoint (refs #75)
The gateway resolves the zaaktype's eindstatus from the catalogus (isEindstatus,
falling back to the highest volgnummer) and POSTs a status against the zaak. Exposed
as POST /statussen for the domain's approve use case. Adds an integration test that
sets the eindstatus against a real OpenZaak and verifies the zaak's current status.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:54:39 +02:00
3e42999c23 test(acl): approving a zaak sets it to the zaaktype's eindstatus, dated today (refs #75)
Red: AclService.ApproveZaakAsync and IZaakGateway.SetZaakToEindstatusAsync don't exist yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:54:39 +02:00
3e0d2f9b11 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>
2026-07-13 16:50:45 +02:00
66fe1e5f6f test(domain): approve use case sets the zaak status via the ACL, idempotently (refs #75)
Red: ApproveRegistration and IAclClient.ApproveZaakAsync don't exist yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:50:45 +02:00
ca9964f79b feat(domain): add the Ingeschreven state and Registration.Approve() (refs #75)
Approve() advances a submitted registration with an opened zaak to INGESCHREVEN;
re-approval and approval-before-zaak are rejected as invalid transitions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:45:36 +02:00
551f6e0a0b test(domain): approving a registration advances INGEDIEND → INGESCHREVEN (refs #75)
Guards: an opened zaak is required and only a submitted registration can be
approved. Fails to compile — Registration.Approve() and RegistrationStatus.
Ingeschreven don't exist yet (red).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:45:36 +02:00
7 changed files with 29 additions and 360 deletions

View File

@@ -47,51 +47,40 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
ArgumentNullException.ThrowIfNull(zaaktypeUrl);
var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct);
var resultaattype = await ResolveResultaattypeAsync(zaaktypeUrl, ct);
// OpenZaak refuses to set a zaak's eindstatus unless the zaak has a resultaat
// ("resultaat-does-not-exist"), so record the resultaat first, then the status.
await PostAsync("/zaken/api/v1/resultaten",
new ResultaatDto(zaakUrl.ToString(), resultaattype.ToString()), "Setting the zaak resultaat", ct);
await PostAsync("/zaken/api/v1/statussen",
// datumStatusGezet is a ZGW date-time; set it at the start of the given day (UTC).
new StatusDto(zaakUrl.ToString(), eindstatus.ToString(),
datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ")),
"Setting the zaak status", ct);
}
// POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets
// a Content-Length instead of a chunked body (as with zaak-create).
private async Task PostAsync(string path, object dto, string action, CancellationToken ct)
{
using var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path))
using var message = new HttpRequestMessage(
HttpMethod.Post, new Uri(options.BaseUrl, "/zaken/api/v1/statussen"))
{
Content = JsonContent.Create(dto),
Content = JsonContent.Create(new StatusDto(
zaakUrl.ToString(),
eindstatus.ToString(),
// datumStatusGezet is a ZGW date-time; set it at the start of the given day (UTC).
datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ"))),
};
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
// The status resource carries no geometry, so no CRS headers. As with zaak-create, buffer the
// body so uwsgi gets a Content-Length instead of a chunked body.
await message.Content.LoadIntoBufferAsync(ct);
using var response = await http.SendAsync(message, ct);
await EnsureSuccessAsync(response, action, ct);
}
// EnsureSuccessStatusCode discards the response body; ZGW returns a JSON problem detail on 400 that
// is essential for diagnosing a rejected request, so surface it in the exception.
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken ct)
{
if (response.IsSuccessStatusCode)
return;
var body = await response.Content.ReadAsStringAsync(ct);
throw new HttpRequestException($"{action} failed: {(int)response.StatusCode} {response.ReasonPhrase}. {body}");
response.EnsureSuccessStatusCode();
}
/// <summary>Resolve the zaaktype's eindstatus (the terminal statustype) from the catalogus.</summary>
private async Task<Uri> ResolveEindstatusAsync(Uri zaaktypeUrl, CancellationToken ct)
{
var page = await GetCatalogusAsync<StatustypePage>("statustypen", zaaktypeUrl, "statustypen", ct);
var query = new Uri(options.BaseUrl,
"/catalogi/api/v1/statustypen?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
using var message = new HttpRequestMessage(HttpMethod.Get, query);
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
using var response = await http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();
var page = await response.Content.ReadFromJsonAsync<StatustypePage>(ct)
?? throw new InvalidOperationException("OpenZaak returned an empty statustypen response");
var results = page.Results ?? [];
// OpenZaak flags the terminal statustype (highest volgnummer) as isEindstatus; fall back to the
@@ -102,31 +91,6 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
return new Uri(eindstatus.Url);
}
/// <summary>Resolve the zaaktype's resultaattype from the catalogus (the seed defines one).</summary>
private async Task<Uri> ResolveResultaattypeAsync(Uri zaaktypeUrl, CancellationToken ct)
{
var page = await GetCatalogusAsync<ResultaattypePage>("resultaattypen", zaaktypeUrl, "resultaattypen", ct);
var resultaattype = (page.Results ?? []).FirstOrDefault()
?? throw new InvalidOperationException($"No resultaattypen found for zaaktype {zaaktypeUrl}");
return new Uri(resultaattype.Url);
}
// GETs a catalogus collection filtered by zaaktype (status=alles includes concept + published).
private async Task<T> GetCatalogusAsync<T>(string resource, Uri zaaktypeUrl, string label, CancellationToken ct)
{
var query = new Uri(options.BaseUrl,
$"/catalogi/api/v1/{resource}?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
using var message = new HttpRequestMessage(HttpMethod.Get, query);
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
using var response = await http.SendAsync(message, ct);
await EnsureSuccessAsync(response, $"Querying {label}", ct);
return await response.Content.ReadFromJsonAsync<T>(ct)
?? throw new InvalidOperationException($"OpenZaak returned an empty {label} response");
}
private sealed record ZaakDto(
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
[property: JsonPropertyName("zaaktype")] string Zaaktype,
@@ -149,14 +113,4 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("volgnummer")] int Volgnummer,
[property: JsonPropertyName("isEindstatus")] bool IsEindstatus);
private sealed record ResultaatDto(
[property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("resultaattype")] string Resultaattype);
private sealed record ResultaattypePage(
[property: JsonPropertyName("results")] IReadOnlyList<ResultaattypeDto>? Results);
private sealed record ResultaattypeDto(
[property: JsonPropertyName("url")] string Url);
}

View File

@@ -131,9 +131,8 @@ public class OpenZaakGatewayTests
Content = new StringContent("null", Encoding.UTF8, "application/json"),
}));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
await Assert.ThrowsAsync<InvalidOperationException>(
() => Gateway(handler).OpenZaakAsync(SampleRequest()));
Assert.Contains("empty zaak response", ex.Message);
}
[Fact]
@@ -145,229 +144,6 @@ public class OpenZaakGatewayTests
() => Gateway(handler).OpenZaakAsync(null!));
}
// --- SetZaakToEindstatusAsync (approval / S-09b) ---
private const string ZaakUrl = "http://openzaak/zaken/api/v1/zaken/xyz";
private static readonly Uri Zaaktype = new("http://openzaak/catalogi/api/v1/zaaktypen/big");
private sealed class Recorder
{
public List<HttpRequestMessage> Requests { get; } = [];
public List<string?> Bodies { get; } = [];
public List<long?> ContentLengths { get; } = [];
public int IndexOf(string pathContains) =>
Requests.FindIndex(r => r.RequestUri!.ToString().Contains(pathContains));
// The (body, content-length, request) of the single request whose URL contains the segment.
public (string? Body, long? Length, HttpRequestMessage Request) Sent(string pathContains)
{
var i = IndexOf(pathContains);
return (Bodies[i], ContentLengths[i], Requests[i]);
}
}
// Per-route response config for the four calls the approval makes.
private sealed class OzRoutes
{
public string StatustypenJson { get; init; } = StatustypenPage(withEindstatusFlag: true);
public string ResultaattypenJson { get; init; } = """{"results":[{"url":"http://openzaak/catalogi/api/v1/resultaattypen/1"}]}""";
public HttpStatusCode StatustypenStatus { get; init; } = HttpStatusCode.OK;
public HttpStatusCode ResultaattypenStatus { get; init; } = HttpStatusCode.OK;
public HttpStatusCode ResultaatPostStatus { get; init; } = HttpStatusCode.Created;
public HttpStatusCode StatusPostStatus { get; init; } = HttpStatusCode.Created;
}
// Routes the approval's four calls by URL: GET /statustypen, GET /resultaattypen (catalogus),
// then POST /resultaten and POST /statussen (zaken).
private static StubHandler ApprovalStub(Recorder rec, OzRoutes routes) => new(async req =>
{
rec.Requests.Add(req);
// Capture the length BEFORE reading the body (ReadAsStringAsync buffers as a side effect).
rec.ContentLengths.Add(req.Content?.Headers.ContentLength);
rec.Bodies.Add(req.Content is null ? null : await req.Content.ReadAsStringAsync());
var url = req.RequestUri!.ToString();
if (req.Method == HttpMethod.Get && url.Contains("/statustypen"))
return Json(routes.StatustypenStatus, routes.StatustypenJson);
if (req.Method == HttpMethod.Get && url.Contains("/resultaattypen"))
return Json(routes.ResultaattypenStatus, routes.ResultaattypenJson);
if (url.Contains("/resultaten"))
return Json(routes.ResultaatPostStatus, """{"url":"http://openzaak/zaken/api/v1/resultaten/new"}""");
return Json(routes.StatusPostStatus, """{"url":"http://openzaak/zaken/api/v1/statussen/new"}""");
});
private static HttpResponseMessage Json(HttpStatusCode status, string body) =>
new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
// Two statustypen; the eindstatus is flagged on the *lower* volgnummer so the tests prove the
// isEindstatus flag is preferred over "highest volgnummer", not coincidentally equal to it.
private static string StatustypenPage(bool withEindstatusFlag) => JsonSerializer.Serialize(new
{
results = new object[]
{
new { url = "http://openzaak/catalogi/api/v1/statustypen/1", volgnummer = 1, isEindstatus = withEindstatusFlag },
new { url = "http://openzaak/catalogi/api/v1/statustypen/2", volgnummer = 2, isEindstatus = false },
},
});
[Fact]
public async Task Approving_sets_a_resultaat_then_posts_the_flagged_eindstatus_against_the_zaak()
{
var rec = new Recorder();
await Gateway(ApprovalStub(rec, new OzRoutes()))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
Assert.Equal(4, rec.Requests.Count);
// Both catalogus queries filter by the zaaktype and carry the bearer.
var statustypenGet = rec.Sent("/statustypen").Request;
Assert.Equal(HttpMethod.Get, statustypenGet.Method);
Assert.Contains(Uri.EscapeDataString(Zaaktype.ToString()), statustypenGet.RequestUri!.ToString());
Assert.Equal("Bearer", statustypenGet.Headers.Authorization!.Scheme);
Assert.Contains(Uri.EscapeDataString(Zaaktype.ToString()), rec.Sent("/resultaattypen").Request.RequestUri!.ToString());
// OpenZaak requires a resultaat before the eindstatus, so /resultaten precedes /statussen.
Assert.True(rec.IndexOf("/resultaten") < rec.IndexOf("/statussen"));
var resultaat = rec.Sent("/resultaten");
Assert.Equal("http://openzaak/zaken/api/v1/resultaten", resultaat.Request.RequestUri!.ToString());
Assert.Equal("Bearer", resultaat.Request.Headers.Authorization!.Scheme);
Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", resultaat.Body);
Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/1\"", resultaat.Body);
Assert.True(resultaat.Length > 0);
var status = rec.Sent("/statussen");
Assert.Equal("http://openzaak/zaken/api/v1/statussen", status.Request.RequestUri!.ToString());
Assert.Equal("Bearer", status.Request.Headers.Authorization!.Scheme);
Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", status.Body);
// The isEindstatus-flagged statustype (/1) is chosen — even though /2 has a higher volgnummer.
Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/1\"", status.Body);
Assert.Contains("\"datumStatusGezet\":\"2026-06-04T00:00:00Z\"", status.Body);
// Bodies are buffered (Content-Length set), so uwsgi doesn't get a chunked body.
Assert.True(status.Length > 0);
}
[Fact]
public async Task Approving_falls_back_to_the_highest_volgnummer_when_no_eindstatus_is_flagged()
{
var rec = new Recorder();
await Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = StatustypenPage(withEindstatusFlag: false) }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
// No isEindstatus flag → the highest volgnummer (/2) is chosen.
Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/2\"", rec.Sent("/statussen").Body);
}
[Fact]
public async Task Approving_throws_when_the_zaaktype_has_no_statustypen()
{
var rec = new Recorder();
// A page with no `results` property (Results is null) — the eindstatus cannot be resolved.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = "{}" }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("No statustypen found", ex.Message);
// It never posts anything when it cannot resolve the eindstatus.
Assert.Single(rec.Requests);
}
[Fact]
public async Task Approving_throws_when_the_statustypen_response_is_empty()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = "null" }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("empty statustypen", ex.Message);
Assert.Single(rec.Requests);
}
[Fact]
public async Task Approving_throws_when_the_statustypen_query_fails()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { StatustypenStatus = HttpStatusCode.InternalServerError }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("Querying statustypen", ex.Message);
}
[Fact]
public async Task Approving_throws_when_the_resultaattypen_query_fails()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenStatus = HttpStatusCode.InternalServerError }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("Querying resultaattypen", ex.Message);
Assert.Equal(-1, rec.IndexOf("/resultaten"));
}
[Fact]
public async Task Approving_throws_when_the_zaaktype_has_no_resultaattype()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = "{}" }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("No resultaattypen found", ex.Message);
// Resolved the eindstatus + queried resultaattypen, but posted nothing.
Assert.Equal(-1, rec.IndexOf("/resultaten"));
Assert.Equal(-1, rec.IndexOf("/statussen"));
}
[Fact]
public async Task Approving_throws_when_posting_the_resultaat_fails()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { ResultaatPostStatus = HttpStatusCode.BadRequest }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("Setting the zaak resultaat", ex.Message);
// The status is never posted if the resultaat could not be recorded.
Assert.Equal(-1, rec.IndexOf("/statussen"));
}
[Fact]
public async Task Approving_throws_when_posting_the_status_fails()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { StatusPostStatus = HttpStatusCode.BadRequest }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("Setting the zaak status", ex.Message);
// It got as far as the resultaat + the status POST (4 calls) before failing.
Assert.Equal(4, rec.Requests.Count);
}
[Fact]
public async Task Approving_rejects_a_null_zaak_or_zaaktype()
{
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
await Assert.ThrowsAsync<ArgumentNullException>(() =>
Gateway(handler).SetZaakToEindstatusAsync(null!, Zaaktype, new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() =>
Gateway(handler).SetZaakToEindstatusAsync(new Uri(ZaakUrl), null!, new DateOnly(2026, 6, 4)));
}
// ZGW tokens are base64url with padding stripped (ZgwToken.B64Url); restore it to decode.
private static string DecodeSegment(string segment)
{

View File

@@ -64,14 +64,4 @@ public class AclHttpClientTests
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);
}
}

View File

@@ -27,19 +27,6 @@ public class ApproveRegistrationTests
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]
@@ -49,9 +36,8 @@ public class ApproveRegistrationTests
var acl = new FakeAclClient();
var handler = new ApproveRegistration(store, acl);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new ApproveRegistrationCommand(RegistrationId.New())));
Assert.Contains("No registration", ex.Message);
Assert.Equal(0, acl.ApproveCallCount);
}
@@ -64,9 +50,8 @@ public class ApproveRegistrationTests
store.Seed(registration);
var handler = new ApproveRegistration(store, acl);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new ApproveRegistrationCommand(registration.Id)));
Assert.Contains("no zaak", ex.Message);
Assert.Equal(0, acl.ApproveCallCount);
}

View File

@@ -117,8 +117,7 @@ public class RegistrationTests
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.Throws<InvalidOperationException>(() => registration.Approve());
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
}

View File

@@ -19,46 +19,12 @@ public static class ServiceCollectionExtensions
return services;
}
// A fixed application-scoped key for the migration advisory lock (any stable 64-bit constant).
private const long MigrationAdvisoryLockKey = 727501;
/// <summary>Apply any pending EF migrations. Called once on service start so a fresh stack
/// reaches a usable schema without a manual migration step (DoD: compose up reaches green).
///
/// The Event Subscriber and the projection-api share this DB and both migrate on start. EF's
/// migrations-history lock is released between individual migrations, so with more than one pending
/// migration two migrators can interleave and one re-applies a migration the other just did
/// ("column already exists"). Hold a Postgres session <c>pg_advisory_lock</c> across the whole
/// sequence so it runs exactly once; the second migrator then finds nothing pending.</summary>
/// reaches a usable schema without a manual migration step (DoD: compose up reaches green).</summary>
public static async Task MigrateProjectionAsync(this IServiceProvider services, CancellationToken ct = default)
{
await using var scope = services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ProjectionDbContext>();
var connection = db.Database.GetDbConnection();
await connection.OpenAsync(ct);
try
{
await ExecuteAsync(connection, $"SELECT pg_advisory_lock({MigrationAdvisoryLockKey})", ct);
try
{
await db.Database.MigrateAsync(ct);
}
finally
{
await ExecuteAsync(connection, $"SELECT pg_advisory_unlock({MigrationAdvisoryLockKey})", ct);
}
}
finally
{
await connection.CloseAsync();
}
}
private static async Task ExecuteAsync(System.Data.Common.DbConnection connection, string sql, CancellationToken ct)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
await command.ExecuteNonQueryAsync(ct);
await db.Database.MigrateAsync(ct);
}
}

View File

@@ -10,9 +10,8 @@ builder.Services.AddProjectionReadModel(connectionString);
var app = builder.Build();
// Ensure the schema exists before serving reads. The Event Subscriber migrates this shared DB too;
// MigrateProjectionAsync serialises concurrent migrators with a session advisory lock so the whole
// migration sequence runs exactly once (see ServiceCollectionExtensions).
// Ensure the schema exists before serving reads. EF serialises concurrent migrators via the
// migrations-history lock, so it is safe that the Event Subscriber migrates too.
await app.Services.MigrateProjectionAsync();
app.MapGet("/health", () => "Healthy");