Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
84 changes: 80 additions & 4 deletions .github/workflows/test-suite.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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)
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions WidgetWorks.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@
</Folder>
<Folder Name="/tests/">
<Project Path="tests/WidgetWorks.UnitTests/WidgetWorks.UnitTests.csproj" />
<Project Path="tests/WidgetWorks.IntegrationTests/WidgetWorks.IntegrationTests.csproj" />
</Folder>
</Solution>
33 changes: 33 additions & 0 deletions coverlet.runsettings
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat code coverage">
<Configuration>
<Format>cobertura</Format>

<!--
Excluded on purpose, with a reason each:

*.DependencyInjection / Program — composition roots. They are one long list of
registrations; a test that asserts "AddScoped was called" restates the code rather
than checking behaviour, and the smoke test already proves the container resolves.

Migrations/*.sql runner internals are covered by the integration suite; the generated
and attribute-marked members below are compiler artifacts.
-->
<Exclude>[*]*.DependencyInjection,[WidgetWorks.WebApi]Program</Exclude>
<!--
CompilerGeneratedAttribute is deliberately NOT listed: every async method compiles to a
state machine carrying it, so excluding it silently drops almost the whole codebase from
measurement and reports a number that means nothing.
-->
<ExcludeByAttribute>Obsolete,GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
<SingleHit>false</SingleHit>
<UseSourceLink>false</UseSourceLink>
<IncludeTestAssembly>false</IncludeTestAssembly>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>
4 changes: 3 additions & 1 deletion docs/handbook/01-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)) |

Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions docs/handbook/02-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
95 changes: 88 additions & 7 deletions docs/handbook/07-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -36,21 +46,76 @@ 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
concurrent refresh replays a dead token and signs the user out — the test fails loudly if
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 `<dialog>`.** `showModal`/`close` are absent, so any component
> built on the native modal throws on mount. `src/test/setup.ts` supplies minimal versions.

## Repository integration tests

`tests/WidgetWorks.IntegrationTests` runs the Dapper repositories against a **real
PostgreSQL**. This layer exists because the repositories are mostly SQL, and an in-memory
fake would only prove the fake works:

- **Stock reservation.** Ten concurrent buyers, two units each, ten in stock — exactly five
may win. Overselling is prevented by a conditional `UPDATE` inside a transaction, and
nothing short of concurrent connections against a real server demonstrates that.
- **Transactional integrity** — a refused reservation rolls the order row back with it.
- **Constraints and indexes** — SKU uniqueness folded through `upper()`, the `ON CONFLICT`
cart upsert, cascading deletes.
- **Idempotent startup** — migrations journaled, and a seeder that can run on every boot
without duplicating an account or resetting a password someone changed.

It creates and drops a **throwaway database per run**, migrated by the same DbUp scripts the
app runs at startup, so it never touches developer or demo data. Point it at any Postgres:

```bash
docker compose up -d db
```

```bash
dotnet test tests/WidgetWorks.IntegrationTests
```

It defaults to the local compose database. Override with `WIDGETWORKS_TEST_DB` (a connection
string to the **`postgres`** maintenance database — the suite creates its own from there).

> **Why not Testcontainers?** It pulls `SSH.NET 2024.2.0`, which carries a known
> high-severity advisory, and this repo builds with NuGet audit as an error. Using the
> Postgres that compose and CI already provide costs one environment variable instead.

## End-to-end smoke test

`scripts/smoke-test.ps1` drives the **running API** over HTTP and checks real responses.
Expand Down Expand Up @@ -100,7 +165,7 @@ Sample:
| **CodeQL** | code changes (public) | security-extended analysis |
| **Web CI** | `web/**` changes | `npm run build` (tsc + Vite) |
| **Smoke test** | code changes (docs ignored) | `docker compose up db api` → wait `/health` → run `smoke-test.ps1` |
| **Test suite** | called by both deploys | all three layers at once — backend units, frontend units + build, and the smoke test |
| **Test suite** | called by both deploys | all four layers plus the coverage floor — see below |
| **Deploy API** | `main`, only for `src/**`, `tests/**`, `Dockerfile.api`, build files | `needs: tests` → publish Release → zip-deploy to App Service |
| **Deploy web** | `main`, only for `web/**` | `needs: tests` → build the SPA → Static Web Apps |

Expand All @@ -110,8 +175,23 @@ smoke workflow can also be run on demand from the Actions tab (`workflow_dispatc

### The deployment gate

`test-suite.yml` is a **reusable** workflow (`on: workflow_call`) with three jobs — backend
units, frontend units, smoke test. Both deploy workflows start with:
`test-suite.yml` is a **reusable** workflow (`on: workflow_call`) with five jobs:

| Job | What it runs |
|---|---|
| `backend` | unit tests + coverage report |
| `integration` | repository tests against a PostgreSQL **service container** |
| `coverage` | `needs: [backend, integration]` — merges both reports, enforces the **90%** floor |
| `frontend` | Vitest with thresholds, then `tsc` + Vite build |
| `smoke` | compose up, wait for `/health`, run `smoke-test.ps1` |

The floor is a separate job because **neither suite reaches it alone**: the repositories are
only exercised by the integration tests and the handlers only by the unit tests. Each uploads
its cobertura report; the floor job merges them by taking the highest hit count per line.
Summing or averaging would understate the real figure, because a line covered by one suite is
missed by the other.

Both deploy workflows start with:

```yaml
jobs:
Expand All @@ -121,7 +201,8 @@ jobs:
needs: tests
```

so a failure in **any** of the three stops the deploy before a single artifact is uploaded.
so a failure in **any** job — including the coverage floor — stops the deploy before a single
artifact is uploaded.
The web deploy runs the API smoke test too, deliberately: a SPA is useless against a broken
API, so it isn't allowed to ship on frontend tests alone.

Expand Down
Loading
Loading