All checks were successful
CI / lint (pull_request) Successful in 1m15s
CI / build (pull_request) Successful in 1m1s
CI / unit (pull_request) Successful in 1m20s
CI / frontend (pull_request) Successful in 2m50s
CI / mutation (pull_request) Successful in 5m22s
CI / verify-stack (pull_request) Successful in 7m18s
verify-stack surfaced a Flowable 500: delivering messageEventReceived to the Beoordelen task's execution is wrong — a message boundary event's subscription lives on its own execution. Correlate instead by the registration's process instance id (recorded at submit): query the execution subscribed to RegistratieIngetrokken and deliver the message there. Withdrawal moves from IUserTaskClient to IWorkflowClient.WithdrawProcessAsync; the handler no longer needs a task lookup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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));
|
|
}
|
|
}
|