test(domain): expiry worker cancels the zaak via the ACL on timeout (refs #106)

S-10c: expiring the aggregate to VERLOPEN is not enough — the ZGW zaak must
also be cancelled through the ACL (§8.1). Adds IAclClient.CancelZaakAsync and
its client/fakes, and asserts the worker cancels a still-open registration's
zaak but leaves an already-resolved one untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
not
2026-07-21 14:36:33 +02:00
co-authored by Claude Opus 4.8
parent 9275cfeecd
commit e31297bd4c
7 changed files with 73 additions and 12 deletions
@@ -9,7 +9,7 @@ namespace Big.Application;
/// nothing of Flowable. The polling loop that feeds it jobs lives in Infrastructure. Mirrors /// nothing of Flowable. The polling loop that feeds it jobs lives in Infrastructure. Mirrors
/// <see cref="OpenZaakWorker"/>. /// <see cref="OpenZaakWorker"/>.
/// </summary> /// </summary>
public sealed class ExpireRegistrationWorker(IRegistrationStore store) public sealed class ExpireRegistrationWorker(IRegistrationStore store, IAclClient acl)
{ {
/// <summary> /// <summary>
/// Process the job. Idempotent and tolerant of races (§8.6, at-least-once delivery): a job whose /// Process the job. Idempotent and tolerant of races (§8.6, at-least-once delivery): a job whose
+6
View File
@@ -59,6 +59,12 @@ public interface IAclClient
/// zaak (§8.1). Returns the stored document's URL. /// zaak (§8.1). Returns the stored document's URL.
/// </summary> /// </summary>
Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default); Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default);
/// <summary>
/// Cancel the zaak on document-timeout expiry (S-10c): the 30-day document term lapsed, so the ACL
/// translates this to the ZGW cancellation status/resultaat. The domain never names statustypen.
/// </summary>
Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default);
} }
/// <summary> /// <summary>
@@ -31,6 +31,15 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
public async Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
using var response = await http.PostAsJsonAsync(
new Uri(options.BaseUrl, "annuleringen"), new CancelZaakRequest(zaakUrl.ToString()), ct);
response.EnsureSuccessStatusCode();
}
public async Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default) public async Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
{ {
ArgumentNullException.ThrowIfNull(zaakUrl); ArgumentNullException.ThrowIfNull(zaakUrl);
@@ -56,6 +65,8 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli
private sealed record SetStatusRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl); private sealed record SetStatusRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
private sealed record CancelZaakRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
private sealed record StoreDocumentRequest( private sealed record StoreDocumentRequest(
[property: JsonPropertyName("zaakUrl")] string ZaakUrl, [property: JsonPropertyName("zaakUrl")] string ZaakUrl,
[property: JsonPropertyName("contentBase64")] string ContentBase64, [property: JsonPropertyName("contentBase64")] string ContentBase64,
@@ -10,10 +10,13 @@ public class ExpireRegistrationWorkerTests
{ {
private const string Bsn = "123456782"; private const string Bsn = "123456782";
private static Registration Submitted(string processInstanceId = "proc-1") // By the time the 30-day document timer fires, the zaak was opened long ago (OpenZaakAanmaken runs
// early in the flow), so a timed-out registration carries a zaak the worker can cancel.
private static Registration Submitted(string processInstanceId = "proc-1", Uri? zaakUrl = null)
{ {
var registration = Registration.Submit(Bsn); var registration = Registration.Submit(Bsn);
registration.RecordProcessStarted(processInstanceId); registration.RecordProcessStarted(processInstanceId);
registration.AttachZaak(zaakUrl ?? FakeAclClient.DefaultZaakUrl);
return registration; return registration;
} }
@@ -24,7 +27,7 @@ public class ExpireRegistrationWorkerTests
var registration = Submitted(); var registration = Submitted();
store.Seed(registration); store.Seed(registration);
await new ExpireRegistrationWorker(store).HandleAsync( await new ExpireRegistrationWorker(store, new FakeAclClient()).HandleAsync(
new RegistratieVerlopenJob("job-7", registration.Id)); new RegistratieVerlopenJob("job-7", registration.Id));
var saved = await store.GetAsync(registration.Id); var saved = await store.GetAsync(registration.Id);
@@ -33,37 +36,60 @@ public class ExpireRegistrationWorkerTests
} }
[Fact] [Fact]
public async Task An_already_verlopen_registration_is_not_persisted_again() public async Task Cancels_the_zaak_via_the_acl_when_expiring_a_still_open_registration()
{ {
// A redelivered job (§8.6) finds the aggregate already VERLOPEN: a no-op, not saved again. // S-10c: expiring the aggregate is not enough — the ZGW zaak must also be set to its
// cancellation status, which the ACL owns (§8.1). The worker hands the ACL the zaak URL.
var store = new FakeRegistrationStore();
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/timed-out");
var registration = Submitted(zaakUrl: zaak);
store.Seed(registration);
var acl = new FakeAclClient();
await new ExpireRegistrationWorker(store, acl).HandleAsync(
new RegistratieVerlopenJob("job-7", registration.Id));
Assert.Equal(1, acl.CancelCallCount);
Assert.Equal(zaak, acl.CancelledZaakUrl);
}
[Fact]
public async Task An_already_verlopen_registration_is_not_persisted_again_and_the_zaak_is_not_recancelled()
{
// A redelivered job (§8.6) finds the aggregate already VERLOPEN: a no-op, not saved again — and
// the ACL is not asked to cancel the zaak a second time (posting a second resultaat would 400).
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var registration = Submitted(); var registration = Submitted();
registration.Expire(); registration.Expire();
store.Seed(registration); store.Seed(registration);
var acl = new FakeAclClient();
await new ExpireRegistrationWorker(store).HandleAsync( await new ExpireRegistrationWorker(store, acl).HandleAsync(
new RegistratieVerlopenJob("job-7", registration.Id)); new RegistratieVerlopenJob("job-7", registration.Id));
Assert.Equal(0, store.SaveCount); Assert.Equal(0, store.SaveCount);
Assert.Equal(0, acl.CancelCallCount);
Assert.Equal(RegistrationStatus.Verlopen, (await store.GetAsync(registration.Id))!.Status); Assert.Equal(RegistrationStatus.Verlopen, (await store.GetAsync(registration.Id))!.Status);
} }
[Fact] [Fact]
public async Task An_already_resolved_registration_is_left_alone_and_the_job_completes() public async Task An_already_resolved_registration_is_left_alone_and_the_zaak_is_not_cancelled()
{ {
// Race with S-11: the citizen withdrew while parked at WachtOpDocumenten, so the aggregate is // Race with S-11: the citizen withdrew while parked at WachtOpDocumenten, so the aggregate is
// already terminal (INGETROKKEN) when the timer's job arrives. Expiring it would violate the // already terminal (INGETROKKEN) when the timer's job arrives. Expiring it would violate the
// aggregate's invariant; the worker must instead no-op (and let the job complete), not throw // aggregate's invariant; the worker must instead no-op (and let the job complete), not throw
// into a redelivery loop. // into a redelivery loop — and it must not cancel the zaak of a registration it didn't expire.
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var registration = Submitted(); var registration = Submitted();
registration.Withdraw(); registration.Withdraw();
store.Seed(registration); store.Seed(registration);
var acl = new FakeAclClient();
await new ExpireRegistrationWorker(store).HandleAsync( await new ExpireRegistrationWorker(store, acl).HandleAsync(
new RegistratieVerlopenJob("job-7", registration.Id)); new RegistratieVerlopenJob("job-7", registration.Id));
Assert.Equal(0, store.SaveCount); Assert.Equal(0, store.SaveCount);
Assert.Equal(0, acl.CancelCallCount);
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status); Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
} }
@@ -73,12 +99,12 @@ public class ExpireRegistrationWorkerTests
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
await Assert.ThrowsAsync<InvalidOperationException>(() => await Assert.ThrowsAsync<InvalidOperationException>(() =>
new ExpireRegistrationWorker(store).HandleAsync( new ExpireRegistrationWorker(store, new FakeAclClient()).HandleAsync(
new RegistratieVerlopenJob("job-7", RegistrationId.New()))); new RegistratieVerlopenJob("job-7", RegistrationId.New())));
} }
[Fact] [Fact]
public async Task Rejects_a_null_job() public async Task Rejects_a_null_job()
=> await Assert.ThrowsAsync<ArgumentNullException>(() => => await Assert.ThrowsAsync<ArgumentNullException>(() =>
new ExpireRegistrationWorker(new FakeRegistrationStore()).HandleAsync(null!)); new ExpireRegistrationWorker(new FakeRegistrationStore(), new FakeAclClient()).HandleAsync(null!));
} }
+10
View File
@@ -119,4 +119,14 @@ internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
StoredDiploma = (zaakUrl, content, fileName, contentType); StoredDiploma = (zaakUrl, content, fileName, contentType);
return Task.FromResult(DefaultDocumentUrl); return Task.FromResult(DefaultDocumentUrl);
} }
public Uri? CancelledZaakUrl { get; private set; }
public int CancelCallCount { get; private set; }
public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
CancelCallCount++;
CancelledZaakUrl = zaakUrl;
return Task.CompletedTask;
}
} }
@@ -30,7 +30,7 @@ public class RegistratieVerlopenProcessorTests
} }
} }
private static ExpireRegistrationWorker Worker(FakeRegistrationStore store) => new(store); private static ExpireRegistrationWorker Worker(FakeRegistrationStore store) => new(store, new FakeAclClient());
[Fact] [Fact]
public async Task Acquires_a_job_expires_the_registration_and_completes_the_job() public async Task Acquires_a_job_expires_the_registration_and_completes_the_job()
@@ -60,6 +60,14 @@ public sealed class InMemoryAclClient : IAclClient
return Task.CompletedTask; return Task.CompletedTask;
} }
public Uri? CancelledZaakUrl { get; private set; }
public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
CancelledZaakUrl = zaakUrl;
return Task.CompletedTask;
}
public (Uri ZaakUrl, string FileName)? StoredDiploma { get; private set; } public (Uri ZaakUrl, string FileName)? StoredDiploma { get; private set; }
public Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default) public Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)