diff --git a/backend/README.md b/backend/README.md index 9245c5c..b55f692 100644 --- a/backend/README.md +++ b/backend/README.md @@ -58,8 +58,6 @@ cd backend && dotnet test # rule unit tests + endpoint integration tests | GET | `/api/duo/diplomas` | diplomas with derived profession + applicable policy questions, + manual fallback | | GET | `/api/intake/policy` | scholing threshold (config value) | | POST | `/api/registrations` | submit registration → reference, or 422 (manual diploma) | -| POST | `/api/herregistraties` | submit re-registration → reference, or 422 (0 hours) | -| POST | `/api/intakes` | submit intake → reference, or 422 (0 hours) / 400 (incomplete scholing answer) | Rejections use **ProblemDetails (RFC 7807)** with status **422**. Every request carries an `X-Correlation-Id` (set by the FE fetch adapter); the backend echoes it diff --git a/backend/src/BigRegister.Api/Contracts/Dtos.cs b/backend/src/BigRegister.Api/Contracts/Dtos.cs index 44c8bfc..1a8c787 100644 --- a/backend/src/BigRegister.Api/Contracts/Dtos.cs +++ b/backend/src/BigRegister.Api/Contracts/Dtos.cs @@ -76,12 +76,6 @@ public sealed record DocumentRefDto(string CategoryId, string Channel, string? D // stay on the client). ponytail: a real submit would carry the full application. public sealed record RegistratieRequest(string DiplomaHerkomst, IReadOnlyList? Documents = null); -// AanvullendeScholing/ScholingPunten (WP-69): the wizard's scholing answer, re-validated -// server-side as the authority by IntakePolicy.RejectIncompleteScholing. Named -// ScholingPunten (not Punten) — the sibling SubmitApplicationRequest is shared by all three -// wizard types and the herregistratie wizard has its own unrelated `punten`. -public sealed record IntakeRequest(int Uren, bool? AanvullendeScholing = null, int? ScholingPunten = null); -public sealed record HerregistratieRequest(int Uren, IReadOnlyList? Documents = null); public sealed record ChangeRequestRequest(string Telefoon); // Authz/PII-reveal audit row (WP-41) — data-minimised, no PII (see AuthzAuditEntry). @@ -125,8 +119,8 @@ public sealed record DraftSyncRequest( IReadOnlyList? DocumentIds = null); // Submit carries only the fields the server re-validates per wizard type. -// AanvullendeScholing/ScholingPunten (WP-69) — see IntakeRequest; intake-typed aanvragen -// only (gated by IntakePolicy.RejectIncompleteScholing's caller), null for the others. +// AanvullendeScholing/ScholingPunten (WP-69) — intake-typed aanvragen only (gated by +// IntakePolicy.RejectIncompleteScholing's caller), null for the others. public sealed record SubmitApplicationRequest( string? DiplomaHerkomst = null, int? Uren = null, IReadOnlyList? Documents = null, diff --git a/backend/src/BigRegister.Api/Contracts/Mappers.cs b/backend/src/BigRegister.Api/Contracts/Mappers.cs index 8ba9493..d4af6fb 100644 --- a/backend/src/BigRegister.Api/Contracts/Mappers.cs +++ b/backend/src/BigRegister.Api/Contracts/Mappers.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using BigRegister.Api.Data; using BigRegister.Domain.Applications; using BigRegister.Domain.Diplomas; @@ -12,12 +13,13 @@ public static class Mappers { private static string D(DateOnly d) => d.ToString("yyyy-MM-dd"); - public static RegistrationStatusDto ToDto(this RegistrationStatus s) => new( - Tag: s.Tag.ToString(), - HerregistratieDatum: s.HerregistratieDatum is { } h ? D(h) : null, - GeschorstTot: s.GeschorstTot is { } g ? D(g) : null, - Reden: s.Reden, - DoorgehaaldOp: s.DoorgehaaldOp is { } x ? D(x) : null); + public static RegistrationStatusDto ToDto(this RegistrationStatus s) => s switch + { + RegistrationStatus.Geregistreerd g => new(s.Tag.ToString(), HerregistratieDatum: D(g.HerregistratieDatum)), + RegistrationStatus.Geschorst g => new(s.Tag.ToString(), GeschorstTot: D(g.GeschorstTot), Reden: g.Reden), + RegistrationStatus.Doorgehaald d => new(s.Tag.ToString(), DoorgehaaldOp: D(d.DoorgehaaldOp), Reden: d.Reden), + _ => throw new ArgumentOutOfRangeException(nameof(s), s, "Unknown RegistrationStatus variant"), + }; public static RegistrationDto ToDto(this Registration r) => new( r.BigNummer, r.Naam, r.Beroep, D(r.Registratiedatum), D(r.Geboortedatum), r.Status.ToDto()); @@ -45,19 +47,33 @@ public static class Mappers public static AanvraagStatusDto ToDto(this AanvraagStatus s) => new( s.Tag?.ToString() ?? "Concept", s.StepIndex, s.StepCount, s.Referentie, s.Manual, s.Reden); - // Aanvraag status is COMPUTED ON READ (see Aanvraag.StatusAt) — this is now a one-line - // projection of that domain method onto the wire DTO (WP-68 F3). + // Aanvraag status is COMPUTED ON READ (see the StatusAt extension, Data/AanvraagMapper.cs) — + // this is now a one-line projection of that onto the wire DTO (WP-68 F3, WP-73). public static AanvraagStatusDto ToStatusDto(this Aanvraag a, DateTimeOffset now) => a.StatusAt(now).ToDto(); + /// SubmittedAt only exists once Submitted/Decided (WP-73) — null for a Concept, + /// same as the wire DTO's own nullable field. + private static string? SubmittedAtOf(Aanvraag a) => a switch + { + Aanvraag.Concept => null, + Aanvraag.Submitted s => s.SubmittedAt.ToString("o"), + Aanvraag.Decided d => d.SubmittedAt.ToString("o"), + _ => null, + }; + + /// Draft only exists pre-submission (WP-73) — null once Submitted/Decided (nothing + /// reads it past that point; see AanvraagMapper.ApplyTo's Submitted branch). + private static JsonElement? DraftOf(Aanvraag a) => a is Aanvraag.Concept c ? c.Draft : null; + public static ApplicationSummaryDto ToSummaryDto(this Aanvraag a, DateTimeOffset now) => new( 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"), SubmittedAtOf(a)); /// Admin summary — same shape plus the owner (WP-36; the user-facing list leaves Owner null). public static ApplicationSummaryDto ToAdminSummaryDto(this Aanvraag a, DateTimeOffset now) => a.ToSummaryDto(now) with { Owner = a.Owner }; public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new( - a.Id, a.Type, a.ToStatusDto(now), a.Draft, a.DocumentIds, - a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), a.SubmittedAt?.ToString("o")); + a.Id, a.Type, a.ToStatusDto(now), DraftOf(a), a.DocumentIds, + a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a)); } diff --git a/backend/src/BigRegister.Api/Data/AanvraagMapper.cs b/backend/src/BigRegister.Api/Data/AanvraagMapper.cs new file mode 100644 index 0000000..104d195 --- /dev/null +++ b/backend/src/BigRegister.Api/Data/AanvraagMapper.cs @@ -0,0 +1,206 @@ +using BigRegister.Domain.Applications; + +namespace BigRegister.Api.Data; + +/// +/// The two-way seam between (the EF-mapped persistence row — +/// mutable, no invariants of its own, exactly the shape SQLite needs) and +/// (the closed Concept/Submitted/Decided domain union, WP-73). is the +/// read half: it reconstructs whichever variant a row's stored fields describe, going through +/// that variant's own constructor/required members, so a row that doesn't actually describe a +/// legal aanvraag throws here rather than downstream. / +/// are the write half, used by 's writers (and test fixtures, e.g. +/// Builders/AanvraagBuilder.cs) to flush a freshly-constructed domain value onto a row +/// before SaveChanges. +/// +public static class AanvraagMapper +{ + public static Aanvraag ToDomain(this AanvraagEntity row) + { + if (!row.Submitted) + return new Aanvraag.Concept(row.StepIndex, row.StepCount) + { + Id = row.Id, + Type = row.Type, + Owner = row.Owner, + DocumentIds = row.DocumentIds, + CreatedAt = row.CreatedAt, + UpdatedAt = row.UpdatedAt, + ZaakUrl = row.ZaakUrl, + ZgwError = row.ZgwError, + Draft = row.Draft, + }; + + var referentie = row.Referentie + ?? throw new InvalidOperationException($"Submitted aanvraag {row.Id} has no Referentie."); + var submittedAt = row.SubmittedAt + ?? throw new InvalidOperationException($"Submitted aanvraag {row.Id} has no SubmittedAt."); + + // Reden wins over BesluitStatus — matches the pre-WP-73 StatusAt's own priority. In + // practice a row never carries both (BeoordelingRules.CanDecide already refuses a besluit + // once Reden's auto-reject makes the projected status Afgewezen), but if it somehow did, + // the auto-reject at submission time is authoritative. + if (row.Reden is null && row.BesluitStatus is { } besluit) + return besluit switch + { + Besluit.Goedkeuren => new Aanvraag.Decided.Goedgekeurd + { + Id = row.Id, + Type = row.Type, + Owner = row.Owner, + DocumentIds = row.DocumentIds, + CreatedAt = row.CreatedAt, + UpdatedAt = row.UpdatedAt, + ZaakUrl = row.ZaakUrl, + ZgwError = row.ZgwError, + Referentie = referentie, + SubmittedAt = submittedAt, + }, + Besluit.Afwijzen => new Aanvraag.Decided.Afgewezen + { + Id = row.Id, + Type = row.Type, + Owner = row.Owner, + DocumentIds = row.DocumentIds, + CreatedAt = row.CreatedAt, + UpdatedAt = row.UpdatedAt, + ZaakUrl = row.ZaakUrl, + ZgwError = row.ZgwError, + Referentie = referentie, + SubmittedAt = submittedAt, + Toelichting = row.BesluitToelichting + ?? throw new InvalidOperationException($"Afgewezen aanvraag {row.Id} has no toelichting."), + }, + Besluit.MeerInfoOpvragen => new Aanvraag.Decided.MeerInfoGevraagd + { + Id = row.Id, + Type = row.Type, + Owner = row.Owner, + DocumentIds = row.DocumentIds, + CreatedAt = row.CreatedAt, + UpdatedAt = row.UpdatedAt, + ZaakUrl = row.ZaakUrl, + ZgwError = row.ZgwError, + Referentie = referentie, + SubmittedAt = submittedAt, + Toelichting = row.BesluitToelichting + ?? throw new InvalidOperationException($"MeerInfoGevraagd aanvraag {row.Id} has no toelichting."), + }, + _ => throw new InvalidOperationException($"Unknown besluit {besluit}."), + }; + + return new Aanvraag.Submitted + { + Id = row.Id, + Type = row.Type, + Owner = row.Owner, + DocumentIds = row.DocumentIds, + CreatedAt = row.CreatedAt, + UpdatedAt = row.UpdatedAt, + ZaakUrl = row.ZaakUrl, + ZgwError = row.ZgwError, + Referentie = referentie, + SubmittedAt = submittedAt, + AutoApprovable = row.AutoApprovable, + Reden = row.Reden, + }; + } + + /// Flushes a domain value onto an already-tracked row — everything but identity + /// (Id/Type/Owner) and CreatedAt, which never change once a row exists. Used by + /// 's SyncDraft/Submit/RecordBesluit, each of which already + /// Find()ed the row this applies to. + public static void ApplyTo(this Aanvraag a, AanvraagEntity row) + { + row.DocumentIds = a.DocumentIds.ToList(); + row.UpdatedAt = a.UpdatedAt; + row.ZaakUrl = a.ZaakUrl; + row.ZgwError = a.ZgwError; + + switch (a) + { + case Aanvraag.Concept c: + row.Draft = c.Draft; + row.StepIndex = c.StepIndex; + row.StepCount = c.StepCount; + row.Submitted = false; + row.Referentie = null; + row.SubmittedAt = null; + row.AutoApprovable = false; + row.Reden = null; + row.BesluitStatus = null; + row.BesluitToelichting = null; + break; + + case Aanvraag.Submitted s: + // Submitted ⇒ !Draft (WP-73's Draft decision) — nothing reads a submitted aanvraag's + // draft (registratie/application/draft-sync.ts only ever resumes a still-Concept + // wizard), so this is now actually true rather than the aspirational doc-comment it + // used to be. + row.Draft = null; + row.Submitted = true; + row.Referentie = s.Referentie; + row.SubmittedAt = s.SubmittedAt; + row.AutoApprovable = s.AutoApprovable; + row.Reden = s.Reden; + row.BesluitStatus = null; + row.BesluitToelichting = null; + break; + + case Aanvraag.Decided d: + row.Draft = null; + row.Submitted = true; + row.Referentie = d.Referentie; + row.SubmittedAt = d.SubmittedAt; + // AutoApprovable/Reden are left as whatever the row already carries from its earlier + // Submitted stage: Decided doesn't model them (StatusAt never consults them once a + // besluit is recorded — its Decided branches are checked first), and a fresh row built + // straight from a Decided fixture with no prior Submitted stage (see ToEntity) simply + // keeps their type defaults (false/null), which is equally harmless for the same reason. + row.BesluitStatus = d switch + { + Aanvraag.Decided.Goedgekeurd => Besluit.Goedkeuren, + Aanvraag.Decided.Afgewezen => Besluit.Afwijzen, + Aanvraag.Decided.MeerInfoGevraagd => Besluit.MeerInfoOpvragen, + _ => throw new InvalidOperationException($"Unknown Decided variant {d.GetType().Name}."), + }; + row.BesluitToelichting = d switch + { + Aanvraag.Decided.Goedgekeurd => null, + Aanvraag.Decided.Afgewezen af => af.Toelichting, + Aanvraag.Decided.MeerInfoGevraagd m => m.Toelichting, + _ => throw new InvalidOperationException($"Unknown Decided variant {d.GetType().Name}."), + }; + break; + } + } + + /// A brand-new row for a domain value that has no existing row yet — test fixtures' + /// db.Applications.Add(...) (see Acceptance/BesluitLifecycleTests.cs, + /// Acceptance/IntakeSubmissionTests.cs), and (indirectly, via ) + /// 's very first insert. + public static AanvraagEntity ToEntity(this Aanvraag a) + { + var row = new AanvraagEntity { Id = a.Id, Type = a.Type, Owner = a.Owner, CreatedAt = a.CreatedAt }; + a.ApplyTo(row); + return row; + } + + /// The status at a point in time (WP-68 F3, WP-73) — pattern matching over the + /// closed union, replacing the null-forgiving derefs the old flat + /// mutable row needed (Referentie/SubmittedAt are simply non-nullable on Submitted/Decided + /// now, so there's nothing left to force). A recorded decision wins over the auto-approve + /// computation, matching the pre-WP-73 priority. + public static AanvraagStatus StatusAt(this Aanvraag a, DateTimeOffset now) => a switch + { + Aanvraag.Concept c => AanvraagStatus.Concept(c.StepIndex, c.StepCount), + Aanvraag.Submitted { Reden: { } reden } s => AanvraagStatus.Afgewezen(s.Referentie, reden), + Aanvraag.Decided.Goedgekeurd g => AanvraagStatus.Goedgekeurd(g.Referentie), + Aanvraag.Decided.Afgewezen af => AanvraagStatus.Afgewezen(af.Referentie, af.Toelichting), + Aanvraag.Decided.MeerInfoGevraagd m => AanvraagStatus.MeerInfoGevraagd(m.Referentie, m.Toelichting), + Aanvraag.Submitted s when s.AutoApprovable && now > s.SubmittedAt + ApplicationStore.ProcessingWindow => + AanvraagStatus.Goedgekeurd(s.Referentie), + Aanvraag.Submitted s => AanvraagStatus.InBehandeling(s.Referentie, manual: !s.AutoApprovable), + _ => throw new InvalidOperationException($"Unknown Aanvraag variant {a.GetType().Name}."), + }; +} diff --git a/backend/src/BigRegister.Api/Data/AppDbContext.cs b/backend/src/BigRegister.Api/Data/AppDbContext.cs index fa562ca..876789a 100644 --- a/backend/src/BigRegister.Api/Data/AppDbContext.cs +++ b/backend/src/BigRegister.Api/Data/AppDbContext.cs @@ -7,7 +7,7 @@ namespace BigRegister.Api.Data; /// /// EF Core/SQLite persistence for the three stores that used to be static -/// in-memory dictionaries (WP-22): , +/// in-memory dictionaries (WP-22): , /// + , and . Opaque nested shapes /// (a wizard's draft snapshot, a brief's sections/placeholders/status) are stored as /// JSON text columns rather than redesigned into relational tables — the backend @@ -21,7 +21,7 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon public DbSet AuditEntries => Set(); public DbSet AuthzAudit => Set(); public DbSet FeatureFlags => Set(); - public DbSet Applications => Set(); + public DbSet Applications => Set(); public DbSet Briefs => Set(); public DbSet OrgTemplates => Set(); @@ -43,7 +43,7 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon modelBuilder.Entity().HasKey(f => f.Key); - modelBuilder.Entity(e => + modelBuilder.Entity(e => { e.HasKey(a => a.Id); e.Property(a => a.Draft).HasConversion(DraftConverter); diff --git a/backend/src/BigRegister.Api/Data/ApplicationStore.cs b/backend/src/BigRegister.Api/Data/ApplicationStore.cs index 904f9bd..6160148 100644 --- a/backend/src/BigRegister.Api/Data/ApplicationStore.cs +++ b/backend/src/BigRegister.Api/Data/ApplicationStore.cs @@ -6,13 +6,16 @@ using BigRegister.Domain.Submissions; namespace BigRegister.Api.Data; /// -/// An application (aanvraag) — the system of record the dashboard reads. A wizard -/// creates one as a Concept on its first step, syncs its draft snapshot per step, -/// then submits it into the Concept → In behandeling → Goedgekeurd/Afgewezen -/// lifecycle (ADR-0002). Status is COMPUTED ON READ (see ) so -/// auto-approval is purely a function of stored timestamps — no timers, no jobs. +/// The EF-mapped persistence row for an application (aanvraag) — WP-73 demoted this to +/// exactly that: a flat, mutable bag with no invariants of its own (SQLite needs precisely +/// this shape), never read or written directly outside this file. Everywhere else, production +/// code reads and writes (the closed Concept/Submitted/Decided domain +/// union, Domain/Applications/Aanvraag.cs) — 's +/// ToDomain/ApplyTo/ToEntity is the two-way seam between the two. Status is +/// COMPUTED ON READ (see the StatusAt extension below) so auto-approval is purely a +/// function of stored timestamps — no timers, no jobs. /// -public sealed class Aanvraag +public sealed class AanvraagEntity { public required string Id { get; init; } public required string Type { get; init; } // registratie | herregistratie | intake @@ -53,26 +56,6 @@ public sealed class Aanvraag /// The behandelaar's toelichting — required for Afwijzen/MeerInfoOpvragen (becomes /// the published status's Reden), optional for Goedkeuren. public string? BesluitToelichting { get; set; } - - /// The status at a point in time (WP-68 F3) — moved here from - /// Contracts.Mappers.ToStatusDto, which is now a one-line projection of this. A - /// recorded decision wins over the auto-approve computation below. - public AanvraagStatus StatusAt(DateTimeOffset now) - { - if (!Submitted) return AanvraagStatus.Concept(StepIndex, StepCount); - if (Reden is not null) return AanvraagStatus.Afgewezen(Referentie!, Reden); - if (BesluitStatus is { } besluit) - return besluit switch - { - Besluit.Goedkeuren => AanvraagStatus.Goedgekeurd(Referentie!), - Besluit.Afwijzen => AanvraagStatus.Afgewezen(Referentie!, BesluitToelichting), - Besluit.MeerInfoOpvragen => AanvraagStatus.MeerInfoGevraagd(Referentie!, BesluitToelichting), - _ => throw new InvalidOperationException($"Unknown besluit {besluit}"), - }; - if (AutoApprovable && now > SubmittedAt!.Value + ApplicationStore.ProcessingWindow) - return AanvraagStatus.Goedgekeurd(Referentie!); - return AanvraagStatus.InBehandeling(Referentie!, manual: !AutoApprovable); - } } /// @@ -90,10 +73,13 @@ public static class ApplicationStore /// Create a Concept for — UNLESS one of this /// already exists unsubmitted. WP-35: at most one Concept per - /// type is a server-enforced invariant (the FE's draft-sync only guards it best-effort). - /// Race-free: the existence check and the insert share the single write gate. Returns - /// null when a duplicate would be created (the caller maps that to 409 Conflict). - public static Aanvraag? CreateConcept(string type, string owner) + /// type is a server-enforced invariant (the FE's draft-sync only guards it best-effort; + /// this stays procedural here — it's an AGGREGATE-SET rule over every (Owner, Type), not + /// something a single Aanvraag value's own shape could ever encode, and there is no unique + /// index in the schema either). Race-free: the existence check and the insert share the + /// single write gate. Returns null when a duplicate would be created (the caller maps that + /// to 409 Conflict). + public static Aanvraag.Concept? CreateConcept(string type, string owner) { var now = DateTimeOffset.UtcNow; lock (_gate) @@ -101,10 +87,18 @@ public static class ApplicationStore using var db = Db.Create(); if (db.Applications.Any(a => a.Owner == owner && a.Type == type && !a.Submitted)) return null; - var a = new Aanvraag { Id = Guid.NewGuid().ToString(), Type = type, Owner = owner, CreatedAt = now, UpdatedAt = now }; - db.Applications.Add(a); + var concept = new Aanvraag.Concept(stepIndex: 0, stepCount: 0) + { + Id = Guid.NewGuid().ToString(), + Type = type, + Owner = owner, + DocumentIds = Array.Empty(), + CreatedAt = now, + UpdatedAt = now, + }; + db.Applications.Add(concept.ToEntity()); db.SaveChanges(); - return a; + return concept; } } @@ -114,7 +108,7 @@ public static class ApplicationStore { using var db = Db.Create(); var a = db.Applications.Find(id); - return a is not null && a.Owner == owner ? a : null; + return a is not null && a.Owner == owner ? a.ToDomain() : null; } } @@ -123,7 +117,7 @@ public static class ApplicationStore lock (_gate) { using var db = Db.Create(); - return db.Applications.Where(a => a.Owner == owner).ToList(); + return db.Applications.Where(a => a.Owner == owner).ToList().Select(a => a.ToDomain()).ToList(); } } @@ -134,7 +128,7 @@ public static class ApplicationStore lock (_gate) { using var db = Db.Create(); - return db.Applications.Find(id); + return db.Applications.Find(id)?.ToDomain(); } } @@ -149,7 +143,7 @@ public static class ApplicationStore lock (_gate) { using var db = Db.Create(); - return db.Applications.FirstOrDefault(a => a.Referentie == referentie); + return db.Applications.FirstOrDefault(a => a.Referentie == referentie)?.ToDomain(); } } @@ -162,23 +156,34 @@ public static class ApplicationStore using var db = Db.Create(); // Order client-side: SQLite can't ORDER BY a DateTimeOffset (same constraint the // rest of the store sidesteps by never sorting in the query). - return db.Applications.ToList().OrderByDescending(a => a.UpdatedAt).ToList(); + return db.Applications.ToList().OrderByDescending(a => a.UpdatedAt).Select(a => a.ToDomain()).ToList(); } } - /// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable. + /// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable — the + /// domain reconstruction below is what enforces "0 <= StepIndex <= StepCount" + /// ('s own constructor throws on an out-of-range pair instead + /// of this silently writing one onto the row, the way the pre-WP-73 code did). public static bool SyncDraft(string id, string owner, JsonElement draft, int stepIndex, int stepCount, IReadOnlyList? documentIds) { lock (_gate) { using var db = Db.Create(); - var a = db.Applications.Find(id); - if (a is null || a.Owner != owner || a.Submitted) return false; - 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; + var row = db.Applications.Find(id); + if (row is null || row.Owner != owner || row.Submitted) return false; + var concept = new Aanvraag.Concept(stepIndex, stepCount) + { + Id = row.Id, + Type = row.Type, + Owner = row.Owner, + DocumentIds = documentIds ?? row.DocumentIds, + CreatedAt = row.CreatedAt, + UpdatedAt = DateTimeOffset.UtcNow, + ZaakUrl = row.ZaakUrl, + ZgwError = row.ZgwError, + Draft = draft.Clone(), // detach from the request's JsonDocument (disposed after the call) + }; + concept.ApplyTo(row); db.SaveChanges(); return true; } @@ -226,23 +231,39 @@ public static class ApplicationStore /// 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? documentIds) + /// if the aanvraag is gone or already submitted (idempotency guard). WP-73: the returned + /// is constructed with a non-null Referentie/SubmittedAt by + /// its own required members — there is no longer a null-forgiving deref anywhere down the + /// line reading them back (StatusAt, IZaakSource.CreateZaak). Submitting also + /// clears the row's Draft ('s Submitted branch) — nothing + /// reads a submitted aanvraag's draft (the FE only ever resumes a still-Concept wizard), so + /// the doc-comment's old "Draft is Concept only" claim is now actually true, not aspirational. + public static Aanvraag.Submitted? Submit(string id, string owner, string? reject, bool autoApprovable, IReadOnlyList? documentIds) { lock (_gate) { using var db = Db.Create(); - var a = db.Applications.Find(id); - if (a is null || 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(); + var row = db.Applications.Find(id); + if (row is null || row.Owner != owner || row.Submitted) return null; + var now = DateTimeOffset.UtcNow; + var submitted = new Aanvraag.Submitted + { + Id = row.Id, + Type = row.Type, + Owner = row.Owner, + DocumentIds = documentIds ?? row.DocumentIds, + CreatedAt = row.CreatedAt, + UpdatedAt = now, + ZaakUrl = row.ZaakUrl, + ZgwError = row.ZgwError, + Referentie = SubmissionRules.NewReference(), + SubmittedAt = now, + AutoApprovable = autoApprovable, + Reden = reject, + }; + submitted.ApplyTo(row); db.SaveChanges(); - return a; + return submitted; } } @@ -282,22 +303,83 @@ public static class ApplicationStore /// now runs INSIDE this lock, against a status read fresh under the lock, rather than in /// the endpoint beforehand — two concurrent besluiten used to both pass the endpoint's /// check before either wrote, letting the second silently overwrite a terminal decision. + /// WP-73: / + /// require a non-null Toelichting by their own shape — the endpoint already 400s a missing + /// one (BeoordelingRules.RequiresToelichting), and this is the defense-in-depth + /// backstop for any other caller (this method is public, and e.g. + /// Acceptance/BesluitLifecycleTests.cs calls it directly, bypassing the endpoint). /// - public static (RecordBesluitOutcome Outcome, Aanvraag? Aanvraag) RecordBesluit(string id, Besluit besluit, string? toelichting, DateTimeOffset now) + public static (RecordBesluitOutcome Outcome, Aanvraag.Decided? Aanvraag) RecordBesluit(string id, Besluit besluit, string? toelichting, DateTimeOffset now) { lock (_gate) { using var db = Db.Create(); - var a = db.Applications.Find(id); - if (a is null) return (RecordBesluitOutcome.NotFound, null); - var current = a.StatusAt(now).Tag; - if (current is null || !BeoordelingRules.CanDecide(current.Value)) + var row = db.Applications.Find(id); + if (row is null) return (RecordBesluitOutcome.NotFound, null); + + var current = row.ToDomain(); + var tag = current.StatusAt(now).Tag; + if (tag is null || !BeoordelingRules.CanDecide(tag.Value)) return (RecordBesluitOutcome.Conflict, null); - a.BesluitStatus = besluit; - a.BesluitToelichting = toelichting; - a.UpdatedAt = DateTimeOffset.UtcNow; + + // tag non-null ⇒ current is Submitted or already Decided, never Concept ⇒ Referentie/ + // SubmittedAt already exist — carried forward rather than re-derived. + var (referentie, submittedAt) = current switch + { + Aanvraag.Submitted s => (s.Referentie, s.SubmittedAt), + Aanvraag.Decided d => (d.Referentie, d.SubmittedAt), + _ => throw new InvalidOperationException($"Aanvraag {id} has a decidable status but is not submitted."), + }; + + Aanvraag.Decided decided = besluit switch + { + Besluit.Goedkeuren => new Aanvraag.Decided.Goedgekeurd + { + Id = row.Id, + Type = row.Type, + Owner = row.Owner, + DocumentIds = current.DocumentIds, + CreatedAt = current.CreatedAt, + UpdatedAt = now, + ZaakUrl = row.ZaakUrl, + ZgwError = row.ZgwError, + Referentie = referentie, + SubmittedAt = submittedAt, + }, + Besluit.Afwijzen => new Aanvraag.Decided.Afgewezen + { + Id = row.Id, + Type = row.Type, + Owner = row.Owner, + DocumentIds = current.DocumentIds, + CreatedAt = current.CreatedAt, + UpdatedAt = now, + ZaakUrl = row.ZaakUrl, + ZgwError = row.ZgwError, + Referentie = referentie, + SubmittedAt = submittedAt, + Toelichting = toelichting ?? throw new InvalidOperationException("Afwijzen requires a toelichting."), + }, + Besluit.MeerInfoOpvragen => new Aanvraag.Decided.MeerInfoGevraagd + { + Id = row.Id, + Type = row.Type, + Owner = row.Owner, + DocumentIds = current.DocumentIds, + CreatedAt = current.CreatedAt, + UpdatedAt = now, + ZaakUrl = row.ZaakUrl, + ZgwError = row.ZgwError, + Referentie = referentie, + SubmittedAt = submittedAt, + Toelichting = toelichting ?? throw new InvalidOperationException("MeerInfoOpvragen requires a toelichting."), + }, + _ => throw new InvalidOperationException($"Unknown besluit {besluit}."), + }; + + decided.ApplyTo(row); // also sets row.UpdatedAt = decided.UpdatedAt (= now, above) db.SaveChanges(); - return (RecordBesluitOutcome.Ok, a); + return (RecordBesluitOutcome.Ok, decided); } } } diff --git a/backend/src/BigRegister.Api/Data/IZaakSource.cs b/backend/src/BigRegister.Api/Data/IZaakSource.cs index f6fb895..22da698 100644 --- a/backend/src/BigRegister.Api/Data/IZaakSource.cs +++ b/backend/src/BigRegister.Api/Data/IZaakSource.cs @@ -33,17 +33,20 @@ public interface IZaakSource /// /// Register a just-submitted as a zaak (WP-50). The aanvraag is - /// already persisted locally (ApplicationStore.Submit already ran) — this is the - /// integration side-effect, and (Referentie, Status) is what the submit endpoint hands back - /// to the FE (ADR-0001: route the create through the existing submit response DTO, don't add - /// a second one). The local source is a pure passthrough of the already-computed local - /// reference/status (ZaakUrl null — nothing to persist); the OpenZaak source creates a Zaak - /// (+ status + rol) and maps the result back into the same shape, returning the zaak's URL - /// so the endpoint can persist it (, WP-51 needs it - /// to later link documents to this zaak). (WP-53) is the acting - /// citizen — the ZGW JWT's audit claims reflect them, not a static config identity. + /// already persisted locally (ApplicationStore.Submit already ran, hence the + /// parameter type — WP-73: a freshly submitted aanvraag + /// always has a Referentie, so neither implementation needs a null-forgiving deref for it + /// any more) — this is the integration side-effect, and (Referentie, Status) is what the + /// submit endpoint hands back to the FE (ADR-0001: route the create through the existing + /// submit response DTO, don't add a second one). The local source is a pure passthrough of + /// the already-computed local reference/status (ZaakUrl null — nothing to persist); the + /// OpenZaak source creates a Zaak (+ status + rol) and maps the result back into the same + /// shape, returning the zaak's URL so the endpoint can persist it + /// (, WP-51 needs it to later link documents to this + /// zaak). (WP-53) is the acting citizen — the ZGW JWT's audit + /// claims reflect them, not a static config identity. /// - (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller); + (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller); /// /// Extend a behandelaar's already-locally-recorded decision (WP-65b's diff --git a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs index 5858cb1..bce8453 100644 --- a/backend/src/BigRegister.Api/Data/LocalZaakSource.cs +++ b/backend/src/BigRegister.Api/Data/LocalZaakSource.cs @@ -24,8 +24,8 @@ public sealed class LocalZaakSource : IZaakSource /// No external zaak to create — the aanvraag's local submit already IS the record /// of truth, exactly as before this seam existed (WP-50). Zero behaviour change. - public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) => - (aanvraag.Referentie!, aanvraag.ToStatusDto(now), null); + public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller) => + (aanvraag.Referentie, aanvraag.ToStatusDto(now), null); /// No external zaak to update — the recorded decision already IS the record of /// truth locally (WP-66). Zero behaviour change. diff --git a/backend/src/BigRegister.Api/Data/SeedData.cs b/backend/src/BigRegister.Api/Data/SeedData.cs index e2b8b9b..0924e07 100644 --- a/backend/src/BigRegister.Api/Data/SeedData.cs +++ b/backend/src/BigRegister.Api/Data/SeedData.cs @@ -16,7 +16,7 @@ public static class SeedData Beroep: "Arts", Registratiedatum: new DateOnly(2012, 9, 1), Geboortedatum: new DateOnly(1985, 3, 14), - Status: new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1))); + Status: new RegistrationStatus.Geregistreerd(HerregistratieDatum: new DateOnly(2027, 3, 1))); public static readonly Person Person = new( Naam: "Dr. A. (Anna) de Vries", diff --git a/backend/src/BigRegister.Api/Domain/Applications/Aanvraag.cs b/backend/src/BigRegister.Api/Domain/Applications/Aanvraag.cs new file mode 100644 index 0000000..4954b64 --- /dev/null +++ b/backend/src/BigRegister.Api/Domain/Applications/Aanvraag.cs @@ -0,0 +1,103 @@ +using System.Text.Json; + +namespace BigRegister.Domain.Applications; + +/// +/// The aanvraag lifecycle as a closed union (WP-73): (the pre-submission +/// wizard draft) → (awaiting a behandelaar's decision, or already +/// auto-rejected at submission time — see ) → +/// (a behandelaar's outcome recorded). Each variant carries only the fields that make sense for +/// it; the private base constructor closes the hierarchy to the nested sealed records below, so +/// a caller can never construct a fourth variant, a with no referentie, or +/// an Afwijzen/MeerInfoGevraagd with no toelichting — each is a compile error (a missing +/// `required` member, CS9035), not a runtime null-check the way the old flat, mutable +/// Aanvraag needed one. +/// +/// is the EF-mapped persistence row this maps to/from +/// (Api.Data.AanvraagMapper's ToDomain/ApplyTo/ToEntity) — it stays a +/// flat, mutable bag with no invariants of its own (SQLite needs exactly that shape); this type +/// is what production code actually reads and writes everywhere else. The wire-facing, +/// point-in-time a screen renders is a further, time-dependent +/// projection (StatusAt, in Api.Data) — the auto-approval window is a function of +/// wall-clock time, not of this stored shape, so it stays a derived read rather than a fourth +/// member of this union. +/// +public abstract record Aanvraag +{ + public required string Id { get; init; } + public required string Type { get; init; } // registratie | herregistratie | intake + public required string Owner { get; init; } + public required IReadOnlyList DocumentIds { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public required DateTimeOffset UpdatedAt { get; init; } + + /// The OpenZaak zaak's URL, set once CreateZaak (WP-50) registers one — null under + /// the local source, or before a zaak has been registered at all. + public string? ZaakUrl { get; init; } + + /// WP-60: non-null means the ZGW side of this aanvraag's last write did not + /// complete — see Api.Data.ApplicationStore.SetZgwError. + public string? ZgwError { get; init; } + + private Aanvraag() { } + + /// Pre-submission wizard draft. + public sealed record Concept : Aanvraag + { + public JsonElement? Draft { get; init; } + public int StepIndex { get; } + public int StepCount { get; } + + /// 0 <= <= — the + /// non-strict upper bound, not the strict "<" a wizard's own step cursor uses, because + /// ApplicationStore.CreateConcept's freshly-created row is (StepIndex: 0, StepCount: + /// 0) before the wizard's first draft sync ever runs, and that has to stay constructible. + /// + public Concept(int stepIndex, int stepCount) + { + if (stepIndex < 0 || stepCount < 0 || stepIndex > stepCount) + throw new ArgumentOutOfRangeException( + nameof(stepIndex), stepIndex, $"StepIndex must be within [0, StepCount ({stepCount})]."); + StepIndex = stepIndex; + StepCount = stepCount; + } + } + + /// Submitted, no behandelaar decision recorded yet. non-null + /// means SubmissionRules rejected it automatically at submission time (e.g. a manually + /// entered diploma) — terminal in practice (BeoordelingRules.CanDecide refuses a + /// besluit once the projected status is already Afgewezen) but structurally still "no besluit + /// was ever recorded", hence it lives here rather than in . + public sealed record Submitted : Aanvraag + { + public required string Referentie { get; init; } + public required DateTimeOffset SubmittedAt { get; init; } + public required bool AutoApprovable { get; init; } + public string? Reden { get; init; } + } + + /// A behandelaar's decision (WP-65b/68) — closed by besluit: only + /// / require a toelichting + /// (BeoordelingRules.RequiresToelichting's rule, now also a type, not just an endpoint + /// check) — omitting it is a compile error, not merely a 400 the type happens to also let + /// slip through at runtime. + public abstract record Decided : Aanvraag + { + public required string Referentie { get; init; } + public required DateTimeOffset SubmittedAt { get; init; } + + private Decided() { } + + public sealed record Goedgekeurd : Decided; + + public sealed record Afgewezen : Decided + { + public required string Toelichting { get; init; } + } + + public sealed record MeerInfoGevraagd : Decided + { + public required string Toelichting { get; init; } + } + } +} diff --git a/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs b/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs index 484c36e..3d1a37d 100644 --- a/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs +++ b/backend/src/BigRegister.Api/Domain/Intake/IntakePolicy.cs @@ -5,10 +5,10 @@ namespace BigRegister.Domain.Intake; /// scholing question is required. The frontend receives this value /// (GET /intake/policy) and applies it for instant UX feedback /// (intake.machine.ts's lageUren); is the -/// backend re-validating it as the authority on submit (WP-69) — both -/// POST /applications/{id}/submit (intake-typed aanvragen only) and the legacy -/// POST /intakes call it before writing anything, and a violation 400s -/// (ProblemDetails), never silently accepts an incomplete answer. +/// backend re-validating it as the authority on submit (WP-69) — +/// POST /applications/{id}/submit (intake-typed aanvragen only) calls it before +/// writing anything, and a violation 400s (ProblemDetails), never silently accepts +/// an incomplete answer. /// public static class IntakePolicy { diff --git a/backend/src/BigRegister.Api/Domain/Registrations/HerregistratieRule.cs b/backend/src/BigRegister.Api/Domain/Registrations/HerregistratieRule.cs index 9cff934..7527754 100644 --- a/backend/src/BigRegister.Api/Domain/Registrations/HerregistratieRule.cs +++ b/backend/src/BigRegister.Api/Domain/Registrations/HerregistratieRule.cs @@ -11,7 +11,7 @@ public static class HerregistratieRule public const int WindowMonths = 12; public static DateOnly? Deadline(Registration reg) => - reg.Status.Tag == StatusTag.Geregistreerd ? reg.Status.HerregistratieDatum : null; + reg.Status is RegistrationStatus.Geregistreerd g ? g.HerregistratieDatum : null; public static (bool Eligible, string? Reason) Evaluate( Registration reg, DateOnly today, int windowMonths = WindowMonths) @@ -25,8 +25,4 @@ public static class HerregistratieRule ? (true, $"Registratie verloopt binnen {windowMonths} maanden ({deadline:yyyy-MM-dd}).") : (false, $"Herregistratie kan vanaf {windowStart:yyyy-MM-dd}."); } - - /// Invariant: a non-active status must not carry a herregistratie date. - public static bool IsStatusConsistent(RegistrationStatus s) => - s.Tag != StatusTag.Geregistreerd || s.HerregistratieDatum is not null; } diff --git a/backend/src/BigRegister.Api/Domain/Registrations/Registration.cs b/backend/src/BigRegister.Api/Domain/Registrations/Registration.cs index 9896b2f..b3d0bad 100644 --- a/backend/src/BigRegister.Api/Domain/Registrations/Registration.cs +++ b/backend/src/BigRegister.Api/Domain/Registrations/Registration.cs @@ -9,15 +9,37 @@ public enum StatusTag } /// -/// Status as a flat record: only carries a -/// herregistratie deadline. The frontend mirrors this as a discriminated union. +/// Status as a closed union: each variant carries exactly the data that makes sense for it +/// (WP-73). Only carries a herregistratie deadline; only +/// and carry a reden — and there it is +/// required, not nullable (the old flat record left Reden nullable on every tag, +/// diverging from the frontend union, which has always required it on those two variants — +/// see registratie/domain/registration.ts). The private base constructor closes the +/// hierarchy: only the three nested sealed records below can ever inherit from +/// , so a caller can never construct e.g. a +/// with a herregistratie date, or a fourth variant. /// -public sealed record RegistrationStatus( - StatusTag Tag, - DateOnly? HerregistratieDatum = null, - DateOnly? GeschorstTot = null, - string? Reden = null, - DateOnly? DoorgehaaldOp = null); +public abstract record RegistrationStatus +{ + public abstract StatusTag Tag { get; } + + private RegistrationStatus() { } + + public sealed record Geregistreerd(DateOnly HerregistratieDatum) : RegistrationStatus + { + public override StatusTag Tag => StatusTag.Geregistreerd; + } + + public sealed record Geschorst(DateOnly GeschorstTot, string Reden) : RegistrationStatus + { + public override StatusTag Tag => StatusTag.Geschorst; + } + + public sealed record Doorgehaald(DateOnly DoorgehaaldOp, string Reden) : RegistrationStatus + { + public override StatusTag Tag => StatusTag.Doorgehaald; + } +} public sealed record Registration( string BigNummer, diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 5c23d50..5dd830d 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -189,24 +189,6 @@ api.MapPost("/registrations", (RegistratieRequest req, HttpContext ctx) => .Produces() .ProducesProblem(StatusCodes.Status422UnprocessableEntity); -api.MapPost("/herregistraties", (HerregistratieRequest req, HttpContext ctx) => - Submit(ctx, "herregistratie", SubmissionRules.RejectZeroUren(req.Uren), req.Documents)) -.Produces() -.ProducesProblem(StatusCodes.Status422UnprocessableEntity); - -api.MapPost("/intakes", (IntakeRequest req, HttpContext ctx) => -{ - // WP-69: completeness check outside Submit(...) — deliberately not folded into `reject`, - // so this 400 is never cached in IdempotencyStore the way a 422 rejection would be. - var reject = SubmissionRules.RejectZeroUren(req.Uren); - if (reject is null && IntakePolicy.RejectIncompleteScholing(req.Uren, req.AanvullendeScholing, req.ScholingPunten) is { } incomplete) - return Results.Problem(detail: incomplete, statusCode: StatusCodes.Status400BadRequest); - return Submit(ctx, "intake", reject); -}) -.Produces() -.ProducesProblem(StatusCodes.Status400BadRequest) -.ProducesProblem(StatusCodes.Status422UnprocessableEntity); - api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) => Submit(ctx, "telefoonwijziging", SubmissionRules.RejectPhoneChange(req.Telefoon))) .Produces() @@ -344,7 +326,7 @@ api.MapDelete("/applications/{id}", (string id, HttpContext ctx) => { var a = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn); if (a is null) return Results.NotFound(); - if (a.Submitted) + if (a is not Aanvraag.Concept) return Results.Problem(detail: "Een ingediende aanvraag kan niet worden geannuleerd.", statusCode: StatusCodes.Status409Conflict); ApplicationStore.Delete(id, ctx.Zorgverlener().Bsn); return Results.NoContent(); @@ -359,7 +341,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re { var existing = ApplicationStore.Get(id, ctx.Zorgverlener().Bsn); if (existing is null) return Results.NotFound(); - if (existing.Submitted) + if (existing is not Aanvraag.Concept) return Results.Problem(detail: "Aanvraag is al ingediend.", statusCode: StatusCodes.Status409Conflict); // Per wizard type: what rejects the submission (→ Afgewezen) and whether it auto-approves. @@ -404,7 +386,7 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re // failure (an orphan zaak from a rolled-back-then-retried submit is worse than a flagged // one, see openzaak-integration.md's "Write resilience" section). Each ZGW half is caught // separately so a create-zaak failure doesn't also skip the (still-local) document link. - var referentie = submitted.Referentie!; + var referentie = submitted.Referentie; var status = submitted.ToStatusDto(DateTimeOffset.UtcNow); string? zaakUrl = null; try @@ -525,7 +507,8 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H } catch (Exception ex) { - RecordZgwDivergence(ctx, a.Id, updated!.Referentie ?? a.Id, ex); + // WP-73: Aanvraag.Decided's Referentie is required/non-null — no `?? a.Id` fallback needed. + RecordZgwDivergence(ctx, a.Id, updated!.Referentie, ex); } return Results.Ok(new RecordBesluitResponse(updated!.ToStatusDto(now))); diff --git a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs index eda7a34..2780998 100644 --- a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs +++ b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs @@ -94,10 +94,10 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, /// succeeded. The caller (Program.cs's submit endpoint) catches this and records it as a /// flagged divergence (Aanvraag.ZgwError) instead of letting it fail (or diverge) silently — /// see openzaak-integration.md's "Write resilience" section. - public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) => + public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak(Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller) => CreateZaakAsync(aanvraag, now, caller).GetAwaiter().GetResult(); - private async Task<(string Referentie, AanvraagStatusDto Status, string? ZaakUrl)> CreateZaakAsync(Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) + private async Task<(string Referentie, AanvraagStatusDto Status, string? ZaakUrl)> CreateZaakAsync(Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller) { if (!options.ZaaktypeUrls.TryGetValue(aanvraag.Type, out var zaaktypeUrl)) throw new InvalidOperationException( @@ -108,8 +108,9 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, Bronorganisatie: options.Bronorganisatie, VerantwoordelijkeOrganisatie: options.VerantwoordelijkeOrganisatie, Startdatum: DateOnly.FromDateTime(now.UtcDateTime), - Identificatie: aanvraag.Referentie - ?? throw new InvalidOperationException("Aanvraag has no Referentie yet — submit it locally first.")), caller); + // WP-73: Aanvraag.Submitted's Referentie is a required, non-nullable member — a + // just-submitted aanvraag always has one, so there is nothing left to null-check here. + Identificatie: aanvraag.Referentie), caller); var statustypeUrl = await FirstStatustypeUrlAsync(zaaktypeUrl); await zgw.PostAsync($"{options.ZrcBaseUrl}/statussen", diff --git a/backend/swagger.json b/backend/swagger.json index f16fd58..6ca507f 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -249,94 +249,6 @@ } } }, - "/api/v1/herregistraties": { - "post": { - "tags": [ - "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HerregistratieRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReferentieResponse" - } - } - } - }, - "422": { - "description": "Unprocessable Content", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/intakes": { - "post": { - "tags": [ - "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/IntakeRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReferentieResponse" - } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "422": { - "description": "Unprocessable Content", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, "/api/v1/change-requests": { "post": { "tags": [ @@ -2149,23 +2061,6 @@ }, "additionalProperties": false }, - "HerregistratieRequest": { - "type": "object", - "properties": { - "uren": { - "type": "integer", - "format": "int32" - }, - "documents": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DocumentRefDto" - }, - "nullable": true - } - }, - "additionalProperties": false - }, "IntakePolicyDto": { "type": "object", "properties": { @@ -2176,25 +2071,6 @@ }, "additionalProperties": false }, - "IntakeRequest": { - "type": "object", - "properties": { - "uren": { - "type": "integer", - "format": "int32" - }, - "aanvullendeScholing": { - "type": "boolean", - "nullable": true - }, - "scholingPunten": { - "type": "integer", - "format": "int32", - "nullable": true - } - }, - "additionalProperties": false - }, "LetterBlockDto": { "type": "object", "properties": { diff --git a/backend/tests/BigRegister.Tests/Acceptance/BesluitLifecycleTests.cs b/backend/tests/BigRegister.Tests/Acceptance/BesluitLifecycleTests.cs index a02b795..807ce5c 100644 --- a/backend/tests/BigRegister.Tests/Acceptance/BesluitLifecycleTests.cs +++ b/backend/tests/BigRegister.Tests/Acceptance/BesluitLifecycleTests.cs @@ -25,7 +25,7 @@ public class BesluitLifecycleTests(TestWebApplicationFactory factory) : IClassFi private static void Persist(Aanvraag aanvraag) { using var db = Db.Create(); - db.Applications.Add(aanvraag); + db.Applications.Add(aanvraag.ToEntity()); db.SaveChanges(); } @@ -74,7 +74,7 @@ public class BesluitLifecycleTests(TestWebApplicationFactory factory) : IClassFi // When the status is read long after the auto-approve window has passed — the instant an // undecided auto-approvable case of the same shape WOULD read Goedgekeurd (see // ApplicationTests.AutoApprovable_flips_to_goedgekeurd_after_the_window)... - var longAfterTheWindow = aanvraag.SubmittedAt!.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1); + var longAfterTheWindow = aanvraag.SubmittedAt + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1); var status = ApplicationStore.GetAny(aanvraag.Id)!.StatusAt(longAfterTheWindow); // Then the recorded decision still wins — Afgewezen, never Goedgekeurd. diff --git a/backend/tests/BigRegister.Tests/Acceptance/IntakeSubmissionTests.cs b/backend/tests/BigRegister.Tests/Acceptance/IntakeSubmissionTests.cs index c21eb64..d70ee6c 100644 --- a/backend/tests/BigRegister.Tests/Acceptance/IntakeSubmissionTests.cs +++ b/backend/tests/BigRegister.Tests/Acceptance/IntakeSubmissionTests.cs @@ -8,13 +8,13 @@ using BigRegister.Tests.Builders; namespace BigRegister.Tests.Acceptance; /// -/// Behaviour-level tests for the scholing-threshold enforcement (WP-69) over both live HTTP -/// paths — POST /applications/{id}/submit (the wizard's real path) and the legacy -/// POST /intakes (dead from the UI, still a live crafted-POST surface). Built through -/// the type-state builder, mirroring -/// rather than the full wizard/upload dance — the builder's default owner IS -/// 's default caller, -/// so no header juggling. +/// Behaviour-level tests for the scholing-threshold enforcement (WP-69) over +/// POST /applications/{id}/submit (the wizard's real path — WP-72 deleted the legacy +/// POST /intakes endpoint this once also covered). Built through the type-state builder, mirroring rather +/// than the full wizard/upload dance — the builder's default owner IS 's default caller, so +/// no header juggling. /// public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFixture { @@ -23,7 +23,7 @@ public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFi private static void Persist(Aanvraag aanvraag) { using var db = Db.Create(); - db.Applications.Add(aanvraag); + db.Applications.Add(aanvraag.ToEntity()); db.SaveChanges(); } @@ -44,8 +44,7 @@ public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFi Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); // ...and the aanvraag is left a retryable Concept, never marked Submitted. - var stillConcept = ApplicationStore.GetAny(aanvraag.Id)!; - Assert.False(stillConcept.Submitted); + Assert.IsType(ApplicationStore.GetAny(aanvraag.Id)); } [Fact] @@ -121,16 +120,4 @@ public class IntakeSubmissionTests(TestWebApplicationFactory factory) : IClassFi var body = (await res.Content.ReadFromJsonAsync())!; Assert.Equal("Afgewezen", body.Status.Tag); } - - [Fact] - public async Task Legacy_intakes_endpoint_enforces_it_too() - { - // Given no aanvraag needed — the legacy endpoint mints its own reference. - // When a crafted POST hits the dead-from-the-UI /intakes endpoint below threshold, - // with no scholing answer... - var res = await _client.PostAsJsonAsync("/api/v1/intakes", new { uren = 500 }); - - // Then it is rejected too — the crafted-POST surface this WP closes. - Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); - } } diff --git a/backend/tests/BigRegister.Tests/ApplicationTests.cs b/backend/tests/BigRegister.Tests/ApplicationTests.cs index 5aedd71..551a6ca 100644 --- a/backend/tests/BigRegister.Tests/ApplicationTests.cs +++ b/backend/tests/BigRegister.Tests/ApplicationTests.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using BigRegister.Api.Contracts; using BigRegister.Api.Data; using BigRegister.Domain.Applications; +using BigRegister.Tests.Builders; using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; @@ -227,25 +228,14 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture // --- Auto-approval is computed on read: exercise the window boundary without waiting. --- - private static Aanvraag Accepted(bool autoApprovable) => new() - { - Id = "x", - Type = "registratie", - Owner = "test", - Submitted = true, - AutoApprovable = autoApprovable, - Referentie = "BIG-2026-1", - SubmittedAt = DateTimeOffset.UtcNow, - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow, - }; + private static Aanvraag.Submitted Accepted(bool autoApprovable) => + Given.Concept(type: "registratie", owner: "test").Submitted(autoApprovable).Build(); [Fact] public void AutoApprovable_flips_to_goedgekeurd_after_the_window() { var a = Accepted(autoApprovable: true); - Assert.NotNull(a.SubmittedAt); - var t0 = a.SubmittedAt.Value; + var t0 = a.SubmittedAt; Assert.Equal("InBehandeling", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow - TimeSpan.FromSeconds(1)).Tag); Assert.Equal("Goedgekeurd", a.ToStatusDto(t0 + ApplicationStore.ProcessingWindow + TimeSpan.FromSeconds(1)).Tag); } @@ -254,8 +244,7 @@ public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture public void Manual_case_never_auto_advances() { var a = Accepted(autoApprovable: false); - Assert.NotNull(a.SubmittedAt); - var far = a.SubmittedAt.Value + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1); + var far = a.SubmittedAt + ApplicationStore.ProcessingWindow + TimeSpan.FromDays(1); var status = a.ToStatusDto(far); Assert.Equal("InBehandeling", status.Tag); Assert.True(status.Manual); diff --git a/backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs b/backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs index 31fd155..812c86a 100644 --- a/backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs +++ b/backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs @@ -26,7 +26,7 @@ file sealed class IdMismatchZaakSource : IZaakSource inner.ListMyCases(caller, now).Select(Rekey).ToList(); public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak( - Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) => inner.CreateZaak(aanvraag, now, caller); + Aanvraag.Submitted aanvraag, DateTimeOffset now, CallerIdentity caller) => inner.CreateZaak(aanvraag, now, caller); public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) => inner.RecordBesluit(aanvraag, besluit, toelichting, now, caller); diff --git a/backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs b/backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs index ed54c3f..b486b3c 100644 --- a/backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs +++ b/backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs @@ -1,7 +1,6 @@ using System.Threading; using BigRegister.Api.Data; using BigRegister.Domain.Applications; -using BigRegister.Domain.Beoordeling; namespace BigRegister.Tests.Builders; @@ -15,18 +14,16 @@ public static class TestIdentities } /// -/// Type-state test-data builder for (WP-70). "Build test data through the -/// same door production code uses" — a Concept can only ever become Submitted, and only a -/// Submitted aanvraag can be Decided, so the compiler refuses a fixture built through an illegal -/// path (e.g. deciding a still-Concept aanvraag) instead of that being a runtime assertion nobody -/// wrote. Start at . -/// -/// ponytail: itself stays exactly what it always was — a mutable, -/// EF-backed bag with no invariants of its own (that's Data/ApplicationStore.cs's job in -/// production, via its own lock + checks). This builder does not -/// refactor it into an immutable aggregate; it's the one enforced DOOR through which TEST code -/// builds one, so the invariants a real request path enforces don't quietly go missing from a -/// fixture assembled by hand. +/// Type-state test-data builder for (WP-70; simplified at WP-73). "Build +/// test data through the same door production code uses" — itself is now +/// the closed Concept/Submitted/Decided union WP-73 introduced, so this builder no longer needs +/// to mirror production's guards (step-index bounds, "Afwijzen needs a toelichting") by hand — +/// it just calls the real nested constructors/required members, which enforce them. A call that +/// would build an illegal Aanvraag (e.g. deciding a still-Concept aanvraag, or an Afwijzen with +/// no toelichting) is refused the same way production refuses it: a still-Concept aanvraag has +/// no .Decided(...) to call in the first place, and a missing toelichting is a runtime +/// guard identical to ApplicationStore.RecordBesluit's own. Start at +/// . /// public static class Given { @@ -52,60 +49,50 @@ public sealed class ConceptAanvraag } /// The wizard's current position — step of . - /// Guarded the same way a real cursor is (`STEPS[Math.min(cursor, STEPS.length - 1)]` on the - /// frontend): must be at least 1, and must fall - /// within [0, of)AtStep(9, 2) is not a position any real wizard can reach, so - /// the builder refuses it instead of silently building an impossible fixture. + /// Bounds are 's OWN constructor's to enforce, not this + /// builder's — an out-of-range pair fails at , the same + /// production throws, not a guard restated here. public ConceptAanvraag AtStep(int index, int of) { - if (of < 1) - throw new ArgumentOutOfRangeException(nameof(of), of, "Step count must be at least 1."); - if (index < 0 || index >= of) - throw new ArgumentOutOfRangeException(nameof(index), index, $"Step index must be within [0, {of})."); _stepIndex = index; _stepCount = of; return this; } /// Submits the draft — always assigns a Referentie AND SubmittedAt together (mirrors - /// ApplicationStore.Submit), so Aanvraag.StatusAt's Referentie! is honest - /// for every fixture built this way, never a null-ref waiting to happen. - public SubmittedAanvraag Submitted(bool autoApprovable = false) => - new(_type, _owner, _stepIndex, _stepCount, autoApprovable); + /// ApplicationStore.Submit), so a fixture built this way can never hit the + /// null-forgiving derefs the pre-WP-73 flat Aanvraag needed (there's nothing to force any + /// more: both are required, non-null members of ). + public SubmittedAanvraag Submitted(bool autoApprovable = false) => new(_type, _owner, autoApprovable); - public Aanvraag Build() => new() + public Aanvraag.Concept Build() => new(_stepIndex, _stepCount) { Id = Guid.NewGuid().ToString(), Type = _type, Owner = _owner, - StepIndex = _stepIndex, - StepCount = _stepCount, + DocumentIds = Array.Empty(), CreatedAt = DateTimeOffset.UtcNow, UpdatedAt = DateTimeOffset.UtcNow, }; } /// A submitted aanvraag, open for a behandelaar's decision. The only next step is -/// — there is no way back to ConceptAanvraag. +/// — there is no way back to . public sealed class SubmittedAanvraag { private static int _referentieSeq; private readonly string _type; private readonly string _owner; - private readonly int _stepIndex; - private readonly int _stepCount; private readonly bool _autoApprovable; private readonly string _referentie; private readonly DateTimeOffset _submittedAt; private string? _zaakUrl; - internal SubmittedAanvraag(string type, string owner, int stepIndex, int stepCount, bool autoApprovable) + internal SubmittedAanvraag(string type, string owner, bool autoApprovable) { _type = type; _owner = owner; - _stepIndex = stepIndex; - _stepCount = stepCount; _autoApprovable = autoApprovable; // A plausible reference in SubmissionRules.NewReference's shape ("BIG-2026-" + a number) — // sequential (not random) so a fixture's value is reproducible across a test run. @@ -114,67 +101,97 @@ public sealed class SubmittedAanvraag } /// Registers this aanvraag's already-known OpenZaak zaak URL — mirrors - /// , the one production writer of this - /// field, so a fixture that needs a pre-existing zaak doesn't reach past Build() to - /// mutate the result by hand. + /// , the one production writer of this field, so a + /// fixture that needs a pre-existing zaak doesn't reach past Build() to mutate the + /// result by hand. public SubmittedAanvraag WithZaakUrl(string zaakUrl) { _zaakUrl = zaakUrl; return this; } - /// Records a behandelaar's decision — reusing , - /// the SAME rule production's besluit endpoint runs, rather than restating it here where it - /// could quietly drift. Throws for an Afwijzen/MeerInfoOpvragen - /// with a null/blank — exactly what that endpoint rejects with - /// a 400, just caught here at fixture-build time instead. - public DecidedAanvraag Decided(Besluit besluit, string? toelichting = null) + /// Records a behandelaar's decision. Unlike the pre-WP-73 builder, there is no + /// hand-written toelichting guard mirroring BeoordelingRules.RequiresToelichting any + /// more — / + /// simply have a `required string Toelichting` member; the null-coalescing throw below is the + /// one place a null has to turn into an exception (this method's own parameter is still the + /// nullable string? a wire request would carry), same failure production's own + /// ApplicationStore.RecordBesluit raises for the identical input. + public DecidedAanvraag Decided(Besluit besluit, string? toelichting = null) => new(BuildDecided(besluit, toelichting)); + + private Aanvraag.Decided BuildDecided(Besluit besluit, string? toelichting) { - if (BeoordelingRules.RequiresToelichting(besluit) && string.IsNullOrWhiteSpace(toelichting)) - throw new ArgumentException($"{besluit} requires a toelichting.", nameof(toelichting)); - return new DecidedAanvraag(this, besluit, toelichting); + var (id, createdAt) = (Guid.NewGuid().ToString(), _submittedAt); + return besluit switch + { + Besluit.Goedkeuren => new Aanvraag.Decided.Goedgekeurd + { + Id = id, + Type = _type, + Owner = _owner, + DocumentIds = Array.Empty(), + CreatedAt = createdAt, + UpdatedAt = createdAt, + ZaakUrl = _zaakUrl, + Referentie = _referentie, + SubmittedAt = _submittedAt, + }, + Besluit.Afwijzen => new Aanvraag.Decided.Afgewezen + { + Id = id, + Type = _type, + Owner = _owner, + DocumentIds = Array.Empty(), + CreatedAt = createdAt, + UpdatedAt = createdAt, + ZaakUrl = _zaakUrl, + Referentie = _referentie, + SubmittedAt = _submittedAt, + Toelichting = toelichting ?? throw new ArgumentException("Afwijzen requires a toelichting.", nameof(toelichting)), + }, + Besluit.MeerInfoOpvragen => new Aanvraag.Decided.MeerInfoGevraagd + { + Id = id, + Type = _type, + Owner = _owner, + DocumentIds = Array.Empty(), + CreatedAt = createdAt, + UpdatedAt = createdAt, + ZaakUrl = _zaakUrl, + Referentie = _referentie, + SubmittedAt = _submittedAt, + Toelichting = toelichting ?? throw new ArgumentException("MeerInfoOpvragen requires a toelichting.", nameof(toelichting)), + }, + _ => throw new ArgumentOutOfRangeException(nameof(besluit), besluit, "Unknown besluit."), + }; } - public Aanvraag Build() => new() + public Aanvraag.Submitted Build() => new() { Id = Guid.NewGuid().ToString(), Type = _type, Owner = _owner, - StepIndex = _stepIndex, - StepCount = _stepCount, - Submitted = true, - Referentie = _referentie, - AutoApprovable = _autoApprovable, - SubmittedAt = _submittedAt, + DocumentIds = Array.Empty(), CreatedAt = _submittedAt, UpdatedAt = _submittedAt, ZaakUrl = _zaakUrl, + Referentie = _referentie, + SubmittedAt = _submittedAt, + AutoApprovable = _autoApprovable, }; } -/// A submitted aanvraag with a behandelaar's decision already recorded. Terminal in the -/// builder too — there's nothing past , matching Goedgekeurd/Afgewezen being -/// terminal in the domain (); a fixture that needs a -/// SECOND besluit (the MeerInfoGevraagd "still decidable" case) builds fresh from -/// again, exactly as a real second request would. -public sealed class DecidedAanvraag +/// A submitted aanvraag with a behandelaar's decision already recorded — terminal in +/// the builder too, matching Goedgekeurd/Afgewezen being terminal in the domain +/// (); a fixture that +/// needs a SECOND besluit (the MeerInfoGevraagd "still decidable" case) builds fresh from +/// again, exactly as a real second request would. Just a one-line +/// wrapper around the already-fully-built value — WP-73 moved +/// all the actual construction (and its invariant enforcement) into +/// itself, so there's nothing left for this type to do +/// except keep .Decided(...).Build() a valid two-call chain for the existing test +/// suite. +public sealed class DecidedAanvraag(Aanvraag.Decided value) { - private readonly SubmittedAanvraag _submitted; - private readonly Besluit _besluit; - private readonly string? _toelichting; - - internal DecidedAanvraag(SubmittedAanvraag submitted, Besluit besluit, string? toelichting) - { - _submitted = submitted; - _besluit = besluit; - _toelichting = toelichting; - } - - public Aanvraag Build() - { - var aanvraag = _submitted.Build(); - aanvraag.BesluitStatus = _besluit; - aanvraag.BesluitToelichting = _toelichting; - return aanvraag; - } + public Aanvraag.Decided Build() => value; } diff --git a/backend/tests/BigRegister.Tests/Domain/BeoordelingRuleTests.cs b/backend/tests/BigRegister.Tests/Domain/BeoordelingRuleTests.cs index f9a6c95..8c263a6 100644 --- a/backend/tests/BigRegister.Tests/Domain/BeoordelingRuleTests.cs +++ b/backend/tests/BigRegister.Tests/Domain/BeoordelingRuleTests.cs @@ -1,3 +1,4 @@ +using BigRegister.Api.Data; using BigRegister.Domain.Applications; using BigRegister.Domain.Beoordeling; using BigRegister.Tests.Builders; diff --git a/backend/tests/BigRegister.Tests/Domain/RegistrationRuleTests.cs b/backend/tests/BigRegister.Tests/Domain/RegistrationRuleTests.cs index 091b6e0..ef12013 100644 --- a/backend/tests/BigRegister.Tests/Domain/RegistrationRuleTests.cs +++ b/backend/tests/BigRegister.Tests/Domain/RegistrationRuleTests.cs @@ -7,7 +7,7 @@ public class HerregistratieRuleTests private static Registration Active(DateOnly deadline) => new( "19012345601", "Test", "Arts", new DateOnly(2012, 9, 1), new DateOnly(1985, 3, 14), - new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: deadline)); + new RegistrationStatus.Geregistreerd(HerregistratieDatum: deadline)); [Fact] public void Eligible_within_window() @@ -40,18 +40,9 @@ public class HerregistratieRuleTests { 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.Geschorst(GeschorstTot: new DateOnly(2027, 1, 1), Reden: "x"), }; var (eligible, _) = HerregistratieRule.Evaluate(reg, today: new DateOnly(2026, 6, 26)); Assert.False(eligible); } - - [Fact] - public void Status_consistency_invariant() - { - Assert.True(HerregistratieRule.IsStatusConsistent( - new RegistrationStatus(StatusTag.Geregistreerd, HerregistratieDatum: new DateOnly(2027, 3, 1)))); - Assert.False(HerregistratieRule.IsStatusConsistent( - new RegistrationStatus(StatusTag.Geregistreerd))); - } } diff --git a/backend/tests/BigRegister.Tests/EndpointTests.cs b/backend/tests/BigRegister.Tests/EndpointTests.cs index f851844..d7335a1 100644 --- a/backend/tests/BigRegister.Tests/EndpointTests.cs +++ b/backend/tests/BigRegister.Tests/EndpointTests.cs @@ -89,27 +89,6 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture(null as any); } - /** - * @return OK - */ - herregistraties(body: HerregistratieRequest): Promise { - let url_ = this.baseUrl + "/api/v1/herregistraties"; - url_ = url_.replace(/[?&]$/, ""); - - const content_ = JSON.stringify(body); - - let options_: RequestInit = { - body: content_, - method: "POST", - headers: { - "Content-Type": "application/json", - "Accept": "application/json" - } - }; - - return this.http.fetch(url_, options_).then((_response: Response) => { - return this.processHerregistraties(_response); - }); - } - - protected processHerregistraties(response: Response): Promise { - const status = response.status; - let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; - if (status === 200) { - return response.text().then((_responseText) => { - let result200: any = null; - result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse; - return result200; - }); - } else if (status === 422) { - return response.text().then((_responseText) => { - let result422: any = null; - result422 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; - return throwException("Unprocessable Content", status, _responseText, _headers, result422); - }); - } else if (status !== 200 && status !== 204) { - return response.text().then((_responseText) => { - return throwException("An unexpected server error occurred.", status, _responseText, _headers); - }); - } - return Promise.resolve(null as any); - } - - /** - * @return OK - */ - intakes(body: IntakeRequest): Promise { - let url_ = this.baseUrl + "/api/v1/intakes"; - url_ = url_.replace(/[?&]$/, ""); - - const content_ = JSON.stringify(body); - - let options_: RequestInit = { - body: content_, - method: "POST", - headers: { - "Content-Type": "application/json", - "Accept": "application/json" - } - }; - - return this.http.fetch(url_, options_).then((_response: Response) => { - return this.processIntakes(_response); - }); - } - - protected processIntakes(response: Response): Promise { - const status = response.status; - let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; - if (status === 200) { - return response.text().then((_responseText) => { - let result200: any = null; - result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse; - return result200; - }); - } else if (status === 400) { - return response.text().then((_responseText) => { - let result400: any = null; - result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; - return throwException("Bad Request", status, _responseText, _headers, result400); - }); - } else if (status === 422) { - return response.text().then((_responseText) => { - let result422: any = null; - result422 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; - return throwException("Unprocessable Content", status, _responseText, _headers, result422); - }); - } else if (status !== 200 && status !== 204) { - return response.text().then((_responseText) => { - return throwException("An unexpected server error occurred.", status, _responseText, _headers); - }); - } - return Promise.resolve(null as any); - } - /** * @return OK */ @@ -2205,21 +2107,10 @@ export interface HerregistratieDecisionsDto { herregistratieReason?: string | undefined; } -export interface HerregistratieRequest { - uren?: number; - documents?: DocumentRefDto[] | undefined; -} - export interface IntakePolicyDto { scholingThreshold?: number; } -export interface IntakeRequest { - uren?: number; - aanvullendeScholing?: boolean | undefined; - scholingPunten?: number | undefined; -} - export interface LetterBlockDto { type?: string | undefined; blockId?: string | undefined;