The new gateway landed at 77.6%, dragging the ACL score under its 90 break threshold. The gaps were all real behaviour nobody was asserting: a failed or empty read being mistaken for "nothing there yet" and followed by a blind write, a `results`-less response taking down the resolve with an ArgumentNullException, the CRS headers going to Objecttypen (which is not a geo API), and the write body being sent chunked. ACL score 86.63% → 92.08%.
305 lines
13 KiB
C#
305 lines
13 KiB
C#
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<HttpRequestMessage, Task<HttpResponseMessage>> onSend)
|
|
: HttpMessageHandler
|
|
{
|
|
protected override Task<HttpResponseMessage> 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> sent, Func<HttpRequestMessage, HttpResponseMessage> 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<Sent>();
|
|
|
|
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<Sent>();
|
|
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<Sent>();
|
|
|
|
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<Sent>();
|
|
|
|
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<Sent>();
|
|
|
|
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<Sent>();
|
|
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<Sent>();
|
|
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<InvalidOperationException>(() => 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<Sent>();
|
|
var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = JsonContent.Create(new { results = Array.Empty<object>() }),
|
|
});
|
|
|
|
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => 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<Sent>();
|
|
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<HttpRequestException>(() => 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<Sent>();
|
|
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<HttpRequestException>(() => 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<Sent>();
|
|
var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.Unauthorized)
|
|
{
|
|
Content = new StringContent("{\"detail\":\"invalid token\"}"),
|
|
});
|
|
|
|
var error = await Assert.ThrowsAsync<HttpRequestException>(() => 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<Sent>();
|
|
var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK)
|
|
{
|
|
Content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"),
|
|
});
|
|
|
|
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => 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<Sent>();
|
|
// 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<object>())
|
|
: Json(new { }));
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Creates_the_object_when_the_search_response_carries_no_results_key()
|
|
{
|
|
var sent = new List<Sent>();
|
|
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<Sent>();
|
|
|
|
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<Sent>();
|
|
|
|
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<Sent>();
|
|
|
|
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(sent, req => Route(req, [])).UpsertAsync(null!));
|
|
Assert.Empty(sent);
|
|
}
|
|
}
|