diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bc088eb..e5276fd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -29,6 +29,25 @@ jobs:
build-test:
name: Build & test (.NET)
runs-on: ubuntu-latest
+
+ # The repository suite talks to a real PostgreSQL: the atomic stock reservation and the
+ # schema constraints cannot be exercised by an in-memory fake. Running it here means every
+ # PR proves it, not just a deployment.
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_USER: widgetworks
+ POSTGRES_PASSWORD: replace-me-locally
+ POSTGRES_DB: widgetworks
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U widgetworks"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 10
+
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -41,17 +60,24 @@ jobs:
- name: Restore
run: dotnet restore
- # BOOTSTRAP: scaffold was authored without a local SDK. Run `dotnet format`
- # locally once, then set continue-on-error back to false to re-enable the gate.
+ # Enforced. The bootstrap exemption is spent: the tree was formatted once a local SDK was
+ # available, so a formatting drift is now a build failure rather than a warning nobody reads.
- name: Verify formatting
- continue-on-error: true
run: dotnet format --verify-no-changes
- name: Build (warnings as errors)
run: dotnet build --no-restore -c Release -warnaserror
- name: Test
- run: dotnet test --no-build -c Release --collect:"XPlat Code Coverage"
+ env:
+ WIDGETWORKS_TEST_DB: Host=localhost;Port=5432;Database=postgres;Username=widgetworks;Password=replace-me-locally
+ run: >
+ dotnet test --no-build -c Release
+ --collect:"XPlat Code Coverage" --settings coverlet.runsettings
+ --results-directory ./TestResults
+
+ - name: Coverage floor
+ run: ./scripts/check-coverage.sh ./TestResults 90
dependency-review:
name: Dependency review
diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml
index ccf9a32..71b8e8a 100644
--- a/.github/workflows/test-suite.yml
+++ b/.github/workflows/test-suite.yml
@@ -1,6 +1,6 @@
name: Test suite
-# Reusable gate: every deployment calls this and will not proceed unless all three jobs pass.
+# Reusable gate: every deployment calls this and will not proceed unless all four jobs pass.
# Kept in one file so the API and web deploys cannot drift apart on what "tests passed" means.
on:
workflow_call:
@@ -20,7 +20,81 @@ jobs:
with:
dotnet-version: '10.0.x'
- name: Test
- run: dotnet test WidgetWorks.slnx --configuration Release --nologo
+ run: >
+ dotnet test tests/WidgetWorks.UnitTests/WidgetWorks.UnitTests.csproj
+ --configuration Release --nologo
+ --collect:"XPlat Code Coverage" --settings coverlet.runsettings
+ --results-directory ./TestResults
+ - name: Upload coverage
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: coverage-unit
+ path: TestResults/**/coverage.cobertura.xml
+ retention-days: 1
+
+ integration:
+ name: Repository integration tests (PostgreSQL)
+ runs-on: ubuntu-latest
+
+ # The Dapper repositories are mostly SQL — the atomic stock reservation, the ON CONFLICT
+ # upserts, the cascades. A real server is the only thing that can exercise them, so one runs
+ # here. The suite creates and drops its own throwaway database per run.
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_USER: widgetworks
+ POSTGRES_PASSWORD: replace-me-locally
+ POSTGRES_DB: widgetworks
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U widgetworks"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 10
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Setup .NET
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
+ with:
+ dotnet-version: '10.0.x'
+ - name: Test
+ env:
+ WIDGETWORKS_TEST_DB: Host=localhost;Port=5432;Database=postgres;Username=widgetworks;Password=replace-me-locally
+ run: >
+ dotnet test tests/WidgetWorks.IntegrationTests/WidgetWorks.IntegrationTests.csproj
+ --configuration Release --nologo
+ --collect:"XPlat Code Coverage" --settings coverlet.runsettings
+ --results-directory ./TestResults
+ - name: Upload coverage
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
+ with:
+ name: coverage-integration
+ path: TestResults/**/coverage.cobertura.xml
+ retention-days: 1
+
+ coverage:
+ name: Backend coverage floor
+ runs-on: ubuntu-latest
+ needs: [backend, integration]
+
+ # Neither suite reaches the floor alone — the repositories are only exercised by the
+ # integration tests, the handlers only by the unit tests. The floor applies to the merged
+ # figure, so it has to run after both and combine their reports.
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Download coverage
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+ with:
+ path: TestResults
+ pattern: coverage-*
+ merge-multiple: false
+ - name: Check the floor
+ run: ./scripts/check-coverage.sh ./TestResults 90
frontend:
name: Frontend unit tests (Vitest)
@@ -33,8 +107,10 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install
run: npm ci --no-audit --no-fund
- - name: Test
- run: npm test
+
+ # Thresholds live in vitest.config.ts, so this fails the job if coverage regresses.
+ - name: Test with coverage
+ run: npm run test:coverage
- name: Build (type-check + bundle)
run: npm run build
diff --git a/WidgetWorks.slnx b/WidgetWorks.slnx
index 4c3964b..ac0db9c 100644
--- a/WidgetWorks.slnx
+++ b/WidgetWorks.slnx
@@ -7,5 +7,6 @@
+
diff --git a/coverlet.runsettings b/coverlet.runsettings
new file mode 100644
index 0000000..7ed8acb
--- /dev/null
+++ b/coverlet.runsettings
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+ cobertura
+
+
+ [*]*.DependencyInjection,[WidgetWorks.WebApi]Program
+
+ Obsolete,GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute
+ false
+ false
+ false
+
+
+
+
+
diff --git a/docs/handbook/01-overview.md b/docs/handbook/01-overview.md
index 41b1aca..aaeabca 100644
--- a/docs/handbook/01-overview.md
+++ b/docs/handbook/01-overview.md
@@ -38,9 +38,10 @@ parts most demos skip, on clean, testable, time-abstracted code.
| Auth | JWT (short-lived access + rotating refresh), per-user **security stamp**, `kid` key rotation, **TOTP 2FA** (Otp.NET), **Google OIDC** |
| Time | `TimeProvider` everywhere for deterministic, testable time |
| Payments | `IPaymentGateway` — Mock (default) + Stripe test mode |
-| Web | **React 18 + TypeScript** (Vite 8) SPA, **Vitest** unit tests |
+| Web | **React 18 + TypeScript** (Vite 8) SPA; **Vitest + Testing Library** |
| Run | **Docker Compose** (db + api + web + **Mailpit** mail catcher) |
| CI | GitHub Actions — gitleaks, build (warnings-as-errors) + tests, CodeQL, Dependabot, web build |
+| Tests | 463 across four layers — backend unit, PostgreSQL integration, frontend component, end-to-end smoke. **95.5% backend / 89.5% frontend** lines, floors enforced in CI |
| CD | Path-scoped deploys (API and web move independently; docs move nothing), each gated on the **whole** test suite |
| Hosting | Azure **App Service F1** (API) + **Static Web Apps** (SPA) + **Key Vault** via managed identity, Postgres on **Neon** — all free tiers ([ch.10](10-deploy-azure-free.md)) |
@@ -54,6 +55,7 @@ src/
WidgetWorks.WebApi Minimal API endpoints, DI, auth wiring
tests/
WidgetWorks.UnitTests xUnit tests with in-memory fakes + FakeTimeProvider
+ WidgetWorks.IntegrationTests repository tests against a real PostgreSQL
web/ React + TypeScript SPA (Vite)
infra/ Provision.ps1 — idempotent Azure provisioning
scripts/ smoke-test.ps1, deploy helpers, tooling
diff --git a/docs/handbook/02-architecture.md b/docs/handbook/02-architecture.md
index 178c078..bfd62b6 100644
--- a/docs/handbook/02-architecture.md
+++ b/docs/handbook/02-architecture.md
@@ -62,6 +62,15 @@ host, and infrastructure choices (DB, payment provider, email) are swappable beh
- **`kid` key rotation** — a signing-key ring signs with the active key and still validates
tokens signed by previous, non-revoked keys; unknown/revoked `kid` → rejected.
- **2FA** — TOTP (authenticator app) with single-use, hashed recovery codes.
+- **The order owns its fulfilment rules.** `OrderStatus.AllowedNext`/`CanTransition` hold the
+ transition table and `Order.TransitionTo` applies it, so the invariant travels with the
+ entity instead of living in whichever handler happens to call it. `UpdateOrderStatusHandler`
+ asks permission first and reports a refusal as a `Result` — a rejected transition is an
+ expected outcome at an API boundary, not an exception.
+- **One pricer, two callers.** `OrderPricer` is the single calculation behind both
+ `POST /checkout/quote` and checkout itself, so the total a shopper is shown and the total
+ they are charged cannot drift apart. `OrderDraft` builds the order row, leaving
+ `CheckoutHandler` sequencing steps rather than performing them.
- **RBAC** — policy-based: `ManageCatalog` (Manager or Administrator) guards catalog/orders;
`ManageUsers` and `DeleteCatalog` are Administrator-only. Removing a widget is deliberately
narrower than editing one: a Manager can create, edit, restock and hide, but not retire.
diff --git a/docs/handbook/07-testing.md b/docs/handbook/07-testing.md
index 477b471..f56d258 100644
--- a/docs/handbook/07-testing.md
+++ b/docs/handbook/07-testing.md
@@ -2,9 +2,19 @@
# 7. Testing & the smoke test
-Three layers: fast **backend unit tests** (logic, no I/O), **frontend unit tests**, and an
-**end-to-end smoke test** (the running API over HTTP). All three are the gate: no deployment
-runs unless every one of them passes.
+Four layers, and all four are the gate — no deployment runs unless every one passes:
+
+| Layer | What it proves | Needs |
+|---|---|---|
+| **Backend unit** (xUnit) | handler and domain logic | nothing |
+| **Repository integration** (xUnit) | the SQL: reservations, constraints, cascades | PostgreSQL |
+| **Frontend unit** (Vitest + Testing Library) | components render and behave | jsdom |
+| **Smoke test** (PowerShell) | the running API over HTTP, end to end | Docker |
+
+**Coverage: 95.5% backend (merged), 86% frontend statements / 89.5% lines.** Floors are
+enforced in CI — 90% backend, and thresholds in `vitest.config.ts` — so a regression fails
+the build. They are floors, not targets: they catch a slide, they are not an invitation to
+write tests that move a number.
## Backend unit tests
@@ -36,6 +46,8 @@ CI runs `dotnet build -warnaserror` then `dotnet test` on every code change (see
`web/**/*.test.ts` (Vitest) cover the logic that isn't worth a browser:
+**Logic**
+
- **`api/client.test.ts`** — the token-refresh contract. The important case is the
regression test for bug #12: fire several concurrent requests that all get a `401`, and
assert the client issues **exactly one** refresh. Refresh tokens rotate, so a second
@@ -43,14 +55,67 @@ CI runs `dotnet build -warnaserror` then `dotnet test` on every code change (see
the single-flight guard is ever removed.
- **`lib/catalog.test.ts`** — catalog filtering/sorting behaviour.
+**Components** (Testing Library, jsdom) — the screens where a silent break costs the most:
+
+- **`ProtectedRoute`** — every combination of signed-in / staff-route / role, including the
+ half-written session (refresh token, no role) that must not open an admin screen.
+- **`AdminWidgetsPage`** — nothing is sent before the delete confirmation, cancelling sends
+ nothing at all, and a Manager is never shown the control.
+- **`CheckoutPage`** — totals come from the server and are re-fetched when the state or
+ shipping method changes; the selected payment method is the token actually submitted; a
+ decline leaves the shopper on the page with the reason.
+- **`LoginPage`** — the 2FA branch stores no session until the code is verified, and a guest
+ cart merges on the way in without a merge failure undoing an accepted sign-in.
+- **`Layout`**, **`CartPage`**, **`AdminOrderPage`**, the storefront and account pages.
+
Run them:
```bash
cd web && npm test
```
+```bash
+cd web && npm run test:coverage
+```
+
`npm run build` (tsc + Vite) runs alongside them in CI, so a type error fails the same gate.
+> **jsdom does not implement `