Compare commits
10 Commits
main
...
8badef02af
| Author | SHA1 | Date | |
|---|---|---|---|
| 8badef02af | |||
| d433bdee0e | |||
| 2cacedc469 | |||
| b672132ed4 | |||
| b697b0d865 | |||
| 3e42999c23 | |||
| 3e0d2f9b11 | |||
| 66fe1e5f6f | |||
| ca9964f79b | |||
| 551f6e0a0b |
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user