using System.Net; using System.Net.Http.Json; using Acl.Application; using Acl.Infrastructure; namespace Acl.Tests; public class ObjectenGatewayTests { private sealed class StubHandler(Func> onSend) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) => onSend(request); } private sealed class FixedClock(DateOnly today) : IClock { public DateOnly Today { get; } = today; } private sealed record Sent( HttpMethod Method, Uri Uri, string? Body, string? Auth, string? ContentCrs, string? AcceptCrs, long? ContentLength); private const string ObjecttypeUrl = "http://objecttypen:8000/api/v2/objecttypes/ot-1"; private static ObjectenGateway Gateway(List sent, Func respond) => new( new HttpClient(new StubHandler(async req => { // Read the length BEFORE the body: ReadAsStringAsync buffers the content and would set // ContentLength as a side effect, masking whether the gateway buffered it itself (uwsgi // rejects a chunked body). sent.Add(new Sent( req.Method, req.RequestUri!, ContentLength: req.Content?.Headers.ContentLength, Body: req.Content is null ? null : await req.Content.ReadAsStringAsync(), Auth: req.Headers.Authorization?.ToString(), ContentCrs: req.Content?.Headers.TryGetValues("Content-Crs", out var c) == true ? string.Join(",", c!) : null, AcceptCrs: req.Headers.TryGetValues("Accept-Crs", out var a) ? string.Join(",", a) : null)); return respond(req); })), new ObjectenOptions { BaseUrl = new("http://objecten:8000"), Token = "objecten-token", ObjecttypenBaseUrl = new("http://objecttypen:8000"), ObjecttypenToken = "objecttypen-token", ObjecttypeName = "RegisterRecord", }, 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. 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" }, }) : 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" }, }, }) : 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"); [Fact] public async Task Creates_the_object_when_none_exists_for_the_registration() { var sent = new List(); await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); 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); Assert.Contains("\"reference\":\"REG-2026-0001\"", write.Body); Assert.Contains("\"startAt\":\"2026-06-04\"", write.Body); } [Fact] public async Task Updates_the_existing_object_instead_of_creating_a_second_one() { var sent = new List(); object[] existing = [new { uuid = "obj-9", url = "http://objecten:8000/api/v2/objects/obj-9" }]; await Gateway(sent, req => Route(req, existing)).UpsertAsync(Record()); Assert.DoesNotContain(sent, s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects"); var write = sent.Single(s => s.Method == HttpMethod.Patch); Assert.Equal("http://objecten:8000/api/v2/objects/obj-9", write.Uri.ToString()); Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body); } [Fact] public async Task Searches_objecten_for_the_registration_id_within_the_objecttype() { var sent = new List(); await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); var search = sent.Single(s => s.Method == HttpMethod.Get && s.Uri.AbsolutePath == "/api/v2/objects"); Assert.Contains("type=" + Uri.EscapeDataString(ObjecttypeUrl), search.Uri.Query); Assert.Contains("data_attrs=id__exact__zaak-uuid-1", search.Uri.Query); } [Fact] public async Task Authenticates_with_the_static_token_of_each_api() { var sent = new List(); await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); Assert.All( sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)), s => Assert.Equal("Token objecttypen-token", s.Auth)); Assert.All( sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objects", StringComparison.Ordinal)), s => Assert.Equal("Token objecten-token", s.Auth)); } [Fact] public async Task Sends_the_geo_crs_headers_the_objecten_api_requires() { var sent = new List(); await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); var objects = sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objects", StringComparison.Ordinal)).ToList(); Assert.All(objects, s => Assert.Equal("EPSG:4326", s.AcceptCrs)); Assert.All(objects.Where(s => s.Body is not null), s => Assert.Equal("EPSG:4326", s.ContentCrs)); } [Fact] public async Task Resolves_the_objecttype_once_and_reuses_it_across_writes() { var sent = new List(); var gateway = Gateway(sent, req => Route(req, [])); await gateway.UpsertAsync(Record()); await gateway.UpsertAsync(Record() with { Id = "zaak-uuid-2" }); 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] public async Task Fails_loudly_when_the_objecttype_is_not_registered() { var sent = new List(); var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK) { Content = JsonContent.Create(new { results = Array.Empty() }), }); var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); Assert.Contains("RegisterRecord", error.Message); } [Fact] public async Task Surfaces_the_objecten_error_body_when_a_write_is_rejected() { var sent = new List(); var gateway = Gateway(sent, req => req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath == "/api/v2/objects" ? new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{\"detail\":\"schema mismatch\"}") } : Route(req, [])); var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); Assert.Contains("schema mismatch", error.Message); Assert.Contains("Creating the register record", error.Message); } [Fact] public async Task Surfaces_the_objecten_error_body_when_an_update_is_rejected() { var sent = new List(); object[] existing = [new { url = "http://objecten:8000/api/v2/objects/obj-9" }]; var gateway = Gateway(sent, req => req.Method == HttpMethod.Patch ? new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{\"detail\":\"stale version\"}") } : Route(req, existing)); var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); Assert.Contains("stale version", error.Message); Assert.Contains("Updating the register record", error.Message); } [Fact] public async Task Surfaces_a_failed_read_instead_of_writing_blind() { var sent = new List(); var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.Unauthorized) { Content = new StringContent("{\"detail\":\"invalid token\"}"), }); var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); Assert.Contains("Querying objecttypen", error.Message); Assert.Contains("invalid token", error.Message); // A read that failed must never be mistaken for "nothing there yet" and followed by a write. Assert.DoesNotContain(sent, s => s.Method == HttpMethod.Post || s.Method == HttpMethod.Patch); } [Fact] public async Task Surfaces_an_empty_read_body_rather_than_dereferencing_it() { var sent = new List(); var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"), }); var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); Assert.Contains("objecttypen", error.Message); } [Fact] 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 { })); await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); } [Fact] public async Task Creates_the_object_when_the_search_response_carries_no_results_key() { var sent = new List(); var gateway = Gateway(sent, req => req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath == "/api/v2/objects" ? Json(new { }) : Route(req, [])); await gateway.UpsertAsync(Record()); Assert.Contains(sent, s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects"); } [Fact] public async Task Reads_objecttypen_without_the_crs_headers_it_does_not_accept() { var sent = new List(); await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); // Objecttypen is not a geo API; only the Objecten hops carry CRS. Assert.All( sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)), s => Assert.Null(s.AcceptCrs)); } [Fact] public async Task Buffers_the_write_body_so_uwsgi_gets_a_content_length() { var sent = new List(); await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects"); Assert.NotNull(write.ContentLength); } [Fact] public async Task Rejects_a_null_record_without_calling_objecten() { var sent = new List(); await Assert.ThrowsAsync(() => Gateway(sent, req => Route(req, [])).UpsertAsync(null!)); Assert.Empty(sent); } }