style: format backend with dotnet format

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 13:39:31 +02:00
parent e82309786d
commit 1137f59f7b
17 changed files with 1135 additions and 1129 deletions

View File

@@ -9,53 +9,53 @@ namespace BigRegister.Api.Contracts;
/// <summary>Domain → wire DTO mapping (the anti-corruption boundary, server side).</summary> /// <summary>Domain → wire DTO mapping (the anti-corruption boundary, server side).</summary>
public static class Mappers public static class Mappers
{ {
private static string D(DateOnly d) => d.ToString("yyyy-MM-dd"); private static string D(DateOnly d) => d.ToString("yyyy-MM-dd");
public static RegistrationStatusDto ToDto(this RegistrationStatus s) => new( public static RegistrationStatusDto ToDto(this RegistrationStatus s) => new(
Tag: s.Tag.ToString(), Tag: s.Tag.ToString(),
HerregistratieDatum: s.HerregistratieDatum is { } h ? D(h) : null, HerregistratieDatum: s.HerregistratieDatum is { } h ? D(h) : null,
GeschorstTot: s.GeschorstTot is { } g ? D(g) : null, GeschorstTot: s.GeschorstTot is { } g ? D(g) : null,
Reden: s.Reden, Reden: s.Reden,
DoorgehaaldOp: s.DoorgehaaldOp is { } x ? D(x) : null); DoorgehaaldOp: s.DoorgehaaldOp is { } x ? D(x) : null);
public static RegistrationDto ToDto(this Registration r) => new( public static RegistrationDto ToDto(this Registration r) => new(
r.BigNummer, r.Naam, r.Beroep, D(r.Registratiedatum), D(r.Geboortedatum), r.Status.ToDto()); 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 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 PersonDto ToDto(this Person p) => new(p.Naam, D(p.Geboortedatum), p.Adres.ToDto());
public static PolicyQuestionDto ToDto(this PolicyQuestion q) => new( public static PolicyQuestionDto ToDto(this PolicyQuestion q) => new(
q.Id, q.Vraag, q.Type == QuestionType.JaNee ? "ja-nee" : "tekst"); q.Id, q.Vraag, q.Type == QuestionType.JaNee ? "ja-nee" : "tekst");
public static DuoDiplomaDto ToDto(this Diploma d) => new( public static DuoDiplomaDto ToDto(this Diploma d) => new(
d.Id, d.Naam, d.Instelling, d.Jaar, d.Id, d.Naam, d.Instelling, d.Jaar,
DiplomaRules.ProfessionFor(d), DiplomaRules.ProfessionFor(d),
DiplomaRules.QuestionsFor(d).Select(q => q.ToDto()).ToList()); DiplomaRules.QuestionsFor(d).Select(q => q.ToDto()).ToList());
public static DocumentCategoryDto ToDto(this DocumentCategory c) => new( public static DocumentCategoryDto ToDto(this DocumentCategory c) => new(
c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery); c.CategoryId, c.Label, c.Description, c.Required, c.AcceptedTypes, c.MaxSizeMb, c.Multiple, c.AllowPostDelivery);
// Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports // Aanvraag status is COMPUTED ON READ: an auto-approvable submission reports
// Goedgekeurd once past the processing window, else In behandeling; a manual case // Goedgekeurd once past the processing window, else In behandeling; a manual case
// stays In behandeling forever (awaits the unbuilt backoffice). Pure — testable // stays In behandeling forever (awaits the unbuilt backoffice). Pure — testable
// by passing different `now` values without waiting for the wall clock. // by passing different `now` values without waiting for the wall clock.
public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now) public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now)
{ {
if (!a.Submitted) if (!a.Submitted)
return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount); return new("Concept", StepIndex: a.StepIndex, StepCount: a.StepCount);
if (a.Reden is not null) if (a.Reden is not null)
return new("Afgewezen", Referentie: a.Referentie, Reden: a.Reden); return new("Afgewezen", Referentie: a.Referentie, Reden: a.Reden);
if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow) if (a.AutoApprovable && now > a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow)
return new("Goedgekeurd", Referentie: a.Referentie); return new("Goedgekeurd", Referentie: a.Referentie);
return new("InBehandeling", Referentie: a.Referentie, Manual: !a.AutoApprovable); return new("InBehandeling", Referentie: a.Referentie, Manual: !a.AutoApprovable);
} }
public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new( public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds, a.Id, a.Type, a.ToStatusDto(now), a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o")); a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new( public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), a.Draft, a.DocumentIds, a.Id, a.Type, a.ToStatusDto(now), a.Draft, a.DocumentIds,
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o")); a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o"));
} }

View File

@@ -12,20 +12,20 @@ namespace BigRegister.Api.Data;
/// </summary> /// </summary>
public sealed class Aanvraag public sealed class Aanvraag
{ {
public required string Id { get; init; } public required string Id { get; init; }
public required string Type { get; init; } // registratie | herregistratie | intake public required string Type { get; init; } // registratie | herregistratie | intake
public required string Owner { get; init; } public required string Owner { get; init; }
public JsonElement? Draft { get; set; } // opaque wizard machine snapshot (Concept only) public JsonElement? Draft { get; set; } // opaque wizard machine snapshot (Concept only)
public int StepIndex { get; set; } public int StepIndex { get; set; }
public int StepCount { get; set; } public int StepCount { get; set; }
public List<string> DocumentIds { get; set; } = new(); public List<string> DocumentIds { get; set; } = new();
public string? Referentie { get; set; } // set on submit public string? Referentie { get; set; } // set on submit
public bool AutoApprovable { get; set; } // set on submit: duo (registratie) / other types public bool AutoApprovable { get; set; } // set on submit: duo (registratie) / other types
public string? Reden { get; set; } // set on submit when rejected → Afgewezen public string? Reden { get; set; } // set on submit when rejected → Afgewezen
public bool Submitted { get; set; } public bool Submitted { get; set; }
public DateTimeOffset CreatedAt { get; init; } public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset UpdatedAt { get; set; } public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset? SubmittedAt { get; set; } public DateTimeOffset? SubmittedAt { get; set; }
} }
/// <summary> /// <summary>
@@ -35,76 +35,76 @@ public sealed class Aanvraag
/// </summary> /// </summary>
public static class ApplicationStore public static class ApplicationStore
{ {
/// After this window an auto-approvable submission reports Goedgekeurd (computed on read). /// After this window an auto-approvable submission reports Goedgekeurd (computed on read).
public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8); public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8);
private static readonly Dictionary<string, Aanvraag> _apps = new(); private static readonly Dictionary<string, Aanvraag> _apps = new();
private static readonly object _gate = new(); private static readonly object _gate = new();
public static Aanvraag Create(string type, string owner) public static Aanvraag Create(string type, string owner)
{
var now = DateTimeOffset.UtcNow;
var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now };
lock (_gate) _apps[a.Id] = a;
return a;
}
public static Aanvraag? Get(string id, string owner)
{
lock (_gate) return _apps.TryGetValue(id, out var a) && a.Owner == owner ? a : null;
}
public static IReadOnlyList<Aanvraag> List(string owner)
{
lock (_gate) return _apps.Values.Where(a => a.Owner == owner).ToList();
}
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable.
public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList<string>? documentIds)
{
lock (_gate)
{ {
var now = DateTimeOffset.UtcNow; if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return false;
var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now }; a.Draft = draft.Clone(); // detach from the request's JsonDocument (disposed after the call)
lock (_gate) _apps[a.Id] = a; a.StepIndex = stepIndex;
return a; a.StepCount = stepCount;
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
a.UpdatedAt = DateTimeOffset.UtcNow;
return true;
} }
}
public static Aanvraag? Get(string id, string owner) /// Cancel a Concept: remove it and delete its (unlinked) documents. Linked docs
/// (belonging to a submitted aanvraag) are left untouched by DocumentStore.
public static bool Delete(string id, string owner)
{
List<string> docs;
lock (_gate)
{ {
lock (_gate) return _apps.TryGetValue(id, out var a) && a.Owner == owner ? a : null; if (!_apps.TryGetValue(id, out var a) || a.Owner != owner) return false;
docs = a.DocumentIds.ToList();
_apps.Remove(id);
} }
foreach (var d in docs) DocumentStore.DeleteOwned(d, owner);
return true;
}
public static IReadOnlyList<Aanvraag> List(string owner) /// Submit transition. reject != null → Afgewezen; else accepted (In behandeling,
/// auto-advancing to Goedgekeurd after the window when autoApprovable). Returns null
/// if the aanvraag is gone or already submitted (idempotency guard).
public static Aanvraag? Submit(string id, string owner, string? reject, bool autoApprovable, IReadOnlyList<string>? documentIds)
{
lock (_gate)
{ {
lock (_gate) return _apps.Values.Where(a => a.Owner == owner).ToList(); if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return null;
} a.Submitted = true;
a.SubmittedAt = DateTimeOffset.UtcNow;
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable. a.UpdatedAt = a.SubmittedAt.Value;
public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList<string>? documentIds) a.Referentie = SubmissionRules.NewReference();
{ a.AutoApprovable = autoApprovable;
lock (_gate) a.Reden = reject;
{ if (documentIds is not null) a.DocumentIds = documentIds.ToList();
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return false; return a;
a.Draft = draft.Clone(); // detach from the request's JsonDocument (disposed after the call)
a.StepIndex = stepIndex;
a.StepCount = stepCount;
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
a.UpdatedAt = DateTimeOffset.UtcNow;
return true;
}
}
/// Cancel a Concept: remove it and delete its (unlinked) documents. Linked docs
/// (belonging to a submitted aanvraag) are left untouched by DocumentStore.
public static bool Delete(string id, string owner)
{
List<string> docs;
lock (_gate)
{
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner) return false;
docs = a.DocumentIds.ToList();
_apps.Remove(id);
}
foreach (var d in docs) DocumentStore.DeleteOwned(d, owner);
return true;
}
/// Submit transition. reject != null → Afgewezen; else accepted (In behandeling,
/// auto-advancing to Goedgekeurd after the window when autoApprovable). Returns null
/// if the aanvraag is gone or already submitted (idempotency guard).
public static Aanvraag? Submit(string id, string owner, string? reject, bool autoApprovable, IReadOnlyList<string>? documentIds)
{
lock (_gate)
{
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return null;
a.Submitted = true;
a.SubmittedAt = DateTimeOffset.UtcNow;
a.UpdatedAt = a.SubmittedAt.Value;
a.Referentie = SubmissionRules.NewReference();
a.AutoApprovable = autoApprovable;
a.Reden = reject;
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
return a;
}
} }
}
} }

View File

@@ -11,134 +11,134 @@ namespace BigRegister.Api.Data;
/// </summary> /// </summary>
public sealed class BriefEntity public sealed class BriefEntity
{ {
public required string BriefId { get; init; } public required string BriefId { get; init; }
public required string Owner { get; init; } public required string Owner { get; init; }
public required string Beroep { get; init; } public required string Beroep { get; init; }
public required string TemplateId { get; init; } public required string TemplateId { get; init; }
public required string DrafterId { get; init; } public required string DrafterId { get; init; }
public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; } public required IReadOnlyList<PlaceholderDefDto> Placeholders { get; init; }
public List<LetterSectionDto> Sections { get; set; } = new(); public List<LetterSectionDto> Sections { get; set; } = new();
public BriefStatusDto Status { get; set; } = new("draft"); public BriefStatusDto Status { get; set; } = new("draft");
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId); public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
} }
public static class BriefStore public static class BriefStore
{ {
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle). // Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
public const string DrafterId = "demo-drafter"; public const string DrafterId = "demo-drafter";
public const string ApproverId = "demo-approver"; public const string ApproverId = "demo-approver";
public enum Outcome { Ok, Forbidden, Conflict } public enum Outcome { Ok, Forbidden, Conflict }
private static readonly Dictionary<string, BriefEntity> _byOwner = new(); private static readonly Dictionary<string, BriefEntity> _byOwner = new();
private static readonly object _gate = new(); private static readonly object _gate = new();
public static BriefEntity GetOrCreate(string owner) public static BriefEntity GetOrCreate(string owner)
{
lock (_gate)
{ {
lock (_gate) if (_byOwner.TryGetValue(owner, out var existing)) return existing;
{ var created = BriefSeed.NewBrief(owner);
if (_byOwner.TryGetValue(owner, out var existing)) return existing; _byOwner[owner] = created;
var created = BriefSeed.NewBrief(owner); return created;
_byOwner[owner] = created;
return created;
}
} }
}
/// Save draft content. Drafter-only; only editable in draft/rejected; a rejected /// Save draft content. Drafter-only; only editable in draft/rejected; a rejected
/// letter reopens to draft on edit (mirrors the FE reducer). /// letter reopens to draft on edit (mirrors the FE reducer).
public static (Outcome, BriefEntity?) Save(string owner, IReadOnlyList<LetterSectionDto> sections, bool isDrafter) public static (Outcome, BriefEntity?) Save(string owner, IReadOnlyList<LetterSectionDto> sections, bool isDrafter)
{
lock (_gate)
{ {
lock (_gate) if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
{ if (!isDrafter) return (Outcome.Forbidden, null);
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null); if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null); e.Sections = sections.ToList();
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null); if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
e.Sections = sections.ToList(); return (Outcome.Ok, e);
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
return (Outcome.Ok, e);
}
} }
}
public static (Outcome, BriefEntity?) Submit(string owner, bool isDrafter, string at) public static (Outcome, BriefEntity?) Submit(string owner, bool isDrafter, string at)
{
lock (_gate)
{ {
lock (_gate) if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
{ if (!isDrafter) return (Outcome.Forbidden, null);
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null); if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null); e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null); return (Outcome.Ok, e);
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
return (Outcome.Ok, e);
}
} }
}
public static (Outcome, BriefEntity?) Approve(string owner, string actingId, string at) => public static (Outcome, BriefEntity?) Approve(string owner, string actingId, string at) =>
Review(owner, actingId, e => new BriefStatusDto("approved", ApprovedBy: actingId, ApprovedAt: at)); Review(owner, actingId, e => new BriefStatusDto("approved", ApprovedBy: actingId, ApprovedAt: at));
public static (Outcome, BriefEntity?) Reject(string owner, string actingId, string comments, string at) => public static (Outcome, BriefEntity?) Reject(string owner, string actingId, string comments, string at) =>
Review(owner, actingId, e => new BriefStatusDto("rejected", RejectedBy: actingId, RejectedAt: at, Comments: comments)); Review(owner, actingId, e => new BriefStatusDto("rejected", RejectedBy: actingId, RejectedAt: at, Comments: comments));
public static (Outcome, BriefEntity?) Send(string owner, string at) public static (Outcome, BriefEntity?) Send(string owner, string at)
{
lock (_gate)
{ {
lock (_gate) if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
{ if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null); e.Status = new BriefStatusDto("sent", SentAt: at);
if (e.Status.Tag != "approved") return (Outcome.Conflict, null); return (Outcome.Ok, e);
e.Status = new BriefStatusDto("sent", SentAt: at);
return (Outcome.Ok, e);
}
} }
}
/// Test seam: clear the demo brief between tests. /// Test seam: clear the demo brief between tests.
public static void Reset() public static void Reset()
{
lock (_gate) _byOwner.Clear();
}
/// Demo affordance: drop the owner's brief and create a fresh one. No guards — a
/// "start over" for the showcase, not a real workflow transition.
public static BriefEntity ResetAndCreate(string owner)
{
lock (_gate)
{ {
lock (_gate) _byOwner.Clear(); _byOwner.Remove(owner);
var created = BriefSeed.NewBrief(owner);
_byOwner[owner] = created;
return created;
} }
}
/// Demo affordance: drop the owner's brief and create a fresh one. No guards — a // Approve/reject share the guard: must be submitted, and the approver must differ
/// "start over" for the showcase, not a real workflow transition. // from the drafter (a drafter cannot approve their own letter).
public static BriefEntity ResetAndCreate(string owner) private static (Outcome, BriefEntity?) Review(string owner, string actingId, Func<BriefEntity, BriefStatusDto> next)
{
lock (_gate)
{ {
lock (_gate) if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
{ if (actingId == e.DrafterId) return (Outcome.Forbidden, null);
_byOwner.Remove(owner); if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
var created = BriefSeed.NewBrief(owner); e.Status = next(e);
_byOwner[owner] = created; return (Outcome.Ok, e);
return created;
}
} }
}
// Approve/reject share the guard: must be submitted, and the approver must differ private static bool RequiredFilled(BriefEntity e) => e.Sections.All(s => !s.Required || s.Blocks.Count > 0);
// from the drafter (a drafter cannot approve their own letter).
private static (Outcome, BriefEntity?) Review(string owner, string actingId, Func<BriefEntity, BriefStatusDto> next)
{
lock (_gate)
{
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
if (actingId == e.DrafterId) return (Outcome.Forbidden, null);
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
e.Status = next(e);
return (Outcome.Ok, e);
}
}
private static bool RequiredFilled(BriefEntity e) => e.Sections.All(s => !s.Required || s.Blocks.Count > 0);
} }
/// <summary>Seeded template (sections + placeholder fields) and passage library.</summary> /// <summary>Seeded template (sections + placeholder fields) and passage library.</summary>
public static class BriefSeed public static class BriefSeed
{ {
public const string TemplateId = "besluit-arts"; public const string TemplateId = "besluit-arts";
public const string Beroep = "arts"; public const string Beroep = "arts";
private static RichTextNodeDto T(string t) => new("text", Text: t); private static RichTextNodeDto T(string t) => new("text", Text: t);
private static RichTextNodeDto P(string key) => new("placeholder", Key: key); private static RichTextNodeDto P(string key) => new("placeholder", Key: key);
private static RichTextBlockDto Block(params RichTextNodeDto[] nodes) => new(new[] { new ParagraphDto(nodes) }); private static RichTextBlockDto Block(params RichTextNodeDto[] nodes) => new(new[] { new ParagraphDto(nodes) });
// The template's valid placeholder fields. Two are flagged to exercise the linter: // The template's valid placeholder fields. Two are flagged to exercise the linter:
// a deprecated field and one not fillable for this beroep. // a deprecated field and one not fillable for this beroep.
private static readonly IReadOnlyList<PlaceholderDefDto> Placeholders = new[] private static readonly IReadOnlyList<PlaceholderDefDto> Placeholders = new[]
{ {
new PlaceholderDefDto("naam_zorgverlener", "Naam zorgverlener", true), new PlaceholderDefDto("naam_zorgverlener", "Naam zorgverlener", true),
new PlaceholderDefDto("datum", "Datum", true), new PlaceholderDefDto("datum", "Datum", true),
new PlaceholderDefDto("big_nummer", "BIG-nummer", true), new PlaceholderDefDto("big_nummer", "BIG-nummer", true),
@@ -147,18 +147,18 @@ public static class BriefSeed
new PlaceholderDefDto("specialisme_code", "Specialismecode", true, Fillable: false), new PlaceholderDefDto("specialisme_code", "Specialismecode", true, Fillable: false),
}; };
public static BriefEntity NewBrief(string owner) => new() public static BriefEntity NewBrief(string owner) => new()
{ {
BriefId = Guid.NewGuid().ToString(), BriefId = Guid.NewGuid().ToString(),
Owner = owner, Owner = owner,
Beroep = Beroep, Beroep = Beroep,
TemplateId = TemplateId, TemplateId = TemplateId,
DrafterId = BriefStore.DrafterId, DrafterId = BriefStore.DrafterId,
Placeholders = Placeholders, Placeholders = Placeholders,
// aanhef + slot are predefined, locked template text (prefilled); the drafter // aanhef + slot are predefined, locked template text (prefilled); the drafter
// composes only the unlocked `kern`. Save trusts the FE not to mutate locked // composes only the unlocked `kern`. Save trusts the FE not to mutate locked
// sections (FE-authoritative for this POC — the FE reducer also refuses it). // sections (FE-authoritative for this POC — the FE reducer also refuses it).
Sections = new() Sections = new()
{ {
new("aanhef", "Aanhef", true, new List<LetterBlockDto> new("aanhef", "Aanhef", true, new List<LetterBlockDto>
{ {
@@ -170,13 +170,13 @@ public static class BriefSeed
new("freeText", "slot-1", Block(T("Met vriendelijke groet,"))), new("freeText", "slot-1", Block(T("Met vriendelijke groet,"))),
}, Locked: true), }, Locked: true),
}, },
Status = new BriefStatusDto("draft"), Status = new BriefStatusDto("draft"),
}; };
/// Global passages plus those scoped to the given beroep. /// Global passages plus those scoped to the given beroep.
public static IReadOnlyList<LibraryPassageDto> PassagesFor(string beroep) public static IReadOnlyList<LibraryPassageDto> PassagesFor(string beroep)
{ {
var all = new List<LibraryPassageDto> var all = new List<LibraryPassageDto>
{ {
new("p-aanhef-1", "global", "aanhef", "Standaard aanhef", new("p-aanhef-1", "global", "aanhef", "Standaard aanhef",
Block(T("Geachte heer/mevrouw "), P("naam_zorgverlener"), T(",")), 1), Block(T("Geachte heer/mevrouw "), P("naam_zorgverlener"), T(",")), 1),
@@ -193,6 +193,6 @@ public static class BriefSeed
new("p-slot-code", "global", "slot", "Specialismecode (niet invulbaar)", new("p-slot-code", "global", "slot", "Specialismecode (niet invulbaar)",
Block(T("Specialisme: "), P("specialisme_code"), T(".")), 1), Block(T("Specialisme: "), P("specialisme_code"), T(".")), 1),
}; };
return all.Where(p => p.Scope == "global" || p.Beroep == beroep).ToList(); return all.Where(p => p.Scope == "global" || p.Beroep == beroep).ToList();
} }
} }

View File

@@ -10,7 +10,7 @@ public sealed record StoredDocument(
string DocumentId, string LocalId, string CategoryId, string WizardId, string DocumentId, string LocalId, string CategoryId, string WizardId,
string FileName, long SizeBytes, string ContentType, byte[] Content, string Owner, DateTimeOffset UploadedAt) string FileName, long SizeBytes, string ContentType, byte[] Content, string Owner, DateTimeOffset UploadedAt)
{ {
public bool Linked { get; set; } public bool Linked { get; set; }
} }
public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor); public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor);
@@ -22,81 +22,81 @@ public sealed record AuditEntry(DateTimeOffset At, string Action, string Documen
/// </summary> /// </summary>
public static class DocumentStore public static class DocumentStore
{ {
/// The single seeded user (the demo has no real auth; ownership = this id). /// The single seeded user (the demo has no real auth; ownership = this id).
public const string DemoOwner = "19012345601"; public const string DemoOwner = "19012345601";
private static readonly Dictionary<string, StoredDocument> _docs = new(); private static readonly Dictionary<string, StoredDocument> _docs = new();
private static readonly List<AuditEntry> _audit = new(); private static readonly List<AuditEntry> _audit = new();
private static readonly object _gate = new(); private static readonly object _gate = new();
public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, string contentType, byte[] content, string owner) public static StoredDocument Add(string localId, string categoryId, string wizardId, string fileName, string contentType, byte[] content, string owner)
{
var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, content.LongLength, contentType, content, owner, DateTimeOffset.UtcNow);
lock (_gate) _docs[doc.DocumentId] = doc;
Audit("upload", doc.DocumentId, categoryId, owner);
return doc;
}
public static StoredDocument? Get(string documentId)
{
lock (_gate) return _docs.TryGetValue(documentId, out var d) ? d : null;
}
/// Status for the poll-on-return pattern: a known localId is "complete" (it
/// arrived), an unknown one is still in flight / never started.
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
{
var set = localIds.ToHashSet();
lock (_gate) return _docs.Values.Where(d => set.Contains(d.LocalId)).ToList();
}
/// Mark digital documents as linked to a finalised submission (blocks user delete).
public static void Link(IEnumerable<string> documentIds)
{
lock (_gate)
foreach (var id in documentIds)
if (_docs.TryGetValue(id, out var d)) d.Linked = true;
}
public enum DeleteResult { Ok, NotFound, Linked }
/// User delete: owner-scoped; blocked once linked to a finalised submission.
public static DeleteResult DeleteOwned(string documentId, string owner)
{
string categoryId;
lock (_gate)
{ {
var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, content.LongLength, contentType, content, owner, DateTimeOffset.UtcNow); if (!_docs.TryGetValue(documentId, out var d) || d.Owner != owner) return DeleteResult.NotFound;
lock (_gate) _docs[doc.DocumentId] = doc; if (d.Linked) return DeleteResult.Linked;
Audit("upload", doc.DocumentId, categoryId, owner); categoryId = d.CategoryId;
return doc; _docs.Remove(documentId);
} }
Audit("delete-user", documentId, categoryId, owner);
return DeleteResult.Ok;
}
public static StoredDocument? Get(string documentId) /// Admin delete: bypasses ownership, unlinks, and (seam) flags the submission for
/// review so a caseworker is notified. Returns false if the document is unknown.
public static bool AdminDelete(string documentId, string actor)
{
string categoryId;
lock (_gate)
{ {
lock (_gate) return _docs.TryGetValue(documentId, out var d) ? d : null; if (!_docs.TryGetValue(documentId, out var d)) return false;
categoryId = d.CategoryId;
_docs.Remove(documentId);
} }
Audit("delete-admin", documentId, categoryId, actor);
return true;
}
/// Status for the poll-on-return pattern: a known localId is "complete" (it public static void Audit(string action, string documentId, string categoryId, string actor)
/// arrived), an unknown one is still in flight / never started. {
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds) lock (_gate) _audit.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
{ }
var set = localIds.ToHashSet();
lock (_gate) return _docs.Values.Where(d => set.Contains(d.LocalId)).ToList();
}
/// Mark digital documents as linked to a finalised submission (blocks user delete). public static IReadOnlyList<AuditEntry> AuditLog
public static void Link(IEnumerable<string> documentIds) {
{ get { lock (_gate) return _audit.ToList(); }
lock (_gate) }
foreach (var id in documentIds)
if (_docs.TryGetValue(id, out var d)) d.Linked = true;
}
public enum DeleteResult { Ok, NotFound, Linked }
/// User delete: owner-scoped; blocked once linked to a finalised submission.
public static DeleteResult DeleteOwned(string documentId, string owner)
{
string categoryId;
lock (_gate)
{
if (!_docs.TryGetValue(documentId, out var d) || d.Owner != owner) return DeleteResult.NotFound;
if (d.Linked) return DeleteResult.Linked;
categoryId = d.CategoryId;
_docs.Remove(documentId);
}
Audit("delete-user", documentId, categoryId, owner);
return DeleteResult.Ok;
}
/// Admin delete: bypasses ownership, unlinks, and (seam) flags the submission for
/// review so a caseworker is notified. Returns false if the document is unknown.
public static bool AdminDelete(string documentId, string actor)
{
string categoryId;
lock (_gate)
{
if (!_docs.TryGetValue(documentId, out var d)) return false;
categoryId = d.CategoryId;
_docs.Remove(documentId);
}
Audit("delete-admin", documentId, categoryId, actor);
return true;
}
public static void Audit(string action, string documentId, string categoryId, string actor)
{
lock (_gate) _audit.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
}
public static IReadOnlyList<AuditEntry> AuditLog
{
get { lock (_gate) return _audit.ToList(); }
}
} }

View File

@@ -10,31 +10,31 @@ namespace BigRegister.Api.Data;
/// </summary> /// </summary>
public static class SeedData public static class SeedData
{ {
public static readonly Registration Registration = new( public static readonly Registration Registration = new(
BigNummer: "19012345601", BigNummer: "19012345601",
Naam: "Dr. A. (Anna) de Vries", Naam: "Dr. A. (Anna) de Vries",
Beroep: "Arts", Beroep: "Arts",
Registratiedatum: new DateOnly(2012, 9, 1), Registratiedatum: new DateOnly(2012, 9, 1),
Geboortedatum: new DateOnly(1985, 3, 14), Geboortedatum: new DateOnly(1985, 3, 14),
Status: new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1))); Status: new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)));
public static readonly Person Person = new( public static readonly Person Person = new(
Naam: "Dr. A. (Anna) de Vries", Naam: "Dr. A. (Anna) de Vries",
Geboortedatum: new DateOnly(1985, 3, 14), Geboortedatum: new DateOnly(1985, 3, 14),
Adres: new Adres("Lange Voorhout 9", "2514 EA", "Den Haag")); Adres: new Adres("Lange Voorhout 9", "2514 EA", "Den Haag"));
/// <summary>The address BRP returns for the seeded citizen.</summary> /// <summary>The address BRP returns for the seeded citizen.</summary>
public static readonly Adres BrpAddress = Person.Adres; public static readonly Adres BrpAddress = Person.Adres;
public static readonly IReadOnlyList<Diploma> Diplomas = new[] public static readonly IReadOnlyList<Diploma> Diplomas = new[]
{ {
new Diploma("d1", "Geneeskunde", "Universiteit Leiden", 2011, "geneeskunde", Engelstalig: false), new Diploma("d1", "Geneeskunde", "Universiteit Leiden", 2011, "geneeskunde", Engelstalig: false),
new Diploma("d2", "Medicine (MBChB)", "University of Edinburgh", 2013, "geneeskunde", Engelstalig: true), new Diploma("d2", "Medicine (MBChB)", "University of Edinburgh", 2013, "geneeskunde", Engelstalig: true),
new Diploma("d3", "HBO-Verpleegkunde", "Hogeschool Utrecht", 2016, "verpleegkunde", Engelstalig: false), new Diploma("d3", "HBO-Verpleegkunde", "Hogeschool Utrecht", 2016, "verpleegkunde", Engelstalig: false),
}; };
public static readonly IReadOnlyList<(string Type, string Omschrijving, string Datum)> Notes = new[] public static readonly IReadOnlyList<(string Type, string Omschrijving, string Datum)> Notes = new[]
{ {
("Specialisme", "Huisartsgeneeskunde", "2016-04-12"), ("Specialisme", "Huisartsgeneeskunde", "2016-04-12"),
("Aantekening", "Erkend opleider huisartsgeneeskunde", "2019-01-08"), ("Aantekening", "Erkend opleider huisartsgeneeskunde", "2019-01-08"),
("Specialisme", "Spoedeisende hulp (kaderopleiding)", "2021-06-30"), ("Specialisme", "Spoedeisende hulp (kaderopleiding)", "2021-06-30"),

View File

@@ -2,8 +2,8 @@ namespace BigRegister.Domain.Diplomas;
public enum QuestionType public enum QuestionType
{ {
JaNee, JaNee,
Tekst, Tekst,
} }
public sealed record PolicyQuestion(string Id, string Vraag, QuestionType Type); public sealed record PolicyQuestion(string Id, string Vraag, QuestionType Type);

View File

@@ -8,61 +8,61 @@ namespace BigRegister.Domain.Diplomas;
/// </summary> /// </summary>
public static class DiplomaRules public static class DiplomaRules
{ {
// RULE: study program → BIG profession. // RULE: study program → BIG profession.
private static readonly Dictionary<string, string> ProfessionByProgram = new(StringComparer.OrdinalIgnoreCase) private static readonly Dictionary<string, string> ProfessionByProgram = new(StringComparer.OrdinalIgnoreCase)
{ {
["geneeskunde"] = "Arts", ["geneeskunde"] = "Arts",
["verpleegkunde"] = "Verpleegkundige", ["verpleegkunde"] = "Verpleegkundige",
["fysiotherapie"] = "Fysiotherapeut", ["fysiotherapie"] = "Fysiotherapeut",
["farmacie"] = "Apotheker", ["farmacie"] = "Apotheker",
["tandheelkunde"] = "Tandarts", ["tandheelkunde"] = "Tandarts",
}; };
public static string ProfessionFor(Diploma d) => public static string ProfessionFor(Diploma d) =>
ProfessionByProgram.TryGetValue(d.Opleiding, out var beroep) ? beroep : "Onbekend"; ProfessionByProgram.TryGetValue(d.Opleiding, out var beroep) ? beroep : "Onbekend";
/// <summary>Professions a user may declare for a manual (unlisted) diploma.</summary> /// <summary>Professions a user may declare for a manual (unlisted) diploma.</summary>
public static IReadOnlyList<string> ManualProfessions() => public static IReadOnlyList<string> ManualProfessions() =>
ProfessionByProgram.Values.Distinct().ToList(); ProfessionByProgram.Values.Distinct().ToList();
// --- Policy questions (geldigheidsvragen) --- // --- Policy questions (geldigheidsvragen) ---
private static readonly PolicyQuestion NlTaalEngelstalig = new( private static readonly PolicyQuestion NlTaalEngelstalig = new(
"nl-taalvaardigheid", "nl-taalvaardigheid",
"Uw opleiding was Engelstalig. Beheerst u de Nederlandse taal op het vereiste niveau (B2)?", "Uw opleiding was Engelstalig. Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
QuestionType.JaNee); QuestionType.JaNee);
private static readonly PolicyQuestion NlTaalManual = new( private static readonly PolicyQuestion NlTaalManual = new(
"nl-taalvaardigheid", "nl-taalvaardigheid",
"Beheerst u de Nederlandse taal op het vereiste niveau (B2)?", "Beheerst u de Nederlandse taal op het vereiste niveau (B2)?",
QuestionType.JaNee); QuestionType.JaNee);
private static readonly PolicyQuestion DiplomaErkend = new( private static readonly PolicyQuestion DiplomaErkend = new(
"diploma-erkend", "diploma-erkend",
"Is uw diploma erkend door de Nederlandse overheid (bijv. via Nuffic)?", "Is uw diploma erkend door de Nederlandse overheid (bijv. via Nuffic)?",
QuestionType.JaNee); QuestionType.JaNee);
private static readonly PolicyQuestion Toelichting = new( private static readonly PolicyQuestion Toelichting = new(
"toelichting", "toelichting",
"Geef een korte toelichting op uw diploma en opleiding.", "Geef een korte toelichting op uw diploma en opleiding.",
QuestionType.Tekst); QuestionType.Tekst);
/// <summary> /// <summary>
/// RULE: an English-language diploma requires proof of Dutch proficiency (B2). /// 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 /// Add a question here to apply it to a (set of) diploma(s) — a single backend
/// change, no frontend change. /// change, no frontend change.
/// </summary> /// </summary>
public static IReadOnlyList<PolicyQuestion> QuestionsFor(Diploma d) public static IReadOnlyList<PolicyQuestion> QuestionsFor(Diploma d)
{ {
var questions = new List<PolicyQuestion>(); var questions = new List<PolicyQuestion>();
if (d.Engelstalig) if (d.Engelstalig)
questions.Add(NlTaalEngelstalig); questions.Add(NlTaalEngelstalig);
return questions; return questions;
} }
/// <summary> /// <summary>
/// RULE: a manual diploma is unverified, so the strictest (maximal) set applies. /// RULE: a manual diploma is unverified, so the strictest (maximal) set applies.
/// </summary> /// </summary>
public static IReadOnlyList<PolicyQuestion> ManualQuestions() => public static IReadOnlyList<PolicyQuestion> ManualQuestions() =>
new[] { NlTaalManual, DiplomaErkend, Toelichting }; new[] { NlTaalManual, DiplomaErkend, Toelichting };
} }

View File

@@ -17,67 +17,67 @@ public sealed record DocumentCategory(
public static class DocumentRules public static class DocumentRules
{ {
private static readonly string[] Pdf = { "application/pdf" }; private static readonly string[] Pdf = { "application/pdf" };
private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" }; private static readonly string[] PdfImage = { "application/pdf", "image/jpeg", "image/png" };
private static readonly DocumentCategory Diploma = new("diploma", "Diplomabewijs", private static readonly DocumentCategory Diploma = new("diploma", "Diplomabewijs",
"Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false); "Upload uw diploma als PDF-bestand.", true, Pdf, 10, false, false);
private static readonly DocumentCategory Identiteit = new("identiteit", "Identiteitsbewijs", private static readonly DocumentCategory Identiteit = new("identiteit", "Identiteitsbewijs",
"Upload een kopie van uw paspoort of ID-kaart.", true, PdfImage, 10, false, true); "Upload een kopie van uw paspoort of ID-kaart.", true, PdfImage, 10, false, true);
private static readonly DocumentCategory Taalvaardigheid = new("taalvaardigheid", "Bewijs Nederlandse taalvaardigheid", private static readonly DocumentCategory Taalvaardigheid = new("taalvaardigheid", "Bewijs Nederlandse taalvaardigheid",
"Upload een bewijs van uw Nederlandse taalvaardigheid op het vereiste niveau (B2).", true, PdfImage, 10, false, true); "Upload een bewijs van uw Nederlandse taalvaardigheid op het vereiste niveau (B2).", true, PdfImage, 10, false, true);
/// <summary> /// <summary>
/// The MAXIMAL set of categories a wizard can ever ask for. Used to validate an /// The MAXIMAL set of categories a wizard can ever ask for. Used to validate an
/// upload POST (any real category must resolve) — <see cref="CategoriesFor"/> /// upload POST (any real category must resolve) — <see cref="CategoriesFor"/>
/// decides which subset is actually presented for a given set of answers. /// decides which subset is actually presented for a given set of answers.
/// </summary> /// </summary>
public static IReadOnlyList<DocumentCategory> AllCategoriesFor(string wizardId) => wizardId switch public static IReadOnlyList<DocumentCategory> AllCategoriesFor(string wizardId) => wizardId switch
{
"registratie" => new[] { Diploma, Identiteit, Taalvaardigheid },
"herregistratie" => new[]
{ {
"registratie" => new[] { Diploma, Identiteit, Taalvaardigheid },
"herregistratie" => new[]
{
new DocumentCategory("werkervaring", "Bewijs van werkervaring", new DocumentCategory("werkervaring", "Bewijs van werkervaring",
"Upload bewijs van uw gewerkte uren (bijv. een werkgeversverklaring).", true, Pdf, 10, true, true), "Upload bewijs van uw gewerkte uren (bijv. een werkgeversverklaring).", true, Pdf, 10, true, true),
new DocumentCategory("nascholing", "Nascholingscertificaten", new DocumentCategory("nascholing", "Nascholingscertificaten",
"Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true), "Upload uw nascholingscertificaten (optioneel).", false, PdfImage, 10, true, true),
}, },
_ => Array.Empty<DocumentCategory>(), _ => Array.Empty<DocumentCategory>(),
}; };
/// <summary> /// <summary>
/// The categories to PRESENT for a wizard, given the answers that affect required /// The categories to PRESENT for a wizard, given the answers that affect required
/// documents. RULES (registratie): a diploma upload is required ONLY for a manually /// documents. RULES (registratie): a diploma upload is required ONLY for a manually
/// entered diploma (a DUO diploma is verified digitally, and nothing is required /// entered diploma (a DUO diploma is verified digitally, and nothing is required
/// before a diploma is chosen); proof of Dutch taalvaardigheid is required only when /// before a diploma is chosen); proof of Dutch taalvaardigheid is required only when
/// the applicant confirms ("ja") they meet the language requirement. Answer-agnostic /// the applicant confirms ("ja") they meet the language requirement. Answer-agnostic
/// wizards get their full set. /// wizards get their full set.
/// </summary> /// </summary>
public static IReadOnlyList<DocumentCategory> CategoriesFor( public static IReadOnlyList<DocumentCategory> CategoriesFor(
string wizardId, string? diplomaHerkomst = null, string? taalvaardigheid = null) string wizardId, string? diplomaHerkomst = null, string? taalvaardigheid = null)
{ {
if (wizardId != "registratie") return AllCategoriesFor(wizardId); if (wizardId != "registratie") return AllCategoriesFor(wizardId);
var result = new List<DocumentCategory>(); var result = new List<DocumentCategory>();
if (diplomaHerkomst == "handmatig") result.Add(Diploma); if (diplomaHerkomst == "handmatig") result.Add(Diploma);
result.Add(Identiteit); result.Add(Identiteit);
if (taalvaardigheid == "ja") result.Add(Taalvaardigheid); if (taalvaardigheid == "ja") result.Add(Taalvaardigheid);
return result; return result;
} }
public static DocumentCategory? Find(string wizardId, string categoryId) => public static DocumentCategory? Find(string wizardId, string categoryId) =>
AllCategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId); AllCategoriesFor(wizardId).FirstOrDefault(c => c.CategoryId == categoryId);
/// <summary> /// <summary>
/// Authoritative upload validation (the client check is UX-only). Returns a /// Authoritative upload validation (the client check is UX-only). Returns a
/// rejection reason, or null when the file is acceptable. /// rejection reason, or null when the file is acceptable.
/// </summary> /// </summary>
public static string? RejectUpload(DocumentCategory? category, string contentType, long sizeBytes) public static string? RejectUpload(DocumentCategory? category, string contentType, long sizeBytes)
{ {
if (category is null) return "Onbekende documentcategorie."; if (category is null) return "Onbekende documentcategorie.";
if (!category.AcceptedTypes.Contains(contentType)) if (!category.AcceptedTypes.Contains(contentType))
return $"Bestandstype niet toegestaan voor {category.Label}."; return $"Bestandstype niet toegestaan voor {category.Label}.";
if (sizeBytes > (long)category.MaxSizeMb * 1024 * 1024) if (sizeBytes > (long)category.MaxSizeMb * 1024 * 1024)
return $"Bestand is groter dan {category.MaxSizeMb} MB."; return $"Bestand is groter dan {category.MaxSizeMb} MB.";
return null; return null;
} }
} }

View File

@@ -7,5 +7,5 @@ namespace BigRegister.Domain.Intake;
/// </summary> /// </summary>
public static class IntakePolicy public static class IntakePolicy
{ {
public const int ScholingThreshold = 1000; public const int ScholingThreshold = 1000;
} }

View File

@@ -8,25 +8,25 @@ namespace BigRegister.Domain.Registrations;
/// </summary> /// </summary>
public static class HerregistratieRule public static class HerregistratieRule
{ {
public const int WindowMonths = 12; public const int WindowMonths = 12;
public static DateOnly? Deadline(Registration reg) => public static DateOnly? Deadline(Registration reg) =>
reg.Status.Tag == StatusTag.Geregistreerd ? reg.Status.HerregistratieDatum : null; reg.Status.Tag == StatusTag.Geregistreerd ? reg.Status.HerregistratieDatum : null;
public static (bool Eligible, string? Reason) Evaluate( public static (bool Eligible, string? Reason) Evaluate(
Registration reg, DateOnly today, int windowMonths = WindowMonths) Registration reg, DateOnly today, int windowMonths = WindowMonths)
{ {
var deadline = Deadline(reg); var deadline = Deadline(reg);
if (deadline is null) if (deadline is null)
return (false, "Geen actieve registratie."); return (false, "Geen actieve registratie.");
var windowStart = deadline.Value.AddMonths(-windowMonths); var windowStart = deadline.Value.AddMonths(-windowMonths);
return today >= windowStart return today >= windowStart
? (true, $"Registratie verloopt binnen {windowMonths} maanden ({deadline:yyyy-MM-dd}).") ? (true, $"Registratie verloopt binnen {windowMonths} maanden ({deadline:yyyy-MM-dd}).")
: (false, $"Herregistratie kan vanaf {windowStart:yyyy-MM-dd}."); : (false, $"Herregistratie kan vanaf {windowStart:yyyy-MM-dd}.");
} }
/// <summary>Invariant: a non-active status must not carry a herregistratie date.</summary> /// <summary>Invariant: a non-active status must not carry a herregistratie date.</summary>
public static bool IsStatusConsistent(RegistrationStatus s) => public static bool IsStatusConsistent(RegistrationStatus s) =>
s.Tag != StatusTag.Geregistreerd || s.HerregistratieDatum is not null; s.Tag != StatusTag.Geregistreerd || s.HerregistratieDatum is not null;
} }

View File

@@ -3,9 +3,9 @@ namespace BigRegister.Domain.Registrations;
/// <summary>The three states a BIG registration can be in.</summary> /// <summary>The three states a BIG registration can be in.</summary>
public enum StatusTag public enum StatusTag
{ {
Geregistreerd, Geregistreerd,
Geschorst, Geschorst,
Doorgehaald, Doorgehaald,
} }
/// <summary> /// <summary>

View File

@@ -9,29 +9,29 @@ namespace BigRegister.Domain.Submissions;
/// </summary> /// </summary>
public static class SubmissionRules public static class SubmissionRules
{ {
// RULE: a manually entered diploma cannot be auto-verified. // RULE: a manually entered diploma cannot be auto-verified.
public static string? RejectRegistratie(string diplomaHerkomst) => public static string? RejectRegistratie(string diplomaHerkomst) =>
diplomaHerkomst == "handmatig" diplomaHerkomst == "handmatig"
? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling." ? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling."
: null; : null;
// RULE: an application reporting zero worked hours is rejected. // RULE: an application reporting zero worked hours is rejected.
public static string? RejectZeroUren(int uren) => public static string? RejectZeroUren(int uren) =>
uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null; uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null;
private static readonly Regex PostcodePattern = private static readonly Regex PostcodePattern =
new(@"^[1-9]\d{3}\s?[A-Z]{2}$", RegexOptions.IgnoreCase | RegexOptions.Compiled); new(@"^[1-9]\d{3}\s?[A-Z]{2}$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
// RULE: a change request needs a street and a well-formed Dutch postcode. The // RULE: a change request needs a street and a well-formed Dutch postcode. The
// server re-validates format authoritatively (the FE check is UX-only). // server re-validates format authoritatively (the FE check is UX-only).
public static string? RejectChangeRequest(string straat, string postcode) public static string? RejectChangeRequest(string straat, string postcode)
{ {
if (string.IsNullOrWhiteSpace(straat)) return "Vul straat en huisnummer in."; if (string.IsNullOrWhiteSpace(straat)) return "Vul straat en huisnummer in.";
if (!PostcodePattern.IsMatch(postcode?.Trim() ?? "")) return "Voer een geldige postcode in, bijv. 1234 AB."; if (!PostcodePattern.IsMatch(postcode?.Trim() ?? "")) return "Voer een geldige postcode in, bijv. 1234 AB.";
return null; return null;
} }
public static string NewReference() => public static string NewReference() =>
// ponytail: random reference is fine for a demo; a real system reserves it transactionally. // ponytail: random reference is fine for a demo; a real system reserves it transactionally.
"BIG-2026-" + Random.Shared.Next(100_000, 1_000_000); "BIG-2026-" + Random.Shared.Next(100_000, 1_000_000);
} }

View File

@@ -16,8 +16,8 @@ builder.Services.AddSwaggerGen(c =>
builder.Services.AddProblemDetails(); builder.Services.AddProblemDetails();
builder.Services.ConfigureHttpJsonOptions(o => builder.Services.ConfigureHttpJsonOptions(o =>
{ {
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
}); });
const string SpaCors = "spa"; const string SpaCors = "spa";
@@ -42,10 +42,10 @@ var api = app.MapGroup("/api/v1");
api.MapGet("/dashboard-view", () => api.MapGet("/dashboard-view", () =>
{ {
var reg = SeedData.Registration; var reg = SeedData.Registration;
var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today)); var (eligible, reason) = HerregistratieRule.Evaluate(reg, DateOnly.FromDateTime(DateTime.Today));
return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(), return new DashboardViewDto(reg.ToDto(), SeedData.Person.ToDto(),
new HerregistratieDecisionsDto(eligible, reason)); new HerregistratieDecisionsDto(eligible, reason));
}); });
api.MapGet("/notes", () => api.MapGet("/notes", () =>
@@ -96,21 +96,21 @@ api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, str
// and size authoritatively; stores metadata only (no file bytes / PII held). // and size authoritatively; stores metadata only (no file bytes / PII held).
api.MapPost("/uploads", async (HttpRequest request) => api.MapPost("/uploads", async (HttpRequest request) =>
{ {
if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400); if (!request.HasFormContentType) return Results.Problem(detail: "Verwacht multipart/form-data.", statusCode: 400);
var form = await request.ReadFormAsync(); var form = await request.ReadFormAsync();
var file = form.Files.GetFile("file"); var file = form.Files.GetFile("file");
string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString(); string categoryId = form["categoryId"].ToString(), localId = form["localId"].ToString(), wizardId = form["wizardId"].ToString();
if (file is null || categoryId == "" || localId == "" || wizardId == "") if (file is null || categoryId == "" || localId == "" || wizardId == "")
return Results.Problem(detail: "Onvolledige upload.", statusCode: 400); return Results.Problem(detail: "Onvolledige upload.", statusCode: 400);
var category = DocumentRules.Find(wizardId, categoryId); var category = DocumentRules.Find(wizardId, categoryId);
var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length); var reject = DocumentRules.RejectUpload(category, file.ContentType, file.Length);
if (reject is not null) return Results.Problem(detail: reject, statusCode: 400); if (reject is not null) return Results.Problem(detail: reject, statusCode: 400);
using var ms = new MemoryStream(); using var ms = new MemoryStream();
await file.CopyToAsync(ms); await file.CopyToAsync(ms);
var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), DocumentStore.DemoOwner); var doc = DocumentStore.Add(localId, categoryId, wizardId, file.FileName, file.ContentType, ms.ToArray(), DocumentStore.DemoOwner);
return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId)); return Results.Created($"/api/v1/uploads/{doc.DocumentId}", new UploadResponse(doc.DocumentId, localId));
}) })
.ExcludeFromDescription(); .ExcludeFromDescription();
@@ -118,10 +118,10 @@ api.MapPost("/uploads", async (HttpRequest request) =>
// for pdf/image (browser renders it), attachment otherwise (download). // for pdf/image (browser renders it), attachment otherwise (download).
api.MapGet("/uploads/{documentId}/content", (string documentId) => api.MapGet("/uploads/{documentId}/content", (string documentId) =>
{ {
var doc = DocumentStore.Get(documentId); var doc = DocumentStore.Get(documentId);
if (doc is null) return Results.NotFound(); if (doc is null) return Results.NotFound();
var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/"); var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/");
return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName); return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName);
}) })
.Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
@@ -129,23 +129,23 @@ api.MapGet("/uploads/{documentId}/content", (string documentId) =>
// Poll-on-return: which of these client localIds have arrived at the BFF. // Poll-on-return: which of these client localIds have arrived at the BFF.
api.MapGet("/uploads/status", (string? localIds) => api.MapGet("/uploads/status", (string? localIds) =>
{ {
var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId); var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId);
var results = ids.Select(id => found.TryGetValue(id, out var d) var results = ids.Select(id => found.TryGetValue(id, out var d)
? new UploadStatusItemDto(id, "complete", d.DocumentId) ? new UploadStatusItemDto(id, "complete", d.DocumentId)
: new UploadStatusItemDto(id, "unknown", null)).ToList(); : new UploadStatusItemDto(id, "unknown", null)).ToList();
return new UploadStatusDto(results); return new UploadStatusDto(results);
}); });
// User delete: owner-scoped; 409 once linked to a finalised submission. // User delete: owner-scoped; 409 once linked to a finalised submission.
api.MapDelete("/uploads/{documentId}", (string documentId) => api.MapDelete("/uploads/{documentId}", (string documentId) =>
DocumentStore.DeleteOwned(documentId, DocumentStore.DemoOwner) switch DocumentStore.DeleteOwned(documentId, DocumentStore.DemoOwner) switch
{ {
DocumentStore.DeleteResult.Ok => Results.NoContent(), DocumentStore.DeleteResult.Ok => Results.NoContent(),
DocumentStore.DeleteResult.Linked => Results.Problem( DocumentStore.DeleteResult.Linked => Results.Problem(
detail: "Dit document is al gekoppeld aan een ingediende aanvraag en kan niet meer worden verwijderd.", detail: "Dit document is al gekoppeld aan een ingediende aanvraag en kan niet meer worden verwijderd.",
statusCode: StatusCodes.Status409Conflict), statusCode: StatusCodes.Status409Conflict),
_ => Results.NotFound(), _ => Results.NotFound(),
}) })
.Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status409Conflict)
@@ -164,10 +164,10 @@ api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx
api.MapGet("/applications", () => api.MapGet("/applications", () =>
{ {
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
return ApplicationStore.List(DocumentStore.DemoOwner) return ApplicationStore.List(DocumentStore.DemoOwner)
.OrderByDescending(a => a.UpdatedAt) .OrderByDescending(a => a.UpdatedAt)
.Select(a => a.ToSummaryDto(now)).ToList(); .Select(a => a.ToSummaryDto(now)).ToList();
}); });
api.MapGet("/applications/{id}", (string id) => api.MapGet("/applications/{id}", (string id) =>
@@ -179,8 +179,8 @@ api.MapGet("/applications/{id}", (string id) =>
api.MapPost("/applications", (CreateApplicationRequest req) => api.MapPost("/applications", (CreateApplicationRequest req) =>
{ {
var a = ApplicationStore.Create(req.Type, DocumentStore.DemoOwner); var a = ApplicationStore.Create(req.Type, DocumentStore.DemoOwner);
return Results.Created($"/api/v1/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow)); return Results.Created($"/api/v1/applications/{a.Id}", a.ToDetailDto(DateTimeOffset.UtcNow));
}) })
.Produces<ApplicationDetailDto>(StatusCodes.Status201Created); .Produces<ApplicationDetailDto>(StatusCodes.Status201Created);
@@ -195,12 +195,12 @@ api.MapPut("/applications/{id}", (string id, DraftSyncRequest req) =>
// be withdrawn (out of scope — no "intrekken"). // be withdrawn (out of scope — no "intrekken").
api.MapDelete("/applications/{id}", (string id) => api.MapDelete("/applications/{id}", (string id) =>
{ {
var a = ApplicationStore.Get(id, DocumentStore.DemoOwner); var a = ApplicationStore.Get(id, DocumentStore.DemoOwner);
if (a is null) return Results.NotFound(); if (a is null) return Results.NotFound();
if (a.Submitted) if (a.Submitted)
return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict); return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict);
ApplicationStore.Delete(id, DocumentStore.DemoOwner); ApplicationStore.Delete(id, DocumentStore.DemoOwner);
return Results.NoContent(); return Results.NoContent();
}) })
.Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status204NoContent)
.ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status409Conflict)
@@ -210,30 +210,30 @@ api.MapDelete("/applications/{id}", (string id) =>
// aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case. // aanvraag. handmatig no longer 422s (ADR-0002): it becomes a manual (pending) case.
api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx) => api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest req, HttpContext ctx) =>
{ {
var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner); var existing = ApplicationStore.Get(id, DocumentStore.DemoOwner);
if (existing is null) return Results.NotFound(); if (existing is null) return Results.NotFound();
if (existing.Submitted) if (existing.Submitted)
return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict); return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict);
// Per wizard type: what rejects the submission (→ Afgewezen) and whether it auto-approves. // Per wizard type: what rejects the submission (→ Afgewezen) and whether it auto-approves.
(string? reject, bool autoApprovable) = existing.Type switch (string? reject, bool autoApprovable) = existing.Type switch
{ {
"registratie" => (null, req.DiplomaHerkomst == "duo"), "registratie" => (null, req.DiplomaHerkomst == "duo"),
_ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true), _ /* herregistratie | intake */ => (SubmissionRules.RejectZeroUren(req.Uren ?? 0), true),
}; };
var docs = req.Documents; var docs = req.Documents;
if (docs is not null) if (docs is not null)
DocumentStore.Link(docs.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!)); DocumentStore.Link(docs.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
var documentIds = docs?.Where(d => d.DocumentId is not null).Select(d => d.DocumentId!).ToList(); var documentIds = docs?.Where(d => d.DocumentId is not null).Select(d => d.DocumentId!).ToList();
var submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds); var submitted = ApplicationStore.Submit(id, DocumentStore.DemoOwner, reject, autoApprovable, documentIds);
if (submitted is null) return Results.Conflict(); if (submitted is null) return Results.Conflict();
app.Logger.LogInformation( app.Logger.LogInformation(
"aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}", "aanvraag submit id={Id} type={Type} outcome={Outcome} auto={Auto} reference={Reference}",
id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie); id, existing.Type, reject is null ? "accepted" : "rejected", autoApprovable, submitted.Referentie);
return Results.Ok(new SubmitApplicationResponse(submitted.Referentie!, submitted.ToStatusDto(DateTimeOffset.UtcNow))); return Results.Ok(new SubmitApplicationResponse(submitted.Referentie!, submitted.ToStatusDto(DateTimeOffset.UtcNow)));
}) })
.Produces<SubmitApplicationResponse>() .Produces<SubmitApplicationResponse>()
.ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status409Conflict)
@@ -245,15 +245,15 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
api.MapGet("/brief", () => api.MapGet("/brief", () =>
{ {
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner); var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep)); return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
}) })
.Produces<BriefViewDto>(); .Produces<BriefViewDto>();
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) => api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
{ {
var (_, isDrafter) = BriefRole(ctx); var (_, isDrafter) = BriefRole(ctx);
return BriefResult(BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken."); return BriefResult(BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
}) })
.Produces<BriefDto>() .Produces<BriefDto>()
.ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status403Forbidden)
@@ -261,10 +261,10 @@ api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
api.MapPost("/brief/submit", (HttpContext ctx) => api.MapPost("/brief/submit", (HttpContext ctx) =>
{ {
var (_, isDrafter) = BriefRole(ctx); var (_, isDrafter) = BriefRole(ctx);
var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now()); var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now());
LogBrief("submit", r); LogBrief("submit", r);
return BriefResult(r, "Alleen de opsteller mag indienen."); return BriefResult(r, "Alleen de opsteller mag indienen.");
}) })
.WithName("briefSubmit") // distinct name so the generated client method isn't `submit2` .WithName("briefSubmit") // distinct name so the generated client method isn't `submit2`
.Produces<BriefDto>() .Produces<BriefDto>()
@@ -273,10 +273,10 @@ api.MapPost("/brief/submit", (HttpContext ctx) =>
api.MapPost("/brief/approve", (HttpContext ctx) => api.MapPost("/brief/approve", (HttpContext ctx) =>
{ {
var (acting, _) = BriefRole(ctx); var (acting, _) = BriefRole(ctx);
var r = BriefStore.Approve(DocumentStore.DemoOwner, acting, Now()); var r = BriefStore.Approve(DocumentStore.DemoOwner, acting, Now());
LogBrief("approve", r); LogBrief("approve", r);
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn."); return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
}) })
.Produces<BriefDto>() .Produces<BriefDto>()
.ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status403Forbidden)
@@ -284,10 +284,10 @@ api.MapPost("/brief/approve", (HttpContext ctx) =>
api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) => api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
{ {
var (acting, _) = BriefRole(ctx); var (acting, _) = BriefRole(ctx);
var r = BriefStore.Reject(DocumentStore.DemoOwner, acting, req.Comments, Now()); var r = BriefStore.Reject(DocumentStore.DemoOwner, acting, req.Comments, Now());
LogBrief("reject", r); LogBrief("reject", r);
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn."); return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
}) })
.Produces<BriefDto>() .Produces<BriefDto>()
.ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status403Forbidden)
@@ -295,20 +295,20 @@ api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
api.MapPost("/brief/send", () => api.MapPost("/brief/send", () =>
{ {
// Send-time placeholder linting is FE-authoritative in this slice (no C# parity // Send-time placeholder linting is FE-authoritative in this slice (no C# parity
// port); the backend only guards the approved→sent transition. // port); the backend only guards the approved→sent transition.
var r = BriefStore.Send(DocumentStore.DemoOwner, Now()); var r = BriefStore.Send(DocumentStore.DemoOwner, Now());
LogBrief("send", r); LogBrief("send", r);
return BriefResult(r, "Versturen kan niet in deze status."); return BriefResult(r, "Versturen kan niet in deze status.");
}) })
.Produces<BriefDto>() .Produces<BriefDto>()
.ProducesProblem(StatusCodes.Status409Conflict); .ProducesProblem(StatusCodes.Status409Conflict);
api.MapPost("/brief/reset", () => api.MapPost("/brief/reset", () =>
{ {
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only. // Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner); var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner);
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep)); return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
}) })
.WithName("briefReset") .WithName("briefReset")
.Produces<BriefViewDto>(); .Produces<BriefViewDto>();
@@ -323,15 +323,15 @@ static string Now() => DateTimeOffset.UtcNow.ToString("o");
// else (default) acts as the drafter. // else (default) acts as the drafter.
static (string acting, bool isDrafter) BriefRole(HttpContext ctx) static (string acting, bool isDrafter) BriefRole(HttpContext ctx)
{ {
var isDrafter = ctx.Request.Headers["X-Role"].ToString() != "approver"; var isDrafter = ctx.Request.Headers["X-Role"].ToString() != "approver";
return (isDrafter ? BriefStore.DrafterId : BriefStore.ApproverId, isDrafter); return (isDrafter ? BriefStore.DrafterId : BriefStore.ApproverId, isDrafter);
} }
IResult BriefResult((BriefStore.Outcome outcome, BriefEntity? entity) r, string forbiddenDetail) => r.outcome switch IResult BriefResult((BriefStore.Outcome outcome, BriefEntity? entity) r, string forbiddenDetail) => r.outcome switch
{ {
BriefStore.Outcome.Ok => Results.Ok(r.entity!.ToDto()), BriefStore.Outcome.Ok => Results.Ok(r.entity!.ToDto()),
BriefStore.Outcome.Forbidden => Results.Problem(detail: forbiddenDetail, statusCode: StatusCodes.Status403Forbidden), BriefStore.Outcome.Forbidden => Results.Problem(detail: forbiddenDetail, statusCode: StatusCodes.Status403Forbidden),
_ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict), _ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict),
}; };
void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r) => void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r) =>
@@ -343,30 +343,30 @@ void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r
// real system ships this to structured logging / an audit store). // real system ships this to structured logging / an audit store).
IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<DocumentRefDto>? documents = null) IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<DocumentRefDto>? documents = null)
{ {
var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v) var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v)
? v.ToString() ? v.ToString()
: "none"; : "none";
if (reject is not null) if (reject is not null)
{ {
app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid); app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid);
return Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity); return Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
} }
if (documents is not null) if (documents is not null)
{ {
// Link digital documents (blocks later user delete) and record post-delivery // Link digital documents (blocks later user delete) and record post-delivery
// intent so a caseworker knows to expect the physical document. // intent so a caseworker knows to expect the physical document.
DocumentStore.Link(documents.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!)); DocumentStore.Link(documents.Where(d => d.Channel == "digital" && d.DocumentId is not null).Select(d => d.DocumentId!));
foreach (var d in documents.Where(d => d.Channel == "post")) foreach (var d in documents.Where(d => d.Channel == "post"))
DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid); DocumentStore.Audit("post-delivery", d.DocumentId ?? "-", d.CategoryId, cid);
} }
var reference = SubmissionRules.NewReference(); var reference = SubmissionRules.NewReference();
app.Logger.LogInformation( app.Logger.LogInformation(
"submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}", "submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}",
kind, reference, cid, DateTimeOffset.UtcNow); kind, reference, cid, DateTimeOffset.UtcNow);
return Results.Ok(new ReferentieResponse(reference)); return Results.Ok(new ReferentieResponse(reference));
} }
// Exposed so the integration tests can spin up the app with WebApplicationFactory. // Exposed so the integration tests can spin up the app with WebApplicationFactory.

View File

@@ -8,129 +8,135 @@ namespace BigRegister.Tests;
public class ApplicationTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>> public class ApplicationTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{ {
private readonly HttpClient _client = factory.CreateClient(); private readonly HttpClient _client = factory.CreateClient();
private async Task<ApplicationDetailDto> Create(string type = "registratie") private async Task<ApplicationDetailDto> Create(string type = "registratie")
{ {
var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type }); var res = await _client.PostAsJsonAsync("/api/v1/applications", new { type });
Assert.Equal(HttpStatusCode.Created, res.StatusCode); Assert.Equal(HttpStatusCode.Created, res.StatusCode);
return (await res.Content.ReadFromJsonAsync<ApplicationDetailDto>())!; return (await res.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
} }
private Task<List<ApplicationSummaryDto>?> List() => private Task<List<ApplicationSummaryDto>?> List() =>
_client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/applications"); _client.GetFromJsonAsync<List<ApplicationSummaryDto>>("/api/v1/applications");
// --- Lifecycle over HTTP --- // --- Lifecycle over HTTP ---
[Fact] [Fact]
public async Task Create_then_list_shows_a_concept_with_step_progress() public async Task Create_then_list_shows_a_concept_with_step_progress()
{ {
var a = await Create(); var a = await Create();
await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}", await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
new { draft = new { beroep = "arts" }, stepIndex = 1, stepCount = 4 }); new { draft = new { beroep = "arts" }, stepIndex = 1, stepCount = 4 });
var mine = (await List())!.Single(x => x.Id == a.Id); var mine = (await List())!.Single(x => x.Id == a.Id);
Assert.Equal("Concept", mine.Status.Tag); Assert.Equal("Concept", mine.Status.Tag);
Assert.Equal(1, mine.Status.StepIndex); Assert.Equal(1, mine.Status.StepIndex);
Assert.Equal(4, mine.Status.StepCount); Assert.Equal(4, mine.Status.StepCount);
} }
[Fact] [Fact]
public async Task Draft_sync_is_readable_back_from_detail() public async Task Draft_sync_is_readable_back_from_detail()
{ {
var a = await Create(); var a = await Create();
await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}", await _client.PutAsJsonAsync($"/api/v1/applications/{a.Id}",
new { draft = new { beroep = "verpleegkundige" }, stepIndex = 2, stepCount = 4 }); new { draft = new { beroep = "verpleegkundige" }, stepIndex = 2, stepCount = 4 });
var detail = await _client.GetFromJsonAsync<ApplicationDetailDto>($"/api/v1/applications/{a.Id}"); var detail = await _client.GetFromJsonAsync<ApplicationDetailDto>($"/api/v1/applications/{a.Id}");
Assert.NotNull(detail!.Draft); Assert.NotNull(detail!.Draft);
Assert.Equal("verpleegkundige", detail.Draft!.Value.GetProperty("beroep").GetString()); Assert.Equal("verpleegkundige", detail.Draft!.Value.GetProperty("beroep").GetString());
} }
[Fact] [Fact]
public async Task Submit_duo_registratie_is_in_behandeling_and_auto() public async Task Submit_duo_registratie_is_in_behandeling_and_auto()
{ {
var a = await Create("registratie"); var a = await Create("registratie");
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" }); var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!; var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
Assert.StartsWith("BIG-2026-", body.Referentie); Assert.StartsWith("BIG-2026-", body.Referentie);
Assert.Equal("InBehandeling", body.Status.Tag); Assert.Equal("InBehandeling", body.Status.Tag);
Assert.False(body.Status.Manual); // auto-approvable → not a manual case Assert.False(body.Status.Manual); // auto-approvable → not a manual case
} }
[Fact] [Fact]
public async Task Submit_handmatig_registratie_succeeds_as_manual_case() public async Task Submit_handmatig_registratie_succeeds_as_manual_case()
{ {
var a = await Create("registratie"); var a = await Create("registratie");
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "handmatig" }); var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "handmatig" });
res.EnsureSuccessStatusCode(); // no longer a 422 res.EnsureSuccessStatusCode(); // no longer a 422
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!; var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
Assert.Equal("InBehandeling", body.Status.Tag); Assert.Equal("InBehandeling", body.Status.Tag);
Assert.True(body.Status.Manual); Assert.True(body.Status.Manual);
} }
[Fact] [Fact]
public async Task Submit_herregistratie_with_zero_uren_is_afgewezen() public async Task Submit_herregistratie_with_zero_uren_is_afgewezen()
{ {
var a = await Create("herregistratie"); var a = await Create("herregistratie");
var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { uren = 0 }); var res = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { uren = 0 });
res.EnsureSuccessStatusCode(); // the submission is accepted... res.EnsureSuccessStatusCode(); // the submission is accepted...
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!; var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
Assert.Equal("Afgewezen", body.Status.Tag); // ...but resolves to rejected Assert.Equal("Afgewezen", body.Status.Tag); // ...but resolves to rejected
Assert.NotNull(body.Status.Reden); Assert.NotNull(body.Status.Reden);
} }
[Fact] [Fact]
public async Task Submitting_twice_conflicts() public async Task Submitting_twice_conflicts()
{ {
var a = await Create("registratie"); var a = await Create("registratie");
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); (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" }); var again = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" });
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode); Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
} }
[Fact] [Fact]
public async Task Cancel_concept_removes_it() public async Task Cancel_concept_removes_it()
{ {
var a = await Create(); var a = await Create();
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode); 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); Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync($"/api/v1/applications/{a.Id}")).StatusCode);
} }
[Fact] [Fact]
public async Task Cancel_submitted_aanvraag_conflicts() public async Task Cancel_submitted_aanvraag_conflicts()
{ {
var a = await Create("registratie"); var a = await Create("registratie");
(await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new { diplomaHerkomst = "duo" })).EnsureSuccessStatusCode(); (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); Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/applications/{a.Id}")).StatusCode);
} }
// --- Auto-approval is computed on read: exercise the window boundary without waiting. --- // --- Auto-approval is computed on read: exercise the window boundary without waiting. ---
private static Aanvraag Accepted(bool autoApprovable) => new() private static Aanvraag Accepted(bool autoApprovable) => new()
{ {
Id = "x", Type = "registratie", Owner = "test", Id = "x",
Submitted = true, AutoApprovable = autoApprovable, Referentie = "BIG-2026-1", Type = "registratie",
SubmittedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow, Owner = "test",
}; Submitted = true,
AutoApprovable = autoApprovable,
Referentie = "BIG-2026-1",
SubmittedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
};
[Fact] [Fact]
public void AutoApprovable_flips_to_goedgekeurd_after_the_window() public void AutoApprovable_flips_to_goedgekeurd_after_the_window()
{ {
var a = Accepted(autoApprovable: true); var a = Accepted(autoApprovable: true);
var t0 = a.SubmittedAt!.Value; var t0 = a.SubmittedAt!.Value;
Assert.Equal("InBehandeling", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow - TimeSpan.FromSeconds(1)).Tag); Assert.Equal("InBehandeling", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow - TimeSpan.FromSeconds(1)).Tag);
Assert.Equal("Goedgekeurd", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow + TimeSpan.FromSeconds(1)).Tag); Assert.Equal("Goedgekeurd", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow + TimeSpan.FromSeconds(1)).Tag);
} }
[Fact] [Fact]
public void Manual_case_never_auto_advances() public void Manual_case_never_auto_advances()
{ {
var a = Accepted(autoApprovable: false); var a = Accepted(autoApprovable: false);
var far = a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1); var far = a.SubmittedAt!.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1);
var status = a.ToStatusDto(far); var status = a.ToStatusDto(far);
Assert.Equal("InBehandeling", status.Tag); Assert.Equal("InBehandeling", status.Tag);
Assert.True(status.Manual); Assert.True(status.Manual);
} }
} }

View File

@@ -13,150 +13,150 @@ namespace BigRegister.Tests;
/// </summary> /// </summary>
public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>> public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{ {
private readonly HttpClient _client = factory.CreateClient(); private readonly HttpClient _client = factory.CreateClient();
private static LetterBlockDto FreeText(string id) => private static LetterBlockDto FreeText(string id) =>
new("freeText", id, new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) })); new("freeText", id, new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) }));
// Fill every REQUIRED section with a block so submit is allowed. // Fill every REQUIRED section with a block so submit is allowed.
private static SaveBriefRequest FilledFrom(BriefDto brief) private static SaveBriefRequest FilledFrom(BriefDto brief)
{ {
var i = 0; var i = 0;
var sections = brief.Sections var sections = brief.Sections
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, s.Required ? new[] { FreeText($"local-{++i}") } : s.Blocks)) .Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, s.Required ? new[] { FreeText($"local-{++i}") } : s.Blocks))
.ToList(); .ToList();
return new SaveBriefRequest(sections); return new SaveBriefRequest(sections);
} }
private async Task<BriefDto> Get() private async Task<BriefDto> Get()
{ {
BriefStore.Reset(); BriefStore.Reset();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"); var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
return view!.Brief; return view!.Brief;
} }
private HttpRequestMessage Post(string path, string? role = null, object? body = null) private HttpRequestMessage Post(string path, string? role = null, object? body = null)
{ {
var req = new HttpRequestMessage(HttpMethod.Post, path); var req = new HttpRequestMessage(HttpMethod.Post, path);
if (role is not null) req.Headers.Add("X-Role", role); if (role is not null) req.Headers.Add("X-Role", role);
if (body is not null) req.Content = JsonContent.Create(body); if (body is not null) req.Content = JsonContent.Create(body);
return req; return req;
} }
[Fact] [Fact]
public async Task Get_creates_a_draft_from_the_template_with_scoped_passages() public async Task Get_creates_a_draft_from_the_template_with_scoped_passages()
{ {
var brief = await Get(); var brief = await Get();
Assert.Equal("draft", brief.Status.Tag); Assert.Equal("draft", brief.Status.Tag);
Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey)); Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey));
// aanhef + slot are locked, predefined and prefilled; only kern is editable + empty. // aanhef + slot are locked, predefined and prefilled; only kern is editable + empty.
var aanhef = brief.Sections.Single(s => s.SectionKey == "aanhef"); var aanhef = brief.Sections.Single(s => s.SectionKey == "aanhef");
Assert.True(aanhef.Locked); Assert.True(aanhef.Locked);
Assert.NotEmpty(aanhef.Blocks); Assert.NotEmpty(aanhef.Blocks);
Assert.True(brief.Sections.Single(s => s.SectionKey == "slot").Locked); Assert.True(brief.Sections.Single(s => s.SectionKey == "slot").Locked);
var kern = brief.Sections.Single(s => s.SectionKey == "kern"); var kern = brief.Sections.Single(s => s.SectionKey == "kern");
Assert.False(kern.Locked); Assert.False(kern.Locked);
Assert.Empty(kern.Blocks); Assert.Empty(kern.Blocks);
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"); var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
// global passages + the arts-scoped one; no other-beroep passages leak in. // global passages + the arts-scoped one; no other-beroep passages leak in.
Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts"); Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts");
Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts")); Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts"));
} }
[Fact] [Fact]
public async Task Save_is_drafter_only() public async Task Save_is_drafter_only()
{ {
var brief = await Get(); var brief = await Get();
var save = FilledFrom(brief); var save = FilledFrom(brief);
var approver = Post("/api/v1/brief", role: "approver"); var approver = Post("/api/v1/brief", role: "approver");
approver.Method = HttpMethod.Put; approver.Method = HttpMethod.Put;
approver.Content = JsonContent.Create(save); approver.Content = JsonContent.Create(save);
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(approver)).StatusCode); Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(approver)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await _client.PutAsJsonAsync("/api/v1/brief", save)).StatusCode); Assert.Equal(HttpStatusCode.OK, (await _client.PutAsJsonAsync("/api/v1/brief", save)).StatusCode);
} }
[Fact] [Fact]
public async Task Submit_blocks_on_empty_required_section_then_succeeds_when_filled() public async Task Submit_blocks_on_empty_required_section_then_succeeds_when_filled()
{ {
await Get(); await Get();
// Nothing filled yet → required sections empty → 409. // Nothing filled yet → required sections empty → 409.
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode);
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief; var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
var res = await _client.SendAsync(Post("/api/v1/brief/submit")); var res = await _client.SendAsync(Post("/api/v1/brief/submit"));
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
var submitted = await res.Content.ReadFromJsonAsync<BriefDto>(); var submitted = await res.Content.ReadFromJsonAsync<BriefDto>();
Assert.Equal("submitted", submitted!.Status.Tag); Assert.Equal("submitted", submitted!.Status.Tag);
} }
[Fact] [Fact]
public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can() public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can()
{ {
var brief = await Get(); var brief = await Get();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit")); await _client.SendAsync(Post("/api/v1/brief/submit"));
// drafter role approving own letter → 403 // drafter role approving own letter → 403
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(Post("/api/v1/brief/approve"))).StatusCode); Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(Post("/api/v1/brief/approve"))).StatusCode);
var res = await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver")); var res = await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag); Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
} }
[Fact] [Fact]
public async Task Reject_returns_comments_and_editing_reopens_to_draft() public async Task Reject_returns_comments_and_editing_reopens_to_draft()
{ {
var brief = await Get(); var brief = await Get();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit")); await _client.SendAsync(Post("/api/v1/brief/submit"));
var rejected = await (await _client.SendAsync( var rejected = await (await _client.SendAsync(
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefDto>(); Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefDto>();
Assert.Equal("rejected", rejected!.Status.Tag); Assert.Equal("rejected", rejected!.Status.Tag);
Assert.Equal("Graag aanvullen.", rejected.Status.Comments); Assert.Equal("Graag aanvullen.", rejected.Status.Comments);
// A drafter save on a rejected letter reopens it to draft. // A drafter save on a rejected letter reopens it to draft.
var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefDto>(); var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefDto>();
Assert.Equal("draft", reopened!.Status.Tag); Assert.Equal("draft", reopened!.Status.Tag);
} }
[Fact] [Fact]
public async Task Send_only_from_approved() public async Task Send_only_from_approved()
{ {
var brief = await Get(); var brief = await Get();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit")); await _client.SendAsync(Post("/api/v1/brief/submit"));
// submitted (not approved) → send 409 // submitted (not approved) → send 409
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/send"))).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/send"))).StatusCode);
await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver")); await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
var res = await _client.SendAsync(Post("/api/v1/brief/send")); var res = await _client.SendAsync(Post("/api/v1/brief/send"));
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag); Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
} }
[Fact] [Fact]
public async Task Reset_recreates_a_fresh_draft_with_locked_prefilled_sections() public async Task Reset_recreates_a_fresh_draft_with_locked_prefilled_sections()
{ {
var brief = await Get(); var brief = await Get();
// Advance out of draft so the reset back to draft is observable. // Advance out of draft so the reset back to draft is observable.
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit")); await _client.SendAsync(Post("/api/v1/brief/submit"));
var res = await _client.SendAsync(Post("/api/v1/brief/reset")); var res = await _client.SendAsync(Post("/api/v1/brief/reset"));
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
var view = await res.Content.ReadFromJsonAsync<BriefViewDto>(); var view = await res.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.Equal("draft", view!.Brief.Status.Tag); Assert.Equal("draft", view!.Brief.Status.Tag);
var aanhef = view.Brief.Sections.Single(s => s.SectionKey == "aanhef"); var aanhef = view.Brief.Sections.Single(s => s.SectionKey == "aanhef");
Assert.True(aanhef.Locked); Assert.True(aanhef.Locked);
Assert.NotEmpty(aanhef.Blocks); Assert.NotEmpty(aanhef.Blocks);
Assert.Empty(view.Brief.Sections.Single(s => s.SectionKey == "kern").Blocks); Assert.Empty(view.Brief.Sections.Single(s => s.SectionKey == "kern").Blocks);
} }
} }

View File

@@ -9,223 +9,223 @@ namespace BigRegister.Tests;
public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>> public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{ {
private readonly HttpClient _client = factory.CreateClient(); private readonly HttpClient _client = factory.CreateClient();
[Fact] [Fact]
public async Task DashboardView_computes_eligibility_decision() public async Task DashboardView_computes_eligibility_decision()
{ {
var dto = await _client.GetFromJsonAsync<DashboardViewDto>("/api/v1/dashboard-view"); var dto = await _client.GetFromJsonAsync<DashboardViewDto>("/api/v1/dashboard-view");
Assert.NotNull(dto); Assert.NotNull(dto);
Assert.Equal("19012345601", dto.Registration.BigNummer); Assert.Equal("19012345601", dto.Registration.BigNummer);
Assert.Equal("Geregistreerd", dto.Registration.Status.Tag); Assert.Equal("Geregistreerd", dto.Registration.Status.Tag);
// seed deadline 2027-03-01 is within 12 months of "today" (2026) → eligible // seed deadline 2027-03-01 is within 12 months of "today" (2026) → eligible
Assert.True(dto.Decisions.EligibleForHerregistratie); Assert.True(dto.Decisions.EligibleForHerregistratie);
Assert.NotNull(dto.Decisions.HerregistratieReason); Assert.NotNull(dto.Decisions.HerregistratieReason);
} }
[Fact] [Fact]
public async Task Notes_returns_seeded_aantekeningen() public async Task Notes_returns_seeded_aantekeningen()
{ {
var notes = await _client.GetFromJsonAsync<List<AantekeningDto>>("/api/v1/notes"); var notes = await _client.GetFromJsonAsync<List<AantekeningDto>>("/api/v1/notes");
Assert.Equal(3, notes!.Count); Assert.Equal(3, notes!.Count);
} }
[Fact] [Fact]
public async Task Brp_returns_address() public async Task Brp_returns_address()
{ {
var dto = await _client.GetFromJsonAsync<BrpAddressDto>("/api/v1/brp/address"); var dto = await _client.GetFromJsonAsync<BrpAddressDto>("/api/v1/brp/address");
Assert.True(dto!.Gevonden); Assert.True(dto!.Gevonden);
Assert.Equal("2514 EA", dto.Adres!.Postcode); Assert.Equal("2514 EA", dto.Adres!.Postcode);
} }
[Fact] [Fact]
public async Task Duo_lookup_carries_server_decided_questions_and_professions() public async Task Duo_lookup_carries_server_decided_questions_and_professions()
{ {
var dto = await _client.GetFromJsonAsync<DuoLookupDto>("/api/v1/duo/diplomas"); var dto = await _client.GetFromJsonAsync<DuoLookupDto>("/api/v1/duo/diplomas");
Assert.NotNull(dto); Assert.NotNull(dto);
var english = dto.Diplomas.Single(d => d.Id == "d2"); var english = dto.Diplomas.Single(d => d.Id == "d2");
Assert.Equal("Arts", english.Beroep); Assert.Equal("Arts", english.Beroep);
Assert.Contains(english.PolicyQuestions, q => q.Id == "nl-taalvaardigheid"); Assert.Contains(english.PolicyQuestions, q => q.Id == "nl-taalvaardigheid");
var dutch = dto.Diplomas.Single(d => d.Id == "d1"); var dutch = dto.Diplomas.Single(d => d.Id == "d1");
Assert.Empty(dutch.PolicyQuestions); Assert.Empty(dutch.PolicyQuestions);
// DUO "not found" fallback: an unlisted diploma → user uses the manual path, // DUO "not found" fallback: an unlisted diploma → user uses the manual path,
// which the same lookup provides (maximal question set + declarable professions). // which the same lookup provides (maximal question set + declarable professions).
Assert.DoesNotContain(dto.Diplomas, d => d.Id == "unknown-id"); Assert.DoesNotContain(dto.Diplomas, d => d.Id == "unknown-id");
Assert.Equal(3, dto.Handmatig.PolicyQuestions.Count); Assert.Equal(3, dto.Handmatig.PolicyQuestions.Count);
Assert.Equal(5, dto.Handmatig.Beroepen.Count); Assert.Equal(5, dto.Handmatig.Beroepen.Count);
} }
[Fact] [Fact]
public async Task IntakePolicy_returns_scholing_threshold() public async Task IntakePolicy_returns_scholing_threshold()
{ {
var dto = await _client.GetFromJsonAsync<IntakePolicyDto>("/api/v1/intake/policy"); var dto = await _client.GetFromJsonAsync<IntakePolicyDto>("/api/v1/intake/policy");
Assert.Equal(1000, dto!.ScholingThreshold); Assert.Equal(1000, dto!.ScholingThreshold);
} }
[Fact] [Fact]
public async Task Registration_with_duo_diploma_succeeds() public async Task Registration_with_duo_diploma_succeeds()
{ {
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("duo")); var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("duo"));
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>(); var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.StartsWith("BIG-2026-", body!.Referentie); Assert.StartsWith("BIG-2026-", body!.Referentie);
} }
[Fact] [Fact]
public async Task Registration_with_manual_diploma_is_rejected_with_problem_details() public async Task Registration_with_manual_diploma_is_rejected_with_problem_details()
{ {
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("handmatig")); var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("handmatig"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode); Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
Assert.Contains("application/problem+json", res.Content.Headers.ContentType!.ToString()); Assert.Contains("application/problem+json", res.Content.Headers.ContentType!.ToString());
} }
[Theory] [Theory]
[InlineData("/api/v1/intakes")] [InlineData("/api/v1/intakes")]
[InlineData("/api/v1/herregistraties")] [InlineData("/api/v1/herregistraties")]
public async Task Zero_hours_submission_is_rejected(string route) public async Task Zero_hours_submission_is_rejected(string route)
{ {
var res = await _client.PostAsJsonAsync(route, new { uren = 0 }); var res = await _client.PostAsJsonAsync(route, new { uren = 0 });
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode); Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
} }
[Theory] [Theory]
[InlineData("/api/v1/intakes")] [InlineData("/api/v1/intakes")]
[InlineData("/api/v1/herregistraties")] [InlineData("/api/v1/herregistraties")]
public async Task Worked_hours_submission_succeeds(string route) public async Task Worked_hours_submission_succeeds(string route)
{ {
var res = await _client.PostAsJsonAsync(route, new { uren = 40 }); var res = await _client.PostAsJsonAsync(route, new { uren = 40 });
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
} }
[Fact] [Fact]
public async Task Change_request_with_valid_address_succeeds() public async Task Change_request_with_valid_address_succeeds()
{ {
var res = await _client.PostAsJsonAsync("/api/v1/change-requests", var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
new { straat = "Lange Voorhout 9", postcode = "2514 EA", woonplaats = "Den Haag" }); new { straat = "Lange Voorhout 9", postcode = "2514 EA", woonplaats = "Den Haag" });
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>(); var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.StartsWith("BIG-2026-", body!.Referentie); Assert.StartsWith("BIG-2026-", body!.Referentie);
} }
[Fact] [Fact]
public async Task Change_request_with_bad_postcode_is_rejected() public async Task Change_request_with_bad_postcode_is_rejected()
{ {
var res = await _client.PostAsJsonAsync("/api/v1/change-requests", var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" }); new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" });
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode); Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
} }
[Fact] [Fact]
public async Task Health_endpoint_is_ok() public async Task Health_endpoint_is_ok()
{ {
var res = await _client.GetAsync("/health"); var res = await _client.GetAsync("/health");
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
} }
// --- Document upload --- // --- Document upload ---
private static MultipartFormDataContent UploadForm(string localId, string categoryId, string wizardId, string fileName, string contentType) private static MultipartFormDataContent UploadForm(string localId, string categoryId, string wizardId, string fileName, string contentType)
{ {
var content = new MultipartFormDataContent(); var content = new MultipartFormDataContent();
var file = new ByteArrayContent(new byte[] { 1, 2, 3 }); var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
file.Headers.ContentType = new MediaTypeHeaderValue(contentType); file.Headers.ContentType = new MediaTypeHeaderValue(contentType);
content.Add(file, "file", fileName); content.Add(file, "file", fileName);
content.Add(new StringContent(categoryId), "categoryId"); content.Add(new StringContent(categoryId), "categoryId");
content.Add(new StringContent(localId), "localId"); content.Add(new StringContent(localId), "localId");
content.Add(new StringContent(wizardId), "wizardId"); content.Add(new StringContent(wizardId), "wizardId");
return content; return content;
} }
private async Task<UploadResponse> Upload(string localId, string categoryId = "diploma", string type = "application/pdf", string file = "d.pdf") private async Task<UploadResponse> Upload(string localId, string categoryId = "diploma", string type = "application/pdf", string file = "d.pdf")
{ {
var res = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, categoryId, "registratie", file, type)); var res = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, categoryId, "registratie", file, type));
Assert.Equal(HttpStatusCode.Created, res.StatusCode); Assert.Equal(HttpStatusCode.Created, res.StatusCode);
return (await res.Content.ReadFromJsonAsync<UploadResponse>())!; return (await res.Content.ReadFromJsonAsync<UploadResponse>())!;
} }
[Fact] [Fact]
public async Task Categories_are_server_owned_config() public async Task Categories_are_server_owned_config()
{ {
// A manual diploma requires a diploma upload; identiteit is always required. // A manual diploma requires a diploma upload; identiteit is always required.
var dto = await _client.GetFromJsonAsync<UploadCategoriesDto>("/api/v1/uploads/categories?wizardId=registratie&diplomaHerkomst=handmatig"); var dto = await _client.GetFromJsonAsync<UploadCategoriesDto>("/api/v1/uploads/categories?wizardId=registratie&diplomaHerkomst=handmatig");
Assert.Contains(dto!.Categories, c => c.CategoryId == "diploma" && c.Required && !c.AllowPostDelivery); Assert.Contains(dto!.Categories, c => c.CategoryId == "diploma" && c.Required && !c.AllowPostDelivery);
Assert.Contains(dto.Categories, c => c.CategoryId == "identiteit" && c.AllowPostDelivery); Assert.Contains(dto.Categories, c => c.CategoryId == "identiteit" && c.AllowPostDelivery);
} }
[Fact] [Fact]
public async Task Upload_then_status_reports_complete_for_known_localId() public async Task Upload_then_status_reports_complete_for_known_localId()
{ {
var localId = Guid.NewGuid().ToString(); var localId = Guid.NewGuid().ToString();
var doc = await Upload(localId); var doc = await Upload(localId);
var status = await _client.GetFromJsonAsync<UploadStatusDto>($"/api/v1/uploads/status?localIds={localId},onbekend"); var status = await _client.GetFromJsonAsync<UploadStatusDto>($"/api/v1/uploads/status?localIds={localId},onbekend");
Assert.Contains(status!.Results, r => r.LocalId == localId && r.Status == "complete" && r.DocumentId == doc.DocumentId); Assert.Contains(status!.Results, r => r.LocalId == localId && r.Status == "complete" && r.DocumentId == doc.DocumentId);
Assert.Contains(status.Results, r => r.LocalId == "onbekend" && r.Status == "unknown"); Assert.Contains(status.Results, r => r.LocalId == "onbekend" && r.Status == "unknown");
} }
[Fact] [Fact]
public async Task Upload_content_is_served_back_with_its_type_inline_for_pdf() public async Task Upload_content_is_served_back_with_its_type_inline_for_pdf()
{ {
var doc = await Upload(Guid.NewGuid().ToString()); var doc = await Upload(Guid.NewGuid().ToString());
var res = await _client.GetAsync($"/api/v1/uploads/{doc.DocumentId}/content"); var res = await _client.GetAsync($"/api/v1/uploads/{doc.DocumentId}/content");
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
Assert.Equal("application/pdf", res.Content.Headers.ContentType!.MediaType); Assert.Equal("application/pdf", res.Content.Headers.ContentType!.MediaType);
Assert.Equal(new byte[] { 1, 2, 3 }, await res.Content.ReadAsByteArrayAsync()); Assert.Equal(new byte[] { 1, 2, 3 }, await res.Content.ReadAsByteArrayAsync());
// pdf/image → inline (no attachment disposition) so the browser previews it // pdf/image → inline (no attachment disposition) so the browser previews it
Assert.NotEqual("attachment", res.Content.Headers.ContentDisposition?.DispositionType); Assert.NotEqual("attachment", res.Content.Headers.ContentDisposition?.DispositionType);
} }
[Fact] [Fact]
public async Task Upload_content_404_for_unknown_document() public async Task Upload_content_404_for_unknown_document()
{ {
Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync("/api/v1/uploads/demo-nope/content")).StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await _client.GetAsync("/api/v1/uploads/demo-nope/content")).StatusCode);
} }
[Fact] [Fact]
public async Task Upload_rejects_wrong_type() public async Task Upload_rejects_wrong_type()
{ {
var res = await _client.PostAsync("/api/v1/uploads", var res = await _client.PostAsync("/api/v1/uploads",
UploadForm(Guid.NewGuid().ToString(), "diploma", "registratie", "d.txt", "text/plain")); UploadForm(Guid.NewGuid().ToString(), "diploma", "registratie", "d.txt", "text/plain"));
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
} }
[Fact] [Fact]
public async Task User_delete_succeeds_then_404() public async Task User_delete_succeeds_then_404()
{ {
var doc = await Upload(Guid.NewGuid().ToString()); var doc = await Upload(Guid.NewGuid().ToString());
Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode); Assert.Equal(HttpStatusCode.NoContent, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode); Assert.Equal(HttpStatusCode.NotFound, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
} }
[Fact] [Fact]
public async Task User_delete_blocked_with_409_once_linked_to_submission() public async Task User_delete_blocked_with_409_once_linked_to_submission()
{ {
var doc = await Upload(Guid.NewGuid().ToString()); var doc = await Upload(Guid.NewGuid().ToString());
var submit = await _client.PostAsJsonAsync("/api/v1/registrations", var submit = await _client.PostAsJsonAsync("/api/v1/registrations",
new RegistratieRequest("duo", new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) })); new RegistratieRequest("duo", new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) }));
submit.EnsureSuccessStatusCode(); submit.EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
} }
[Fact] [Fact]
public async Task Admin_delete_requires_admin_role() public async Task Admin_delete_requires_admin_role()
{ {
var doc = await Upload(Guid.NewGuid().ToString()); var doc = await Upload(Guid.NewGuid().ToString());
Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync($"/api/v1/admin/uploads/{doc.DocumentId}")).StatusCode); Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync($"/api/v1/admin/uploads/{doc.DocumentId}")).StatusCode);
var req = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/uploads/{doc.DocumentId}"); var req = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/uploads/{doc.DocumentId}");
req.Headers.Add("X-Admin", "true"); req.Headers.Add("X-Admin", "true");
Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(req)).StatusCode); Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(req)).StatusCode);
} }
[Fact] [Fact]
public async Task Audit_log_records_upload_and_delete_metadata_only() public async Task Audit_log_records_upload_and_delete_metadata_only()
{ {
var doc = await Upload(Guid.NewGuid().ToString()); var doc = await Upload(Guid.NewGuid().ToString());
await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}"); await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}");
Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "upload"); Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "upload");
Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "delete-user"); Assert.Contains(DocumentStore.AuditLog, a => a.DocumentId == doc.DocumentId && a.Action == "delete-user");
} }
} }

View File

@@ -7,174 +7,174 @@ namespace BigRegister.Tests;
public class DocumentRuleTests public class DocumentRuleTests
{ {
[Fact] [Fact]
public void Rejects_unknown_category() => public void Rejects_unknown_category() =>
Assert.NotNull(DocumentRules.RejectUpload(null, "application/pdf", 1)); Assert.NotNull(DocumentRules.RejectUpload(null, "application/pdf", 1));
[Fact] [Fact]
public void Rejects_disallowed_type() public void Rejects_disallowed_type()
{ {
var c = DocumentRules.Find("registratie", "diploma"); var c = DocumentRules.Find("registratie", "diploma");
Assert.NotNull(DocumentRules.RejectUpload(c, "text/plain", 1)); Assert.NotNull(DocumentRules.RejectUpload(c, "text/plain", 1));
} }
[Fact] [Fact]
public void Rejects_oversized_file() public void Rejects_oversized_file()
{ {
var c = DocumentRules.Find("registratie", "diploma"); var c = DocumentRules.Find("registratie", "diploma");
Assert.NotNull(DocumentRules.RejectUpload(c, "application/pdf", 11L * 1024 * 1024)); Assert.NotNull(DocumentRules.RejectUpload(c, "application/pdf", 11L * 1024 * 1024));
} }
[Fact] [Fact]
public void Accepts_valid_file() public void Accepts_valid_file()
{ {
var c = DocumentRules.Find("registratie", "diploma"); var c = DocumentRules.Find("registratie", "diploma");
Assert.Null(DocumentRules.RejectUpload(c, "application/pdf", 5L * 1024 * 1024)); Assert.Null(DocumentRules.RejectUpload(c, "application/pdf", 5L * 1024 * 1024));
} }
private static IReadOnlyList<string> Ids(string? herkomst, string? taalvaardigheid) => private static IReadOnlyList<string> Ids(string? herkomst, string? taalvaardigheid) =>
DocumentRules.CategoriesFor("registratie", herkomst, taalvaardigheid).Select(c => c.CategoryId).ToList(); DocumentRules.CategoriesFor("registratie", herkomst, taalvaardigheid).Select(c => c.CategoryId).ToList();
[Fact] [Fact]
public void First_load_has_no_diploma_upload() => // no diploma chosen yet public void First_load_has_no_diploma_upload() => // no diploma chosen yet
Assert.Equal(new[] { "identiteit" }, Ids(null, null)); Assert.Equal(new[] { "identiteit" }, Ids(null, null));
[Fact] [Fact]
public void Manual_diploma_needs_a_diploma_upload() => public void Manual_diploma_needs_a_diploma_upload() =>
Assert.Equal(new[] { "diploma", "identiteit" }, Ids("handmatig", null)); Assert.Equal(new[] { "diploma", "identiteit" }, Ids("handmatig", null));
[Fact] [Fact]
public void Duo_diploma_skips_diploma_upload() => public void Duo_diploma_skips_diploma_upload() =>
Assert.DoesNotContain("diploma", Ids("duo", null)); Assert.DoesNotContain("diploma", Ids("duo", null));
[Fact] [Fact]
public void Confirmed_dutch_proficiency_requires_taalvaardigheid_proof() => public void Confirmed_dutch_proficiency_requires_taalvaardigheid_proof() =>
Assert.Contains("taalvaardigheid", Ids("handmatig", "ja")); Assert.Contains("taalvaardigheid", Ids("handmatig", "ja"));
[Fact] [Fact]
public void Unconfirmed_proficiency_requires_no_taalvaardigheid_proof() => public void Unconfirmed_proficiency_requires_no_taalvaardigheid_proof() =>
Assert.DoesNotContain("taalvaardigheid", Ids("handmatig", "nee")); Assert.DoesNotContain("taalvaardigheid", Ids("handmatig", "nee"));
[Fact] [Fact]
public void Find_resolves_taalvaardigheid_for_upload_validation() => public void Find_resolves_taalvaardigheid_for_upload_validation() =>
Assert.NotNull(DocumentRules.Find("registratie", "taalvaardigheid")); Assert.NotNull(DocumentRules.Find("registratie", "taalvaardigheid"));
} }
public class HerregistratieRuleTests public class HerregistratieRuleTests
{ {
private static Registration Active(DateOnly deadline) => new( private static Registration Active(DateOnly deadline) => new(
"19012345601", "Test", "Arts", "19012345601", "Test", "Arts",
new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14), new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14),
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline)); new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline));
[Fact] [Fact]
public void Eligible_within_window() public void Eligible_within_window()
{ {
var (eligible, reason) = HerregistratieRule.Evaluate( var (eligible, reason) = HerregistratieRule.Evaluate(
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 6, 26)); Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 6, 26));
Assert.True(eligible); Assert.True(eligible);
Assert.Contains("12 maanden", reason); Assert.Contains("12 maanden", reason);
} }
[Fact] [Fact]
public void Not_eligible_before_window() public void Not_eligible_before_window()
{ {
var (eligible, _) = HerregistratieRule.Evaluate( var (eligible, _) = HerregistratieRule.Evaluate(
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2025, 1, 1)); Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2025, 1, 1));
Assert.False(eligible); Assert.False(eligible);
} }
[Fact] [Fact]
public void Eligible_on_window_boundary() public void Eligible_on_window_boundary()
{ {
// window opens exactly 12 months before the deadline // window opens exactly 12 months before the deadline
var (eligible, _) = HerregistratieRule.Evaluate( var (eligible, _) = HerregistratieRule.Evaluate(
Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 3, 1)); Active(new DateOnly(2027, 3, 1)), today: new DateOnly(2026, 3, 1));
Assert.True(eligible); Assert.True(eligible);
} }
[Fact] [Fact]
public void Suspended_is_not_eligible() public void Suspended_is_not_eligible()
{
var reg = Active(new DateOnly(2027, 3, 1)) with
{ {
var reg = Active(new DateOnly(2027, 3, 1)) with Status = new RegistrationStatus(StatusTag.Geschorst, GeschorstTot: new DateOnly(2027, 1, 1), Reden: "x"),
{ };
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);
var (eligible, _) = HerregistratieRule.Evaluate(reg, today: new DateOnly(2026, 6, 26)); }
Assert.False(eligible);
}
[Fact] [Fact]
public void Status_consistency_invariant() public void Status_consistency_invariant()
{ {
Assert.True(HerregistratieRule.IsStatusConsistent( Assert.True(HerregistratieRule.IsStatusConsistent(
new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)))); new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1))));
Assert.False(HerregistratieRule.IsStatusConsistent( Assert.False(HerregistratieRule.IsStatusConsistent(
new RegistrationStatus(StatusTag.Geregistreerd))); new RegistrationStatus(StatusTag.Geregistreerd)));
} }
} }
public class DiplomaRuleTests public class DiplomaRuleTests
{ {
private static Diploma Diploma(string opleiding, bool engelstalig) => private static Diploma Diploma(string opleiding, bool engelstalig) =>
new("x", "naam", "instelling", 2011, opleiding, engelstalig); new("x", "naam", "instelling", 2011, opleiding, engelstalig);
[Theory] [Theory]
[InlineData("geneeskunde", "Arts")] [InlineData("geneeskunde", "Arts")]
[InlineData("verpleegkunde", "Verpleegkundige")] [InlineData("verpleegkunde", "Verpleegkundige")]
[InlineData("onbekend-programma", "Onbekend")] [InlineData("onbekend-programma", "Onbekend")]
public void Profession_is_derived_from_program(string opleiding, string expected) => public void Profession_is_derived_from_program(string opleiding, string expected) =>
Assert.Equal(expected, DiplomaRules.ProfessionFor(Diploma(opleiding, false))); Assert.Equal(expected, DiplomaRules.ProfessionFor(Diploma(opleiding, false)));
[Fact] [Fact]
public void English_diploma_requires_dutch_proficiency() public void English_diploma_requires_dutch_proficiency()
{ {
var questions = DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: true)); var questions = DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: true));
Assert.Single(questions); Assert.Single(questions);
Assert.Equal("nl-taalvaardigheid", questions[0].Id); Assert.Equal("nl-taalvaardigheid", questions[0].Id);
} }
[Fact] [Fact]
public void Dutch_diploma_has_no_policy_questions() => public void Dutch_diploma_has_no_policy_questions() =>
Assert.Empty(DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: false))); Assert.Empty(DiplomaRules.QuestionsFor(Diploma("geneeskunde", engelstalig: false)));
[Fact] [Fact]
public void Manual_diploma_gets_maximal_set() public void Manual_diploma_gets_maximal_set()
{ {
var questions = DiplomaRules.ManualQuestions(); var questions = DiplomaRules.ManualQuestions();
Assert.Equal(3, questions.Count); Assert.Equal(3, questions.Count);
Assert.Equal(new[] { "nl-taalvaardigheid", "diploma-erkend", "toelichting" }, Assert.Equal(new[] { "nl-taalvaardigheid", "diploma-erkend", "toelichting" },
questions.Select(q => q.Id)); questions.Select(q => q.Id));
} }
[Fact] [Fact]
public void Manual_professions_match_known_programs() => public void Manual_professions_match_known_programs() =>
Assert.Equal(new[] { "Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts" }, Assert.Equal(new[] { "Arts", "Verpleegkundige", "Fysiotherapeut", "Apotheker", "Tandarts" },
DiplomaRules.ManualProfessions()); DiplomaRules.ManualProfessions());
} }
public class SubmissionRuleTests public class SubmissionRuleTests
{ {
[Fact] [Fact]
public void Manual_diploma_is_rejected() => public void Manual_diploma_is_rejected() =>
Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig")); Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig"));
[Fact] [Fact]
public void Duo_diploma_is_accepted() => public void Duo_diploma_is_accepted() =>
Assert.Null(SubmissionRules.RejectRegistratie("duo")); Assert.Null(SubmissionRules.RejectRegistratie("duo"));
[Fact] [Fact]
public void Zero_hours_is_rejected() => public void Zero_hours_is_rejected() =>
Assert.NotNull(SubmissionRules.RejectZeroUren(0)); Assert.NotNull(SubmissionRules.RejectZeroUren(0));
[Fact] [Fact]
public void Worked_hours_are_accepted() => public void Worked_hours_are_accepted() =>
Assert.Null(SubmissionRules.RejectZeroUren(40)); Assert.Null(SubmissionRules.RejectZeroUren(40));
[Theory] [Theory]
[InlineData("Lange Voorhout 9", "2514 EA", null)] // valid [InlineData("Lange Voorhout 9", "2514 EA", null)] // valid
[InlineData("", "2514 EA", "Vul straat en huisnummer in.")] [InlineData("", "2514 EA", "Vul straat en huisnummer in.")]
[InlineData("Straat 1", "nope", "Voer een geldige postcode in, bijv. 1234 AB.")] [InlineData("Straat 1", "nope", "Voer een geldige postcode in, bijv. 1234 AB.")]
public void Change_request_is_validated(string straat, string postcode, string? expected) => public void Change_request_is_validated(string straat, string postcode, string? expected) =>
Assert.Equal(expected, SubmissionRules.RejectChangeRequest(straat, postcode)); Assert.Equal(expected, SubmissionRules.RejectChangeRequest(straat, postcode));
} }