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(() => 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( () => 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( () => 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( () => 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(() => 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(() => 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(); 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( () => client.CompleteBeoordelingAsync("task-1", BeoordelingsBesluit.Goedkeuren)); } }