Files
register-referentie/services/domain/Big.Tests/FlowableWorkflowClientTests.cs
Niek Otten 0b31f8db49 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) <noreply@anthropic.com>
2026-07-17 09:00:52 +02:00

414 lines
18 KiB
C#

using System.Net;
using System.Text;
using Big.Application;
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" });
private static string DecodeBasic(HttpRequestMessage request)
=> Encoding.ASCII.GetString(Convert.FromBase64String(request.Headers.Authorization!.Parameter!));
[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.Equal("rest-admin:test", DecodeBasic(capture.Seen));
Assert.Contains("\"processDefinitionKey\":\"registratie\"", capture.Body);
Assert.Contains("\"name\":\"registrationId\"", capture.Body);
Assert.Contains("\"type\":\"string\"", capture.Body);
Assert.Contains($"\"value\":\"{rid}\"", capture.Body);
}
[Fact]
public async Task Start_uses_the_configured_worker_credentials_and_defaults()
{
var capture = new RequestCapture();
// Only BaseUrl set — the worker id and (empty) credentials must come from the defaults.
var client = new FlowableWorkflowClient(
new HttpClient(capture.Responds(HttpStatusCode.OK, "[]")),
new FlowableOptions { BaseUrl = Base });
await client.AcquireOpenZaakJobsAsync(1);
Assert.Equal(":", DecodeBasic(capture.Seen!));
Assert.Contains("\"workerId\":\"big-domain-worker\"", capture.Body);
Assert.Contains("\"lockDuration\":\"PT5M\"", 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);
}
[Theory]
[InlineData("[]")]
[InlineData("null")]
public async Task Acquire_returns_empty_when_flowable_has_no_parked_jobs(string body)
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK, body));
var jobs = await client.AcquireOpenZaakJobsAsync(1);
Assert.NotNull(capture.Seen);
Assert.Empty(jobs);
}
[Fact]
public async Task Acquire_throws_when_a_job_carries_no_registration_id()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK, """[{"id":"job-1","variables":[]}]"""));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.AcquireOpenZaakJobsAsync(1));
Assert.Contains("job-1", ex.Message);
Assert.Contains("registrationId", ex.Message);
}
[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("\"type\":\"string\"", 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<HttpRequestException>(
() => 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<InvalidOperationException>(
() => client.StartRegistrationProcessAsync(RegistrationId.New()));
Assert.Contains("empty process-instance", ex.Message);
}
[Fact]
public async Task Complete_throws_when_flowable_rejects_the_request()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.InternalServerError));
await Assert.ThrowsAsync<HttpRequestException>(
() => 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<InvalidOperationException>(() => 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<HttpRequestException>(() => 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 Withdraw_process_correlates_the_message_to_the_subscribed_execution()
{
HttpRequestMessage? getReq = null;
HttpRequestMessage? putReq = null;
string? putBody = null;
var client = Client(new StubHandler(async req =>
{
if (req.Method == HttpMethod.Get)
{
getReq = req;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[{"id":"exec-9"}]}""", Encoding.UTF8, "application/json"),
};
}
putReq = req;
putBody = await req.Content!.ReadAsStringAsync();
return new HttpResponseMessage(HttpStatusCode.OK);
}));
await client.WithdrawProcessAsync("pi-1");
// 1. Find the execution subscribed to the withdrawal message for this instance.
Assert.Equal(HttpMethod.Get, getReq!.Method);
Assert.Contains("service/runtime/executions", getReq.RequestUri!.ToString());
Assert.Contains("messageEventSubscriptionName=RegistratieIngetrokken", getReq.RequestUri!.Query);
Assert.Contains("processInstanceId=pi-1", getReq.RequestUri!.Query);
// 2. Deliver messageEventReceived to that execution (PUT), tripping the interrupting event.
Assert.Equal(HttpMethod.Put, putReq!.Method);
Assert.Equal("http://flowable/flowable-rest/service/runtime/executions/exec-9",
putReq.RequestUri!.ToString());
Assert.Contains("\"action\":\"messageEventReceived\"", putBody);
Assert.Contains("\"messageName\":\"RegistratieIngetrokken\"", putBody);
}
[Fact]
public async Task Withdraw_process_is_a_no_op_when_no_execution_is_subscribed()
{
var methods = new List<HttpMethod>();
var client = Client(new StubHandler(req =>
{
methods.Add(req.Method);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[]}""", Encoding.UTF8, "application/json"),
});
}));
await client.WithdrawProcessAsync("pi-1");
// No subscribed execution → no message delivered (best-effort), no throw.
Assert.DoesNotContain(HttpMethod.Put, methods);
}
[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<HttpRequestException>(
() => 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<HttpMethod>();
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);
}
}