feat(workflow): beoordeling escalation to teamlead after 14 days (S-14, closes #15) #99
@@ -22,6 +22,9 @@
|
||||
<ProjectReference Include="..\..\services\acl\Acl.Infrastructure\Acl.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\services\event-subscriber\EventSubscriber.Application\EventSubscriber.Application.csproj" />
|
||||
<ProjectReference Include="..\..\services\domain\Big.Application\Big.Application.csproj" />
|
||||
<!-- The beoordeling-escalation scenario drives the escalation worker (Infrastructure), as the
|
||||
ACL scenario drives Acl.Infrastructure — escalation has no domain-aggregate surface (S-14). -->
|
||||
<ProjectReference Include="..\..\services\domain\Big.Infrastructure\Big.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\services\bff\Bff.Api\Bff.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
22
tests/acceptance/Features/EenBeoordelingEscaleren.feature
Normal file
22
tests/acceptance/Features/EenBeoordelingEscaleren.feature
Normal file
@@ -0,0 +1,22 @@
|
||||
# language: en
|
||||
# Drives S-14 (#15). A beoordeling a behandelaar does not pick up within 14 days escalates to the
|
||||
# teamlead: a non-interrupting boundary timer parks a BeoordelingEscaleren job (ADR-0015) which the
|
||||
# escalation worker drains, reassigning the still-open Beoordelen task's candidate group to teamlead.
|
||||
# A beoordeling completed before the timer fires does not escalate. This scenario exercises the
|
||||
# escalation worker against an in-memory Flowable stand-in; the timer firing live is verify-domain.
|
||||
Feature: Een beoordeling escaleren
|
||||
Als teamleider wil ik dat een beoordeling die na 14 dagen niet is opgepakt naar mij escaleert
|
||||
zodat aanvragen niet blijven liggen.
|
||||
|
||||
Scenario: Na 14 dagen zonder oppakken escaleert de beoordeling naar de teamlead
|
||||
Given a registration parked at the Beoordelen task for the behandelaar
|
||||
When the 14-day escalation timer fires
|
||||
And the escalation worker runs
|
||||
Then the beoordeling is reassigned to the teamlead
|
||||
|
||||
Scenario: Een tijdig afgeronde beoordeling escaleert niet
|
||||
Given a registration parked at the Beoordelen task for the behandelaar
|
||||
When the behandelaar completes the beoordeling before the timer fires
|
||||
And the 14-day escalation timer fires
|
||||
And the escalation worker runs
|
||||
Then the beoordeling stays with the behandelaar
|
||||
44
tests/acceptance/Steps/EenBeoordelingEscalerenSteps.cs
Normal file
44
tests/acceptance/Steps/EenBeoordelingEscalerenSteps.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using Acceptance.Support;
|
||||
using Big.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Reqnroll;
|
||||
using Xunit;
|
||||
|
||||
namespace Acceptance.Steps;
|
||||
|
||||
/// <summary>Bindings for <c>EenBeoordelingEscaleren.feature</c> (S-14). Drives the escalation worker
|
||||
/// (<see cref="BeoordelingEscalatieProcessor"/>) against an in-memory Flowable stand-in; one instance
|
||||
/// per scenario. Escalation has no domain-aggregate surface — it only reassigns who may claim the
|
||||
/// still-open Beoordelen task — so the scenario asserts on the task's candidate group.</summary>
|
||||
[Binding]
|
||||
[Scope(Feature = "Een beoordeling escaleren")]
|
||||
public sealed class EenBeoordelingEscalerenSteps
|
||||
{
|
||||
private readonly InMemoryEscalatieClient _flowable = new();
|
||||
private string _processInstanceId = "";
|
||||
|
||||
[Given("a registration parked at the Beoordelen task for the behandelaar")]
|
||||
public void GivenARegistrationParkedForTheBehandelaar()
|
||||
=> _processInstanceId = _flowable.ParkBeoordeling();
|
||||
|
||||
[When("the 14-day escalation timer fires")]
|
||||
public void WhenTheEscalationTimerFires()
|
||||
=> _flowable.FireEscalationTimer(_processInstanceId);
|
||||
|
||||
[When("the behandelaar completes the beoordeling before the timer fires")]
|
||||
public void WhenTheBehandelaarCompletesBeforeTheTimer()
|
||||
=> _flowable.CompleteBeoordeling(_processInstanceId);
|
||||
|
||||
[When("the escalation worker runs")]
|
||||
public async Task WhenTheEscalationWorkerRuns()
|
||||
=> await new BeoordelingEscalatieProcessor(
|
||||
_flowable, NullLogger<BeoordelingEscalatieProcessor>.Instance).PumpOnceAsync(5);
|
||||
|
||||
[Then("the beoordeling is reassigned to the teamlead")]
|
||||
public void ThenTheBeoordelingIsReassignedToTheTeamlead()
|
||||
=> Assert.Equal("teamlead", _flowable.CandidateGroupFor(_processInstanceId));
|
||||
|
||||
[Then("the beoordeling stays with the behandelaar")]
|
||||
public void ThenTheBeoordelingStaysWithTheBehandelaar()
|
||||
=> Assert.Equal("behandelaar", _flowable.CandidateGroupFor(_processInstanceId));
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
using Big.Infrastructure;
|
||||
|
||||
namespace Acceptance.Support;
|
||||
|
||||
@@ -73,6 +74,59 @@ public sealed class InMemoryUserTaskClient : IUserTaskClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An in-memory Flowable stand-in for the beoordeling-escalation scenario (S-14): it models
|
||||
/// one Beoordelen task per process instance — its candidate group and whether it is still open — and
|
||||
/// the escalation jobs the non-interrupting 14-day boundary timer parks. It drives the escalation
|
||||
/// worker's behaviour without a running Flowable; the timer firing live is the verify-domain check.</summary>
|
||||
public sealed class InMemoryEscalatieClient : IBeoordelingEscalatieClient
|
||||
{
|
||||
private sealed class ParkedTask
|
||||
{
|
||||
public string CandidateGroup { get; set; } = "behandelaar";
|
||||
public bool IsOpen { get; set; } = true;
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, ParkedTask> _tasks = [];
|
||||
private readonly List<EscalatieJob> _parked = [];
|
||||
private int _seq;
|
||||
|
||||
/// <summary>A registration parks at Beoordelen, claimable by the behandelaar group.</summary>
|
||||
public string ParkBeoordeling()
|
||||
{
|
||||
var pid = $"pi-{++_seq}";
|
||||
_tasks[pid] = new ParkedTask();
|
||||
return pid;
|
||||
}
|
||||
|
||||
/// <summary>The behandelaar completes the beoordeling before the timer fires: the task closes.</summary>
|
||||
public void CompleteBeoordeling(string processInstanceId) => _tasks[processInstanceId].IsOpen = false;
|
||||
|
||||
/// <summary>The 14-day boundary timer fires: a non-interrupting token parks an escalation job.</summary>
|
||||
public void FireEscalationTimer(string processInstanceId)
|
||||
=> _parked.Add(new EscalatieJob($"job-{++_seq}", processInstanceId));
|
||||
|
||||
/// <summary>The candidate group that may currently pick the task up.</summary>
|
||||
public string CandidateGroupFor(string processInstanceId) => _tasks[processInstanceId].CandidateGroup;
|
||||
|
||||
public Task<IReadOnlyList<EscalatieJob>> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<EscalatieJob>>(_parked.Take(maxJobs).ToList());
|
||||
|
||||
public Task ReassignBeoordelingToTeamleadAsync(string processInstanceId, CancellationToken ct = default)
|
||||
{
|
||||
// No-op if the behandelaar already completed it — the timer/completion race (§8.6).
|
||||
var task = _tasks[processInstanceId];
|
||||
if (task.IsOpen)
|
||||
task.CandidateGroup = "teamlead";
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CompleteBeoordelingEscalatieJobAsync(string jobId, CancellationToken ct = default)
|
||||
{
|
||||
_parked.RemoveAll(j => j.JobId == jobId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
|
||||
public sealed class InMemoryRegistrationStore : IRegistrationStore
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user