diff --git a/services/acl/Acl.Infrastructure/ObjectenGateway.cs b/services/acl/Acl.Infrastructure/ObjectenGateway.cs index 722f62c..1d89ca0 100644 --- a/services/acl/Acl.Infrastructure/ObjectenGateway.cs +++ b/services/acl/Acl.Infrastructure/ObjectenGateway.cs @@ -41,7 +41,7 @@ public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IC private RecordDto NewRecord(int typeVersion, RecordDataDto data) => new(typeVersion, data, clock.Today.ToString("yyyy-MM-dd")); - /// The URL + latest version number of the configured objecttype, read from Objecttypen. + /// The URL + latest published version of the configured objecttype, read from Objecttypen. private async Task ResolveObjecttypeAsync(CancellationToken ct) { var page = await GetAsync( @@ -52,10 +52,16 @@ public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IC ?? throw new InvalidOperationException( $"No objecttype '{options.ObjecttypeName}' registered in Objecttypen — is the RegisterRecord seed applied?"); - // `versions` lists the objecttype's version URLs; the count is the latest version number. - var version = match.Versions?.Count - ?? throw new InvalidOperationException($"Objecttype '{options.ObjecttypeName}' has no published version"); - return new Objecttype(new Uri(match.Url), version); + var url = new Uri(match.Url); + // Write against the highest *published* version: a draft version's schema is still being + // shaped, and objects written against it would be validated by a moving target. + var versions = await GetAsync>( + new Uri(url + "/versions"), options.ObjecttypenToken, crs: false, "objecttype versions", ct); + var latest = versions.Where(v => v.Status == "published").Select(v => v.Version).DefaultIfEmpty(0).Max(); + if (latest == 0) + throw new InvalidOperationException($"Objecttype '{options.ObjecttypeName}' has no published version"); + + return new Objecttype(url, latest); } /// The URL of the object already holding this registration's record, or null if there is none. @@ -115,8 +121,11 @@ public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IC private sealed record ObjecttypeDto( [property: JsonPropertyName("url")] string Url, - [property: JsonPropertyName("name")] string? Name, - [property: JsonPropertyName("versions")] IReadOnlyList? Versions); + [property: JsonPropertyName("name")] string? Name); + + private sealed record ObjecttypeVersionDto( + [property: JsonPropertyName("version")] int Version, + [property: JsonPropertyName("status")] string? Status); private sealed record ObjectPage( [property: JsonPropertyName("results")] IReadOnlyList? Results); diff --git a/services/acl/Acl.Tests/ObjectenGatewayTests.cs b/services/acl/Acl.Tests/ObjectenGatewayTests.cs index bbedc84..6a3de55 100644 --- a/services/acl/Acl.Tests/ObjectenGatewayTests.cs +++ b/services/acl/Acl.Tests/ObjectenGatewayTests.cs @@ -46,24 +46,32 @@ public class ObjectenGatewayTests }, new FixedClock(new DateOnly(2026, 6, 4))); - // The Objecttypen lookup (by name) and the Objecten search (by data attribute) that precede every - // write. `respondToWrite` decides what the POST/PATCH returns. + // A stack that answers the three reads every write is preceded by: the objecttype list (matched by + // name), that objecttype's versions, and the Objecten search for an existing record. private static HttpResponseMessage Route(HttpRequestMessage req, object[] existingObjects) => - req.RequestUri!.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal) - ? new HttpResponseMessage(HttpStatusCode.OK) + req.RequestUri!.AbsolutePath.EndsWith("/versions", StringComparison.Ordinal) + ? Json(new object[] { - Content = JsonContent.Create(new + new { version = 1, status = "published" }, + new { version = 2, status = "published" }, + // A draft must never be written against, even though it is the highest version. + new { version = 3, status = "draft" }, + }) + : req.RequestUri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal) + ? Json(new { results = new[] { - new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse", versions = new[] { "…/versions/1" } }, - new { url = ObjecttypeUrl, name = "RegisterRecord", versions = new[] { "…/versions/1", "…/versions/2" } }, + new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse" }, + new { url = ObjecttypeUrl, name = "RegisterRecord" }, }, - }), - } - : req.Method == HttpMethod.Get - ? new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(new { results = existingObjects }) } - : new HttpResponseMessage(HttpStatusCode.Created) { Content = JsonContent.Create(new { url = "http://objecten:8000/api/v2/objects/obj-1" }) }; + }) + : req.Method == HttpMethod.Get + ? Json(new { results = existingObjects }) + : new HttpResponseMessage(HttpStatusCode.Created) { Content = JsonContent.Create(new { url = "http://objecten:8000/api/v2/objects/obj-1" }) }; + + private static HttpResponseMessage Json(object body) => + new(HttpStatusCode.OK) { Content = JsonContent.Create(body) }; private static RegisterRecord Record() => new("zaak-uuid-1", RegisterRecordStatus.Ingeschreven, "REG-2026-0001"); @@ -76,6 +84,7 @@ public class ObjectenGatewayTests var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects"); Assert.Contains($"\"type\":\"{ObjecttypeUrl}\"", write.Body); + // The highest *published* version (2), not the highest version (a draft 3). Assert.Contains("\"typeVersion\":2", write.Body); Assert.Contains("\"id\":\"zaak-uuid-1\"", write.Body); Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body); @@ -145,7 +154,19 @@ public class ObjectenGatewayTests await gateway.UpsertAsync(Record()); await gateway.UpsertAsync(Record() with { Id = "zaak-uuid-2" }); - Assert.Single(sent, s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)); + Assert.Single(sent, s => s.Uri.AbsolutePath == "/api/v2/objecttypes"); + } + + [Fact] + public async Task Fails_loudly_when_the_objecttype_has_no_published_version() + { + var sent = new List(); + var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath.EndsWith("/versions", StringComparison.Ordinal) + ? Json(new object[] { new { version = 1, status = "draft" } }) + : Route(req, [])); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("published version", error.Message); } [Fact]