## 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: #119
This commit was merged in pull request #119.
This commit is contained in:
@@ -5,6 +5,10 @@ namespace Bff.Api;
|
||||
/// <summary>What the self-service submit returns to the portal (the domain's registration id + status).</summary>
|
||||
public sealed record SubmitAccepted(string RegistrationId, string Status);
|
||||
|
||||
/// <summary>The caller's current open registration, for resuming the self-service portal after a
|
||||
/// refresh (S-26): the reference (registration id) + its status.</summary>
|
||||
public sealed record CurrentRegistration(string RegistrationId, string Status);
|
||||
|
||||
/// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are
|
||||
/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).
|
||||
/// <c>Reference</c> is the public-safe citizen reference (the zaak identificatie, #78).</summary>
|
||||
@@ -22,6 +26,10 @@ public interface IDomainClient
|
||||
{
|
||||
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The caller's current open registration (resume after refresh, S-26), or <c>null</c>
|
||||
/// when they have none in flight. Owner-scoped by <paramref name="bsn"/>.</summary>
|
||||
Task<CurrentRegistration?> GetCurrentRegistrationAsync(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>
|
||||
@@ -58,6 +66,18 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
|
||||
return new SubmitAccepted(dto.RegistrationId, dto.Status);
|
||||
}
|
||||
|
||||
public async Task<CurrentRegistration?> 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<DomainResponse>(ct)
|
||||
?? throw new InvalidOperationException("The Domain Service returned an empty registration response.");
|
||||
return new CurrentRegistration(dto.RegistrationId, dto.Status);
|
||||
}
|
||||
|
||||
public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||
{
|
||||
using var response = await http.PostAsJsonAsync(
|
||||
|
||||
@@ -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<CurrentRegistration>(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).
|
||||
|
||||
@@ -82,6 +82,18 @@ internal sealed class FakeDomainClient : IDomainClient
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
|
||||
public string? CurrentQueriedBsn { get; private set; }
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public CurrentRegistration? Current { get; set; }
|
||||
|
||||
public Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default)
|
||||
{
|
||||
CurrentQueriedBsn = bsn;
|
||||
return Task.FromResult(Current);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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<CurrentRegistrationDto>();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -102,6 +102,11 @@ public interface IRegistrationStore
|
||||
|
||||
/// <summary>Load a registration by id, or <c>null</c> if none exists.</summary>
|
||||
Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The citizen's current <em>open</em> (non-terminal: INGEDIEND/IN_BEHANDELING)
|
||||
/// registration, or <c>null</c> 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.</summary>
|
||||
Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -22,4 +22,8 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore
|
||||
|
||||
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
||||
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
||||
|
||||
public Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default)
|
||||
=> Task.FromResult(_byId.Values.FirstOrDefault(r =>
|
||||
r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling));
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ internal sealed class FakeRegistrationStore : IRegistrationStore
|
||||
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
||||
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
||||
|
||||
public Task<Registration?> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,4 +41,59 @@ public class InMemoryRegistrationStoreTests
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => 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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user