feat: self-service resume of an existing registration after refresh (S-26, closes #111) #119
@@ -21,16 +21,20 @@ function providers(
|
|||||||
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||||
withdraw = vi.fn().mockReturnValue(of(undefined)),
|
withdraw = vi.fn().mockReturnValue(of(undefined)),
|
||||||
provideDocuments = 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 {
|
return {
|
||||||
post,
|
post,
|
||||||
withdraw,
|
withdraw,
|
||||||
provideDocuments,
|
provideDocuments,
|
||||||
|
getCurrent,
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: AuthService, useClass: FakeAuth },
|
{ provide: AuthService, useClass: FakeAuth },
|
||||||
{
|
{
|
||||||
provide: BffApiV1Service,
|
provide: BffApiV1Service,
|
||||||
useValue: {
|
useValue: {
|
||||||
|
getSelfServiceRegistrations: getCurrent,
|
||||||
postSelfServiceRegistrations: post,
|
postSelfServiceRegistrations: post,
|
||||||
postSelfServiceRegistrationsIdWithdraw: withdraw,
|
postSelfServiceRegistrationsIdWithdraw: withdraw,
|
||||||
postSelfServiceRegistrationsIdDocuments: provideDocuments,
|
postSelfServiceRegistrationsIdDocuments: provideDocuments,
|
||||||
@@ -56,6 +60,21 @@ describe('RegistrationPage', () => {
|
|||||||
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
|
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 () => {
|
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'))));
|
const { post, providers: p } = providers(vi.fn().mockReturnValue(throwError(() => new Error('BFF rejected'))));
|
||||||
await render(RegistrationPage, { providers: p });
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, inject, type OnInit, signal } from '@angular/core';
|
||||||
import { BffApiV1Service, type SubmitAccepted } from 'api-client';
|
import { BffApiV1Service, type CurrentRegistration, type SubmitAccepted } from 'api-client';
|
||||||
import { AuthService } from 'auth';
|
import { AuthService } from 'auth';
|
||||||
import { UtrechtComponentsModule } from 'ui';
|
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-
|
* 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
|
* 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).
|
* 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({
|
@Component({
|
||||||
selector: 'app-registration-page',
|
selector: 'app-registration-page',
|
||||||
imports: [UtrechtComponentsModule],
|
imports: [UtrechtComponentsModule],
|
||||||
templateUrl: './registration-page.html',
|
templateUrl: './registration-page.html',
|
||||||
})
|
})
|
||||||
export class RegistrationPage {
|
export class RegistrationPage implements OnInit {
|
||||||
private readonly auth = inject(AuthService);
|
private readonly auth = inject(AuthService);
|
||||||
private readonly bff = inject(BffApiV1Service);
|
private readonly bff = inject(BffApiV1Service);
|
||||||
|
|
||||||
@@ -31,6 +34,23 @@ export class RegistrationPage {
|
|||||||
protected readonly provideDocumentsFailed = signal(false);
|
protected readonly provideDocumentsFailed = signal(false);
|
||||||
protected readonly selectedFile = signal<File | undefined>(undefined);
|
protected readonly selectedFile = signal<File | undefined>(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 {
|
submit(): void {
|
||||||
this.submitting.set(true);
|
this.submitting.set(true);
|
||||||
this.failed.set(false);
|
this.failed.set(false);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ All test users share the password **`test123`**.
|
|||||||
| Realm | Mimics | User | Identifying claim |
|
| Realm | Mimics | User | Identifying claim |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `digid` | DigiD (burgers) | `jan-burger` | `bsn` = `123456782` |
|
| `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` |
|
| `eherkenning` | eHerkenning (bedrijven) | `acme-ondernemer` | `kvk` = `12345678` |
|
||||||
| `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` |
|
| `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` |
|
||||||
| `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` |
|
| `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` |
|
||||||
|
|||||||
@@ -38,6 +38,36 @@
|
|||||||
"emailVerified": true,
|
"emailVerified": true,
|
||||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||||
"attributes": { "bsn": ["123456782"] }
|
"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"] }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ import {
|
|||||||
Observable
|
Observable
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
|
|
||||||
|
export interface CurrentRegistration {
|
||||||
|
registrationId: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DecideRequest {
|
export interface DecideRequest {
|
||||||
besluit: string;
|
besluit: string;
|
||||||
}
|
}
|
||||||
@@ -200,6 +205,37 @@ export class BffApiV1Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientBodyOptions): Observable<TData>;
|
||||||
|
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||||
|
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||||
|
getSelfServiceRegistrations<TData = CurrentRegistration | void>(
|
||||||
|
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||||
|
if (options?.observe === 'events') {
|
||||||
|
return this.http.get<TData>(
|
||||||
|
`/self-service/registrations`,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'events',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.observe === 'response') {
|
||||||
|
return this.http.get<TData>(
|
||||||
|
`/self-service/registrations`,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'response',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.get<TData>(
|
||||||
|
`/self-service/registrations`,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'body',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
|
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?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||||
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ namespace Bff.Api;
|
|||||||
/// <summary>What the self-service submit returns to the portal (the domain's registration id + status).</summary>
|
/// <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);
|
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
|
/// <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).
|
/// 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>
|
/// <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);
|
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
|
/// <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
|
/// <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>
|
/// 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);
|
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)
|
public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
using var response = await http.PostAsJsonAsync(
|
using var response = await http.PostAsJsonAsync(
|
||||||
|
|||||||
@@ -86,6 +86,24 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
|
|||||||
.Produces(StatusCodes.Status400BadRequest)
|
.Produces(StatusCodes.Status400BadRequest)
|
||||||
.Produces(StatusCodes.Status401Unauthorized);
|
.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.
|
// 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;
|
// 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).
|
// 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);
|
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; }
|
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
|
/// <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;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using Bff.Api;
|
||||||
|
|
||||||
namespace Bff.Tests;
|
namespace Bff.Tests;
|
||||||
|
|
||||||
@@ -168,5 +169,52 @@ public class SelfServiceEndpointTests
|
|||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
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 SubmitAcceptedDto(string RegistrationId, string Status);
|
||||||
|
|
||||||
|
private sealed record CurrentRegistrationDto(string RegistrationId, string Status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,32 @@
|
|||||||
"description": "Unauthorized"
|
"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": {
|
"/self-service/registrations/{id}/withdraw": {
|
||||||
@@ -205,6 +231,21 @@
|
|||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"CurrentRegistration": {
|
||||||
|
"required": [
|
||||||
|
"registrationId",
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"registrationId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"DecideRequest": {
|
"DecideRequest": {
|
||||||
"required": [
|
"required": [
|
||||||
"besluit"
|
"besluit"
|
||||||
|
|||||||
@@ -141,6 +141,21 @@ app.MapGet("/behandel/werkbak", async (Werkbak werkbak, CancellationToken ct) =>
|
|||||||
Results.Ok(await werkbak.GetAsync(ct)));
|
Results.Ok(await werkbak.GetAsync(ct)));
|
||||||
|
|
||||||
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
|
// 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) =>
|
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
if (!Guid.TryParse(id, out var guid))
|
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>
|
/// <summary>Load a registration by id, or <c>null</c> if none exists.</summary>
|
||||||
Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default);
|
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>
|
/// <summary>
|
||||||
|
|||||||
@@ -22,4 +22,8 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore
|
|||||||
|
|
||||||
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
||||||
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
=> 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)
|
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
||||||
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
=> 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;
|
public void Seed(Registration registration) => _byId[registration.Id] = registration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,4 +41,59 @@ public class InMemoryRegistrationStoreTests
|
|||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => store.SaveAsync(null!));
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ public sealed class CapturingDomainClient : IDomainClient
|
|||||||
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
|
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<CurrentRegistration?>(null);
|
||||||
|
|
||||||
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||||
=> Task.FromResult(true);
|
=> Task.FromResult(true);
|
||||||
|
|
||||||
|
|||||||
@@ -217,4 +217,8 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore
|
|||||||
|
|
||||||
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
||||||
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
=> 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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
|
|||||||
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
// Keycloak's default login form (stable ids across themes).
|
// Keycloak's default login form (stable ids across themes). Its own DigiD user: the verify-* API
|
||||||
await page.locator('#username').fill('jan-burger');
|
// 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('#password').fill('test123');
|
||||||
await page.locator('#kc-login').click();
|
await page.locator('#kc-login').click();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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.
|
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
||||||
await page.goto('/');
|
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('#password').fill('test123');
|
||||||
await page.locator('#kc-login').click();
|
await page.locator('#kc-login').click();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user