feat(bff): owner-scoped self-service withdraw endpoint (S-11c-1, refs #12) #90

Merged
not merged 3 commits from feat/12-withdrawal-bff into main 2026-07-16 12:20:44 +00:00
3 changed files with 90 additions and 14 deletions
Showing only changes of commit 248f57024e - Show all commits

View File

@@ -82,6 +82,18 @@ internal sealed class FakeDomainClient : IDomainClient
return Task.FromResult(Result); return Task.FromResult(Result);
} }
public (string RegistrationId, string Bsn)? Withdrawn { get; private set; }
/// <summary>Whether the fake domain reports the withdrawal as done (true → 204) or not-found/not-owned
/// (false → 404). Tests set this to exercise the relay.</summary>
public bool WithdrawSucceeds { get; set; } = true;
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
{
Withdrawn = (registrationId, bsn);
return Task.FromResult(WithdrawSucceeds);
}
public (string RegistrationId, string Besluit)? Decided { get; private set; } public (string RegistrationId, string Besluit)? Decided { get; private set; }
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default) public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)

View File

@@ -71,5 +71,46 @@ public class SelfServiceEndpointTests
Assert.Equal("reg-123", body!.RegistrationId); Assert.Equal("reg-123", body!.RegistrationId);
} }
private static HttpRequestMessage Withdraw(string? bearer, string id = "reg-123")
{
var request = new HttpRequestMessage(HttpMethod.Post, $"/self-service/registrations/{id}/withdraw");
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_a_withdrawal_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Withdraw(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Null(factory.Domain.Withdrawn);
}
[Fact]
public async Task Withdraws_the_callers_registration_forwarding_the_id_and_bsn()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Withdraw(TestTokens.Valid("123456782"), "reg-9"));
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal(("reg-9", "123456782"), factory.Domain.Withdrawn);
}
[Fact]
public async Task Relays_not_found_when_the_registration_is_unknown_or_not_the_callers()
{
using var factory = new BffFactory();
factory.Domain.WithdrawSucceeds = false;
var response = await factory.CreateClient().SendAsync(Withdraw(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
private sealed record SubmitAcceptedDto(string RegistrationId, string Status); private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
} }

View File

@@ -5,13 +5,17 @@ namespace Big.Tests;
public class WithdrawRegistrationTests public class WithdrawRegistrationTests
{ {
private const string Bsn = "123456782";
private static Registration Submitted(string processInstanceId = "proc-1") private static Registration Submitted(string processInstanceId = "proc-1")
{ {
var registration = Registration.Submit("123456782"); var registration = Registration.Submit(Bsn);
registration.RecordProcessStarted(processInstanceId); registration.RecordProcessStarted(processInstanceId);
return registration; return registration;
} }
private static WithdrawRegistrationCommand Command(RegistrationId id, string bsn = Bsn) => new(id, bsn);
[Fact] [Fact]
public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it() public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it()
{ {
@@ -20,8 +24,9 @@ public class WithdrawRegistrationTests
store.Seed(registration); store.Seed(registration);
var handler = new WithdrawRegistration(store, new FakeWorkflowClient()); var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); var outcome = await handler.HandleAsync(Command(registration.Id));
Assert.Equal(WithdrawOutcome.Withdrawn, outcome);
var saved = await store.GetAsync(registration.Id); var saved = await store.GetAsync(registration.Id);
Assert.Equal(RegistrationStatus.Ingetrokken, saved!.Status); Assert.Equal(RegistrationStatus.Ingetrokken, saved!.Status);
Assert.Equal(1, store.SaveCount); Assert.Equal(1, store.SaveCount);
@@ -36,7 +41,7 @@ public class WithdrawRegistrationTests
var workflow = new FakeWorkflowClient(); var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow); var handler = new WithdrawRegistration(store, workflow);
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); await handler.HandleAsync(Command(registration.Id));
// The process the registration recorded at submit is cancelled (ADR-0014). // The process the registration recorded at submit is cancelled (ADR-0014).
Assert.Equal("proc-42", workflow.WithdrawnProcessInstanceId); Assert.Equal("proc-42", workflow.WithdrawnProcessInstanceId);
@@ -45,20 +50,37 @@ public class WithdrawRegistrationTests
[Fact] [Fact]
public async Task Withdrawing_before_a_process_was_started_still_marks_ingetrokken() public async Task Withdrawing_before_a_process_was_started_still_marks_ingetrokken()
{ {
// A registration with no recorded process (e.g. start never happened) can still be withdrawn;
// there is simply no process to cancel.
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var registration = Registration.Submit("123456782"); // no RecordProcessStarted var registration = Registration.Submit(Bsn); // no RecordProcessStarted
store.Seed(registration); store.Seed(registration);
var workflow = new FakeWorkflowClient(); var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow); var handler = new WithdrawRegistration(store, workflow);
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); await handler.HandleAsync(Command(registration.Id));
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status); Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
Assert.Null(workflow.WithdrawnProcessInstanceId); Assert.Null(workflow.WithdrawnProcessInstanceId);
} }
[Fact]
public async Task A_different_bsn_cannot_withdraw_the_registration()
{
// Owner-scoping: only the zorgprofessional who submitted may withdraw. Another bsn is told
// NotFound (we don't reveal the registration exists) and nothing is changed.
var store = new FakeRegistrationStore();
var registration = Submitted();
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow);
var outcome = await handler.HandleAsync(Command(registration.Id, bsn: "999999990"));
Assert.Equal(WithdrawOutcome.NotFound, outcome);
Assert.Equal(RegistrationStatus.Ingediend, (await store.GetAsync(registration.Id))!.Status);
Assert.Equal(0, store.SaveCount);
Assert.Null(workflow.WithdrawnProcessInstanceId);
}
[Fact] [Fact]
public async Task Rejects_a_null_command_without_touching_the_store() public async Task Rejects_a_null_command_without_touching_the_store()
{ {
@@ -70,14 +92,14 @@ public class WithdrawRegistrationTests
} }
[Fact] [Fact]
public async Task Withdrawing_an_unknown_registration_throws() public async Task Withdrawing_an_unknown_registration_is_not_found()
{ {
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var handler = new WithdrawRegistration(store, new FakeWorkflowClient()); var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
var ex = await Assert.ThrowsAsync<InvalidOperationException>( var outcome = await handler.HandleAsync(Command(RegistrationId.New()));
() => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New())));
Assert.Contains("No registration", ex.Message); Assert.Equal(WithdrawOutcome.NotFound, outcome);
Assert.Equal(0, store.SaveCount); Assert.Equal(0, store.SaveCount);
} }
@@ -90,10 +112,11 @@ public class WithdrawRegistrationTests
var workflow = new FakeWorkflowClient(); var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow); var handler = new WithdrawRegistration(store, workflow);
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); await handler.HandleAsync(Command(registration.Id));
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); var second = await handler.HandleAsync(Command(registration.Id));
// The second withdrawal is a no-op: the aggregate is not persisted again. // The second withdrawal is a no-op: still Withdrawn, but the aggregate is not persisted again.
Assert.Equal(WithdrawOutcome.Withdrawn, second);
Assert.Equal(1, store.SaveCount); Assert.Equal(1, store.SaveCount);
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status); Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
} }