diff --git a/services/acl/Acl.Tests/ObjectenGatewayTests.cs b/services/acl/Acl.Tests/ObjectenGatewayTests.cs index 6a3de55..d42625c 100644 --- a/services/acl/Acl.Tests/ObjectenGatewayTests.cs +++ b/services/acl/Acl.Tests/ObjectenGatewayTests.cs @@ -19,7 +19,8 @@ public class ObjectenGatewayTests public DateOnly Today { get; } = today; } - private sealed record Sent(HttpMethod Method, Uri Uri, string? Body, string? Auth, string? ContentCrs, string? AcceptCrs); + 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"; @@ -27,13 +28,17 @@ public class ObjectenGatewayTests 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!, - req.Content is null ? null : await req.Content.ReadAsStringAsync(), - req.Headers.Authorization?.ToString(), - req.Content?.Headers.TryGetValues("Content-Crs", out var c) == true ? string.Join(",", c!) : null, - req.Headers.TryGetValues("Accept-Crs", out var a) ? string.Join(",", a) : null)); + 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 @@ -192,6 +197,100 @@ public class ObjectenGatewayTests 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]