All checks were successful
## What & why Second sub-slice of **S-11 · Withdrawal (Flow 3)** (#12). S-11a (#88) made a withdrawal advance the aggregate to INGETROKKEN; this sub-slice **cancels the running Flowable process** so the withdrawn case leaves the behandelaar's werkbak. - **BPMN** (`registratie.bpmn`): an interrupting message boundary event (`RegistratieIngetrokken`) on the `Beoordelen` task, routing to a dedicated "Registratie ingetrokken" end event. - **Workflow Client**: `WithdrawBeoordelingAsync(executionId)` delivers `messageEventReceived` to the task's execution (PUT); `BeoordelingTask` now carries its `executionId`. - **`WithdrawRegistration` handler**: after the domain transition, finds the open `Beoordelen` task for the registration and delivers the withdrawal message — best-effort, mirroring how the beoordeling completes its task. - **Werkbak**: also filters out registrations that are no longer open, so a withdrawn case never surfaces even in the brief window before cancellation lands. - **ADR-0014** records the decision (message event in BPMN vs. deleting the instance from code). - **verify (`run-domain-check.sh`)**: a second registration parks at `Beoordelen`, is withdrawn via the domain, and the check asserts its `Beoordelen` task disappears — so verify-stack validates the live Flowable message correlation. Refs #12 (S-11c — the BFF + self-service "trek aanvraag in" button + e2e — closes it). ## Definition of Done - [x] Linked Gitea issue (#12). - [x] Failing tests committed before the implementation (red → green per commit). - [x] Implementation makes the tests pass. - [x] Conventional Commits referencing the issue (`refs #12`). - [ ] CI green — all Gitea Actions jobs. - [x] `docker compose up` unaffected (BPMN redeploys on a fresh CI DB via flowable-init). - [x] ADR added (ADR-0014). - [x] Docs — the user-visible demo note lands with S-11c. ## Notes for reviewers - Verified locally: `Big.Tests` 94/94 pass; `Big.Api` builds; `registratie.bpmn` is well-formed. - The Flowable message-correlation REST shape is validated **live** by verify-stack (the Workflow Client unit tests stub the exchange and assert only the request shape, per ADR-0009) — the new `run-domain-check.sh` withdrawal step is that live check. - Known gap (ADR-0014): a withdrawal that races ahead of the process reaching `Beoordelen` finds no task to cancel; the aggregate is still INGETROKKEN and the werkbak filter hides it, but that instance parks unattended. A process-level event subprocess would close the gap — deferred. Reviewed-on: #89
310 lines
13 KiB
C#
310 lines
13 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));
|
|
}
|
|
}
|