Compare commits

..

1 Commits

Author SHA1 Message Date
01a32b884f chore(release): 2026.07.0 (refs #80)
All checks were successful
CI / lint (pull_request) Successful in 1m27s
CI / build (pull_request) Successful in 1m19s
CI / unit (pull_request) Successful in 1m35s
CI / frontend (pull_request) Successful in 2m51s
CI / mutation (pull_request) Successful in 6m20s
CI / verify-stack (pull_request) Successful in 7m36s
First CalVer release, marking the end of Iteration 1 (Walking Skeleton).
CHANGELOG.md regenerated from Conventional Commits via git-cliff for tag v2026.07.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:11:34 +02:00
13 changed files with 26 additions and 729 deletions

View File

@@ -51,71 +51,18 @@ 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
zaak_ok=1
break
exit 0
fi
sleep 2
done
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
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

View File

@@ -20,12 +20,10 @@ 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>();
@@ -57,22 +55,6 @@ 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) =>
{
@@ -90,8 +72,6 @@ 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;

View File

@@ -1,57 +0,0 @@
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);
}
}

View File

@@ -36,30 +36,6 @@ 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.

View File

@@ -66,28 +66,10 @@ public sealed class Registration
}
/// <summary>
/// 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.
/// 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.
/// </summary>
public void Approve()
{
@@ -95,27 +77,10 @@ public sealed class Registration
throw new InvalidOperationException(
$"Registration {Id} has no zaak yet; it cannot be approved before its zaak is opened.");
RequireOpenForDecision(nameof(Approve));
if (Status != RegistrationStatus.Ingediend)
throw new InvalidOperationException(
$"Registration {Id} is {Status}; only an INGEDIEND registration can be approved.");
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}).");
}
}

View File

@@ -1,20 +1,13 @@
namespace Big.Domain;
/// <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>
/// <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>
public enum RegistrationStatus
{
/// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary>
Ingediend,
/// <summary>Picked up by a behandelaar for beoordeling (S-12).</summary>
InBehandeling,
/// <summary>Approved: entered in the register. Terminal.</summary>
/// <summary>Approved: entered in the register. Terminal in the walking skeleton (S-09b).</summary>
Ingeschreven,
/// <summary>Rejected by the behandelaar. Terminal.</summary>
Afgewezen,
}

View File

@@ -15,14 +15,12 @@ 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, IUserTaskClient
: IWorkflowClient, IExternalWorkerClient
{
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)
{
@@ -57,34 +55,6 @@ 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);
@@ -119,34 +89,6 @@ 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,

View File

@@ -1,142 +0,0 @@
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);
}
}

View File

@@ -1,6 +1,5 @@
using System.Net;
using System.Text;
using Big.Application;
using Big.Domain;
using Big.Infrastructure;
@@ -128,17 +127,6 @@ 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()
{
@@ -148,107 +136,4 @@ 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));
}
}

View File

@@ -121,100 +121,4 @@ 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);
}
}

View File

@@ -1,24 +0,0 @@
# 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

View File

@@ -1,58 +0,0 @@
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);
}

View File

@@ -6,12 +6,9 @@
xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI"
targetNamespace="http://respellion.nl/big">
<!-- 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. -->
<!-- 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. -->
<process id="registratie" name="Registratie ontvangen" isExecutable="true">
<startEvent id="start" name="Registratie ontvangen"/>
@@ -22,13 +19,9 @@
flowable:type="external-worker"
flowable:topic="OpenZaakAanmaken"/>
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="Beoordelen"/>
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="end"/>
<userTask id="Beoordelen" name="Beoordelen" flowable:candidateGroups="behandelaar"/>
<sequenceFlow id="flow3" sourceRef="Beoordelen" targetRef="end"/>
<endEvent id="end" name="Registratie beoordeeld"/>
<endEvent id="end" name="Zaak aangemaakt"/>
</process>
<bpmndi:BPMNDiagram id="diagram">
@@ -39,11 +32,8 @@
<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="580" y="100" width="30" height="30"/>
<omgdc:Bounds x="400" y="100" width="30" height="30"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="e_flow1" bpmnElement="flow1">
<omgdi:waypoint x="130" y="115"/>
@@ -51,11 +41,7 @@
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flow2" bpmnElement="flow2">
<omgdi:waypoint x="320" 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"/>
<omgdi:waypoint x="400" y="115"/>
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>