From 0b31f8db49043776ecd0249c833874edd82f4334 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 17 Jul 2026 09:00:52 +0200 Subject: [PATCH] test(workflow): beoordeling escalation reassigns to teamlead (S-14, refs #15) Failing Workflow Client tests for the 14-day escalation capability (ADR proposal #98): acquire BeoordelingEscaleren external-worker jobs, reassign the still-open Beoordelen task's candidate group behandelaar -> teamlead, and complete the escalation job. Methods are stubbed to throw so the suite compiles and fails on the new behaviour only. Co-Authored-By: Claude Opus 4.8 (1M context) --- services/domain/Big.Application/Ports.cs | 7 ++ .../FlowableWorkflowClient.cs | 9 ++ .../Big.Tests/FlowableWorkflowClientTests.cs | 104 ++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index 4a26dd5..8e74b72 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -86,3 +86,10 @@ public interface IRegistrationStore /// it) and the registration id it carries as a process variable. /// public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId); + +/// +/// An acquired BeoordelingEscaleren escalation job (S-14): the Flowable job id and the process +/// instance whose still-open Beoordelen task must be reassigned from behandelaar to teamlead +/// once the 14-day boundary timer fires (ADR-0015). +/// +public sealed record EscalatieJob(string JobId, string ProcessInstanceId); diff --git a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs index 6a723c8..5f886d4 100644 --- a/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs +++ b/services/domain/Big.Infrastructure/FlowableWorkflowClient.cs @@ -106,6 +106,15 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti response.EnsureSuccessStatusCode(); } + public Task> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default) + => throw new NotImplementedException(); + + public Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default) + => throw new NotImplementedException(); + private async Task GetAsync(string path, CancellationToken ct) { var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, path)); diff --git a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs index aff3bf3..d852e11 100644 --- a/services/domain/Big.Tests/FlowableWorkflowClientTests.cs +++ b/services/domain/Big.Tests/FlowableWorkflowClientTests.cs @@ -306,4 +306,108 @@ public class FlowableWorkflowClientTests await Assert.ThrowsAsync( () => client.CompleteBeoordelingAsync("task-1", BeoordelingsBesluit.Goedkeuren)); } + + // ── S-14 (#15): 14-day beoordeling escalation → reassign to teamlead (ADR-0015) ─────────────── + // The BPMN parks a parallel escalation token on a non-interrupting P14D boundary timer, surfaced as + // an external-worker job on the BeoordelingEscaleren topic. The worker reassigns the still-open + // Beoordelen task from the behandelaar group to teamlead, then completes the escalation job. + + [Fact] + public async Task Acquire_escalation_jobs_posts_the_escalation_topic_and_parses_the_process_instance() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, + """[{"id":"job-9","processInstanceId":"pi-1"}]""")); + + var jobs = await client.AcquireBeoordelingEscalatieJobsAsync(3); + + var job = Assert.Single(jobs); + Assert.Equal("job-9", job.JobId); + Assert.Equal("pi-1", job.ProcessInstanceId); + Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs", + capture.Seen!.RequestUri!.ToString()); + Assert.Contains("\"topic\":\"BeoordelingEscaleren\"", capture.Body); + Assert.Contains("\"numberOfTasks\":3", capture.Body); + Assert.Contains("\"workerId\":\"worker-x\"", capture.Body); + } + + [Theory] + [InlineData("[]")] + [InlineData("null")] + public async Task Acquire_escalation_jobs_returns_empty_when_none_are_parked(string body) + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.OK, body)); + + Assert.Empty(await client.AcquireBeoordelingEscalatieJobsAsync(1)); + Assert.NotNull(capture.Seen); + } + + [Fact] + public async Task Reassign_moves_the_open_beoordelen_task_from_behandelaar_to_teamlead() + { + var requests = new List<(HttpMethod Method, string Url, string? Body)>(); + var client = Client(new StubHandler(async req => + { + requests.Add((req.Method, req.RequestUri!.ToString(), + req.Content is null ? null : await req.Content.ReadAsStringAsync())); + // The task query returns the still-open Beoordelen task parked in this instance. + return req.RequestUri!.AbsoluteUri.EndsWith("service/query/tasks") + ? new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"data":[{"id":"task-7"}],"total":1}""", + Encoding.UTF8, "application/json"), + } + : new HttpResponseMessage(HttpStatusCode.OK); + })); + + await client.ReassignBeoordelingToTeamleadAsync("pi-1"); + + // 1. Find the still-open Beoordelen task in this process instance. + var query = requests.Single(r => r.Url.EndsWith("service/query/tasks")); + Assert.Equal(HttpMethod.Post, query.Method); + Assert.Contains("\"processInstanceId\":\"pi-1\"", query.Body); + Assert.Contains("\"taskDefinitionKey\":\"Beoordelen\"", query.Body); + // 2. Add teamlead as a candidate group on that task. + var add = requests.Single(r => r.Method == HttpMethod.Post + && r.Url.EndsWith("service/runtime/tasks/task-7/identitylinks")); + Assert.Contains("\"group\":\"teamlead\"", add.Body); + Assert.Contains("\"type\":\"candidate\"", add.Body); + // 3. Remove behandelaar as a candidate group — the task now belongs to teamlead. + Assert.Contains(requests, r => r.Method == HttpMethod.Delete + && r.Url.EndsWith("service/runtime/tasks/task-7/identitylinks/groups/behandelaar/candidate")); + } + + [Fact] + public async Task Reassign_is_a_no_op_when_the_beoordelen_task_is_no_longer_open() + { + // The behandelaar completed it just before the timer fired: nothing to reassign, no throw. + var methods = new List(); + var client = Client(new StubHandler(req => + { + methods.Add(req.Method); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"data":[],"total":0}""", Encoding.UTF8, "application/json"), + }); + })); + + await client.ReassignBeoordelingToTeamleadAsync("pi-1"); + + Assert.DoesNotContain(HttpMethod.Delete, methods); + } + + [Fact] + public async Task Complete_escalation_job_posts_to_the_job_complete_endpoint() + { + var capture = new RequestCapture(); + var client = Client(capture.Responds(HttpStatusCode.NoContent)); + + await client.CompleteBeoordelingEscalatieJobAsync("job-9"); + + Assert.Equal(HttpMethod.Post, capture.Seen!.Method); + Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs/job-9/complete", + capture.Seen.RequestUri!.ToString()); + Assert.Contains("\"workerId\":\"worker-x\"", capture.Body); + } }