From fd77aff8c0d7177ca4ab418650204b2e2c916dcc Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 2 Oct 2026 09:25:39 +0200 Subject: [PATCH] test(bff): relay a refused diploma as 422 with its reason, a down scanner as 503 (refs #192) Co-Authored-By: Claude Opus 5.5 (1M context) --- services/bff/Bff.Api/DownstreamClients.cs | 15 +++++---- services/bff/Bff.Api/Program.cs | 2 +- services/bff/Bff.Tests/BffFactory.cs | 9 +++--- .../DomainClientProvideDocumentsTests.cs | 32 +++++++++++++++++++ .../bff/Bff.Tests/SelfServiceEndpointTests.cs | 29 ++++++++++++++++- 5 files changed, 74 insertions(+), 13 deletions(-) create mode 100644 services/bff/Bff.Tests/DomainClientProvideDocumentsTests.cs diff --git a/services/bff/Bff.Api/DownstreamClients.cs b/services/bff/Bff.Api/DownstreamClients.cs index 8696b66..8ca730d 100644 --- a/services/bff/Bff.Api/DownstreamClients.cs +++ b/services/bff/Bff.Api/DownstreamClients.cs @@ -36,9 +36,9 @@ public interface IDomainClient Task WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default); /// Provide (upload) the diploma the caller's own registration is waiting for ("documenten - /// aanleveren"). The file is carried base64-encoded. Owner-scoped by . Returns - /// false when the domain reports the registration is unknown or not the caller's (404). - Task ProvideDocumentsAsync( + /// aanleveren"). The file is carried base64-encoded. Owner-scoped by . The domain + /// refuses a non-PDF or infected file, and an upload it could not scan (S-29, ADR-0036). + Task ProvideDocumentsAsync( string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default); /// The behandelaar's werkbak — registrations awaiting beoordeling. @@ -48,6 +48,9 @@ public interface IDomainClient Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default); } +/// How the domain answered a provide-documents request (S-29). +public enum ProvideDocumentsResult { Provided, NotFound, NotAPdf, Infected, ScannerUnavailable } + /// Port to the read projection. public interface IProjectionClient { @@ -116,7 +119,7 @@ public sealed class DomainClient(HttpClient http) : IDomainClient return true; } - public async Task ProvideDocumentsAsync( + public async Task ProvideDocumentsAsync( string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default) { using var response = await http.PostAsJsonAsync( @@ -124,9 +127,9 @@ public sealed class DomainClient(HttpClient http) : IDomainClient new { bsn, contentBase64, fileName, contentType }, ct); // The domain 404s an unknown or not-owned registration; relay that rather than fail hard. if (response.StatusCode == System.Net.HttpStatusCode.NotFound) - return false; + return ProvideDocumentsResult.NotFound; response.EnsureSuccessStatusCode(); - return true; + return ProvideDocumentsResult.Provided; } public async Task> GetWerkbakAsync(CancellationToken ct = default) diff --git a/services/bff/Bff.Api/Program.cs b/services/bff/Bff.Api/Program.cs index ee2a36e..5a9579b 100644 --- a/services/bff/Bff.Api/Program.cs +++ b/services/bff/Bff.Api/Program.cs @@ -177,7 +177,7 @@ app.MapPost("/self-service/registrations/{id}/documents", async (string id, Prov return Results.BadRequest("A document is required."); var provided = await domain.ProvideDocumentsAsync(id, bsn, body.ContentBase64, body.FileName, body.ContentType, ct); - return provided ? Results.NoContent() : Results.NotFound(); + return provided == ProvideDocumentsResult.Provided ? Results.NoContent() : Results.NotFound(); }) .RequireAuthorization() .Produces(StatusCodes.Status204NoContent) diff --git a/services/bff/Bff.Tests/BffFactory.cs b/services/bff/Bff.Tests/BffFactory.cs index 278a61c..29942cc 100644 --- a/services/bff/Bff.Tests/BffFactory.cs +++ b/services/bff/Bff.Tests/BffFactory.cs @@ -111,14 +111,13 @@ internal sealed class FakeDomainClient : IDomainClient public (string RegistrationId, string Bsn, string ContentBase64, string? FileName, string? ContentType)? DocumentsProvidedFor { get; private set; } - /// Whether the fake domain reports the provide-documents as done (true → 204) or - /// not-found/not-owned (false → 404). Tests set this to exercise the relay. - public bool ProvideDocumentsSucceeds { get; set; } = true; + /// How the fake domain answers provide-documents. Tests set this to exercise the relay. + public ProvideDocumentsResult ProvideDocumentsResult { get; set; } = ProvideDocumentsResult.Provided; - public Task ProvideDocumentsAsync(string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default) + public Task ProvideDocumentsAsync(string registrationId, string bsn, string contentBase64, string? fileName, string? contentType, CancellationToken ct = default) { DocumentsProvidedFor = (registrationId, bsn, contentBase64, fileName, contentType); - return Task.FromResult(ProvideDocumentsSucceeds); + return Task.FromResult(ProvideDocumentsResult); } public (string RegistrationId, string Besluit)? Decided { get; private set; } diff --git a/services/bff/Bff.Tests/DomainClientProvideDocumentsTests.cs b/services/bff/Bff.Tests/DomainClientProvideDocumentsTests.cs new file mode 100644 index 0000000..8836aed --- /dev/null +++ b/services/bff/Bff.Tests/DomainClientProvideDocumentsTests.cs @@ -0,0 +1,32 @@ +using System.Net; +using System.Text; +using Bff.Api; + +namespace Bff.Tests; + +// S-29 (#192): the BFF reads the domain's provide-documents answer — 422 carries the refusal reason, +// 503 means the scanner was down — into a result the endpoint relays. +public class DomainClientProvideDocumentsTests +{ + private sealed class Reply(HttpStatusCode status, string? json) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => Task.FromResult(new HttpResponseMessage(status) + { + Content = new StringContent(json ?? "", Encoding.UTF8, "application/json"), + }); + } + + [Theory] + [InlineData(HttpStatusCode.NoContent, null, ProvideDocumentsResult.Provided)] + [InlineData(HttpStatusCode.NotFound, null, ProvideDocumentsResult.NotFound)] + [InlineData(HttpStatusCode.UnprocessableEntity, """{"reason":"not-a-pdf"}""", ProvideDocumentsResult.NotAPdf)] + [InlineData(HttpStatusCode.UnprocessableEntity, """{"reason":"infected"}""", ProvideDocumentsResult.Infected)] + [InlineData(HttpStatusCode.ServiceUnavailable, null, ProvideDocumentsResult.ScannerUnavailable)] + public async Task Maps_the_domain_answer_to_a_result(HttpStatusCode status, string? body, ProvideDocumentsResult expected) + { + var client = new DomainClient(new HttpClient(new Reply(status, body)) { BaseAddress = new Uri("http://domain/") }); + + Assert.Equal(expected, await client.ProvideDocumentsAsync("reg-1", "123456782", "JVBERi0=", null, null)); + } +} diff --git a/services/bff/Bff.Tests/SelfServiceEndpointTests.cs b/services/bff/Bff.Tests/SelfServiceEndpointTests.cs index 9b855ba..137012d 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 System.Text.Json; using Bff.Api; namespace Bff.Tests; @@ -162,13 +163,39 @@ public class SelfServiceEndpointTests public async Task Relays_not_found_providing_documents_for_an_unknown_or_not_owned_registration() { using var factory = new BffFactory(); - factory.Domain.ProvideDocumentsSucceeds = false; + factory.Domain.ProvideDocumentsResult = ProvideDocumentsResult.NotFound; var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782"))); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } + // S-29 (#192): the domain's refusal reaches the portal — 422 with the reason it words for the citizen. + [Theory] + [InlineData(ProvideDocumentsResult.NotAPdf, "not-a-pdf")] + [InlineData(ProvideDocumentsResult.Infected, "infected")] + public async Task Relays_a_refused_document_as_unprocessable_with_its_reason(ProvideDocumentsResult result, string reason) + { + using var factory = new BffFactory(); + factory.Domain.ProvideDocumentsResult = result; + + var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782"))); + + Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); + Assert.Equal(reason, (await response.Content.ReadFromJsonAsync()).GetProperty("reason").GetString()); + } + + [Fact] + public async Task Relays_an_unavailable_scanner_as_service_unavailable() + { + using var factory = new BffFactory(); + factory.Domain.ProvideDocumentsResult = ProvideDocumentsResult.ScannerUnavailable; + + var response = await factory.CreateClient().SendAsync(ProvideDocuments(TestTokens.Valid("123456782"))); + + Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); + } + private static HttpRequestMessage Current(string? bearer) { var request = new HttpRequestMessage(HttpMethod.Get, "/self-service/registrations");