feat(workflow): diploma-eligibility DMN routes foreign diplomas via CBGV-advies (S-13, closes #14) (#101)
CI / lint (push) Successful in 1m16s
CI / unit (push) Successful in 1m14s
CI / mutation (push) Successful in 5m14s
CI / build (push) Successful in 58s
CI / frontend (push) Successful in 2m29s
CI / verify-stack (push) Successful in 9m20s

## What & why

S-13: a diploma's origin decides its route. A **DMN** (`diploma-eligibility`) is evaluated inline by
the registratie process as a **`businessRuleTask`**; an exclusive gateway routes a **foreign**
(Buitenlands) diploma through a new **CBGVAdvies** user task before `Beoordelen`, a **domestic** one
straight there (PRD flow 4). The domain's only new job is carrying the diploma origin and passing it
as a process start variable.

Chose **Option B (DMN in the BPMN)** over the issue's literal "evaluated by the Domain Service via
Workflow Client" wording — keeps the decision a first-class workflow artefact and §8.2 clean.
Rationale in **ADR-0016** (proposal #100); noted on this issue.

Closes #14

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation.
- [x] Implementation makes the test pass.
- [x] Conventional Commits referencing the issue (`refs #14`).
- [ ] CI green — all Gitea Actions jobs.
- [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes (additive; DMN deployed by flowable-init).
- [x] Docs updated (ADR-0016, demo note).
- [x] ADR added (`docs/architecture/adr-0016-diploma-eligibility-dmn.md`).
- [x] Demo note in `docs/demo-script.md`.

## How it was built (TDD)

- **Domain**: `DiplomaOrigin` on the aggregate + submit command; threaded through the process-start port so the Workflow Client emits a `diplomaOrigin` start variable. Red → green.
- **DMN + BPMN**: `workflows/diploma-eligibility.dmn` (origin → route); `businessRuleTask` + exclusive gateway + `CBGVAdvies` user task in `registratie.bpmn`; DMN deployed to Flowable's DMN engine by `flowable-init`.
- **Both paths**: `Een diploma op herkomst routeren` acceptance scenarios (origin carried into the process) + unit tests; verify-domain drives a foreign registration through CBGVAdvies→Beoordelen and the domestic one straight to Beoordelen — exercising both DMN branches live.

## Notes for reviewers

- Deviation from the issue's Option-A wording is deliberate and recorded (ADR-0016); the outcome is unchanged.
- The self-service eIDAS→foreign wiring is out of scope here (this slice is area:domain + area:workflow); the domain submit accepts an optional `diplomaOrigin` so the foreign path is drivable.
- Local green: domain unit 109, acceptance 15, `dotnet format`, Release build (0 errors), **domain mutation 95.39%** (break 90). The DMN/`businessRuleTask` REST wiring is CI-verified on verify-stack (no local full-stack run here).

Reviewed-on: #101
This commit was merged in pull request #101.
This commit is contained in:
not
2026-07-20 07:26:52 +00:00
parent 7bcbc726ce
commit ccae27b3da
21 changed files with 497 additions and 67 deletions
+4 -1
View File
@@ -32,12 +32,15 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
: IWorkflowClient
{
public RegistrationId? StartedFor { get; private set; }
public DiplomaOrigin? StartedWithOrigin { get; private set; }
public string? WithdrawnProcessInstanceId { get; private set; }
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
public Task<string> StartRegistrationProcessAsync(
RegistrationId registrationId, DiplomaOrigin diplomaOrigin, CancellationToken ct = default)
{
onStart?.Invoke(registrationId);
StartedFor = registrationId;
StartedWithOrigin = diplomaOrigin;
return Task.FromResult(processInstanceId);
}
@@ -24,7 +24,7 @@ public class FlowableWorkflowClientTests
var client = Client(capture.Responds(HttpStatusCode.Created, """{"id":"pi-1"}"""));
var rid = RegistrationId.New();
var pid = await client.StartRegistrationProcessAsync(rid);
var pid = await client.StartRegistrationProcessAsync(rid, DiplomaOrigin.Binnenlands);
Assert.Equal("pi-1", pid);
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
@@ -38,6 +38,22 @@ public class FlowableWorkflowClientTests
Assert.Contains($"\"value\":\"{rid}\"", capture.Body);
}
[Theory]
[InlineData(DiplomaOrigin.Binnenlands, "Binnenlands")]
[InlineData(DiplomaOrigin.Buitenlands, "Buitenlands")]
public async Task Start_posts_the_diploma_origin_as_a_process_variable(DiplomaOrigin origin, string expected)
{
// The diploma origin rides along as a start variable so the workflow's DMN can route foreign
// diplomas through CBGV-advies (S-13, ADR-0016).
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.Created, """{"id":"pi-1"}"""));
await client.StartRegistrationProcessAsync(RegistrationId.New(), origin);
Assert.Contains("\"name\":\"diplomaOrigin\"", capture.Body);
Assert.Contains($"\"value\":\"{expected}\"", capture.Body);
}
[Fact]
public async Task Start_uses_the_configured_worker_credentials_and_defaults()
{
@@ -125,7 +141,7 @@ public class FlowableWorkflowClientTests
var client = Client(capture.Responds(HttpStatusCode.InternalServerError));
await Assert.ThrowsAsync<HttpRequestException>(
() => client.StartRegistrationProcessAsync(RegistrationId.New()));
() => client.StartRegistrationProcessAsync(RegistrationId.New(), DiplomaOrigin.Binnenlands));
}
[Fact]
@@ -135,7 +151,7 @@ public class FlowableWorkflowClientTests
var client = Client(capture.Responds(HttpStatusCode.Created, "null"));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => client.StartRegistrationProcessAsync(RegistrationId.New()));
() => client.StartRegistrationProcessAsync(RegistrationId.New(), DiplomaOrigin.Binnenlands));
Assert.Contains("empty process-instance", ex.Message);
}
@@ -16,6 +16,15 @@ public class RegistrationTests
Assert.Null(registration.ProcessInstanceId);
}
[Fact]
public void A_registration_defaults_to_a_domestic_diploma()
=> Assert.Equal(DiplomaOrigin.Binnenlands, Registration.Submit("123456782").DiplomaOrigin);
[Fact]
public void A_foreign_diploma_submission_records_its_origin()
=> Assert.Equal(DiplomaOrigin.Buitenlands,
Registration.Submit("123456782", DiplomaOrigin.Buitenlands).DiplomaOrigin);
[Theory]
[InlineData("")]
[InlineData(" ")]
@@ -25,6 +25,19 @@ public class SubmitRegistrationTests
Assert.Equal(2, store.SaveCount);
}
[Fact]
public async Task Submitting_a_foreign_diploma_carries_its_origin_to_the_process()
{
var store = new FakeRegistrationStore();
var workflow = new FakeWorkflowClient();
var handler = new SubmitRegistration(store, workflow);
var id = await handler.HandleAsync(new SubmitRegistrationCommand("123456782", DiplomaOrigin.Buitenlands));
Assert.Equal(DiplomaOrigin.Buitenlands, (await store.GetAsync(id))!.DiplomaOrigin);
Assert.Equal(DiplomaOrigin.Buitenlands, workflow.StartedWithOrigin);
}
[Fact]
public async Task Rejects_a_null_command_without_touching_the_store_or_workflow()
{