diff --git a/services/acl/Acl.Infrastructure/ObjectenGateway.cs b/services/acl/Acl.Infrastructure/ObjectenGateway.cs index 1d89ca0..aa43e68 100644 --- a/services/acl/Acl.Infrastructure/ObjectenGateway.cs +++ b/services/acl/Acl.Infrastructure/ObjectenGateway.cs @@ -52,16 +52,23 @@ public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IC ?? throw new InvalidOperationException( $"No objecttype '{options.ObjecttypeName}' registered in Objecttypen — is the RegisterRecord seed applied?"); - 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(); + // shaped, and objects written against it would be validated by a moving target. The objecttype + // carries its versions as URLs, so each is fetched for its status (the collection response + // gives no status) — once per gateway instance, alongside the lookup above. + var latest = 0; + foreach (var versionUrl in match.Versions ?? []) + { + var version = await GetAsync( + new Uri(versionUrl), options.ObjecttypenToken, crs: false, "objecttype version", ct); + if (version.Status == "published" && version.Version > latest) + latest = version.Version; + } + if (latest == 0) throw new InvalidOperationException($"Objecttype '{options.ObjecttypeName}' has no published version"); - return new Objecttype(url, latest); + return new Objecttype(new Uri(match.Url), latest); } /// The URL of the object already holding this registration's record, or null if there is none. @@ -121,7 +128,8 @@ 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("name")] string? Name, + [property: JsonPropertyName("versions")] IReadOnlyList? Versions); private sealed record ObjecttypeVersionDto( [property: JsonPropertyName("version")] int Version, diff --git a/services/acl/Acl.Tests/ObjectenGatewayTests.cs b/services/acl/Acl.Tests/ObjectenGatewayTests.cs index d42625c..60c3a6f 100644 --- a/services/acl/Acl.Tests/ObjectenGatewayTests.cs +++ b/services/acl/Acl.Tests/ObjectenGatewayTests.cs @@ -51,24 +51,27 @@ public class ObjectenGatewayTests }, new FixedClock(new DateOnly(2026, 6, 4))); - // 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. + // A published v1 and v2, plus a draft v3 that must never be written against even though it is the + // highest version. + private static readonly Dictionary Versions = new() + { + [$"{ObjecttypeUrl}/versions/1"] = new { version = 1, status = "published" }, + [$"{ObjecttypeUrl}/versions/2"] = new { version = 2, status = "published" }, + [$"{ObjecttypeUrl}/versions/3"] = new { version = 3, status = "draft" }, + }; + + // A stack that answers the reads every write is preceded by: the objecttype list (matched by name), + // each of that objecttype's versions, and the Objecten search for an existing record. private static HttpResponseMessage Route(HttpRequestMessage req, object[] existingObjects) => - req.RequestUri!.AbsolutePath.EndsWith("/versions", StringComparison.Ordinal) - ? Json(new object[] - { - 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" }, - }) + Versions.TryGetValue(req.RequestUri!.ToString(), out var version) + ? Json(version) : req.RequestUri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal) ? Json(new { results = new[] { - new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse" }, - new { url = ObjecttypeUrl, name = "RegisterRecord" }, + new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse", versions = Array.Empty() }, + new { url = ObjecttypeUrl, name = "RegisterRecord", versions = Versions.Keys.ToArray() }, }, }) : req.Method == HttpMethod.Get @@ -166,8 +169,8 @@ public class ObjectenGatewayTests 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" } }) + var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath.Contains("/versions/", StringComparison.Ordinal) + ? Json(new { version = 1, status = "draft" }) : Route(req, [])); var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); @@ -230,6 +233,30 @@ public class ObjectenGatewayTests Assert.DoesNotContain(sent, s => s.Method == HttpMethod.Post || s.Method == HttpMethod.Patch); } + [Fact] + public async Task Fails_loudly_when_the_objecttype_carries_no_versions_at_all() + { + var sent = new List(); + var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath == "/api/v2/objecttypes" + ? Json(new { results = new[] { new { url = ObjecttypeUrl, name = "RegisterRecord" } } }) + : Route(req, [])); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("published version", error.Message); + } + + [Fact] + public async Task Says_which_read_failed_when_the_objecten_search_errors() + { + var sent = new List(); + var gateway = Gateway(sent, req => req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath == "/api/v2/objects" + ? new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("boom") } + : Route(req, [])); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("Querying objects", error.Message); + } + [Fact] public async Task Surfaces_an_empty_read_body_rather_than_dereferencing_it() { @@ -247,13 +274,12 @@ public class ObjectenGatewayTests public async Task Treats_a_result_less_response_as_no_match_rather_than_crashing() { var sent = new List(); - // Neither collection carries a `results` key — the objecttype is absent, which must surface as - // the "not registered" error rather than a NullReferenceException. - var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath.EndsWith("/versions", StringComparison.Ordinal) - ? Json(Array.Empty()) - : Json(new { })); + // The objecttypes collection carries no `results` key — the objecttype is absent, which must + // surface as the "not registered" error rather than an ArgumentNullException from LINQ. + var gateway = Gateway(sent, _ => Json(new { })); - await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("RegisterRecord", error.Message); } [Fact]