feat(#13): S-12b — Workflow Client user-tasks + Beoordelen userTask (#83)
All checks were successful
CI / lint (push) Successful in 1m23s
CI / build (push) Successful in 1m10s
CI / unit (push) Successful in 1m14s
CI / frontend (push) Successful in 2m23s
CI / mutation (push) Successful in 5m45s
CI / verify-stack (push) Successful in 7m7s

## What & why

Second sub-slice of **S-12 (#13)** — the **Workflow Client gains behandelaar user-task operations**, and the process model gains the beoordeling step.

- **BPMN:** `registratie.bpmn` now parks at a `Beoordelen` **userTask** (candidate group `behandelaar`) after `OpenZaakAanmaken`; `registrationId` rides along as a process variable so the werkbak can correlate each task to its aggregate.
- **Workflow Client** (`IUserTaskClient`, the only code that talks to Flowable §8.2):
  - `GetOpenBeoordelingenAsync()` — the werkbak (open `Beoordelen` tasks + their `registrationId`)
  - `ClaimAsync(taskId, behandelaar)`
  - `CompleteBeoordelingAsync(taskId, besluit)` — carries the decision into the process as the `besluit` variable
- **Live integration:** `verify-domain` now drives the full user-task lifecycle against a real Flowable — after the worker opens the zaak, it polls for the task, claims it as `merel-behandelaar`, completes it (`goedkeuren`), and asserts the process finishes. This proves the exact REST contract (`service/runtime/tasks/query` + `…/{id}` claim/complete) the client depends on.

The walking skeleton is unaffected: the temporary `/approve` path still sets the zaak status directly; wiring the domain decision to *complete this task* (and driving the werkbak from the BFF) lands in **S-12c**.

## Definition of Done

- [x] Linked issue: #13 (umbrella; `refs`, does not close)
- [x] Tests first; red → green
- [x] Unit + acceptance green (`make unit`): domain 76, acceptance 9 (acl/event-subscriber/bff unaffected)
- [x] Mutation ≥ break(90): **domain 100%** (killed the new survivors *and* the pre-existing `FlowableWorkflowClient` baseline)
- [x] Live Flowable user-task lifecycle asserted in `verify-domain`
- [ ] CI green (pending)

Part of #13.

Reviewed-on: #83
This commit was merged in pull request #83.
This commit is contained in:
2026-07-15 08:53:33 +00:00
parent d4ed0ffc22
commit 4085bdead7
6 changed files with 279 additions and 14 deletions

View File

@@ -20,6 +20,7 @@ builder.Services.AddSingleton<IRegistrationStore, InMemoryRegistrationStore>();
builder.Services.AddHttpClient<FlowableWorkflowClient>();
builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IUserTaskClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
builder.Services.AddScoped<SubmitRegistration>();

View File

@@ -36,6 +36,30 @@ public interface IAclClient
Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default);
}
/// <summary>
/// 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
/// <c>Beoordelen</c> tasks (the werkbak), claims one for a behandelaar, and completes it with the
/// decision; it never names Flowable's REST shapes.
/// </summary>
public interface IUserTaskClient
{
/// <summary>The werkbak: the <c>Beoordelen</c> tasks awaiting a behandelaar, each with the
/// registration it belongs to.</summary>
Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default);
/// <summary>Claim a beoordeling task for a behandelaar (assigns it to them).</summary>
Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default);
/// <summary>Complete a beoordeling task, carrying the decision into the process as the
/// <c>besluit</c> variable so the workflow can continue on the chosen branch.</summary>
Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default);
}
/// <summary>A <c>Beoordelen</c> user task in the werkbak: the Flowable task id (needed to claim and
/// complete it) and the registration it carries as a process variable.</summary>
public sealed record BeoordelingTask(string TaskId, RegistrationId RegistrationId);
/// <summary>
/// Persistence port for the <see cref="Registration"/> 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.

View File

@@ -15,12 +15,14 @@ namespace Big.Infrastructure;
/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009).
/// </summary>
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<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
{
@@ -55,6 +57,34 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
response.EnsureSuccessStatusCode();
}
public async Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
{
var request = new TaskQueryRequest(ProcessDefinitionKey, BeoordelenTaskKey, IncludeProcessVariables: true);
var page = await PostAsync<TaskQueryRequest, TaskQueryResult>(
"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<TResponse?> PostAsync<TRequest, TResponse>(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<Variable> 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<Variable> Variables);
private sealed record TaskQueryResult(
[property: JsonPropertyName("data")] IReadOnlyList<UserTaskDto>? 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<Variable>? Variables)
{
/// <summary>The registration id this task carries as a process variable.</summary>
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,

View File

@@ -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<InvalidOperationException>(
() => 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<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 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));
}
}