diff --git a/register-referentie.slnx b/register-referentie.slnx index 072c997..960a92e 100644 --- a/register-referentie.slnx +++ b/register-referentie.slnx @@ -10,6 +10,7 @@ + diff --git a/services/domain/Big.Infrastructure/AclHttpClient.cs b/services/domain/Big.Infrastructure/AclHttpClient.cs new file mode 100644 index 0000000..224cf41 --- /dev/null +++ b/services/domain/Big.Infrastructure/AclHttpClient.cs @@ -0,0 +1,17 @@ +using Big.Application; + +namespace Big.Infrastructure; + +/// +/// HTTP client to the ACL service — the boundary the domain crosses to open a zaak (§8.1). It POSTs +/// the bsn to the ACL's /zaken endpoint and returns the created zaak URL; it never constructs +/// ZGW URLs or talks to OpenZaak itself. +/// +public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclClient +{ + public Task OpenZaakAsync(string bsn, CancellationToken ct = default) + { + // STUB (red). + return Task.FromResult(new Uri("about:blank")); + } +} diff --git a/services/domain/Big.Infrastructure/Big.Infrastructure.csproj b/services/domain/Big.Infrastructure/Big.Infrastructure.csproj new file mode 100644 index 0000000..6a23067 --- /dev/null +++ b/services/domain/Big.Infrastructure/Big.Infrastructure.csproj @@ -0,0 +1,24 @@ + + + + + net10.0 + enable + enable + + + + + + + + + + + + + + diff --git a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs new file mode 100644 index 0000000..016a349 --- /dev/null +++ b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs @@ -0,0 +1,31 @@ +using Big.Application; +using Big.Domain; + +namespace Big.Infrastructure; + +/// +/// The Workflow Client — the only code that talks to Flowable (§8.2). It starts registratie process +/// instances and drives the OpenZaakAanmaken external-worker jobs over Flowable's REST API +/// (start: service/runtime/process-instances; acquire/complete: external-job-api/…). +/// +public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options) + : IWorkflowClient, IExternalWorkerClient +{ + public Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) + { + // STUB (red). + return Task.FromResult(""); + } + + public Task> AcquireOpenZaakJobsAsync(int maxJobs, CancellationToken ct = default) + { + // STUB (red). + return Task.FromResult>([]); + } + + public Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default) + { + // STUB (red). + return Task.CompletedTask; + } +} diff --git a/services/domain/Big.Infrastructure/IExternalWorkerClient.cs b/services/domain/Big.Infrastructure/IExternalWorkerClient.cs new file mode 100644 index 0000000..c8fa2f6 --- /dev/null +++ b/services/domain/Big.Infrastructure/IExternalWorkerClient.cs @@ -0,0 +1,18 @@ +using Big.Application; + +namespace Big.Infrastructure; + +/// +/// The worker-facing side of the Workflow Client: acquiring and completing Flowable external-worker +/// jobs (ADR-0009). Kept separate from the Application's because job +/// acquisition is a Flowable-specific polling mechanic the application never needs to know about. +/// Implemented by — the only code that talks to Flowable (§8.2). +/// +public interface IExternalWorkerClient +{ + /// Acquire and lock up to OpenZaakAanmaken jobs. + Task> AcquireOpenZaakJobsAsync(int maxJobs, CancellationToken ct = default); + + /// Complete an acquired job, passing the opened zaak URL back into the process. + Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default); +} diff --git a/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs b/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs new file mode 100644 index 0000000..9e83fa9 --- /dev/null +++ b/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs @@ -0,0 +1,27 @@ +using System.Collections.Concurrent; +using Big.Application; +using Big.Domain; + +namespace Big.Infrastructure; + +/// +/// In-memory for the minimal slice (ADR-0009). The walking +/// skeleton's read path is the projection (S-06), not this store, so durable domain persistence is a +/// documented follow-up. Registered as a singleton so the submit endpoint and the worker share it. +/// +public sealed class InMemoryRegistrationStore : IRegistrationStore +{ + private readonly ConcurrentDictionary _byId = new(); + + public Task SaveAsync(Registration registration, CancellationToken ct = default) + { + // STUB (red). + return Task.CompletedTask; + } + + public Task GetAsync(RegistrationId id, CancellationToken ct = default) + { + // STUB (red). + return Task.FromResult(null); + } +} diff --git a/services/domain/Big.Infrastructure/OpenZaakJobProcessor.cs b/services/domain/Big.Infrastructure/OpenZaakJobProcessor.cs new file mode 100644 index 0000000..32acf16 --- /dev/null +++ b/services/domain/Big.Infrastructure/OpenZaakJobProcessor.cs @@ -0,0 +1,26 @@ +using Big.Application; +using Microsoft.Extensions.Logging; + +namespace Big.Infrastructure; + +/// +/// One poll tick of the external-task worker (ADR-0009): acquire the parked OpenZaakAanmaken +/// jobs, hand each to the to open a zaak via the ACL, and complete the +/// Flowable job with the resulting zaak URL. A job that fails is logged and left un-completed so +/// Flowable redelivers it (§8.6). Split out from the hosted so the +/// acquire→process→complete logic is unit-testable without a running host. +/// +public sealed class OpenZaakJobProcessor( + IExternalWorkerClient client, + OpenZaakWorker worker, + ILogger logger) +{ + /// Process up to jobs. Returns the number completed. + public async Task PumpOnceAsync(int maxJobs, CancellationToken ct = default) + { + // STUB (red). + await Task.CompletedTask; + _ = logger; + return 0; + } +} diff --git a/services/domain/Big.Infrastructure/Options.cs b/services/domain/Big.Infrastructure/Options.cs new file mode 100644 index 0000000..ccbe7b1 --- /dev/null +++ b/services/domain/Big.Infrastructure/Options.cs @@ -0,0 +1,29 @@ +namespace Big.Infrastructure; + +/// Configuration for the Flowable Workflow Client. is the flowable-rest +/// root and must end with a slash (e.g. http://flowable-rest:8080/flowable-rest/) so the +/// service/… and external-job-api/… sub-paths resolve correctly. +public sealed class FlowableOptions +{ + public Uri BaseUrl { get; set; } = null!; + public string Username { get; set; } = ""; + public string Password { get; set; } = ""; + + /// The id this worker locks jobs under, so two workers don't process the same job. + public string WorkerId { get; set; } = "big-domain-worker"; + + /// How long an acquired job stays locked to this worker (ISO-8601 duration). + public string LockDuration { get; set; } = "PT5M"; + + /// How many OpenZaakAanmaken jobs to acquire per poll. + public int MaxJobsPerPoll { get; set; } = 5; + + /// How often the worker polls Flowable for new jobs. + public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(2); +} + +/// Configuration for the ACL HTTP client. is the ACL service root. +public sealed class AclOptions +{ + public Uri BaseUrl { get; set; } = null!; +} diff --git a/services/domain/Big.Tests/AclHttpClientTests.cs b/services/domain/Big.Tests/AclHttpClientTests.cs new file mode 100644 index 0000000..a08f174 --- /dev/null +++ b/services/domain/Big.Tests/AclHttpClientTests.cs @@ -0,0 +1,43 @@ +using System.Net; +using Big.Infrastructure; + +namespace Big.Tests; + +public class AclHttpClientTests +{ + private static AclHttpClient Client(StubHandler handler) => new( + new HttpClient(handler), new AclOptions { BaseUrl = new("http://acl/") }); + + [Fact] + public async Task Opens_a_zaak_by_posting_the_bsn_and_returns_the_zaak_url() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, + """{"zaakUrl":"http://openzaak/zaken/api/v1/zaken/abc"}""")); + + var url = await client.OpenZaakAsync("123456782"); + + Assert.Equal("http://openzaak/zaken/api/v1/zaken/abc", url.ToString()); + Assert.Equal(HttpMethod.Post, capture.Seen!.Method); + Assert.Equal("http://acl/zaken", capture.Seen.RequestUri!.ToString()); + Assert.Contains("\"bsn\":\"123456782\"", capture.Body); + } + + [Fact] + public async Task Throws_when_the_acl_rejects_the_request() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.BadGateway)); + + await Assert.ThrowsAsync(() => client.OpenZaakAsync("123456782")); + } + + [Fact] + public async Task Throws_when_the_acl_returns_an_empty_body() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, "null")); + + await Assert.ThrowsAsync(() => client.OpenZaakAsync("123456782")); + } +} diff --git a/services/domain/Big.Tests/Big.Tests.csproj b/services/domain/Big.Tests/Big.Tests.csproj index 52fba41..930e2f9 100644 --- a/services/domain/Big.Tests/Big.Tests.csproj +++ b/services/domain/Big.Tests/Big.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/services/domain/Big.Tests/Fakes.cs b/services/domain/Big.Tests/Fakes.cs index d32397f..38d1bae 100644 --- a/services/domain/Big.Tests/Fakes.cs +++ b/services/domain/Big.Tests/Fakes.cs @@ -4,8 +4,9 @@ using Big.Domain; namespace Big.Tests; /// An in-memory for the application-layer tests. Upserts -/// keyed on the registration id, mirroring the production in-memory store. -internal sealed class InMemoryRegistrationStore : IRegistrationStore +/// keyed on the registration id, mirroring the production store (kept distinct from the production +/// InMemoryRegistrationStore so tests can seed and inspect save counts). +internal sealed class FakeRegistrationStore : IRegistrationStore { private readonly Dictionary _byId = []; diff --git a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs new file mode 100644 index 0000000..e1b3b23 --- /dev/null +++ b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs @@ -0,0 +1,93 @@ +using System.Net; +using Big.Domain; +using Big.Infrastructure; + +namespace Big.Tests; + +public class FlowableWorkflowClientTests +{ + private static readonly Uri Base = new("http://flowable/flowable-rest/"); + + private static FlowableWorkflowClient Client(StubHandler handler) => new( + new HttpClient(handler), + new FlowableOptions { BaseUrl = Base, Username = "rest-admin", Password = "test", WorkerId = "worker-x" }); + + [Fact] + public async Task Start_posts_the_registration_id_variable_and_returns_the_instance_id() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.Created, """{"id":"pi-1"}""")); + var rid = RegistrationId.New(); + + var pid = await client.StartRegistrationProcessAsync(rid); + + Assert.Equal("pi-1", pid); + Assert.Equal(HttpMethod.Post, capture.Seen!.Method); + Assert.Equal("http://flowable/flowable-rest/service/runtime/process-instances", + capture.Seen.RequestUri!.ToString()); + Assert.Equal("Basic", capture.Seen.Headers.Authorization!.Scheme); + Assert.Contains("\"processDefinitionKey\":\"registratie\"", capture.Body); + Assert.Contains("\"name\":\"registrationId\"", capture.Body); + Assert.Contains($"\"value\":\"{rid}\"", capture.Body); + } + + [Fact] + public async Task Acquire_posts_the_topic_and_parses_jobs_with_their_registration_id() + { + var rid = RegistrationId.New(); + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, + $$"""[{"id":"job-1","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}]""")); + + var jobs = await client.AcquireOpenZaakJobsAsync(3); + + var job = Assert.Single(jobs); + Assert.Equal("job-1", job.JobId); + Assert.Equal(rid, job.RegistrationId); + Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs", + capture.Seen!.RequestUri!.ToString()); + Assert.Contains("\"topic\":\"OpenZaakAanmaken\"", capture.Body); + Assert.Contains("\"numberOfTasks\":3", capture.Body); + Assert.Contains("\"workerId\":\"worker-x\"", capture.Body); + Assert.Contains("\"lockDuration\":\"PT5M\"", capture.Body); + } + + [Fact] + public async Task Acquire_returns_empty_when_flowable_has_no_parked_jobs() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, "[]")); + + var jobs = await client.AcquireOpenZaakJobsAsync(1); + + Assert.NotNull(capture.Seen); + Assert.Empty(jobs); + } + + [Fact] + public async Task Complete_posts_the_zaak_url_variable_to_the_job_complete_endpoint() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.NoContent)); + + await client.CompleteOpenZaakJobAsync("job-1", new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + + Assert.NotNull(capture.Seen); + Assert.Equal(HttpMethod.Post, capture.Seen!.Method); + Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs/job-1/complete", + capture.Seen.RequestUri!.ToString()); + Assert.Contains("\"workerId\":\"worker-x\"", capture.Body); + Assert.Contains("\"name\":\"zaakUrl\"", capture.Body); + Assert.Contains("\"value\":\"http://openzaak/zaken/api/v1/zaken/abc\"", capture.Body); + } + + [Fact] + public async Task Start_throws_when_flowable_rejects_the_request() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.InternalServerError)); + + await Assert.ThrowsAsync( + () => client.StartRegistrationProcessAsync(RegistrationId.New())); + } +} diff --git a/services/domain/Big.Tests/HttpStubs.cs b/services/domain/Big.Tests/HttpStubs.cs new file mode 100644 index 0000000..93b2d22 --- /dev/null +++ b/services/domain/Big.Tests/HttpStubs.cs @@ -0,0 +1,30 @@ +using System.Net; + +namespace Big.Tests; + +/// A test double for that records the last request and +/// returns a scripted response — the same approach the ACL gateway tests use. +internal sealed class StubHandler(Func> onSend) : HttpMessageHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => onSend(request); +} + +/// Captures the request a client sent, including the (buffered) body. +internal sealed class RequestCapture +{ + public HttpRequestMessage? Seen { get; private set; } + public string? Body { get; private set; } + + /// A handler that records the request, then replies with and + /// . + public StubHandler Responds(HttpStatusCode status, string? json = null) => new(async req => + { + Seen = req; + Body = req.Content is null ? null : await req.Content.ReadAsStringAsync(); + var response = new HttpResponseMessage(status); + if (json is not null) + response.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + return response; + }); +} diff --git a/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs b/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs new file mode 100644 index 0000000..8faaae6 --- /dev/null +++ b/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs @@ -0,0 +1,36 @@ +using Big.Domain; +using Big.Infrastructure; + +namespace Big.Tests; + +public class InMemoryRegistrationStoreTests +{ + [Fact] + public async Task Saves_and_reads_back_a_registration_by_id() + { + var store = new InMemoryRegistrationStore(); + var registration = Registration.Submit("123456782"); + + await store.SaveAsync(registration); + + var loaded = await store.GetAsync(registration.Id); + Assert.NotNull(loaded); + Assert.Equal(registration.Id, loaded.Id); + Assert.Null(await store.GetAsync(RegistrationId.New())); + } + + [Fact] + public async Task Saving_the_same_id_upserts() + { + var store = new InMemoryRegistrationStore(); + var registration = Registration.Submit("123456782"); + await store.SaveAsync(registration); + + registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + await store.SaveAsync(registration); + + var loaded = await store.GetAsync(registration.Id); + Assert.NotNull(loaded); + Assert.Equal("http://openzaak/zaken/api/v1/zaken/abc", loaded.ZaakUrl!.ToString()); + } +} diff --git a/services/domain/Big.Tests/OpenZaakJobProcessorTests.cs b/services/domain/Big.Tests/OpenZaakJobProcessorTests.cs new file mode 100644 index 0000000..35a0c22 --- /dev/null +++ b/services/domain/Big.Tests/OpenZaakJobProcessorTests.cs @@ -0,0 +1,77 @@ +using Big.Application; +using Big.Domain; +using Big.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Big.Tests; + +public class OpenZaakJobProcessorTests +{ + /// A fake external-worker client: scripts the jobs to acquire and records completions. + private sealed class FakeExternalWorkerClient(params OpenZaakJob[] jobs) : IExternalWorkerClient + { + public int AcquireCount { get; private set; } + public List<(string JobId, Uri ZaakUrl)> Completed { get; } = []; + + public Task> AcquireOpenZaakJobsAsync(int maxJobs, CancellationToken ct = default) + { + AcquireCount++; + return Task.FromResult>(jobs.Take(maxJobs).ToList()); + } + + public Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default) + { + Completed.Add((jobId, zaakUrl)); + return Task.CompletedTask; + } + } + + private static OpenZaakJobProcessor Processor(IExternalWorkerClient client, IRegistrationStore store, FakeAclClient acl) + => new(client, new OpenZaakWorker(store, acl), NullLogger.Instance); + + [Fact] + public async Task Acquires_a_job_opens_the_zaak_and_completes_it() + { + var registration = Registration.Submit("123456782"); + var store = new FakeRegistrationStore(); + store.Seed(registration); + var client = new FakeExternalWorkerClient(new OpenZaakJob("job-1", registration.Id)); + var acl = new FakeAclClient(); + + var acquired = await Processor(client, store, acl).PumpOnceAsync(5); + + Assert.Equal(1, acquired); + var completed = Assert.Single(client.Completed); + Assert.Equal("job-1", completed.JobId); + Assert.Equal(FakeAclClient.DefaultZaakUrl, completed.ZaakUrl); + Assert.Equal(FakeAclClient.DefaultZaakUrl, (await store.GetAsync(registration.Id))!.ZaakUrl); + } + + [Fact] + public async Task A_failing_job_is_left_uncompleted_for_flowable_to_redeliver() + { + var store = new FakeRegistrationStore(); + var acl = new FakeAclClient(); + // The job correlates to a registration that is not in the store, so the worker throws. + var client = new FakeExternalWorkerClient(new OpenZaakJob("job-1", RegistrationId.New())); + + var acquired = await Processor(client, store, acl).PumpOnceAsync(5); + + Assert.Equal(1, acquired); + Assert.Empty(client.Completed); + } + + [Fact] + public async Task Does_nothing_but_poll_when_there_are_no_jobs() + { + var store = new FakeRegistrationStore(); + var acl = new FakeAclClient(); + var client = new FakeExternalWorkerClient(); + + var acquired = await Processor(client, store, acl).PumpOnceAsync(5); + + Assert.Equal(0, acquired); + Assert.Equal(1, client.AcquireCount); + Assert.Empty(client.Completed); + } +} diff --git a/services/domain/Big.Tests/OpenZaakWorkerTests.cs b/services/domain/Big.Tests/OpenZaakWorkerTests.cs index c00ba7a..2213d59 100644 --- a/services/domain/Big.Tests/OpenZaakWorkerTests.cs +++ b/services/domain/Big.Tests/OpenZaakWorkerTests.cs @@ -16,7 +16,7 @@ public class OpenZaakWorkerTests public async Task Handling_a_job_opens_a_zaak_via_the_acl_and_attaches_it() { var registration = Submitted(); - var store = new InMemoryRegistrationStore(); + var store = new FakeRegistrationStore(); store.Seed(registration); var acl = new FakeAclClient(); var worker = new OpenZaakWorker(store, acl); @@ -32,7 +32,7 @@ public class OpenZaakWorkerTests [Fact] public async Task Handling_a_job_for_an_unknown_registration_throws_and_opens_no_zaak() { - var store = new InMemoryRegistrationStore(); + var store = new FakeRegistrationStore(); var acl = new FakeAclClient(); var worker = new OpenZaakWorker(store, acl); @@ -45,7 +45,7 @@ public class OpenZaakWorkerTests public async Task Handling_a_redelivered_job_is_idempotent_and_does_not_open_a_second_zaak() { var registration = Submitted(); - var store = new InMemoryRegistrationStore(); + var store = new FakeRegistrationStore(); store.Seed(registration); var acl = new FakeAclClient(); var worker = new OpenZaakWorker(store, acl); diff --git a/services/domain/Big.Tests/SubmitRegistrationTests.cs b/services/domain/Big.Tests/SubmitRegistrationTests.cs index b1b15a7..fd3c097 100644 --- a/services/domain/Big.Tests/SubmitRegistrationTests.cs +++ b/services/domain/Big.Tests/SubmitRegistrationTests.cs @@ -8,7 +8,7 @@ public class SubmitRegistrationTests [Fact] public async Task Submitting_persists_an_ingediend_registration_and_starts_the_process() { - var store = new InMemoryRegistrationStore(); + var store = new FakeRegistrationStore(); var workflow = new FakeWorkflowClient(processInstanceId: "proc-42"); var handler = new SubmitRegistration(store, workflow); @@ -28,7 +28,7 @@ public class SubmitRegistrationTests { // The worker correlates the OpenZaakAanmaken job back to the aggregate by id, so the // aggregate must already be persisted when the process starts (ADR-0009). - var store = new InMemoryRegistrationStore(); + var store = new FakeRegistrationStore(); Registration? visibleAtStart = null; var workflow = new FakeWorkflowClient(onStart: id => visibleAtStart = store.GetAsync(id).Result); var handler = new SubmitRegistration(store, workflow); @@ -41,7 +41,7 @@ public class SubmitRegistrationTests [Fact] public async Task Submitting_without_a_bsn_is_rejected_and_starts_no_process() { - var store = new InMemoryRegistrationStore(); + var store = new FakeRegistrationStore(); var workflow = new FakeWorkflowClient(); var handler = new SubmitRegistration(store, workflow); diff --git a/services/domain/Big.slnx b/services/domain/Big.slnx index 78b7664..f60c02a 100644 --- a/services/domain/Big.slnx +++ b/services/domain/Big.slnx @@ -1,5 +1,6 @@ +