Add ASP.NET Core backend hosting business rules; FE consumes via typed client
Move the authoritative business rules off the frontend into a real backend,
realising the BFF-lite + decision-DTO design (ADR-0001) that until now lived
only in static mock JSON.
Backend (backend/):
- ASP.NET Core (.NET 10) minimal API, contract-first, Swagger UI at /swagger.
- DDD Domain/ rules layer: profession derivation + applicable policy questions
(DiplomaRules), herregistratie eligibility + reason (HerregistratieRule),
scholing threshold (IntakePolicy), submit rejections + reference generation
(SubmissionRules). In-memory seeded data, ProblemDetails (RFC 7807) errors.
- 27 xUnit tests: rule units + endpoint integration incl. BRP no-address and
DUO not-found fallbacks and 422 submit paths.
Frontend (only infrastructure/ + contracts/ change, as the architecture promised):
- NSwag-generated typed client (api-client.ts), routed through Angular HttpClient
via a small fetch adapter so the ?scenario= interceptor still applies.
- GET adapters use resource({ loader: client.x }); submit commands call the client
and map ProblemDetails -> err. The hardcoded uren==0 / manual-diploma rules are
deleted (now server-side). Domain, stores, UI and format validators unchanged.
- Deleted the now-dead public/mock/*.json.
Tooling/docs:
- npm start proxies /api -> backend; npm run gen:api regenerates the client;
docker compose up runs both (bind mounts use :z for SELinux/Fedora).
- backend/README.md walkthrough: adding a policy question is a one-file backend
change, no FE change, no client regen. Updated CLAUDE.md + ARCHITECTURE.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2
backend/.gitignore
vendored
Normal file
2
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
bin/
|
||||
obj/
|
||||
8
backend/BigRegister.slnx
Normal file
8
backend/BigRegister.slnx
Normal file
@@ -0,0 +1,8 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/BigRegister.Api/BigRegister.Api.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/BigRegister.Tests/BigRegister.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
102
backend/README.md
Normal file
102
backend/README.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# BIG-register BFF (ASP.NET Core)
|
||||
|
||||
The backend that hosts the **business rules** for the BIG-register portal. The
|
||||
frontend renders the decisions this service computes; it does not recompute them
|
||||
(BFF-lite + decision DTOs — see `../docs/architecture/0001-bff-lite-decision-dtos.md`).
|
||||
|
||||
No database, no real BRP/DUO: data is in-memory and seeded (`Data/SeedData.cs`),
|
||||
but the endpoints, DTOs, status codes and error envelope are production-shaped.
|
||||
|
||||
## Run
|
||||
|
||||
### Everything (docker-compose, from repo root)
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
- App: <http://localhost:4200>
|
||||
- Swagger UI: <http://localhost:5000/swagger>
|
||||
|
||||
### Backend only (local)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
dotnet run --project src/BigRegister.Api
|
||||
# → http://localhost:5000/swagger
|
||||
```
|
||||
|
||||
### Frontend against a local backend
|
||||
|
||||
```bash
|
||||
npm start # ng serve, proxies /api → http://localhost:5000 (proxy.conf.json)
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
cd backend && dotnet test # rule unit tests + endpoint integration tests
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| Method | Route | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/api/dashboard-view` | registration + person + computed herregistratie decision |
|
||||
| GET | `/api/notes` | specialisms / aantekeningen |
|
||||
| GET | `/api/brp/address` | BRP address lookup (`gevonden:false` = no address) |
|
||||
| GET | `/api/duo/diplomas` | diplomas with derived profession + applicable policy questions, + manual fallback |
|
||||
| GET | `/api/intake/policy` | scholing threshold (config value) |
|
||||
| POST | `/api/registrations` | submit registration → reference, or 422 (manual diploma) |
|
||||
| POST | `/api/herregistraties` | submit re-registration → reference, or 422 (0 hours) |
|
||||
| POST | `/api/intakes` | submit intake → reference, or 422 (0 hours) |
|
||||
|
||||
Rejections use **ProblemDetails (RFC 7807)** with status **422**.
|
||||
|
||||
## Where the rules live (`src/BigRegister.Api/Domain/`)
|
||||
|
||||
- `Diplomas/DiplomaRules.cs` — profession derivation + which policy questions apply.
|
||||
- `Registrations/HerregistratieRule.cs` — eligibility + reason + status invariant.
|
||||
- `Intake/IntakePolicy.cs` — scholing threshold.
|
||||
- `Submissions/SubmissionRules.cs` — submit rejections + reference generation.
|
||||
|
||||
## Typed client (NSwag)
|
||||
|
||||
The frontend calls this API through a generated TypeScript client. Regenerate it
|
||||
from the contract after a **shape** change:
|
||||
|
||||
```bash
|
||||
npm run gen:api # builds backend → swagger.json → src/app/shared/infrastructure/api-client.ts
|
||||
```
|
||||
|
||||
## Maintainability: changing a policy is one backend change
|
||||
|
||||
**Goal:** require every *Verpleegkundige* diploma to confirm a Dutch skills
|
||||
assessment. This is a new policy question on a diploma type.
|
||||
|
||||
Edit **one file** — `Domain/Diplomas/DiplomaRules.cs`:
|
||||
|
||||
```diff
|
||||
public static IReadOnlyList<PolicyQuestion> QuestionsFor(Diploma d)
|
||||
{
|
||||
var questions = new List<PolicyQuestion>();
|
||||
if (d.Engelstalig)
|
||||
questions.Add(NlTaalEngelstalig);
|
||||
+ if (d.Opleiding == "verpleegkunde")
|
||||
+ questions.Add(new PolicyQuestion(
|
||||
+ "bekwaamheid",
|
||||
+ "Heeft u in de afgelopen vijf jaar een bekwaamheidstoets afgelegd?",
|
||||
+ QuestionType.JaNee));
|
||||
return questions;
|
||||
}
|
||||
```
|
||||
|
||||
Rebuild the backend (`docker compose up` or `dotnet run`). The new question now
|
||||
appears in the registration wizard for HBO-Verpleegkunde.
|
||||
|
||||
- **No frontend change.** The FE renders whatever questions the API returns.
|
||||
- **No client regeneration.** The wire shape (`PolicyQuestionDto`) is unchanged —
|
||||
only the data behind it. `npm run gen:api` is only needed when a DTO *shape* changes.
|
||||
|
||||
Add a unit test for the new rule in `tests/BigRegister.Tests/RuleTests.cs` and
|
||||
you're done.
|
||||
13
backend/dotnet-tools.json
Normal file
13
backend/dotnet-tools.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"swashbuckle.aspnetcore.cli": {
|
||||
"version": "10.2.3",
|
||||
"commands": [
|
||||
"swagger"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
13
backend/src/BigRegister.Api/BigRegister.Api.csproj
Normal file
13
backend/src/BigRegister.Api/BigRegister.Api.csproj
Normal file
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
55
backend/src/BigRegister.Api/Contracts/Dtos.cs
Normal file
55
backend/src/BigRegister.Api/Contracts/Dtos.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
namespace BigRegister.Api.Contracts;
|
||||
|
||||
// Wire contracts (the FE⇄BE seam). Field names + shapes mirror the frontend's
|
||||
// contracts/*.dto.ts 1:1; the NSwag-generated TS client is produced from these.
|
||||
|
||||
public sealed record AdresDto(string Straat, string Postcode, string Woonplaats);
|
||||
|
||||
public sealed record RegistrationStatusDto(
|
||||
string Tag,
|
||||
string? HerregistratieDatum = null,
|
||||
string? GeschorstTot = null,
|
||||
string? Reden = null,
|
||||
string? DoorgehaaldOp = null);
|
||||
|
||||
public sealed record RegistrationDto(
|
||||
string BigNummer,
|
||||
string Naam,
|
||||
string Beroep,
|
||||
string Registratiedatum,
|
||||
string Geboortedatum,
|
||||
RegistrationStatusDto Status);
|
||||
|
||||
public sealed record PersonDto(string Naam, string Geboortedatum, AdresDto Adres);
|
||||
|
||||
public sealed record HerregistratieDecisionsDto(bool EligibleForHerregistratie, string? HerregistratieReason);
|
||||
|
||||
public sealed record DashboardViewDto(RegistrationDto Registration, PersonDto Person, HerregistratieDecisionsDto Decisions);
|
||||
|
||||
public sealed record AantekeningDto(string Type, string Omschrijving, string Datum);
|
||||
|
||||
public sealed record BrpAddressDto(bool Gevonden, AdresDto? Adres);
|
||||
|
||||
public sealed record PolicyQuestionDto(string Id, string Vraag, string Type);
|
||||
|
||||
public sealed record DuoDiplomaDto(
|
||||
string Id,
|
||||
string Naam,
|
||||
string Instelling,
|
||||
int Jaar,
|
||||
string Beroep,
|
||||
IReadOnlyList<PolicyQuestionDto> PolicyQuestions);
|
||||
|
||||
public sealed record ManualDiplomaPolicyDto(IReadOnlyList<string> Beroepen, IReadOnlyList<PolicyQuestionDto> PolicyQuestions);
|
||||
|
||||
public sealed record DuoLookupDto(IReadOnlyList<DuoDiplomaDto> Diplomas, ManualDiplomaPolicyDto Handmatig);
|
||||
|
||||
public sealed record IntakePolicyDto(int ScholingThreshold);
|
||||
|
||||
// Submit requests carry only the fields the server re-validates (UX-only fields
|
||||
// stay on the client). ponytail: a real submit would carry the full application.
|
||||
public sealed record RegistratieRequest(string DiplomaHerkomst);
|
||||
public sealed record IntakeRequest(int Uren);
|
||||
public sealed record HerregistratieRequest(int Uren);
|
||||
|
||||
public sealed record ReferentieResponse(string Referentie);
|
||||
33
backend/src/BigRegister.Api/Contracts/Mappers.cs
Normal file
33
backend/src/BigRegister.Api/Contracts/Mappers.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using BigRegister.Domain.Diplomas;
|
||||
using BigRegister.Domain.People;
|
||||
using BigRegister.Domain.Registrations;
|
||||
|
||||
namespace BigRegister.Api.Contracts;
|
||||
|
||||
/// <summary>Domain → wire DTO mapping (the anti-corruption boundary, server side).</summary>
|
||||
public static class Mappers
|
||||
{
|
||||
private static string D(DateOnly d) => d.ToString("yyyy-MM-dd");
|
||||
|
||||
public static RegistrationStatusDto ToDto(this RegistrationStatus s) => new(
|
||||
Tag: s.Tag.ToString(),
|
||||
HerregistratieDatum: s.HerregistratieDatum is { } h ? D(h) : null,
|
||||
GeschorstTot: s.GeschorstTot is { } g ? D(g) : null,
|
||||
Reden: s.Reden,
|
||||
DoorgehaaldOp: s.DoorgehaaldOp is { } x ? D(x) : null);
|
||||
|
||||
public static RegistrationDto ToDto(this Registration r) => new(
|
||||
r.BigNummer, r.Naam, r.Beroep, D(r.Registratiedatum), D(r.Geboortedatum), r.Status.ToDto());
|
||||
|
||||
public static AdresDto ToDto(this Adres a) => new(a.Straat, a.Postcode, a.Woonplaats);
|
||||
|
||||
public static PersonDto ToDto(this Person p) => new(p.Naam, D(p.Geboortedatum), p.Adres.ToDto());
|
||||
|
||||
public static PolicyQuestionDto ToDto(this PolicyQuestion q) => new(
|
||||
q.Id, q.Vraag, q.Type == QuestionType.JaNee ? "ja-nee" : "tekst");
|
||||
|
||||
public static DuoDiplomaDto ToDto(this Diploma d) => new(
|
||||
d.Id, d.Naam, d.Instelling, d.Jaar,
|
||||
DiplomaRules.ProfessionFor(d),
|
||||
DiplomaRules.QuestionsFor(d).Select(q => q.ToDto()).ToList());
|
||||
}
|
||||
42
backend/src/BigRegister.Api/Data/SeedData.cs
Normal file
42
backend/src/BigRegister.Api/Data/SeedData.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using BigRegister.Domain.Diplomas;
|
||||
using BigRegister.Domain.People;
|
||||
using BigRegister.Domain.Registrations;
|
||||
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory seeded synthetic data — no DB, no real BRP/DUO. Stands in for the
|
||||
/// upstream systems so the API behaves like production for a demo.
|
||||
/// </summary>
|
||||
public static class SeedData
|
||||
{
|
||||
public static readonly Registration Registration = new(
|
||||
BigNummer: "19012345601",
|
||||
Naam: "Dr. A. (Anna) de Vries",
|
||||
Beroep: "Arts",
|
||||
Registratiedatum: new DateOnly(2012, 9, 1),
|
||||
Geboortedatum: new DateOnly(1985, 3, 14),
|
||||
Status: new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)));
|
||||
|
||||
public static readonly Person Person = new(
|
||||
Naam: "Dr. A. (Anna) de Vries",
|
||||
Geboortedatum: new DateOnly(1985, 3, 14),
|
||||
Adres: new Adres("Lange Voorhout 9", "2514 EA", "Den Haag"));
|
||||
|
||||
/// <summary>The address BRP returns for the seeded citizen.</summary>
|
||||
public static readonly Adres BrpAddress = Person.Adres;
|
||||
|
||||
public static readonly IReadOnlyList<Diploma> Diplomas = new[]
|
||||
{
|
||||
new Diploma("d1", "Geneeskunde", "Universiteit Leiden", 2011, "geneeskunde", Engelstalig: false),
|
||||
new Diploma("d2", "Medicine (MBChB)", "University of Edinburgh", 2013, "geneeskunde", Engelstalig: true),
|
||||
new Diploma("d3", "HBO-Verpleegkunde", "Hogeschool Utrecht", 2016, "verpleegkunde", Engelstalig: false),
|
||||
};
|
||||
|
||||
public static readonly IReadOnlyList<(string Type, string Omschrijving, string Datum)> Notes = new[]
|
||||
{
|
||||
("Specialisme", "Huisartsgeneeskunde", "2016-04-12"),
|
||||
("Aantekening", "Erkend opleider huisartsgeneeskunde", "2019-01-08"),
|
||||
("Specialisme", "Spoedeisende hulp (kaderopleiding)", "2021-06-30"),
|
||||
};
|
||||
}
|
||||
22
backend/src/BigRegister.Api/Domain/Diplomas/Diploma.cs
Normal file
22
backend/src/BigRegister.Api/Domain/Diplomas/Diploma.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace BigRegister.Domain.Diplomas;
|
||||
|
||||
public enum QuestionType
|
||||
{
|
||||
JaNee,
|
||||
Tekst,
|
||||
}
|
||||
|
||||
public sealed record PolicyQuestion(string Id, string Vraag, QuestionType Type);
|
||||
|
||||
/// <summary>
|
||||
/// A diploma as DUO knows it. The profession and the applicable policy questions
|
||||
/// are NOT stored on the diploma — they are DERIVED by the rules below from its
|
||||
/// attributes (<see cref="Opleiding"/>, <see cref="Engelstalig"/>).
|
||||
/// </summary>
|
||||
public sealed record Diploma(
|
||||
string Id,
|
||||
string Naam,
|
||||
string Instelling,
|
||||
int Jaar,
|
||||
string Opleiding,
|
||||
bool Engelstalig);
|
||||
68
backend/src/BigRegister.Api/Domain/Diplomas/DiplomaRules.cs
Normal file
68
backend/src/BigRegister.Api/Domain/Diplomas/DiplomaRules.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
namespace BigRegister.Domain.Diplomas;
|
||||
|
||||
/// <summary>
|
||||
/// SERVER-OWNED business rules for diplomas. This is the single place a policy
|
||||
/// changes: which profession a study program maps to, and which policy questions
|
||||
/// (geldigheidsvragen) apply to a diploma. The frontend renders these; it never
|
||||
/// derives them.
|
||||
/// </summary>
|
||||
public static class DiplomaRules
|
||||
{
|
||||
// RULE: study program → BIG profession.
|
||||
private static readonly Dictionary<string, string> ProfessionByProgram = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["geneeskunde"] = "Arts",
|
||||
["verpleegkunde"] = "Verpleegkundige",
|
||||
["fysiotherapie"] = "Fysiotherapeut",
|
||||
["farmacie"] = "Apotheker",
|
||||
["tandheelkunde"] = "Tandarts",
|
||||
};
|
||||
|
||||
public static string ProfessionFor(Diploma d) =>
|
||||
ProfessionByProgram.TryGetValue(d.Opleiding, out var beroep) ? beroep : "Onbekend";
|
||||
|
||||
/// <summary>Professions a user may declare for a manual (unlisted) diploma.</summary>
|
||||
public static IReadOnlyList<string> ManualProfessions() =>
|
||||
ProfessionByProgram.Values.Distinct().ToList();
|
||||
|
||||
// --- Policy questions (geldigheidsvragen) ---
|
||||
|
||||
private static readonly PolicyQuestion NlTaalEngelstalig = new(
|
||||
"nl-taalvaardigheid",
|
||||
"Uw opleiding was Engelstalig. Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
|
||||
QuestionType.JaNee);
|
||||
|
||||
private static readonly PolicyQuestion NlTaalManual = new(
|
||||
"nl-taalvaardigheid",
|
||||
"Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
|
||||
QuestionType.JaNee);
|
||||
|
||||
private static readonly PolicyQuestion DiplomaErkend = new(
|
||||
"diploma-erkend",
|
||||
"Is uw diploma erkend door de Nederlandse overheid (bijv. via Nuffic)?",
|
||||
QuestionType.JaNee);
|
||||
|
||||
private static readonly PolicyQuestion Toelichting = new(
|
||||
"toelichting",
|
||||
"Geef een korte toelichting op uw diploma en opleiding.",
|
||||
QuestionType.Tekst);
|
||||
|
||||
/// <summary>
|
||||
/// RULE: an English-language diploma requires proof of Dutch proficiency (B2).
|
||||
/// Add a question here to apply it to a (set of) diploma(s) — a single backend
|
||||
/// change, no frontend change.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PolicyQuestion> QuestionsFor(Diploma d)
|
||||
{
|
||||
var questions = new List<PolicyQuestion>();
|
||||
if (d.Engelstalig)
|
||||
questions.Add(NlTaalEngelstalig);
|
||||
return questions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// RULE: a manual diploma is unverified, so the strictest (maximal) set applies.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PolicyQuestion> ManualQuestions() =>
|
||||
new[] { NlTaalManual, DiplomaErkend, Toelichting };
|
||||
}
|
||||
11
backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs
Normal file
11
backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace BigRegister.Domain.Intake;
|
||||
|
||||
/// <summary>
|
||||
/// SERVER-OWNED config value. Below this many NL work-hours the scholing question
|
||||
/// is required. The frontend receives this value and applies it for instant UX
|
||||
/// feedback, but the backend re-validates on submit as the authority.
|
||||
/// </summary>
|
||||
public static class IntakePolicy
|
||||
{
|
||||
public const int ScholingThreshold = 1000;
|
||||
}
|
||||
5
backend/src/BigRegister.Api/Domain/People/Person.cs
Normal file
5
backend/src/BigRegister.Api/Domain/People/Person.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace BigRegister.Domain.People;
|
||||
|
||||
public sealed record Adres(string Straat, string Postcode, string Woonplaats);
|
||||
|
||||
public sealed record Person(string Naam, DateOnly Geboortedatum, Adres Adres);
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace BigRegister.Domain.Registrations;
|
||||
|
||||
/// <summary>
|
||||
/// SERVER-OWNED business rule (ported from the frontend reference impl
|
||||
/// registration.policy.ts:isHerregistratieEligible). A registration may apply for
|
||||
/// herregistratie only while active and within the window before its deadline.
|
||||
/// The endpoint ships the result as a decision flag + reason; the frontend renders it.
|
||||
/// </summary>
|
||||
public static class HerregistratieRule
|
||||
{
|
||||
public const int WindowMonths = 12;
|
||||
|
||||
public static DateOnly? Deadline(Registration reg) =>
|
||||
reg.Status.Tag == StatusTag.Geregistreerd ? reg.Status.HerregistratieDatum : null;
|
||||
|
||||
public static (bool Eligible, string? Reason) Evaluate(
|
||||
Registration reg, DateOnly today, int windowMonths = WindowMonths)
|
||||
{
|
||||
var deadline = Deadline(reg);
|
||||
if (deadline is null)
|
||||
return (false, "Geen actieve registratie.");
|
||||
|
||||
var windowStart = deadline.Value.AddMonths(-windowMonths);
|
||||
return today >= windowStart
|
||||
? (true, $"Registratie verloopt binnen {windowMonths} maanden ({deadline:yyyy-MM-dd}).")
|
||||
: (false, $"Herregistratie kan vanaf {windowStart:yyyy-MM-dd}.");
|
||||
}
|
||||
|
||||
/// <summary>Invariant: a non-active status must not carry a herregistratie date.</summary>
|
||||
public static bool IsStatusConsistent(RegistrationStatus s) =>
|
||||
s.Tag != StatusTag.Geregistreerd || s.HerregistratieDatum is not null;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace BigRegister.Domain.Registrations;
|
||||
|
||||
/// <summary>The three states a BIG registration can be in.</summary>
|
||||
public enum StatusTag
|
||||
{
|
||||
Geregistreerd,
|
||||
Geschorst,
|
||||
Doorgehaald,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status as a flat record: only <see cref="StatusTag.Geregistreerd"/> carries a
|
||||
/// herregistratie deadline. The frontend mirrors this as a discriminated union.
|
||||
/// </summary>
|
||||
public sealed record RegistrationStatus(
|
||||
StatusTag Tag,
|
||||
DateOnly? HerregistratieDatum = null,
|
||||
DateOnly? GeschorstTot = null,
|
||||
string? Reden = null,
|
||||
DateOnly? DoorgehaaldOp = null);
|
||||
|
||||
public sealed record Registration(
|
||||
string BigNummer,
|
||||
string Naam,
|
||||
string Beroep,
|
||||
DateOnly Registratiedatum,
|
||||
DateOnly Geboortedatum,
|
||||
RegistrationStatus Status);
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace BigRegister.Domain.Submissions;
|
||||
|
||||
/// <summary>
|
||||
/// SERVER-OWNED submit rules (ported from the frontend submit-*.ts commands, where
|
||||
/// they were hardcoded). Each method returns a rejection reason, or null when the
|
||||
/// submission is accepted. The reference is generated server-side on acceptance.
|
||||
/// </summary>
|
||||
public static class SubmissionRules
|
||||
{
|
||||
// RULE: a manually entered diploma cannot be auto-verified.
|
||||
public static string? RejectRegistratie(string diplomaHerkomst) =>
|
||||
diplomaHerkomst == "handmatig"
|
||||
? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling."
|
||||
: null;
|
||||
|
||||
// RULE: an application reporting zero worked hours is rejected.
|
||||
public static string? RejectZeroUren(int uren) =>
|
||||
uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null;
|
||||
|
||||
public static string NewReference() =>
|
||||
// ponytail: random reference is fine for a demo; a real system reserves it transactionally.
|
||||
"BIG-2026-" + Random.Shared.Next(100_000, 1_000_000);
|
||||
}
|
||||
94
backend/src/BigRegister.Api/Program.cs
Normal file
94
backend/src/BigRegister.Api/Program.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Diplomas;
|
||||
using BigRegister.Domain.Intake;
|
||||
using BigRegister.Domain.Registrations;
|
||||
using BigRegister.Domain.Submissions;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
c.SwaggerDoc("v1", new() { Title = "BIG-register BFF", Version = "v1" }));
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.ConfigureHttpJsonOptions(o =>
|
||||
{
|
||||
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||
o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||
});
|
||||
|
||||
const string SpaCors = "spa";
|
||||
builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
|
||||
p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod()));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
app.UseCors(SpaCors);
|
||||
|
||||
var api = app.MapGroup("/api");
|
||||
|
||||
// --- GET: screen-shaped reads. Decisions are computed here, never on the client. ---
|
||||
|
||||
api.MapGet("/dashboard-view", () =>
|
||||
{
|
||||
var reg = SeedData.Registration;
|
||||
var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today));
|
||||
return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(),
|
||||
new HerregistratieDecisionsDto(eligible, reason));
|
||||
});
|
||||
|
||||
api.MapGet("/notes", () =>
|
||||
SeedData.Notes.Select(n => new AantekeningDto(n.Type, n.Omschrijving, n.Datum)).ToList());
|
||||
|
||||
// BRP "no address" fallback would be `new BrpAddressDto(false, null)` — the seeded
|
||||
// citizen has one.
|
||||
api.MapGet("/brp/address", () => new BrpAddressDto(true, SeedData.BrpAddress.ToDto()));
|
||||
|
||||
api.MapGet("/duo/diplomas", () => new DuoLookupDto(
|
||||
SeedData.Diplomas.Select(d => d.ToDto()).ToList(),
|
||||
new ManualDiplomaPolicyDto(
|
||||
DiplomaRules.ManualProfessions(),
|
||||
DiplomaRules.ManualQuestions().Select(q => q.ToDto()).ToList())));
|
||||
|
||||
api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThreshold));
|
||||
|
||||
// --- POST: submits. The server is the authority; it re-validates and decides. ---
|
||||
|
||||
api.MapPost("/registrations", (RegistratieRequest req) =>
|
||||
{
|
||||
var reject = SubmissionRules.RejectRegistratie(req.DiplomaHerkomst);
|
||||
return reject is null
|
||||
? Results.Ok(new ReferentieResponse(SubmissionRules.NewReference()))
|
||||
: Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
|
||||
})
|
||||
.Produces<ReferentieResponse>()
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
api.MapPost("/herregistraties", (HerregistratieRequest req) =>
|
||||
{
|
||||
var reject = SubmissionRules.RejectZeroUren(req.Uren);
|
||||
return reject is null
|
||||
? Results.Ok(new ReferentieResponse(SubmissionRules.NewReference()))
|
||||
: Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
|
||||
})
|
||||
.Produces<ReferentieResponse>()
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
api.MapPost("/intakes", (IntakeRequest req) =>
|
||||
{
|
||||
var reject = SubmissionRules.RejectZeroUren(req.Uren);
|
||||
return reject is null
|
||||
? Results.Ok(new ReferentieResponse(SubmissionRules.NewReference()))
|
||||
: Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
|
||||
})
|
||||
.Produces<ReferentieResponse>()
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
app.Run();
|
||||
|
||||
// Exposed so the integration tests can spin up the app with WebApplicationFactory.
|
||||
public partial class Program { }
|
||||
15
backend/src/BigRegister.Api/Properties/launchSettings.json
Normal file
15
backend/src/BigRegister.Api/Properties/launchSettings.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5000",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
backend/src/BigRegister.Api/appsettings.Development.json
Normal file
8
backend/src/BigRegister.Api/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
backend/src/BigRegister.Api/appsettings.json
Normal file
9
backend/src/BigRegister.Api/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
545
backend/swagger.json
Normal file
545
backend/swagger.json
Normal file
@@ -0,0 +1,545 @@
|
||||
{
|
||||
"openapi": "3.0.4",
|
||||
"info": {
|
||||
"title": "BIG-register BFF",
|
||||
"version": "v1"
|
||||
},
|
||||
"paths": {
|
||||
"/api/dashboard-view": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DashboardViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/notes": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AantekeningDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/brp/address": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BrpAddressDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/duo/diplomas": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DuoLookupDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/intake/policy": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/IntakePolicyDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/registrations": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RegistratieRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReferentieResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Content",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/herregistraties": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HerregistratieRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReferentieResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Content",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/intakes": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/IntakeRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReferentieResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Content",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"AantekeningDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"omschrijving": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"datum": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"AdresDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"straat": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"postcode": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"woonplaats": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"BrpAddressDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"gevonden": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"adres": {
|
||||
"$ref": "#/components/schemas/AdresDto"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DashboardViewDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"registration": {
|
||||
"$ref": "#/components/schemas/RegistrationDto"
|
||||
},
|
||||
"person": {
|
||||
"$ref": "#/components/schemas/PersonDto"
|
||||
},
|
||||
"decisions": {
|
||||
"$ref": "#/components/schemas/HerregistratieDecisionsDto"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DuoDiplomaDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"naam": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"instelling": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"jaar": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"beroep": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"policyQuestions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PolicyQuestionDto"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"DuoLookupDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"diplomas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DuoDiplomaDto"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"handmatig": {
|
||||
"$ref": "#/components/schemas/ManualDiplomaPolicyDto"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HerregistratieDecisionsDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"eligibleForHerregistratie": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"herregistratieReason": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"HerregistratieRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uren": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"IntakePolicyDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scholingThreshold": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"IntakeRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uren": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ManualDiplomaPolicyDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"beroepen": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"policyQuestions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PolicyQuestionDto"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PersonDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"naam": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"geboortedatum": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"adres": {
|
||||
"$ref": "#/components/schemas/AdresDto"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PolicyQuestionDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"vraag": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ProblemDetails": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"nullable": true
|
||||
},
|
||||
"detail": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"instance": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": { }
|
||||
},
|
||||
"ReferentieResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"referentie": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RegistratieRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"diplomaHerkomst": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RegistrationDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bigNummer": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"naam": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"beroep": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"registratiedatum": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"geboortedatum": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/RegistrationStatusDto"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RegistrationStatusDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"herregistratieDatum": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"geschorstTot": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"reden": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"doorgehaaldOp": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
}
|
||||
]
|
||||
}
|
||||
26
backend/tests/BigRegister.Tests/BigRegister.Tests.csproj
Normal file
26
backend/tests/BigRegister.Tests/BigRegister.Tests.csproj
Normal file
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\BigRegister.Api\BigRegister.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
100
backend/tests/BigRegister.Tests/EndpointTests.cs
Normal file
100
backend/tests/BigRegister.Tests/EndpointTests.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using BigRegister.Api.Contracts;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
[Fact]
|
||||
public async Task DashboardView_computes_eligibility_decision()
|
||||
{
|
||||
var dto = await _client.GetFromJsonAsync<DashboardViewDto>("/api/dashboard-view");
|
||||
Assert.NotNull(dto);
|
||||
Assert.Equal("19012345601", dto.Registration.BigNummer);
|
||||
Assert.Equal("Geregistreerd", dto.Registration.Status.Tag);
|
||||
// seed deadline 2027-03-01 is within 12 months of "today" (2026) → eligible
|
||||
Assert.True(dto.Decisions.EligibleForHerregistratie);
|
||||
Assert.NotNull(dto.Decisions.HerregistratieReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Notes_returns_seeded_aantekeningen()
|
||||
{
|
||||
var notes = await _client.GetFromJsonAsync<List<AantekeningDto>>("/api/notes");
|
||||
Assert.Equal(3, notes!.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Brp_returns_address()
|
||||
{
|
||||
var dto = await _client.GetFromJsonAsync<BrpAddressDto>("/api/brp/address");
|
||||
Assert.True(dto!.Gevonden);
|
||||
Assert.Equal("2514 EA", dto.Adres!.Postcode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Duo_lookup_carries_server_decided_questions_and_professions()
|
||||
{
|
||||
var dto = await _client.GetFromJsonAsync<DuoLookupDto>("/api/duo/diplomas");
|
||||
Assert.NotNull(dto);
|
||||
|
||||
var english = dto.Diplomas.Single(d => d.Id == "d2");
|
||||
Assert.Equal("Arts", english.Beroep);
|
||||
Assert.Contains(english.PolicyQuestions, q => q.Id == "nl-taalvaardigheid");
|
||||
|
||||
var dutch = dto.Diplomas.Single(d => d.Id == "d1");
|
||||
Assert.Empty(dutch.PolicyQuestions);
|
||||
|
||||
// DUO "not found" fallback: an unlisted diploma → user uses the manual path,
|
||||
// which the same lookup provides (maximal question set + declarable professions).
|
||||
Assert.DoesNotContain(dto.Diplomas, d => d.Id == "unknown-id");
|
||||
Assert.Equal(3, dto.Handmatig.PolicyQuestions.Count);
|
||||
Assert.Equal(5, dto.Handmatig.Beroepen.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IntakePolicy_returns_scholing_threshold()
|
||||
{
|
||||
var dto = await _client.GetFromJsonAsync<IntakePolicyDto>("/api/intake/policy");
|
||||
Assert.Equal(1000, dto!.ScholingThreshold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Registration_with_duo_diploma_succeeds()
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync("/api/registrations", new RegistratieRequest("duo"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||
Assert.StartsWith("BIG-2026-", body!.Referentie);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Registration_with_manual_diploma_is_rejected_with_problem_details()
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync("/api/registrations", new RegistratieRequest("handmatig"));
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
|
||||
Assert.Contains("application/problem+json", res.Content.Headers.ContentType!.ToString());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/intakes")]
|
||||
[InlineData("/api/herregistraties")]
|
||||
public async Task Zero_hours_submission_is_rejected(string route)
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync(route, new { uren = 0 });
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/intakes")]
|
||||
[InlineData("/api/herregistraties")]
|
||||
public async Task Worked_hours_submission_succeeds(string route)
|
||||
{
|
||||
var res = await _client.PostAsJsonAsync(route, new { uren = 40 });
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
117
backend/tests/BigRegister.Tests/RuleTests.cs
Normal file
117
backend/tests/BigRegister.Tests/RuleTests.cs
Normal file
@@ -0,0 +1,117 @@
|
||||
using BigRegister.Domain.Diplomas;
|
||||
using BigRegister.Domain.Registrations;
|
||||
using BigRegister.Domain.Submissions;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
public class HerregistratieRuleTests
|
||||
{
|
||||
private static Registration Active(DateOnly deadline) => new(
|
||||
"19012345601", "Test", "Arts",
|
||||
new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14),
|
||||
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline));
|
||||
|
||||
[Fact]
|
||||
public void Eligible_within_window()
|
||||
{
|
||||
var (eligible, reason) = HerregistratieRule.Evaluate(
|
||||
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 6, 26));
|
||||
Assert.True(eligible);
|
||||
Assert.Contains("12 maanden", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Not_eligible_before_window()
|
||||
{
|
||||
var (eligible, _) = HerregistratieRule.Evaluate(
|
||||
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2025, 1, 1));
|
||||
Assert.False(eligible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Eligible_on_window_boundary()
|
||||
{
|
||||
// window opens exactly 12 months before the deadline
|
||||
var (eligible, _) = HerregistratieRule.Evaluate(
|
||||
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 3, 1));
|
||||
Assert.True(eligible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suspended_is_not_eligible()
|
||||
{
|
||||
var reg = Active(new DateOnly(2027, 3, 1)) with
|
||||
{
|
||||
Status = new RegistrationStatus(StatusTag.Geschorst, GeschorstTot: new DateOnly(2027, 1, 1), Reden: "x"),
|
||||
};
|
||||
var (eligible, _) = HerregistratieRule.Evaluate(reg, today: new DateOnly(2026, 6, 26));
|
||||
Assert.False(eligible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Status_consistency_invariant()
|
||||
{
|
||||
Assert.True(HerregistratieRule.IsStatusConsistent(
|
||||
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1))));
|
||||
Assert.False(HerregistratieRule.IsStatusConsistent(
|
||||
new RegistrationStatus(StatusTag.Geregistreerd)));
|
||||
}
|
||||
}
|
||||
|
||||
public class DiplomaRuleTests
|
||||
{
|
||||
private static Diploma Diploma(string opleiding, bool engelstalig) =>
|
||||
new("x", "naam", "instelling", 2011, opleiding, engelstalig);
|
||||
|
||||
[Theory]
|
||||
[InlineData("geneeskunde", "Arts")]
|
||||
[InlineData("verpleegkunde", "Verpleegkundige")]
|
||||
[InlineData("onbekend-programma", "Onbekend")]
|
||||
public void Profession_is_derived_from_program(string opleiding, string expected) =>
|
||||
Assert.Equal(expected, DiplomaRules.ProfessionFor(Diploma(opleiding, false)));
|
||||
|
||||
[Fact]
|
||||
public void English_diploma_requires_dutch_proficiency()
|
||||
{
|
||||
var questions = DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: true));
|
||||
Assert.Single(questions);
|
||||
Assert.Equal("nl-taalvaardigheid", questions[0].Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dutch_diploma_has_no_policy_questions() =>
|
||||
Assert.Empty(DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: false)));
|
||||
|
||||
[Fact]
|
||||
public void Manual_diploma_gets_maximal_set()
|
||||
{
|
||||
var questions = DiplomaRules.ManualQuestions();
|
||||
Assert.Equal(3, questions.Count);
|
||||
Assert.Equal(new[] { "nl-taalvaardigheid", "diploma-erkend", "toelichting" },
|
||||
questions.Select(q => q.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Manual_professions_match_known_programs() =>
|
||||
Assert.Equal(new[] { "Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts" },
|
||||
DiplomaRules.ManualProfessions());
|
||||
}
|
||||
|
||||
public class SubmissionRuleTests
|
||||
{
|
||||
[Fact]
|
||||
public void Manual_diploma_is_rejected() =>
|
||||
Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig"));
|
||||
|
||||
[Fact]
|
||||
public void Duo_diploma_is_accepted() =>
|
||||
Assert.Null(SubmissionRules.RejectRegistratie("duo"));
|
||||
|
||||
[Fact]
|
||||
public void Zero_hours_is_rejected() =>
|
||||
Assert.NotNull(SubmissionRules.RejectZeroUren(0));
|
||||
|
||||
[Fact]
|
||||
public void Worked_hours_are_accepted() =>
|
||||
Assert.Null(SubmissionRules.RejectZeroUren(40));
|
||||
}
|
||||
Reference in New Issue
Block a user