The backend half of the sweep RD-18 did for the front end. git blame holds the provenance and stays correct when the code moves; the comment names a closed ticket and tells the reader nothing the sentence around it does not. public/letter.css and LetterHtml.golden.html change together, because the renderer inlines the CSS and the golden file snapshots the result. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
253 lines
10 KiB
C#
253 lines
10 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using BigRegister.Api.Contracts;
|
|
using BigRegister.Api.Data;
|
|
using BigRegister.Domain.Applications;
|
|
using BigRegister.Tests.Builders;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
|
|
namespace BigRegister.Tests;
|
|
|
|
public class AanvraagTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
|
{
|
|
private readonly HttpClient _client = factory.CreateClient();
|
|
|
|
private async Task<AanvraagDetailDto> Create(string type = "registratie")
|
|
{
|
|
// One Concept per type is now server-enforced, and these tests share one DB
|
|
// (IClassFixture). Clear any leftover Concept so each test starts from a clean slate.
|
|
var existing = await List();
|
|
Assert.NotNull(existing);
|
|
foreach (var s in existing.Where(x => x.Status.Tag == "Concept"))
|
|
await _client.DeleteAsync($"/api/v1/aanvragen/{s.Id}");
|
|
var res = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type });
|
|
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
|
|
var created = await res.Content.ReadFromJsonAsync<AanvraagDetailDto>();
|
|
Assert.NotNull(created);
|
|
return created;
|
|
}
|
|
|
|
private Task<List<AanvraagSummaryDto>?> List() =>
|
|
_client.GetFromJsonAsync<List<AanvraagSummaryDto>>("/api/v1/aanvragen");
|
|
|
|
// --- Lifecycle over HTTP ---
|
|
|
|
[Fact]
|
|
public async Task Create_then_list_shows_a_concept_with_step_progress()
|
|
{
|
|
var a = await Create();
|
|
await _client.PutAsJsonAsync($"/api/v1/aanvragen/{a.Id}",
|
|
new { draft = new { beroep = "arts" }, stepIndex = 1, stepCount = 4 });
|
|
|
|
var list = await List();
|
|
Assert.NotNull(list);
|
|
var mine = list.Single(x => x.Id == a.Id);
|
|
Assert.Equal("Concept", mine.Status.Tag);
|
|
Assert.Equal(1, mine.Status.StepIndex);
|
|
Assert.Equal(4, mine.Status.StepCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Draft_sync_is_readable_back_from_detail()
|
|
{
|
|
var a = await Create();
|
|
await _client.PutAsJsonAsync($"/api/v1/aanvragen/{a.Id}",
|
|
new { draft = new { beroep = "verpleegkundige" }, stepIndex = 2, stepCount = 4 });
|
|
|
|
var detail = await _client.GetFromJsonAsync<AanvraagDetailDto>($"/api/v1/aanvragen/{a.Id}");
|
|
Assert.NotNull(detail);
|
|
Assert.NotNull(detail.Draft);
|
|
Assert.Equal("verpleegkundige", detail.Draft.Value.GetProperty("beroep").GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Submit_duo_registratie_is_in_behandeling_and_auto()
|
|
{
|
|
var a = await Create("registratie");
|
|
var res = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" });
|
|
res.EnsureSuccessStatusCode();
|
|
var body = (await res.Content.ReadFromJsonAsync<AanvraagIndienenResponse>())!;
|
|
Assert.StartsWith("BIG-2026-", body.Referentie);
|
|
Assert.Equal("InBehandeling", body.Status.Tag);
|
|
Assert.False(body.Status.Manual); // auto-approvable → not a manual case
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Submit_handmatig_registratie_succeeds_as_manual_case()
|
|
{
|
|
var a = await Create("registratie");
|
|
var res = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "handmatig" });
|
|
res.EnsureSuccessStatusCode(); // no longer a 422
|
|
var body = (await res.Content.ReadFromJsonAsync<AanvraagIndienenResponse>())!;
|
|
Assert.Equal("InBehandeling", body.Status.Tag);
|
|
Assert.True(body.Status.Manual);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Submit_herregistratie_with_zero_uren_is_afgewezen()
|
|
{
|
|
var a = await Create("herregistratie");
|
|
var res = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { uren = 0 });
|
|
res.EnsureSuccessStatusCode(); // the submission is accepted...
|
|
var body = (await res.Content.ReadFromJsonAsync<AanvraagIndienenResponse>())!;
|
|
Assert.Equal("Afgewezen", body.Status.Tag); // ...but resolves to rejected
|
|
Assert.NotNull(body.Status.Reden);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Submitting_twice_conflicts()
|
|
{
|
|
var a = await Create("registratie");
|
|
(await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
|
|
var again = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" });
|
|
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
|
|
}
|
|
|
|
// --- One Concept per case type (server-enforced) ---
|
|
|
|
[Fact]
|
|
public async Task Creating_a_second_concept_of_the_same_type_conflicts()
|
|
{
|
|
await Create("herregistratie");
|
|
var dup = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "herregistratie" });
|
|
Assert.Equal(HttpStatusCode.Conflict, dup.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_concept_of_a_different_type_is_allowed()
|
|
{
|
|
await Create("registratie");
|
|
var other = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "herregistratie" });
|
|
Assert.Equal(HttpStatusCode.Created, other.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_new_concept_is_allowed_once_the_previous_one_is_submitted()
|
|
{
|
|
var a = await Create("registratie");
|
|
(await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
|
|
var next = await _client.PostAsJsonAsync("/api/v1/aanvragen", new { type = "registratie" });
|
|
Assert.Equal(HttpStatusCode.Created, next.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cancel_concept_removes_it()
|
|
{
|
|
var a = await Create();
|
|
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/aanvragen/{a.Id}")).StatusCode);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/aanvragen/{a.Id}")).StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cancel_submitted_aanvraag_conflicts()
|
|
{
|
|
var a = await Create("registratie");
|
|
(await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
|
|
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/aanvragen/{a.Id}")).StatusCode);
|
|
}
|
|
|
|
// --- Citizen-scoping — GET /aanvragen must never leak across identities. ---
|
|
|
|
[Fact]
|
|
public async Task Applications_are_scoped_to_the_caller_bsn()
|
|
{
|
|
var mine = await Create("intake");
|
|
|
|
var createOther = new HttpRequestMessage(HttpMethod.Post, "/api/v1/aanvragen")
|
|
{
|
|
Content = JsonContent.Create(new { type = "intake" }),
|
|
Headers = { { "X-Subject", "999888777" } },
|
|
};
|
|
var otherRes = await _client.SendAsync(createOther);
|
|
Assert.Equal(HttpStatusCode.Created, otherRes.StatusCode);
|
|
var other = (await otherRes.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
|
|
|
|
try
|
|
{
|
|
var listOther = new HttpRequestMessage(HttpMethod.Get, "/api/v1/aanvragen") { Headers = { { "X-Subject", "999888777" } } };
|
|
var theirCases = (await (await _client.SendAsync(listOther)).Content.ReadFromJsonAsync<List<AanvraagSummaryDto>>())!;
|
|
Assert.Contains(theirCases, c => c.Id == other.Id);
|
|
Assert.DoesNotContain(theirCases, c => c.Id == mine.Id);
|
|
|
|
var myCases = (await List())!;
|
|
Assert.Contains(myCases, c => c.Id == mine.Id);
|
|
Assert.DoesNotContain(myCases, c => c.Id == other.Id);
|
|
}
|
|
finally
|
|
{
|
|
var deleteOther = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/aanvragen/{other.Id}") { Headers = { { "X-Subject", "999888777" } } };
|
|
await _client.SendAsync(deleteOther);
|
|
await _client.DeleteAsync($"/api/v1/aanvragen/{mine.Id}");
|
|
}
|
|
}
|
|
|
|
// --- A citizen may only reference their own uploads — submit/draft-sync must
|
|
// reject a foreign documentId rather than silently attaching it. ---
|
|
|
|
private static async Task<UploadResponse> UploadAs(HttpClient client, string owner, string localId)
|
|
{
|
|
var content = new MultipartFormDataContent();
|
|
var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
|
|
file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf");
|
|
content.Add(file, "file", "d.pdf");
|
|
content.Add(new StringContent("diploma"), "categoryId");
|
|
content.Add(new StringContent(localId), "localId");
|
|
content.Add(new StringContent("registratie"), "wizardId");
|
|
var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/uploads") { Content = content, Headers = { { "X-Subject", owner } } };
|
|
var res = await client.SendAsync(req);
|
|
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
|
|
return (await res.Content.ReadFromJsonAsync<UploadResponse>())!;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Submitting_a_foreign_documentId_is_rejected_and_leaves_it_deletable_by_its_owner()
|
|
{
|
|
var foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString());
|
|
var a = await Create("registratie");
|
|
|
|
var res = await _client.PostAsJsonAsync($"/api/v1/aanvragen/{a.Id}/submit",
|
|
new { diplomaHerkomst = "duo", documents = new[] { new { categoryId = "diploma", channel = "digital", documentId = foreignDoc.DocumentId } } });
|
|
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
|
|
|
// The rejected submit must not have flipped the foreign document's Linked flag — its
|
|
// owner can still delete it.
|
|
var deleteReq = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/uploads/{foreignDoc.DocumentId}") { Headers = { { "X-Subject", "999888777" } } };
|
|
Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(deleteReq)).StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Draft_sync_with_a_foreign_documentId_is_rejected()
|
|
{
|
|
var foreignDoc = await UploadAs(_client, "999888777", Guid.NewGuid().ToString());
|
|
var a = await Create("registratie");
|
|
|
|
var res = await _client.PutAsJsonAsync($"/api/v1/aanvragen/{a.Id}",
|
|
new { draft = new { }, stepIndex = 0, stepCount = 1, documentIds = new[] { foreignDoc.DocumentId } });
|
|
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
|
}
|
|
|
|
// --- Auto-approval is computed on read: exercise the window boundary without waiting. ---
|
|
|
|
private static Aanvraag.Submitted Accepted(bool autoApprovable) =>
|
|
Given.Concept(type: "registratie", owner: "test").Submitted(autoApprovable).Build();
|
|
|
|
[Fact]
|
|
public void AutoApprovable_flips_to_goedgekeurd_after_the_window()
|
|
{
|
|
var a = Accepted(autoApprovable: true);
|
|
var t0 = a.SubmittedAt;
|
|
Assert.Equal("InBehandeling", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow - TimeSpan.FromSeconds(1)).Tag);
|
|
Assert.Equal("Goedgekeurd", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow + TimeSpan.FromSeconds(1)).Tag);
|
|
}
|
|
|
|
[Fact]
|
|
public void Manual_case_never_auto_advances()
|
|
{
|
|
var a = Accepted(autoApprovable: false);
|
|
var far = a.SubmittedAt + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1);
|
|
var status = a.ToStatusDto(far);
|
|
Assert.Equal("InBehandeling", status.Tag);
|
|
Assert.True(status.Manual);
|
|
}
|
|
}
|