feat(workflow): beoordeling escalation to teamlead after 14 days (S-14, closes #15) #99
@@ -86,3 +86,10 @@ public interface IRegistrationStore
|
|||||||
/// it) and the registration id it carries as a process variable.
|
/// it) and the registration id it carries as a process variable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId);
|
public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An acquired <c>BeoordelingEscaleren</c> escalation job (S-14): the Flowable job id and the process
|
||||||
|
/// instance whose still-open <c>Beoordelen</c> task must be reassigned from behandelaar to teamlead
|
||||||
|
/// once the 14-day boundary timer fires (ADR-0015).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record EscalatieJob(string JobId, string ProcessInstanceId);
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
|||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
|
||||||
|
=> throw new NotImplementedException();
|
||||||
|
|
||||||
|
public Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
|
||||||
|
=> throw new NotImplementedException();
|
||||||
|
|
||||||
|
public Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
|
||||||
|
=> throw new NotImplementedException();
|
||||||
|
|
||||||
private async Task<TResponse?> GetAsync<TResponse>(string path, CancellationToken ct)
|
private async Task<TResponse?> GetAsync<TResponse>(string path, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, path));
|
var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, path));
|
||||||
|
|||||||
@@ -306,4 +306,108 @@ public class FlowableWorkflowClientTests
|
|||||||
await Assert.ThrowsAsync<HttpRequestException>(
|
await Assert.ThrowsAsync<HttpRequestException>(
|
||||||
() => client.CompleteBeoordelingAsync("task-1", BeoordelingsBesluit.Goedkeuren));
|
() => client.CompleteBeoordelingAsync("task-1", BeoordelingsBesluit.Goedkeuren));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── S-14 (#15): 14-day beoordeling escalation → reassign to teamlead (ADR-0015) ───────────────
|
||||||
|
// The BPMN parks a parallel escalation token on a non-interrupting P14D boundary timer, surfaced as
|
||||||
|
// an external-worker job on the BeoordelingEscaleren topic. The worker reassigns the still-open
|
||||||
|
// Beoordelen task from the behandelaar group to teamlead, then completes the escalation job.
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Acquire_escalation_jobs_posts_the_escalation_topic_and_parses_the_process_instance()
|
||||||
|
{
|
||||||
|
var capture = new RequestCapture();
|
||||||
|
var client = Client(capture.Responds(HttpStatusCode.OK,
|
||||||
|
"""[{"id":"job-9","processInstanceId":"pi-1"}]"""));
|
||||||
|
|
||||||
|
var jobs = await client.AcquireBeoordelingEscalatieJobsAsync(3);
|
||||||
|
|
||||||
|
var job = Assert.Single(jobs);
|
||||||
|
Assert.Equal("job-9", job.JobId);
|
||||||
|
Assert.Equal("pi-1", job.ProcessInstanceId);
|
||||||
|
Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs",
|
||||||
|
capture.Seen!.RequestUri!.ToString());
|
||||||
|
Assert.Contains("\"topic\":\"BeoordelingEscaleren\"", capture.Body);
|
||||||
|
Assert.Contains("\"numberOfTasks\":3", capture.Body);
|
||||||
|
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("[]")]
|
||||||
|
[InlineData("null")]
|
||||||
|
public async Task Acquire_escalation_jobs_returns_empty_when_none_are_parked(string body)
|
||||||
|
{
|
||||||
|
var capture = new RequestCapture();
|
||||||
|
var client = Client(capture.Responds(HttpStatusCode.OK, body));
|
||||||
|
|
||||||
|
Assert.Empty(await client.AcquireBeoordelingEscalatieJobsAsync(1));
|
||||||
|
Assert.NotNull(capture.Seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reassign_moves_the_open_beoordelen_task_from_behandelaar_to_teamlead()
|
||||||
|
{
|
||||||
|
var requests = new List<(HttpMethod Method, string Url, string? Body)>();
|
||||||
|
var client = Client(new StubHandler(async req =>
|
||||||
|
{
|
||||||
|
requests.Add((req.Method, req.RequestUri!.ToString(),
|
||||||
|
req.Content is null ? null : await req.Content.ReadAsStringAsync()));
|
||||||
|
// The task query returns the still-open Beoordelen task parked in this instance.
|
||||||
|
return req.RequestUri!.AbsoluteUri.EndsWith("service/query/tasks")
|
||||||
|
? new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("""{"data":[{"id":"task-7"}],"total":1}""",
|
||||||
|
Encoding.UTF8, "application/json"),
|
||||||
|
}
|
||||||
|
: new HttpResponseMessage(HttpStatusCode.OK);
|
||||||
|
}));
|
||||||
|
|
||||||
|
await client.ReassignBeoordelingToTeamleadAsync("pi-1");
|
||||||
|
|
||||||
|
// 1. Find the still-open Beoordelen task in this process instance.
|
||||||
|
var query = requests.Single(r => r.Url.EndsWith("service/query/tasks"));
|
||||||
|
Assert.Equal(HttpMethod.Post, query.Method);
|
||||||
|
Assert.Contains("\"processInstanceId\":\"pi-1\"", query.Body);
|
||||||
|
Assert.Contains("\"taskDefinitionKey\":\"Beoordelen\"", query.Body);
|
||||||
|
// 2. Add teamlead as a candidate group on that task.
|
||||||
|
var add = requests.Single(r => r.Method == HttpMethod.Post
|
||||||
|
&& r.Url.EndsWith("service/runtime/tasks/task-7/identitylinks"));
|
||||||
|
Assert.Contains("\"group\":\"teamlead\"", add.Body);
|
||||||
|
Assert.Contains("\"type\":\"candidate\"", add.Body);
|
||||||
|
// 3. Remove behandelaar as a candidate group — the task now belongs to teamlead.
|
||||||
|
Assert.Contains(requests, r => r.Method == HttpMethod.Delete
|
||||||
|
&& r.Url.EndsWith("service/runtime/tasks/task-7/identitylinks/groups/behandelaar/candidate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reassign_is_a_no_op_when_the_beoordelen_task_is_no_longer_open()
|
||||||
|
{
|
||||||
|
// The behandelaar completed it just before the timer fired: nothing to reassign, no throw.
|
||||||
|
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":[],"total":0}""", Encoding.UTF8, "application/json"),
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
await client.ReassignBeoordelingToTeamleadAsync("pi-1");
|
||||||
|
|
||||||
|
Assert.DoesNotContain(HttpMethod.Delete, methods);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Complete_escalation_job_posts_to_the_job_complete_endpoint()
|
||||||
|
{
|
||||||
|
var capture = new RequestCapture();
|
||||||
|
var client = Client(capture.Responds(HttpStatusCode.NoContent));
|
||||||
|
|
||||||
|
await client.CompleteBeoordelingEscalatieJobAsync("job-9");
|
||||||
|
|
||||||
|
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
||||||
|
Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs/job-9/complete",
|
||||||
|
capture.Seen.RequestUri!.ToString());
|
||||||
|
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user