Files
atomic-design-poc/backend/tests/BigRegister.Tests/Domain/IntakeRuleTests.cs
T
ehoandClaude Sonnet 5 5d73ca21f6 feat(backend): enforce the scholing threshold server-side (WP-69)
ADR-0001's own canonical "config value" example was unenforced: GET
/intake/policy echoed ScholingThreshold, but no request DTO carried a
scholing answer, so the server had nothing to re-validate. A crafted
POST could skip a requirement the wizard presents as mandatory.

IntakePolicy.RejectIncompleteScholing is the authority — three-valued
completeness (below threshold an answer is required; "nee" is legal and
still submits; punten only belong to a followed scholing), living in the
class that owns the constant so scripts/check-seam.sh keeps guarding the
FE/BE literal pair. Both submit paths call it; a violation 400s with
ProblemDetails and leaves the aanvraag a Concept. Gated on
Type == "intake" (the endpoint's switch lumps herregistratie with
intake, which has no scholing question), and guarded by `reject is null`
so a zero-uren submission is still decided on its merits.

Also fixes a live FE bug in the same rule: validateStep required punten
whenever scholingGevolgd was 'ja' regardless of lageUren, while the
template renders those fields only when lageUren — so answering 'ja'
then raising uren either blocked the user on an invisible field or
emitted aanvullendeScholing: undefined alongside punten. punten now
derives from aanvullendeScholing, so that combination is unrepresentable
in ValidIntake.

Note: EndpointTests' Worked_hours_submission_succeeds was itself
asserting the vulnerable payload ({ uren: 40 }, no answer) and needed a
complete answer added; the zero-hours rows are the ordering regression
net and are unmodified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 22:42:14 +02:00

44 lines
1.9 KiB
C#

using BigRegister.Domain.Intake;
namespace BigRegister.Tests.Domain;
public class IntakeRuleTests
{
// The arguments ARE the Given (WP-69/bdd.mdx) — these degenerate to When/Then.
[Fact]
public void Below_threshold_with_no_answer_is_incomplete() =>
Assert.NotNull(IntakePolicy.RejectIncompleteScholing(999, aanvullendeScholing: null, scholingPunten: null));
[Fact]
public void At_the_threshold_no_answer_is_required() =>
// Pins `<` vs `<=` — lageUren's own boundary.
Assert.Null(IntakePolicy.RejectIncompleteScholing(1000, aanvullendeScholing: null, scholingPunten: null));
[Fact]
public void Niet_gevolgd_is_a_complete_answer_below_threshold() =>
// "nee" is legal — this WP is completeness, not merit (§1's scope).
Assert.Null(IntakePolicy.RejectIncompleteScholing(500, aanvullendeScholing: false, scholingPunten: null));
[Fact]
public void Gevolgd_without_punten_is_incomplete() =>
Assert.NotNull(IntakePolicy.RejectIncompleteScholing(500, aanvullendeScholing: true, scholingPunten: null));
[Fact]
public void Gevolgd_with_zero_punten_is_valid() =>
Assert.Null(IntakePolicy.RejectIncompleteScholing(500, aanvullendeScholing: true, scholingPunten: 0));
[Fact]
public void Gevolgd_with_negative_punten_is_refused() =>
Assert.NotNull(IntakePolicy.RejectIncompleteScholing(500, aanvullendeScholing: true, scholingPunten: -1));
[Theory]
[InlineData(null)] // stale-punten shape (§6): raising uren above threshold left an unanswered
// question but punten still set from when it was visible
[InlineData(false)]
public void Punten_without_gevolgd_is_refused(bool? aanvullendeScholing) =>
// uren ABOVE threshold so the "missing answer" branch can't also explain the rejection —
// this row isolates the "stale punten" rule on its own.
Assert.NotNull(IntakePolicy.RejectIncompleteScholing(1500, aanvullendeScholing, scholingPunten: 150));
}