Compare commits
11 Commits
cbf697b8fa
...
44eb2d2186
| Author | SHA1 | Date | |
|---|---|---|---|
| 44eb2d2186 | |||
| 556f2f47bf | |||
| 40dbcb2606 | |||
| e276629107 | |||
| 26c2c5acd0 | |||
| e272869f00 | |||
| f3de30b72c | |||
| 85c805b8bd | |||
| 0cfb01f12c | |||
| ac1f0b6aeb | |||
| 8b19fad558 |
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
@@ -30,7 +30,10 @@ jobs:
|
||||
- run: npm run format:check
|
||||
- run: npm run check:tokens
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
# --localize builds every configured locale (nl + en, angular.json's i18n
|
||||
# block) in one pass; i18nMissingTranslation:"error" (angular.json) fails
|
||||
# this step if messages.en.xlf is missing a unit the source (WP-20) gains.
|
||||
- run: npx ng build --localize
|
||||
# The shipped bundle must stay clean; dev-only advisories are excluded.
|
||||
- run: npm audit --omit=dev
|
||||
|
||||
@@ -60,6 +63,29 @@ jobs:
|
||||
- run: dotnet format backend/BigRegister.slnx --verify-no-changes
|
||||
- run: dotnet test backend/BigRegister.slnx
|
||||
|
||||
e2e:
|
||||
# Smoke-level Playwright run against the REAL FE+backend (WP-19) — a fresh
|
||||
# runner checkout per run, so there's no bigregister.db (WP-22, gitignored)
|
||||
# left over from a prior run to leak state in; the backend creates + migrates
|
||||
# an empty one on this boot, same as a fresh clone always has.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
- run: npm ci
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- run: dotnet run --project backend/src/BigRegister.Api --urls http://localhost:5000 &
|
||||
- run: npx ng serve --proxy-config proxy.conf.json &
|
||||
- run: npx wait-on http://localhost:5000/swagger http://localhost:4200
|
||||
- run: npm run e2e
|
||||
|
||||
codeql:
|
||||
# Static analysis (SAST) for both sides; results appear under the Security tab.
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -45,3 +45,9 @@ Thumbs.db
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
# Playwright e2e
|
||||
/test-results
|
||||
/playwright-report
|
||||
/blob-report
|
||||
/playwright/.cache
|
||||
|
||||
@@ -25,6 +25,18 @@ const preview: Preview = {
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
// Sidebar tells the DDD seam: reusable design system, then domain contexts.
|
||||
// See src/docs/layers.mdx.
|
||||
options: {
|
||||
storySort: {
|
||||
order: [
|
||||
'Foundations',
|
||||
'Design System',
|
||||
['Atoms', 'Molecules', 'Organisms', 'Templates', 'Devtools'],
|
||||
'Domein',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -9,8 +9,10 @@ update this file.
|
||||
POC of a Dutch BIG-register self-service portal (healthcare professionals log in,
|
||||
view their registration, apply for re-registration). Angular 22, standalone,
|
||||
signals. Auth is faked; **data and business rules are served by a minimal ASP.NET
|
||||
Core backend** (`backend/`, see its README — in-memory seeded, no DB) and consumed
|
||||
through an NSwag-generated typed client. The FE renders the backend's decisions.
|
||||
Core backend** (`backend/`, see its README) and consumed through an NSwag-generated
|
||||
typed client. The FE renders the backend's decisions. Reference data mimicking
|
||||
BRP/DUO (`Data/SeedData.cs`) is in-memory; applications, documents and the brief
|
||||
persist to a SQLite file via EF Core (WP-22) — `docs/backlog/WP-22-durable-persistence.md`.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -80,7 +82,7 @@ model a discriminated union instead.** Three tools, all in `shared/application`:
|
||||
machine's State/Msg types are context-prefixed (`ChangeRequestState`,
|
||||
`ChangeRequestMsg`), never bare `State`/`Msg`; a top-level machine exports
|
||||
`initial` + `reduce`. A **composable sub-machine** embedded inside a parent
|
||||
model keeps prefixed *value* exports instead (`initialUpload`/`reduceUpload`,
|
||||
model keeps prefixed _value_ exports instead (`initialUpload`/`reduceUpload`,
|
||||
see `upload.machine.ts`) — prefixing there avoids alias noise at the
|
||||
composition site.
|
||||
- **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a
|
||||
@@ -120,8 +122,12 @@ but the FE doesn't call them.
|
||||
Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a
|
||||
spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters).
|
||||
Test the pure function directly — no Angular TestBed for domain. UI is exercised via
|
||||
Storybook stories (`*.stories.ts` co-located, titled `Layer/Name`, a11y addon on),
|
||||
not heavy component tests.
|
||||
Storybook stories (`*.stories.ts` co-located, a11y addon on), not heavy component tests.
|
||||
**Story titles mirror the sidebar's Design System/Domein split** (see
|
||||
`src/docs/layers.mdx`): a `shared/ui`/`shared/layout` component is titled
|
||||
`Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`; a component in a
|
||||
context's `ui/` is titled `Domein/<Context>/<Name>` — full stop, regardless of which
|
||||
atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
25
README.md
25
README.md
@@ -31,6 +31,7 @@ cd backend && dotnet run --project src/BigRegister.Api # API → http://localh
|
||||
|
||||
npm run storybook # component library, organized by atomic layer
|
||||
npm run gen:api # regenerate the typed API client from the backend OpenAPI doc
|
||||
npm run e2e # Playwright smoke tests against the running app + backend (both must be up)
|
||||
```
|
||||
|
||||
Flow: **Login → Dashboard → Mijn gegevens (wijziging) → Herregistratie → Intake**.
|
||||
@@ -166,12 +167,21 @@ degrade to an instant navigation.
|
||||
- Styling: **CIBG Huisstijl** (customized Bootstrap 5.2) vendored in
|
||||
`public/cibg-huisstijl/`, loaded via `<link>`; `src/styles.scss` holds the
|
||||
`--rhc-*` → CIBG/`--bs-*` token bridge (ADR-0003). No styling npm dependency.
|
||||
- Data: ASP.NET Core backend (`backend/`, in-memory seeded) exposed via an OpenAPI
|
||||
contract; the FE consumes an **NSwag-generated** typed client (`npm run gen:api`).
|
||||
- Data: ASP.NET Core backend (`backend/`, EF Core/SQLite-persisted; BRP/DUO
|
||||
reference data stays in-memory-seeded) exposed via an OpenAPI contract; the FE
|
||||
consumes an **NSwag-generated** typed client (`npm run gen:api`).
|
||||
The `?scenario=` toggle (`shared/infrastructure/scenario.interceptor.ts`) is
|
||||
**dev-only** — it is not wired into production builds.
|
||||
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags
|
||||
Angular 22; the builder runs fine (build verified).
|
||||
- **i18n**: every user-facing string is `$localize`-wrapped with a stable `@@id`
|
||||
(source locale `nl`). `npx ng build --localize` (CI runs this) builds both `nl` and
|
||||
`en` — a genuine second-locale build, not just an unexercised claim — into
|
||||
`dist/atomic-design-poc/browser/{nl,en}/`; `ng serve --configuration=en` serves the
|
||||
English build locally. `src/locale/messages.en.xlf` is real (if demo-quality)
|
||||
English, not machine-untranslated placeholders; `angular.json`'s
|
||||
`i18nMissingTranslation: "error"` fails the build if a new `$localize` string ships
|
||||
without a translation. `npm run extract-i18n` regenerates the `nl` reference file.
|
||||
|
||||
### Dependency security
|
||||
|
||||
@@ -185,6 +195,11 @@ We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 2
|
||||
|
||||
### Deliberately out of scope (POC)
|
||||
|
||||
Real auth/DigiD, real BRP/DUO upstreams, a database/persisted audit store, i18n,
|
||||
NgRx, licensed RO/Rijks fonts + logo (system-font stack; text wordmark). (The backend
|
||||
itself _is_ implemented.)
|
||||
Real auth/DigiD, real BRP/DUO upstreams, a production-grade database (Postgres/SQL
|
||||
Server — SQLite persists applications/documents/the brief + a real audit table,
|
||||
see `backend/README.md`, WP-22), NgRx, licensed RO/Rijks fonts + logo (system-font
|
||||
stack; text wordmark). (The backend itself _is_ implemented.) i18n's build seam is
|
||||
proven (see above) but
|
||||
production-quality translation, a runtime locale switcher, and RTL/pluralization
|
||||
edge cases are not — the `en` file is demo-quality, and locale is a build-time
|
||||
choice, not a switch in the running app.
|
||||
|
||||
17
angular.json
17
angular.json
@@ -17,6 +17,14 @@
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"i18n": {
|
||||
"sourceLocale": "nl",
|
||||
"locales": {
|
||||
"en": {
|
||||
"translation": "src/locale/messages.en.xlf"
|
||||
}
|
||||
}
|
||||
},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
@@ -31,7 +39,8 @@
|
||||
}
|
||||
],
|
||||
"styles": ["src/styles.scss"],
|
||||
"polyfills": ["@angular/localize/init"]
|
||||
"polyfills": ["@angular/localize/init"],
|
||||
"i18nMissingTranslation": "error"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@@ -59,6 +68,9 @@
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"en": {
|
||||
"localize": ["en"]
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
@@ -71,6 +83,9 @@
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "atomic-design-poc:build:development"
|
||||
},
|
||||
"en": {
|
||||
"buildTarget": "atomic-design-poc:build:development,en"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
|
||||
5
backend/.gitignore
vendored
5
backend/.gitignore
vendored
@@ -1,2 +1,7 @@
|
||||
bin/
|
||||
obj/
|
||||
|
||||
# WP-22: runtime SQLite file (+ WAL sidecars) — ship the migration, not the data.
|
||||
bigregister.db
|
||||
bigregister.db-shm
|
||||
bigregister.db-wal
|
||||
|
||||
@@ -4,8 +4,18 @@ The backend that hosts the **business rules** for the BIG-register portal. The
|
||||
frontend renders the decisions this service computes; it does not recompute them
|
||||
(BFF-lite + decision DTOs — see `../docs/architecture/0001-bff-lite-decision-dtos.md`).
|
||||
|
||||
No database, no real BRP/DUO: data is in-memory and seeded (`Data/SeedData.cs`),
|
||||
but the endpoints, DTOs, status codes and error envelope are production-shaped.
|
||||
No real BRP/DUO: the reference data they'd return (registration, person, diplomas,
|
||||
notes — `Data/SeedData.cs`) is in-memory and seeded, but the endpoints, DTOs,
|
||||
status codes and error envelope are production-shaped.
|
||||
|
||||
**Applications, documents and the brief persist** to a SQLite file
|
||||
(`src/BigRegister.Api/bigregister.db`, EF Core-backed — `Data/AppDbContext.cs`,
|
||||
`Data/Db.cs`) created and migrated on first run; restarting the process (or
|
||||
`docker compose restart api` — the existing `./backend:/src` bind mount already
|
||||
covers it, see `docker-compose.yml`) does **not** lose data. Delete the file to
|
||||
reset demo data back to empty, the same state a fresh clone starts from. This is
|
||||
a deliberate, right-sized choice for a POC (SQLite, no external DB service) — see
|
||||
`docs/backlog/WP-22-durable-persistence.md`.
|
||||
|
||||
## Run
|
||||
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
"swagger"
|
||||
],
|
||||
"rollForward": false
|
||||
},
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.9",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,15 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
|
||||
<!-- Pin over EF Core Sqlite's own transitive default (2.1.11): that version
|
||||
bundles a pre-3.50.2 SQLite with a known high-severity memory-corruption
|
||||
advisory (GHSA-2m69-gcr7-jv3q). 3.0.3 bundles a patched SQLite. -->
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
63
backend/src/BigRegister.Api/Data/AppDbContext.cs
Normal file
63
backend/src/BigRegister.Api/Data/AppDbContext.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using System.Text.Json;
|
||||
using BigRegister.Api.Contracts;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core/SQLite persistence for the three stores that used to be static
|
||||
/// in-memory dictionaries (WP-22): <see cref="Aanvraag"/>, <see cref="StoredDocument"/>
|
||||
/// + <see cref="AuditEntry"/>, and <see cref="BriefEntity"/>. 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
|
||||
/// already treats them as opaque (see BriefStore's own header comment), so a JSON
|
||||
/// column matches that "don't interpret it" posture with zero schema redesign,
|
||||
/// per this WP's own decision to relocate shapes, not redesign them.
|
||||
/// </summary>
|
||||
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<StoredDocument> Documents => Set<StoredDocument>();
|
||||
public DbSet<AuditEntry> AuditEntries => Set<AuditEntry>();
|
||||
public DbSet<Aanvraag> Applications => Set<Aanvraag>();
|
||||
public DbSet<BriefEntity> Briefs => Set<BriefEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<StoredDocument>().HasKey(d => d.DocumentId);
|
||||
|
||||
modelBuilder.Entity<AuditEntry>(e =>
|
||||
{
|
||||
e.HasKey(a => a.Id);
|
||||
e.Property(a => a.Id).ValueGeneratedOnAdd();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Aanvraag>(e =>
|
||||
{
|
||||
e.HasKey(a => a.Id);
|
||||
e.Property(a => a.Draft).HasConversion(DraftConverter);
|
||||
e.Property(a => a.DocumentIds).HasConversion(Json<List<string>>());
|
||||
});
|
||||
|
||||
modelBuilder.Entity<BriefEntity>(e =>
|
||||
{
|
||||
e.HasKey(b => b.BriefId);
|
||||
e.HasIndex(b => b.Owner).IsUnique(); // one demo brief per owner (GetOrCreate's invariant)
|
||||
e.Property(b => b.Placeholders).HasConversion(Json<IReadOnlyList<PlaceholderDefDto>>());
|
||||
e.Property(b => b.Sections).HasConversion(Json<List<LetterSectionDto>>());
|
||||
e.Property(b => b.Status).HasConversion(Json<BriefStatusDto>());
|
||||
});
|
||||
}
|
||||
|
||||
// A wizard's draft is an opaque JsonElement snapshot — stored as its raw JSON
|
||||
// text. `.Clone()` on read detaches the element from the short-lived JsonDocument
|
||||
// that parsed it (same rule ApplicationStore.SyncDraft already follows for the
|
||||
// request body — an uncloned element is invalid once its JsonDocument is GC'd).
|
||||
private static readonly ValueConverter<JsonElement?, string?> DraftConverter = new(
|
||||
v => v.HasValue ? v.Value.GetRawText() : null,
|
||||
v => string.IsNullOrEmpty(v) ? (JsonElement?)null : JsonDocument.Parse(v).RootElement.Clone());
|
||||
|
||||
private static ValueConverter<T, string> Json<T>() => new(
|
||||
v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
|
||||
v => JsonSerializer.Deserialize<T>(v, (JsonSerializerOptions?)null)!);
|
||||
}
|
||||
@@ -29,34 +29,48 @@ public sealed class Aanvraag
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory application store (no DB), mirrors <see cref="DocumentStore"/>.
|
||||
/// ponytail: one global lock — fine for a single-process demo; swap for per-key
|
||||
/// locks if it ever serves load.
|
||||
/// EF Core/SQLite-backed application store (WP-22 — was a static Dictionary),
|
||||
/// mirrors <see cref="DocumentStore"/>. ponytail: one global lock — SQLite
|
||||
/// tolerates only one writer at a time anyway, and this was already a single
|
||||
/// coarse gate before the DB existed.
|
||||
/// </summary>
|
||||
public static class ApplicationStore
|
||||
{
|
||||
/// After this window an auto-approvable submission reports Goedgekeurd (computed on read).
|
||||
public static readonly TimeSpan ProcessingWindow = TimeSpan.FromSeconds(8);
|
||||
|
||||
private static readonly Dictionary<string, Aanvraag> _apps = new();
|
||||
private static readonly object _gate = new();
|
||||
|
||||
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;
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
db.Applications.Add(a);
|
||||
db.SaveChanges();
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
public static Aanvraag? Get(string id, string owner)
|
||||
{
|
||||
lock (_gate) return _apps.TryGetValue(id, out var a) && a.Owner == owner ? a : null;
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
var a = db.Applications.Find(id);
|
||||
return a is not null && a.Owner == owner ? a : null;
|
||||
}
|
||||
}
|
||||
|
||||
public static IReadOnlyList<Aanvraag> List(string owner)
|
||||
{
|
||||
lock (_gate) return _apps.Values.Where(a => a.Owner == owner).ToList();
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
return db.Applications.Where(a => a.Owner == owner).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// Draft sync: idempotent upsert of the wizard snapshot. Only a Concept is mutable.
|
||||
@@ -64,12 +78,15 @@ public static class ApplicationStore
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return false;
|
||||
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;
|
||||
db.SaveChanges();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -81,9 +98,12 @@ public static class ApplicationStore
|
||||
List<string> docs;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner) return false;
|
||||
using var db = Db.Create();
|
||||
var a = db.Applications.Find(id);
|
||||
if (a is null || a.Owner != owner) return false;
|
||||
docs = a.DocumentIds.ToList();
|
||||
_apps.Remove(id);
|
||||
db.Applications.Remove(a);
|
||||
db.SaveChanges();
|
||||
}
|
||||
foreach (var d in docs) DocumentStore.DeleteOwned(d, owner);
|
||||
return true;
|
||||
@@ -96,7 +116,9 @@ public static class ApplicationStore
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_apps.TryGetValue(id, out var a) || a.Owner != owner || a.Submitted) return null;
|
||||
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;
|
||||
@@ -104,6 +126,7 @@ public static class ApplicationStore
|
||||
a.AutoApprovable = autoApprovable;
|
||||
a.Reden = reject;
|
||||
if (documentIds is not null) a.DocumentIds = documentIds.ToList();
|
||||
db.SaveChanges();
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Domain.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// The letter (brief) — one demo brief per owner, created from a template on first
|
||||
/// read. In-memory, mirrors <see cref="ApplicationStore"/>. The status machine and
|
||||
/// its guards live here (the server is authoritative for transitions); the FE mirrors
|
||||
/// them in its pure reducer for UX. Rich-text content is stored opaquely as DTOs — the
|
||||
/// stub does not interpret it (no server-side placeholder linting in this slice).
|
||||
/// read. EF Core/SQLite-backed (WP-22 — was a static Dictionary), mirrors
|
||||
/// <see cref="ApplicationStore"/>. The status machine and its guards live here (the
|
||||
/// server is authoritative for transitions); the FE mirrors them in its pure reducer
|
||||
/// for UX. Rich-text content is stored opaquely as DTOs — the stub does not
|
||||
/// interpret it (no server-side placeholder linting in this slice).
|
||||
/// </summary>
|
||||
public sealed class BriefEntity
|
||||
{
|
||||
@@ -24,6 +26,8 @@ public sealed class BriefEntity
|
||||
public BriefDto ToDto() => new(BriefId, Beroep, TemplateId, Placeholders, Sections, Status, DrafterId);
|
||||
}
|
||||
|
||||
/// <summary>ponytail: one global lock, same as before this WP — SQLite tolerates
|
||||
/// only one writer at a time anyway, and this was already a single coarse gate.</summary>
|
||||
public static class BriefStore
|
||||
{
|
||||
// Dev-only role stand-ins (no real identities in this POC — see the ?role= toggle).
|
||||
@@ -32,16 +36,18 @@ public static class BriefStore
|
||||
|
||||
public enum Outcome { Ok, Forbidden, Conflict }
|
||||
|
||||
private static readonly Dictionary<string, BriefEntity> _byOwner = new();
|
||||
private static readonly object _gate = new();
|
||||
|
||||
public static BriefEntity GetOrCreate(string owner)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_byOwner.TryGetValue(owner, out var existing)) return existing;
|
||||
using var db = Db.Create();
|
||||
var existing = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||
if (existing is not null) return existing;
|
||||
var created = BriefSeed.NewBrief(owner);
|
||||
_byOwner[owner] = created;
|
||||
db.Briefs.Add(created);
|
||||
db.SaveChanges();
|
||||
return created;
|
||||
}
|
||||
}
|
||||
@@ -52,11 +58,14 @@ public static class BriefStore
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
||||
using var db = Db.Create();
|
||||
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||
if (e is null) return (Outcome.Conflict, null);
|
||||
if (!isDrafter) return (Outcome.Forbidden, null);
|
||||
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
|
||||
e.Sections = sections.ToList();
|
||||
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
|
||||
db.SaveChanges();
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
}
|
||||
@@ -65,10 +74,13 @@ public static class BriefStore
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
||||
using var db = Db.Create();
|
||||
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||
if (e is null) return (Outcome.Conflict, null);
|
||||
if (!isDrafter) return (Outcome.Forbidden, null);
|
||||
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
|
||||
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
|
||||
db.SaveChanges();
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
}
|
||||
@@ -85,9 +97,12 @@ public static class BriefStore
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
||||
using var db = Db.Create();
|
||||
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||
if (e is null) return (Outcome.Conflict, null);
|
||||
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
|
||||
e.Status = new BriefStatusDto("sent", SentAt: at);
|
||||
db.SaveChanges();
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
}
|
||||
@@ -95,7 +110,11 @@ public static class BriefStore
|
||||
/// Test seam: clear the demo brief between tests.
|
||||
public static void Reset()
|
||||
{
|
||||
lock (_gate) _byOwner.Clear();
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
db.Briefs.ExecuteDelete();
|
||||
}
|
||||
}
|
||||
|
||||
/// Demo affordance: drop the owner's brief and create a fresh one. No guards — a
|
||||
@@ -104,9 +123,11 @@ public static class BriefStore
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_byOwner.Remove(owner);
|
||||
using var db = Db.Create();
|
||||
db.Briefs.Where(e => e.Owner == owner).ExecuteDelete();
|
||||
var created = BriefSeed.NewBrief(owner);
|
||||
_byOwner[owner] = created;
|
||||
db.Briefs.Add(created);
|
||||
db.SaveChanges();
|
||||
return created;
|
||||
}
|
||||
}
|
||||
@@ -120,10 +141,13 @@ public static class BriefStore
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
||||
using var db = Db.Create();
|
||||
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||
if (e is null) return (Outcome.Conflict, null);
|
||||
if (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null);
|
||||
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
|
||||
e.Status = next();
|
||||
db.SaveChanges();
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
}
|
||||
|
||||
27
backend/src/BigRegister.Api/Data/Db.cs
Normal file
27
backend/src/BigRegister.Api/Data/Db.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for short-lived <see cref="AppDbContext"/> instances. The three stores
|
||||
/// (ApplicationStore/DocumentStore/BriefStore) are static classes — that shape
|
||||
/// predates WP-22 and this WP keeps it — so they can't take a constructor-injected
|
||||
/// DbContext; each store method opens one here, uses it, and disposes it under its
|
||||
/// own lock instead.
|
||||
/// </summary>
|
||||
public static class Db
|
||||
{
|
||||
public static string ConnectionString { get; set; } = "Data Source=bigregister.db";
|
||||
|
||||
public static AppDbContext Create() =>
|
||||
new(new DbContextOptionsBuilder<AppDbContext>().UseSqlite(ConnectionString).Options);
|
||||
}
|
||||
|
||||
/// <summary>Lets `dotnet ef migrations add` construct a context at design time
|
||||
/// without booting the full app (no DI registration exists for AppDbContext —
|
||||
/// see Db.Create above for why). Auto-discovered by EF Core's tooling.</summary>
|
||||
public sealed class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
|
||||
{
|
||||
public AppDbContext CreateDbContext(string[] args) => Db.Create();
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Stored document: metadata + bytes. The demo holds bytes IN-MEMORY (reset on
|
||||
/// restart) purely so re-opened wizards can preview/download what was uploaded — a
|
||||
/// Stored document: metadata + bytes. The demo persists bytes in the SQLite file
|
||||
/// (WP-22) purely so a re-opened wizard can preview/download what was uploaded — a
|
||||
/// real backend persists them to blob storage keyed by DocumentId. Bytes are never
|
||||
/// serialized into a JSON response; only the dedicated content endpoint streams them.
|
||||
/// </summary>
|
||||
@@ -13,33 +13,48 @@ public sealed record StoredDocument(
|
||||
public bool Linked { get; set; }
|
||||
}
|
||||
|
||||
public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor);
|
||||
/// <summary>Id is EF Core's auto-increment key — not part of the positional
|
||||
/// constructor, so every existing `new AuditEntry(at, action, ...)` call site
|
||||
/// keeps working unchanged; EF Core assigns it on insert.</summary>
|
||||
public sealed record AuditEntry(DateTimeOffset At, string Action, string DocumentId, string CategoryId, string Actor)
|
||||
{
|
||||
public long Id { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory document store + audit log (no DB). ponytail: one global lock — fine
|
||||
/// for a single-process demo store; swap for per-key locks if it ever serves load.
|
||||
/// The audit log holds metadata only (never file content or other PII).
|
||||
/// EF Core/SQLite-backed document store + audit log (WP-22 — was a static
|
||||
/// Dictionary). ponytail: one global lock, same as before — SQLite tolerates only
|
||||
/// one writer at a time anyway, and this process already serialized all access
|
||||
/// through a single gate, so it now doubles as a coarse single-writer guard for
|
||||
/// the DB file. The audit log holds metadata only (never file content or other PII).
|
||||
/// </summary>
|
||||
public static class DocumentStore
|
||||
{
|
||||
/// The single seeded user (the demo has no real auth; ownership = this id).
|
||||
public const string DemoOwner = "19012345601";
|
||||
|
||||
private static readonly Dictionary<string, StoredDocument> _docs = new();
|
||||
private static readonly List<AuditEntry> _audit = 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)
|
||||
{
|
||||
var doc = new StoredDocument(Guid.NewGuid().ToString(), localId, categoryId, wizardId, fileName, content.LongLength, contentType, content, owner, DateTimeOffset.UtcNow);
|
||||
lock (_gate) _docs[doc.DocumentId] = doc;
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
db.Documents.Add(doc);
|
||||
db.SaveChanges();
|
||||
}
|
||||
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;
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
return db.Documents.Find(documentId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Status for the poll-on-return pattern: a known localId is "complete" (it
|
||||
@@ -47,15 +62,26 @@ public static class DocumentStore
|
||||
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
|
||||
{
|
||||
var set = localIds.ToHashSet();
|
||||
lock (_gate) return _docs.Values.Where(d => set.Contains(d.LocalId)).ToList();
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
return db.Documents.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)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
foreach (var id in documentIds)
|
||||
if (_docs.TryGetValue(id, out var d)) d.Linked = true;
|
||||
{
|
||||
var d = db.Documents.Find(id);
|
||||
if (d is not null) d.Linked = true;
|
||||
}
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
public enum DeleteResult { Ok, NotFound, Linked }
|
||||
@@ -66,10 +92,13 @@ public static class DocumentStore
|
||||
string categoryId;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_docs.TryGetValue(documentId, out var d) || d.Owner != owner) return DeleteResult.NotFound;
|
||||
using var db = Db.Create();
|
||||
var d = db.Documents.Find(documentId);
|
||||
if (d is null || d.Owner != owner) return DeleteResult.NotFound;
|
||||
if (d.Linked) return DeleteResult.Linked;
|
||||
categoryId = d.CategoryId;
|
||||
_docs.Remove(documentId);
|
||||
db.Documents.Remove(d);
|
||||
db.SaveChanges();
|
||||
}
|
||||
Audit("delete-user", documentId, categoryId, owner);
|
||||
return DeleteResult.Ok;
|
||||
@@ -82,9 +111,12 @@ public static class DocumentStore
|
||||
string categoryId;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_docs.TryGetValue(documentId, out var d)) return false;
|
||||
using var db = Db.Create();
|
||||
var d = db.Documents.Find(documentId);
|
||||
if (d is null) return false;
|
||||
categoryId = d.CategoryId;
|
||||
_docs.Remove(documentId);
|
||||
db.Documents.Remove(d);
|
||||
db.SaveChanges();
|
||||
}
|
||||
Audit("delete-admin", documentId, categoryId, actor);
|
||||
return true;
|
||||
@@ -92,11 +124,23 @@ public static class DocumentStore
|
||||
|
||||
public static void Audit(string action, string documentId, string categoryId, string actor)
|
||||
{
|
||||
lock (_gate) _audit.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
db.AuditEntries.Add(new AuditEntry(DateTimeOffset.UtcNow, action, documentId, categoryId, actor));
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
public static IReadOnlyList<AuditEntry> AuditLog
|
||||
{
|
||||
get { lock (_gate) return _audit.ToList(); }
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
return db.AuditEntries.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
25
backend/src/BigRegister.Api/Data/IdempotencyStore.cs
Normal file
25
backend/src/BigRegister.Api/Data/IdempotencyStore.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory idempotency-key ledger for the submit endpoints (Program.cs's `Submit`
|
||||
/// helper): a replayed `Idempotency-Key` returns the exact result computed the first
|
||||
/// time, instead of re-running the submission and minting a new reference.
|
||||
/// ponytail: one global lock + no TTL/eviction — fine for a single-process demo;
|
||||
/// swap for a TTL'd cache before this ever serves real traffic (an unbounded
|
||||
/// dictionary keyed on client-supplied strings is a memory leak at scale).
|
||||
/// </summary>
|
||||
public static class IdempotencyStore
|
||||
{
|
||||
private static readonly Dictionary<string, IResult> _results = new();
|
||||
private static readonly object _gate = new();
|
||||
|
||||
public static bool TryGet(string key, out IResult? result)
|
||||
{
|
||||
lock (_gate) return _results.TryGetValue(key, out result);
|
||||
}
|
||||
|
||||
public static void Set(string key, IResult result)
|
||||
{
|
||||
lock (_gate) _results[key] = result;
|
||||
}
|
||||
}
|
||||
195
backend/src/BigRegister.Api/Data/Migrations/20260704190822_Initial.Designer.cs
generated
Normal file
195
backend/src/BigRegister.Api/Data/Migrations/20260704190822_Initial.Designer.cs
generated
Normal file
@@ -0,0 +1,195 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using BigRegister.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BigRegister.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260704190822_Initial")]
|
||||
partial class Initial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("AutoApprovable")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DocumentIds")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Draft")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Owner")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reden")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Referentie")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("StepCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("StepIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Submitted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("SubmittedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Applications");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Actor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("At")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CategoryId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DocumentId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AuditEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
|
||||
{
|
||||
b.Property<string>("BriefId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Beroep")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DrafterId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Owner")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Placeholders")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Sections")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TemplateId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BriefId");
|
||||
|
||||
b.HasIndex("Owner")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Briefs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
|
||||
{
|
||||
b.Property<string>("DocumentId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CategoryId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Linked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LocalId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Owner")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("UploadedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WizardId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("DocumentId");
|
||||
|
||||
b.ToTable("Documents");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BigRegister.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Initial : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Applications",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Type = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Owner = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Draft = table.Column<string>(type: "TEXT", nullable: true),
|
||||
StepIndex = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
StepCount = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
DocumentIds = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Referentie = table.Column<string>(type: "TEXT", nullable: true),
|
||||
AutoApprovable = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Reden = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Submitted = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
SubmittedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Applications", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AuditEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
At = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
Action = table.Column<string>(type: "TEXT", nullable: false),
|
||||
DocumentId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
CategoryId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Actor = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AuditEntries", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Briefs",
|
||||
columns: table => new
|
||||
{
|
||||
BriefId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Owner = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Beroep = table.Column<string>(type: "TEXT", nullable: false),
|
||||
TemplateId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
DrafterId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Placeholders = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Sections = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Status = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Briefs", x => x.BriefId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Documents",
|
||||
columns: table => new
|
||||
{
|
||||
DocumentId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
LocalId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
CategoryId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
WizardId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
FileName = table.Column<string>(type: "TEXT", nullable: false),
|
||||
SizeBytes = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
ContentType = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Content = table.Column<byte[]>(type: "BLOB", nullable: false),
|
||||
Owner = table.Column<string>(type: "TEXT", nullable: false),
|
||||
UploadedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
Linked = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Documents", x => x.DocumentId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Briefs_Owner",
|
||||
table: "Briefs",
|
||||
column: "Owner",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Applications");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AuditEntries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Briefs");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Documents");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using BigRegister.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace BigRegister.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.Aanvraag", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("AutoApprovable")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DocumentIds")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Draft")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Owner")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Reden")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Referentie")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("StepCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("StepIndex")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Submitted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("SubmittedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Applications");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.AuditEntry", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Action")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Actor")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("At")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CategoryId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DocumentId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AuditEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.BriefEntity", b =>
|
||||
{
|
||||
b.Property<string>("BriefId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Beroep")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DrafterId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Owner")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Placeholders")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Sections")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("TemplateId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BriefId");
|
||||
|
||||
b.HasIndex("Owner")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Briefs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BigRegister.Api.Data.StoredDocument", b =>
|
||||
{
|
||||
b.Property<string>("DocumentId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CategoryId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("ContentType")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Linked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("LocalId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Owner")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("UploadedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WizardId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("DocumentId");
|
||||
|
||||
b.ToTable("Documents");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ using BigRegister.Domain.Documents;
|
||||
using BigRegister.Domain.Intake;
|
||||
using BigRegister.Domain.Registrations;
|
||||
using BigRegister.Domain.Submissions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Console;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
@@ -20,13 +22,46 @@ builder.Services.ConfigureHttpJsonOptions(o =>
|
||||
o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
|
||||
o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
|
||||
});
|
||||
// So the correlation-id scope pushed by the middleware below actually shows up in
|
||||
// the console, not just in memory for a formatter that never renders it.
|
||||
builder.Logging.AddSimpleConsole(o => o.IncludeScopes = true);
|
||||
|
||||
const string SpaCors = "spa";
|
||||
builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
|
||||
p.WithOrigins("http://localhost:4200").AllowAnyHeader().AllowAnyMethod()));
|
||||
|
||||
// WP-22: the three stores (Applications/Documents/Briefs — Data/*.cs) are static
|
||||
// classes that open their own short-lived AppDbContext per call (see Db.Create),
|
||||
// not DI-injected, so there's no builder.Services.AddDbContext here. Configuring
|
||||
// the connection string still goes through IConfiguration so tests/deployments can
|
||||
// override it (ConnectionStrings:AppDb) without touching this file.
|
||||
Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.ConnectionString;
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Migrate on every startup, seed nothing (WP-22): unlike SeedData's read-only
|
||||
// reference fixtures (registration/diplomas/notes — untouched by this WP, still
|
||||
// static in-memory), Applications/Documents/Briefs never had seed data — they
|
||||
// started empty and accumulated through normal use before this WP too. A fresh
|
||||
// SQLite file just starts empty again, same as the old in-memory dictionaries did.
|
||||
using (var db = Db.Create())
|
||||
db.Database.Migrate();
|
||||
|
||||
// Every request gets a correlation id (client-supplied X-Correlation-Id if present,
|
||||
// else generated), pushed into the logging scope for every log line the request
|
||||
// produces (not just the Submit helper's) and echoed back as a response header for
|
||||
// support/debugging correlation. Runs first so nothing downstream logs without it.
|
||||
app.Use(async (ctx, next) =>
|
||||
{
|
||||
var cid = ctx.Request.Headers.TryGetValue("X-Correlation-Id", out var v) && !string.IsNullOrEmpty(v)
|
||||
? v.ToString()
|
||||
: Guid.NewGuid().ToString();
|
||||
ctx.Items["CorrelationId"] = cid;
|
||||
ctx.Response.Headers["X-Correlation-Id"] = cid;
|
||||
using (app.Logger.BeginScope("CorrelationId:{CorrelationId}", cid))
|
||||
await next(ctx);
|
||||
});
|
||||
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
app.UseCors(SpaCors);
|
||||
@@ -345,19 +380,30 @@ void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r
|
||||
|
||||
// Audit + outcome for a submit, with NO personal data: only kind, outcome,
|
||||
// generated reference and the caller's correlation id (the observability seam — a
|
||||
// real system ships this to structured logging / an audit store).
|
||||
// real system ships this to structured logging / an audit store). A repeated
|
||||
// Idempotency-Key short-circuits to the first call's result — see IdempotencyStore
|
||||
// — so a retried submit dedupes instead of minting a second reference.
|
||||
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)
|
||||
? v.ToString()
|
||||
: "none";
|
||||
var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none";
|
||||
var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k)
|
||||
? k.ToString()
|
||||
: null;
|
||||
|
||||
if (idemKey is not null && IdempotencyStore.TryGet(idemKey, out var cached))
|
||||
{
|
||||
app.Logger.LogInformation("submit kind={Kind} outcome=replayed correlationId={Cid}", kind, cid);
|
||||
return cached!;
|
||||
}
|
||||
|
||||
IResult result;
|
||||
if (reject is not null)
|
||||
{
|
||||
app.Logger.LogInformation("submit kind={Kind} outcome=rejected correlationId={Cid}", kind, cid);
|
||||
return Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
|
||||
result = Results.Problem(detail: reject, statusCode: StatusCodes.Status422UnprocessableEntity);
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
if (documents is not null)
|
||||
{
|
||||
// Link digital documents (blocks later user delete) and record post-delivery
|
||||
@@ -371,7 +417,11 @@ IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<Docum
|
||||
app.Logger.LogInformation(
|
||||
"submit kind={Kind} outcome=accepted reference={Reference} correlationId={Cid} at={At:o}",
|
||||
kind, reference, cid, DateTimeOffset.UtcNow);
|
||||
return Results.Ok(new ReferentieResponse(reference));
|
||||
result = Results.Ok(new ReferentieResponse(reference));
|
||||
}
|
||||
|
||||
if (idemKey is not null) IdempotencyStore.Set(idemKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Exposed so the integration tests can spin up the app with WebApplicationFactory.
|
||||
|
||||
@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
public class ApplicationTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||
public class ApplicationTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace BigRegister.Tests;
|
||||
/// (tests within a class run sequentially in xUnit). Role is the dev-only X-Role
|
||||
/// header: absent = drafter, "approver" = a different identity.
|
||||
/// </summary>
|
||||
public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||
public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
|
||||
public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
@@ -125,6 +125,22 @@ public class EndpointTests(WebApplicationFactory<Program> factory) : IClassFixtu
|
||||
res.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Correlation_id_supplied_by_the_caller_is_echoed_back()
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, "/api/v1/notes");
|
||||
req.Headers.Add("X-Correlation-Id", "test-cid-123");
|
||||
var res = await _client.SendAsync(req);
|
||||
Assert.Equal("test-cid-123", res.Headers.GetValues("X-Correlation-Id").Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Correlation_id_is_generated_when_the_caller_omits_it()
|
||||
{
|
||||
var res = await _client.GetAsync("/api/v1/notes");
|
||||
Assert.NotEmpty(res.Headers.GetValues("X-Correlation-Id").Single());
|
||||
}
|
||||
|
||||
// --- Document upload ---
|
||||
|
||||
private static MultipartFormDataContent UploadForm(string localId, string categoryId, string wizardId, string fileName, string contentType)
|
||||
|
||||
69
backend/tests/BigRegister.Tests/IdempotencyTests.cs
Normal file
69
backend/tests/BigRegister.Tests/IdempotencyTests.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using System.Net.Http.Json;
|
||||
using BigRegister.Api.Contracts;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
public class IdempotencyTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client = factory.CreateClient();
|
||||
|
||||
private static HttpRequestMessage ChangeRequestWithKey(string key)
|
||||
{
|
||||
var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/change-requests")
|
||||
{
|
||||
Content = JsonContent.Create(new { straat = "Lange Voorhout 9", postcode = "2514 EA", woonplaats = "Den Haag" }),
|
||||
};
|
||||
req.Headers.Add("Idempotency-Key", key);
|
||||
return req;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Replaying_the_same_idempotency_key_returns_the_same_reference_not_a_new_one()
|
||||
{
|
||||
var key = Guid.NewGuid().ToString();
|
||||
|
||||
var first = await _client.SendAsync(ChangeRequestWithKey(key));
|
||||
first.EnsureSuccessStatusCode();
|
||||
var firstBody = await first.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||
|
||||
var replay = await _client.SendAsync(ChangeRequestWithKey(key));
|
||||
replay.EnsureSuccessStatusCode();
|
||||
var replayBody = await replay.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||
|
||||
Assert.Equal(firstBody!.Referentie, replayBody!.Referentie);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Different_idempotency_keys_are_independent_submissions()
|
||||
{
|
||||
var first = await _client.SendAsync(ChangeRequestWithKey(Guid.NewGuid().ToString()));
|
||||
var second = await _client.SendAsync(ChangeRequestWithKey(Guid.NewGuid().ToString()));
|
||||
var firstBody = await first.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||
var secondBody = await second.Content.ReadFromJsonAsync<ReferentieResponse>();
|
||||
|
||||
Assert.NotEqual(firstBody!.Referentie, secondBody!.Referentie);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_rejected_submission_replays_the_same_rejection_not_a_retry()
|
||||
{
|
||||
var key = Guid.NewGuid().ToString();
|
||||
var badRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/change-requests")
|
||||
{
|
||||
Content = JsonContent.Create(new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" }),
|
||||
};
|
||||
badRequest.Headers.Add("Idempotency-Key", key);
|
||||
|
||||
var first = await _client.SendAsync(badRequest);
|
||||
Assert.Equal(System.Net.HttpStatusCode.UnprocessableEntity, first.StatusCode);
|
||||
|
||||
var replayRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/change-requests")
|
||||
{
|
||||
Content = JsonContent.Create(new { straat = "Straat 1", postcode = "nope", woonplaats = "Den Haag" }),
|
||||
};
|
||||
replayRequest.Headers.Add("Idempotency-Key", key);
|
||||
var replay = await _client.SendAsync(replayRequest);
|
||||
Assert.Equal(System.Net.HttpStatusCode.UnprocessableEntity, replay.StatusCode);
|
||||
}
|
||||
}
|
||||
41
backend/tests/BigRegister.Tests/TestWebApplicationFactory.cs
Normal file
41
backend/tests/BigRegister.Tests/TestWebApplicationFactory.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
// WP-22's stores read a single static Db.ConnectionString (there's no DI, matching
|
||||
// their pre-WP-22 static-Dictionary shape — see Data/Db.cs). That's correct for a
|
||||
// real single-instance process, but xUnit's default parallel-across-classes
|
||||
// execution would run multiple WebApplicationFactory hosts concurrently in this
|
||||
// ONE test process, each overwriting that same static field with its own temp-file
|
||||
// path — a real race (caught as "table already exists" from two Migrate() calls
|
||||
// interleaving on whichever file won the race), not a hypothetical one. Serializing
|
||||
// test classes is the fix, not a redesign of the stores for a test-only concern.
|
||||
[assembly: CollectionBehavior(DisableTestParallelization = true)]
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// WP-22 moved Applications/Documents/Briefs off in-memory dictionaries onto a real
|
||||
/// SQLite file (see Data/Db.cs). Unlike static dictionaries, a shared file path
|
||||
/// would let concurrent test classes' WebApplicationFactory instances hit the same
|
||||
/// file at once — xUnit runs different test classes in parallel by default, and
|
||||
/// SQLite tolerates only one writer at a time, so that's a real "database is
|
||||
/// locked" flake risk, not a hypothetical one. Every test class below points at its
|
||||
/// own throwaway file instead, deleted when the factory (and its class's tests) are
|
||||
/// done — the same one-store-per-class isolation the old dictionaries gave for free.
|
||||
/// </summary>
|
||||
public sealed class TestWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-test-{Guid.NewGuid():N}.db");
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
|
||||
builder.UseSetting("ConnectionStrings:AppDb", $"Data Source={_dbPath}");
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
if (!disposing) return;
|
||||
File.Delete(_dbPath);
|
||||
File.Delete(_dbPath + "-shm"); // ponytail: best-effort — WAL sidecar files if SQLite created any.
|
||||
File.Delete(_dbPath + "-wal");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@ services:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
volumes:
|
||||
# ':z' relabels for SELinux (Fedora/RHEL); harmless on other hosts.
|
||||
# WP-22: no separate volume needed for the SQLite file — `dotnet run` sets
|
||||
# its cwd to the project dir (src/BigRegister.Api), which this bind mount
|
||||
# already covers, so bigregister.db lands on the host and survives
|
||||
# `docker compose restart api` / `down` + `up` for free (gitignored).
|
||||
- ./backend:/src:z
|
||||
- api-bin:/src/src/BigRegister.Api/bin
|
||||
- api-obj:/src/src/BigRegister.Api/obj
|
||||
|
||||
@@ -33,6 +33,10 @@ npm run test-storybook:ci
|
||||
Backend stays untouched throughout (frontend-only backlog); `cd backend && dotnet test`
|
||||
only needs re-running if a WP unexpectedly touches `backend/`.
|
||||
|
||||
From WP-19 onward, `npm run e2e` is part of CI (its own job) but NOT part of the local
|
||||
GREEN one-liner above — it needs the real backend + `npm start` already running (see
|
||||
WP-19's own file), so it's a separate manual/CI step, not chained into the others.
|
||||
|
||||
## Order
|
||||
|
||||
Gates land before the work they cover; each lint rule lands in the same WP as the fixes
|
||||
@@ -53,12 +57,12 @@ for its existing violations, so every WP ends green.
|
||||
| [WP-11](WP-11-markup-fidelity.md) | CIBG markup fidelity: application-link + absent-class triage | 2 · CIBG | done |
|
||||
| [WP-12](WP-12-datablock.md) | CIBG Datablock for application data | 2 · CIBG | done |
|
||||
| [WP-13](WP-13-cibg-gap-register.md) | CIBG-gap register + hygiene + MDX | 2 · CIBG | done |
|
||||
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | todo |
|
||||
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | todo |
|
||||
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | todo |
|
||||
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | todo |
|
||||
| [WP-14](WP-14-storybook-taxonomy.md) | Storybook taxonomy reorg + Layers MDX | 3 · Storybook | done |
|
||||
| [WP-15](WP-15-missing-stories.md) | Missing stories: shell + brief components | 3 · Storybook | done |
|
||||
| [WP-16](WP-16-component-a11y.md) | Component a11y: description wiring + alert role | 4 · a11y | done |
|
||||
| [WP-17](WP-17-app-a11y.md) | App-level a11y: route focus, template lint, WCAG checklist | 4 · a11y | done |
|
||||
| [WP-18](WP-18-abac-capability-spine.md) | ABAC capability spine (Principal + capabilities, phase P1) | 5 · productie-volwassenheid | done |
|
||||
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | todo |
|
||||
| [WP-19](WP-19-e2e-smoke.md) | Playwright e2e smoke | 5 · productie-volwassenheid | done |
|
||||
| [WP-20](WP-20-second-locale.md) | Second locale proof | 5 · productie-volwassenheid | todo |
|
||||
| [WP-21](WP-21-resilience-seams.md) | Resilience seams (correlation-id, idempotency, retry) | 5 · productie-volwassenheid | todo |
|
||||
| [WP-22](WP-22-durable-persistence.md) | Durable persistence (optional tier) | 5 · productie-volwassenheid | todo |
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: done (69880ef)
|
||||
Phase: 2 — CIBG fidelity
|
||||
|
||||
> **Deviation:** file-input's label-button was already reworked to `.btn-primary
|
||||
> .btn-upload` by the earlier out-of-order "CIBG UI fidelity pass" (WP-11/12) — the
|
||||
.btn-upload` by the earlier out-of-order "CIBG UI fidelity pass" (WP-11/12) — the
|
||||
> vendored upload vocabulary (`.btn-upload`) supersedes this WP's original
|
||||
> `.btn-secondary` assumption, so no change was needed there. Icon affordances
|
||||
> (chevron/pijl classes) are verified present in the vendored CSS, but no in-scope
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
# WP-14 — Storybook taxonomy reorg + Layers MDX
|
||||
|
||||
Status: todo
|
||||
Status: done (8b19fad)
|
||||
Phase: 3 — Storybook as curriculum
|
||||
|
||||
> **Deviation:** the "Layout/" bucket (breadcrumb, site-footer, site-header) wasn't in the
|
||||
> Decisions block's explicit scheme, so each got folded into Atoms/Molecules/Organisms by
|
||||
> its own doc-comment classification (breadcrumb → Molecules, site-footer/site-header →
|
||||
> Organisms, both already documented as such in their component header comments) rather
|
||||
> than kept as a separate bucket. Fixing `atomic-design.mdx`'s "status banner" reference
|
||||
> (stale since WP-13 deleted `upload-status-banner`) was caught as a side effect of
|
||||
> reviewing every MDX page for broken references — not itself a retitle issue, but the
|
||||
> same "unbroken MDX" acceptance criterion covers it.
|
||||
|
||||
## Why
|
||||
|
||||
49 stories sit in a flat `Atoms/Molecules/Organisms/Templates/Layout` scheme with domain
|
||||
@@ -58,11 +67,11 @@ Domein/
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Sidebar shows exactly Foundations → Design System → Domein with the sub-order
|
||||
- [x] Sidebar shows exactly Foundations → Design System → Domein with the sub-order
|
||||
pinned.
|
||||
- [ ] Zero story titles outside the scheme (grep `title:` and eyeball).
|
||||
- [ ] `layers.mdx` renders; existing MDX pages unbroken.
|
||||
- [ ] Convention in CLAUDE.md.
|
||||
- [x] Zero story titles outside the scheme (grep `title:` and eyeball).
|
||||
- [x] `layers.mdx` renders; existing MDX pages unbroken.
|
||||
- [x] Convention in CLAUDE.md.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
# WP-15 — Missing stories: shell + brief components
|
||||
|
||||
Status: todo
|
||||
Status: done (0cfb01f)
|
||||
Phase: 3 — Storybook as curriculum
|
||||
Depends on: WP-14 (titles), WP-01 (axe gate covers the new stories automatically)
|
||||
|
||||
> **Note:** fixture duplication across the four brief stories that need `Brief`/
|
||||
> `LetterSection`/`LetterBlock` shapes (letter-block, letter-preview, letter-section, plus
|
||||
> the pre-existing letter-composer) didn't bite enough to justify the shared-fixtures
|
||||
> escape hatch — each story only builds the minimal slice it actually renders (letter-block
|
||||
> needs one block, not a whole `Brief`), so the co-located fixtures stayed small and
|
||||
> non-duplicative in practice. A pre-existing, unrelated axe finding on
|
||||
> `text-input--invalid` (informational only — `test-storybook:ci` doesn't fail on it) shows
|
||||
> up in the run; it predates this WP and isn't caused by anything here.
|
||||
|
||||
## Why
|
||||
|
||||
"UI is exercised via Storybook stories" (CLAUDE.md §5) — but 7 components have none:
|
||||
@@ -44,11 +53,12 @@ invisible to the axe gate.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Every component in `src/app` has ≥1 story (verify: list components without a
|
||||
- [x] Every component in `src/app` has ≥1 story (verify: list components without a
|
||||
co-located `*.stories.ts`; expect zero, pages excepted if that's the existing
|
||||
norm — note the norm in this file when checked).
|
||||
- [ ] All new stories pass the axe gate (or carry a justified skip).
|
||||
- [ ] Titles follow WP-14.
|
||||
norm — note the norm in this file when checked). **Confirmed norm:** `*.page.ts`
|
||||
files (9 of them) have never had stories; every `*.component.ts` now does.
|
||||
- [x] All new stories pass the axe gate (or carry a justified skip).
|
||||
- [x] Titles follow WP-14.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WP-16 — Component a11y: description wiring + alert role
|
||||
|
||||
Status: todo
|
||||
Status: done (pending commit)
|
||||
Phase: 4 — a11y
|
||||
|
||||
## Why
|
||||
@@ -57,12 +57,25 @@ Audit findings axe can't (fully) catch:
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Description text is programmatically associated in the canonical composition;
|
||||
- [x] Description text is programmatically associated in the canonical composition;
|
||||
login-form BSN hint announced.
|
||||
- [ ] `-error` id appended exactly when invalid; order stable.
|
||||
- [ ] Error alerts are `role="alert"`; play tests assert both behaviors and run in the
|
||||
- [x] `-error` id appended exactly when invalid; order stable.
|
||||
- [x] Error alerts are `role="alert"`; play tests assert both behaviors and run in the
|
||||
CI gate.
|
||||
|
||||
## Deviation from the original plan
|
||||
|
||||
`radio-group`/`checkbox` were left unchanged — grepping every call site found zero
|
||||
consumers pairing either with a `form-field` `description` (only the login-form BSN
|
||||
field, which uses `text-input`). The WP's own Files note ("describedby joins; only
|
||||
where the component takes a hint") already carved out this exact case — adding
|
||||
`hasDescription` to atoms with no live description consumer would be unused surface,
|
||||
not a fix. `radio-group` already had correct `-error`-only wiring; untouched.
|
||||
`describedBy()` was added directly on `text-input` rather than factored into a shared
|
||||
`shared/kernel` helper — one consumer, ~5 lines, not worth the indirection yet.
|
||||
Manual screen-reader spot check (optional per the WP) skipped; the play test is the
|
||||
enforced check going forward.
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `npm run test-storybook:ci` (includes the new play tests).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WP-17 — App-level a11y: route focus/scroll, template lint, WCAG checklist + MDX
|
||||
|
||||
Status: todo
|
||||
Status: done (pending commit)
|
||||
Phase: 4 — a11y
|
||||
|
||||
## Why
|
||||
@@ -59,11 +59,30 @@ Three app-level gaps close the WCAG story:
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Navigating between routes moves focus to the new page's heading; scroll resets;
|
||||
- [x] Navigating between routes moves focus to the new page's heading; scroll resets;
|
||||
view transitions still play.
|
||||
- [ ] Template a11y rules active and _proven_ to fire; lint green.
|
||||
- [ ] Checklist checked in with an initial pass filled in for the dashboard at minimum.
|
||||
- [ ] `a11y.mdx` renders; links to checklist and WP-01 skip rules.
|
||||
- [x] Template a11y rules active and _proven_ to fire; lint green.
|
||||
- [x] Checklist checked in with an initial pass filled in for the dashboard at minimum.
|
||||
- [x] `a11y.mdx` renders; links to checklist and WP-01 skip rules.
|
||||
|
||||
## Deviation from the original plan
|
||||
|
||||
- Used the official `angular.configs.templateAccessibility` bundle (11 rules) instead of
|
||||
hand-listing the 6 named in this WP's Decisions — it's a strict superset (includes
|
||||
`no-autofocus`, `no-distracting-elements`, `mouse-events-have-key-events`,
|
||||
`role-has-required-aria`, `table-scope` on top of the 6 named), maintained upstream,
|
||||
and is exactly what `@angular-eslint/schematics`' own generated config uses for this
|
||||
setup. Less code to hand-maintain, same coverage plus more.
|
||||
- The dashboard's checklist pass surfaced a **real bug**: `aanvraag-block`'s warning
|
||||
`app-alert` (two `app-button` actions) overflows the viewport at 320px — its
|
||||
`.feedback` flex row doesn't wrap. Documented in `docs/wcag-checklist.md` with the
|
||||
root cause, **not fixed** — fixing live component CSS found via the checklist is the
|
||||
"full manual audit" scope this WP's Out-of-scope section explicitly defers, not this
|
||||
WP's own deliverable. Flagged here so it isn't lost.
|
||||
- "Screen reader" column left unfilled for every page — the pass available in this
|
||||
environment was a headless-browser keyboard/DOM/computed-style check, not an actual
|
||||
NVDA/VoiceOver run. The checklist says so explicitly rather than implying more
|
||||
coverage than was done.
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WP-19 — Playwright e2e smoke
|
||||
|
||||
Status: todo
|
||||
Status: done (pending commit)
|
||||
Phase: 5 — productie-volwassenheid
|
||||
|
||||
## Why
|
||||
@@ -70,14 +70,46 @@ serve` backgrounded), wait-on both ports, `npm run e2e`. `timeout-minutes: 15`
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `npm run e2e` passes locally against `docker compose up` or `npm start` +
|
||||
- [x] `npm run e2e` passes locally against `docker compose up` or `npm start` +
|
||||
`dotnet run` run manually.
|
||||
- [ ] CI job `e2e` is green and runs on every PR alongside the existing jobs.
|
||||
- [ ] The happy-path spec exercises a real wizard submit against the real backend
|
||||
- [x] CI job `e2e` is green and runs on every PR alongside the existing jobs.
|
||||
- [x] The happy-path spec exercises a real wizard submit against the real backend
|
||||
(not mocked) and asserts on the resulting UI state.
|
||||
- [ ] The error-path spec exercises `<app-async>`'s error slot + retry via the real
|
||||
- [x] The error-path spec exercises `<app-async>`'s error slot + retry via the real
|
||||
`?scenario=error` toggle, not a mocked HTTP response.
|
||||
|
||||
## Deviation from the original plan
|
||||
|
||||
- **Found and fixed a real bug while writing the error-path spec**: `AsyncComponent`'s
|
||||
built-in `retry()` only calls `.reload()` on a `[resource]` input — every real page
|
||||
(`dashboard`, `registration-detail`, `aanvraag-detail`, `brief`) feeds `<app-async>`
|
||||
via `[data]` (a store's combined `RemoteData`), so clicking "Opnieuw proberen" was a
|
||||
silent no-op everywhere except the showcase teaching page. Added a `retryClicked`
|
||||
output that fires regardless of feed mode, and wired the two dashboard instances
|
||||
(`BigProfileStore.reloadProfile()`/`reloadAantekeningen()`) since that's what this
|
||||
WP's spec exercises. **Not fixed**: `registration-detail`, `aanvraag-detail`, and
|
||||
`brief` pages still have the same latent no-op retry — same "found via testing,
|
||||
fixing the whole surface is beyond this WP" call as WP-17's dashboard CSS finding.
|
||||
Flagging here so it isn't lost.
|
||||
- Confirmed `currentScenario()` reads `window.location.search` fresh on every call —
|
||||
the error-path spec's retry assertion had to change from "counts a browser network
|
||||
request" (the scenario interceptor never reaches the real transport; it substitutes
|
||||
`throwError` in the rxjs pipe before `next(req)`) to "observes a real Loading→Failure
|
||||
reload cycle via `aria-busy`". The Steps section's literal suggestion ("assert it
|
||||
re-fetches... via a network tab") didn't hold; adapted per the WP's own Risks note
|
||||
to verify actual interceptor behavior first.
|
||||
- Verified the suite isn't a no-op per the Verification section: temporarily broke
|
||||
`diplomaOptions`' `value: d.id` (appended `-x`), watched `smoke.spec.ts` fail on the
|
||||
now-missing `#diploma-d1` selector, reverted.
|
||||
- The registratie wizard's minimum path needed an actual file upload (`identiteit` is
|
||||
always required for `registratie` regardless of diploma choice, per
|
||||
`DocumentRules.CategoriesFor` — only `diploma`/`taalvaardigheid` are answer-gated).
|
||||
Picked the first DUO diploma (non-English, `Engelstalig: false`) specifically because
|
||||
it carries zero policy questions, keeping the happy path to one upload.
|
||||
- CIBG-styled radios hide the native `<input>` behind its `<label>` — Playwright's
|
||||
`.check()` on the input times out fighting the label for pointer events; the specs
|
||||
click the `label[for=...]` instead (also more representative of a real click).
|
||||
|
||||
## Verification
|
||||
|
||||
`npm run e2e` locally; then push a branch and confirm the new `e2e` CI job appears
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WP-20 — Second locale proof
|
||||
|
||||
Status: todo
|
||||
Status: done (pending commit)
|
||||
Phase: 5 — productie-volwassenheid
|
||||
|
||||
## Why
|
||||
@@ -73,12 +73,14 @@ seam is built into every component but never proven to actually work end to end.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `ng extract-i18n` runs clean (no missing/duplicate `@@id`s).
|
||||
- [ ] `messages.en.xlf` exists with a translation for every extracted unit.
|
||||
- [ ] `ng build --localize` (or equivalent) produces both an `nl` and an `en` output
|
||||
- [x] `ng extract-i18n` runs clean (no missing/duplicate `@@id`s).
|
||||
- [x] `messages.en.xlf` exists with a translation for every extracted unit.
|
||||
- [x] `ng build --localize` (or equivalent) produces both an `nl` and an `en` output
|
||||
bundle in CI, and CI fails if the `en` file is missing a unit the source gains.
|
||||
- [ ] Manually verified: the `en` build actually shows English strings in a browser,
|
||||
not just "the build succeeded."
|
||||
- [x] Manually verified: the `en` build actually shows English strings in a browser,
|
||||
not just "the build succeeded." (`login.submit`: `nl` bundle ships "Inloggen
|
||||
met DigiD", `en` bundle ships "Log in with DigiD" — checked in the built JS,
|
||||
not just that the build succeeded.)
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WP-21 — Resilience seams (correlation-id, idempotency, retry)
|
||||
|
||||
Status: todo
|
||||
Status: done (pending commit)
|
||||
Phase: 5 — productie-volwassenheid
|
||||
|
||||
## Why
|
||||
@@ -98,22 +98,41 @@ delay })` in `httpClientFetch`'s pipe, per the existing header comment's own
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Every backend log line for a given request shares one correlation id (not
|
||||
- [x] Every backend log line for a given request shares one correlation id (not
|
||||
just lines inside `Submit`); the id is echoed in the response headers.
|
||||
- [ ] Replaying a submit with the same `Idempotency-Key` returns the same result
|
||||
without minting a second reference (backend test proves this).
|
||||
- [ ] A logical wizard submit generates exactly one `Idempotency-Key`, reused across
|
||||
Verified manually: `LogBrief`'s log line — which never interpolates a `Cid`
|
||||
itself — now prints `=> CorrelationId:scope-check-5` from the middleware's
|
||||
`BeginScope`, and `EndpointTests.Correlation_id_supplied_by_the_caller_is_echoed_back`
|
||||
/ `..._is_generated_when_the_caller_omits_it` cover the response header.
|
||||
- [x] Replaying a submit with the same `Idempotency-Key` returns the same result
|
||||
without minting a second reference (backend test proves this —
|
||||
`IdempotencyTests`, 3 cases: same key twice, different keys, a replayed
|
||||
rejection).
|
||||
- [x] A logical wizard submit generates exactly one `Idempotency-Key`, reused across
|
||||
any FE-side retry of that submit (not regenerated per HTTP attempt).
|
||||
- [ ] GET requests retry on transient failure (e.g. simulated via `?scenario=slow`
|
||||
or a forced 5xx); POST/PUT/DELETE never auto-retry.
|
||||
`runSubmit` mints it once and threads it via `withIdempotencyKey`; covered by
|
||||
`api-client.provider.spec.ts`.
|
||||
- [x] GET requests retry on transient failure (e.g. simulated via `?scenario=slow`
|
||||
or a forced 5xx); POST/PUT/DELETE never auto-retry. Covered by
|
||||
`api-client.provider.spec.ts` (3 retries on GET, 1 attempt on POST).
|
||||
|
||||
## Verification
|
||||
|
||||
GREEN + `cd backend && dotnet test`. Manual: `?scenario=error` on a GET-backed page,
|
||||
confirm a retry attempt happens (network tab shows 2 requests) before the error
|
||||
state renders; submit a wizard twice with a manually replayed idempotency key (e.g.
|
||||
via curl against the backend directly) and confirm the second call doesn't create a
|
||||
duplicate application.
|
||||
GREEN + `cd backend && dotnet test` (84 passing) — done. Manual: confirmed via curl
|
||||
against a locally running backend that `X-Correlation-Id` is echoed (client-supplied
|
||||
and server-generated) and that replaying `/api/v1/change-requests` with the same
|
||||
`Idempotency-Key` returns the identical `referentie` while a different key mints a
|
||||
new one; tailed the console log to confirm the correlation id shows up on a brief
|
||||
endpoint's log line that never explicitly threads it.
|
||||
|
||||
**Deviation**: the `?scenario=error` network-tab check this section originally
|
||||
proposed doesn't actually work — `scenario.interceptor.ts`'s `error` case
|
||||
`throwError`s before ever calling `next(req)`, so no real request reaches the
|
||||
network stack and devtools shows nothing to retry. Automated tests
|
||||
(`api-client.provider.spec.ts`, using a fake `HttpClient`-shaped `.request()` so no
|
||||
TestBed/HttpClientTestingModule is needed) are the actual proof of the retry
|
||||
mechanism instead — a more reliable check than a manual browser pass would have
|
||||
been anyway.
|
||||
|
||||
## Out of scope
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# WP-22 — Durable persistence (optional tier)
|
||||
|
||||
Status: todo
|
||||
Status: done (pending commit)
|
||||
Phase: 5 — productie-volwassenheid
|
||||
|
||||
## Why
|
||||
@@ -98,22 +98,30 @@ speculatively.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] All three stores are EF Core/SQLite-backed; no `static Dictionary` remains in
|
||||
- [x] All three stores are EF Core/SQLite-backed; no `static Dictionary` remains in
|
||||
`Data/*.cs` for application/document/brief state.
|
||||
- [ ] Every existing backend test passes unchanged (signatures didn't change).
|
||||
- [ ] Restarting the backend process preserves previously created applications,
|
||||
- [x] Every existing backend test passes unchanged (signatures didn't change).
|
||||
84/84 green, stable across repeated runs (see Deviations for a real race this
|
||||
surfaced).
|
||||
- [x] Restarting the backend process preserves previously created applications,
|
||||
documents, and brief drafts (manually verified).
|
||||
- [ ] The audit log survives a restart and is queryable (even if no new endpoint
|
||||
exposes it yet — persistence is the bar, not a new audit UI).
|
||||
- [ ] `docker compose up` with the new volume also survives a container restart.
|
||||
- [x] The audit log survives a restart and is queryable (even if no new endpoint
|
||||
exposes it yet — persistence is the bar, not a new audit UI). `AuditEntries`
|
||||
is a real table now; not separately re-verified across restart beyond the
|
||||
applications/brief checks (same store mechanism, same `Db.Create()` seam).
|
||||
- [x] `docker compose up` with a container restart preserves data — **no new
|
||||
volume** turned out to be needed (see Deviations).
|
||||
|
||||
## Verification
|
||||
|
||||
`cd backend && dotnet test`. Manual: `dotnet run --project src/BigRegister.Api`,
|
||||
create an application via the FE or a curl request, kill and restart the process,
|
||||
confirm `GET /api/v1/applications` still returns it. Repeat with `docker compose up`
|
||||
|
||||
- `docker compose restart api`.
|
||||
`cd backend && dotnet test` — 84/84 green. Manual: `dotnet run --project
|
||||
src/BigRegister.Api`, created an application via curl, killed and restarted the
|
||||
process, confirmed `GET /api/v1/applications` still returned it (repeated for the
|
||||
brief). Repeated the same check against the **real** `docker compose up` stack
|
||||
(this environment has an actual podman-backed compose, not a mock) — created an
|
||||
application via `curl localhost:5000`, ran `docker compose restart api`, confirmed
|
||||
it survived, and confirmed on the host that `backend/src/BigRegister.Api/bigregister.db`
|
||||
is the file being written (gitignored, not tracked).
|
||||
|
||||
## Out of scope
|
||||
|
||||
@@ -131,3 +139,47 @@ synchronously; converting to `async`/`await` may ripple further than "just the
|
||||
Data/ layer" if minimal-API handlers aren't already `async`. Check this before
|
||||
starting and budget for handler signature changes (still not a _behavior_ change,
|
||||
but a wider diff than the Files section implies if handlers need `async` added).
|
||||
|
||||
**Resolved**: didn't ripple at all. EF Core's SQLite provider fully supports
|
||||
synchronous APIs (`.Find()`, `.ToList()`, `.SaveChanges()`, `.ExecuteDelete()`); every
|
||||
store method stayed synchronous, so `Program.cs`'s minimal-API handlers needed zero
|
||||
changes. The stores stayed **static classes** with no DI — each method opens its
|
||||
own short-lived `AppDbContext` via a small `Db.Create()` factory (`Data/Db.cs`) under
|
||||
the same `lock (_gate)` each store already had, which now doubles as a single-writer
|
||||
guard for the SQLite file (SQLite tolerates only one writer at a time anyway).
|
||||
|
||||
## Deviations from the plan
|
||||
|
||||
- **No SeedData → DB seed step.** The WP's own "Decisions"/"Files" sections assumed
|
||||
`SeedData` populates the three stores and needs a "seed if empty" migration. It
|
||||
doesn't — `SeedData` only backs the read-only BRP/DUO-mimicking GET endpoints
|
||||
(registration, person, diplomas, notes), which stay in-memory and are untouched by
|
||||
this WP. Applications/Documents/Briefs never had seed data; they started empty
|
||||
before this WP and still do. One less step than planned.
|
||||
- **No new docker-compose volume.** The existing `./backend:/src` bind mount already
|
||||
covers `bigregister.db` (it's written under `src/BigRegister.Api/`, itself inside
|
||||
the bind-mounted tree — confirmed empirically, not just by reading the compose
|
||||
file), so a container restart already persists it for free. Added a comment
|
||||
instead of a redundant `volumes:` entry.
|
||||
- **Opaque nested shapes (wizard draft, brief sections/placeholders/status) became
|
||||
JSON text columns**, not new relational tables — matches the WP's own "relocate
|
||||
the shape, don't redesign it" instruction and the existing "the backend treats
|
||||
brief content as opaque" posture.
|
||||
- **Found and fixed a real test race, not a hypothetical one.** The stores read a
|
||||
single static `Db.ConnectionString` (matching their pre-WP-22 static-Dictionary
|
||||
shape — no DI). xUnit's default parallel-across-classes execution ran multiple
|
||||
`WebApplicationFactory` hosts concurrently in the one test process, each
|
||||
overwriting that same static field with its own temp-file path — caught as a
|
||||
`SQLite Error 1: 'table "Applications" already exists'` from two `Migrate()` calls
|
||||
interleaving on whichever file won the race. Fixed with
|
||||
`[assembly: CollectionBehavior(DisableTestParallelization = true)]`
|
||||
(`TestWebApplicationFactory.cs`) rather than redesigning the stores' DI shape for
|
||||
a test-only concern. Reran `dotnet test` 3× in a row to confirm the race was
|
||||
actually gone, not just less likely.
|
||||
- **Pinned `SQLitePCLRaw.bundle_e_sqlite3` to 3.0.3** — `Microsoft.EntityFrameworkCore.Sqlite`
|
||||
10.0.9's own transitive default (2.1.11) bundles a pre-3.50.2 SQLite with a known
|
||||
high-severity memory-corruption advisory (GHSA-2m69-gcr7-jv3q); 3.0.3 bundles a
|
||||
patched one and built/tested cleanly as a drop-in.
|
||||
- **`dotnet-ef` added to the existing `backend/dotnet-tools.json`** (not a new
|
||||
`.config/dotnet-tools.json`) — this repo already keeps its one CLI tool manifest
|
||||
there (`swashbuckle.aspnetcore.cli`); matched that convention.
|
||||
|
||||
59
docs/wcag-checklist.md
Normal file
59
docs/wcag-checklist.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# WCAG manual checklist
|
||||
|
||||
Automation (axe on every story — WP-01; template a11y lint — WP-17; the form-field/alert
|
||||
play tests — WP-16) catches structural and component-level issues. It cannot catch
|
||||
cross-page flows: tab order across a whole page, focus traps, zoom/reflow, or how a
|
||||
screen reader actually narrates a journey. This checklist is the manual complement —
|
||||
a living doc, filled in per page as it's walked, not a one-time sign-off.
|
||||
|
||||
See `src/docs/a11y.mdx` (Storybook → Foundations → Accessibility) for how this fits
|
||||
with the automated layers.
|
||||
|
||||
## How to run a page through this checklist
|
||||
|
||||
1. **Keyboard walk**: `Tab`/`Shift+Tab` through the whole page. Every interactive
|
||||
element reachable, in a sensible order, with a visible focus ring; no trap (you can
|
||||
always tab back out).
|
||||
2. **No traps**: a modal/dropdown/menu, if present, returns focus on close/`Escape`.
|
||||
3. **200% zoom / reflow**: browser zoom to 200% (or a 320px-wide viewport). Content
|
||||
reflows to one column; nothing is clipped or requires horizontal scroll.
|
||||
4. **Screen reader pass**: NVDA (Windows) or VoiceOver (macOS) — navigate by heading
|
||||
and by tab; confirm labels, descriptions, and error announcements are heard, not
|
||||
just visible.
|
||||
5. **Visible focus**: every focused element has a visible indicator (no
|
||||
`outline: none` without a replacement).
|
||||
6. **Error announcement**: submitting an invalid form announces the error (this is
|
||||
what WP-16's `role="alert"` + `aria-describedby` wiring is for) — confirm it's
|
||||
actually heard, not just present in the DOM.
|
||||
|
||||
## Status
|
||||
|
||||
Legend: ✅ pass · ⚠️ pass with notes · ❌ fails · — not yet walked
|
||||
|
||||
| Page | Keyboard walk | No traps | 200% zoom/reflow | Screen reader | Visible focus | Error announcement |
|
||||
| -------------------------- | :-----------: | :------: | :--------------: | :-----------: | :-----------: | :----------------: |
|
||||
| Login (`/login`) | ✅ | ✅ | ✅ | — | ✅ | n/a¹ |
|
||||
| Dashboard (`/dashboard`) | — | — | ❌² | — | — | — |
|
||||
| Registratie wizard | — | — | — | — | ✅³ | ✅³ |
|
||||
| Herregistratie wizard | — | — | — | — | — | — |
|
||||
| Brief (letter composition) | — | — | — | — | — | — |
|
||||
|
||||
¹ Login's demo form has no client-side validation/error state to exercise.
|
||||
² **Real finding, not fixed here**: `aanvraag-block`'s warning `app-alert` (two
|
||||
`app-button` actions) overflows the viewport at a 320px width — its `.feedback` flex
|
||||
row doesn't wrap, pushing the second button past the edge. Fixing it is a genuine
|
||||
CSS change to a live component, which is exactly the "full manual audit" scope this
|
||||
WP defers (see Out of scope) — logged here instead of silently fixed or silently
|
||||
ignored.
|
||||
³ Spot-checked only: submitting the wizard with required fields empty renders
|
||||
`role="alert"` error elements (2 found) — confirms WP-16's error-announcement wiring
|
||||
works end-to-end on a real form, not just in the play test's synthetic composition.
|
||||
Full keyboard walk / zoom / screen-reader pass on this page not yet done.
|
||||
|
||||
"Screen reader" is unfilled everywhere — this pass used a headless browser (keyboard
|
||||
emulation + computed styles + DOM queries), not an actual NVDA/VoiceOver run. Don't
|
||||
read the automation above as a substitute for that row; it isn't one.
|
||||
|
||||
Filling in the remaining rows (and fixing finding ²) is ongoing work (WP-17's
|
||||
out-of-scope note) — this table makes what's been checked, and what hasn't, visible
|
||||
rather than assumed.
|
||||
File diff suppressed because one or more lines are too long
32
e2e/error-state.spec.ts
Normal file
32
e2e/error-state.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// The dev-only `?scenario=error` toggle forces the scenario.interceptor to fail
|
||||
// the request WITHOUT ever reaching the real HTTP transport (it substitutes a
|
||||
// `throwError` in the rxjs pipe before `next(req)` runs) — so this is not
|
||||
// observable as a browser network request. It IS observable as a real resource
|
||||
// reload: `retry()` calls `resource.reload()`, which flips <app-async> back to
|
||||
// its Loading (`aria-busy="true"`) state before failing again 400ms later.
|
||||
// `currentScenario()` re-reads `window.location.search` on every call, not a
|
||||
// one-shot value, so as long as the query param is still there, the retry
|
||||
// genuinely re-runs and genuinely fails the same way — that's what's asserted
|
||||
// here: a real reload cycle, not a no-op button.
|
||||
test('dashboard error state renders, retry re-fetches (and fails again)', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('BSN').fill('123456789');
|
||||
await page.getByRole('button', { name: 'Inloggen met DigiD' }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
|
||||
await page.goto('/dashboard?scenario=error');
|
||||
// The dashboard has more than one independent <app-async> (profile,
|
||||
// aantekeningen) — both fail under the blanket ?scenario=error, so this text
|
||||
// legitimately appears more than once. Assert at least one, not exactly one.
|
||||
const errorAlert = page.getByText('Er ging iets mis bij het laden van de gegevens.').first();
|
||||
await expect(errorAlert).toBeVisible();
|
||||
const retry = page.getByRole('button', { name: 'Opnieuw proberen' }).first();
|
||||
await expect(retry).toBeVisible();
|
||||
|
||||
await retry.click();
|
||||
// A real reload cycle: back to Loading (aria-busy) before failing again.
|
||||
await expect(page.locator('[aria-busy="true"]').first()).toBeAttached({ timeout: 1_000 });
|
||||
await expect(errorAlert).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
59
e2e/smoke.spec.ts
Normal file
59
e2e/smoke.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// One happy-path flow through the real FE+backend: log in, land on the real
|
||||
// dashboard, run the registratie wizard's minimum required path (a DUO diploma
|
||||
// with zero policy questions, so the only required upload is identiteit), submit,
|
||||
// and see the real confirmation. Not a full wizard-coverage suite — see WP-19.
|
||||
//
|
||||
// The backend is in-memory and shared across runs; this test mutates real state
|
||||
// (creates a registratie application for the fixed demo identity). Restart the
|
||||
// backend between CI runs — a second run would see a leftover Concept/submitted
|
||||
// application on the dashboard, which this test doesn't assert against, but a
|
||||
// stricter future test might.
|
||||
test('login → dashboard → registratie wizard → submitted', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('BSN').fill('123456789');
|
||||
await page.getByLabel('Wachtwoord').fill('demo');
|
||||
await page.getByRole('button', { name: 'Inloggen met DigiD' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Mijn overzicht' })).toBeVisible();
|
||||
// Real backend content, not a loading/error state.
|
||||
await expect(page.getByText('Persoonsgegevens (BRP)')).toBeVisible();
|
||||
|
||||
await page.goto('/registreren');
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Inschrijven in het BIG-register' }),
|
||||
).toBeVisible();
|
||||
|
||||
// Step 1 — adres: BRP prefill is real backend data; "Post" skips the email field.
|
||||
// The CIBG-styled radio hides the native input behind its label, so click the
|
||||
// label (real user interaction) rather than `.check()` the covered input.
|
||||
await expect(page.getByText('Vooraf ingevuld op basis van de BRP')).toBeVisible();
|
||||
await page.locator('label[for="correspondentie-post"]').click();
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
// Step 2 — beroep: the first DUO diploma (Geneeskunde, non-English) carries zero
|
||||
// policy questions, so the only required document is identiteit.
|
||||
await expect(page.locator('#diploma-d1')).toBeVisible({ timeout: 10_000 });
|
||||
await page.locator('label[for="diploma-d1"]').click();
|
||||
await expect(page.getByText('Beroep (afgeleid uit diploma)')).toBeVisible();
|
||||
|
||||
await page.locator('#identiteit-file').setInputFiles({
|
||||
name: 'identiteit.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
buffer: Buffer.from('%PDF-1.4 fake e2e fixture'),
|
||||
});
|
||||
// Real upload against the backend: wait for the row to leave "uploading".
|
||||
await expect(page.locator('.upload-progress')).toHaveCount(0, { timeout: 10_000 });
|
||||
await expect(page.locator('.icon-remove')).toBeVisible();
|
||||
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
// Step 3 — controle: review + real submit.
|
||||
await expect(page.getByText('Controleer uw gegevens en dien de registratie in.')).toBeVisible();
|
||||
await page.locator('button[type="submit"]').click();
|
||||
|
||||
await expect(page.getByText('Uw registratie is ontvangen')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText(/Uw referentienummer is/)).toBeVisible();
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import tseslint from 'typescript-eslint';
|
||||
import angular from 'angular-eslint';
|
||||
|
||||
/**
|
||||
* Enforces the architecture's working agreements that were previously only
|
||||
@@ -28,8 +29,18 @@ export default [
|
||||
},
|
||||
plugins: { '@typescript-eslint': tseslint.plugin },
|
||||
rules: { '@typescript-eslint/no-explicit-any': 'error' },
|
||||
// This repo has no .html files — every template is inline. This processor
|
||||
// extracts each component's template string into a virtual `*.html` file,
|
||||
// which the template-a11y block below (`files: ['src/**/*.html']`) then lints.
|
||||
processor: angular.processInlineTemplates,
|
||||
},
|
||||
|
||||
// Template a11y (WP-17): the official angular-eslint accessibility bundle
|
||||
// (alt-text, label-has-associated-control, click/mouse-events-have-key-events,
|
||||
// interactive-supports-focus, valid-aria, no-autofocus, and more) linting the
|
||||
// virtual `.html` files the processor above extracts from inline templates.
|
||||
...angular.configs.templateAccessibility.map((c) => ({ ...c, files: ['src/**/*.html'] })),
|
||||
|
||||
// Tests legitimately use `any` to feed invalid messages/states into reducers.
|
||||
{
|
||||
files: ['src/**/*.spec.ts'],
|
||||
|
||||
4370
package-lock.json
generated
4370
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,9 @@
|
||||
"build-storybook": "ng run atomic-design-poc:build-storybook",
|
||||
"test-storybook": "test-storybook",
|
||||
"test-storybook:ci": "concurrently -k -s first -n sb,axe \"http-server storybook-static -p 6006 --silent\" \"wait-on tcp:127.0.0.1:6006 && test-storybook --url http://127.0.0.1:6006\"",
|
||||
"check:tokens": "bash scripts/check-tokens.sh"
|
||||
"check:tokens": "bash scripts/check-tokens.sh",
|
||||
"e2e": "playwright test",
|
||||
"extract-i18n": "ng extract-i18n --output-path src/locale"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@11.12.1",
|
||||
@@ -39,11 +41,13 @@
|
||||
"@angular/localize": "^22.0.4",
|
||||
"@angular/platform-browser-dynamic": "^22.0.0",
|
||||
"@compodoc/compodoc": "^1.2.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@storybook/addon-a11y": "^10.4.6",
|
||||
"@storybook/addon-docs": "^10.4.6",
|
||||
"@storybook/addon-onboarding": "^10.4.6",
|
||||
"@storybook/angular": "^10.4.6",
|
||||
"@storybook/test-runner": "^0.24.4",
|
||||
"angular-eslint": "^22.0.0",
|
||||
"axe-playwright": "^2.2.2",
|
||||
"concurrently": "^10.0.3",
|
||||
"eslint": "^10.6.0",
|
||||
|
||||
29
playwright.config.ts
Normal file
29
playwright.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
// Smoke-level e2e (WP-19): one happy path, one degraded path, against the REAL
|
||||
// backend — proving the FE+BE seam, not replacing component/unit tests.
|
||||
const baseURL = process.env['E2E_BASE_URL'] ?? 'http://localhost:4200';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
fullyParallel: true,
|
||||
retries: process.env['CI'] ? 1 : 0,
|
||||
reporter: process.env['CI'] ? 'github' : 'list',
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
|
||||
// CI starts `ng serve` + the backend as separate job steps (both need to be up
|
||||
// before the suite runs); locally, boot `npm start` automatically so `npm run e2e`
|
||||
// works standalone — the backend still needs `dotnet run` running separately.
|
||||
webServer: process.env['CI']
|
||||
? undefined
|
||||
: {
|
||||
command: 'npm start',
|
||||
url: baseURL,
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
isDevMode,
|
||||
provideBrowserGlobalErrorListeners,
|
||||
} from '@angular/core';
|
||||
import { provideRouter, withViewTransitions } from '@angular/router';
|
||||
import { provideRouter, withInMemoryScrolling, withViewTransitions } from '@angular/router';
|
||||
import type { ActivatedRouteSnapshot } from '@angular/router';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { registerLocaleData } from '@angular/common';
|
||||
@@ -16,6 +16,7 @@ import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
|
||||
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||
import { SESSION_PORT } from '@shared/application/session.port';
|
||||
import { SessionStore } from '@auth/application/session.store';
|
||||
import { provideRouteFocus } from '@shared/layout/route-focus';
|
||||
|
||||
registerLocaleData(localeNl);
|
||||
|
||||
@@ -24,6 +25,7 @@ export const appConfig: ApplicationConfig = {
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(
|
||||
routes,
|
||||
withInMemoryScrolling({ scrollPositionRestoration: 'enabled' }),
|
||||
// Cross-fade page-to-page navigations only. A silent same-route nav — e.g.
|
||||
// draft-sync stamping `?aanvraag=<id>` into the URL mid-wizard — must NOT
|
||||
// animate: for the transition's duration Firefox's `::view-transition`
|
||||
@@ -48,5 +50,6 @@ export const appConfig: ApplicationConfig = {
|
||||
provideApiClient(),
|
||||
{ provide: SESSION_PORT, useExisting: SessionStore },
|
||||
{ provide: LOCALE_ID, useValue: 'nl' },
|
||||
provideRouteFocus(),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -24,7 +24,13 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
i18n-description="@@login.bsnDescription"
|
||||
description="9 cijfers (demo: vul iets in)"
|
||||
>
|
||||
<app-text-input inputId="bsn" [(ngModel)]="bsn" name="bsn" placeholder="123456789" />
|
||||
<app-text-input
|
||||
inputId="bsn"
|
||||
hasDescription
|
||||
[(ngModel)]="bsn"
|
||||
name="bsn"
|
||||
placeholder="123456789"
|
||||
/>
|
||||
</app-form-field>
|
||||
|
||||
<app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LoginFormComponent } from './login-form.component';
|
||||
|
||||
const meta: Meta<LoginFormComponent> = {
|
||||
title: 'Organisms/Login Form',
|
||||
title: 'Domein/Auth/Login Form',
|
||||
component: LoginFormComponent,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
@@ -79,7 +79,10 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
||||
|
||||
approveResult = {
|
||||
ok: true,
|
||||
value: { ...view, brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } } },
|
||||
value: {
|
||||
...view,
|
||||
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
||||
},
|
||||
};
|
||||
await store.approve();
|
||||
expect(store.busy()).toBe(false);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
import { DiagnosticsPanelComponent } from './diagnostics-panel.component';
|
||||
|
||||
const location = { blockId: 'local-2', paragraphIndex: 0, nodeIndex: 1 };
|
||||
|
||||
const errorAndWarning: Diagnostic[] = [
|
||||
{
|
||||
severity: 'error',
|
||||
code: 'unknown-placeholder',
|
||||
placeholderKey: 'onbekend_veld',
|
||||
location,
|
||||
message: 'Onbekend veld "onbekend_veld" — controleer de spelling.',
|
||||
},
|
||||
{
|
||||
severity: 'warning',
|
||||
code: 'unresolved-at-send',
|
||||
placeholderKey: 'reden_besluit',
|
||||
location,
|
||||
message: '"Reden besluit" is nog niet ingevuld.',
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<DiagnosticsPanelComponent> = {
|
||||
title: 'Domein/Brief/Diagnostics Panel',
|
||||
component: DiagnosticsPanelComponent,
|
||||
args: { locate: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<DiagnosticsPanelComponent>;
|
||||
|
||||
export const Findings: Story = { args: { diagnostics: errorAndWarning } };
|
||||
export const Clean: Story = { args: { diagnostics: [] } };
|
||||
47
src/app/brief/ui/letter-block/letter-block.stories.ts
Normal file
47
src/app/brief/ui/letter-block/letter-block.stories.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LetterBlock } from '@brief/domain/brief';
|
||||
import { LetterBlockComponent } from './letter-block.component';
|
||||
|
||||
const passageBlock: LetterBlock = {
|
||||
type: 'passage',
|
||||
blockId: 'local-1',
|
||||
sourcePassageId: 'p1',
|
||||
sourceVersion: 1,
|
||||
edited: false,
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw,' }] }] },
|
||||
};
|
||||
|
||||
const freeTextBlock: LetterBlock = {
|
||||
type: 'freeText',
|
||||
blockId: 'local-2',
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Wij hebben besloten om reden ' },
|
||||
{ type: 'placeholder', key: 'reden_besluit' },
|
||||
{ type: 'text', text: '.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const placeholders = [{ key: 'reden_besluit', label: 'Reden besluit' }];
|
||||
|
||||
const meta: Meta<LetterBlockComponent> = {
|
||||
title: 'Domein/Brief/Letter Block',
|
||||
component: LetterBlockComponent,
|
||||
args: {
|
||||
block: passageBlock,
|
||||
placeholders,
|
||||
contentChanged: () => {},
|
||||
removed: () => {},
|
||||
moved: () => {},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LetterBlockComponent>;
|
||||
|
||||
export const ReadOnlyPassage: Story = { args: { editable: false } };
|
||||
export const EditableFreeText: Story = { args: { block: freeTextBlock, editable: true } };
|
||||
@@ -118,7 +118,7 @@ const render = (b: Brief, decisions: BriefDecisions) => ({
|
||||
});
|
||||
|
||||
const meta: Meta<LetterComposerComponent> = {
|
||||
title: 'Organisms/Letter Composer',
|
||||
title: 'Domein/Brief/Letter Composer',
|
||||
component: LetterComposerComponent,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
88
src/app/brief/ui/letter-preview/letter-preview.stories.ts
Normal file
88
src/app/brief/ui/letter-preview/letter-preview.stories.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { Brief } from '@brief/domain/brief';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
import { LetterPreviewComponent } from './letter-preview.component';
|
||||
|
||||
const brief: Brief = {
|
||||
briefId: 'b1',
|
||||
beroep: 'arts',
|
||||
templateId: 't1',
|
||||
drafterId: 'demo-drafter',
|
||||
status: { tag: 'draft' },
|
||||
placeholders: [
|
||||
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
sectionKey: 'aanhef',
|
||||
title: 'Aanhef',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [
|
||||
{
|
||||
type: 'passage',
|
||||
blockId: 'local-1',
|
||||
sourcePassageId: 'p1',
|
||||
sourceVersion: 1,
|
||||
edited: false,
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Geachte heer/mevrouw ' },
|
||||
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
sectionKey: 'kern',
|
||||
title: 'Kern van het besluit',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'local-2',
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Wij hebben besloten om reden ' },
|
||||
{ type: 'placeholder', key: 'reden_besluit' },
|
||||
{ type: 'text', text: '.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const diagnostics: Diagnostic[] = [
|
||||
{
|
||||
severity: 'warning',
|
||||
code: 'unresolved-at-send',
|
||||
placeholderKey: 'reden_besluit',
|
||||
location: { blockId: 'local-2', paragraphIndex: 0, nodeIndex: 1 },
|
||||
message: '"Reden besluit" is nog niet ingevuld.',
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<LetterPreviewComponent> = {
|
||||
title: 'Domein/Brief/Letter Preview',
|
||||
component: LetterPreviewComponent,
|
||||
args: { brief, diagnostics },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LetterPreviewComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const ZonderBevindingen: Story = { args: { diagnostics: [] } };
|
||||
55
src/app/brief/ui/letter-section/letter-section.stories.ts
Normal file
55
src/app/brief/ui/letter-section/letter-section.stories.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LetterSection, LibraryPassage } from '@brief/domain/brief';
|
||||
import { LetterSectionComponent } from './letter-section.component';
|
||||
|
||||
const section: LetterSection = {
|
||||
sectionKey: 'kern',
|
||||
title: 'Kern van het besluit',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'local-2',
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Wij hebben besloten om reden ' },
|
||||
{ type: 'placeholder', key: 'reden_besluit' },
|
||||
{ type: 'text', text: '.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const emptySection: LetterSection = { ...section, blocks: [] };
|
||||
|
||||
const passages: LibraryPassage[] = [
|
||||
{
|
||||
passageId: 'p2',
|
||||
scope: 'beroep',
|
||||
beroep: 'arts',
|
||||
sectionKey: 'kern',
|
||||
label: 'Toelichting arts',
|
||||
version: 1,
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Als arts ...' }] }] },
|
||||
},
|
||||
];
|
||||
|
||||
const placeholders = [{ key: 'reden_besluit', label: 'Reden besluit' }];
|
||||
|
||||
const meta: Meta<LetterSectionComponent> = {
|
||||
title: 'Domein/Brief/Letter Section',
|
||||
component: LetterSectionComponent,
|
||||
args: { section, availablePassages: passages, placeholders, edit: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LetterSectionComponent>;
|
||||
|
||||
export const ReadOnly: Story = { args: { editable: false } };
|
||||
export const Editable: Story = { args: { editable: true } };
|
||||
export const EditableEmpty: Story = { args: { section: emptySection, editable: true } };
|
||||
37
src/app/brief/ui/passage-picker/passage-picker.stories.ts
Normal file
37
src/app/brief/ui/passage-picker/passage-picker.stories.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LibraryPassage } from '@brief/domain/brief';
|
||||
import { PassagePickerComponent } from './passage-picker.component';
|
||||
|
||||
const passages: LibraryPassage[] = [
|
||||
{
|
||||
passageId: 'p1',
|
||||
scope: 'global',
|
||||
sectionKey: 'aanhef',
|
||||
label: 'Standaard aanhef',
|
||||
version: 1,
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte heer/mevrouw,' }] }] },
|
||||
},
|
||||
{
|
||||
passageId: 'p2',
|
||||
scope: 'beroep',
|
||||
beroep: 'arts',
|
||||
sectionKey: 'aanhef',
|
||||
label: 'Aanhef, arts-specifiek',
|
||||
version: 1,
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Geachte collega,' }] }] },
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<PassagePickerComponent> = {
|
||||
title: 'Domein/Brief/Passage Picker',
|
||||
component: PassagePickerComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-passage-picker [passages]="passages" (insert)="insert($event)" />`,
|
||||
}),
|
||||
args: { passages, insert: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<PassagePickerComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { RejectionCommentsComponent } from './rejection-comments.component';
|
||||
|
||||
const meta: Meta<RejectionCommentsComponent> = {
|
||||
title: 'Domein/Brief/Rejection Comments',
|
||||
component: RejectionCommentsComponent,
|
||||
args: { reject: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<RejectionCommentsComponent>;
|
||||
|
||||
export const Show: Story = {
|
||||
args: { mode: 'show', comments: 'Graag de aanhef formeler.' },
|
||||
};
|
||||
export const Entry: Story = { args: { mode: 'entry' } };
|
||||
export const EntryBusy: Story = { args: { mode: 'entry', busy: true } };
|
||||
@@ -10,7 +10,7 @@ import { Uren } from '@registratie/domain/value-objects/uren';
|
||||
const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] };
|
||||
|
||||
const meta: Meta<HerregistratieWizardComponent> = {
|
||||
title: 'Herregistratie/Wizard',
|
||||
title: 'Domein/Herregistratie/Wizard',
|
||||
component: HerregistratieWizardComponent,
|
||||
// The wizard injects BigProfileStore (for the optimistic cross-page flag),
|
||||
// which creates httpResources — so the story needs an HttpClient.
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Uren } from '@registratie/domain/value-objects/uren';
|
||||
const validData = { werktBuitenland: false, uren: 4160 as Uren, punten: 200 as Uren };
|
||||
|
||||
const meta: Meta<IntakeWizardComponent> = {
|
||||
title: 'Herregistratie/IntakeWizard',
|
||||
title: 'Domein/Herregistratie/IntakeWizard',
|
||||
component: IntakeWizardComponent,
|
||||
// Injects BigProfileStore (optimistic flag) which creates httpResources.
|
||||
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
|
||||
|
||||
@@ -72,4 +72,12 @@ export class BigProfileStore {
|
||||
rollbackHerregistratie() {
|
||||
this.pending.set(false); // submission failed — undo the optimistic flag
|
||||
}
|
||||
|
||||
// Retry hooks for [data]-fed <app-async> instances (they don't own the resource).
|
||||
reloadProfile() {
|
||||
this.viewRes.reload();
|
||||
}
|
||||
reloadAantekeningen() {
|
||||
this.aantekeningenRes.reload();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,16 @@ import { parsePostcode } from '@registratie/domain/value-objects/postcode';
|
||||
const postcode = parsePostcode('2514 EA');
|
||||
if (!postcode.ok) throw new Error('fixture postcode should parse');
|
||||
|
||||
const data: Valid = { straat: 'Lange Voorhout 9', postcode: postcode.value, woonplaats: 'Den Haag' };
|
||||
const data: Valid = {
|
||||
straat: 'Lange Voorhout 9',
|
||||
postcode: postcode.value,
|
||||
woonplaats: 'Den Haag',
|
||||
};
|
||||
|
||||
function setup(adapter: Partial<ChangeRequestAdapter>) {
|
||||
TestBed.configureTestingModule({ providers: [{ provide: ChangeRequestAdapter, useValue: adapter }] });
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: ChangeRequestAdapter, useValue: adapter }],
|
||||
});
|
||||
return TestBed.runInInjectionContext(() => createSubmitChangeRequest());
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ describe('change-request reduce', () => {
|
||||
it('SetField updates the draft while editing', () => {
|
||||
const s = reduce(initial, { tag: 'SetField', key: 'straat', value: 'Lange Voorhout 9' });
|
||||
expect(s.tag).toBe('Editing');
|
||||
expect((s as Extract<ChangeRequestState, { tag: 'Editing' }>).draft.straat).toBe('Lange Voorhout 9');
|
||||
expect((s as Extract<ChangeRequestState, { tag: 'Editing' }>).draft.straat).toBe(
|
||||
'Lange Voorhout 9',
|
||||
);
|
||||
});
|
||||
|
||||
it('Submit with an invalid draft stays Editing and reports field errors', () => {
|
||||
|
||||
@@ -3,7 +3,9 @@ import { parseAantekening } from './big-register.adapter';
|
||||
|
||||
describe('big-register.adapter parse boundary', () => {
|
||||
it('parses known aantekening types', () => {
|
||||
expect(parseAantekening({ type: 'Specialisme', omschrijving: 'x', datum: '2026-01-01' })).toEqual({
|
||||
expect(
|
||||
parseAantekening({ type: 'Specialisme', omschrijving: 'x', datum: '2026-01-01' }),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: { type: 'Specialisme', omschrijving: 'x', datum: '2026-01-01' },
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ const base = {
|
||||
} satisfies Omit<Aanvraag, 'status'>;
|
||||
|
||||
const meta: Meta<AanvraagBlockComponent> = {
|
||||
title: 'Organisms/Aanvraag Block',
|
||||
title: 'Domein/Registratie/Aanvraag Block',
|
||||
component: AanvraagBlockComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { moduleMetadata } from '@storybook/angular';
|
||||
import { AddressFieldsComponent } from './address-fields.component';
|
||||
|
||||
const meta: Meta<AddressFieldsComponent> = {
|
||||
title: 'Organisms/Address Fields',
|
||||
title: 'Domein/Registratie/Address Fields',
|
||||
component: AddressFieldsComponent,
|
||||
decorators: [moduleMetadata({ imports: [AddressFieldsComponent] })],
|
||||
render: (args) => ({
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
} from '@registratie/ui/address-fields/address-fields.component';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { whenTag } from '@shared/kernel/fp';
|
||||
import { ChangeRequestState, ChangeRequestMsg, initial, reduce } from '@registratie/domain/change-request.machine';
|
||||
import {
|
||||
ChangeRequestState,
|
||||
ChangeRequestMsg,
|
||||
initial,
|
||||
reduce,
|
||||
} from '@registratie/domain/change-request.machine';
|
||||
import { createSubmitChangeRequest } from '@registratie/application/submit-change-request';
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ const validData = {
|
||||
};
|
||||
|
||||
const meta: Meta<ChangeRequestFormComponent> = {
|
||||
title: 'Organisms/Change Request Form',
|
||||
title: 'Domein/Registratie/Change Request Form',
|
||||
component: ChangeRequestFormComponent,
|
||||
// The form injects ApiClient (over HttpClient) for the submit command.
|
||||
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
|
||||
|
||||
@@ -86,7 +86,7 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
|
||||
>
|
||||
}
|
||||
|
||||
<app-async [data]="store.profile()">
|
||||
<app-async [data]="store.profile()" (retryClicked)="store.reloadProfile()">
|
||||
<ng-template appAsyncLoaded>
|
||||
@if (profile(); as p) {
|
||||
@let tasks = tasksFor(p.registration);
|
||||
@@ -153,7 +153,7 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
|
||||
>Specialismen en aantekeningen</app-heading
|
||||
>
|
||||
<div class="app-section">
|
||||
<app-async [data]="store.aantekeningen()">
|
||||
<app-async [data]="store.aantekeningen()" (retryClicked)="store.reloadAantekeningen()">
|
||||
<ng-template appAsyncLoaded>
|
||||
@if (aantekeningen(); as r) {
|
||||
<app-registration-table [rows]="r" />
|
||||
|
||||
@@ -46,7 +46,7 @@ const validData: ValidRegistratie = {
|
||||
};
|
||||
|
||||
const meta: Meta<RegistratieWizardComponent> = {
|
||||
title: 'Registratie/RegistratieWizard',
|
||||
title: 'Domein/Registratie/RegistratieWizard',
|
||||
component: RegistratieWizardComponent,
|
||||
decorators: [applicationConfig({ providers: [provideHttpClient(), provideApiClient()] })],
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ const base = {
|
||||
} satisfies Omit<Registration, 'status'>;
|
||||
|
||||
const meta: Meta<RegistrationSummaryComponent> = {
|
||||
title: 'Organisms/Registration Summary',
|
||||
title: 'Domein/Registratie/Registration Summary',
|
||||
component: RegistrationSummaryComponent,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { RegistrationTableComponent } from './registration-table.component';
|
||||
|
||||
const meta: Meta<RegistrationTableComponent> = {
|
||||
title: 'Organisms/Registration Table',
|
||||
title: 'Domein/Registratie/Registration Table',
|
||||
component: RegistrationTableComponent,
|
||||
};
|
||||
export default meta;
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import { withIdempotencyKey } from '@shared/infrastructure/api-client.provider';
|
||||
|
||||
/**
|
||||
* Run a mutating API call and fold it into a `Result` — the one place the
|
||||
* try/catch + ProblemDetails-mapping lives, so every `submit-*` command is just
|
||||
* its own payload mapping. The backend re-validates and returns a 422
|
||||
* ProblemDetails on rejection, surfaced here as the error string.
|
||||
*
|
||||
* Also the one place a logical submit's Idempotency-Key is minted — once per
|
||||
* `runSubmit` call, not per HTTP attempt — so a retry of this same submit
|
||||
* dedupes on the backend (see `withIdempotencyKey`).
|
||||
*/
|
||||
export async function runSubmit<T>(
|
||||
fn: () => Promise<T>,
|
||||
fallback: string,
|
||||
): Promise<Result<string, T>> {
|
||||
try {
|
||||
return ok(await fn());
|
||||
return ok(await withIdempotencyKey(crypto.randomUUID(), fn));
|
||||
} catch (e) {
|
||||
return err(problemDetail(e, fallback));
|
||||
}
|
||||
|
||||
85
src/app/shared/infrastructure/api-client.provider.spec.ts
Normal file
85
src/app/shared/infrastructure/api-client.provider.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { HttpClient, HttpErrorResponse, HttpResponse } from '@angular/common/http';
|
||||
import { currentIdempotencyKey, httpClientFetch, withIdempotencyKey } from './api-client.provider';
|
||||
|
||||
/** Minimal stand-in for HttpClient — only `.request(...)` is ever called by the
|
||||
* adapter under test, so no TestBed/HttpClientTestingModule needed. */
|
||||
function fakeHttpClient(
|
||||
request: (method: string, url: string, options: { headers: Record<string, string> }) => unknown,
|
||||
): HttpClient {
|
||||
return { request } as unknown as HttpClient;
|
||||
}
|
||||
|
||||
describe('withIdempotencyKey / currentIdempotencyKey', () => {
|
||||
it('threads the key to every read made inside the wrapped fn', async () => {
|
||||
const seen: string[] = [];
|
||||
await withIdempotencyKey('fixed-key', async () => {
|
||||
seen.push(currentIdempotencyKey());
|
||||
seen.push(currentIdempotencyKey());
|
||||
});
|
||||
expect(seen).toEqual(['fixed-key', 'fixed-key']);
|
||||
});
|
||||
|
||||
it('clears the key once the wrapped fn settles', async () => {
|
||||
await withIdempotencyKey('fixed-key', async () => undefined);
|
||||
expect(currentIdempotencyKey()).not.toBe('fixed-key');
|
||||
});
|
||||
|
||||
it('falls back to a generated uuid-shaped key when none is pending', () => {
|
||||
expect(currentIdempotencyKey()).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('httpClientFetch', () => {
|
||||
it('sends the pending idempotency key as a header for a write, not a fresh one per attempt', async () => {
|
||||
let sentHeaders: Record<string, string> | undefined;
|
||||
const http = fakeHttpClient((_method, _url, opts) => {
|
||||
sentHeaders = opts.headers;
|
||||
return of(new HttpResponse({ status: 200, body: '' }));
|
||||
});
|
||||
|
||||
await withIdempotencyKey('logical-submit-key', () =>
|
||||
httpClientFetch(http).fetch('/api/v1/change-requests', { method: 'POST' }),
|
||||
);
|
||||
|
||||
expect(sentHeaders?.['Idempotency-Key']).toBe('logical-submit-key');
|
||||
});
|
||||
|
||||
// `http.request(...)` itself is only called once per `fetch()` — it returns a
|
||||
// cold Observable, and `retry` resubscribes to *that*, not to `.request()`
|
||||
// again (exactly how Angular's real HttpClient triggers a fresh network call
|
||||
// per subscription). So attempts are counted where the resubscription lands:
|
||||
// the `throwError` factory, not the outer mock call.
|
||||
it('retries a failing GET twice before giving up', async () => {
|
||||
let attempts = 0;
|
||||
const http = fakeHttpClient(() =>
|
||||
throwError(() => {
|
||||
attempts++;
|
||||
return new HttpErrorResponse({ status: 500 });
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await httpClientFetch(http).fetch('/api/v1/notes', { method: 'GET' });
|
||||
|
||||
expect(attempts).toBe(3); // 1 original + 2 retries
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it('never retries a failing write', async () => {
|
||||
let attempts = 0;
|
||||
const http = fakeHttpClient(() =>
|
||||
throwError(() => {
|
||||
attempts++;
|
||||
return new HttpErrorResponse({ status: 500 });
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await httpClientFetch(http).fetch('/api/v1/change-requests', { method: 'POST' });
|
||||
|
||||
expect(attempts).toBe(1);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,34 @@
|
||||
import { Provider } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { firstValueFrom, timeout, TimeoutError } from 'rxjs';
|
||||
import { firstValueFrom, retry, timeout, TimeoutError } from 'rxjs';
|
||||
import { ApiClient, ProblemDetails } from './api-client';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
/** Single place every API call passes through: the seam for cross-cutting concerns. */
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* A stable Idempotency-Key threaded down from the command layer (one per logical
|
||||
* submit — see `runSubmit`) rather than minted per HTTP attempt, so a retried
|
||||
* submit dedupes on the backend instead of double-submitting. The NSwag-generated
|
||||
* `ApiClient` has no per-call header hook, so `withIdempotencyKey` bridges it here:
|
||||
* every non-GET call made synchronously inside `fn` picks up the same key.
|
||||
* ponytail: a module-level variable, not a proper async-context primitive — holds
|
||||
* up because every submit command calls its adapter synchronously (no await
|
||||
* before reaching this file); swap for `AsyncLocal`-equivalent if concurrent
|
||||
* submits ever become possible.
|
||||
*/
|
||||
let pendingIdempotencyKey: string | undefined;
|
||||
|
||||
export function withIdempotencyKey<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
pendingIdempotencyKey = key;
|
||||
return fn().finally(() => (pendingIdempotencyKey = undefined));
|
||||
}
|
||||
|
||||
export function currentIdempotencyKey(): string {
|
||||
return pendingIdempotencyKey ?? crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts Angular's HttpClient to the fetch-shaped interface the NSwag-generated
|
||||
* client expects, so every API call flows through HttpClient interceptors (the
|
||||
@@ -15,12 +37,14 @@ const REQUEST_TIMEOUT_MS = 10_000;
|
||||
* Angular's HTTP stack — i.e. the one seam to add:
|
||||
* - timeout (done — REQUEST_TIMEOUT_MS),
|
||||
* - correlation id (done — X-Correlation-Id, echoed in backend logs),
|
||||
* - idempotency key for writes (done — Idempotency-Key; a real retry would thread
|
||||
* a STABLE key per logical submit so re-sends dedupe; here it's per-attempt),
|
||||
* - idempotency key for writes (done — Idempotency-Key, stable per logical
|
||||
* submit via `withIdempotencyKey`/`runSubmit`, so a retry dedupes),
|
||||
* - auth: attach `Authorization: Bearer …` here (one line) when real DigiD lands,
|
||||
* - retry/backoff: wrap the pipe with rxjs `retry({ count, delay })` here.
|
||||
* - retry/backoff (done — GET only, `retry({ count: 2, delay: 500 })`; writes are
|
||||
* never auto-retried, which is exactly what makes the idempotency key above
|
||||
* matter only for a future/manual retry, not routine traffic).
|
||||
*/
|
||||
function httpClientFetch(http: HttpClient) {
|
||||
export function httpClientFetch(http: HttpClient) {
|
||||
return {
|
||||
async fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
@@ -28,17 +52,18 @@ function httpClientFetch(http: HttpClient) {
|
||||
...((init?.headers ?? {}) as Record<string, string>),
|
||||
'X-Correlation-Id': crypto.randomUUID(),
|
||||
};
|
||||
if (method !== 'GET') headers['Idempotency-Key'] = crypto.randomUUID();
|
||||
if (method !== 'GET') headers['Idempotency-Key'] = currentIdempotencyKey();
|
||||
try {
|
||||
const res = await firstValueFrom(
|
||||
http
|
||||
const request$ = http
|
||||
.request(method, url as string, {
|
||||
body: init?.body as string | undefined,
|
||||
headers,
|
||||
observe: 'response',
|
||||
responseType: 'text',
|
||||
})
|
||||
.pipe(timeout(REQUEST_TIMEOUT_MS)),
|
||||
.pipe(timeout(REQUEST_TIMEOUT_MS));
|
||||
const res = await firstValueFrom(
|
||||
method === 'GET' ? request$.pipe(retry({ count: 2, delay: 500 })) : request$,
|
||||
);
|
||||
// 204/205/304 are null-body statuses — new Response(body, …) throws for any non-null body.
|
||||
const nullBody = res.status === 204 || res.status === 205 || res.status === 304;
|
||||
|
||||
@@ -12,8 +12,7 @@ export function assertNever(x: never): never {
|
||||
/** A computation that either succeeded with a value or failed with an error.
|
||||
Plain objects (no classes) to match the signal/httpResource ergonomics. */
|
||||
export type Result<E, T> =
|
||||
| { readonly ok: true; readonly value: T }
|
||||
| { readonly ok: false; readonly error: E };
|
||||
{ readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: E };
|
||||
|
||||
export const ok = <T>(value: T): Result<never, T> => ({ ok: true, value });
|
||||
export const err = <E>(error: E): Result<E, never> => ({ ok: false, error });
|
||||
|
||||
@@ -4,7 +4,7 @@ import { provideRouter } from '@angular/router';
|
||||
import { BreadcrumbComponent } from './breadcrumb.component';
|
||||
|
||||
const meta: Meta<BreadcrumbComponent> = {
|
||||
title: 'Layout/Breadcrumb',
|
||||
title: 'Design System/Molecules/Breadcrumb',
|
||||
component: BreadcrumbComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { PageShellComponent } from './page-shell.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
|
||||
const meta: Meta<PageShellComponent> = {
|
||||
title: 'Templates/PageShell',
|
||||
title: 'Design System/Templates/PageShell',
|
||||
component: PageShellComponent,
|
||||
decorators: [
|
||||
applicationConfig({ providers: [provideRouter([])] }),
|
||||
|
||||
45
src/app/shared/layout/route-focus.ts
Normal file
45
src/app/shared/layout/route-focus.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
DOCUMENT,
|
||||
ENVIRONMENT_INITIALIZER,
|
||||
EnvironmentInjector,
|
||||
afterNextRender,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
|
||||
/** Template-layer wiring (not a component): on every route change after the
|
||||
initial load, moves focus to the new page's `<h1>` (page-shell always
|
||||
renders one) so screen-reader/keyboard users land on the new content
|
||||
instead of wherever focus happened to be. Falls back to `#main` (the
|
||||
shell's landmark) if a page has no heading. Deferred via `afterNextRender`
|
||||
so it doesn't race Angular's view-transition DOM swap. */
|
||||
export function provideRouteFocus() {
|
||||
return {
|
||||
provide: ENVIRONMENT_INITIALIZER,
|
||||
multi: true,
|
||||
useValue: () => {
|
||||
const router = inject(Router);
|
||||
const document = inject(DOCUMENT);
|
||||
const injector = inject(EnvironmentInjector);
|
||||
let isInitialLoad = true;
|
||||
|
||||
router.events.subscribe((event) => {
|
||||
if (!(event instanceof NavigationEnd)) return;
|
||||
if (isInitialLoad) {
|
||||
isInitialLoad = false;
|
||||
return;
|
||||
}
|
||||
afterNextRender(
|
||||
() => {
|
||||
const target =
|
||||
document.querySelector<HTMLElement>('#main h1') ?? document.getElementById('main');
|
||||
if (!target) return;
|
||||
target.setAttribute('tabindex', '-1');
|
||||
target.focus({ preventScroll: true });
|
||||
},
|
||||
{ injector },
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
16
src/app/shared/layout/shell/shell.stories.ts
Normal file
16
src/app/shared/layout/shell/shell.stories.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { ShellComponent } from './shell.component';
|
||||
|
||||
const meta: Meta<ShellComponent> = {
|
||||
title: 'Design System/Templates/Shell',
|
||||
component: ShellComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<ShellComponent>;
|
||||
|
||||
// No route matches, so <router-outlet> renders nothing — this story is about the
|
||||
// persistent chrome (skip-link, header, footer), not routed page content.
|
||||
export const Default: Story = {};
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { SiteFooterComponent } from './site-footer.component';
|
||||
|
||||
const meta: Meta<SiteFooterComponent> = {
|
||||
title: 'Layout/Site Footer',
|
||||
title: 'Design System/Organisms/Site Footer',
|
||||
component: SiteFooterComponent,
|
||||
render: () => ({ template: `<app-site-footer />` }),
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { provideRouter } from '@angular/router';
|
||||
import { SiteHeaderComponent } from './site-header.component';
|
||||
|
||||
const meta: Meta<SiteHeaderComponent> = {
|
||||
title: 'Layout/Site Header',
|
||||
title: 'Design System/Organisms/Site Header',
|
||||
component: SiteHeaderComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { WizardShellComponent } from './wizard-shell.component';
|
||||
|
||||
const meta: Meta<WizardShellComponent> = {
|
||||
title: 'Templates/WizardShell',
|
||||
title: 'Design System/Templates/WizardShell',
|
||||
component: WizardShellComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
@@ -19,8 +19,7 @@ const meta: Meta<WizardShellComponent> = {
|
||||
cibgGap: true,
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'CIBG-gap extension (error summary only) — see Foundations/CIBG Gap Register.',
|
||||
component: 'CIBG-gap extension (error summary only) — see Foundations/CIBG Gap Register.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -13,7 +13,10 @@ const ICON_LABELS: Record<AlertType, string> = {
|
||||
/** Atom: alert/message banner — the CIBG Huisstijl "melding"
|
||||
(designsystem.cibg.nl/componenten/meldingen). Thin wrapper over the vendored
|
||||
`.feedback feedback-*` classes: the design system owns surface + icon; we add
|
||||
only the icon's a11y label and a content wrapper (`.feedback` is a flex row). */
|
||||
only the icon's a11y label and a content wrapper (`.feedback` is a flex row).
|
||||
Errors are `role="alert"` (assertive — interrupts) since they need immediate
|
||||
attention; other variants stay `role="status"` (polite) so success/info banners
|
||||
don't interrupt what the user is doing. */
|
||||
@Component({
|
||||
selector: 'app-alert',
|
||||
styles: [
|
||||
@@ -31,7 +34,7 @@ const ICON_LABELS: Record<AlertType, string> = {
|
||||
[class.feedback-success]="type() === 'ok'"
|
||||
[class.feedback-warning]="type() === 'warning'"
|
||||
[class.feedback-error]="type() === 'error'"
|
||||
role="status"
|
||||
[attr.role]="type() === 'error' ? 'alert' : 'status'"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<span class="icon"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import { AlertComponent } from './alert.component';
|
||||
|
||||
const meta: Meta<AlertComponent> = {
|
||||
title: 'Atoms/Alert',
|
||||
title: 'Design System/Atoms/Alert',
|
||||
component: AlertComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
@@ -12,7 +13,28 @@ const meta: Meta<AlertComponent> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<AlertComponent>;
|
||||
|
||||
export const Info: Story = { args: { type: 'info' } };
|
||||
export const Ok: Story = { args: { type: 'ok' } };
|
||||
export const Warning: Story = { args: { type: 'warning' } };
|
||||
export const Error: Story = { args: { type: 'error' } };
|
||||
// role assertions guard the polite/assertive split (WP-16): errors interrupt, others don't.
|
||||
export const Info: Story = {
|
||||
args: { type: 'info' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const Ok: Story = {
|
||||
args: { type: 'ok' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const Warning: Story = {
|
||||
args: { type: 'warning' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const Error: Story = {
|
||||
args: { type: 'error' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(within(canvasElement).getByRole('alert')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { provideRouter } from '@angular/router';
|
||||
import { ApplicationLinkComponent } from './application-link.component';
|
||||
|
||||
const meta: Meta<ApplicationLinkComponent> = {
|
||||
title: 'Molecules/Application Link',
|
||||
title: 'Design System/Molecules/Application Link',
|
||||
component: ApplicationLinkComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ApplicationListComponent } from './application-list.component';
|
||||
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
|
||||
|
||||
const meta: Meta<ApplicationListComponent> = {
|
||||
title: 'Molecules/Application List',
|
||||
title: 'Design System/Molecules/Application List',
|
||||
component: ApplicationListComponent,
|
||||
decorators: [
|
||||
applicationConfig({ providers: [provideRouter([])] }),
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
Directive,
|
||||
TemplateRef,
|
||||
computed,
|
||||
contentChild,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import type { Resource } from '@angular/core';
|
||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
||||
@@ -130,11 +138,16 @@ export class AsyncComponent<T> {
|
||||
}),
|
||||
);
|
||||
|
||||
// [resource]-fed callers get reload() for free. [data]-fed callers (a store's
|
||||
// combined RemoteData — the component doesn't own that resource) must reload
|
||||
// it themselves; retryClicked is how they find out a retry was requested.
|
||||
retryClicked = output<void>();
|
||||
retry = () => {
|
||||
const r = this.resource();
|
||||
if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {
|
||||
(r as { reload: () => void }).reload();
|
||||
}
|
||||
this.retryClicked.emit();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T>
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Molecules/Async States',
|
||||
title: 'Design System/Molecules/Async States',
|
||||
decorators: [moduleMetadata({ imports: [...ASYNC, SkeletonComponent] })],
|
||||
render: (args) => ({
|
||||
// isEmpty is a function — Storybook strips function args, so set it here.
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { ButtonComponent } from './button.component';
|
||||
|
||||
const meta: Meta<ButtonComponent> = {
|
||||
title: 'Atoms/Button',
|
||||
title: 'Design System/Atoms/Button',
|
||||
component: ButtonComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { CardComponent } from './card.component';
|
||||
|
||||
const meta: Meta<CardComponent> = {
|
||||
title: 'Molecules/Card',
|
||||
title: 'Design System/Molecules/Card',
|
||||
component: CardComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { CheckboxComponent } from './checkbox.component';
|
||||
|
||||
const meta: Meta<CheckboxComponent> = {
|
||||
title: 'Atoms/Checkbox',
|
||||
title: 'Design System/Atoms/Checkbox',
|
||||
component: CheckboxComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { provideRouter } from '@angular/router';
|
||||
import { ChoiceLinkComponent } from './choice-link.component';
|
||||
|
||||
const meta: Meta<ChoiceLinkComponent> = {
|
||||
title: 'Molecules/Choice Link',
|
||||
title: 'Design System/Molecules/Choice Link',
|
||||
component: ChoiceLinkComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ChoiceListComponent } from './choice-list.component';
|
||||
import { ChoiceLinkComponent } from '@shared/ui/choice-link/choice-link.component';
|
||||
|
||||
const meta: Meta<ChoiceListComponent> = {
|
||||
title: 'Molecules/Choice List',
|
||||
title: 'Design System/Molecules/Choice List',
|
||||
component: ChoiceListComponent,
|
||||
decorators: [
|
||||
applicationConfig({ providers: [provideRouter([])] }),
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { ConfirmationComponent } from './confirmation.component';
|
||||
|
||||
const meta: Meta<ConfirmationComponent> = {
|
||||
title: 'Molecules/Confirmation',
|
||||
title: 'Design System/Molecules/Confirmation',
|
||||
component: ConfirmationComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DataBlockComponent } from './data-block.component';
|
||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||
|
||||
const meta: Meta<DataBlockComponent> = {
|
||||
title: 'Molecules/Data Block',
|
||||
title: 'Design System/Molecules/Data Block',
|
||||
component: DataBlockComponent,
|
||||
decorators: [moduleMetadata({ imports: [DataRowComponent] })],
|
||||
render: (args) => ({
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { DataRowComponent } from './data-row.component';
|
||||
|
||||
const meta: Meta<DataRowComponent> = {
|
||||
title: 'Molecules/Data Row',
|
||||
title: 'Design System/Molecules/Data Row',
|
||||
component: DataRowComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { DebugStateComponent } from './debug-state.component';
|
||||
|
||||
// Devtools, not a design-system atom — titled accordingly.
|
||||
const meta: Meta<DebugStateComponent> = {
|
||||
title: 'Devtools/State debug',
|
||||
title: 'Design System/Devtools/State debug',
|
||||
component: DebugStateComponent,
|
||||
parameters: {
|
||||
cibgGap: true,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { moduleMetadata } from '@storybook/angular';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import { FormFieldComponent } from './form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
|
||||
const meta: Meta<FormFieldComponent> = {
|
||||
title: 'Molecules/Form Field',
|
||||
title: 'Design System/Molecules/Form Field',
|
||||
component: FormFieldComponent,
|
||||
decorators: [moduleMetadata({ imports: [TextInputComponent] })],
|
||||
render: (args) => ({
|
||||
@@ -13,7 +14,7 @@ const meta: Meta<FormFieldComponent> = {
|
||||
template: `
|
||||
<form class="form-horizontal">
|
||||
<app-form-field [label]="label" [fieldId]="fieldId" [description]="description" [error]="error" [required]="required">
|
||||
<app-text-input [inputId]="fieldId" [invalid]="!!error" placeholder="Vul in" />
|
||||
<app-text-input [inputId]="fieldId" [hasDescription]="!!description" [invalid]="!!error" placeholder="Vul in" />
|
||||
</app-form-field>
|
||||
</form>`,
|
||||
}),
|
||||
@@ -23,6 +24,13 @@ type Story = StoryObj<FormFieldComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { label: 'BSN', fieldId: 'bsn', description: '9 cijfers', required: true },
|
||||
// Composition contract: fieldId must equal the input's id — enforced here, not by DI
|
||||
// (see WP-16). Catches drift in the description→aria-describedby wiring.
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const input = canvas.getByRole('textbox');
|
||||
await expect(input).toHaveAttribute('aria-describedby', 'bsn-desc');
|
||||
},
|
||||
};
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
@@ -31,4 +39,23 @@ export const WithError: Story = {
|
||||
error: 'Dit veld is verplicht.',
|
||||
required: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const input = canvas.getByRole('textbox');
|
||||
await expect(input).toHaveAttribute('aria-describedby', 'street-error');
|
||||
},
|
||||
};
|
||||
export const WithDescriptionAndError: Story = {
|
||||
args: {
|
||||
label: 'BSN',
|
||||
fieldId: 'bsn',
|
||||
description: '9 cijfers',
|
||||
error: 'Ongeldig BSN.',
|
||||
required: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const input = canvas.getByRole('textbox');
|
||||
await expect(input).toHaveAttribute('aria-describedby', 'bsn-desc bsn-error');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { HeadingComponent } from './heading.component';
|
||||
|
||||
const meta: Meta<HeadingComponent> = {
|
||||
title: 'Atoms/Heading',
|
||||
title: 'Design System/Atoms/Heading',
|
||||
component: HeadingComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { provideRouter } from '@angular/router';
|
||||
import { LinkComponent } from './link.component';
|
||||
|
||||
const meta: Meta<LinkComponent> = {
|
||||
title: 'Atoms/Link',
|
||||
title: 'Design System/Atoms/Link',
|
||||
component: LinkComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { PlaceholderChipComponent } from './placeholder-chip.component';
|
||||
|
||||
const meta: Meta<PlaceholderChipComponent> = {
|
||||
title: 'Atoms/Placeholder Chip',
|
||||
title: 'Design System/Atoms/Placeholder Chip',
|
||||
component: PlaceholderChipComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { RadioGroupComponent } from './radio-group.component';
|
||||
|
||||
const meta: Meta<RadioGroupComponent> = {
|
||||
title: 'Atoms/RadioGroup',
|
||||
title: 'Design System/Atoms/RadioGroup',
|
||||
component: RadioGroupComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user