Compare commits
10 Commits
78e6c1d3d2
...
feat/13-wo
| Author | SHA1 | Date | |
|---|---|---|---|
| 94a506cbfc | |||
| c53354cd22 | |||
| 57b8755f9d | |||
| 3d561deb05 | |||
| 5db55eebf4 | |||
| efd1c06b99 | |||
| 57cc3637c9 | |||
| d4ed0ffc22 | |||
| 3023bb6fbe | |||
| 9997da8beb |
113
CHANGELOG.md
113
CHANGELOG.md
@@ -2,19 +2,130 @@
|
||||
|
||||
All notable changes to this project. Generated from Conventional Commits by git-cliff.
|
||||
|
||||
## Unreleased
|
||||
## v2026.07.0 — 2026-07-14
|
||||
|
||||
### Architecture
|
||||
- ADR-0005 adopt Stryker.NET for mutation testing (refs #47)
|
||||
- ADR-0006 — provision the ACL integration test against the compose stack (refs #46)
|
||||
- ADR-0007 + runbooks for the OZ→NRC notification wiring (refs #56)
|
||||
- ADR-0009 external-task job-worker pattern (refs #6, #60)
|
||||
- ADR-0010 BFF OIDC validation + downstream boundaries (refs #8, #63)
|
||||
|
||||
### Bug Fixes
|
||||
- Pin OpenZaak/NRC image tags; add smoke log capture on failure (refs #30)
|
||||
- Harden oz-db healthcheck and raise compose-up timeout (refs #30)
|
||||
- Bake config into images so compose-smoke passes on CI (refs #30)
|
||||
- Nrc-init runs migrations only, not setup_configuration (refs #30)
|
||||
- Smoke waits on durable services, not the whole project (refs #30)
|
||||
- Portable health poll instead of compose --wait (refs #30)
|
||||
- Pin upload-artifact to @v3 — @v4 refuses to run on Gitea (refs #47)
|
||||
- Buffer the zaak POST body so OpenZaak accepts it (refs #46)
|
||||
- Keep dotnet format green under the shared .editorconfig (refs #65)
|
||||
- Re-export the full Utrecht package from libs/ui (refs #67)
|
||||
- Run checkAuth() at startup to end the login redirect loop (refs #67)
|
||||
- Health-check nginx over IPv4 (127.0.0.1) (refs #68)
|
||||
- Treat the http portal origin as secure so DigiD PKCE login works (refs #68)
|
||||
- Attach the DigiD token to relative BFF calls (refs #68)
|
||||
|
||||
### Build
|
||||
- Pin Stryker.NET as a local dotnet tool (refs #47)
|
||||
|
||||
### CI
|
||||
- Gitea Actions pipeline + runner runbook (refs #30) (#37)
|
||||
- ACL Dockerfile + full compose stack for smoke test (refs #30)
|
||||
- Switch runner label to ubuntu-latest (refs #30)
|
||||
- Run the mutation ratchet as a parallel CI job (refs #47)
|
||||
- Publish the Stryker HTML report as a CI artifact (refs #47)
|
||||
- Run the ACL integration test as a Gitea Actions job (refs #46)
|
||||
- Keep the integration lane local-only; document the runner gap (refs #46)
|
||||
- Run the ACL integration test in CI inside the compose network (closes #55) (refs #46)
|
||||
- Run the Event Subscriber + projection-api in compose and verify end-to-end (refs #7)
|
||||
- Containerize, wire into compose, and verify end-to-end (refs #6)
|
||||
- Make Stryker report upload best-effort (refs #62)
|
||||
- Retrigger after runner cleanup (refs #6)
|
||||
- Retrigger CI (refs #6)
|
||||
- Retrigger CI after gitea restart (refs #6)
|
||||
- Compose wiring, verify-bff live check, mutation baseline (refs #8)
|
||||
- Nx frontend lane (lint/test/build) (refs #65)
|
||||
- Serve the self-service app in compose (refs #68)
|
||||
- Run Vitest ahead of the production build to stop worker-start timeout (refs #68)
|
||||
- Cache the NuGet package store across the .NET jobs (refs #73)
|
||||
- Run Playwright from the prebuilt image instead of downloading browsers (refs #73)
|
||||
|
||||
### Chores
|
||||
- Add idempotent Gitea backlog seeder
|
||||
- Remove bootstrap scripts from main (#35)
|
||||
- Contributor workflow — templates, git-cliff, gitea-workflow doc (closes #31) (#38)
|
||||
|
||||
### Documentation
|
||||
- Split S-00 into sub-slices (refs #1) (#33)
|
||||
- MkDocs scaffold + ADR-0001 + README quickstart (closes #32) (#39)
|
||||
- Tighten gitea-actions-gotchas, add local compose (refs #30)
|
||||
- ADR-0008 read projection store + demo note for the event path (refs #7)
|
||||
- Demo note for submitting a registration (S-05) (refs #6)
|
||||
- Demo note for the BFF front door (S-07) (refs #8)
|
||||
- Split S-08 into S-08a-d (refs #65)
|
||||
- Frontend-decisions + demo note for S-08a (refs #65)
|
||||
- Record the orval generator choice (refs #66)
|
||||
- Record NL DS + DigiD decisions and demo note (refs #67)
|
||||
- Serving/e2e decisions + walking-skeleton demo note (refs #68)
|
||||
|
||||
### Features
|
||||
- Placeholder BFF + /health endpoint (closes #28) (#34)
|
||||
- Containerize BFF + compose-up smoke (closes #29) (#36)
|
||||
- OpenZaak + Postgres + Redis up in compose (refs #10) (#40)
|
||||
- Seed BIG catalogus + JWT client for OpenZaak (refs #2) (#41)
|
||||
- Open Notificaties up + shared network (closes #2) (#42)
|
||||
- Keycloak with four mock realms (closes #3) (#43)
|
||||
- Flowable + registratie.bpmn external task (closes #4) (#44)
|
||||
- ACL skeleton — OpenZaak default-fill (refs #5) (#45)
|
||||
- Add bind-mount local compose for no-make/Windows dev (refs #30)
|
||||
- Publish the BIG zaaktype on demand via OZ_PUBLISH (refs #46)
|
||||
- Wire OpenZaak → Open Notificaties notifications (refs #56)
|
||||
- Project zaak-created notifications into the read projection (refs #7)
|
||||
- Persist the read projection and expose webhook + read APIs (refs #7)
|
||||
- Enforce the callback bearer before reading the body (refs #7)
|
||||
- Implement the Registration aggregate invariants (refs #6)
|
||||
- Implement SubmitRegistration and OpenZaakWorker (refs #6)
|
||||
- Implement the Flowable Workflow Client and ACL client (refs #6)
|
||||
- Expose POST /registrations and the read endpoint (refs #6)
|
||||
- Implement self-service submit and openbaar lookup (refs #8)
|
||||
- Committed OpenAPI contract + drift guard (refs #8)
|
||||
- Self-service portal placeholder page (refs #65)
|
||||
- Expose the generated BFF client + repeatable generate target (refs #66)
|
||||
- Implement the DigiD registration submit page (refs #67)
|
||||
- Runtime config + nginx serve/proxy image (refs #68)
|
||||
- Surface submit failures with a retryable alert (refs #68)
|
||||
- One citizen reference across self-service and the openbaar register (#79)
|
||||
|
||||
### Other
|
||||
- Openbaar Register portal — public lookup (#76)
|
||||
- Approval flow — temp admin endpoint + status transition to projection (#77)
|
||||
|
||||
### Refactor
|
||||
- Bake config via dockerfile_inline, drop Dockerfile files (refs #30)
|
||||
- Use upstream images verbatim, seed config via docker cp (refs #30)
|
||||
- One verify-stack stage for all live-stack checks (closes #58) (refs #46 #56)
|
||||
|
||||
### Tests
|
||||
- BDD acceptance scenario for opening a zaak (closes #5) (#49)
|
||||
- Kill surviving mutants — assert CRS headers, guards, error paths, JWT claims (refs #47)
|
||||
- Add Stryker config + mutation make target recording the 95% baseline (refs #47)
|
||||
- Integration test opens a real zaak against OpenZaak (refs #46)
|
||||
- Verify-notifications smoke + CI job for the OZ→NRC path (refs #56)
|
||||
- Project zaak-created notifications into the read projection (refs #7)
|
||||
- Ratchet projector mutation baseline to 100% (refs #7)
|
||||
- Registration aggregate invariants (refs #6)
|
||||
- SubmitRegistration + OpenZaakWorker use cases (refs #6)
|
||||
- Workflow Client, ACL client, store and job processor (refs #6)
|
||||
- Acceptance scenario for submitting a registration (refs #6)
|
||||
- Mutation baseline 90 (achieved 97.7%) + CI/Makefile wiring (refs #6)
|
||||
- Endpoints, JWT auth and public-safe projection (refs #8)
|
||||
- Acceptance scenario for BFF access (valid/invalid tokens) (refs #8)
|
||||
- Self-service portal placeholder renders (refs #65)
|
||||
- Generated BFF client is exposed and calls the endpoints (refs #66)
|
||||
- DigiD-guarded registration submit page (refs #67)
|
||||
- Walking-skeleton Playwright happy path + verify-e2e lane (refs #68)
|
||||
- Submit surfaces BFF failures instead of swallowing them (refs #68)
|
||||
- Guard that the DigiD token attaches to relative BFF calls (refs #68)
|
||||
|
||||
|
||||
@@ -51,18 +51,71 @@ loc="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||
echo ">> registration accepted at $loc"
|
||||
|
||||
echo ">> polling the domain until the worker records the opened zaak"
|
||||
zaak_ok=""
|
||||
for _ in $(seq 1 30); do
|
||||
body="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||
-fsS "http://$dom_ip:8080$loc" 2>/dev/null || true)"
|
||||
if echo "$body" | grep -q '/zaken/api/v1/zaken/'; then
|
||||
echo "OK — the domain opened a zaak and recorded it on the registration:"
|
||||
echo "$body" | cut -c1-300
|
||||
exit 0
|
||||
zaak_ok=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "FAIL — the registration never received a zaak URL" >&2
|
||||
echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2
|
||||
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
|
||||
[ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; }
|
||||
exit 1
|
||||
if [ -z "$zaak_ok" ]; then
|
||||
echo "FAIL — the registration never received a zaak URL" >&2
|
||||
echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2
|
||||
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
|
||||
[ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; }
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── S-12b: the process now parks at the Beoordelen user task. Exercise the exact Flowable REST
|
||||
# contract the Workflow Client uses (query/claim/complete) against the live engine, reaching
|
||||
# flowable-rest by container IP (same in-network constraint as above). ──────────────────────────
|
||||
fl="$(docker ps -q --filter 'name=flowable-rest' | head -1)"
|
||||
[ -n "$fl" ] || { echo "ERROR: no running flowable-rest container" >&2; exit 1; }
|
||||
fl_base="http://$(ip "$fl"):8080/flowable-rest/service"
|
||||
reg_id="${loc##*/}"
|
||||
|
||||
# Extracts the Beoordelen task id for our registration from a Flowable task-query response on stdin.
|
||||
# Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing.
|
||||
task_for_reg() { REG_ID="$reg_id" python3 -c "import os,sys,json
|
||||
try:
|
||||
d=json.load(sys.stdin)
|
||||
except Exception:
|
||||
d={}
|
||||
rid=os.environ['REG_ID']
|
||||
# Flowable's task-query returns the included process variables under 'variables'.
|
||||
print(next((t['id'] for t in (d.get('data') or [])
|
||||
if any(v.get('name')=='registrationId' and v.get('value')==rid for v in (t.get('variables') or []))), ''))"; }
|
||||
|
||||
flcurl() { docker run --rm --network "$net" curlimages/curl:latest -fsS -u rest-admin:test "$@"; }
|
||||
query='{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen","includeProcessVariables":true}'
|
||||
|
||||
echo ">> polling Flowable for the Beoordelen user task (werkbak)"
|
||||
task_id=""
|
||||
for _ in $(seq 1 30); do
|
||||
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
|
||||
task_id="$(printf '%s' "$resp" | task_for_reg)"
|
||||
[ -n "$task_id" ] && break
|
||||
sleep 2
|
||||
done
|
||||
[ -n "$task_id" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||
echo ">> Beoordelen task $task_id is waiting"
|
||||
|
||||
echo ">> claiming the task as merel-behandelaar"
|
||||
flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/json' \
|
||||
-d '{"action":"claim","assignee":"merel-behandelaar"}' >/dev/null
|
||||
|
||||
echo ">> completing the beoordeling (goedkeuren)"
|
||||
flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/json' \
|
||||
-d '{"action":"complete","variables":[{"name":"besluit","type":"string","value":"goedkeuren"}]}' >/dev/null
|
||||
|
||||
echo ">> asserting the process finished (no Beoordelen task remains for the registration)"
|
||||
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")"
|
||||
still="$(printf '%s' "$resp" | task_for_reg)"
|
||||
[ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; }
|
||||
echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished"
|
||||
exit 0
|
||||
|
||||
@@ -20,10 +20,12 @@ builder.Services.AddSingleton<IRegistrationStore, InMemoryRegistrationStore>();
|
||||
builder.Services.AddHttpClient<FlowableWorkflowClient>();
|
||||
builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||
builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||
builder.Services.AddTransient<IUserTaskClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||
builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
|
||||
|
||||
builder.Services.AddScoped<SubmitRegistration>();
|
||||
builder.Services.AddScoped<ApproveRegistration>();
|
||||
builder.Services.AddScoped<BeoordeelRegistratie>();
|
||||
builder.Services.AddScoped<OpenZaakWorker>();
|
||||
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
||||
|
||||
@@ -55,6 +57,22 @@ app.MapPost("/registrations/{id}/approve", async (string id, ApproveRegistration
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
// The behandelaar's beoordeling (S-12): decide a registration goedkeuren (→ INGESCHREVEN, sets the
|
||||
// zaak's final status via the ACL) or afwijzen (→ AFGEWEZEN). Idempotent. This is the domain contract
|
||||
// the behandel-portal's decision reaches through the BFF; it supersedes the temporary /approve above,
|
||||
// which is retired once the portal lands.
|
||||
app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body, BeoordeelRegistratie beoordeel, CancellationToken ct) =>
|
||||
{
|
||||
if (!Guid.TryParse(id, out var guid))
|
||||
return Results.NotFound();
|
||||
|
||||
if (!Enum.TryParse<BeoordelingsBesluit>(body.Besluit, ignoreCase: true, out var besluit))
|
||||
return Results.BadRequest(new { error = $"Unknown besluit '{body.Besluit}'. Expected 'goedkeuren' or 'afwijzen'." });
|
||||
|
||||
await beoordeel.HandleAsync(new BeoordeelRegistratieCommand(new RegistrationId(guid), besluit), ct);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
|
||||
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
|
||||
{
|
||||
@@ -72,6 +90,8 @@ await app.RunAsync();
|
||||
|
||||
public sealed record SubmitRegistrationRequest(string Bsn);
|
||||
|
||||
public sealed record DecideRequest(string Besluit);
|
||||
|
||||
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||
|
||||
public partial class Program;
|
||||
|
||||
57
services/domain/Big.Application/BeoordeelRegistratie.cs
Normal file
57
services/domain/Big.Application/BeoordeelRegistratie.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Application;
|
||||
|
||||
/// <summary>A behandelaar's beoordeling outcome, in domain language.</summary>
|
||||
public enum BeoordelingsBesluit
|
||||
{
|
||||
/// <summary>Approve — enter the registration in the register.</summary>
|
||||
Goedkeuren,
|
||||
|
||||
/// <summary>Reject — turn the registration down.</summary>
|
||||
Afwijzen,
|
||||
}
|
||||
|
||||
/// <summary>A behandelaar's decision on a registration.</summary>
|
||||
public sealed record BeoordeelRegistratieCommand(RegistrationId RegistrationId, BeoordelingsBesluit Besluit);
|
||||
|
||||
/// <summary>
|
||||
/// The beoordeling use case (S-12): apply a behandelaar's decision to a registration.
|
||||
/// <see cref="BeoordelingsBesluit.Goedkeuren"/> sets the zaak's final status via the ACL (§8.1) and
|
||||
/// advances the aggregate to INGESCHREVEN; <see cref="BeoordelingsBesluit.Afwijzen"/> advances it to
|
||||
/// AFGEWEZEN in the domain (propagating a rejection to the zaak, so the openbaar projection reflects
|
||||
/// it, is a later sub-slice of S-12). Both decisions are idempotent — a repeated or redelivered
|
||||
/// decision that matches the current terminal state is a no-op, so the ACL is not called twice.
|
||||
/// </summary>
|
||||
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl)
|
||||
{
|
||||
public async Task HandleAsync(BeoordeelRegistratieCommand command, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
var registration = await store.GetAsync(command.RegistrationId, ct)
|
||||
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to decide.");
|
||||
|
||||
switch (command.Besluit)
|
||||
{
|
||||
case BeoordelingsBesluit.Goedkeuren:
|
||||
// A repeated approval is a no-op: don't set the zaak status a second time.
|
||||
if (registration.Status == RegistrationStatus.Ingeschreven)
|
||||
return;
|
||||
if (registration.ZaakUrl is null)
|
||||
throw new InvalidOperationException(
|
||||
$"Registration {command.RegistrationId} has no zaak yet; it cannot be approved.");
|
||||
await acl.ApproveZaakAsync(registration.ZaakUrl, ct);
|
||||
registration.Approve();
|
||||
break;
|
||||
|
||||
case BeoordelingsBesluit.Afwijzen:
|
||||
if (registration.Status == RegistrationStatus.Afgewezen)
|
||||
return;
|
||||
registration.Reject();
|
||||
break;
|
||||
}
|
||||
|
||||
await store.SaveAsync(registration, ct);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,30 @@ public interface IAclClient
|
||||
Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The port to the behandelaar's user tasks in the workflow engine (S-12). Implemented by the
|
||||
/// Workflow Client — the only code that talks to Flowable (§8.2). The application lists the open
|
||||
/// <c>Beoordelen</c> tasks (the werkbak), claims one for a behandelaar, and completes it with the
|
||||
/// decision; it never names Flowable's REST shapes.
|
||||
/// </summary>
|
||||
public interface IUserTaskClient
|
||||
{
|
||||
/// <summary>The werkbak: the <c>Beoordelen</c> tasks awaiting a behandelaar, each with the
|
||||
/// registration it belongs to.</summary>
|
||||
Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Claim a beoordeling task for a behandelaar (assigns it to them).</summary>
|
||||
Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Complete a beoordeling task, carrying the decision into the process as the
|
||||
/// <c>besluit</c> variable so the workflow can continue on the chosen branch.</summary>
|
||||
Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>A <c>Beoordelen</c> user task in the werkbak: the Flowable task id (needed to claim and
|
||||
/// complete it) and the registration it carries as a process variable.</summary>
|
||||
public sealed record BeoordelingTask(string TaskId, RegistrationId RegistrationId);
|
||||
|
||||
/// <summary>
|
||||
/// Persistence port for the <see cref="Registration"/> aggregate. In-memory for the minimal slice
|
||||
/// (ADR-0009); an EF-backed store is a documented follow-up, and this port keeps that change additive.
|
||||
|
||||
@@ -66,10 +66,28 @@ public sealed class Registration
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances
|
||||
/// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.Ingeschreven"/>.
|
||||
/// Requires an opened zaak (the approval sets that zaak's status via the ACL), and only a
|
||||
/// submitted registration can be approved: re-approving is rejected as an invalid transition.
|
||||
/// A behandelaar picks the registration up for beoordeling. Advances
|
||||
/// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.InBehandeling"/>.
|
||||
/// Re-taking one already <see cref="RegistrationStatus.InBehandeling"/> is a no-op (the same or
|
||||
/// another behandelaar re-opens it); a decided registration can no longer be taken into behandeling.
|
||||
/// </summary>
|
||||
public void TakeIntoBehandeling()
|
||||
{
|
||||
if (Status == RegistrationStatus.InBehandeling)
|
||||
return;
|
||||
|
||||
if (Status != RegistrationStatus.Ingediend)
|
||||
throw new InvalidOperationException(
|
||||
$"Registration {Id} is {Status}; only an INGEDIEND registration can be taken into behandeling.");
|
||||
|
||||
Status = RegistrationStatus.InBehandeling;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances a
|
||||
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Ingeschreven"/>.
|
||||
/// Requires an opened zaak (the approval sets that zaak's status via the ACL); a registration that
|
||||
/// has already been decided cannot be approved again.
|
||||
/// </summary>
|
||||
public void Approve()
|
||||
{
|
||||
@@ -77,10 +95,27 @@ public sealed class Registration
|
||||
throw new InvalidOperationException(
|
||||
$"Registration {Id} has no zaak yet; it cannot be approved before its zaak is opened.");
|
||||
|
||||
if (Status != RegistrationStatus.Ingediend)
|
||||
throw new InvalidOperationException(
|
||||
$"Registration {Id} is {Status}; only an INGEDIEND registration can be approved.");
|
||||
|
||||
RequireOpenForDecision(nameof(Approve));
|
||||
Status = RegistrationStatus.Ingeschreven;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reject the registration — the behandelaar's decision not to enter it in the register. Advances a
|
||||
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Afgewezen"/>. Unlike
|
||||
/// approval this needs no zaak: a registration can be rejected before or after its zaak is opened.
|
||||
/// A registration that has already been decided cannot be rejected again.
|
||||
/// </summary>
|
||||
public void Reject()
|
||||
{
|
||||
RequireOpenForDecision(nameof(Reject));
|
||||
Status = RegistrationStatus.Afgewezen;
|
||||
}
|
||||
|
||||
// A decision is only valid while the registration is still open (INGEDIEND or IN_BEHANDELING).
|
||||
private void RequireOpenForDecision(string decision)
|
||||
{
|
||||
if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
|
||||
throw new InvalidOperationException(
|
||||
$"Registration {Id} is {Status}; only an INGEDIEND or IN_BEHANDELING registration can be decided ({decision}).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
namespace Big.Domain;
|
||||
|
||||
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. The walking
|
||||
/// skeleton knows <see cref="Ingediend"/> and the terminal <see cref="Ingeschreven"/>; withdrawal,
|
||||
/// beoordeling and herregistratie states arrive in their own slices (Iteration 2+).</summary>
|
||||
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. Submission starts in
|
||||
/// <see cref="Ingediend"/>; a behandelaar takes it <see cref="InBehandeling"/> and decides it into one
|
||||
/// of the terminal states <see cref="Ingeschreven"/> (approved) or <see cref="Afgewezen"/> (rejected).
|
||||
/// Withdrawal and herregistratie states arrive in their own slices (S-11+).</summary>
|
||||
public enum RegistrationStatus
|
||||
{
|
||||
/// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary>
|
||||
Ingediend,
|
||||
|
||||
/// <summary>Approved: entered in the register. Terminal in the walking skeleton (S-09b).</summary>
|
||||
/// <summary>Picked up by a behandelaar for beoordeling (S-12).</summary>
|
||||
InBehandeling,
|
||||
|
||||
/// <summary>Approved: entered in the register. Terminal.</summary>
|
||||
Ingeschreven,
|
||||
|
||||
/// <summary>Rejected by the behandelaar. Terminal.</summary>
|
||||
Afgewezen,
|
||||
}
|
||||
|
||||
@@ -15,12 +15,14 @@ namespace Big.Infrastructure;
|
||||
/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009).
|
||||
/// </summary>
|
||||
public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options)
|
||||
: IWorkflowClient, IExternalWorkerClient
|
||||
: IWorkflowClient, IExternalWorkerClient, IUserTaskClient
|
||||
{
|
||||
private const string Topic = "OpenZaakAanmaken";
|
||||
private const string ProcessDefinitionKey = "registratie";
|
||||
private const string BeoordelenTaskKey = "Beoordelen";
|
||||
private const string RegistrationIdVariable = "registrationId";
|
||||
private const string ZaakUrlVariable = "zaakUrl";
|
||||
private const string BesluitVariable = "besluit";
|
||||
|
||||
public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -55,6 +57,34 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
|
||||
{
|
||||
var request = new TaskQueryRequest(ProcessDefinitionKey, BeoordelenTaskKey, IncludeProcessVariables: true);
|
||||
|
||||
var page = await PostAsync<TaskQueryRequest, TaskQueryResult>(
|
||||
"service/query/tasks", request, ct);
|
||||
|
||||
var tasks = page?.Data ?? [];
|
||||
return [.. tasks.Select(t => new BeoordelingTask(t.Id, RegistrationId.Parse(t.RegistrationId())))];
|
||||
}
|
||||
|
||||
public async Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default)
|
||||
{
|
||||
using var response = await SendAsync(
|
||||
$"service/runtime/tasks/{taskId}", new ClaimTaskRequest("claim", behandelaar), ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default)
|
||||
{
|
||||
var request = new CompleteTaskRequest(
|
||||
"complete",
|
||||
[new Variable(BesluitVariable, "string", besluit.ToString().ToLowerInvariant())]);
|
||||
|
||||
using var response = await SendAsync($"service/runtime/tasks/{taskId}", request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
|
||||
{
|
||||
using var response = await SendAsync(path, body, ct);
|
||||
@@ -89,6 +119,34 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
||||
[property: JsonPropertyName("workerId")] string WorkerId,
|
||||
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
|
||||
|
||||
private sealed record TaskQueryRequest(
|
||||
[property: JsonPropertyName("processDefinitionKey")] string ProcessDefinitionKey,
|
||||
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey,
|
||||
[property: JsonPropertyName("includeProcessVariables")] bool IncludeProcessVariables);
|
||||
|
||||
private sealed record ClaimTaskRequest(
|
||||
[property: JsonPropertyName("action")] string Action,
|
||||
[property: JsonPropertyName("assignee")] string Assignee);
|
||||
|
||||
private sealed record CompleteTaskRequest(
|
||||
[property: JsonPropertyName("action")] string Action,
|
||||
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
|
||||
|
||||
private sealed record TaskQueryResult(
|
||||
[property: JsonPropertyName("data")] IReadOnlyList<UserTaskDto>? Data);
|
||||
|
||||
private sealed record UserTaskDto(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
// Flowable's task-query returns the (included) process variables under "variables", not
|
||||
// "processVariables"; the request opts in via includeProcessVariables.
|
||||
[property: JsonPropertyName("variables")] IReadOnlyList<Variable>? Variables)
|
||||
{
|
||||
/// <summary>The registration id this task carries as a process variable.</summary>
|
||||
public string RegistrationId() =>
|
||||
Variables?.SingleOrDefault(v => v.Name == "registrationId")?.Value
|
||||
?? throw new InvalidOperationException($"Beoordelen task {Id} carries no registrationId variable.");
|
||||
}
|
||||
|
||||
private sealed record Variable(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
|
||||
142
services/domain/Big.Tests/BeoordeelRegistratieTests.cs
Normal file
142
services/domain/Big.Tests/BeoordeelRegistratieTests.cs
Normal file
@@ -0,0 +1,142 @@
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The beoordeling use case (S-12): a behandelaar's decision on a registration. Goedkeuren sets the
|
||||
/// zaak's final status via the ACL (§8.1) and marks the aggregate INGESCHREVEN; Afwijzen marks it
|
||||
/// AFGEWEZEN in the domain (propagating a rejection to the zaak is a later sub-slice). Both are
|
||||
/// idempotent so a repeated or redelivered decision is a no-op.
|
||||
/// </summary>
|
||||
public class BeoordeelRegistratieTests
|
||||
{
|
||||
private static Registration WithZaak(string bsn = "123456782")
|
||||
{
|
||||
var registration = Registration.Submit(bsn);
|
||||
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
|
||||
return registration;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Goedkeuren_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl);
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||
|
||||
var saved = await store.GetAsync(registration.Id);
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, saved!.Status);
|
||||
Assert.Equal(FakeAclClient.DefaultZaakUrl, acl.ApprovedZaakUrl);
|
||||
Assert.Equal(1, acl.ApproveCallCount);
|
||||
Assert.Equal(1, store.SaveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Afwijzen_marks_the_registration_afgewezen_without_calling_the_acl()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl);
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
|
||||
|
||||
var saved = await store.GetAsync(registration.Id);
|
||||
Assert.Equal(RegistrationStatus.Afgewezen, saved!.Status);
|
||||
Assert.Equal(0, acl.ApproveCallCount);
|
||||
Assert.Equal(1, store.SaveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Goedkeuren_an_in_behandeling_registration_marks_it_ingeschreven()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
registration.TakeIntoBehandeling();
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl);
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_null_command_without_touching_the_store_or_acl()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var handler = new BeoordeelRegistratie(store, acl);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
|
||||
Assert.Equal(0, acl.ApproveCallCount);
|
||||
Assert.Equal(0, store.SaveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deciding_an_unknown_registration_throws_and_does_not_call_the_acl()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var handler = new BeoordeelRegistratie(store, acl);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
handler.HandleAsync(new BeoordeelRegistratieCommand(RegistrationId.New(), BeoordelingsBesluit.Goedkeuren)));
|
||||
Assert.Contains("No registration", ex.Message);
|
||||
Assert.Equal(0, acl.ApproveCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Goedkeuren_before_the_zaak_is_opened_throws_without_calling_the_acl()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var registration = Registration.Submit("123456782"); // no zaak yet
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)));
|
||||
Assert.Contains("no zaak", ex.Message);
|
||||
Assert.Equal(0, acl.ApproveCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Re_goedkeuren_an_already_ingeschreven_registration_is_idempotent()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl);
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||
|
||||
Assert.Equal(1, acl.ApproveCallCount);
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Re_afwijzen_an_already_afgewezen_registration_is_idempotent()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl);
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
|
||||
|
||||
Assert.Equal(1, store.SaveCount);
|
||||
Assert.Equal(RegistrationStatus.Afgewezen, (await store.GetAsync(registration.Id))!.Status);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
using Big.Infrastructure;
|
||||
|
||||
@@ -127,6 +128,17 @@ public class FlowableWorkflowClientTests
|
||||
() => 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<InvalidOperationException>(
|
||||
() => client.StartRegistrationProcessAsync(RegistrationId.New()));
|
||||
Assert.Contains("empty process-instance", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Complete_throws_when_flowable_rejects_the_request()
|
||||
{
|
||||
@@ -136,4 +148,107 @@ public class FlowableWorkflowClientTests
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => 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<InvalidOperationException>(() => 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<HttpRequestException>(() => 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 Complete_beoordeling_throws_when_flowable_rejects_the_request()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.InternalServerError));
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => client.CompleteBeoordelingAsync("task-1", BeoordelingsBesluit.Goedkeuren));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,4 +121,100 @@ public class RegistrationTests
|
||||
Assert.Contains("only an INGEDIEND", ex.Message);
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Taking_a_registration_into_behandeling_moves_it_to_in_behandeling()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
|
||||
registration.TakeIntoBehandeling();
|
||||
|
||||
Assert.Equal(RegistrationStatus.InBehandeling, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Re_taking_an_in_behandeling_registration_is_idempotent()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.TakeIntoBehandeling();
|
||||
|
||||
registration.TakeIntoBehandeling();
|
||||
|
||||
Assert.Equal(RegistrationStatus.InBehandeling, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Taking_a_decided_registration_into_behandeling_is_rejected()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
registration.Approve();
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => registration.TakeIntoBehandeling());
|
||||
Assert.Contains("only an INGEDIEND", ex.Message);
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approving_an_in_behandeling_registration_with_a_zaak_sets_it_ingeschreven()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
registration.TakeIntoBehandeling();
|
||||
|
||||
registration.Approve();
|
||||
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejecting_a_registration_sets_it_afgewezen()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
|
||||
registration.Reject();
|
||||
|
||||
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejecting_needs_no_zaak()
|
||||
{
|
||||
// A registration can be turned down before its zaak is ever opened, so Reject must not require one.
|
||||
var registration = Registration.Submit("123456782");
|
||||
|
||||
registration.Reject();
|
||||
|
||||
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
|
||||
Assert.Null(registration.ZaakUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejecting_an_in_behandeling_registration_sets_it_afgewezen()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.TakeIntoBehandeling();
|
||||
|
||||
registration.Reject();
|
||||
|
||||
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deciding_an_already_decided_registration_is_rejected()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.Reject();
|
||||
|
||||
var approveEx = Assert.Throws<InvalidOperationException>(() =>
|
||||
{
|
||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
registration.Approve();
|
||||
});
|
||||
Assert.Contains("IN_BEHANDELING", approveEx.Message);
|
||||
|
||||
var rejectEx = Assert.Throws<InvalidOperationException>(() => registration.Reject());
|
||||
Assert.Contains("Afgewezen", rejectEx.Message);
|
||||
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
|
||||
}
|
||||
}
|
||||
|
||||
24
tests/acceptance/Features/EenRegistratieBeoordelen.feature
Normal file
24
tests/acceptance/Features/EenRegistratieBeoordelen.feature
Normal file
@@ -0,0 +1,24 @@
|
||||
# language: en
|
||||
# Drives S-12 (#13). A behandelaar picks up a submitted registration for beoordeling and decides it:
|
||||
# goedkeuren enters it in the register (INGESCHREVEN, the zaak's final status set via the ACL, §8.1),
|
||||
# afwijzen turns it down (AFGEWEZEN). This scenario exercises the use cases against in-memory
|
||||
# stand-ins for the ACL and the store; real Flowable user-task claim/complete arrives in a later
|
||||
# sub-slice and is verified live.
|
||||
Feature: Een registratie beoordelen
|
||||
Als behandelaar wil ik een ingediende registratie beoordelen
|
||||
zodat deze wordt ingeschreven of afgewezen.
|
||||
|
||||
Scenario: Goedkeuren schrijft de registratie in via de ACL
|
||||
Given a submitted registration with an opened zaak
|
||||
When the behandelaar takes it into behandeling
|
||||
Then the registration has status "INBEHANDELING"
|
||||
When the behandelaar decides "goedkeuren"
|
||||
Then the registration has status "INGESCHREVEN"
|
||||
And the zaak's final status is set via the ACL
|
||||
|
||||
Scenario: Afwijzen wijst de registratie af zonder de ACL
|
||||
Given a submitted registration with an opened zaak
|
||||
When the behandelaar takes it into behandeling
|
||||
And the behandelaar decides "afwijzen"
|
||||
Then the registration has status "AFGEWEZEN"
|
||||
And the ACL is not asked to set the zaak status
|
||||
58
tests/acceptance/Steps/EenRegistratieBeoordelenSteps.cs
Normal file
58
tests/acceptance/Steps/EenRegistratieBeoordelenSteps.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using Acceptance.Support;
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
using Reqnroll;
|
||||
using Xunit;
|
||||
|
||||
namespace Acceptance.Steps;
|
||||
|
||||
/// <summary>Bindings for <c>EenRegistratieBeoordelen.feature</c> (S-12). Drives the TakeIntoBehandeling
|
||||
/// transition and the BeoordeelRegistratie use case against in-memory ports; one instance per scenario.
|
||||
/// Scoped to this feature so its "the registration has status" step does not clash with the identically
|
||||
/// phrased (but differently-asserting) step in <see cref="RegistratieIndienenSteps"/>.</summary>
|
||||
[Binding]
|
||||
[Scope(Feature = "Een registratie beoordelen")]
|
||||
public sealed class EenRegistratieBeoordelenSteps
|
||||
{
|
||||
private readonly InMemoryAclClient _acl = new();
|
||||
private readonly InMemoryRegistrationStore _store = new();
|
||||
private RegistrationId _id;
|
||||
|
||||
[Given("a submitted registration with an opened zaak")]
|
||||
public async Task GivenASubmittedRegistrationWithAnOpenedZaak()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.AttachZaak(InMemoryAclClient.OpenedZaakUrl);
|
||||
await _store.SaveAsync(registration);
|
||||
_id = registration.Id;
|
||||
}
|
||||
|
||||
[When("the behandelaar takes it into behandeling")]
|
||||
public async Task WhenTheBehandelaarTakesItIntoBehandeling()
|
||||
{
|
||||
var registration = await _store.GetAsync(_id);
|
||||
registration!.TakeIntoBehandeling();
|
||||
await _store.SaveAsync(registration);
|
||||
}
|
||||
|
||||
[When("the behandelaar decides \"(.*)\"")]
|
||||
public async Task WhenTheBehandelaarDecides(string besluit)
|
||||
=> await new BeoordeelRegistratie(_store, _acl).HandleAsync(
|
||||
new BeoordeelRegistratieCommand(_id, Enum.Parse<BeoordelingsBesluit>(besluit, ignoreCase: true)));
|
||||
|
||||
[Then("the registration has status \"(.*)\"")]
|
||||
public async Task ThenTheRegistrationHasStatus(string expected)
|
||||
{
|
||||
var registration = await _store.GetAsync(_id);
|
||||
Assert.NotNull(registration);
|
||||
Assert.Equal(expected, registration.Status.ToString().ToUpperInvariant());
|
||||
}
|
||||
|
||||
[Then("the zaak's final status is set via the ACL")]
|
||||
public void ThenTheZaakFinalStatusIsSetViaTheAcl()
|
||||
=> Assert.Equal(InMemoryAclClient.OpenedZaakUrl, _acl.ApprovedZaakUrl);
|
||||
|
||||
[Then("the ACL is not asked to set the zaak status")]
|
||||
public void ThenTheAclIsNotAskedToSetTheZaakStatus()
|
||||
=> Assert.Null(_acl.ApprovedZaakUrl);
|
||||
}
|
||||
@@ -26,19 +26,22 @@ test('DigiD login → submit → public INGEDIEND → approve → public INGESCH
|
||||
|
||||
// The openbaar register (anonymous, its own origin) shows the submitted entry once the projection
|
||||
// catches up. The projection updates asynchronously (NRC → event-subscriber), and the register loads
|
||||
// on open, so reload until a submitted (INGEDIEND) row appears.
|
||||
// on open, so reload until *this* submission's row appears. We poll on the reference cell (not a
|
||||
// generic INGEDIEND cell): the shared verify stack already holds INGEDIEND rows from earlier checks,
|
||||
// so a status-only poll would short-circuit on a stale row before our row is projected.
|
||||
await page.goto('http://openbaar/');
|
||||
await expect(page.getByRole('heading', { name: /Openbaar BIG-register/i })).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
await page.reload();
|
||||
return page.getByRole('cell', { name: 'INGEDIEND' }).count();
|
||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// #78: the reference shown in the public register must be the exact one the citizen saw on the
|
||||
// submit confirmation — no mismatch between the two portals.
|
||||
await expect(page.getByRole('cell', { name: reference })).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
await page.reload();
|
||||
return page.getByRole('cell', { name: reference }).count();
|
||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||
.toBeGreaterThan(0);
|
||||
await expect(page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
|
||||
.toBeVisible();
|
||||
|
||||
// Approve via the temporary admin endpoint (reached directly on the compose network, as a
|
||||
// behandelaar would until the behandel-portal exists — S-12). The zaak is opened off the request
|
||||
@@ -53,11 +56,12 @@ test('DigiD login → submit → public INGEDIEND → approve → public INGESCH
|
||||
const approve = await request.post(`http://domain:8080/registrations/${reference}/approve`);
|
||||
expect(approve.status()).toBe(204);
|
||||
|
||||
// The approval flows back to the projection; the openbaar register now shows it as INGESCHREVEN.
|
||||
// The approval flows back to the projection; the openbaar register now shows *our* row (matched by
|
||||
// its reference) as INGESCHREVEN.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
await page.reload();
|
||||
return page.getByRole('cell', { name: 'INGESCHREVEN' }).count();
|
||||
return page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count();
|
||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI"
|
||||
targetNamespace="http://respellion.nl/big">
|
||||
|
||||
<!-- S-03: minimal "Registratie ontvangen" flow.
|
||||
start -> external-worker task (OpenZaakAanmaken) -> end.
|
||||
The external task is handled by the Workflow Client / ACL in later slices. -->
|
||||
<!-- Registratie ontvangen flow.
|
||||
start -> external-worker task (OpenZaakAanmaken) -> Beoordelen (behandelaar user task) -> end.
|
||||
S-03 added the external task (Workflow Client / ACL). S-12 adds the behandelaar's beoordeling
|
||||
as a user task: the process parks here until a behandelaar claims and completes it with a
|
||||
`besluit` (goedkeuren/afwijzen). The registrationId set at start rides along as a process
|
||||
variable so the werkbak can correlate each task back to its aggregate. -->
|
||||
<process id="registratie" name="Registratie ontvangen" isExecutable="true">
|
||||
|
||||
<startEvent id="start" name="Registratie ontvangen"/>
|
||||
@@ -19,9 +22,13 @@
|
||||
flowable:type="external-worker"
|
||||
flowable:topic="OpenZaakAanmaken"/>
|
||||
|
||||
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="end"/>
|
||||
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="Beoordelen"/>
|
||||
|
||||
<endEvent id="end" name="Zaak aangemaakt"/>
|
||||
<userTask id="Beoordelen" name="Beoordelen" flowable:candidateGroups="behandelaar"/>
|
||||
|
||||
<sequenceFlow id="flow3" sourceRef="Beoordelen" targetRef="end"/>
|
||||
|
||||
<endEvent id="end" name="Registratie beoordeeld"/>
|
||||
</process>
|
||||
|
||||
<bpmndi:BPMNDiagram id="diagram">
|
||||
@@ -32,8 +39,11 @@
|
||||
<bpmndi:BPMNShape id="s_task" bpmnElement="OpenZaakAanmaken">
|
||||
<omgdc:Bounds x="200" y="85" width="120" height="60"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_beoordelen" bpmnElement="Beoordelen">
|
||||
<omgdc:Bounds x="390" y="85" width="120" height="60"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_end" bpmnElement="end">
|
||||
<omgdc:Bounds x="400" y="100" width="30" height="30"/>
|
||||
<omgdc:Bounds x="580" y="100" width="30" height="30"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNEdge id="e_flow1" bpmnElement="flow1">
|
||||
<omgdi:waypoint x="130" y="115"/>
|
||||
@@ -41,7 +51,11 @@
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="e_flow2" bpmnElement="flow2">
|
||||
<omgdi:waypoint x="320" y="115"/>
|
||||
<omgdi:waypoint x="400" y="115"/>
|
||||
<omgdi:waypoint x="390" y="115"/>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="e_flow3" bpmnElement="flow3">
|
||||
<omgdi:waypoint x="510" y="115"/>
|
||||
<omgdi:waypoint x="580" y="115"/>
|
||||
</bpmndi:BPMNEdge>
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
|
||||
Reference in New Issue
Block a user