diff --git a/infra/run-domain-check.sh b/infra/run-domain-check.sh index 33d7d9e..a701b21 100755 --- a/infra/run-domain-check.sh +++ b/infra/run-domain-check.sh @@ -51,18 +51,71 @@ loc="$(docker run --rm --network "$net" curlimages/curl:latest \ echo ">> registration accepted at $loc" echo ">> polling the domain until the worker records the opened zaak" +zaak_ok="" for _ in $(seq 1 30); do body="$(docker run --rm --network "$net" curlimages/curl:latest \ -fsS "http://$dom_ip:8080$loc" 2>/dev/null || true)" if echo "$body" | grep -q '/zaken/api/v1/zaken/'; then echo "OK — the domain opened a zaak and recorded it on the registration:" echo "$body" | cut -c1-300 - exit 0 + zaak_ok=1 + break fi sleep 2 done -echo "FAIL — the registration never received a zaak URL" >&2 -echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2 -acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)" -[ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; } -exit 1 +if [ -z "$zaak_ok" ]; then + echo "FAIL — the registration never received a zaak URL" >&2 + echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2 + acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)" + [ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; } + exit 1 +fi + +# ── S-12b: the process now parks at the Beoordelen user task. Exercise the exact Flowable REST +# contract the Workflow Client uses (query/claim/complete) against the live engine, reaching +# flowable-rest by container IP (same in-network constraint as above). ────────────────────────── +fl="$(docker ps -q --filter 'name=flowable-rest' | head -1)" +[ -n "$fl" ] || { echo "ERROR: no running flowable-rest container" >&2; exit 1; } +fl_base="http://$(ip "$fl"):8080/flowable-rest/service" +reg_id="${loc##*/}" + +# Extracts the Beoordelen task id for our registration from a Flowable task-query response on stdin. +# Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing. +task_for_reg() { REG_ID="$reg_id" python3 -c "import os,sys,json +try: + d=json.load(sys.stdin) +except Exception: + d={} +rid=os.environ['REG_ID'] +# Flowable's task-query returns the included process variables under 'variables'. +print(next((t['id'] for t in (d.get('data') or []) + if any(v.get('name')=='registrationId' and v.get('value')==rid for v in (t.get('variables') or []))), ''))"; } + +flcurl() { docker run --rm --network "$net" curlimages/curl:latest -fsS -u rest-admin:test "$@"; } +query='{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen","includeProcessVariables":true}' + +echo ">> polling Flowable for the Beoordelen user task (werkbak)" +task_id="" +for _ in $(seq 1 30); do + resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)" + task_id="$(printf '%s' "$resp" | task_for_reg)" + [ -n "$task_id" ] && break + sleep 2 +done +[ -n "$task_id" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; } +echo ">> Beoordelen task $task_id is waiting" + +echo ">> claiming the task as merel-behandelaar" +flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/json' \ + -d '{"action":"claim","assignee":"merel-behandelaar"}' >/dev/null + +echo ">> completing the beoordeling (goedkeuren)" +flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/json' \ + -d '{"action":"complete","variables":[{"name":"besluit","type":"string","value":"goedkeuren"}]}' >/dev/null + +echo ">> asserting the process finished (no Beoordelen task remains for the registration)" +resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")" +still="$(printf '%s' "$resp" | task_for_reg)" +[ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; } +echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished" +exit 0 diff --git a/services/domain/Big.Api/Program.cs b/services/domain/Big.Api/Program.cs index eb26a17..d24d5c6 100644 --- a/services/domain/Big.Api/Program.cs +++ b/services/domain/Big.Api/Program.cs @@ -20,6 +20,7 @@ builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddTransient(sp => sp.GetRequiredService()); builder.Services.AddTransient(sp => sp.GetRequiredService()); +builder.Services.AddTransient(sp => sp.GetRequiredService()); builder.Services.AddHttpClient(); builder.Services.AddScoped(); diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index 12350c6..0859d7c 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -36,6 +36,30 @@ public interface IAclClient Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default); } +/// +/// The port to the behandelaar's user tasks in the workflow engine (S-12). Implemented by the +/// Workflow Client — the only code that talks to Flowable (§8.2). The application lists the open +/// Beoordelen tasks (the werkbak), claims one for a behandelaar, and completes it with the +/// decision; it never names Flowable's REST shapes. +/// +public interface IUserTaskClient +{ + /// The werkbak: the Beoordelen tasks awaiting a behandelaar, each with the + /// registration it belongs to. + Task> GetOpenBeoordelingenAsync(CancellationToken ct = default); + + /// Claim a beoordeling task for a behandelaar (assigns it to them). + Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default); + + /// Complete a beoordeling task, carrying the decision into the process as the + /// besluit variable so the workflow can continue on the chosen branch. + Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default); +} + +/// A Beoordelen user task in the werkbak: the Flowable task id (needed to claim and +/// complete it) and the registration it carries as a process variable. +public sealed record BeoordelingTask(string TaskId, RegistrationId RegistrationId); + /// /// Persistence port for the aggregate. In-memory for the minimal slice /// (ADR-0009); an EF-backed store is a documented follow-up, and this port keeps that change additive. diff --git a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs index 0b6ba23..f782cea 100644 --- a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs +++ b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs @@ -15,12 +15,14 @@ namespace Big.Infrastructure; /// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009). /// public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options) - : IWorkflowClient, IExternalWorkerClient + : IWorkflowClient, IExternalWorkerClient, IUserTaskClient { private const string Topic = "OpenZaakAanmaken"; private const string ProcessDefinitionKey = "registratie"; + private const string BeoordelenTaskKey = "Beoordelen"; private const string RegistrationIdVariable = "registrationId"; private const string ZaakUrlVariable = "zaakUrl"; + private const string BesluitVariable = "besluit"; public async Task StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) { @@ -55,6 +57,34 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti response.EnsureSuccessStatusCode(); } + public async Task> GetOpenBeoordelingenAsync(CancellationToken ct = default) + { + var request = new TaskQueryRequest(ProcessDefinitionKey, BeoordelenTaskKey, IncludeProcessVariables: true); + + var page = await PostAsync( + "service/query/tasks", request, ct); + + var tasks = page?.Data ?? []; + return [.. tasks.Select(t => new BeoordelingTask(t.Id, RegistrationId.Parse(t.RegistrationId())))]; + } + + public async Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default) + { + using var response = await SendAsync( + $"service/runtime/tasks/{taskId}", new ClaimTaskRequest("claim", behandelaar), ct); + response.EnsureSuccessStatusCode(); + } + + public async Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default) + { + var request = new CompleteTaskRequest( + "complete", + [new Variable(BesluitVariable, "string", besluit.ToString().ToLowerInvariant())]); + + using var response = await SendAsync($"service/runtime/tasks/{taskId}", request, ct); + response.EnsureSuccessStatusCode(); + } + private async Task PostAsync(string path, TRequest body, CancellationToken ct) { using var response = await SendAsync(path, body, ct); @@ -89,6 +119,34 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti [property: JsonPropertyName("workerId")] string WorkerId, [property: JsonPropertyName("variables")] IReadOnlyList Variables); + private sealed record TaskQueryRequest( + [property: JsonPropertyName("processDefinitionKey")] string ProcessDefinitionKey, + [property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey, + [property: JsonPropertyName("includeProcessVariables")] bool IncludeProcessVariables); + + private sealed record ClaimTaskRequest( + [property: JsonPropertyName("action")] string Action, + [property: JsonPropertyName("assignee")] string Assignee); + + private sealed record CompleteTaskRequest( + [property: JsonPropertyName("action")] string Action, + [property: JsonPropertyName("variables")] IReadOnlyList Variables); + + private sealed record TaskQueryResult( + [property: JsonPropertyName("data")] IReadOnlyList? Data); + + private sealed record UserTaskDto( + [property: JsonPropertyName("id")] string Id, + // Flowable's task-query returns the (included) process variables under "variables", not + // "processVariables"; the request opts in via includeProcessVariables. + [property: JsonPropertyName("variables")] IReadOnlyList? Variables) + { + /// The registration id this task carries as a process variable. + public string RegistrationId() => + Variables?.SingleOrDefault(v => v.Name == "registrationId")?.Value + ?? throw new InvalidOperationException($"Beoordelen task {Id} carries no registrationId variable."); + } + private sealed record Variable( [property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("type")] string Type, diff --git a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs index 625d69f..f1bf579 100644 --- a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs +++ b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Text; +using Big.Application; using Big.Domain; using Big.Infrastructure; @@ -127,6 +128,17 @@ public class FlowableWorkflowClientTests () => client.StartRegistrationProcessAsync(RegistrationId.New())); } + [Fact] + public async Task Start_throws_when_flowable_returns_an_empty_body() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.Created, "null")); + + var ex = await Assert.ThrowsAsync( + () => client.StartRegistrationProcessAsync(RegistrationId.New())); + Assert.Contains("empty process-instance", ex.Message); + } + [Fact] public async Task Complete_throws_when_flowable_rejects_the_request() { @@ -136,4 +148,107 @@ public class FlowableWorkflowClientTests await Assert.ThrowsAsync( () => client.CompleteOpenZaakJobAsync("job-1", new Uri("http://openzaak/zaken/api/v1/zaken/abc"))); } + + // ── S-12b: behandelaar user tasks (werkbak / claim / complete) ──────────────────────────────── + + [Fact] + public async Task Open_beoordelingen_queries_beoordelen_tasks_with_their_registration_id() + { + var rid = RegistrationId.New(); + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, + $$""" + {"data":[{"id":"task-1","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}],"total":1} + """)); + + var tasks = await client.GetOpenBeoordelingenAsync(); + + var task = Assert.Single(tasks); + Assert.Equal("task-1", task.TaskId); + Assert.Equal(rid, task.RegistrationId); + Assert.Equal(HttpMethod.Post, capture.Seen!.Method); + Assert.Equal("http://flowable/flowable-rest/service/query/tasks", + capture.Seen.RequestUri!.ToString()); + Assert.Equal("rest-admin:test", DecodeBasic(capture.Seen)); + Assert.Contains("\"processDefinitionKey\":\"registratie\"", capture.Body); + Assert.Contains("\"taskDefinitionKey\":\"Beoordelen\"", capture.Body); + Assert.Contains("\"includeProcessVariables\":true", capture.Body); + } + + [Theory] + [InlineData("""{"data":[],"total":0}""")] + [InlineData("""{"data":null,"total":0}""")] + public async Task Open_beoordelingen_is_empty_when_no_tasks_are_waiting(string body) + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, body)); + + Assert.Empty(await client.GetOpenBeoordelingenAsync()); + Assert.NotNull(capture.Seen); + } + + [Fact] + public async Task Open_beoordelingen_throws_when_a_task_carries_no_registration_id() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, + """{"data":[{"id":"task-1","variables":[]}],"total":1}""")); + + var ex = await Assert.ThrowsAsync(() => client.GetOpenBeoordelingenAsync()); + Assert.Contains("task-1", ex.Message); + Assert.Contains("registrationId", ex.Message); + } + + [Fact] + public async Task Claim_assigns_the_task_to_the_behandelaar() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK)); + + await client.ClaimAsync("task-1", "merel-behandelaar"); + + Assert.Equal(HttpMethod.Post, capture.Seen!.Method); + Assert.Equal("http://flowable/flowable-rest/service/runtime/tasks/task-1", + capture.Seen.RequestUri!.ToString()); + Assert.Contains("\"action\":\"claim\"", capture.Body); + Assert.Contains("\"assignee\":\"merel-behandelaar\"", capture.Body); + } + + [Fact] + public async Task Claim_throws_when_flowable_rejects_the_request() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.Conflict)); + + await Assert.ThrowsAsync(() => client.ClaimAsync("task-1", "merel-behandelaar")); + } + + [Theory] + [InlineData(BeoordelingsBesluit.Goedkeuren, "goedkeuren")] + [InlineData(BeoordelingsBesluit.Afwijzen, "afwijzen")] + public async Task Complete_posts_the_besluit_variable_to_the_task(BeoordelingsBesluit besluit, string expected) + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK)); + + await client.CompleteBeoordelingAsync("task-1", besluit); + + Assert.Equal(HttpMethod.Post, capture.Seen!.Method); + Assert.Equal("http://flowable/flowable-rest/service/runtime/tasks/task-1", + capture.Seen.RequestUri!.ToString()); + Assert.Contains("\"action\":\"complete\"", capture.Body); + Assert.Contains("\"name\":\"besluit\"", capture.Body); + Assert.Contains("\"type\":\"string\"", capture.Body); + Assert.Contains($"\"value\":\"{expected}\"", capture.Body); + } + + [Fact] + public async Task Complete_beoordeling_throws_when_flowable_rejects_the_request() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.InternalServerError)); + + await Assert.ThrowsAsync( + () => client.CompleteBeoordelingAsync("task-1", BeoordelingsBesluit.Goedkeuren)); + } } diff --git a/workflows/registratie.bpmn b/workflows/registratie.bpmn index e49e22a..51f7de9 100644 --- a/workflows/registratie.bpmn +++ b/workflows/registratie.bpmn @@ -6,9 +6,12 @@ xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI" targetNamespace="http://respellion.nl/big"> - + @@ -19,9 +22,13 @@ flowable:type="external-worker" flowable:topic="OpenZaakAanmaken"/> - + - + + + + + @@ -32,8 +39,11 @@ + + + - + @@ -41,7 +51,11 @@ - + + + + +