Files
register-referentie/services/bff/Bff.Tests/SelfServiceEndpointTests.cs
T
not 4777ff2b1d
CI / build (push) Successful in 1m1s
CI / unit (push) Successful in 1m11s
CI / frontend (push) Successful in 2m33s
CI / mutation (push) Successful in 5m14s
CI / verify-stack (push) Successful in 7m37s
CI / lint (push) Successful in 1m17s
feat(workflow): document-wait task + 30-day timeout cancellation (S-10a, closes #102) (#105)
## What & why

S-10a, the **workflow/timeout spine** of the (split) document-upload slice: the registratie process
now parks at a **`WachtOpDocumenten`** user task with an **interrupting `P30D` boundary timer**. When
the documents arrive the task completes and the process continues into the diploma routing (S-13) →
Beoordelen; if the 30 days lapse, the timer cancels the wait, runs a `RegistratieVerlopen`
external-worker task, and the domain expires the aggregate to a new terminal status **`Verlopen`**.
Backend only — the real upload trigger (portal → BFF → ACL → Documenten API) is S-10b (#103).

Closes #102

Mechanism recorded in **ADR-0017**; opened as proposal #104. Mirrors the S-14 escalation
(boundary-timer + external-worker) and S-11 withdrawal (interrupting cancel) patterns.

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation (red→green pairs per layer).
- [x] Implementation makes the test pass.
- [x] Conventional Commits referencing the issue (`refs #102`).
- [ ] CI green — all Gitea Actions jobs (pending on this PR).
- [x] `docker compose up` health unaffected (no new services; deploy path unchanged).
- [x] Docs updated (ADR-0017, demo-script, BACKLOG split).
- [x] ADR added (`docs/architecture/adr-0017-document-wait-timeout-cancellation.md`).
- [x] Demo note in `docs/demo-script.md`.

## Notes for reviewers

- **Domain** (`Registration.Expire()` + `Verlopen`), **application** (`ExpireRegistrationWorker`),
  **infra** (`RegistratieVerlopenProcessor`/`Pump`, `IRegistratieVerlopenClient`, Flowable
  acquire/complete + `CompleteDocumentWaitAsync`) — the timeout counterpart to the OpenZaak/escalation
  worker trios; idempotent per §8.6.
- **BPMN** verified live against a `flowable-rest` probe: complete `WachtOpDocumenten` → routes to
  Beoordelen; fire the P30D timer → `RegistratieVerlopen` job (carrying `registrationId`) + the wait
  task cancelled. `verify-domain` exercises both branches in-stack (completes the wait in every existing
  block; fires the timer and asserts `Verlopen` in a new block).
- **Scope boundary:** on expiry the aggregate goes `Verlopen` and the process ends, but the ZGW *zaak*
  is not yet set to a cancellation status — that needs a new ACL method + statustype seeding and is
  folded into S-10b (noted in ADR-0017).
- `CompleteDocumentWaitAsync` is built and HTTP-tested here but not yet called from a domain endpoint;
  S-10b wires the upload trigger to it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #105
2026-07-20 09:42:02 +00:00

158 lines
5.4 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
namespace Bff.Tests;
public class SelfServiceEndpointTests
{
private static HttpRequestMessage Submit(string? bearer)
{
var request = new HttpRequestMessage(HttpMethod.Post, "/self-service/registrations")
{
Content = JsonContent.Create(new { }),
};
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_a_request_without_a_token()
{
using var factory = new BffFactory();
var client = factory.CreateClient();
var response = await client.SendAsync(Submit(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Null(factory.Domain.SubmittedBsn);
}
[Theory]
[InlineData("not-a-jwt")]
public async Task Rejects_a_malformed_token(string bearer)
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Submit(bearer));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Rejects_a_token_signed_with_the_wrong_key()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Submit(TestTokens.WrongKey("123456782")));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Rejects_an_expired_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Submit(TestTokens.Expired("123456782")));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Accepts_a_valid_token_and_forwards_the_bsn_to_the_domain()
{
using var factory = new BffFactory();
var client = factory.CreateClient();
var response = await client.SendAsync(Submit(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
Assert.Equal("123456782", factory.Domain.SubmittedBsn);
var body = await response.Content.ReadFromJsonAsync<SubmitAcceptedDto>();
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 static HttpRequestMessage ProvideDocuments(string? bearer, string id = "reg-123")
{
var request = new HttpRequestMessage(HttpMethod.Post, $"/self-service/registrations/{id}/documents");
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_providing_documents_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(ProvideDocuments(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Null(factory.Domain.DocumentsProvidedFor);
}
[Fact]
public async Task Provides_documents_for_the_callers_registration_forwarding_the_id_and_bsn()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782"), "reg-9"));
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal(("reg-9", "123456782"), factory.Domain.DocumentsProvidedFor);
}
[Fact]
public async Task Relays_not_found_providing_documents_for_an_unknown_or_not_owned_registration()
{
using var factory = new BffFactory();
factory.Domain.ProvideDocumentsSucceeds = false;
var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
}