Two work packages in one commit because both edit Program.cs and splitting
them would leave a commit that does not build.
WP-72 — deletes POST /api/v1/intakes and /herregistraties. Both were dead
from the UI (the wizard submits via /applications/{id}/submit) and strictly
less capable: they minted a bare reference and wrote no Aanvraag, made no
ZGW call, and did no document-ownership check. The shared Submit(...) helper
survives — /registrations and /change-requests still use it. WP-69 hardened
/intakes with a 400 last session; removing the surface is the stronger fix,
and WP-69's /applications/{id}/submit enforcement is untouched.
WP-73 — RegistrationStatus becomes an abstract record with three sealed
variants behind a private base ctor, so only Geregistreerd carries a
herregistratie deadline and reden is required on Geschorst/Doorgehaald
(matching the FE union, which was already right). HerregistratieRule
.IsStatusConsistent and its test are deleted: the type now guarantees what
the runtime check was for, and the test could no longer construct the
illegal state it existed to catch.
Aanvraag splits into a Concept | Submitted | Decided union with the EF row
demoted to AanvraagEntity behind a two-way mapper. Submitted carries a
non-null Referentie and SubmittedAt, and Decided.Afgewezen/MeerInfoGevraagd
require a Toelichting — so the five Referentie! null-forgiving derefs in
StatusAt are gone, not merely suppressed. IZaakSource.CreateZaak narrows to
Aanvraag.Submitted, removing the same class of deref in both zaak sources.
Draft is now cleared on submit rather than lingering: ApplicationStore's
doc-comment claimed "Concept only" but Submit never cleared it. Verified
nothing reads a submitted aanvraag's draft (draft-sync's applyResume only
resumes unsubmitted wizards), so the comment is now true instead of
aspirational.
No migration, no schema change, no wire change — RegistrationStatusDto and
the application DTOs are byte-identical, confirmed against a live swagger.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
253 lines
11 KiB
C#
253 lines
11 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 ApplicationTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
|
{
|
|
private readonly HttpClient _client = factory.CreateClient();
|
|
|
|
private async Task<ApplicationDetailDto> Create(string type = "registratie")
|
|
{
|
|
// WP-35: 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/applications/{s.Id}");
|
|
var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type });
|
|
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
|
|
var created = await res.Content.ReadFromJsonAsync<ApplicationDetailDto>();
|
|
Assert.NotNull(created);
|
|
return created;
|
|
}
|
|
|
|
private Task<List<ApplicationSummaryDto>?> List() =>
|
|
_client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/applications");
|
|
|
|
// --- 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/applications/{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/applications/{a.Id}",
|
|
new { draft = new { beroep = "verpleegkundige" }, stepIndex = 2, stepCount = 4 });
|
|
|
|
var detail = await _client.GetFromJsonAsync<ApplicationDetailDto>($"/api/v1/applications/{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/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
|
|
res.EnsureSuccessStatusCode();
|
|
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
|
|
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/applications/{a.Id}/submit", new { diplomaHerkomst = "handmatig" });
|
|
res.EnsureSuccessStatusCode(); // no longer a 422
|
|
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
|
|
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/applications/{a.Id}/submit", new { uren = 0 });
|
|
res.EnsureSuccessStatusCode(); // the submission is accepted...
|
|
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
|
|
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/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
|
|
var again = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
|
|
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
|
|
}
|
|
|
|
// --- WP-35: 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/applications", 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/applications", 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/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
|
|
var next = await _client.PostAsJsonAsync("/api/v1/applications", 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/applications/{a.Id}")).StatusCode);
|
|
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/applications/{a.Id}")).StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cancel_submitted_aanvraag_conflicts()
|
|
{
|
|
var a = await Create("registratie");
|
|
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode();
|
|
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode);
|
|
}
|
|
|
|
// --- WP-53: citizen-scoping — GET /applications 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/applications")
|
|
{
|
|
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<ApplicationDetailDto>())!;
|
|
|
|
try
|
|
{
|
|
var listOther = new HttpRequestMessage(HttpMethod.Get, "/api/v1/applications") { Headers = { { "X-Subject", "999888777" } } };
|
|
var theirCases = (await (await _client.SendAsync(listOther)).Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!;
|
|
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/applications/{other.Id}") { Headers = { { "X-Subject", "999888777" } } };
|
|
await _client.SendAsync(deleteOther);
|
|
await _client.DeleteAsync($"/api/v1/applications/{mine.Id}");
|
|
}
|
|
}
|
|
|
|
// --- WP-68 (F1): 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/applications/{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/applications/{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);
|
|
}
|
|
}
|