S-19a · ACL writes the RegisterRecord to Objecten on approval (closes #149) #151

Merged
not merged 11 commits from feat/149-acl-writes-registerrecord into main 2026-08-14 09:34:05 +00:00
2 changed files with 50 additions and 20 deletions
Showing only changes of commit c67ee7d3f5 - Show all commits
@@ -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"));
/// <summary>The URL + latest version number of the configured objecttype, read from Objecttypen.</summary>
/// <summary>The URL + latest published version of the configured objecttype, read from Objecttypen.</summary>
private async Task<Objecttype> ResolveObjecttypeAsync(CancellationToken ct)
{
var page = await GetAsync<ObjecttypePage>(
@@ -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<IReadOnlyList<ObjecttypeVersionDto>>(
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);
}
/// <summary>The URL of the object already holding this registration's record, or null if there is none.</summary>
@@ -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<string>? 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<ObjectDto>? Results);
+34 -13
View File
@@ -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<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]