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 { private readonly HttpClient _client = factory.CreateClient(); private async Task 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(); Assert.NotNull(created); return created; } private Task?> List() => _client.GetFromJsonAsync>("/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($"/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())!; 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())!; 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())!; 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())!; try { var listOther = new HttpRequestMessage(HttpMethod.Get, "/api/v1/aanvragen") { Headers = { { "X-Subject", "999888777" } } }; var theirCases = (await (await _client.SendAsync(listOther)).Content.ReadFromJsonAsync>())!; 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 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())!; } [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); } }