Compare commits
7 Commits
feat/13-wo
...
feat/13-be
| Author | SHA1 | Date | |
|---|---|---|---|
| 7693a4a85a | |||
| ddebfd371a | |||
| d700659957 | |||
| 63699c3f55 | |||
| 5c05879cde | |||
| 09000d8e64 | |||
| 23125ef492 |
@@ -51,71 +51,18 @@ 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
|
||||
zaak_ok=1
|
||||
break
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
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
|
||||
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
|
||||
|
||||
@@ -20,7 +20,6 @@ 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>();
|
||||
|
||||
@@ -36,30 +36,6 @@ 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.
|
||||
|
||||
@@ -15,14 +15,12 @@ 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, IUserTaskClient
|
||||
: IWorkflowClient, IExternalWorkerClient
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -57,34 +55,6 @@ 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);
|
||||
@@ -119,34 +89,6 @@ 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,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
using Big.Infrastructure;
|
||||
|
||||
@@ -128,17 +127,6 @@ 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()
|
||||
{
|
||||
@@ -148,107 +136,4 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,9 @@
|
||||
xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI"
|
||||
targetNamespace="http://respellion.nl/big">
|
||||
|
||||
<!-- Registratie ontvangen flow.
|
||||
start -> external-worker task (OpenZaakAanmaken) -> Beoordelen (behandelaar user task) -> end.
|
||||
S-03 added the external task (Workflow Client / ACL). S-12 adds the behandelaar's beoordeling
|
||||
as a user task: the process parks here until a behandelaar claims and completes it with a
|
||||
`besluit` (goedkeuren/afwijzen). The registrationId set at start rides along as a process
|
||||
variable so the werkbak can correlate each task back to its aggregate. -->
|
||||
<!-- S-03: minimal "Registratie ontvangen" flow.
|
||||
start -> external-worker task (OpenZaakAanmaken) -> end.
|
||||
The external task is handled by the Workflow Client / ACL in later slices. -->
|
||||
<process id="registratie" name="Registratie ontvangen" isExecutable="true">
|
||||
|
||||
<startEvent id="start" name="Registratie ontvangen"/>
|
||||
@@ -22,13 +19,9 @@
|
||||
flowable:type="external-worker"
|
||||
flowable:topic="OpenZaakAanmaken"/>
|
||||
|
||||
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="Beoordelen"/>
|
||||
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="end"/>
|
||||
|
||||
<userTask id="Beoordelen" name="Beoordelen" flowable:candidateGroups="behandelaar"/>
|
||||
|
||||
<sequenceFlow id="flow3" sourceRef="Beoordelen" targetRef="end"/>
|
||||
|
||||
<endEvent id="end" name="Registratie beoordeeld"/>
|
||||
<endEvent id="end" name="Zaak aangemaakt"/>
|
||||
</process>
|
||||
|
||||
<bpmndi:BPMNDiagram id="diagram">
|
||||
@@ -39,11 +32,8 @@
|
||||
<bpmndi:BPMNShape id="s_task" bpmnElement="OpenZaakAanmaken">
|
||||
<omgdc:Bounds x="200" y="85" width="120" height="60"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_beoordelen" bpmnElement="Beoordelen">
|
||||
<omgdc:Bounds x="390" y="85" width="120" height="60"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_end" bpmnElement="end">
|
||||
<omgdc:Bounds x="580" y="100" width="30" height="30"/>
|
||||
<omgdc:Bounds x="400" y="100" width="30" height="30"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNEdge id="e_flow1" bpmnElement="flow1">
|
||||
<omgdi:waypoint x="130" y="115"/>
|
||||
@@ -51,11 +41,7 @@
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="e_flow2" bpmnElement="flow2">
|
||||
<omgdi:waypoint x="320" y="115"/>
|
||||
<omgdi:waypoint x="390" y="115"/>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="e_flow3" bpmnElement="flow3">
|
||||
<omgdi:waypoint x="510" y="115"/>
|
||||
<omgdi:waypoint x="580" y="115"/>
|
||||
<omgdi:waypoint x="400" y="115"/>
|
||||
</bpmndi:BPMNEdge>
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
|
||||
Reference in New Issue
Block a user