Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e94a2a38bb | ||
|
|
2397d9196a | ||
|
|
9fc7b4487b | ||
|
|
a34caba9ea |
@@ -29,11 +29,12 @@ task; the Workflow Client correlates a `RegistratieIngetrokken` message to the t
|
||||
Client only delivers the message; it never reaches into Flowable to delete an instance. This keeps
|
||||
the workflow's control flow in the workflow (§8.2) and leaves an audit trail in Flowable history
|
||||
(the process ended via the ingetrokken path, not a raw delete).
|
||||
- **Correlated via the Beoordelen task's execution.** The `WithdrawRegistration` handler already
|
||||
knows the registration; it finds the open `Beoordelen` task for it (the same task-query the werkbak
|
||||
uses, §8.2) and asks the Workflow Client to deliver the withdrawal message to that task's execution
|
||||
(`messageEventReceived`). No separate correlation store is needed — the werkbak task set is the
|
||||
authoritative correlation, exactly as the beoordeling decision reuses it (ADR-0013).
|
||||
- **Correlated by the registration's own process instance.** The aggregate records its Flowable
|
||||
process instance id at submit, so the `WithdrawRegistration` handler correlates directly by that
|
||||
id — no task lookup. The Workflow Client asks Flowable for the execution **subscribed to** the
|
||||
`RegistratieIngetrokken` message in that instance and delivers `messageEventReceived` to it.
|
||||
Targeting the subscribed execution (not the user task's execution — a message boundary event's
|
||||
subscription lives on its own execution) is what makes the correlation land.
|
||||
- **Best-effort, mirroring the beoordeling.** If no open `Beoordelen` task is found (the process has
|
||||
not yet parked there — the `OpenZaakAanmaken` window — or has already ended), the withdrawal still
|
||||
stands: the aggregate is INGETROKKEN and the werkbak filters it out regardless (S-11b). We complete
|
||||
|
||||
@@ -17,7 +17,12 @@
|
||||
#
|
||||
# Port map (host):
|
||||
# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST
|
||||
# 8100 ACL · 8180 Keycloak (all admin: admin / admin — dev only)
|
||||
# 8100 ACL · 8130 Domain · 8180 Keycloak (all admin: admin / admin — dev only)
|
||||
# 8140 self-service portal · 8141 openbaar register · 8142 behandel portal
|
||||
#
|
||||
# Portal OIDC on the HOST: browse the portals at their 8140/8141/8142 ports and log in via
|
||||
# Keycloak on localhost:8180 (KC_HOSTNAME below pins the issuer there; the BFF still validates
|
||||
# in-network via keycloak:8080). Test users are in docs/synthetic-data.md.
|
||||
|
||||
services:
|
||||
|
||||
@@ -205,6 +210,12 @@ services:
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
KC_HTTP_ENABLED: "true"
|
||||
# Pin the frontend/issuer URL to the host-published address so a browser on the host and the
|
||||
# tokens it gets both use localhost:8180. KC_HOSTNAME_BACKCHANNEL_DYNAMIC lets in-network
|
||||
# callers (the BFF via keycloak:8080) still resolve token/jwks endpoints to their request host,
|
||||
# so the BFF validates the localhost:8180 issuer while fetching keys over the compose network.
|
||||
KC_HOSTNAME: http://localhost:8180
|
||||
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
|
||||
ports:
|
||||
- "8180:8080"
|
||||
volumes:
|
||||
@@ -295,6 +306,14 @@ services:
|
||||
context: ../services/bff
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/bff:dev
|
||||
environment:
|
||||
# Reach Keycloak over the compose network for metadata/keys; the discovered issuer is the
|
||||
# host-pinned localhost:8180 (KC_HOSTNAME above), which is what browser tokens carry — so
|
||||
# validation matches without the BFF ever needing to resolve localhost:8180 itself.
|
||||
Keycloak__Authority: http://keycloak:8080/realms/digid
|
||||
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
|
||||
Downstream__Domain__BaseUrl: http://domain:8080/
|
||||
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
||||
ports:
|
||||
- "8080:8080"
|
||||
healthcheck:
|
||||
@@ -303,6 +322,39 @@ services:
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
domain:
|
||||
condition: service_healthy
|
||||
projection-api:
|
||||
condition: service_healthy
|
||||
keycloak:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
# ── BIG Domain Service (S-05) ─────────────────────────────────────────────
|
||||
domain:
|
||||
build:
|
||||
context: ../services/domain
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/domain:dev
|
||||
environment:
|
||||
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
|
||||
Flowable__Username: rest-admin
|
||||
Flowable__Password: test
|
||||
Acl__BaseUrl: http://acl:8080/
|
||||
ports:
|
||||
- "8130:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
acl:
|
||||
condition: service_healthy
|
||||
flowable-init:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
# ── Read projection (S-06) ────────────────────────────────────────────────
|
||||
@@ -362,6 +414,73 @@ services:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
# ── Portals (S-08/S-09/S-12) ──────────────────────────────────────────────
|
||||
# nginx serves each Angular app and reverse-proxies its endpoint group to the BFF (same-origin).
|
||||
# The images bake config.json with the compose authority (keycloak:8080), which a HOST browser
|
||||
# can't resolve — so here we bind-mount a config.json pointing at the host-published localhost:8180
|
||||
# (matching KC_HOSTNAME). openbaar is anonymous and needs no config.
|
||||
self-service:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/self-service/Dockerfile
|
||||
image: register-referentie/self-service:dev
|
||||
ports:
|
||||
- "8140:80"
|
||||
volumes:
|
||||
- ./local-config/self-service.config.json:/usr/share/nginx/html/config.json:ro,z
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
bff:
|
||||
condition: service_healthy
|
||||
keycloak:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
openbaar:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/openbaar/Dockerfile
|
||||
image: register-referentie/openbaar:dev
|
||||
ports:
|
||||
- "8141:80"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
bff:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
behandel:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/behandel/Dockerfile
|
||||
image: register-referentie/behandel:dev
|
||||
ports:
|
||||
- "8142:80"
|
||||
volumes:
|
||||
- ./local-config/behandel.config.json:/usr/share/nginx/html/config.json:ro,z
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
bff:
|
||||
condition: service_healthy
|
||||
keycloak:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
volumes:
|
||||
oz-db:
|
||||
nrc-db:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"authority": "http://localhost:8180/realms/medewerker"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"authority": "http://localhost:8180/realms/digid"
|
||||
}
|
||||
@@ -142,8 +142,10 @@ done
|
||||
[ -n "$task_id2" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id2" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||
echo ">> Beoordelen task $task_id2 is waiting; withdrawing the registration via the domain"
|
||||
|
||||
# Owner-scoped: the withdraw carries the same bsn the registration was submitted with (S-11c).
|
||||
docker run --rm --network "$net" curlimages/curl:latest \
|
||||
-fsS -X POST "http://$dom_ip:8080/registrations/$reg_id2/withdraw" >/dev/null
|
||||
-fsS -X POST "http://$dom_ip:8080/registrations/$reg_id2/withdraw" \
|
||||
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' >/dev/null
|
||||
|
||||
echo ">> asserting the process was cancelled (no Beoordelen task remains for the registration)"
|
||||
gone=""
|
||||
|
||||
@@ -192,6 +192,40 @@ export class BffApiV1Service {
|
||||
);
|
||||
}
|
||||
|
||||
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
|
||||
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||
postSelfServiceRegistrationsIdWithdraw<TData = void>(
|
||||
id: string, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||
if (options?.observe === 'events') {
|
||||
return this.http.post<TData>(
|
||||
`/self-service/registrations/${id}/withdraw`,
|
||||
undefined,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'events',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (options?.observe === 'response') {
|
||||
return this.http.post<TData>(
|
||||
`/self-service/registrations/${id}/withdraw`,
|
||||
undefined,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'response',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.post<TData>(
|
||||
`/self-service/registrations/${id}/withdraw`,
|
||||
undefined,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'body',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable<TData>;
|
||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||
|
||||
@@ -22,6 +22,11 @@ public interface IDomainClient
|
||||
{
|
||||
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Withdraw the caller's own registration ("trek aanvraag in"). Owner-scoped by
|
||||
/// <paramref name="bsn"/>. Returns <c>false</c> when the domain reports the registration is
|
||||
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary>
|
||||
Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
|
||||
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
|
||||
|
||||
@@ -47,6 +52,17 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
|
||||
return new SubmitAccepted(dto.RegistrationId, dto.Status);
|
||||
}
|
||||
|
||||
public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||
{
|
||||
using var response = await http.PostAsJsonAsync(
|
||||
$"registrations/{registrationId}/withdraw", new { bsn }, ct);
|
||||
// The domain 404s an unknown or not-owned registration; relay that rather than fail hard.
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return false;
|
||||
response.EnsureSuccessStatusCode();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
|
||||
|
||||
|
||||
@@ -86,6 +86,24 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized);
|
||||
|
||||
// Self-service withdrawal (S-11): the signed-in zorgprofessional withdraws their own registration.
|
||||
// The bsn comes from the DigiD token and is forwarded to the domain, which owner-scopes the action;
|
||||
// a registration that is unknown or not the caller's comes back 404 (ownership is not revealed).
|
||||
app.MapPost("/self-service/registrations/{id}/withdraw", async (string id, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
|
||||
{
|
||||
var bsn = user.FindFirstValue("bsn");
|
||||
if (string.IsNullOrWhiteSpace(bsn))
|
||||
return Results.BadRequest("The token carries no bsn claim.");
|
||||
|
||||
var withdrawn = await domain.WithdrawRegistrationAsync(id, bsn, ct);
|
||||
return withdrawn ? Results.NoContent() : Results.NotFound();
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
|
||||
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
|
||||
{
|
||||
|
||||
@@ -82,6 +82,18 @@ internal sealed class FakeDomainClient : IDomainClient
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
|
||||
public (string RegistrationId, string Bsn)? Withdrawn { get; private set; }
|
||||
|
||||
/// <summary>Whether the fake domain reports the withdrawal as done (true → 204) or not-found/not-owned
|
||||
/// (false → 404). Tests set this to exercise the relay.</summary>
|
||||
public bool WithdrawSucceeds { get; set; } = true;
|
||||
|
||||
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||
{
|
||||
Withdrawn = (registrationId, bsn);
|
||||
return Task.FromResult(WithdrawSucceeds);
|
||||
}
|
||||
|
||||
public (string RegistrationId, string Besluit)? Decided { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||
|
||||
@@ -71,5 +71,46 @@ public class SelfServiceEndpointTests
|
||||
Assert.Equal("reg-123", body!.RegistrationId);
|
||||
}
|
||||
|
||||
private static HttpRequestMessage Withdraw(string? bearer, string id = "reg-123")
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"/self-service/registrations/{id}/withdraw");
|
||||
if (bearer is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||
return request;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_withdrawal_without_a_token()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(Withdraw(bearer: null));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Null(factory.Domain.Withdrawn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Withdraws_the_callers_registration_forwarding_the_id_and_bsn()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(Withdraw(TestTokens.Valid("123456782"), "reg-9"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
Assert.Equal(("reg-9", "123456782"), factory.Domain.Withdrawn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Relays_not_found_when_the_registration_is_unknown_or_not_the_callers()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
factory.Domain.WithdrawSucceeds = false;
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(Withdraw(TestTokens.Valid("123456782")));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,37 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/self-service/registrations/{id}/withdraw": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Bff.Api"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/openbaar/register": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
@@ -76,16 +76,19 @@ app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body,
|
||||
});
|
||||
|
||||
// Withdraw a registration (S-11): the zorgprofessional pulls their own still-open submission back,
|
||||
// advancing it to INGETROKKEN. Idempotent. The BFF reaches this behind a digid token, owner-scoped
|
||||
// to the caller's bsn (S-11c); the domain trusts its callers (§8.3). Cancelling the running Flowable
|
||||
// process is a later sub-slice (S-11b).
|
||||
app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRegistration withdraw, CancellationToken ct) =>
|
||||
// advancing it to INGETROKKEN and cancelling its workflow. Owner-scoped by the caller's bsn (the BFF
|
||||
// forwards it from the DigiD token, S-11c); a registration that is unknown or not the caller's is
|
||||
// 404 (indistinguishable, so ownership isn't leaked). Idempotent.
|
||||
app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRequest body, WithdrawRegistration withdraw, CancellationToken ct) =>
|
||||
{
|
||||
if (!Guid.TryParse(id, out var guid))
|
||||
return Results.NotFound();
|
||||
|
||||
await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid)), ct);
|
||||
return Results.NoContent();
|
||||
if (string.IsNullOrWhiteSpace(body?.Bsn))
|
||||
return Results.BadRequest(new { error = "A bsn is required to withdraw a registration." });
|
||||
|
||||
var outcome = await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid), body.Bsn), ct);
|
||||
return outcome == WithdrawOutcome.Withdrawn ? Results.NoContent() : Results.NotFound();
|
||||
});
|
||||
|
||||
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
|
||||
@@ -113,6 +116,8 @@ public sealed record SubmitRegistrationRequest(string Bsn);
|
||||
|
||||
public sealed record DecideRequest(string Besluit);
|
||||
|
||||
public sealed record WithdrawRequest(string Bsn);
|
||||
|
||||
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -15,6 +15,14 @@ public interface IWorkflowClient
|
||||
/// aggregate. Returns the process instance id.
|
||||
/// </summary>
|
||||
Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Cancel a running <c>registratie</c> process on withdrawal (S-11): correlate the
|
||||
/// <c>RegistratieIngetrokken</c> message to the instance, tripping the interrupting message event
|
||||
/// that ends it (ADR-0014). Best-effort — if the instance is not waiting on that message (already
|
||||
/// ended, or not yet parked) it is a no-op; the aggregate is INGETROKKEN regardless.
|
||||
/// </summary>
|
||||
Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -54,18 +62,11 @@ public interface IUserTaskClient
|
||||
/// <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>Deliver the withdrawal message to a <c>Beoordelen</c> task's execution, tripping the
|
||||
/// process's interrupting message boundary event so the registratie process cancels (S-11,
|
||||
/// ADR-0014). The registration itself is already INGETROKKEN in the domain; this ends its
|
||||
/// workflow so the case leaves the werkbak.</summary>
|
||||
Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>A <c>Beoordelen</c> user task in the werkbak: the Flowable task id (needed to claim and
|
||||
/// complete it), the execution it runs in (needed to deliver the withdrawal message to its boundary
|
||||
/// event), and the registration it carries as a process variable.</summary>
|
||||
public sealed record BeoordelingTask(string TaskId, string ExecutionId, RegistrationId RegistrationId);
|
||||
/// 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
|
||||
|
||||
@@ -2,43 +2,54 @@ using Big.Domain;
|
||||
|
||||
namespace Big.Application;
|
||||
|
||||
/// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in").</summary>
|
||||
public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId);
|
||||
/// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in").
|
||||
/// <paramref name="Bsn"/> is the authenticated caller (from the DigiD token, forwarded by the BFF):
|
||||
/// only the registration's own bsn may withdraw it.</summary>
|
||||
public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId, string Bsn);
|
||||
|
||||
/// <summary>The outcome of a withdrawal request.</summary>
|
||||
public enum WithdrawOutcome
|
||||
{
|
||||
/// <summary>The registration is now (or already was) INGETROKKEN.</summary>
|
||||
Withdrawn,
|
||||
|
||||
/// <summary>No registration with that id belongs to the caller — unknown, or owned by someone
|
||||
/// else (the two are deliberately indistinguishable, so the endpoint reveals neither).</summary>
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It
|
||||
/// advances the aggregate to INGETROKKEN, persists it, then cancels the running registratie process
|
||||
/// by delivering the withdrawal message to its open <c>Beoordelen</c> task (ADR-0014), so the case
|
||||
/// leaves the behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an
|
||||
/// already-withdrawn registration is a no-op (not persisted or cancelled again). Cancelling is
|
||||
/// best-effort: if no <c>Beoordelen</c> task is open (the process has not parked there yet, or has
|
||||
/// already ended) the withdrawal still stands — mirroring <see cref="BeoordeelRegistratie"/>.
|
||||
/// by correlating the withdrawal message to its instance (ADR-0014), so the case leaves the
|
||||
/// behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an already-withdrawn
|
||||
/// registration is a no-op (not persisted or cancelled again). Cancelling is best-effort: if the
|
||||
/// registration never started a process the withdrawal still stands (the process cancel is skipped),
|
||||
/// mirroring how <see cref="BeoordeelRegistratie"/> completes its task best-effort.
|
||||
/// </summary>
|
||||
public sealed class WithdrawRegistration(IRegistrationStore store, IUserTaskClient tasks)
|
||||
public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClient workflow)
|
||||
{
|
||||
public async Task HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
|
||||
public async Task<WithdrawOutcome> HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
var registration = await store.GetAsync(command.RegistrationId, ct)
|
||||
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to withdraw.");
|
||||
var registration = await store.GetAsync(command.RegistrationId, ct);
|
||||
|
||||
// Unknown, or not the caller's registration: report NotFound either way (don't reveal which).
|
||||
if (registration is null || registration.Bsn != command.Bsn)
|
||||
return WithdrawOutcome.NotFound;
|
||||
|
||||
// A repeated withdrawal is a no-op: don't persist or cancel the already-withdrawn one again.
|
||||
if (registration.Status == RegistrationStatus.Ingetrokken)
|
||||
return;
|
||||
return WithdrawOutcome.Withdrawn;
|
||||
|
||||
registration.Withdraw();
|
||||
await store.SaveAsync(registration, ct);
|
||||
await CancelWorkflowTaskAsync(command.RegistrationId, ct);
|
||||
}
|
||||
|
||||
// Cancel the workflow: deliver the withdrawal message to the open Beoordelen task's execution so
|
||||
// the process's boundary event ends it. If none is open the withdrawal still stands.
|
||||
private async Task CancelWorkflowTaskAsync(RegistrationId registrationId, CancellationToken ct)
|
||||
{
|
||||
var open = await tasks.GetOpenBeoordelingenAsync(ct);
|
||||
var task = open.FirstOrDefault(t => t.RegistrationId == registrationId);
|
||||
if (task is not null)
|
||||
await tasks.WithdrawBeoordelingAsync(task.ExecutionId, ct);
|
||||
// Cancel the running process (if one was started) so its Beoordelen task leaves the werkbak.
|
||||
if (registration.ProcessInstanceId is not null)
|
||||
await workflow.WithdrawProcessAsync(registration.ProcessInstanceId, ct);
|
||||
|
||||
return WithdrawOutcome.Withdrawn;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
||||
"service/query/tasks", request, ct);
|
||||
|
||||
var tasks = page?.Data ?? [];
|
||||
return [.. tasks.Select(t => new BeoordelingTask(t.Id, t.ExecutionId, RegistrationId.Parse(t.RegistrationId())))];
|
||||
return [.. tasks.Select(t => new BeoordelingTask(t.Id, RegistrationId.Parse(t.RegistrationId())))];
|
||||
}
|
||||
|
||||
public async Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default)
|
||||
@@ -86,18 +86,35 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default)
|
||||
public async Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
|
||||
{
|
||||
// Deliver the withdrawal message to the Beoordelen task's execution, tripping the process's
|
||||
// interrupting message boundary event so the registratie instance ends (ADR-0014). Flowable
|
||||
// takes messageEventReceived as a PUT on the subscribed execution.
|
||||
var request = new MessageEventRequest("messageEventReceived", IngetrokkenMessage);
|
||||
// Correlate the withdrawal message to the instance: find the execution subscribed to it (the
|
||||
// interrupting message event's own execution — NOT the user task's), then deliver
|
||||
// messageEventReceived to that execution so the process ends (ADR-0014). If nothing is
|
||||
// subscribed (the process is not parked at Beoordelen) this is a best-effort no-op.
|
||||
var subscribed = await GetAsync<ExecutionQueryResult>(
|
||||
$"service/runtime/executions?messageEventSubscriptionName={IngetrokkenMessage}&processInstanceId={processInstanceId}",
|
||||
ct);
|
||||
|
||||
var execution = subscribed?.Data?.FirstOrDefault();
|
||||
if (execution is null)
|
||||
return;
|
||||
|
||||
var request = new MessageEventRequest("messageEventReceived", IngetrokkenMessage);
|
||||
using var response = await SendAsync(
|
||||
$"service/runtime/executions/{executionId}", request, ct, HttpMethod.Put);
|
||||
$"service/runtime/executions/{execution.Id}", request, ct, HttpMethod.Put);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private async Task<TResponse?> GetAsync<TResponse>(string path, CancellationToken ct)
|
||||
{
|
||||
var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, path));
|
||||
message.Headers.Authorization = new AuthenticationHeaderValue("Basic", BasicCredentials());
|
||||
using var response = await http.SendAsync(message, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
|
||||
}
|
||||
|
||||
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
|
||||
{
|
||||
using var response = await SendAsync(path, body, ct);
|
||||
@@ -154,7 +171,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
||||
|
||||
private sealed record UserTaskDto(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("executionId")] string ExecutionId,
|
||||
// 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)
|
||||
@@ -172,6 +188,11 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
||||
|
||||
private sealed record ProcessInstance([property: JsonPropertyName("id")] string Id);
|
||||
|
||||
private sealed record ExecutionQueryResult(
|
||||
[property: JsonPropertyName("data")] IReadOnlyList<ExecutionDto>? Data);
|
||||
|
||||
private sealed record ExecutionDto([property: JsonPropertyName("id")] string Id);
|
||||
|
||||
private sealed record AcquiredJob(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables)
|
||||
|
||||
@@ -19,7 +19,7 @@ public class BeoordeelRegistratieTests
|
||||
}
|
||||
|
||||
private static FakeUserTaskClient TaskFor(Registration registration) =>
|
||||
new([new BeoordelingTask("task-1", "exec-1", registration.Id)]);
|
||||
new([new BeoordelingTask("task-1", registration.Id)]);
|
||||
|
||||
[Fact]
|
||||
public async Task Goedkeuren_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven()
|
||||
|
||||
@@ -32,6 +32,7 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
|
||||
: IWorkflowClient
|
||||
{
|
||||
public RegistrationId? StartedFor { get; private set; }
|
||||
public string? WithdrawnProcessInstanceId { get; private set; }
|
||||
|
||||
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -39,6 +40,12 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
|
||||
StartedFor = registrationId;
|
||||
return Task.FromResult(processInstanceId);
|
||||
}
|
||||
|
||||
public Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
|
||||
{
|
||||
WithdrawnProcessInstanceId = processInstanceId;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A fake user-task client for the werkbak/decision use cases: returns a scripted set of
|
||||
@@ -47,7 +54,6 @@ internal sealed class FakeUserTaskClient(IReadOnlyList<BeoordelingTask> open) :
|
||||
{
|
||||
public (string TaskId, string Behandelaar)? Claimed { get; private set; }
|
||||
public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; }
|
||||
public string? WithdrawnExecutionId { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(open);
|
||||
@@ -63,12 +69,6 @@ internal sealed class FakeUserTaskClient(IReadOnlyList<BeoordelingTask> open) :
|
||||
Completed = (taskId, besluit);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default)
|
||||
{
|
||||
WithdrawnExecutionId = executionId;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a
|
||||
|
||||
@@ -158,14 +158,13 @@ public class FlowableWorkflowClientTests
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK,
|
||||
$$"""
|
||||
{"data":[{"id":"task-1","executionId":"exec-1","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}],"total":1}
|
||||
{"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("exec-1", task.ExecutionId);
|
||||
Assert.Equal(rid, task.RegistrationId);
|
||||
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
||||
Assert.Equal("http://flowable/flowable-rest/service/query/tasks",
|
||||
@@ -244,29 +243,58 @@ public class FlowableWorkflowClientTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Withdraw_beoordeling_delivers_the_message_to_the_task_execution()
|
||||
public async Task Withdraw_process_correlates_the_message_to_the_subscribed_execution()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK));
|
||||
HttpRequestMessage? getReq = null;
|
||||
HttpRequestMessage? putReq = null;
|
||||
string? putBody = null;
|
||||
var client = Client(new StubHandler(async req =>
|
||||
{
|
||||
if (req.Method == HttpMethod.Get)
|
||||
{
|
||||
getReq = req;
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""{"data":[{"id":"exec-9"}]}""", Encoding.UTF8, "application/json"),
|
||||
};
|
||||
}
|
||||
putReq = req;
|
||||
putBody = await req.Content!.ReadAsStringAsync();
|
||||
return new HttpResponseMessage(HttpStatusCode.OK);
|
||||
}));
|
||||
|
||||
await client.WithdrawBeoordelingAsync("exec-1");
|
||||
await client.WithdrawProcessAsync("pi-1");
|
||||
|
||||
// A PUT messageEventReceived on the execution trips the Beoordelen boundary event (ADR-0014).
|
||||
Assert.Equal(HttpMethod.Put, capture.Seen!.Method);
|
||||
Assert.Equal("http://flowable/flowable-rest/service/runtime/executions/exec-1",
|
||||
capture.Seen.RequestUri!.ToString());
|
||||
Assert.Equal("rest-admin:test", DecodeBasic(capture.Seen));
|
||||
Assert.Contains("\"action\":\"messageEventReceived\"", capture.Body);
|
||||
Assert.Contains("\"messageName\":\"RegistratieIngetrokken\"", capture.Body);
|
||||
// 1. Find the execution subscribed to the withdrawal message for this instance.
|
||||
Assert.Equal(HttpMethod.Get, getReq!.Method);
|
||||
Assert.Contains("service/runtime/executions", getReq.RequestUri!.ToString());
|
||||
Assert.Contains("messageEventSubscriptionName=RegistratieIngetrokken", getReq.RequestUri!.Query);
|
||||
Assert.Contains("processInstanceId=pi-1", getReq.RequestUri!.Query);
|
||||
// 2. Deliver messageEventReceived to that execution (PUT), tripping the interrupting event.
|
||||
Assert.Equal(HttpMethod.Put, putReq!.Method);
|
||||
Assert.Equal("http://flowable/flowable-rest/service/runtime/executions/exec-9",
|
||||
putReq.RequestUri!.ToString());
|
||||
Assert.Contains("\"action\":\"messageEventReceived\"", putBody);
|
||||
Assert.Contains("\"messageName\":\"RegistratieIngetrokken\"", putBody);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Withdraw_beoordeling_throws_when_flowable_rejects_the_request()
|
||||
public async Task Withdraw_process_is_a_no_op_when_no_execution_is_subscribed()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.InternalServerError));
|
||||
var methods = new List<HttpMethod>();
|
||||
var client = Client(new StubHandler(req =>
|
||||
{
|
||||
methods.Add(req.Method);
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("""{"data":[]}""", Encoding.UTF8, "application/json"),
|
||||
});
|
||||
}));
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => client.WithdrawBeoordelingAsync("exec-1"));
|
||||
await client.WithdrawProcessAsync("pi-1");
|
||||
|
||||
// No subscribed execution → no message delivered (best-effort), no throw.
|
||||
Assert.DoesNotContain(HttpMethod.Put, methods);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -18,7 +18,7 @@ public class WerkbakTests
|
||||
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
|
||||
registration.TakeIntoBehandeling();
|
||||
store.Seed(registration);
|
||||
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1",registration.Id)]);
|
||||
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", registration.Id)]);
|
||||
var werkbak = new Werkbak(tasks, store);
|
||||
|
||||
var items = await werkbak.GetAsync();
|
||||
@@ -41,7 +41,7 @@ public class WerkbakTests
|
||||
public async Task Skips_a_task_whose_registration_is_unknown()
|
||||
{
|
||||
// Defensive: the werkbak never invents an item for a task the domain has no aggregate for.
|
||||
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1",RegistrationId.New())]);
|
||||
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", RegistrationId.New())]);
|
||||
var werkbak = new Werkbak(tasks, new FakeRegistrationStore());
|
||||
|
||||
Assert.Empty(await werkbak.GetAsync());
|
||||
@@ -56,7 +56,7 @@ public class WerkbakTests
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.Withdraw();
|
||||
store.Seed(registration);
|
||||
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1",registration.Id)]);
|
||||
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", registration.Id)]);
|
||||
var werkbak = new Werkbak(tasks, store);
|
||||
|
||||
Assert.Empty(await werkbak.GetAsync());
|
||||
|
||||
@@ -5,75 +5,101 @@ namespace Big.Tests;
|
||||
|
||||
public class WithdrawRegistrationTests
|
||||
{
|
||||
private static FakeUserTaskClient NoTasks() => new([]);
|
||||
private const string Bsn = "123456782";
|
||||
|
||||
private static Registration Submitted(string processInstanceId = "proc-1")
|
||||
{
|
||||
var registration = Registration.Submit(Bsn);
|
||||
registration.RecordProcessStarted(processInstanceId);
|
||||
return registration;
|
||||
}
|
||||
|
||||
private static WithdrawRegistrationCommand Command(RegistrationId id, string bsn = Bsn) => new(id, bsn);
|
||||
|
||||
[Fact]
|
||||
public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var registration = Registration.Submit("123456782");
|
||||
var registration = Submitted();
|
||||
store.Seed(registration);
|
||||
var handler = new WithdrawRegistration(store, NoTasks());
|
||||
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
|
||||
|
||||
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
|
||||
var outcome = await handler.HandleAsync(Command(registration.Id));
|
||||
|
||||
Assert.Equal(WithdrawOutcome.Withdrawn, outcome);
|
||||
var saved = await store.GetAsync(registration.Id);
|
||||
Assert.Equal(RegistrationStatus.Ingetrokken, saved!.Status);
|
||||
Assert.Equal(1, store.SaveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Withdrawing_cancels_the_open_beoordelen_task_via_its_execution()
|
||||
public async Task Withdrawing_cancels_the_running_process()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var registration = Registration.Submit("123456782");
|
||||
var registration = Submitted("proc-42");
|
||||
store.Seed(registration);
|
||||
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1", registration.Id)]);
|
||||
var handler = new WithdrawRegistration(store, tasks);
|
||||
var workflow = new FakeWorkflowClient();
|
||||
var handler = new WithdrawRegistration(store, workflow);
|
||||
|
||||
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
|
||||
await handler.HandleAsync(Command(registration.Id));
|
||||
|
||||
// The withdrawal message is delivered to the Beoordelen task's execution, tripping the
|
||||
// boundary event that ends the process (ADR-0014).
|
||||
Assert.Equal("exec-1", tasks.WithdrawnExecutionId);
|
||||
// The process the registration recorded at submit is cancelled (ADR-0014).
|
||||
Assert.Equal("proc-42", workflow.WithdrawnProcessInstanceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Withdrawing_with_no_open_task_still_marks_ingetrokken()
|
||||
public async Task Withdrawing_before_a_process_was_started_still_marks_ingetrokken()
|
||||
{
|
||||
// If the process has not parked at Beoordelen yet (or already ended), the withdrawal stands:
|
||||
// the aggregate is INGETROKKEN and nothing is cancelled.
|
||||
var store = new FakeRegistrationStore();
|
||||
var registration = Registration.Submit("123456782");
|
||||
var registration = Registration.Submit(Bsn); // no RecordProcessStarted
|
||||
store.Seed(registration);
|
||||
var tasks = NoTasks();
|
||||
var handler = new WithdrawRegistration(store, tasks);
|
||||
var workflow = new FakeWorkflowClient();
|
||||
var handler = new WithdrawRegistration(store, workflow);
|
||||
|
||||
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
|
||||
await handler.HandleAsync(Command(registration.Id));
|
||||
|
||||
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
|
||||
Assert.Null(tasks.WithdrawnExecutionId);
|
||||
Assert.Null(workflow.WithdrawnProcessInstanceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_different_bsn_cannot_withdraw_the_registration()
|
||||
{
|
||||
// Owner-scoping: only the zorgprofessional who submitted may withdraw. Another bsn is told
|
||||
// NotFound (we don't reveal the registration exists) and nothing is changed.
|
||||
var store = new FakeRegistrationStore();
|
||||
var registration = Submitted();
|
||||
store.Seed(registration);
|
||||
var workflow = new FakeWorkflowClient();
|
||||
var handler = new WithdrawRegistration(store, workflow);
|
||||
|
||||
var outcome = await handler.HandleAsync(Command(registration.Id, bsn: "999999990"));
|
||||
|
||||
Assert.Equal(WithdrawOutcome.NotFound, outcome);
|
||||
Assert.Equal(RegistrationStatus.Ingediend, (await store.GetAsync(registration.Id))!.Status);
|
||||
Assert.Equal(0, store.SaveCount);
|
||||
Assert.Null(workflow.WithdrawnProcessInstanceId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_null_command_without_touching_the_store()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var handler = new WithdrawRegistration(store, NoTasks());
|
||||
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
|
||||
Assert.Equal(0, store.SaveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Withdrawing_an_unknown_registration_throws()
|
||||
public async Task Withdrawing_an_unknown_registration_is_not_found()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var handler = new WithdrawRegistration(store, NoTasks());
|
||||
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New())));
|
||||
Assert.Contains("No registration", ex.Message);
|
||||
var outcome = await handler.HandleAsync(Command(RegistrationId.New()));
|
||||
|
||||
Assert.Equal(WithdrawOutcome.NotFound, outcome);
|
||||
Assert.Equal(0, store.SaveCount);
|
||||
}
|
||||
|
||||
@@ -81,14 +107,16 @@ public class WithdrawRegistrationTests
|
||||
public async Task Re_withdrawing_an_already_ingetrokken_registration_is_idempotent()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var registration = Registration.Submit("123456782");
|
||||
var registration = Submitted();
|
||||
store.Seed(registration);
|
||||
var handler = new WithdrawRegistration(store, NoTasks());
|
||||
var workflow = new FakeWorkflowClient();
|
||||
var handler = new WithdrawRegistration(store, workflow);
|
||||
|
||||
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
|
||||
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
|
||||
await handler.HandleAsync(Command(registration.Id));
|
||||
var second = await handler.HandleAsync(Command(registration.Id));
|
||||
|
||||
// The second withdrawal is a no-op: the aggregate is not persisted again.
|
||||
// The second withdrawal is a no-op: still Withdrawn, but the aggregate is not persisted again.
|
||||
Assert.Equal(WithdrawOutcome.Withdrawn, second);
|
||||
Assert.Equal(1, store.SaveCount);
|
||||
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,9 @@ public sealed class CapturingDomainClient : IDomainClient
|
||||
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
|
||||
}
|
||||
|
||||
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
|
||||
|
||||
|
||||
@@ -11,12 +11,19 @@ public sealed class InMemoryWorkflowClient : IWorkflowClient
|
||||
public const string StartedProcessInstanceId = "proc-acc-1";
|
||||
|
||||
public RegistrationId? StartedFor { get; private set; }
|
||||
public string? WithdrawnProcessInstanceId { get; private set; }
|
||||
|
||||
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
|
||||
{
|
||||
StartedFor = registrationId;
|
||||
return Task.FromResult(StartedProcessInstanceId);
|
||||
}
|
||||
|
||||
public Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
|
||||
{
|
||||
WithdrawnProcessInstanceId = processInstanceId;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An in-memory ACL stand-in: records the bsn it opened a zaak for and returns a fixed URL,
|
||||
|
||||
Reference in New Issue
Block a user