From f02d2ef568d3602d27c0e7f8d9fa45fb80ee0fd5 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 17 Jul 2026 09:10:31 +0200 Subject: [PATCH] =?UTF-8?q?test(acceptance):=20beoordeling=20escalation=20?= =?UTF-8?q?=E2=80=94=20both=20branches=20(S-14,=20refs=20#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gherkin scenarios for the two branches: a beoordeling left unclaimed for 14 days escalates to the teamlead, and one completed before the timer fires does not. Driven through the escalation worker against an in-memory Flowable stand-in. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/acceptance/Acceptance.csproj | 3 ++ .../Features/EenBeoordelingEscaleren.feature | 22 ++++++++ .../Steps/EenBeoordelingEscalerenSteps.cs | 44 +++++++++++++++ .../acceptance/Support/InMemoryDomainPorts.cs | 54 +++++++++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 tests/acceptance/Features/EenBeoordelingEscaleren.feature create mode 100644 tests/acceptance/Steps/EenBeoordelingEscalerenSteps.cs diff --git a/tests/acceptance/Acceptance.csproj b/tests/acceptance/Acceptance.csproj index 50d99ab..e062468 100644 --- a/tests/acceptance/Acceptance.csproj +++ b/tests/acceptance/Acceptance.csproj @@ -22,6 +22,9 @@ + + diff --git a/tests/acceptance/Features/EenBeoordelingEscaleren.feature b/tests/acceptance/Features/EenBeoordelingEscaleren.feature new file mode 100644 index 0000000..7072d43 --- /dev/null +++ b/tests/acceptance/Features/EenBeoordelingEscaleren.feature @@ -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 diff --git a/tests/acceptance/Steps/EenBeoordelingEscalerenSteps.cs b/tests/acceptance/Steps/EenBeoordelingEscalerenSteps.cs new file mode 100644 index 0000000..1d49dee --- /dev/null +++ b/tests/acceptance/Steps/EenBeoordelingEscalerenSteps.cs @@ -0,0 +1,44 @@ +using Acceptance.Support; +using Big.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Reqnroll; +using Xunit; + +namespace Acceptance.Steps; + +/// Bindings for EenBeoordelingEscaleren.feature (S-14). Drives the escalation worker +/// () 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. +[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.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)); +} diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs index 3e21752..4926b21 100644 --- a/tests/acceptance/Support/InMemoryDomainPorts.cs +++ b/tests/acceptance/Support/InMemoryDomainPorts.cs @@ -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 } } +/// 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. +public sealed class InMemoryEscalatieClient : IBeoordelingEscalatieClient +{ + private sealed class ParkedTask + { + public string CandidateGroup { get; set; } = "behandelaar"; + public bool IsOpen { get; set; } = true; + } + + private readonly Dictionary _tasks = []; + private readonly List _parked = []; + private int _seq; + + /// A registration parks at Beoordelen, claimable by the behandelaar group. + public string ParkBeoordeling() + { + var pid = $"pi-{++_seq}"; + _tasks[pid] = new ParkedTask(); + return pid; + } + + /// The behandelaar completes the beoordeling before the timer fires: the task closes. + public void CompleteBeoordeling(string processInstanceId) => _tasks[processInstanceId].IsOpen = false; + + /// The 14-day boundary timer fires: a non-interrupting token parks an escalation job. + public void FireEscalationTimer(string processInstanceId) + => _parked.Add(new EscalatieJob($"job-{++_seq}", processInstanceId)); + + /// The candidate group that may currently pick the task up. + public string CandidateGroupFor(string processInstanceId) => _tasks[processInstanceId].CandidateGroup; + + public Task> AcquireBeoordelingEscalatieJobsAsync(int maxJobs, CancellationToken ct = default) + => Task.FromResult>(_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; + } +} + /// An in-memory registration store for the domain acceptance scenario. public sealed class InMemoryRegistrationStore : IRegistrationStore {