All checks were successful
## 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
255 lines
10 KiB
C#
255 lines
10 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 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));
|
|
}
|
|
}
|