feat(self-service): trek aanvraag in — withdrawal action (S-11c-2, closes #12) (#93)
All checks were successful
All checks were successful
## What & why Final sub-slice of **S-11 · Withdrawal (Flow 3)** — the user-facing "trek aanvraag in" action, which **closes #12**. - **self-service portal**: the submit confirmation gains a **"Trek aanvraag in"** button. It withdraws the just-submitted registration via `postSelfServiceRegistrationsIdWithdraw(reference)`; success shows an *ingetrokken* confirmation, a failure is surfaced (`role="alert"`) and the action stays available — same confirm-and-surface pattern as submit. - **acceptance**: `Een registratie intrekken` — owner withdraws → INGETROKKEN + workflow cancelled; a different bsn is reported not-found. - **e2e**: `withdrawal.spec.ts` — DigiD submit → trek aanvraag in → the portal confirms ingetrokken. - **docs**: demo-script + frontend-decisions. Together with S-11a (#88), S-11b (#89), S-11c-1 (#90), this completes the flow: citizen withdraws → domain INGETROKKEN → BPMN message event cancels the process → the case leaves the behandelaar's werkbak. Closes #12 ## Definition of Done - [x] Linked Gitea issue (#12). - [x] Failing tests committed before the implementation. - [x] Implementation makes the tests pass. - [x] Conventional Commits referencing the issue (`refs #12`). - [ ] CI green — all Gitea Actions jobs. - [x] `docker compose up` unaffected. - [x] Docs updated (demo-script + frontend-decisions). - [x] ADR — ADR-0014 (from S-11b) covers the cancellation decision; nothing new here. ## Notes for reviewers - Full local gate run before pushing: `dotnet format --verify-no-changes` clean; `make unit` green (Acceptance **11** incl. the 2 new withdrawal scenarios, Big 95, BFF 30, Acl 27, EventSubscriber 19); self-service lint/test/build green (9 tests, incl. the 2 new withdraw tests). - `withdrawal.spec.ts` waits on the *ingetrokken* confirmation (which only renders after the withdraw POST returns), so it can't cancel the request early (the 499 lesson from #87). Live-validated by verify-stack. Reviewed-on: #93
This commit was merged in pull request #93.
This commit is contained in:
@@ -3,9 +3,29 @@
|
|||||||
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
|
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
|
||||||
|
|
||||||
@if (submitted()) {
|
@if (submitted()) {
|
||||||
<p utrecht-paragraph role="status">
|
@if (withdrawn()) {
|
||||||
Uw registratie is ontvangen. Referentie: {{ reference() }}.
|
<p utrecht-paragraph role="status">
|
||||||
</p>
|
Uw registratie met referentie {{ reference() }} is ingetrokken.
|
||||||
|
</p>
|
||||||
|
} @else {
|
||||||
|
<p utrecht-paragraph role="status">
|
||||||
|
Uw registratie is ontvangen. Referentie: {{ reference() }}.
|
||||||
|
</p>
|
||||||
|
@if (withdrawFailed()) {
|
||||||
|
<p utrecht-paragraph role="alert">
|
||||||
|
Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
utrecht-button
|
||||||
|
appearance="secondary-action-button"
|
||||||
|
type="button"
|
||||||
|
[disabled]="withdrawing()"
|
||||||
|
(click)="withdraw()"
|
||||||
|
>
|
||||||
|
Trek aanvraag in
|
||||||
|
</button>
|
||||||
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
|
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
|
||||||
@if (failed()) {
|
@if (failed()) {
|
||||||
|
|||||||
@@ -17,12 +17,22 @@ class FakeAuth extends AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function providers(post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' }))) {
|
function providers(
|
||||||
|
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||||
|
withdraw = vi.fn().mockReturnValue(of(undefined)),
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
post,
|
post,
|
||||||
|
withdraw,
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: AuthService, useClass: FakeAuth },
|
{ provide: AuthService, useClass: FakeAuth },
|
||||||
{ provide: BffApiV1Service, useValue: { postSelfServiceRegistrations: post } },
|
{
|
||||||
|
provide: BffApiV1Service,
|
||||||
|
useValue: {
|
||||||
|
postSelfServiceRegistrations: post,
|
||||||
|
postSelfServiceRegistrationsIdWithdraw: withdraw,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -56,6 +66,36 @@ describe('RegistrationPage', () => {
|
|||||||
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
|
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers to withdraw after submitting, and withdrawing confirms', async () => {
|
||||||
|
const { withdraw, providers: p } = providers();
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
await screen.findByText(/ontvangen/i);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
|
||||||
|
|
||||||
|
// The withdrawal is keyed by the reference the submit returned, and the page confirms it.
|
||||||
|
expect(withdraw).toHaveBeenCalledWith('reg-9');
|
||||||
|
expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a withdraw failure and keeps the action available', async () => {
|
||||||
|
const { providers: p } = providers(
|
||||||
|
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||||
|
vi.fn().mockReturnValue(throwError(() => new Error('withdraw rejected'))),
|
||||||
|
);
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
|
||||||
|
await screen.findByText(/ontvangen/i);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toBeTruthy();
|
||||||
|
expect(screen.queryByText(/is ingetrokken/i)).toBeNull();
|
||||||
|
expect(screen.getByRole('button', { name: /trek aanvraag in/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('has no WCAG 2.1 AA violations on the submit page', async () => {
|
it('has no WCAG 2.1 AA violations on the submit page', async () => {
|
||||||
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
|
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
|
||||||
// html-has-lang rule reflects the app, not the bare jsdom document.
|
// html-has-lang rule reflects the app, not the bare jsdom document.
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { UtrechtComponentsModule } from 'ui';
|
|||||||
/**
|
/**
|
||||||
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
|
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
|
||||||
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
|
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
|
||||||
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c).
|
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After
|
||||||
|
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-registration-page',
|
selector: 'app-registration-page',
|
||||||
@@ -22,6 +23,9 @@ export class RegistrationPage {
|
|||||||
protected readonly reference = signal<string | undefined>(undefined);
|
protected readonly reference = signal<string | undefined>(undefined);
|
||||||
protected readonly submitted = signal(false);
|
protected readonly submitted = signal(false);
|
||||||
protected readonly failed = signal(false);
|
protected readonly failed = signal(false);
|
||||||
|
protected readonly withdrawing = signal(false);
|
||||||
|
protected readonly withdrawn = signal(false);
|
||||||
|
protected readonly withdrawFailed = signal(false);
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
this.submitting.set(true);
|
this.submitting.set(true);
|
||||||
@@ -39,4 +43,24 @@ export class RegistrationPage {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
withdraw(): void {
|
||||||
|
const reference = this.reference();
|
||||||
|
if (!reference) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.withdrawing.set(true);
|
||||||
|
this.withdrawFailed.set(false);
|
||||||
|
this.bff.postSelfServiceRegistrationsIdWithdraw(reference).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.withdrawn.set(true);
|
||||||
|
this.withdrawing.set(false);
|
||||||
|
},
|
||||||
|
// Surface the failure instead of swallowing it: keep the action so the user can retry.
|
||||||
|
error: () => {
|
||||||
|
this.withdrawFailed.set(true);
|
||||||
|
this.withdrawing.set(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,3 +275,26 @@ ACL → NRC → event-subscriber → projection → openbaar register shows INGE
|
|||||||
> The full round-trip — DigiD submit → public INGEDIEND → behandelaar goedkeurt in the werkbak →
|
> The full round-trip — DigiD submit → public INGEDIEND → behandelaar goedkeurt in the werkbak →
|
||||||
> public INGESCHREVEN — is the Playwright happy path (`tests/e2e/registration.spec.ts`), which now
|
> public INGESCHREVEN — is the Playwright happy path (`tests/e2e/registration.spec.ts`), which now
|
||||||
> drives the behandel portal in place of the old admin endpoint.
|
> drives the behandel portal in place of the old admin endpoint.
|
||||||
|
|
||||||
|
## S-11 — Withdrawal: "trek aanvraag in" (#12, ADR-0014)
|
||||||
|
|
||||||
|
A zorgprofessional can withdraw their own still-open registration from the self-service portal. The
|
||||||
|
withdrawal is owner-scoped (the BFF forwards the DigiD token's bsn; the domain only lets the owner
|
||||||
|
withdraw) and cancels the running workflow via a BPMN message event, so the case leaves the
|
||||||
|
behandelaar's werkbak.
|
||||||
|
|
||||||
|
```text
|
||||||
|
# 1. Log in and submit at the self-service portal (http://localhost:8140/, jan-burger / test123),
|
||||||
|
# note the "Referentie" on the confirmation.
|
||||||
|
# 2. Click "Trek aanvraag in" → the page confirms the registration is ingetrokken.
|
||||||
|
# 3. In the behandel werkbak (http://localhost:8142/, merel-behandelaar) the registration no longer
|
||||||
|
# appears — its Beoordelen task was cancelled.
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** self-service → BFF `POST /self-service/registrations/{id}/withdraw` (DigiD, owner-scoped)
|
||||||
|
→ domain sets INGETROKKEN + correlates the `RegistratieIngetrokken` message to the process → the
|
||||||
|
interrupting boundary event ends it → the werkbak drops the case.
|
||||||
|
|
||||||
|
> DigiD submit → trek aanvraag in → ingetrokken is the Playwright happy path
|
||||||
|
> (`tests/e2e/withdrawal.spec.ts`); the owner-scoping + workflow cancellation are covered by the
|
||||||
|
> `Een registratie intrekken` acceptance scenarios and the domain live check.
|
||||||
|
|||||||
@@ -151,3 +151,19 @@ frontend work is the medewerker realm auth and the werkbak/decide page. Wiring r
|
|||||||
to assert the medewerker token attaches to `/behandel/*` (and not to the anonymous openbaar call).
|
to assert the medewerker token attaches to `/behandel/*` (and not to the anonymous openbaar call).
|
||||||
The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy
|
The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy
|
||||||
path.
|
path.
|
||||||
|
|
||||||
|
## Self-service withdrawal: "trek aanvraag in" (S-11c, #12)
|
||||||
|
|
||||||
|
The submit confirmation grows a **"Trek aanvraag in"** action so a zorgprofessional can withdraw the
|
||||||
|
registration they just submitted (`apps/self-service`, on the existing `RegistrationPage`).
|
||||||
|
|
||||||
|
- **Keyed by the reference, owner-scoped at the BFF.** The button calls the generated
|
||||||
|
`postSelfServiceRegistrationsIdWithdraw(reference)` with the reference the submit returned. The
|
||||||
|
DigiD token (attached by the interceptor) carries the bsn the BFF forwards; the domain only lets
|
||||||
|
the owner withdraw (a mismatch is 404). No extra identity is entered in the UI.
|
||||||
|
- **Same confirm-and-surface pattern as submit.** A secondary-action button; on success the page
|
||||||
|
switches to an ingetrokken confirmation; a failure is surfaced (`role="alert"`) and the action
|
||||||
|
stays available to retry — mirroring how submit handles its failure rather than swallowing it.
|
||||||
|
- **Testing.** Component tests (`@testing-library/angular`, mocked BFF) cover the button appearing
|
||||||
|
after submit, the reference being passed, the ingetrokken confirmation, and the failure path; the
|
||||||
|
browser round-trip is `tests/e2e/withdrawal.spec.ts`.
|
||||||
|
|||||||
22
tests/acceptance/Features/EenRegistratieIntrekken.feature
Normal file
22
tests/acceptance/Features/EenRegistratieIntrekken.feature
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# language: en
|
||||||
|
# Drives S-11 (#12). A zorgprofessional withdraws their own submitted registration ("trek aanvraag
|
||||||
|
# in"): it advances to INGETROKKEN and its running workflow is cancelled (ADR-0014). Only the owner
|
||||||
|
# may withdraw — another bsn is told not-found. Exercised against in-memory stand-ins for the store
|
||||||
|
# and the Workflow Client; the live Flowable message correlation is verified by the domain check.
|
||||||
|
Feature: Een registratie intrekken
|
||||||
|
Als zorgprofessional wil ik mijn ingediende registratie kunnen intrekken
|
||||||
|
zodat een aanvraag die ik niet meer wil niet in behandeling blijft.
|
||||||
|
|
||||||
|
Scenario: De zorgprofessional trekt zijn eigen registratie in
|
||||||
|
Given a submitted registration with a running process
|
||||||
|
When the zorgprofessional withdraws it
|
||||||
|
Then the withdrawal succeeds
|
||||||
|
And the registration has status "INGETROKKEN"
|
||||||
|
And the running process is cancelled
|
||||||
|
|
||||||
|
Scenario: Een andere zorgprofessional kan de registratie niet intrekken
|
||||||
|
Given a submitted registration with a running process
|
||||||
|
When a different zorgprofessional tries to withdraw it
|
||||||
|
Then the withdrawal is reported not found
|
||||||
|
And the registration has status "INGEDIEND"
|
||||||
|
And the running process is not cancelled
|
||||||
59
tests/acceptance/Steps/EenRegistratieIntrekkenSteps.cs
Normal file
59
tests/acceptance/Steps/EenRegistratieIntrekkenSteps.cs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
using Acceptance.Support;
|
||||||
|
using Big.Application;
|
||||||
|
using Big.Domain;
|
||||||
|
using Reqnroll;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Acceptance.Steps;
|
||||||
|
|
||||||
|
/// <summary>Bindings for <c>EenRegistratieIntrekken.feature</c> (S-11). Submits a registration (which
|
||||||
|
/// records its process) and then applies the WithdrawRegistration 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 steps in the other features.</summary>
|
||||||
|
[Binding]
|
||||||
|
[Scope(Feature = "Een registratie intrekken")]
|
||||||
|
public sealed class EenRegistratieIntrekkenSteps
|
||||||
|
{
|
||||||
|
private const string OwnerBsn = "123456782";
|
||||||
|
|
||||||
|
private readonly InMemoryRegistrationStore _store = new();
|
||||||
|
private readonly InMemoryWorkflowClient _workflow = new();
|
||||||
|
private RegistrationId _id;
|
||||||
|
private WithdrawOutcome _outcome;
|
||||||
|
|
||||||
|
[Given("a submitted registration with a running process")]
|
||||||
|
public async Task GivenASubmittedRegistrationWithARunningProcess()
|
||||||
|
=> _id = await new SubmitRegistration(_store, _workflow).HandleAsync(new SubmitRegistrationCommand(OwnerBsn));
|
||||||
|
|
||||||
|
[When("the zorgprofessional withdraws it")]
|
||||||
|
public async Task WhenTheZorgprofessionalWithdrawsIt()
|
||||||
|
=> _outcome = await new WithdrawRegistration(_store, _workflow).HandleAsync(
|
||||||
|
new WithdrawRegistrationCommand(_id, OwnerBsn));
|
||||||
|
|
||||||
|
[When("a different zorgprofessional tries to withdraw it")]
|
||||||
|
public async Task WhenADifferentZorgprofessionalTriesToWithdrawIt()
|
||||||
|
=> _outcome = await new WithdrawRegistration(_store, _workflow).HandleAsync(
|
||||||
|
new WithdrawRegistrationCommand(_id, "999999990"));
|
||||||
|
|
||||||
|
[Then("the withdrawal succeeds")]
|
||||||
|
public void ThenTheWithdrawalSucceeds() => Assert.Equal(WithdrawOutcome.Withdrawn, _outcome);
|
||||||
|
|
||||||
|
[Then("the withdrawal is reported not found")]
|
||||||
|
public void ThenTheWithdrawalIsReportedNotFound() => Assert.Equal(WithdrawOutcome.NotFound, _outcome);
|
||||||
|
|
||||||
|
[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 running process is cancelled")]
|
||||||
|
public void ThenTheRunningProcessIsCancelled()
|
||||||
|
=> Assert.Equal(InMemoryWorkflowClient.StartedProcessInstanceId, _workflow.WithdrawnProcessInstanceId);
|
||||||
|
|
||||||
|
[Then("the running process is not cancelled")]
|
||||||
|
public void ThenTheRunningProcessIsNotCancelled()
|
||||||
|
=> Assert.Null(_workflow.WithdrawnProcessInstanceId);
|
||||||
|
}
|
||||||
27
tests/e2e/withdrawal.spec.ts
Normal file
27
tests/e2e/withdrawal.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
// S-11 (Flow 3): a zorgprofessional logs in via mock DigiD, submits a registration, then withdraws
|
||||||
|
// it ("trek aanvraag in") from the self-service portal. The withdrawal goes portal → BFF (owner-
|
||||||
|
// scoped by the DigiD token's bsn) → domain, which cancels the running workflow (ADR-0014); the page
|
||||||
|
// then confirms the registration is ingetrokken.
|
||||||
|
test('DigiD submit → trek aanvraag in → self-service confirms ingetrokken', async ({ page }) => {
|
||||||
|
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await page.locator('#username').fill('jan-burger');
|
||||||
|
await page.locator('#password').fill('test123');
|
||||||
|
await page.locator('#kc-login').click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /indienen/i }).click();
|
||||||
|
|
||||||
|
// The BFF accepted it and the page shows the confirmation with the reference.
|
||||||
|
await expect(page.getByText(/ontvangen/i)).toBeVisible();
|
||||||
|
|
||||||
|
// Withdraw it. The confirmation of withdrawal appears only after the decide POST completes (204),
|
||||||
|
// so awaiting the "ingetrokken" text also proves the request landed — no premature navigation.
|
||||||
|
await page.getByRole('button', { name: /trek aanvraag in/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText(/is ingetrokken/i)).toBeVisible();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user