From 5f8ab4dbcdfd2c3925431a020ea0e8e4c320bbfe Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 23 Jul 2026 07:22:08 +0000 Subject: [PATCH] feat: self-service resume of an existing registration after refresh (S-26, closes #111) (#119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What & why After submitting, the self-service portal held the registration only in in-memory signals, so a **page refresh stranded an in-flight registration** — the reference and its "Documenten aanleveren" / "Trek aanvraag in" actions were lost, with no way back (the reference wasn't in the URL and there was no read endpoint). This is the gap a citizen hit in testing. Now the portal **resumes on load**: - **Domain:** `IRegistrationStore.FindOpenByBsnAsync` (the citizen's non-terminal INGEDIEND/IN_BEHANDELING registration) + `GET /registrations/current?bsn=`. - **BFF:** owner-scoped `GET /self-service/registrations` (bsn from the DigiD token) → the current registration, or **204** when none. Regenerated `services/bff/openapi.json`. - **Frontend:** `registration-page` calls it on init and restores the submitted view (reference + actions); 204 shows the submit form as before. api-client regenerated (orval). Closes #111 ## Definition of Done - [x] Linked issue (#111). - [x] TDD — store `FindOpenByBsnAsync` tests, BFF endpoint tests, an Angular component test (resume-on-load), a Playwright e2e (submit → reload → restored). - [x] Conventional Commits referencing #111. - [ ] CI green — validated locally (below); runner CI running. - [x] `docker compose up` reaches green health — fresh stack + full e2e (3 specs) green. - [x] Docs — `docs/synthetic-data.md` (new e2e users). - [ ] ADR — N/A (follows existing BFF/domain patterns; no boundary change). - [ ] Demo note — the flow is unchanged for the demo; no new demo-script section (happy to add one if wanted). ## Verified locally - Unit: Big 141 (+7 store tests), Bff 36 (+3 endpoint tests), all suites green. - Frontend: 12 self-service component tests (incl. resume-on-load); lint + build green. - **e2e (fresh CI stack): all 3 specs pass** — `registration`, `resume`, `withdrawal` (29.5s, single worker). - Mutation: domain **91.04%**, bff **100%** (break 90%). `make lint` clean. ## Notes for reviewers - **Shared-stack isolation:** resume-on-load restores any open registration for the logged-in bsn, so the self-service e2e specs can no longer share `jan-burger` (the verify-* API checks submit as `jan-burger`/`123456782` before the e2e). Each spec now has its own DigiD citizen (`emma`/`sanne`/`lars`-burger); `jan-burger` stays the documented citizen for the verify checks. This is the fix for the two intermittent e2e failures seen during development. - **Scope:** resumes the current **in-flight** registration only (terminal ones aren't resumed), per the issue's out-of-scope note. Reviewed-on: https://git.labs.respellion.tech/eho/register-referentie/pulls/119 --- .../registration/registration-page.spec.ts | 19 +++++++ .../src/app/registration/registration-page.ts | 26 ++++++++- docs/synthetic-data.md | 1 + infra/keycloak/realms/digid-realm.json | 30 ++++++++++ libs/api-client/src/lib/generated/bff-api.ts | 36 ++++++++++++ services/bff/Bff.Api/DownstreamClients.cs | 20 +++++++ services/bff/Bff.Api/Program.cs | 18 ++++++ services/bff/Bff.Tests/BffFactory.cs | 12 ++++ .../bff/Bff.Tests/SelfServiceEndpointTests.cs | 48 ++++++++++++++++ services/bff/openapi.json | 41 ++++++++++++++ services/domain/Big.Api/Program.cs | 15 +++++ services/domain/Big.Application/Ports.cs | 5 ++ .../InMemoryRegistrationStore.cs | 4 ++ services/domain/Big.Tests/Fakes.cs | 4 ++ .../InMemoryRegistrationStoreTests.cs | 55 +++++++++++++++++++ tests/acceptance/Support/BffAcceptanceHost.cs | 3 + .../acceptance/Support/InMemoryDomainPorts.cs | 4 ++ tests/e2e/registration.spec.ts | 7 ++- tests/e2e/resume.spec.ts | 33 +++++++++++ tests/e2e/withdrawal.spec.ts | 4 +- 20 files changed, 379 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/resume.spec.ts diff --git a/apps/self-service/src/app/registration/registration-page.spec.ts b/apps/self-service/src/app/registration/registration-page.spec.ts index 8e8cff9..765302c 100644 --- a/apps/self-service/src/app/registration/registration-page.spec.ts +++ b/apps/self-service/src/app/registration/registration-page.spec.ts @@ -21,16 +21,20 @@ function providers( post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })), withdraw = vi.fn().mockReturnValue(of(undefined)), provideDocuments = vi.fn().mockReturnValue(of(undefined)), + // Resume lookup (S-26): default to 204/empty — no in-flight registration, so the submit form shows. + getCurrent = vi.fn().mockReturnValue(of(undefined)), ) { return { post, withdraw, provideDocuments, + getCurrent, providers: [ { provide: AuthService, useClass: FakeAuth }, { provide: BffApiV1Service, useValue: { + getSelfServiceRegistrations: getCurrent, postSelfServiceRegistrations: post, postSelfServiceRegistrationsIdWithdraw: withdraw, postSelfServiceRegistrationsIdDocuments: provideDocuments, @@ -56,6 +60,21 @@ describe('RegistrationPage', () => { expect(await screen.findByText(/ontvangen/i)).toBeTruthy(); }); + it('resumes an existing registration on load, without submitting again (S-26)', async () => { + const { post, providers: p } = providers( + undefined, + undefined, + undefined, + vi.fn().mockReturnValue(of({ registrationId: 'reg-77', status: 'Ingediend' })), + ); + await render(RegistrationPage, { providers: p }); + + // The confirmation view is restored from the in-flight registration — no submit click. + expect(await screen.findByText(/ontvangen/i)).toBeTruthy(); + expect(screen.getByText(/reg-77/)).toBeTruthy(); + expect(post).not.toHaveBeenCalled(); + }); + it('shows an error and keeps the submit available when the BFF call fails', async () => { const { post, providers: p } = providers(vi.fn().mockReturnValue(throwError(() => new Error('BFF rejected')))); await render(RegistrationPage, { providers: p }); diff --git a/apps/self-service/src/app/registration/registration-page.ts b/apps/self-service/src/app/registration/registration-page.ts index 7d6a916..b225ba3 100644 --- a/apps/self-service/src/app/registration/registration-page.ts +++ b/apps/self-service/src/app/registration/registration-page.ts @@ -1,5 +1,5 @@ -import { Component, inject, signal } from '@angular/core'; -import { BffApiV1Service, type SubmitAccepted } from 'api-client'; +import { Component, inject, type OnInit, signal } from '@angular/core'; +import { BffApiV1Service, type CurrentRegistration, type SubmitAccepted } from 'api-client'; import { AuthService } from 'auth'; import { UtrechtComponentsModule } from 'ui'; @@ -8,13 +8,16 @@ import { UtrechtComponentsModule } from 'ui'; * 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). After * submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c). + * + * On load it asks the BFF for the caller's current open registration and restores the submitted view + * if there is one, so a page refresh no longer strands an in-flight registration (S-26). */ @Component({ selector: 'app-registration-page', imports: [UtrechtComponentsModule], templateUrl: './registration-page.html', }) -export class RegistrationPage { +export class RegistrationPage implements OnInit { private readonly auth = inject(AuthService); private readonly bff = inject(BffApiV1Service); @@ -31,6 +34,23 @@ export class RegistrationPage { protected readonly provideDocumentsFailed = signal(false); protected readonly selectedFile = signal(undefined); + /** Resume an existing in-flight registration after a refresh (S-26): the BFF returns the caller's + * current open registration, or 204 (empty body) when there is none — in which case we show the + * submit form as before. Failures are non-fatal for the same reason. */ + ngOnInit(): void { + this.bff.getSelfServiceRegistrations().subscribe({ + next: (current: CurrentRegistration | void) => { + if (current && current.registrationId) { + this.reference.set(current.registrationId); + this.submitted.set(true); + } + }, + error: () => { + // No resumable registration (or the lookup failed) — fall back to the submit form. + }, + }); + } + submit(): void { this.submitting.set(true); this.failed.set(false); diff --git a/docs/synthetic-data.md b/docs/synthetic-data.md index c145825..ccdd538 100644 --- a/docs/synthetic-data.md +++ b/docs/synthetic-data.md @@ -14,6 +14,7 @@ All test users share the password **`test123`**. | Realm | Mimics | User | Identifying claim | |---|---|---|---| | `digid` | DigiD (burgers) | `jan-burger` | `bsn` = `123456782` | +| `digid` | DigiD (burgers) | `sanne-burger` | `bsn` = `231477813` (S-26 resume e2e — its own user so it can leave an open registration) | | `eherkenning` | eHerkenning (bedrijven) | `acme-ondernemer` | `kvk` = `12345678` | | `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` | | `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` | diff --git a/infra/keycloak/realms/digid-realm.json b/infra/keycloak/realms/digid-realm.json index 28f1c51..1dd6336 100644 --- a/infra/keycloak/realms/digid-realm.json +++ b/infra/keycloak/realms/digid-realm.json @@ -38,6 +38,36 @@ "emailVerified": true, "credentials": [{ "type": "password", "value": "test123", "temporary": false }], "attributes": { "bsn": ["123456782"] } + }, + { + "username": "sanne-burger", + "enabled": true, + "firstName": "Sanne", + "lastName": "Burger", + "email": "sanne.burger@example.nl", + "emailVerified": true, + "credentials": [{ "type": "password", "value": "test123", "temporary": false }], + "attributes": { "bsn": ["231477813"] } + }, + { + "username": "emma-burger", + "enabled": true, + "firstName": "Emma", + "lastName": "Burger", + "email": "emma.burger@example.nl", + "emailVerified": true, + "credentials": [{ "type": "password", "value": "test123", "temporary": false }], + "attributes": { "bsn": ["231477805"] } + }, + { + "username": "lars-burger", + "enabled": true, + "firstName": "Lars", + "lastName": "Burger", + "email": "lars.burger@example.nl", + "emailVerified": true, + "credentials": [{ "type": "password", "value": "test123", "temporary": false }], + "attributes": { "bsn": ["231477821"] } } ] } diff --git a/libs/api-client/src/lib/generated/bff-api.ts b/libs/api-client/src/lib/generated/bff-api.ts index 3ac28e9..f0775ad 100644 --- a/libs/api-client/src/lib/generated/bff-api.ts +++ b/libs/api-client/src/lib/generated/bff-api.ts @@ -24,6 +24,11 @@ import { Observable } from 'rxjs'; +export interface CurrentRegistration { + registrationId: string; + status: string; +} + export interface DecideRequest { besluit: string; } @@ -200,6 +205,37 @@ export class BffApiV1Service { ); } + getSelfServiceRegistrations( options?: HttpClientBodyOptions): Observable; + getSelfServiceRegistrations( options?: HttpClientEventOptions): Observable>; + getSelfServiceRegistrations( options?: HttpClientResponseOptions): Observable>; + getSelfServiceRegistrations( + options?: HttpClientObserveOptions): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.get( + `/self-service/registrations`,{ + ...(options as Omit, 'observe'>), + observe: 'events', + } + ); + } + + if (options?.observe === 'response') { + return this.http.get( + `/self-service/registrations`,{ + ...(options as Omit, 'observe'>), + observe: 'response', + } + ); + } + + return this.http.get( + `/self-service/registrations`,{ + ...(options as Omit, 'observe'>), + observe: 'body', + } + ); + } + postSelfServiceRegistrationsIdWithdraw(id: string, options?: HttpClientBodyOptions): Observable; postSelfServiceRegistrationsIdWithdraw(id: string, options?: HttpClientEventOptions): Observable>; postSelfServiceRegistrationsIdWithdraw(id: string, options?: HttpClientResponseOptions): Observable>; diff --git a/services/bff/Bff.Api/DownstreamClients.cs b/services/bff/Bff.Api/DownstreamClients.cs index cf0e448..a2d8151 100644 --- a/services/bff/Bff.Api/DownstreamClients.cs +++ b/services/bff/Bff.Api/DownstreamClients.cs @@ -5,6 +5,10 @@ namespace Bff.Api; /// What the self-service submit returns to the portal (the domain's registration id + status). public sealed record SubmitAccepted(string RegistrationId, string Status); +/// The caller's current open registration, for resuming the self-service portal after a +/// refresh (S-26): the reference (registration id) + its status. +public sealed record CurrentRegistration(string RegistrationId, string Status); + /// A projection row as the projection-api serves it. Bsn/NaamPlaceholder are /// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09). /// Reference is the public-safe citizen reference (the zaak identificatie, #78). @@ -22,6 +26,10 @@ public interface IDomainClient { Task SubmitRegistrationAsync(string bsn, CancellationToken ct = default); + /// The caller's current open registration (resume after refresh, S-26), or null + /// when they have none in flight. Owner-scoped by . + Task GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default); + /// Withdraw the caller's own registration ("trek aanvraag in"). Owner-scoped by /// . Returns false 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. @@ -58,6 +66,18 @@ public sealed class DomainClient(HttpClient http) : IDomainClient return new SubmitAccepted(dto.RegistrationId, dto.Status); } + public async Task GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default) + { + using var response = await http.GetAsync($"registrations/current?bsn={Uri.EscapeDataString(bsn)}", ct); + // The domain 404s when the citizen has no open registration — that's "none", not an error. + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + return null; + response.EnsureSuccessStatusCode(); + var dto = await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("The Domain Service returned an empty registration response."); + return new CurrentRegistration(dto.RegistrationId, dto.Status); + } + public async Task WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default) { using var response = await http.PostAsJsonAsync( diff --git a/services/bff/Bff.Api/Program.cs b/services/bff/Bff.Api/Program.cs index f2a660a..8f7df13 100644 --- a/services/bff/Bff.Api/Program.cs +++ b/services/bff/Bff.Api/Program.cs @@ -86,6 +86,24 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC .Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status401Unauthorized); +// Self-service resume (S-26): the signed-in zorgprofessional's current open registration, so the +// portal can restore its reference + actions after a page refresh. The bsn comes from the DigiD token; +// 204 when the citizen has none in flight (so the portal shows the submit form). +app.MapGet("/self-service/registrations", async (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 current = await domain.GetCurrentRegistrationAsync(bsn, ct); + return current is null ? Results.NoContent() : Results.Ok(current); +}) + .RequireAuthorization() + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status204NoContent) + .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). diff --git a/services/bff/Bff.Tests/BffFactory.cs b/services/bff/Bff.Tests/BffFactory.cs index e1b5132..7985ba1 100644 --- a/services/bff/Bff.Tests/BffFactory.cs +++ b/services/bff/Bff.Tests/BffFactory.cs @@ -82,6 +82,18 @@ internal sealed class FakeDomainClient : IDomainClient return Task.FromResult(Result); } + public string? CurrentQueriedBsn { get; private set; } + + /// The current open registration the fake domain returns (null → the citizen has none in + /// flight, so the BFF replies 204). Tests set this to exercise resume. + public CurrentRegistration? Current { get; set; } + + public Task GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default) + { + CurrentQueriedBsn = bsn; + return Task.FromResult(Current); + } + public (string RegistrationId, string Bsn)? Withdrawn { get; private set; } /// Whether the fake domain reports the withdrawal as done (true → 204) or not-found/not-owned diff --git a/services/bff/Bff.Tests/SelfServiceEndpointTests.cs b/services/bff/Bff.Tests/SelfServiceEndpointTests.cs index 3b23b7b..9b855ba 100644 --- a/services/bff/Bff.Tests/SelfServiceEndpointTests.cs +++ b/services/bff/Bff.Tests/SelfServiceEndpointTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; +using Bff.Api; namespace Bff.Tests; @@ -168,5 +169,52 @@ public class SelfServiceEndpointTests Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } + private static HttpRequestMessage Current(string? bearer) + { + var request = new HttpRequestMessage(HttpMethod.Get, "/self-service/registrations"); + if (bearer is not null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer); + return request; + } + + [Fact] + public async Task Rejects_the_current_registration_lookup_without_a_token() + { + using var factory = new BffFactory(); + + var response = await factory.CreateClient().SendAsync(Current(bearer: null)); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Returns_no_content_when_the_caller_has_no_open_registration() + { + using var factory = new BffFactory(); + factory.Domain.Current = null; + + var response = await factory.CreateClient().SendAsync(Current(TestTokens.Valid("123456782"))); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal("123456782", factory.Domain.CurrentQueriedBsn); + } + + [Fact] + public async Task Returns_the_callers_current_registration_when_one_is_open() + { + using var factory = new BffFactory(); + factory.Domain.Current = new CurrentRegistration("reg-77", "Ingediend"); + + var response = await factory.CreateClient().SendAsync(Current(TestTokens.Valid("123456782"))); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("123456782", factory.Domain.CurrentQueriedBsn); + var body = await response.Content.ReadFromJsonAsync(); + Assert.Equal("reg-77", body!.RegistrationId); + Assert.Equal("Ingediend", body.Status); + } + private sealed record SubmitAcceptedDto(string RegistrationId, string Status); + + private sealed record CurrentRegistrationDto(string RegistrationId, string Status); } diff --git a/services/bff/openapi.json b/services/bff/openapi.json index 02359e1..a212961 100644 --- a/services/bff/openapi.json +++ b/services/bff/openapi.json @@ -28,6 +28,32 @@ "description": "Unauthorized" } } + }, + "get": { + "tags": [ + "Bff.Api" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CurrentRegistration" + } + } + } + }, + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request" + }, + "401": { + "description": "Unauthorized" + } + } } }, "/self-service/registrations/{id}/withdraw": { @@ -205,6 +231,21 @@ }, "components": { "schemas": { + "CurrentRegistration": { + "required": [ + "registrationId", + "status" + ], + "type": "object", + "properties": { + "registrationId": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, "DecideRequest": { "required": [ "besluit" diff --git a/services/domain/Big.Api/Program.cs b/services/domain/Big.Api/Program.cs index 56d6791..5d0db51 100644 --- a/services/domain/Big.Api/Program.cs +++ b/services/domain/Big.Api/Program.cs @@ -141,6 +141,21 @@ app.MapGet("/behandel/werkbak", async (Werkbak werkbak, CancellationToken ct) => Results.Ok(await werkbak.GetAsync(ct))); // Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually). +// The citizen's current open registration, looked up by bsn — lets the self-service portal resume +// after a refresh (S-26). The BFF forwards the bsn from the DigiD token; the domain trusts its +// callers (§8.3). 404 when the citizen has none in flight. +app.MapGet("/registrations/current", async (string bsn, IRegistrationStore store, CancellationToken ct) => +{ + if (string.IsNullOrWhiteSpace(bsn)) + return Results.BadRequest("A bsn is required."); + + var registration = await store.FindOpenByBsnAsync(bsn, ct); + return registration is null + ? Results.NotFound() + : Results.Ok(new RegistrationResponse( + registration.Id.ToString(), registration.Status.ToString(), registration.ZaakUrl?.ToString())); +}); + app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) => { if (!Guid.TryParse(id, out var guid)) diff --git a/services/domain/Big.Application/Ports.cs b/services/domain/Big.Application/Ports.cs index 1a88018..08fdbde 100644 --- a/services/domain/Big.Application/Ports.cs +++ b/services/domain/Big.Application/Ports.cs @@ -102,6 +102,11 @@ public interface IRegistrationStore /// Load a registration by id, or null if none exists. Task GetAsync(RegistrationId id, CancellationToken ct = default); + + /// The citizen's current open (non-terminal: INGEDIEND/IN_BEHANDELING) + /// registration, or null if they have none in flight. Lets the self-service portal resume + /// an existing registration after a refresh (S-26); terminal registrations are not resumed. + Task FindOpenByBsnAsync(string bsn, CancellationToken ct = default); } /// diff --git a/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs b/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs index 0acfe94..086dc81 100644 --- a/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs +++ b/services/domain/Big.Infrastructure/InMemoryRegistrationStore.cs @@ -22,4 +22,8 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore public Task GetAsync(RegistrationId id, CancellationToken ct = default) => Task.FromResult(_byId.GetValueOrDefault(id)); + + public Task FindOpenByBsnAsync(string bsn, CancellationToken ct = default) + => Task.FromResult(_byId.Values.FirstOrDefault(r => + r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling)); } diff --git a/services/domain/Big.Tests/Fakes.cs b/services/domain/Big.Tests/Fakes.cs index de2a029..c9ff30f 100644 --- a/services/domain/Big.Tests/Fakes.cs +++ b/services/domain/Big.Tests/Fakes.cs @@ -22,6 +22,10 @@ internal sealed class FakeRegistrationStore : IRegistrationStore public Task GetAsync(RegistrationId id, CancellationToken ct = default) => Task.FromResult(_byId.GetValueOrDefault(id)); + public Task FindOpenByBsnAsync(string bsn, CancellationToken ct = default) + => Task.FromResult(_byId.Values.FirstOrDefault(r => + r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling)); + public void Seed(Registration registration) => _byId[registration.Id] = registration; } diff --git a/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs b/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs index 1a63c57..14be96e 100644 --- a/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs +++ b/services/domain/Big.Tests/InMemoryRegistrationStoreTests.cs @@ -41,4 +41,59 @@ public class InMemoryRegistrationStoreTests await Assert.ThrowsAsync(() => store.SaveAsync(null!)); } + + [Fact] + public async Task Finds_the_open_registration_for_a_bsn() + { + var store = new InMemoryRegistrationStore(); + var open = Registration.Submit("123456782"); + await store.SaveAsync(open); + + var found = await store.FindOpenByBsnAsync("123456782"); + + Assert.NotNull(found); + Assert.Equal(open.Id, found.Id); + } + + [Fact] + public async Task An_in_behandeling_registration_is_still_open() + { + var store = new InMemoryRegistrationStore(); + var registration = Registration.Submit("123456782"); + registration.TakeIntoBehandeling(); + await store.SaveAsync(registration); + + Assert.NotNull(await store.FindOpenByBsnAsync("123456782")); + } + + [Theory] + [InlineData(nameof(Registration.Withdraw))] + [InlineData(nameof(Registration.Approve))] + [InlineData(nameof(Registration.Reject))] + [InlineData(nameof(Registration.Expire))] + public async Task A_terminal_registration_is_not_returned_as_open(string transition) + { + var store = new InMemoryRegistrationStore(); + var registration = Registration.Submit("123456782"); + registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); // Approve requires an opened zaak + switch (transition) + { + case nameof(Registration.Withdraw): registration.Withdraw(); break; + case nameof(Registration.Approve): registration.Approve(); break; + case nameof(Registration.Reject): registration.Reject(); break; + case nameof(Registration.Expire): registration.Expire(); break; + } + await store.SaveAsync(registration); + + Assert.Null(await store.FindOpenByBsnAsync("123456782")); + } + + [Fact] + public async Task Does_not_return_another_bsns_registration_or_an_unknown_bsn() + { + var store = new InMemoryRegistrationStore(); + await store.SaveAsync(Registration.Submit("111111110")); + + Assert.Null(await store.FindOpenByBsnAsync("123456782")); + } } diff --git a/tests/acceptance/Support/BffAcceptanceHost.cs b/tests/acceptance/Support/BffAcceptanceHost.cs index 75f492f..f6d95b0 100644 --- a/tests/acceptance/Support/BffAcceptanceHost.cs +++ b/tests/acceptance/Support/BffAcceptanceHost.cs @@ -69,6 +69,9 @@ public sealed class CapturingDomainClient : IDomainClient return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend")); } + public Task GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default) + => Task.FromResult(null); + public Task WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default) => Task.FromResult(true); diff --git a/tests/acceptance/Support/InMemoryDomainPorts.cs b/tests/acceptance/Support/InMemoryDomainPorts.cs index 0400d19..72165ed 100644 --- a/tests/acceptance/Support/InMemoryDomainPorts.cs +++ b/tests/acceptance/Support/InMemoryDomainPorts.cs @@ -217,4 +217,8 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore public Task GetAsync(RegistrationId id, CancellationToken ct = default) => Task.FromResult(_byId.GetValueOrDefault(id)); + + public Task FindOpenByBsnAsync(string bsn, CancellationToken ct = default) + => Task.FromResult(_byId.Values.FirstOrDefault(r => + r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling)); } diff --git a/tests/e2e/registration.spec.ts b/tests/e2e/registration.spec.ts index 8274ecb..5a767bf 100644 --- a/tests/e2e/registration.spec.ts +++ b/tests/e2e/registration.spec.ts @@ -13,8 +13,11 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt // Visiting the guarded page redirects to the Keycloak (mock DigiD) login. await page.goto('/'); - // Keycloak's default login form (stable ids across themes). - await page.locator('#username').fill('jan-burger'); + // Keycloak's default login form (stable ids across themes). Its own DigiD user: the verify-* API + // checks submit as jan-burger (bsn 123456782) before the e2e runs on the shared stack, and + // resume-on-load (S-26) would otherwise restore one of those on login — so each self-service spec + // uses a dedicated citizen no other actor touches. + await page.locator('#username').fill('emma-burger'); await page.locator('#password').fill('test123'); await page.locator('#kc-login').click(); diff --git a/tests/e2e/resume.spec.ts b/tests/e2e/resume.spec.ts new file mode 100644 index 0000000..cf749e0 --- /dev/null +++ b/tests/e2e/resume.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test'; + +// S-26: a zorgprofessional submits, then reloads the self-service portal. On load the portal asks the +// BFF for the caller's current open registration (owner-scoped by the DigiD token's bsn) and restores +// the submitted view — so a refresh no longer strands the in-flight registration and its actions. +test('DigiD submit → reload → self-service restores the existing registration', async ({ page }) => { + await page.goto('/'); + + // Its own DigiD user (like every self-service spec): on the shared verify stack, resume-on-load + // (S-26) restores any open registration for the bsn, so each spec uses a dedicated citizen that no + // other spec or verify-* check touches. This one in particular leaves an open registration. + await page.locator('#username').fill('sanne-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(); + + const confirmation = page.getByText(/ontvangen/i); + await expect(confirmation).toBeVisible(); + const reference = (await confirmation.textContent())?.match(/Referentie:\s*([0-9a-fA-F-]+)/)?.[1]; + expect(reference, 'the confirmation shows a registration reference').toBeTruthy(); + + // Reload: the component's in-memory submitted state is gone, but the DigiD session persists and the + // portal resumes from the BFF instead of dropping back to the blank submit form. + await page.reload(); + + await expect(page.getByText(/ontvangen/i)).toBeVisible(); + // The same reference the citizen saw before the reload is restored... + await expect(page.getByText(new RegExp(reference!))).toBeVisible(); + // ...and its actions are reachable again (e.g. "trek aanvraag in"). + await expect(page.getByRole('button', { name: /trek aanvraag in/i })).toBeVisible(); +}); diff --git a/tests/e2e/withdrawal.spec.ts b/tests/e2e/withdrawal.spec.ts index 7b8336f..a13d8f1 100644 --- a/tests/e2e/withdrawal.spec.ts +++ b/tests/e2e/withdrawal.spec.ts @@ -8,7 +8,9 @@ test('DigiD submit → trek aanvraag in → self-service confirms ingetrokken', // Visiting the guarded page redirects to the Keycloak (mock DigiD) login. await page.goto('/'); - await page.locator('#username').fill('jan-burger'); + // Its own DigiD user — isolated from the verify-* checks (jan-burger/123456782) so resume-on-load + // (S-26) can't restore someone else's registration on the shared stack. + await page.locator('#username').fill('lars-burger'); await page.locator('#password').fill('test123'); await page.locator('#kc-login').click();