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 ``.** `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. @@ -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 | @@ -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: @@ -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. diff --git a/docs/handbook/08-bugs-and-lessons.md b/docs/handbook/08-bugs-and-lessons.md index e26fa89..3a0686f 100644 --- a/docs/handbook/08-bugs-and-lessons.md +++ b/docs/handbook/08-bugs-and-lessons.md @@ -8,8 +8,10 @@ Two phases shaped the list. Early on the build environment could not install the so **code was authored without a local compiler and CI acted as the compiler** — which makes the discipline below load-bearing rather than optional. Later, with a local toolchain and a real deployment, the failures shifted: shells mangling arguments, a platform restarting a -crashing container, an identity provider presenting a subject nobody documented. Rows 1–11 -are from the first phase, 12–32 from the second. +crashing container, an identity provider presenting a subject nobody documented. A third pass +went after test coverage, and turned up a different class again: global state that only +misbehaves outside the DI container, and two gates that reported success while doing nothing. +Rows 1–11 are from the first phase, 12–32 from the second, 33–39 from the third. ## Bugs @@ -47,6 +49,13 @@ are from the first phase, 12–32 from the second. | 30 | Azure OIDC login failed with a valid federated credential | deploy workflow | GitHub presented an **immutable** subject (`repo:owner@id/repo@id:environment:production`), not the documented `repo:owner/repo:environment:name` form | Add federated credentials matching the subject actually presented | Read the subject from the failing token/log rather than from the docs | | 31 | A pinned action didn't exist | deploy workflow, immediately | I pinned `Azure/static-web-apps-deploy` to a SHA I had invented | Verify every pin against the GitHub API | A SHA pin is only safe if the SHA is real — resolve it, don't recall it | | 32 | Branch protection could never be satisfied | enabling required checks | The rule required a check named `ci.yml`, which no job publishes — and requiring **path-filtered** workflows deadlocks docs-only PRs (they never run, so they never report) | Require only `Secret scan (gitleaks)`, the one check that runs unconditionally | A required check must be one that runs on **every** PR | +| 33 | A repository read returned `null` for `tracking_number` and `order_number` while `status` and `email` were fine | writing the first integration test | Dapper's snake_case→PascalCase mapping is **global process state**, set inline inside `AddInfrastructure`. Anything constructing a repository outside the DI container never turned it on, so only single-word columns mapped | `DapperConfiguration.Apply()` — explicit, idempotent, called by both the container and the test fixture | Global mutable configuration is a hidden dependency; give it a name and call it, don't bury it in a registration method | +| 34 | Coverage collapsed to 39% the moment a runsettings file was added, and `OrderRepository` reported 2 tracked lines | the number moved the wrong way after a change that should not have moved it | `CompilerGeneratedAttribute` was in `ExcludeByAttribute`. Every `async` method compiles to a state machine carrying that attribute, so the exclusion silently removed almost the whole codebase from measurement | Drop it; keep only `GeneratedCode`, `Obsolete` and `ExcludeFromCodeCoverage`, with a comment saying why | Treat a coverage jump as suspicious in **both** directions — a number that improves for an unexplained reason is telling you the measurement broke | +| 35 | The coverage gate passed at a 99% floor on a 95% codebase | testing the gate's failure path, not its success path | `command -v python3` resolves on Windows to an **App Execution Alias** that prints an advert for the Store and exits 0, so the script's body never ran and the gate always "passed" | Execute each candidate interpreter before accepting it | A gate that has never been seen to fail is not known to work; test the red path first | +| 36 | `dotnet restore` failed the moment a test dependency was added | CI-equivalent restore locally | Testcontainers pulls `SSH.NET 2024.2.0`, which carries a known high-severity advisory, and the repo builds with NuGet audit as an error | Use the PostgreSQL that compose and CI already provide, via a connection-string env var | The audit gate works; when it fires on a *convenience* dependency, take the plainer route rather than weakening the gate | +| 37 | A challenge-token test failed against a correct implementation | writing tests around `ValidateChallengeTokenAsync` | Issuance uses the injected `TimeProvider` but `TokenValidationParameters` has no such hook, so lifetime is validated against the **system** clock. A token minted at a fixed past date is born expired | Anchor those tests on real time and move the *issuing* clock to express age | "Inject time everywhere" holds only as far as the libraries let it; find the seams that don't take your clock | +| 38 | An integration test asserted a uniqueness rule the schema does not have | the test failed | `ix_widgets_live_name` is a plain index for ordering the live set, not a unique one. Only SKU is unique (case-folded via `upper(sku)`) | Assert the rule that exists, and add a test documenting that names are deliberately **not** unique | Read the migration, not your memory of it — a test that asserts an imagined constraint fails honestly, but the same assumption in code would not | +| 39 | New frontend tests passed but `tsc` failed the build | running `npm run build`, not just `npm test` | `.at(-1)` again — the **same ES2022-against-ES2020 trap as row 23** — plus a `let x = null` only assigned inside a Promise executor, which TypeScript narrows to `never` | Index arithmetic, and `let release!: () => void` | Vitest transpiles without type-checking, so a green test run says nothing about the build. Run the gate CI runs | ## Lessons learned @@ -98,3 +107,16 @@ are from the first phase, 12–32 from the second. deadlocks any PR that doesn't touch those paths: it never runs, so it never reports. - **Don't trim data a projection depends on.** Skipping the item rows made the query cheaper and every order display `0 items`. Read the mapper before optimizing the query. +- **A measurement that improves for no reason is broken.** Coverage jumping the wrong way + after a settings change, and a floor "passing" when set above the actual figure, were both + the instrument failing rather than the code improving. Test a gate's red path before + trusting its green one. +- **Global mutable configuration is a hidden dependency.** Dapper's column mapping worked + perfectly through the container and silently mis-mapped everything outside it. Naming it + and calling it explicitly turned an invisible coupling into a one-line requirement. +- **A fake that lies is worse than no fake.** A no-op `TouchAsync` let handlers forget to + stamp the cart and still pass. Fakes have to model the behaviour they stand in for, or the + suite is decorative. +- **Some invariants only exist in the database.** Atomic stock reservation cannot be + demonstrated by any in-memory double; it needs concurrent connections to a real server. + Where the rule lives decides what kind of test can prove it. diff --git a/docs/handbook/README.md b/docs/handbook/README.md index 81078ac..5206bd5 100644 --- a/docs/handbook/README.md +++ b/docs/handbook/README.md @@ -15,8 +15,8 @@ payments (sync + async/webhook), transactional email, and an order lifecycle. 4. [Configuration, secrets, email & 2FA](04-configuration-and-2fa.md) — what keys go where/how/why; email + Google setup; how to set up 2FA. 5. [Payments, tax & testing credit cards](05-payments.md) — how the total is built (shipping + per-state sales tax, worked examples, the rate table), Mock + Stripe test mode, async/webhooks, testing without charging a card, going live. 6. [Database & schema](06-database.md) — why PostgreSQL, migrations, tables and relationships. -7. [Testing & smoke test](07-testing.md) — unit tests, CI gates, and how to run the end-to-end smoke test. -8. [Bugs & lessons learned](08-bugs-and-lessons.md) — 32 real bugs, how each was found, fixed, and prevented — from CI-as-compiler through deployment. +7. [Testing & smoke test](07-testing.md) — four layers (backend unit, PostgreSQL integration, frontend component, end-to-end smoke), the coverage floors CI enforces, and how to run each locally. +8. [Bugs & lessons learned](08-bugs-and-lessons.md) — 39 real bugs, how each was found, fixed, and prevented — from CI-as-compiler through deployment and test coverage. 9. [Runbook — testing & going live](09-runbook.md) — step-by-step to test email, payments, and Google locally, and exactly what to change to go live. 10. [Deploying to Azure on free tiers](10-deploy-azure-free.md) — the whole stack for $0: F1 App Service, Static Web Apps, Key Vault with a managed identity, and Postgres on Neon. diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh new file mode 100755 index 0000000..c91afa7 --- /dev/null +++ b/scripts/check-coverage.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# Fails the build when line coverage falls below a floor. +# +# A floor, not a target: it exists to catch a regression, not to be gamed to exactly 100%. +# It reads every cobertura report under the given directory and merges them by taking the +# highest hit count per line, because a line can be covered by one suite and missed by +# another — summing or averaging the files would understate the real figure. +# +# ./scripts/check-coverage.sh ./TestResults 80 + +set -euo pipefail + +RESULTS_DIR="${1:-./TestResults}" +FLOOR="${2:-80}" + +if ! find "$RESULTS_DIR" -name 'coverage.cobertura.xml' -print -quit | grep -q .; then + echo "::error::No coverage report found under $RESULTS_DIR — did the collector run?" + exit 1 +fi + +# python3 on CI runners; plain python on a Windows dev box. Each candidate is executed before +# being accepted: Windows ships a "python3" App Execution Alias that resolves on PATH, prints an +# advert for the Store and exits 0 — silently turning this gate into a no-op that always passes. +PYTHON="" +for candidate in python3 python; do + if command -v "$candidate" >/dev/null 2>&1 && "$candidate" -c "import sys" >/dev/null 2>&1; then + PYTHON="$candidate" + break + fi +done + +if [ -z "$PYTHON" ]; then + echo "::error::a working python is required to summarise coverage" + exit 1 +fi + +"$PYTHON" - "$RESULTS_DIR" "$FLOOR" <<'PY' +import collections +import glob +import os +import sys +import xml.etree.ElementTree as ET + +results_dir, floor = sys.argv[1], float(sys.argv[2]) + +hits = collections.defaultdict(dict) +for report in glob.glob(os.path.join(results_dir, '**', 'coverage.cobertura.xml'), recursive=True): + for cls in ET.parse(report).getroot().iter('class'): + name = cls.get('filename', '').replace('\\', '/') + for line in cls.iter('line'): + number = int(line.get('number')) + hits[name][number] = max(hits[name].get(number, 0), int(line.get('hits'))) + +covered = sum(1 for lines in hits.values() for h in lines.values() if h > 0) +total = sum(len(lines) for lines in hits.values()) +if total == 0: + print('::error::Coverage report contained no lines') + raise SystemExit(1) + +pct = covered / total * 100 +print(f'Line coverage: {pct:.1f}% ({covered}/{total}); floor {floor:.0f}%') + +by_layer = collections.defaultdict(lambda: [0, 0]) +for name, lines in hits.items(): + layer = name.split('/')[0] + by_layer[layer][0] += sum(1 for h in lines.values() if h > 0) + by_layer[layer][1] += len(lines) +for layer, (c, t) in sorted(by_layer.items()): + print(f' {layer:<30} {c / t * 100:5.1f}%') + +if pct < floor: + print(f'::error::Line coverage {pct:.1f}% is below the {floor:.0f}% floor') + raise SystemExit(1) +PY diff --git a/src/WidgetWorks.Application/Abstractions/IPaymentWebhookParser.cs b/src/WidgetWorks.Application/Abstractions/IPaymentWebhookParser.cs index 0832917..1627198 100644 --- a/src/WidgetWorks.Application/Abstractions/IPaymentWebhookParser.cs +++ b/src/WidgetWorks.Application/Abstractions/IPaymentWebhookParser.cs @@ -19,5 +19,11 @@ public interface IPaymentWebhookParser /// Provider key, matched case-insensitively against the gateway Name and the route segment. string Provider { get; } + /// + /// Request header(s) this provider delivers its signature in, tried in order. Kept here rather + /// than in the endpoint so transport code never has to know one provider's header name. + /// + IReadOnlyList SignatureHeaders { get; } + bool TryParse(string payload, string? signatureHeader, out PaymentEvent? evt, out string? error); } diff --git a/src/WidgetWorks.Application/Checkout/PlaceOrder/CheckoutHandler.cs b/src/WidgetWorks.Application/Checkout/PlaceOrder/CheckoutHandler.cs index 36c6b09..ee285b6 100644 --- a/src/WidgetWorks.Application/Checkout/PlaceOrder/CheckoutHandler.cs +++ b/src/WidgetWorks.Application/Checkout/PlaceOrder/CheckoutHandler.cs @@ -1,6 +1,7 @@ using WidgetWorks.Application.Abstractions; using WidgetWorks.Application.Carts; using WidgetWorks.Application.Notifications; +using WidgetWorks.Application.Pricing; using WidgetWorks.Domain.Common; using WidgetWorks.Domain.Orders; @@ -36,8 +37,7 @@ public sealed class CheckoutHandler( ICartRepository carts, IWidgetRepository widgets, IOrderRepository orders, - IShippingCalculator shipping, - ITaxCalculator tax, + OrderPricer pricer, IPaymentGateway payments, IEmailSender email, TimeProvider clock) @@ -75,45 +75,10 @@ public async Task> Handle(CheckoutCommand command, Cancel } // Re-price server-side; never trust client-supplied totals. - var shippingQuote = shipping.Calculate(command.ShippingMethod, view.Subtotal, view.ItemCount); - var taxLine = tax.Calculate(ship.State, view.Subtotal); - var total = view.Subtotal + shippingQuote.Amount + taxLine.Amount; + var priced = pricer.Price(view, ship.State, command.ShippingMethod); var now = clock.GetUtcNow(); - var order = new Order - { - Id = Guid.NewGuid(), - OrderNumber = $"WW-{now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..6].ToUpperInvariant()}", - UserId = command.UserId, - Email = normalizedEmail, - ShipName = ship.Name?.Trim() ?? string.Empty, - ShipLine1 = ship.Line1.Trim(), - ShipLine2 = string.IsNullOrWhiteSpace(ship.Line2) ? null : ship.Line2.Trim(), - ShipCity = ship.City.Trim(), - ShipState = ship.State.Trim().ToUpperInvariant(), - ShipPostalCode = ship.PostalCode.Trim(), - ShipCountry = string.IsNullOrWhiteSpace(ship.Country) ? "US" : ship.Country.Trim().ToUpperInvariant(), - Subtotal = view.Subtotal, - ShippingMethod = shippingQuote.Method, - Shipping = shippingQuote.Amount, - TaxState = taxLine.StateCode, - TaxRate = taxLine.Rate, - Tax = taxLine.Amount, - Total = total, - Status = OrderStatus.Pending, - CreatedAt = now, - UpdatedAt = now, - Items = view.Items.Select(l => new OrderItem - { - Id = Guid.NewGuid(), - WidgetId = l.WidgetId, - Sku = l.Sku, - Name = l.Name, - UnitPrice = l.UnitPrice, - Quantity = l.Quantity, - LineSubtotal = l.LineSubtotal, - }).ToList(), - }; + var order = OrderDraft.Create(view, priced, ship, normalizedEmail, command.UserId, now, Guid.NewGuid(), Guid.NewGuid); var placed = await orders.TryPlaceAsync(order, ct); if (!placed) diff --git a/src/WidgetWorks.Application/Checkout/PlaceOrder/OrderDraft.cs b/src/WidgetWorks.Application/Checkout/PlaceOrder/OrderDraft.cs new file mode 100644 index 0000000..538c162 --- /dev/null +++ b/src/WidgetWorks.Application/Checkout/PlaceOrder/OrderDraft.cs @@ -0,0 +1,65 @@ +using WidgetWorks.Application.Carts; +using WidgetWorks.Application.Pricing; +using WidgetWorks.Domain.Orders; + +namespace WidgetWorks.Application.Checkout.PlaceOrder; + +/// +/// Turns a priced cart and a shipping address into the order that will be persisted. Separated from +/// so the shape of an order — its number format, which fields are +/// trimmed, what gets snapshotted — can change without touching the payment sequence. +/// +public static class OrderDraft +{ + public static Order Create( + CartView cart, + PricedCart priced, + ShippingAddressInput ship, + string email, + Guid? userId, + DateTimeOffset now, + Guid orderId, + Func newItemId) + { + return new Order + { + Id = orderId, + OrderNumber = NumberFor(now, orderId), + UserId = userId, + Email = email, + ShipName = ship.Name?.Trim() ?? string.Empty, + ShipLine1 = ship.Line1.Trim(), + ShipLine2 = string.IsNullOrWhiteSpace(ship.Line2) ? null : ship.Line2.Trim(), + ShipCity = ship.City.Trim(), + ShipState = ship.State.Trim().ToUpperInvariant(), + ShipPostalCode = ship.PostalCode.Trim(), + ShipCountry = string.IsNullOrWhiteSpace(ship.Country) ? "US" : ship.Country.Trim().ToUpperInvariant(), + Subtotal = priced.Subtotal, + ShippingMethod = priced.ShippingMethod, + Shipping = priced.Shipping, + + // Snapshot the tax that was actually charged: a later rate change must not rewrite history. + TaxState = priced.StateCode, + TaxRate = priced.TaxRate, + Tax = priced.Tax, + Total = priced.Total, + Status = OrderStatus.Pending, + CreatedAt = now, + UpdatedAt = now, + Items = cart.Items.Select(l => new OrderItem + { + Id = newItemId(), + WidgetId = l.WidgetId, + Sku = l.Sku, + Name = l.Name, + UnitPrice = l.UnitPrice, + Quantity = l.Quantity, + LineSubtotal = l.LineSubtotal, + }).ToList(), + }; + } + + /// Human-quotable order number: WW-{date}-{6 chars}, e.g. WW-20260501-A1B2C3. + public static string NumberFor(DateTimeOffset now, Guid orderId) + => $"WW-{now:yyyyMMdd}-{orderId.ToString("N")[..6].ToUpperInvariant()}"; +} diff --git a/src/WidgetWorks.Application/Checkout/Quote/QuoteCartHandler.cs b/src/WidgetWorks.Application/Checkout/Quote/QuoteCartHandler.cs index cde3aaf..d7edd2f 100644 --- a/src/WidgetWorks.Application/Checkout/Quote/QuoteCartHandler.cs +++ b/src/WidgetWorks.Application/Checkout/Quote/QuoteCartHandler.cs @@ -1,5 +1,6 @@ using WidgetWorks.Application.Abstractions; using WidgetWorks.Application.Carts; +using WidgetWorks.Application.Pricing; using WidgetWorks.Domain.Common; namespace WidgetWorks.Application.Checkout.Quote; @@ -21,8 +22,7 @@ public sealed record OrderQuoteView( public sealed class QuoteCartHandler( ICartRepository carts, IWidgetRepository widgets, - IShippingCalculator shipping, - ITaxCalculator tax) + OrderPricer pricer) { public async Task> Handle(QuoteCartCommand command, CancellationToken ct) { @@ -33,21 +33,17 @@ public async Task> Handle(QuoteCartCommand command, Cance } var view = await CartAssembler.BuildAsync(cart, widgets, ct); - var ship = shipping.Calculate(command.ShippingMethod, view.Subtotal, view.ItemCount); - var shippingAmount = view.ItemCount == 0 ? 0m : ship.Amount; - var taxLine = tax.Calculate(command.StateCode, view.Subtotal); - var total = view.Subtotal + shippingAmount + taxLine.Amount; + var priced = pricer.Price(view, command.StateCode, command.ShippingMethod); - var quote = new OrderQuoteView( - view.Subtotal, - ship.Method, - shippingAmount, - taxLine.StateCode, - taxLine.Rate, - taxLine.Amount, - total, - view.ItemCount, - view.ItemCount == 0); - return Result.Success(quote); + return Result.Success(new OrderQuoteView( + priced.Subtotal, + priced.ShippingMethod, + priced.Shipping, + priced.StateCode, + priced.TaxRate, + priced.Tax, + priced.Total, + priced.ItemCount, + priced.IsEmpty)); } } diff --git a/src/WidgetWorks.Application/DependencyInjection.cs b/src/WidgetWorks.Application/DependencyInjection.cs index b11eef3..387aae8 100644 --- a/src/WidgetWorks.Application/DependencyInjection.cs +++ b/src/WidgetWorks.Application/DependencyInjection.cs @@ -5,6 +5,7 @@ using WidgetWorks.Application.Auth.PasswordReset; using WidgetWorks.Application.Auth.Refresh; using WidgetWorks.Application.Auth.Register; +using WidgetWorks.Application.Pricing; using WidgetWorks.Application.Carts.AddItem; using WidgetWorks.Application.Carts.GetCart; using WidgetWorks.Application.Carts.Merge; @@ -39,6 +40,7 @@ public static class DependencyInjection /// Registers the application layer (use-case handlers). No MediatR — plain handlers. public static IServiceCollection AddApplication(this IServiceCollection services) { + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/WidgetWorks.Application/Orders/OrderView.cs b/src/WidgetWorks.Application/Orders/OrderView.cs index 1fc66c8..31f57c1 100644 --- a/src/WidgetWorks.Application/Orders/OrderView.cs +++ b/src/WidgetWorks.Application/Orders/OrderView.cs @@ -43,5 +43,5 @@ public sealed record OrderView( public sealed record OrderSummary(Guid Id, string OrderNumber, string Status, decimal Total, int ItemCount, DateTimeOffset CreatedAt) { - public static OrderSummary From(Order o) => new(o.Id, o.OrderNumber, o.Status, o.Total, o.Items.Sum(i => i.Quantity), o.CreatedAt); + public static OrderSummary From(Order o) => new(o.Id, o.OrderNumber, o.Status, o.Total, o.UnitCount, o.CreatedAt); } diff --git a/src/WidgetWorks.Application/Orders/UpdateStatus/UpdateOrderStatusHandler.cs b/src/WidgetWorks.Application/Orders/UpdateStatus/UpdateOrderStatusHandler.cs index e41bc63..b737c74 100644 --- a/src/WidgetWorks.Application/Orders/UpdateStatus/UpdateOrderStatusHandler.cs +++ b/src/WidgetWorks.Application/Orders/UpdateStatus/UpdateOrderStatusHandler.cs @@ -7,15 +7,13 @@ namespace WidgetWorks.Application.Orders.UpdateStatus; public sealed record UpdateOrderStatusCommand(Guid OrderId, string Status, string? TrackingNumber); +/// +/// Drives fulfilment. The legal transitions belong to the order itself (see +/// ); this handler asks permission, persists what the entity +/// decided, and notifies the customer. +/// public sealed class UpdateOrderStatusHandler(IOrderRepository orders, IEmailSender email, TimeProvider clock) { - // Allowed forward transitions. Anything not listed is rejected. - private static readonly IReadOnlyDictionary Allowed = new Dictionary - { - [OrderStatus.Paid] = [OrderStatus.Shipped, OrderStatus.Cancelled], - [OrderStatus.Shipped] = [OrderStatus.Delivered], - }; - public async Task> Handle(UpdateOrderStatusCommand command, CancellationToken ct) { var order = await orders.GetByIdAsync(command.OrderId, ct); @@ -25,17 +23,15 @@ public async Task> Handle(UpdateOrderStatusCommand command, Ca } var target = (command.Status ?? string.Empty).Trim(); - if (!Allowed.TryGetValue(order.Status, out var next) || Array.IndexOf(next, target) < 0) + if (!order.CanTransitionTo(target)) { + // Asked, not caught: a refused transition is an expected outcome here, not an exception. return Result.Fail($"Cannot change status from {order.Status} to '{target}'."); } var now = clock.GetUtcNow(); - var tracking = string.IsNullOrWhiteSpace(command.TrackingNumber) ? order.TrackingNumber : command.TrackingNumber.Trim(); - await orders.UpdateStatusAsync(order.Id, target, tracking, now, ct); - order.Status = target; - order.TrackingNumber = tracking; - order.UpdatedAt = now; + order.TransitionTo(target, command.TrackingNumber, now); + await orders.UpdateStatusAsync(order.Id, order.Status, order.TrackingNumber, now, ct); try { diff --git a/src/WidgetWorks.Application/Pricing/OrderPricer.cs b/src/WidgetWorks.Application/Pricing/OrderPricer.cs new file mode 100644 index 0000000..4f48f26 --- /dev/null +++ b/src/WidgetWorks.Application/Pricing/OrderPricer.cs @@ -0,0 +1,45 @@ +using WidgetWorks.Application.Abstractions; +using WidgetWorks.Application.Carts; + +namespace WidgetWorks.Application.Pricing; + +/// A fully priced cart: the same numbers whether they are being previewed or charged. +public sealed record PricedCart( + decimal Subtotal, + string ShippingMethod, + decimal Shipping, + string StateCode, + decimal TaxRate, + decimal Tax, + decimal Total, + int ItemCount) +{ + public bool IsEmpty => ItemCount == 0; +} + +/// +/// The single place a cart turns into money. Quoting and checkout both go through it, so the total +/// a shopper is shown and the total they are charged are the same calculation rather than two +/// implementations that happen to agree today. +/// +public sealed class OrderPricer(IShippingCalculator shipping, ITaxCalculator tax) +{ + public PricedCart Price(CartView cart, string? stateCode, string? shippingMethod) + { + var quote = shipping.Calculate(shippingMethod, cart.Subtotal, cart.ItemCount); + + // Nothing in the basket, nothing to deliver -- don't quote a delivery charge on it. + var shippingAmount = cart.ItemCount == 0 ? 0m : quote.Amount; + var taxLine = tax.Calculate(stateCode, cart.Subtotal); + + return new PricedCart( + cart.Subtotal, + quote.Method, + shippingAmount, + taxLine.StateCode, + taxLine.Rate, + taxLine.Amount, + cart.Subtotal + shippingAmount + taxLine.Amount, + cart.ItemCount); + } +} diff --git a/src/WidgetWorks.Domain/Orders/Order.cs b/src/WidgetWorks.Domain/Orders/Order.cs index 94630ef..b48a574 100644 --- a/src/WidgetWorks.Domain/Orders/Order.cs +++ b/src/WidgetWorks.Domain/Orders/Order.cs @@ -15,6 +15,25 @@ public static class OrderStatus public const string Shipped = "Shipped"; public const string Delivered = "Delivered"; public const string Cancelled = "Cancelled"; + + /// + /// The fulfilment state machine. Only a settled (Paid) order can ship or be cancelled, and only + /// a shipped one can be delivered -- so an order still awaiting payment can never be dispatched. + /// Everything absent from this table is a forbidden transition, including any move out of a + /// terminal state. + /// + private static readonly Dictionary Transitions = new(StringComparer.Ordinal) + { + [Paid] = [Shipped, Cancelled], + [Shipped] = [Delivered], + }; + + /// The statuses an order in may legally move to. + public static IReadOnlyList AllowedNext(string? from) + => from is not null && Transitions.TryGetValue(from, out var next) ? next : []; + + public static bool CanTransition(string? from, string? to) + => to is not null && AllowedNext(from).Contains(to, StringComparer.Ordinal); } /// A placed order with a shipping address, computed totals, payment result, and line items. @@ -70,6 +89,28 @@ public sealed class Order public DateTimeOffset UpdatedAt { get; set; } public List Items { get; set; } = []; + + /// Number of units on the order (quantities summed, not lines counted). + public int UnitCount => Items.Sum(i => i.Quantity); + + public bool CanTransitionTo(string? target) => OrderStatus.CanTransition(Status, target); + + /// + /// Applies a fulfilment transition, keeping the rule with the data it guards. A blank tracking + /// number leaves the existing one alone rather than erasing it. Callers that want to report a + /// refusal rather than throw should ask first. + /// + public void TransitionTo(string target, string? trackingNumber, DateTimeOffset now) + { + if (!CanTransitionTo(target)) + { + throw new InvalidOperationException($"Cannot change status from {Status} to '{target}'."); + } + + Status = target; + TrackingNumber = string.IsNullOrWhiteSpace(trackingNumber) ? TrackingNumber : trackingNumber.Trim(); + UpdatedAt = now; + } } public sealed class OrderItem diff --git a/src/WidgetWorks.Infrastructure/DependencyInjection.cs b/src/WidgetWorks.Infrastructure/DependencyInjection.cs index 94b37ab..77bde8f 100644 --- a/src/WidgetWorks.Infrastructure/DependencyInjection.cs +++ b/src/WidgetWorks.Infrastructure/DependencyInjection.cs @@ -1,4 +1,3 @@ -using Dapper; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -21,7 +20,7 @@ public static class DependencyInjection public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) { // Dapper maps snake_case columns to PascalCase properties. - DefaultTypeMap.MatchNamesWithUnderscores = true; + DapperConfiguration.Apply(); // Deterministic, testable time everywhere — never DateTime.Now. services.AddSingleton(TimeProvider.System); diff --git a/src/WidgetWorks.Infrastructure/Email/SmtpEmailSender.cs b/src/WidgetWorks.Infrastructure/Email/SmtpEmailSender.cs index 5576ee2..0356eab 100644 --- a/src/WidgetWorks.Infrastructure/Email/SmtpEmailSender.cs +++ b/src/WidgetWorks.Infrastructure/Email/SmtpEmailSender.cs @@ -15,24 +15,7 @@ public sealed class SmtpEmailSender(EmailOptions options) : IEmailSender { public async Task SendAsync(EmailMessage message, CancellationToken ct) { - using var mail = new MailMessage - { - From = new MailAddress(options.FromAddress, options.FromName), - Subject = message.Subject, - SubjectEncoding = Encoding.UTF8, - - // The plain-text version IS the body, and the HTML version rides alongside as the - // preferred alternative — the canonical multipart/alternative shape. Leaving Body - // empty and adding BOTH text and HTML as alternate views produced a message whose - // HTML part mail clients rendered as blank. - Body = message.TextBody, - BodyEncoding = Encoding.UTF8, - IsBodyHtml = false, - }; - - mail.To.Add(message.To); - mail.AlternateViews.Add( - AlternateView.CreateAlternateViewFromString(message.HtmlBody, Encoding.UTF8, MediaTypeNames.Text.Html)); + using var mail = BuildMailMessage(options, message); using var client = new SmtpClient(options.Host, options.Port) { @@ -54,4 +37,33 @@ public async Task SendAsync(EmailMessage message, CancellationToken ct) throw; } } + + /// + /// Builds the MIME message. Separated from delivery so its shape can be asserted without an SMTP + /// server — this is where the bugs actually were: an HTML part that rendered blank, and headers + /// that mangled non-ASCII subjects. + /// + public static MailMessage BuildMailMessage(EmailOptions options, EmailMessage message) + { + var mail = new MailMessage + { + From = new MailAddress(options.FromAddress, options.FromName), + Subject = message.Subject, + SubjectEncoding = Encoding.UTF8, + + // The plain-text version IS the body, and the HTML version rides alongside as the + // preferred alternative — the canonical multipart/alternative shape. Leaving Body + // empty and adding BOTH text and HTML as alternate views produced a message whose + // HTML part mail clients rendered as blank. + Body = message.TextBody, + BodyEncoding = Encoding.UTF8, + IsBodyHtml = false, + }; + + mail.To.Add(message.To); + mail.AlternateViews.Add( + AlternateView.CreateAlternateViewFromString(message.HtmlBody, Encoding.UTF8, MediaTypeNames.Text.Html)); + + return mail; + } } diff --git a/src/WidgetWorks.Infrastructure/Payments/MockPaymentWebhookParser.cs b/src/WidgetWorks.Infrastructure/Payments/MockPaymentWebhookParser.cs index b9f335e..c858236 100644 --- a/src/WidgetWorks.Infrastructure/Payments/MockPaymentWebhookParser.cs +++ b/src/WidgetWorks.Infrastructure/Payments/MockPaymentWebhookParser.cs @@ -13,6 +13,8 @@ public sealed class MockPaymentWebhookParser(IOptions option { public string Provider => "Mock"; + public IReadOnlyList SignatureHeaders { get; } = ["X-Webhook-Signature"]; + public bool TryParse(string payload, string? signatureHeader, out PaymentEvent? evt, out string? error) { evt = null; diff --git a/src/WidgetWorks.Infrastructure/Payments/StripePaymentWebhookParser.cs b/src/WidgetWorks.Infrastructure/Payments/StripePaymentWebhookParser.cs index e15f7bc..ff15e33 100644 --- a/src/WidgetWorks.Infrastructure/Payments/StripePaymentWebhookParser.cs +++ b/src/WidgetWorks.Infrastructure/Payments/StripePaymentWebhookParser.cs @@ -15,6 +15,8 @@ public sealed class StripePaymentWebhookParser(IOptions options) { public string Provider => "Stripe"; + public IReadOnlyList SignatureHeaders { get; } = ["Stripe-Signature"]; + public bool TryParse(string payload, string? signatureHeader, out PaymentEvent? evt, out string? error) { evt = null; diff --git a/src/WidgetWorks.Infrastructure/Persistence/DapperConfiguration.cs b/src/WidgetWorks.Infrastructure/Persistence/DapperConfiguration.cs new file mode 100644 index 0000000..07adfc6 --- /dev/null +++ b/src/WidgetWorks.Infrastructure/Persistence/DapperConfiguration.cs @@ -0,0 +1,28 @@ +using Dapper; + +namespace WidgetWorks.Infrastructure.Persistence; + +/// +/// Dapper's column-to-property mapping is global, process-wide state. It used to be set inline in +/// AddInfrastructure, which meant any repository constructed outside the DI container -- a test, a +/// console tool, a migration script -- silently mis-mapped every multi-word column: order_number, +/// tracking_number and quantity_reserved came back as defaults while single-word columns worked, so +/// the failure looked like missing data rather than missing configuration. +/// +/// Applying it here, once and idempotently, makes the requirement explicit and callable. +/// +public static class DapperConfiguration +{ + private static bool _applied; + + public static void Apply() + { + if (_applied) + { + return; + } + + DefaultTypeMap.MatchNamesWithUnderscores = true; + _applied = true; + } +} diff --git a/src/WidgetWorks.Infrastructure/Pricing/StaticStateTaxRateProvider.cs b/src/WidgetWorks.Infrastructure/Pricing/StaticStateTaxRateProvider.cs index d5084fe..6b259f5 100644 --- a/src/WidgetWorks.Infrastructure/Pricing/StaticStateTaxRateProvider.cs +++ b/src/WidgetWorks.Infrastructure/Pricing/StaticStateTaxRateProvider.cs @@ -18,16 +18,56 @@ public sealed class StaticStateTaxRateProvider : ITaxRateProvider // Base state sales-tax rates (approximate, state-level only) as decimal fractions. private static readonly IReadOnlyDictionary Rates = new Dictionary { - ["AL"] = 0.0400m, ["AK"] = 0.0000m, ["AZ"] = 0.0560m, ["AR"] = 0.0650m, ["CA"] = 0.0725m, - ["CO"] = 0.0290m, ["CT"] = 0.0635m, ["DE"] = 0.0000m, ["DC"] = 0.0600m, ["FL"] = 0.0600m, - ["GA"] = 0.0400m, ["HI"] = 0.0400m, ["ID"] = 0.0600m, ["IL"] = 0.0625m, ["IN"] = 0.0700m, - ["IA"] = 0.0600m, ["KS"] = 0.0650m, ["KY"] = 0.0600m, ["LA"] = 0.0445m, ["ME"] = 0.0550m, - ["MD"] = 0.0600m, ["MA"] = 0.0625m, ["MI"] = 0.0600m, ["MN"] = 0.06875m, ["MS"] = 0.0700m, - ["MO"] = 0.04225m, ["MT"] = 0.0000m, ["NE"] = 0.0550m, ["NV"] = 0.0685m, ["NH"] = 0.0000m, - ["NJ"] = 0.06625m, ["NM"] = 0.04875m, ["NY"] = 0.0400m, ["NC"] = 0.0475m, ["ND"] = 0.0500m, - ["OH"] = 0.0575m, ["OK"] = 0.0450m, ["OR"] = 0.0000m, ["PA"] = 0.0600m, ["RI"] = 0.0700m, - ["SC"] = 0.0600m, ["SD"] = 0.0420m, ["TN"] = 0.0700m, ["TX"] = 0.0625m, ["UT"] = 0.0610m, - ["VT"] = 0.0600m, ["VA"] = 0.0530m, ["WA"] = 0.0650m, ["WV"] = 0.0600m, ["WI"] = 0.0500m, + ["AL"] = 0.0400m, + ["AK"] = 0.0000m, + ["AZ"] = 0.0560m, + ["AR"] = 0.0650m, + ["CA"] = 0.0725m, + ["CO"] = 0.0290m, + ["CT"] = 0.0635m, + ["DE"] = 0.0000m, + ["DC"] = 0.0600m, + ["FL"] = 0.0600m, + ["GA"] = 0.0400m, + ["HI"] = 0.0400m, + ["ID"] = 0.0600m, + ["IL"] = 0.0625m, + ["IN"] = 0.0700m, + ["IA"] = 0.0600m, + ["KS"] = 0.0650m, + ["KY"] = 0.0600m, + ["LA"] = 0.0445m, + ["ME"] = 0.0550m, + ["MD"] = 0.0600m, + ["MA"] = 0.0625m, + ["MI"] = 0.0600m, + ["MN"] = 0.06875m, + ["MS"] = 0.0700m, + ["MO"] = 0.04225m, + ["MT"] = 0.0000m, + ["NE"] = 0.0550m, + ["NV"] = 0.0685m, + ["NH"] = 0.0000m, + ["NJ"] = 0.06625m, + ["NM"] = 0.04875m, + ["NY"] = 0.0400m, + ["NC"] = 0.0475m, + ["ND"] = 0.0500m, + ["OH"] = 0.0575m, + ["OK"] = 0.0450m, + ["OR"] = 0.0000m, + ["PA"] = 0.0600m, + ["RI"] = 0.0700m, + ["SC"] = 0.0600m, + ["SD"] = 0.0420m, + ["TN"] = 0.0700m, + ["TX"] = 0.0625m, + ["UT"] = 0.0610m, + ["VT"] = 0.0600m, + ["VA"] = 0.0530m, + ["WA"] = 0.0650m, + ["WV"] = 0.0600m, + ["WI"] = 0.0500m, ["WY"] = 0.0400m, }; } diff --git a/src/WidgetWorks.WebApi/Payments/PaymentWebhookEndpoints.cs b/src/WidgetWorks.WebApi/Payments/PaymentWebhookEndpoints.cs index 30eefc7..4b503ca 100644 --- a/src/WidgetWorks.WebApi/Payments/PaymentWebhookEndpoints.cs +++ b/src/WidgetWorks.WebApi/Payments/PaymentWebhookEndpoints.cs @@ -29,7 +29,9 @@ public static void MapPaymentWebhookEndpoints(this IEndpointRouteBuilder routes) payload = await reader.ReadToEndAsync(ct); } - var signature = FirstHeader(request, "Stripe-Signature") ?? FirstHeader(request, "X-Webhook-Signature"); + var signature = parser.SignatureHeaders + .Select(name => FirstHeader(request, name)) + .FirstOrDefault(value => value is not null); if (!parser.TryParse(payload, signature, out var evt, out var error) || evt is null) { diff --git a/tests/WidgetWorks.IntegrationTests/CatalogAndAuthRepositoryTests.cs b/tests/WidgetWorks.IntegrationTests/CatalogAndAuthRepositoryTests.cs new file mode 100644 index 0000000..0b84ae6 --- /dev/null +++ b/tests/WidgetWorks.IntegrationTests/CatalogAndAuthRepositoryTests.cs @@ -0,0 +1,536 @@ +using WidgetWorks.Application.Abstractions; +using WidgetWorks.Domain.Auth; +using WidgetWorks.Domain.Catalog; +using WidgetWorks.Domain.Users; +using WidgetWorks.Infrastructure.Persistence; +using Xunit; + +namespace WidgetWorks.IntegrationTests; + +/// +/// Catalog, cart, and auth persistence against real PostgreSQL. These repositories lean on things +/// only a database provides — a case-folded unique index on SKU, ON CONFLICT upserts, partial +/// indexes, and cascading deletes — so a fake would prove nothing about them. +/// +[Collection(PostgresCollection.Name)] +public class CatalogAndAuthRepositoryTests(PostgresFixture db) +{ + private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero); + + private WidgetRepository Widgets => new(db.Connections); + + private CartRepository Carts => new(db.Connections, TimeProvider.System); + + private UserRepository Users => new(db.Connections); + + private static string Unique(string prefix) => prefix + Guid.NewGuid().ToString("N")[..10]; + + private async Task GivenWidget(int onHand = 10, bool active = true, string? name = null) + { + var widget = new Widget + { + Id = Guid.NewGuid(), + Sku = Unique("SKU-").ToUpperInvariant(), + Name = name ?? Unique("Widget "), + Description = "Integration fixture.", + Price = 12.5m, + QuantityOnHand = onHand, + QuantityReserved = 0, + IsActive = active, + CreatedAt = Now, + UpdatedAt = Now, + }; + await Widgets.AddAsync(widget, CancellationToken.None); + return widget; + } + + private async Task GivenUser(string? role = null) + { + var email = Unique("it-") + "@example.com"; + var user = new User + { + Id = Guid.NewGuid(), + Email = email, + NormalizedEmail = email.ToUpperInvariant(), + PasswordHash = "hash", + Role = role ?? UserRoles.Customer, + SecurityStamp = Guid.NewGuid(), + CreatedAt = Now, + }; + await Users.AddAsync(user, CancellationToken.None); + return user; + } + + // ---- widgets ------------------------------------------------------------------------- + + [Fact] + public async Task A_widget_round_trips_every_column() + { + var widget = await GivenWidget(onHand: 7); + + var stored = await Widgets.GetByIdAsync(widget.Id, CancellationToken.None); + + Assert.Equal(widget.Sku, stored!.Sku); + Assert.Equal(widget.Name, stored.Name); + Assert.Equal(12.5m, stored.Price); + Assert.Equal(7, stored.QuantityOnHand); + Assert.Equal(0, stored.QuantityReserved); + Assert.True(stored.IsActive); + } + + [Fact] + public async Task A_widget_can_be_found_by_its_normalized_sku() + { + var widget = await GivenWidget(); + + Assert.NotNull(await Widgets.GetBySkuAsync(widget.Sku.ToUpperInvariant(), CancellationToken.None)); + Assert.Null(await Widgets.GetBySkuAsync("NOT-A-SKU", CancellationToken.None)); + } + + [Fact] + public async Task Updating_a_widget_persists_the_change() + { + var widget = await GivenWidget(); + widget.Price = 99.99m; + widget.QuantityOnHand = 3; + widget.IsActive = false; + + await Widgets.UpdateAsync(widget, CancellationToken.None); + + var stored = await Widgets.GetByIdAsync(widget.Id, CancellationToken.None); + Assert.Equal(99.99m, stored!.Price); + Assert.Equal(3, stored.QuantityOnHand); + Assert.False(stored.IsActive); + } + + [Fact] + public async Task Search_matches_on_name_and_respects_the_active_filter() + { + var token = Guid.NewGuid().ToString("N")[..8]; + await GivenWidget(name: $"Findable {token}"); + await GivenWidget(name: $"Hidden {token}", active: false); + + var all = await Widgets.SearchAsync(new WidgetQuery(token, ActiveOnly: false, 1, 50), CancellationToken.None); + var live = await Widgets.SearchAsync(new WidgetQuery(token, ActiveOnly: true, 1, 50), CancellationToken.None); + + Assert.Equal(2, all.Count); + Assert.Single(live); + Assert.Equal(2, await Widgets.CountAsync(new WidgetQuery(token, false, 1, 50), CancellationToken.None)); + Assert.Equal(1, await Widgets.CountAsync(new WidgetQuery(token, true, 1, 50), CancellationToken.None)); + } + + [Fact] + public async Task Search_pages_through_results() + { + var token = Guid.NewGuid().ToString("N")[..8]; + for (var i = 0; i < 3; i++) + { + await GivenWidget(name: $"Paged {token} {i}"); + } + + var first = await Widgets.SearchAsync(new WidgetQuery(token, true, 1, 2), CancellationToken.None); + var second = await Widgets.SearchAsync(new WidgetQuery(token, true, 2, 2), CancellationToken.None); + + Assert.Equal(2, first.Count); + Assert.Single(second); + Assert.Empty(first.Select(w => w.Id).Intersect(second.Select(w => w.Id))); + } + + [Fact] + public async Task A_widget_that_was_never_ordered_reports_no_order_lines_and_deletes() + { + var widget = await GivenWidget(); + + Assert.Equal(0, await Widgets.CountOrderLinesAsync(widget.Id, CancellationToken.None)); + + await Widgets.DeleteAsync(widget.Id, CancellationToken.None); + + Assert.Null(await Widgets.GetByIdAsync(widget.Id, CancellationToken.None)); + } + + [Fact] + public async Task Two_widgets_cannot_share_a_sku_whatever_the_casing() + { + var widget = await GivenWidget(); + + // ux_widgets_sku is on upper(sku), so the clash is caught however it is typed. This is + // enforced by the index, not by application code, so a direct write cannot dodge it. + var clash = new Widget + { + Id = Guid.NewGuid(), + Sku = widget.Sku.ToLowerInvariant(), + Name = Unique("Other "), + Description = "Should not be accepted.", + Price = 1m, + QuantityOnHand = 1, + IsActive = true, + CreatedAt = Now, + UpdatedAt = Now, + }; + + await Assert.ThrowsAnyAsync(() => Widgets.AddAsync(clash, CancellationToken.None)); + } + + [Fact] + public async Task Widget_names_are_deliberately_not_unique() + { + // ix_widgets_live_name exists for ordering the live set, not to constrain it: two + // widgets may legitimately share a display name while differing by SKU. + var name = Unique("Shared "); + await GivenWidget(name: name); + + var second = await GivenWidget(name: name); + + Assert.Equal(name, (await Widgets.GetByIdAsync(second.Id, CancellationToken.None))!.Name); + } + + // ---- carts --------------------------------------------------------------------------- + + [Fact] + public async Task A_guest_cart_is_created_and_read_back() + { + var cart = await Carts.CreateAsync(null, CancellationToken.None); + + var stored = await Carts.GetAsync(cart.Id, CancellationToken.None); + + Assert.NotNull(stored); + Assert.Null(stored!.UserId); + Assert.Empty(stored.Items); + } + + [Fact] + public async Task Adding_the_same_widget_twice_updates_the_line_rather_than_duplicating_it() + { + var widget = await GivenWidget(); + var cart = await Carts.CreateAsync(null, CancellationToken.None); + + await Carts.UpsertItemAsync(cart.Id, widget.Id, 2, Now, CancellationToken.None); + await Carts.UpsertItemAsync(cart.Id, widget.Id, 5, Now, CancellationToken.None); + + var stored = await Carts.GetAsync(cart.Id, CancellationToken.None); + var item = Assert.Single(stored!.Items); + Assert.Equal(5, item.Quantity); + } + + [Fact] + public async Task A_users_cart_can_be_found_by_the_user() + { + var user = await GivenUser(); + var cart = await Carts.CreateAsync(user.Id, CancellationToken.None); + + var found = await Carts.GetByUserAsync(user.Id, CancellationToken.None); + + Assert.Equal(cart.Id, found!.Id); + Assert.Null(await Carts.GetByUserAsync(Guid.NewGuid(), CancellationToken.None)); + } + + [Fact] + public async Task Removing_an_item_and_touching_the_cart_both_persist() + { + var widget = await GivenWidget(); + var cart = await Carts.CreateAsync(null, CancellationToken.None); + await Carts.UpsertItemAsync(cart.Id, widget.Id, 1, Now, CancellationToken.None); + + await Carts.RemoveItemAsync(cart.Id, widget.Id, CancellationToken.None); + await Carts.TouchAsync(cart.Id, Now.AddHours(2), CancellationToken.None); + + var stored = await Carts.GetAsync(cart.Id, CancellationToken.None); + Assert.Empty(stored!.Items); + Assert.Equal(Now.AddHours(2), stored.UpdatedAt); + } + + [Fact] + public async Task Deleting_a_cart_takes_its_items_with_it() + { + var widget = await GivenWidget(); + var cart = await Carts.CreateAsync(null, CancellationToken.None); + await Carts.UpsertItemAsync(cart.Id, widget.Id, 1, Now, CancellationToken.None); + + await Carts.DeleteAsync(cart.Id, CancellationToken.None); + + // Relies on the cascade; an orphaned cart_items row would violate the schema. + Assert.Null(await Carts.GetAsync(cart.Id, CancellationToken.None)); + } + + // ---- users --------------------------------------------------------------------------- + + [Fact] + public async Task A_user_is_found_by_normalized_email_regardless_of_typed_case() + { + var user = await GivenUser(); + + var found = await Users.GetByNormalizedEmailAsync(user.Email.ToUpperInvariant(), CancellationToken.None); + + Assert.Equal(user.Id, found!.Id); + Assert.Null(await Users.GetByNormalizedEmailAsync("NOBODY@EXAMPLE.COM", CancellationToken.None)); + } + + [Fact] + public async Task A_google_user_is_found_by_subject() + { + var user = await GivenUser(); + user.GoogleSub = Unique("google-sub-"); + await Users.UpdateAsync(user, CancellationToken.None); + + var found = await Users.GetByGoogleSubAsync(user.GoogleSub, CancellationToken.None); + + Assert.Equal(user.Id, found!.Id); + Assert.Null(await Users.GetByGoogleSubAsync("not-a-subject", CancellationToken.None)); + } + + [Fact] + public async Task Lockout_state_and_the_security_stamp_persist() + { + var user = await GivenUser(); + var rotated = Guid.NewGuid(); + user.FailedAccessCount = 4; + user.LockedUntil = Now.AddMinutes(15); + user.SecurityStamp = rotated; + + await Users.UpdateAsync(user, CancellationToken.None); + + var stored = await Users.GetByIdAsync(user.Id, CancellationToken.None); + Assert.Equal(4, stored!.FailedAccessCount); + Assert.Equal(Now.AddMinutes(15), stored.LockedUntil); + Assert.True(stored.IsLockedOut(Now)); + + // The stamp is read on every request, so it has its own narrow query. + Assert.Equal(rotated, await Users.GetSecurityStampAsync(user.Id, CancellationToken.None)); + Assert.Null(await Users.GetSecurityStampAsync(Guid.NewGuid(), CancellationToken.None)); + } + + [Fact] + public async Task Two_users_cannot_share_an_email() + { + var user = await GivenUser(); + + await Assert.ThrowsAnyAsync(() => Users.AddAsync( + new User + { + Id = Guid.NewGuid(), + Email = user.Email, + NormalizedEmail = user.NormalizedEmail, + PasswordHash = "hash", + Role = UserRoles.Customer, + SecurityStamp = Guid.NewGuid(), + CreatedAt = Now, + }, + CancellationToken.None)); + } + + // ---- refresh tokens ------------------------------------------------------------------ + + private RefreshTokenRepository RefreshTokens => new(db.Connections); + + private static RefreshToken TokenFor(Guid userId, Guid familyId, string hash) => new() + { + Id = Guid.NewGuid(), + UserId = userId, + TokenHash = hash, + FamilyId = familyId, + ExpiresAt = Now.AddDays(14), + CreatedAt = Now, + }; + + [Fact] + public async Task A_refresh_token_round_trips_and_is_found_by_hash() + { + var user = await GivenUser(); + var token = TokenFor(user.Id, Guid.NewGuid(), Unique("hash-")); + await RefreshTokens.AddAsync(token, CancellationToken.None); + + var stored = await RefreshTokens.GetByHashAsync(token.TokenHash, CancellationToken.None); + + Assert.Equal(token.Id, stored!.Id); + Assert.Equal(token.FamilyId, stored.FamilyId); + Assert.True(stored.IsActive(Now)); + Assert.Null(await RefreshTokens.GetByHashAsync("no-such-hash", CancellationToken.None)); + } + + [Fact] + public async Task Revoking_a_family_kills_every_token_in_it_and_spares_the_others() + { + var user = await GivenUser(); + var doomed = Guid.NewGuid(); + var untouched = Guid.NewGuid(); + var a = TokenFor(user.Id, doomed, Unique("hash-")); + var b = TokenFor(user.Id, doomed, Unique("hash-")); + var c = TokenFor(user.Id, untouched, Unique("hash-")); + foreach (var t in new[] { a, b, c }) + { + await RefreshTokens.AddAsync(t, CancellationToken.None); + } + + await RefreshTokens.RevokeFamilyAsync(doomed, Now, CancellationToken.None); + + Assert.NotNull((await RefreshTokens.GetByHashAsync(a.TokenHash, CancellationToken.None))!.RevokedAt); + Assert.NotNull((await RefreshTokens.GetByHashAsync(b.TokenHash, CancellationToken.None))!.RevokedAt); + Assert.Null((await RefreshTokens.GetByHashAsync(c.TokenHash, CancellationToken.None))!.RevokedAt); + } + + [Fact] + public async Task Revoking_everything_for_a_user_signs_out_all_their_devices() + { + var user = await GivenUser(); + var other = await GivenUser(); + var mine = TokenFor(user.Id, Guid.NewGuid(), Unique("hash-")); + var theirs = TokenFor(other.Id, Guid.NewGuid(), Unique("hash-")); + await RefreshTokens.AddAsync(mine, CancellationToken.None); + await RefreshTokens.AddAsync(theirs, CancellationToken.None); + + await RefreshTokens.RevokeAllForUserAsync(user.Id, Now, CancellationToken.None); + + Assert.NotNull((await RefreshTokens.GetByHashAsync(mine.TokenHash, CancellationToken.None))!.RevokedAt); + Assert.Null((await RefreshTokens.GetByHashAsync(theirs.TokenHash, CancellationToken.None))!.RevokedAt); + } + + [Fact] + public async Task Rotation_records_what_replaced_a_token() + { + var user = await GivenUser(); + var family = Guid.NewGuid(); + var original = TokenFor(user.Id, family, Unique("hash-")); + var replacement = TokenFor(user.Id, family, Unique("hash-")); + await RefreshTokens.AddAsync(original, CancellationToken.None); + await RefreshTokens.AddAsync(replacement, CancellationToken.None); + + original.RevokedAt = Now; + original.ReplacedBy = replacement.Id; + await RefreshTokens.UpdateAsync(original, CancellationToken.None); + + var stored = await RefreshTokens.GetByHashAsync(original.TokenHash, CancellationToken.None); + Assert.Equal(Now, stored!.RevokedAt); + Assert.Equal(replacement.Id, stored.ReplacedBy); + Assert.False(stored.IsActive(Now)); + } + + // ---- two-factor ---------------------------------------------------------------------- + + private TwoFactorRepository TwoFactor => new(db.Connections, TimeProvider.System); + + [Fact] + public async Task A_pending_secret_becomes_confirmed_and_can_be_deleted() + { + var user = await GivenUser(); + + await TwoFactor.UpsertPendingSecretAsync(user.Id, "SECRETBASE32", CancellationToken.None); + Assert.False((await TwoFactor.GetSecretAsync(user.Id, CancellationToken.None))!.IsConfirmed); + + await TwoFactor.MarkConfirmedAsync(user.Id, CancellationToken.None); + Assert.True((await TwoFactor.GetSecretAsync(user.Id, CancellationToken.None))!.IsConfirmed); + + await TwoFactor.DeleteSecretAsync(user.Id, CancellationToken.None); + Assert.Null(await TwoFactor.GetSecretAsync(user.Id, CancellationToken.None)); + } + + [Fact] + public async Task Re_enrolling_replaces_the_pending_secret_rather_than_adding_a_second() + { + var user = await GivenUser(); + + await TwoFactor.UpsertPendingSecretAsync(user.Id, "FIRST", CancellationToken.None); + await TwoFactor.UpsertPendingSecretAsync(user.Id, "SECOND", CancellationToken.None); + + Assert.Equal("SECOND", (await TwoFactor.GetSecretAsync(user.Id, CancellationToken.None))!.Secret); + } + + [Fact] + public async Task A_recovery_code_can_be_consumed_exactly_once() + { + var user = await GivenUser(); + await TwoFactor.AddRecoveryCodesAsync(user.Id, ["rc:one", "rc:two"], Now, CancellationToken.None); + + Assert.True(await TwoFactor.ConsumeRecoveryCodeAsync(user.Id, "rc:one", Now, CancellationToken.None)); + Assert.False(await TwoFactor.ConsumeRecoveryCodeAsync(user.Id, "rc:one", Now, CancellationToken.None)); + Assert.True(await TwoFactor.ConsumeRecoveryCodeAsync(user.Id, "rc:two", Now, CancellationToken.None)); + } + + [Fact] + public async Task One_users_recovery_code_is_useless_to_another() + { + var owner = await GivenUser(); + var attacker = await GivenUser(); + await TwoFactor.AddRecoveryCodesAsync(owner.Id, ["rc:shared-value"], Now, CancellationToken.None); + + Assert.False(await TwoFactor.ConsumeRecoveryCodeAsync(attacker.Id, "rc:shared-value", Now, CancellationToken.None)); + Assert.True(await TwoFactor.ConsumeRecoveryCodeAsync(owner.Id, "rc:shared-value", Now, CancellationToken.None)); + } + + [Fact] + public async Task Deleting_recovery_codes_clears_them_all() + { + var user = await GivenUser(); + await TwoFactor.AddRecoveryCodesAsync(user.Id, ["rc:a", "rc:b"], Now, CancellationToken.None); + + await TwoFactor.DeleteRecoveryCodesAsync(user.Id, CancellationToken.None); + + Assert.False(await TwoFactor.ConsumeRecoveryCodeAsync(user.Id, "rc:a", Now, CancellationToken.None)); + } + + // ---- password reset ------------------------------------------------------------------ + + private PasswordResetTokenRepository ResetTokens => new(db.Connections); + + private static PasswordResetToken ResetFor(Guid userId, string hash) => new() + { + Id = Guid.NewGuid(), + UserId = userId, + TokenHash = hash, + ExpiresAt = Now.AddHours(1), + CreatedAt = Now, + }; + + [Fact] + public async Task A_reset_token_round_trips_and_can_be_marked_used() + { + var user = await GivenUser(); + var token = ResetFor(user.Id, Unique("reset-")); + await ResetTokens.AddAsync(token, CancellationToken.None); + + var stored = await ResetTokens.GetByHashAsync(token.TokenHash, CancellationToken.None); + Assert.True(stored!.IsActive(Now)); + + await ResetTokens.MarkUsedAsync(token.Id, Now, CancellationToken.None); + + var used = await ResetTokens.GetByHashAsync(token.TokenHash, CancellationToken.None); + Assert.Equal(Now, used!.UsedAt); + Assert.False(used.IsActive(Now)); + } + + [Fact] + public async Task Requesting_a_new_reset_invalidates_the_outstanding_ones() + { + var user = await GivenUser(); + var first = ResetFor(user.Id, Unique("reset-")); + var second = ResetFor(user.Id, Unique("reset-")); + await ResetTokens.AddAsync(first, CancellationToken.None); + + await ResetTokens.InvalidateForUserAsync(user.Id, Now, CancellationToken.None); + await ResetTokens.AddAsync(second, CancellationToken.None); + + // Only the newest link may work, or an old email stays a way in. + Assert.False((await ResetTokens.GetByHashAsync(first.TokenHash, CancellationToken.None))!.IsActive(Now)); + Assert.True((await ResetTokens.GetByHashAsync(second.TokenHash, CancellationToken.None))!.IsActive(Now)); + } + + [Fact] + public async Task An_unknown_reset_hash_returns_null() + { + Assert.Null(await ResetTokens.GetByHashAsync("never-issued", CancellationToken.None)); + } + + // ---- audit log ----------------------------------------------------------------------- + + [Fact] + public async Task Audit_entries_are_written_for_a_user_and_anonymously() + { + var user = await GivenUser(); + var audit = new AuditLog(db.Connections, TimeProvider.System); + + await audit.WriteAsync(user.Id, "test.action", "detail", CancellationToken.None); + await audit.WriteAsync(null, "test.anonymous", null, CancellationToken.None); + + // No read side on the port; the assertion is that neither write throws or violates the FK. + Assert.NotNull(await Users.GetByIdAsync(user.Id, CancellationToken.None)); + } +} diff --git a/tests/WidgetWorks.IntegrationTests/OrderRepositoryTests.cs b/tests/WidgetWorks.IntegrationTests/OrderRepositoryTests.cs new file mode 100644 index 0000000..19897e0 --- /dev/null +++ b/tests/WidgetWorks.IntegrationTests/OrderRepositoryTests.cs @@ -0,0 +1,308 @@ +using WidgetWorks.Domain.Catalog; +using WidgetWorks.Domain.Orders; +using WidgetWorks.Domain.Users; +using WidgetWorks.Infrastructure.Persistence; +using Xunit; + +namespace WidgetWorks.IntegrationTests; + +/// +/// The order repository against real PostgreSQL. The reservation is the reason this suite exists: +/// stock is committed by a conditional UPDATE inside a transaction, so overselling is prevented by +/// the database, not by application code. No in-memory fake can prove that — only concurrent +/// connections against a real server can. +/// +[Collection(PostgresCollection.Name)] +public class OrderRepositoryTests(PostgresFixture db) +{ + private static readonly DateTimeOffset Now = new(2026, 7, 1, 12, 0, 0, TimeSpan.Zero); + + private OrderRepository Orders => new(db.Connections); + + private WidgetRepository Widgets => new(db.Connections); + + private async Task GivenWidget(int onHand) + { + var widget = new Widget + { + Id = Guid.NewGuid(), + Sku = "IT-" + Guid.NewGuid().ToString("N")[..8].ToUpperInvariant(), + Name = "Widget " + Guid.NewGuid().ToString("N")[..6], + Description = "Integration fixture.", + Price = 10m, + QuantityOnHand = onHand, + QuantityReserved = 0, + IsActive = true, + CreatedAt = Now, + UpdatedAt = Now, + }; + await Widgets.AddAsync(widget, CancellationToken.None); + return widget; + } + + /// orders.user_id is a real foreign key, so an owner has to exist first. + private async Task GivenUser() + { + var id = Guid.NewGuid(); + var email = $"it-{id:N}@example.com"; + await new UserRepository(db.Connections).AddAsync( + new User + { + Id = id, + Email = email, + NormalizedEmail = email.ToUpperInvariant(), + PasswordHash = "hash", + Role = UserRoles.Customer, + SecurityStamp = Guid.NewGuid(), + CreatedAt = Now, + }, + CancellationToken.None); + return id; + } + + private static Order OrderFor(Widget widget, int quantity, string? number = null) => new() + { + Id = Guid.NewGuid(), + OrderNumber = number ?? "WW-IT-" + Guid.NewGuid().ToString("N")[..8].ToUpperInvariant(), + Email = "jane@example.com", + ShipName = "Jane Doe", + ShipLine1 = "1 Main St", + ShipCity = "Springfield", + ShipState = "CA", + ShipPostalCode = "90210", + ShipCountry = "US", + Subtotal = widget.Price * quantity, + ShippingMethod = "Standard", + Shipping = 6.99m, + TaxState = "CA", + TaxRate = 0.0725m, + Tax = 1.45m, + Total = (widget.Price * quantity) + 6.99m + 1.45m, + Status = OrderStatus.Pending, + CreatedAt = Now, + UpdatedAt = Now, + Items = + [ + new OrderItem + { + Id = Guid.NewGuid(), + WidgetId = widget.Id, + Sku = widget.Sku, + Name = widget.Name, + UnitPrice = widget.Price, + Quantity = quantity, + LineSubtotal = widget.Price * quantity, + }, + ], + }; + + [Fact] + public async Task Placing_an_order_reserves_the_stock() + { + var widget = await GivenWidget(onHand: 5); + + var placed = await Orders.TryPlaceAsync(OrderFor(widget, 2), CancellationToken.None); + + Assert.True(placed); + var after = await Widgets.GetByIdAsync(widget.Id, CancellationToken.None); + Assert.Equal(2, after!.QuantityReserved); + Assert.Equal(5, after.QuantityOnHand); + Assert.Equal(3, after.QuantityAvailable); + } + + [Fact] + public async Task An_order_for_more_than_is_available_is_refused_and_reserves_nothing() + { + var widget = await GivenWidget(onHand: 1); + + var placed = await Orders.TryPlaceAsync(OrderFor(widget, 2), CancellationToken.None); + + Assert.False(placed); + var after = await Widgets.GetByIdAsync(widget.Id, CancellationToken.None); + Assert.Equal(0, after!.QuantityReserved); + } + + [Fact] + public async Task A_refused_order_leaves_no_row_behind() + { + var widget = await GivenWidget(onHand: 0); + var order = OrderFor(widget, 1); + + await Orders.TryPlaceAsync(order, CancellationToken.None); + + // The whole placement is one transaction: a failed reservation must roll the order back too. + Assert.Null(await Orders.GetByIdAsync(order.Id, CancellationToken.None)); + } + + [Fact] + public async Task Concurrent_orders_cannot_oversell_the_last_units() + { + var widget = await GivenWidget(onHand: 10); + + // Ten buyers, two units each, ten in stock: exactly five can win. + var attempts = Enumerable.Range(0, 10) + .Select(_ => Task.Run(() => new OrderRepository(db.Connections) + .TryPlaceAsync(OrderFor(widget, 2), CancellationToken.None))) + .ToArray(); + + var results = await Task.WhenAll(attempts); + + Assert.Equal(5, results.Count(placed => placed)); + var after = await Widgets.GetByIdAsync(widget.Id, CancellationToken.None); + Assert.Equal(10, after!.QuantityReserved); + Assert.Equal(0, after.QuantityAvailable); + } + + [Fact] + public async Task Marking_paid_records_the_provider_and_reference() + { + var widget = await GivenWidget(onHand: 5); + var order = OrderFor(widget, 1); + await Orders.TryPlaceAsync(order, CancellationToken.None); + + await Orders.MarkPaidAsync(order.Id, "Mock", "mock_ref_1", Now, CancellationToken.None); + + var stored = await Orders.GetByIdAsync(order.Id, CancellationToken.None); + Assert.Equal(OrderStatus.Paid, stored!.Status); + Assert.Equal("Mock", stored.PaymentProvider); + Assert.Equal("mock_ref_1", stored.PaymentReference); + } + + [Fact] + public async Task A_declined_payment_releases_the_reservation() + { + var widget = await GivenWidget(onHand: 5); + var order = OrderFor(widget, 3); + await Orders.TryPlaceAsync(order, CancellationToken.None); + + await Orders.MarkPaymentFailedAsync(order, "Card declined.", Now, CancellationToken.None); + + // Stock a customer never paid for must go back on the shelf. + var after = await Widgets.GetByIdAsync(widget.Id, CancellationToken.None); + Assert.Equal(0, after!.QuantityReserved); + Assert.Equal(5, after.QuantityAvailable); + Assert.Equal(OrderStatus.PaymentFailed, (await Orders.GetByIdAsync(order.Id, CancellationToken.None))!.Status); + } + + [Fact] + public async Task An_awaiting_payment_order_keeps_its_reservation() + { + var widget = await GivenWidget(onHand: 5); + var order = OrderFor(widget, 2); + await Orders.TryPlaceAsync(order, CancellationToken.None); + + await Orders.MarkAwaitingPaymentAsync(order.Id, "Klarna", "klarna_1", Now, CancellationToken.None); + + // The stock stays committed while the provider settles, or it could be sold twice. + var after = await Widgets.GetByIdAsync(widget.Id, CancellationToken.None); + Assert.Equal(2, after!.QuantityReserved); + Assert.Equal(OrderStatus.AwaitingPayment, (await Orders.GetByIdAsync(order.Id, CancellationToken.None))!.Status); + } + + [Fact] + public async Task An_order_can_be_found_by_its_payment_reference() + { + var widget = await GivenWidget(onHand: 5); + var order = OrderFor(widget, 1); + await Orders.TryPlaceAsync(order, CancellationToken.None); + await Orders.MarkAwaitingPaymentAsync(order.Id, "Klarna", "klarna_lookup", Now, CancellationToken.None); + + // This is how a webhook correlates an inbound event back to an order. + var found = await Orders.GetByPaymentReferenceAsync("Klarna", "klarna_lookup", CancellationToken.None); + + Assert.Equal(order.Id, found!.Id); + Assert.Null(await Orders.GetByPaymentReferenceAsync("Klarna", "not-a-reference", CancellationToken.None)); + Assert.Null(await Orders.GetByPaymentReferenceAsync("Stripe", "klarna_lookup", CancellationToken.None)); + } + + [Fact] + public async Task A_guest_can_look_an_order_up_only_with_the_email_that_placed_it() + { + var widget = await GivenWidget(onHand: 5); + var order = OrderFor(widget, 1); + await Orders.TryPlaceAsync(order, CancellationToken.None); + + Assert.NotNull(await Orders.GetByNumberAndEmailAsync(order.OrderNumber, "jane@example.com", CancellationToken.None)); + Assert.Null(await Orders.GetByNumberAndEmailAsync(order.OrderNumber, "someone@else.com", CancellationToken.None)); + } + + [Fact] + public async Task Updating_status_stores_the_tracking_number() + { + var widget = await GivenWidget(onHand: 5); + var order = OrderFor(widget, 1); + await Orders.TryPlaceAsync(order, CancellationToken.None); + await Orders.MarkPaidAsync(order.Id, "Mock", "r", Now, CancellationToken.None); + + await Orders.UpdateStatusAsync(order.Id, OrderStatus.Shipped, "1Z-TRACK", Now.AddHours(1), CancellationToken.None); + + var stored = await Orders.GetByIdAsync(order.Id, CancellationToken.None); + Assert.Equal(OrderStatus.Shipped, stored!.Status); + Assert.Equal("1Z-TRACK", stored.TrackingNumber); + } + + [Fact] + public async Task A_users_orders_come_back_newest_first_with_their_lines() + { + var userId = await GivenUser(); + var widget = await GivenWidget(onHand: 20); + + var older = OrderFor(widget, 1); + older.UserId = userId; + older.CreatedAt = Now.AddDays(-3); + await Orders.TryPlaceAsync(older, CancellationToken.None); + + var newer = OrderFor(widget, 2); + newer.UserId = userId; + newer.CreatedAt = Now; + await Orders.TryPlaceAsync(newer, CancellationToken.None); + + var mine = await Orders.GetForUserAsync(userId, CancellationToken.None); + + Assert.Equal([newer.Id, older.Id], mine.Select(o => o.Id)); + Assert.All(mine, o => Assert.NotEmpty(o.Items)); + } + + [Fact] + public async Task Another_users_orders_are_not_returned() + { + var widget = await GivenWidget(onHand: 5); + var order = OrderFor(widget, 1); + order.UserId = await GivenUser(); + await Orders.TryPlaceAsync(order, CancellationToken.None); + + Assert.Empty(await Orders.GetForUserAsync(await GivenUser(), CancellationToken.None)); + } + + [Fact] + public async Task The_recent_list_carries_item_rows_so_counts_are_right() + { + var widget = await GivenWidget(onHand: 20); + var order = OrderFor(widget, 4); + await Orders.TryPlaceAsync(order, CancellationToken.None); + + var recent = await Orders.GetRecentAsync(50, CancellationToken.None); + + // The bug this guards: skipping the item rows made every order report 0 items. + var mine = recent.Single(o => o.Id == order.Id); + Assert.Equal(4, mine.UnitCount); + } + + [Fact] + public async Task The_recent_list_honours_its_limit() + { + var widget = await GivenWidget(onHand: 50); + for (var i = 0; i < 4; i++) + { + await Orders.TryPlaceAsync(OrderFor(widget, 1), CancellationToken.None); + } + + Assert.Equal(2, (await Orders.GetRecentAsync(2, CancellationToken.None)).Count); + } + + [Fact] + public async Task An_unknown_order_id_returns_null_rather_than_throwing() + { + Assert.Null(await Orders.GetByIdAsync(Guid.NewGuid(), CancellationToken.None)); + } +} diff --git a/tests/WidgetWorks.IntegrationTests/PostgresFixture.cs b/tests/WidgetWorks.IntegrationTests/PostgresFixture.cs new file mode 100644 index 0000000..cec187a --- /dev/null +++ b/tests/WidgetWorks.IntegrationTests/PostgresFixture.cs @@ -0,0 +1,71 @@ +using Npgsql; +using WidgetWorks.Infrastructure.Migrations; +using WidgetWorks.Infrastructure.Persistence; +using Xunit; + +namespace WidgetWorks.IntegrationTests; + +/// +/// A real PostgreSQL database for the repository suites. The Dapper repositories are mostly SQL -- +/// the atomic stock reservation, the ON CONFLICT upserts, the partial unique index on live widget +/// names -- and none of that can be exercised by an in-memory fake. A fake would only prove the +/// fake works. +/// +/// It connects to the Postgres that `docker compose up db` already provides (override with +/// WIDGETWORKS_TEST_DB), then creates a **throwaway database per run** and migrates it, so the +/// suite never touches developer or demo data and parallel runs cannot collide. +/// +public sealed class PostgresFixture : IAsyncLifetime +{ + private const string DefaultAdmin = + "Host=localhost;Port=5432;Database=postgres;Username=widgetworks;Password=replace-me-locally"; + + private string _adminConnectionString = DefaultAdmin; + + public string DatabaseName { get; } = "ww_test_" + Guid.NewGuid().ToString("N")[..12]; + + public string ConnectionString { get; private set; } = string.Empty; + + public IDbConnectionFactory Connections { get; private set; } = null!; + + public async Task InitializeAsync() + { + _adminConnectionString = Environment.GetEnvironmentVariable("WIDGETWORKS_TEST_DB") ?? DefaultAdmin; + + var builder = new NpgsqlConnectionStringBuilder(_adminConnectionString) { Database = "postgres" }; + await using (var admin = new NpgsqlConnection(builder.ConnectionString)) + { + await admin.OpenAsync(); + await using var create = new NpgsqlCommand($"create database \"{DatabaseName}\"", admin); + await create.ExecuteNonQueryAsync(); + } + + ConnectionString = new NpgsqlConnectionStringBuilder(_adminConnectionString) { Database = DatabaseName } + .ConnectionString; + + // The same Dapper mapping and the same DbUp migrations the application runs at startup, so + // the schema and the mapping under test are the ones that ship. + DapperConfiguration.Apply(); + MigrationRunner.Run(ConnectionString); + + Connections = new NpgsqlConnectionFactory(ConnectionString); + } + + public async Task DisposeAsync() + { + NpgsqlConnection.ClearAllPools(); + + var builder = new NpgsqlConnectionStringBuilder(_adminConnectionString) { Database = "postgres" }; + await using var admin = new NpgsqlConnection(builder.ConnectionString); + await admin.OpenAsync(); + await using var drop = new NpgsqlCommand($"drop database if exists \"{DatabaseName}\" with (force)", admin); + await drop.ExecuteNonQueryAsync(); + } +} + +/// One database shared by every repository suite; each test cleans up after itself. +[CollectionDefinition(Name)] +public sealed class PostgresCollection : ICollectionFixture +{ + public const string Name = "postgres"; +} diff --git a/tests/WidgetWorks.IntegrationTests/SeederAndMigrationTests.cs b/tests/WidgetWorks.IntegrationTests/SeederAndMigrationTests.cs new file mode 100644 index 0000000..df4d11b --- /dev/null +++ b/tests/WidgetWorks.IntegrationTests/SeederAndMigrationTests.cs @@ -0,0 +1,150 @@ +using WidgetWorks.Application.Abstractions; +using WidgetWorks.Domain.Users; +using WidgetWorks.Infrastructure.Migrations; +using WidgetWorks.Infrastructure.Persistence; +using WidgetWorks.Infrastructure.Seeding; +using Xunit; + +namespace WidgetWorks.IntegrationTests; + +/// +/// Startup behaviour: migrations and the demo seed. Both run on every boot, so the property that +/// matters is idempotence — a second start must not duplicate an account, reset a password someone +/// changed, or re-add a widget an administrator deleted. +/// +[Collection(PostgresCollection.Name)] +public class SeederAndMigrationTests(PostgresFixture db) +{ + private UserRepository Users => new(db.Connections); + + private static SeedOptions Options(string suffix) => new() + { + DemoAdminEmail = $"admin-{suffix}@widgetworks.test", + DemoAdminPassword = "DemoAdmin!Change01", + DemoCustomerEmail = $"demo-{suffix}@widgetworks.test", + DemoCustomerPassword = "DemoUser!Change01", + DemoManagerEmail = $"manager-{suffix}@widgetworks.test", + DemoManagerPassword = "DemoManager!Change01", + }; + + private DbSeeder Seeder => new(db.Connections, new PlainHasher(), TimeProvider.System); + + [Fact] + public async Task Seeding_creates_all_three_roles() + { + var options = Options(Guid.NewGuid().ToString("N")[..8]); + + await Seeder.SeedAsync(options, CancellationToken.None); + + var admin = await Users.GetByNormalizedEmailAsync(options.DemoAdminEmail.ToUpperInvariant(), CancellationToken.None); + var manager = await Users.GetByNormalizedEmailAsync(options.DemoManagerEmail.ToUpperInvariant(), CancellationToken.None); + var customer = await Users.GetByNormalizedEmailAsync(options.DemoCustomerEmail.ToUpperInvariant(), CancellationToken.None); + + Assert.Equal(UserRoles.Administrator, admin!.Role); + Assert.Equal(UserRoles.Manager, manager!.Role); + Assert.Equal(UserRoles.Customer, customer!.Role); + } + + [Fact] + public async Task Only_the_seeded_administrator_is_protected() + { + var options = Options(Guid.NewGuid().ToString("N")[..8]); + + await Seeder.SeedAsync(options, CancellationToken.None); + + Assert.True((await Users.GetByNormalizedEmailAsync(options.DemoAdminEmail.ToUpperInvariant(), CancellationToken.None))!.IsProtectedAdmin); + Assert.False((await Users.GetByNormalizedEmailAsync(options.DemoManagerEmail.ToUpperInvariant(), CancellationToken.None))!.IsProtectedAdmin); + Assert.False((await Users.GetByNormalizedEmailAsync(options.DemoCustomerEmail.ToUpperInvariant(), CancellationToken.None))!.IsProtectedAdmin); + } + + [Fact] + public async Task Seeding_twice_does_not_duplicate_an_account() + { + var options = Options(Guid.NewGuid().ToString("N")[..8]); + + await Seeder.SeedAsync(options, CancellationToken.None); + var first = await Users.GetByNormalizedEmailAsync(options.DemoAdminEmail.ToUpperInvariant(), CancellationToken.None); + + await Seeder.SeedAsync(options, CancellationToken.None); + var second = await Users.GetByNormalizedEmailAsync(options.DemoAdminEmail.ToUpperInvariant(), CancellationToken.None); + + // Same row, not a second one — the unique index would have thrown on a blind insert. + Assert.Equal(first!.Id, second!.Id); + } + + [Fact] + public async Task Seeding_leaves_an_existing_password_alone() + { + var options = Options(Guid.NewGuid().ToString("N")[..8]); + await Seeder.SeedAsync(options, CancellationToken.None); + + var user = await Users.GetByNormalizedEmailAsync(options.DemoCustomerEmail.ToUpperInvariant(), CancellationToken.None); + user!.PasswordHash = "plain:changed-by-the-user"; + await Users.UpdateAsync(user, CancellationToken.None); + + await Seeder.SeedAsync(options, CancellationToken.None); + + // Restarting the app must never reset a password someone chose. + var after = await Users.GetByNormalizedEmailAsync(options.DemoCustomerEmail.ToUpperInvariant(), CancellationToken.None); + Assert.Equal("plain:changed-by-the-user", after!.PasswordHash); + } + + [Fact] + public async Task An_account_with_no_configured_password_is_skipped_rather_than_created_open() + { + var options = Options(Guid.NewGuid().ToString("N")[..8]); + options.DemoManagerPassword = string.Empty; + + await Seeder.SeedAsync(options, CancellationToken.None); + + Assert.Null(await Users.GetByNormalizedEmailAsync(options.DemoManagerEmail.ToUpperInvariant(), CancellationToken.None)); + } + + [Fact] + public async Task Seeding_stocks_the_demo_catalog_and_repeats_safely() + { + var widgets = new WidgetRepository(db.Connections); + await Seeder.SeedAsync(Options(Guid.NewGuid().ToString("N")[..8]), CancellationToken.None); + + var standard = await widgets.GetBySkuAsync("WW-001", CancellationToken.None); + Assert.Equal("Standard Widget", standard!.Name); + Assert.Equal(9.99m, standard.Price); + + await Seeder.SeedAsync(Options(Guid.NewGuid().ToString("N")[..8]), CancellationToken.None); + + // Still one row per SKU after a second run. + Assert.Equal(1, await widgets.CountAsync(new WidgetQuery("Standard Widget", true, 1, 50), CancellationToken.None)); + } + + [Fact] + public void Migrations_are_journaled_so_a_second_run_is_a_no_op() + { + // The fixture already migrated this database; running again must succeed without + // reapplying anything, which is what makes restart-on-crash safe. + MigrationRunner.Run(db.ConnectionString); + + var outcome = MigrationRunner.TryRun(db.ConnectionString); + Assert.True(outcome.Successful); + } + + [Fact] + public void An_unreachable_database_is_reported_rather_than_thrown_at_startup() + { + var unreachable = "Host=localhost;Port=59999;Database=nope;Username=nobody;Password=nobody;Timeout=1"; + + var outcome = MigrationRunner.TryRun(unreachable, maxAttempts: 2, firstDelay: TimeSpan.FromMilliseconds(1)); + + // The behaviour that stops a free-tier container restart-looping and burning quota. + Assert.False(outcome.Successful); + Assert.NotNull(outcome.Error); + Assert.Equal(2, outcome.Attempts); + } + + /// Deterministic stand-in: the seeder only needs *a* hasher, not a slow one. + private sealed class PlainHasher : IPasswordHasher + { + public string Hash(string password) => "plain:" + password; + + public bool Verify(string password, string hash) => hash == "plain:" + password; + } +} diff --git a/tests/WidgetWorks.IntegrationTests/WidgetWorks.IntegrationTests.csproj b/tests/WidgetWorks.IntegrationTests/WidgetWorks.IntegrationTests.csproj new file mode 100644 index 0000000..e89398f --- /dev/null +++ b/tests/WidgetWorks.IntegrationTests/WidgetWorks.IntegrationTests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/tests/WidgetWorks.UnitTests/AuthSessionTests.cs b/tests/WidgetWorks.UnitTests/AuthSessionTests.cs new file mode 100644 index 0000000..b62e724 --- /dev/null +++ b/tests/WidgetWorks.UnitTests/AuthSessionTests.cs @@ -0,0 +1,284 @@ +using Microsoft.Extensions.Time.Testing; +using WidgetWorks.Application.Abstractions; +using WidgetWorks.Application.Auth.Logout; +using WidgetWorks.Application.Auth.Refresh; +using WidgetWorks.Application.Auth.Register; +using WidgetWorks.Domain.Auth; +using WidgetWorks.Domain.Users; +using WidgetWorks.UnitTests.Fakes; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// Refresh-token rotation, logout, and registration. The rotation cases matter most: a refresh +/// token is single-use, so replaying one has to revoke the whole family rather than mint a token. +/// +public class AuthSessionTests +{ + private static readonly DateTimeOffset Now = new(2026, 3, 1, 12, 0, 0, TimeSpan.Zero); + + private sealed record Ctx( + FakeTimeProvider Clock, + InMemoryUserRepository Users, + InMemoryRefreshTokenRepository Refresh, + StubTokenService Tokens, + User User); + + private static Ctx Setup() + { + var users = new InMemoryUserRepository(); + var user = new User + { + Id = Guid.NewGuid(), + Email = "jane@example.com", + NormalizedEmail = "JANE@EXAMPLE.COM", + PasswordHash = "hash:pw", + Role = UserRoles.Customer, + SecurityStamp = Guid.NewGuid(), + }; + users.Store[user.Id] = user; + return new Ctx(new FakeTimeProvider(Now), users, new InMemoryRefreshTokenRepository(), new StubTokenService(), user); + } + + private static RefreshToken Issue(Ctx c, Guid familyId, DateTimeOffset? expiresAt = null, DateTimeOffset? revokedAt = null) + { + var token = new RefreshToken + { + Id = Guid.NewGuid(), + UserId = c.User.Id, + TokenHash = c.Tokens.HashRefreshToken("raw-token"), + FamilyId = familyId, + ExpiresAt = expiresAt ?? Now.AddDays(14), + CreatedAt = Now.AddMinutes(-5), + RevokedAt = revokedAt, + }; + c.Refresh.Tokens.Add(token); + return token; + } + + private static RefreshHandler Refresh(Ctx c) => new(c.Users, c.Refresh, c.Tokens, c.Clock); + + private static LogoutHandler Logout(Ctx c) => new(c.Refresh, c.Tokens, c.Clock); + + // ---- refresh ------------------------------------------------------------------------- + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task Refresh_requires_a_token(string raw) + { + var c = Setup(); + var result = await Refresh(c).Handle(new RefreshCommand(raw), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Refresh token is required.", result.Error); + } + + [Fact] + public async Task Refresh_rejects_a_token_it_has_never_seen() + { + var c = Setup(); + var result = await Refresh(c).Handle(new RefreshCommand("never-issued"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Invalid refresh token.", result.Error); + } + + [Fact] + public async Task Refresh_rotates_the_token_and_keeps_the_family() + { + var c = Setup(); + var family = Guid.NewGuid(); + var original = Issue(c, family); + + var result = await Refresh(c).Handle(new RefreshCommand("raw-token"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("access-token", result.Value!.AccessToken); + Assert.Equal(UserRoles.Customer, result.Value.Role); + + // The presented token is spent, and points at what replaced it. + Assert.Equal(Now, original.RevokedAt); + Assert.NotNull(original.ReplacedBy); + + var replacement = Assert.Single(c.Refresh.Tokens, t => t.Id != original.Id); + Assert.Equal(family, replacement.FamilyId); + Assert.Equal(original.ReplacedBy, replacement.Id); + Assert.Null(replacement.RevokedAt); + } + + [Fact] + public async Task Refresh_of_a_revoked_token_revokes_the_whole_family() + { + var c = Setup(); + var family = Guid.NewGuid(); + Issue(c, family, revokedAt: Now.AddMinutes(-1)); + var sibling = Issue(c, family); + + var result = await Refresh(c).Handle(new RefreshCommand("raw-token"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Refresh token no longer valid.", result.Error); + + // Reuse detection: a stolen token being replayed must not leave live siblings behind. + Assert.Equal(Now, sibling.RevokedAt); + Assert.All(c.Refresh.Tokens, t => Assert.NotNull(t.RevokedAt)); + } + + [Fact] + public async Task Refresh_of_an_expired_token_revokes_the_family_too() + { + var c = Setup(); + var family = Guid.NewGuid(); + Issue(c, family, expiresAt: Now.AddSeconds(-1)); + + var result = await Refresh(c).Handle(new RefreshCommand("raw-token"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.All(c.Refresh.Tokens, t => Assert.NotNull(t.RevokedAt)); + } + + [Fact] + public async Task Refresh_fails_when_the_user_behind_the_token_is_gone() + { + var c = Setup(); + Issue(c, Guid.NewGuid()); + c.Users.Store.Clear(); + + var result = await Refresh(c).Handle(new RefreshCommand("raw-token"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Invalid refresh token.", result.Error); + } + + // ---- logout -------------------------------------------------------------------------- + + [Fact] + public async Task Logout_revokes_the_presented_token() + { + var c = Setup(); + var token = Issue(c, Guid.NewGuid()); + + var result = await Logout(c).Handle(new LogoutCommand("raw-token"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(Now, token.RevokedAt); + } + + [Fact] + public async Task Logout_without_a_token_succeeds_quietly() + { + var c = Setup(); + var result = await Logout(c).Handle(new LogoutCommand(" "), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Empty(c.Refresh.Tokens); + } + + [Fact] + public async Task Logout_of_an_unknown_token_succeeds_without_touching_anything() + { + var c = Setup(); + var token = Issue(c, Guid.NewGuid()); + + var result = await Logout(c).Handle(new LogoutCommand("some-other-token"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Null(token.RevokedAt); + } + + [Fact] + public async Task Logout_twice_keeps_the_first_revocation_time() + { + var c = Setup(); + var revokedEarlier = Now.AddMinutes(-10); + var token = Issue(c, Guid.NewGuid(), revokedAt: revokedEarlier); + + await Logout(c).Handle(new LogoutCommand("raw-token"), CancellationToken.None); + + Assert.Equal(revokedEarlier, token.RevokedAt); + } + + // ---- registration -------------------------------------------------------------------- + + private static RegisterHandler Register(Ctx c, IEmailSender email) + => new(c.Users, new FakePasswordHasher(), email, c.Clock); + + [Theory] + [InlineData("not-an-email")] + [InlineData("")] + [InlineData(" ")] + public async Task Register_requires_an_email_address(string email) + { + var c = Setup(); + var result = await Register(c, new FakeEmailSender()).Handle(new RegisterCommand(email, "long-enough-pw"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("A valid email is required.", result.Error); + } + + [Theory] + [InlineData("short")] + [InlineData("")] + [InlineData(null)] + public async Task Register_requires_eight_characters_of_password(string? password) + { + var c = Setup(); + var result = await Register(c, new FakeEmailSender()).Handle(new RegisterCommand("new@example.com", password!), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Password must be at least 8 characters.", result.Error); + } + + [Fact] + public async Task Register_does_not_reveal_that_an_email_is_already_taken() + { + var c = Setup(); + var result = await Register(c, new FakeEmailSender()) + .Handle(new RegisterCommand(" Jane@Example.com ", "long-enough-pw"), CancellationToken.None); + + Assert.False(result.IsSuccess); + + // Deliberately generic: the message must not distinguish "taken" from any other failure. + Assert.Equal("Unable to register with the provided details.", result.Error); + Assert.DoesNotContain("exists", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Register_creates_a_customer_and_sends_a_welcome() + { + var c = Setup(); + var email = new FakeEmailSender(); + + var result = await Register(c, email).Handle(new RegisterCommand(" New@Example.com ", "long-enough-pw"), CancellationToken.None); + + Assert.True(result.IsSuccess); + var created = Assert.Single(c.Users.Store.Values, u => u.NormalizedEmail == "NEW@EXAMPLE.COM"); + Assert.Equal("New@Example.com", created.Email); + Assert.Equal(UserRoles.Customer, created.Role); + Assert.Equal("hash:long-enough-pw", created.PasswordHash); + Assert.NotEqual(Guid.Empty, created.SecurityStamp); + Assert.Equal(Now, created.CreatedAt); + Assert.Single(email.Sent, m => m.To == "New@Example.com"); + } + + [Fact] + public async Task Register_still_succeeds_when_the_welcome_email_fails() + { + var c = Setup(); + + var result = await Register(c, new ThrowingEmailSender()) + .Handle(new RegisterCommand("new@example.com", "long-enough-pw"), CancellationToken.None); + + // A dead mail server must not cost someone their account. + Assert.True(result.IsSuccess); + Assert.Contains(c.Users.Store.Values, u => u.NormalizedEmail == "NEW@EXAMPLE.COM"); + } + + private sealed class ThrowingEmailSender : IEmailSender + { + public Task SendAsync(EmailMessage message, CancellationToken ct) + => throw new InvalidOperationException("smtp is down"); + } +} diff --git a/tests/WidgetWorks.UnitTests/ChallengeTokenTests.cs b/tests/WidgetWorks.UnitTests/ChallengeTokenTests.cs new file mode 100644 index 0000000..e24ab3f --- /dev/null +++ b/tests/WidgetWorks.UnitTests/ChallengeTokenTests.cs @@ -0,0 +1,162 @@ +using Microsoft.Extensions.Options; +using WidgetWorks.Domain.Users; +using WidgetWorks.Infrastructure.Security; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// The short-lived token that carries a half-authenticated user between "password accepted" and +/// "second factor proved". It is the one credential in the system that deliberately grants nothing +/// on its own, so the tests are mostly about what it must refuse: an access token presented in its +/// place, a token past its five minutes, one signed by a key the ring does not know, and one whose +/// signature has been tampered with. +/// +public class ChallengeTokenTests +{ + // Issuance uses the injected clock, but ValidateTokenAsync checks lifetime against the system + // clock — TokenValidationParameters has no TimeProvider. Correct in production, where the two + // agree; in tests it means a token minted at a fixed past date is already expired. So these + // tests anchor on real time and move the *issuing* clock to express age. + private static DateTimeOffset Now => DateTimeOffset.UtcNow; + + private sealed class FixedClock(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } + + private static JwtOptions Options(string key = "test-signing-key-that-is-long-enough-0123456789", string kid = "wk-1") => new() + { + Issuer = "https://localhost", + Audience = "widgetworks", + SigningKey = key, + KeyId = kid, + AccessTokenMinutes = 15, + RefreshTokenDays = 14, + }; + + private static JwtTokenService Service(DateTimeOffset now, JwtOptions? options = null) + { + var o = options ?? Options(); + return new JwtTokenService(Microsoft.Extensions.Options.Options.Create(o), new JwtKeyRing(o), new FixedClock(now)); + } + + private static User TheUser { get; } = new() + { + Id = Guid.Parse("11111111-2222-3333-4444-555555555555"), + Email = "jane@example.com", + NormalizedEmail = "JANE@EXAMPLE.COM", + Role = UserRoles.Customer, + SecurityStamp = Guid.NewGuid(), + }; + + [Fact] + public async Task A_freshly_issued_challenge_resolves_to_its_user() + { + var service = Service(Now); + + var token = service.CreateChallengeToken(TheUser); + + Assert.Equal(TheUser.Id, await service.ValidateChallengeTokenAsync(token)); + } + + [Fact] + public async Task An_access_token_is_not_accepted_as_a_challenge() + { + var service = Service(Now); + + // Both are signed by the same key and would validate structurally; only the purpose claim + // separates them. Without that check, a full access token would satisfy the 2FA step. + var access = service.CreateAccessToken(TheUser); + + Assert.Null(await service.ValidateChallengeTokenAsync(access.Value)); + } + + [Fact] + public async Task A_challenge_still_works_a_few_minutes_in() + { + var issuedFourMinutesAgo = Service(Now.AddMinutes(-4)).CreateChallengeToken(TheUser); + + Assert.Equal(TheUser.Id, await Service(Now).ValidateChallengeTokenAsync(issuedFourMinutesAgo)); + } + + [Fact] + public async Task A_challenge_older_than_five_minutes_is_refused() + { + var stale = Service(Now.AddMinutes(-30)).CreateChallengeToken(TheUser); + + // A half-authenticated session must not stay open indefinitely. + Assert.Null(await Service(Now).ValidateChallengeTokenAsync(stale)); + } + + [Fact] + public async Task A_challenge_signed_by_an_unknown_key_is_rejected() + { + var foreign = Service(Now, Options(key: "a-completely-different-signing-key-9876543210", kid: "other")) + .CreateChallengeToken(TheUser); + + Assert.Null(await Service(Now).ValidateChallengeTokenAsync(foreign)); + } + + [Fact] + public async Task A_tampered_challenge_is_rejected() + { + var service = Service(Now); + var token = service.CreateChallengeToken(TheUser); + + // Flip the last character of the signature segment. + var parts = token.Split('.'); + var signature = parts[2]; + parts[2] = signature[..^1] + (signature[^1] == 'A' ? 'B' : 'A'); + + Assert.Null(await service.ValidateChallengeTokenAsync(string.Join('.', parts))); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-a-token")] + [InlineData("a.b.c")] + public async Task Garbage_is_rejected_without_throwing(string token) + { + Assert.Null(await Service(Now).ValidateChallengeTokenAsync(token)); + } + + [Fact] + public async Task A_challenge_for_a_different_audience_is_rejected() + { + var options = Options(); + options.Audience = "some-other-app"; + var foreign = Service(Now, options).CreateChallengeToken(TheUser); + + Assert.Null(await Service(Now).ValidateChallengeTokenAsync(foreign)); + } + + [Fact] + public async Task A_challenge_from_a_different_issuer_is_rejected() + { + var options = Options(); + options.Issuer = "https://not-us"; + var foreign = Service(Now, options).CreateChallengeToken(TheUser); + + Assert.Null(await Service(Now).ValidateChallengeTokenAsync(foreign)); + } + + [Fact] + public void A_refresh_token_is_random_each_time_and_hashes_to_its_own_value() + { + var service = Service(Now); + var family = Guid.NewGuid(); + + var first = service.CreateRefreshToken(family); + var second = service.CreateRefreshToken(family); + + Assert.NotEqual(first.Value, second.Value); + Assert.Equal(family, first.FamilyId); + Assert.Equal(service.HashRefreshToken(first.Value), first.Hash); + Assert.NotEqual(first.Hash, second.Hash); + + // 14 days from the injected clock. + Assert.Equal(14, Math.Round((first.ExpiresAt - Now).TotalDays)); + } +} diff --git a/tests/WidgetWorks.UnitTests/CheckoutTests.cs b/tests/WidgetWorks.UnitTests/CheckoutTests.cs index 0931c3f..7bcec8b 100644 --- a/tests/WidgetWorks.UnitTests/CheckoutTests.cs +++ b/tests/WidgetWorks.UnitTests/CheckoutTests.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Time.Testing; using WidgetWorks.Application.Checkout.PlaceOrder; +using WidgetWorks.Application.Pricing; using WidgetWorks.Domain.Catalog; using WidgetWorks.Domain.Orders; using WidgetWorks.Infrastructure.Payments; @@ -33,8 +34,7 @@ private static async Task SetupAsync(int onHand = 10, decimal price = 10m, } private static CheckoutHandler Handler(Ctx c, MockPaymentGateway gateway, FakeEmailSender email) - => new(c.Carts, c.Widgets, c.Orders, new FlatRateShippingCalculator(), - new StateSalesTaxCalculator(new StaticStateTaxRateProvider()), gateway, email, Clock()); + => new(c.Carts, c.Widgets, c.Orders, new OrderPricer(new FlatRateShippingCalculator(), new StateSalesTaxCalculator(new StaticStateTaxRateProvider())), gateway, email, Clock()); [Fact] public async Task Successful_checkout_pays_reserves_clears_cart_and_emails_receipt() @@ -113,8 +113,7 @@ public async Task Empty_cart_cannot_checkout() var carts = new InMemoryCartRepository(); var cart = await carts.CreateAsync(null, CancellationToken.None); var orders = new InMemoryOrderRepository(widgets); - var handler = new CheckoutHandler(carts, widgets, orders, new FlatRateShippingCalculator(), - new StateSalesTaxCalculator(new StaticStateTaxRateProvider()), new MockPaymentGateway(), new FakeEmailSender(), Clock()); + var handler = new CheckoutHandler(carts, widgets, orders, new OrderPricer(new FlatRateShippingCalculator(), new StateSalesTaxCalculator(new StaticStateTaxRateProvider())), new MockPaymentGateway(), new FakeEmailSender(), Clock()); var result = await handler.Handle(new CheckoutCommand(cart.Id, null, "jane@example.com", Address(), "Standard", "tok_ok"), CancellationToken.None); diff --git a/tests/WidgetWorks.UnitTests/ConfirmPaymentTests.cs b/tests/WidgetWorks.UnitTests/ConfirmPaymentTests.cs index 4eaa7bf..e421d55 100644 --- a/tests/WidgetWorks.UnitTests/ConfirmPaymentTests.cs +++ b/tests/WidgetWorks.UnitTests/ConfirmPaymentTests.cs @@ -2,6 +2,7 @@ using WidgetWorks.Application.Abstractions; using WidgetWorks.Application.Checkout.ConfirmPayment; using WidgetWorks.Application.Checkout.PlaceOrder; +using WidgetWorks.Application.Pricing; using WidgetWorks.Domain.Catalog; using WidgetWorks.Domain.Orders; using WidgetWorks.Infrastructure.Payments; @@ -31,8 +32,7 @@ private sealed record Ctx(InMemoryWidgetRepository Widgets, InMemoryOrderReposit var orders = new InMemoryOrderRepository(widgets); var email = new FakeEmailSender(); - var handler = new CheckoutHandler(carts, widgets, orders, new FlatRateShippingCalculator(), - new StateSalesTaxCalculator(new StaticStateTaxRateProvider()), new MockPaymentGateway(), email, Clock()); + var handler = new CheckoutHandler(carts, widgets, orders, new OrderPricer(new FlatRateShippingCalculator(), new StateSalesTaxCalculator(new StaticStateTaxRateProvider())), new MockPaymentGateway(), email, Clock()); var result = await handler.Handle( new CheckoutCommand(cart.Id, null, "jane@example.com", Address(), "Standard", "klarna_demo"), CancellationToken.None); diff --git a/tests/WidgetWorks.UnitTests/DeleteWidgetTests.cs b/tests/WidgetWorks.UnitTests/DeleteWidgetTests.cs index bee4cec..9e76d42 100644 --- a/tests/WidgetWorks.UnitTests/DeleteWidgetTests.cs +++ b/tests/WidgetWorks.UnitTests/DeleteWidgetTests.cs @@ -23,7 +23,11 @@ private static (InMemoryWidgetRepository Repo, Widget Widget) Seed(int orderLine var repo = new InMemoryWidgetRepository(); var widget = new Widget { - Id = Guid.NewGuid(), Sku = "WW-900", Name = "Doomed", IsActive = true, QuantityOnHand = 5, + Id = Guid.NewGuid(), + Sku = "WW-900", + Name = "Doomed", + IsActive = true, + QuantityOnHand = 5, }; repo.Store[widget.Id] = widget; if (orderLines > 0) diff --git a/tests/WidgetWorks.UnitTests/EmailMessageTests.cs b/tests/WidgetWorks.UnitTests/EmailMessageTests.cs new file mode 100644 index 0000000..99d9071 --- /dev/null +++ b/tests/WidgetWorks.UnitTests/EmailMessageTests.cs @@ -0,0 +1,215 @@ +using System.Net.Mime; +using System.Text; +using WidgetWorks.Application.Abstractions; +using WidgetWorks.Application.Notifications; +using WidgetWorks.Domain.Orders; +using WidgetWorks.Infrastructure.Email; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// What actually lands in someone's inbox. Two real bugs live here in history: the HTML part +/// rendering blank because the message was assembled as two alternate views with no body, and a +/// widget name containing an ampersand corrupting the layout because values were interpolated into +/// HTML unescaped. Both are now assertions rather than memories. +/// +public class EmailMessageTests +{ + private static readonly EmailOptions Options = new() + { + FromAddress = "no-reply@widgetworks.demo", + FromName = "WidgetWorks", + Host = "localhost", + Port = 1025, + }; + + private static Order OrderWith(string widgetName, decimal price = 1234.56m) => new() + { + Id = Guid.NewGuid(), + OrderNumber = "WW-20260501-ABC123", + Email = "jane@example.com", + ShipName = "Jane Doe", + ShipLine1 = "1 Main St", + ShipCity = "Springfield", + ShipState = "CA", + ShipPostalCode = "90210", + ShipCountry = "US", + Subtotal = price, + ShippingMethod = "Standard", + Shipping = 6.99m, + TaxState = "CA", + TaxRate = 0.0725m, + Tax = 89.51m, + Total = price + 6.99m + 89.51m, + Status = OrderStatus.Paid, + CreatedAt = new DateTimeOffset(2026, 5, 1, 8, 0, 0, TimeSpan.Zero), + Items = + [ + new OrderItem + { + Id = Guid.NewGuid(), + WidgetId = Guid.NewGuid(), + Sku = "WW-001", + Name = widgetName, + UnitPrice = price, + Quantity = 1, + LineSubtotal = price, + }, + ], + }; + + // ---- MIME shape ---------------------------------------------------------------------- + + [Fact] + public void The_body_is_plain_text_with_html_as_the_alternative() + { + var message = new EmailMessage("jane@example.com", "Subject", "

Hi

", "Hi"); + + using var mail = SmtpEmailSender.BuildMailMessage(Options, message); + + // The shape that made the HTML part render as blank was: empty Body + two alternate views. + Assert.Equal("Hi", mail.Body); + Assert.False(mail.IsBodyHtml); + var html = Assert.Single(mail.AlternateViews); + Assert.Equal(MediaTypeNames.Text.Html, html.ContentType.MediaType); + } + + [Fact] + public void Everything_is_utf8_so_accents_and_currency_survive() + { + var message = new EmailMessage("jörg@example.com", "Your order — £12.50 réservé", "

£

", "£12.50 réservé"); + + using var mail = SmtpEmailSender.BuildMailMessage(Options, message); + + Assert.Equal(Encoding.UTF8, mail.SubjectEncoding); + Assert.Equal(Encoding.UTF8, mail.BodyEncoding); + Assert.Equal(Encoding.UTF8, mail.AlternateViews[0].ContentType.CharSet is null + ? Encoding.UTF8 + : Encoding.GetEncoding(mail.AlternateViews[0].ContentType.CharSet!)); + } + + [Fact] + public void The_sender_and_recipient_come_from_configuration_and_the_message() + { + var message = new EmailMessage("jane@example.com", "Subject", "

Hi

", "Hi"); + + using var mail = SmtpEmailSender.BuildMailMessage(Options, message); + + Assert.Equal("no-reply@widgetworks.demo", mail.From!.Address); + Assert.Equal("WidgetWorks", mail.From.DisplayName); + Assert.Equal("jane@example.com", Assert.Single(mail.To).Address); + } + + // ---- template escaping --------------------------------------------------------------- + + [Fact] + public void A_widget_name_with_markup_characters_is_escaped_in_the_html() + { + var order = OrderWith("Widget & Co \"Special\""); + + var email = EmailTemplates.OrderReceived(order); + + // Escaped in HTML... + Assert.Contains("&", email.HtmlBody); + Assert.Contains("<Pro>", email.HtmlBody); + Assert.DoesNotContain("", email.HtmlBody); + + // ...and left alone in the text part, where it is not markup. + Assert.Contains("Widget & Co ", email.TextBody); + } + + [Fact] + public void The_html_is_a_document_with_a_charset_so_clients_do_not_guess() + { + var email = EmailTemplates.OrderReceived(OrderWith("Standard Widget")); + + Assert.Contains(" Assert.Equal(order.Email, e.To)); + } + + [Fact] + public void Account_emails_are_addressed_and_carry_their_link() + { + var welcome = AccountEmailTemplates.Welcome("jane@example.com"); + var reset = AccountEmailTemplates.PasswordReset("jane@example.com", "https://app.test/reset?token=abc"); + + Assert.Equal("jane@example.com", welcome.To); + Assert.NotEmpty(welcome.HtmlBody); + Assert.Equal("jane@example.com", reset.To); + Assert.Contains("https://app.test/reset?token=abc", reset.TextBody); + Assert.Contains("https://app.test/reset?token=abc", reset.HtmlBody); + } + + [Fact] + public void Every_template_supplies_both_a_text_and_an_html_part() + { + var order = OrderWith("Standard Widget"); + + EmailMessage[] all = + [ + EmailTemplates.OrderReceived(order), + EmailTemplates.OrderShipped(order), + EmailTemplates.OrderCancelled(order), + AccountEmailTemplates.Welcome("jane@example.com"), + AccountEmailTemplates.PasswordReset("jane@example.com", "https://app.test/r"), + ]; + + Assert.All(all, e => + { + Assert.False(string.IsNullOrWhiteSpace(e.Subject)); + Assert.False(string.IsNullOrWhiteSpace(e.TextBody)); + Assert.False(string.IsNullOrWhiteSpace(e.HtmlBody)); + }); + } +} diff --git a/tests/WidgetWorks.UnitTests/Fakes.cs b/tests/WidgetWorks.UnitTests/Fakes.cs index e3c002d..7675083 100644 --- a/tests/WidgetWorks.UnitTests/Fakes.cs +++ b/tests/WidgetWorks.UnitTests/Fakes.cs @@ -151,7 +151,17 @@ public Task DeleteAsync(Guid cartId, CancellationToken ct) return Task.CompletedTask; } - public Task TouchAsync(Guid cartId, DateTimeOffset now, CancellationToken ct) => Task.CompletedTask; + public Task TouchAsync(Guid cartId, DateTimeOffset now, CancellationToken ct) + { + // Mirrors the real repository, which stamps updated_at -- a no-op here would let a handler + // forget to touch the cart and still pass. + if (Store.TryGetValue(cartId, out var cart)) + { + cart.UpdatedAt = now; + } + + return Task.CompletedTask; + } private static Cart Clone(Cart c) => new() { diff --git a/tests/WidgetWorks.UnitTests/GoogleTokenValidatorTests.cs b/tests/WidgetWorks.UnitTests/GoogleTokenValidatorTests.cs new file mode 100644 index 0000000..17f17aa --- /dev/null +++ b/tests/WidgetWorks.UnitTests/GoogleTokenValidatorTests.cs @@ -0,0 +1,237 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using WidgetWorks.Infrastructure.Security; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// Google ID-token validation, exercised end to end against a locally generated RSA key served as +/// a JWKS by a stub transport. This is the only place a stranger's assertion becomes an account, so +/// it is tested by forging tokens: right shape, wrong signer; right signer, wrong audience; right +/// everything, expired. Each must be refused, and the refusal must be silent (null) rather than an +/// exception the endpoint would have to interpret. +/// +public class GoogleTokenValidatorTests +{ + private const string ClientId = "866620806528-test.apps.googleusercontent.com"; + + private static readonly RsaSecurityKey GoogleKey = new(RSA.Create(2048)) { KeyId = "test-key-1" }; + private static readonly RsaSecurityKey ImposterKey = new(RSA.Create(2048)) { KeyId = "test-key-1" }; + + private sealed class FixedClock(DateTimeOffset now) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => now; + } + + private static string Jwks(RsaSecurityKey key) + { + var jwk = JsonWebKeyConverter.ConvertFromRSASecurityKey(key); + jwk.KeyId = key.KeyId; + return JsonSerializer.Serialize(new { keys = new[] { jwk } }); + } + + private static string IdToken( + RsaSecurityKey signer, + string issuer = "https://accounts.google.com", + string audience = ClientId, + string? sub = "google-sub-123", + string? email = "jane@example.com", + bool emailVerified = true, + string? name = "Jane Doe", + int expiresInMinutes = 30) + { + var claims = new Dictionary(); + if (sub is not null) claims["sub"] = sub; + if (email is not null) claims["email"] = email; + if (name is not null) claims["name"] = name; + claims["email_verified"] = emailVerified; + + var descriptor = new SecurityTokenDescriptor + { + Issuer = issuer, + Audience = audience, + IssuedAt = DateTime.UtcNow.AddMinutes(-1), + NotBefore = DateTime.UtcNow.AddMinutes(-1), + Expires = DateTime.UtcNow.AddMinutes(expiresInMinutes), + SigningCredentials = new SigningCredentials(signer, SecurityAlgorithms.RsaSha256), + Claims = claims, + }; + + return new JsonWebTokenHandler().CreateToken(descriptor); + } + + private static (GoogleTokenValidator Validator, StubJwks Jwks) Build( + string? jwksBody = null, + HttpStatusCode status = HttpStatusCode.OK, + string clientId = ClientId) + { + var handler = new StubJwks(status, jwksBody ?? Jwks(GoogleKey)); + var validator = new GoogleTokenValidator( + new HttpClient(handler), + new GoogleOptions { ClientId = clientId }, + new FixedClock(DateTimeOffset.UtcNow)); + return (validator, handler); + } + + [Fact] + public async Task A_genuine_token_yields_the_identity() + { + var (validator, _) = Build(); + + var identity = await validator.ValidateAsync(IdToken(GoogleKey), CancellationToken.None); + + Assert.NotNull(identity); + Assert.Equal("google-sub-123", identity!.Subject); + Assert.Equal("jane@example.com", identity.Email); + Assert.True(identity.EmailVerified); + Assert.Equal("Jane Doe", identity.Name); + } + + [Fact] + public async Task A_token_signed_by_someone_else_is_refused() + { + var (validator, _) = Build(); + + // Same key id, same claims, different private key — the whole point of checking signatures. + var forged = IdToken(ImposterKey); + + Assert.Null(await validator.ValidateAsync(forged, CancellationToken.None)); + } + + [Fact] + public async Task A_token_for_another_application_is_refused() + { + var (validator, _) = Build(); + + var otherApp = IdToken(GoogleKey, audience: "someone-elses-client-id.apps.googleusercontent.com"); + + Assert.Null(await validator.ValidateAsync(otherApp, CancellationToken.None)); + } + + [Fact] + public async Task A_token_from_another_issuer_is_refused() + { + var (validator, _) = Build(); + + Assert.Null(await validator.ValidateAsync(IdToken(GoogleKey, issuer: "https://evil.test"), CancellationToken.None)); + } + + [Theory] + [InlineData("https://accounts.google.com")] + [InlineData("accounts.google.com")] + public async Task Both_issuer_spellings_google_uses_are_accepted(string issuer) + { + var (validator, _) = Build(); + + Assert.NotNull(await validator.ValidateAsync(IdToken(GoogleKey, issuer: issuer), CancellationToken.None)); + } + + [Fact] + public async Task An_expired_token_is_refused() + { + var (validator, _) = Build(); + + Assert.Null(await validator.ValidateAsync(IdToken(GoogleKey, expiresInMinutes: -30), CancellationToken.None)); + } + + [Fact] + public async Task A_token_without_an_email_is_refused() + { + var (validator, _) = Build(); + + // The app keys accounts on email; an identity without one cannot be provisioned. + Assert.Null(await validator.ValidateAsync(IdToken(GoogleKey, email: null), CancellationToken.None)); + } + + [Fact] + public async Task An_unverified_email_is_returned_but_flagged() + { + var (validator, _) = Build(); + + var identity = await validator.ValidateAsync(IdToken(GoogleKey, emailVerified: false), CancellationToken.None); + + // The validator reports; the login handler decides. Refusing here would hide the reason. + Assert.NotNull(identity); + Assert.False(identity!.EmailVerified); + } + + [Fact] + public async Task A_token_with_no_name_still_validates() + { + var (validator, _) = Build(); + + var identity = await validator.ValidateAsync(IdToken(GoogleKey, name: null), CancellationToken.None); + + Assert.NotNull(identity); + Assert.Null(identity!.Name); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not-a-jwt")] + public async Task Garbage_is_refused_without_calling_google(string token) + { + var (validator, jwks) = Build(); + + Assert.Null(await validator.ValidateAsync(token, CancellationToken.None)); + if (string.IsNullOrWhiteSpace(token)) + { + Assert.Equal(0, jwks.Calls); + } + } + + [Fact] + public async Task Google_sign_in_is_off_when_no_client_id_is_configured() + { + var (validator, jwks) = Build(clientId: ""); + + Assert.Null(await validator.ValidateAsync(IdToken(GoogleKey), CancellationToken.None)); + Assert.Equal(0, jwks.Calls); + } + + [Fact] + public async Task An_unreachable_key_endpoint_refuses_rather_than_throws() + { + var (validator, _) = Build(status: HttpStatusCode.ServiceUnavailable, jwksBody: "unavailable"); + + // Google being down must not surface as a 500 from our login endpoint. + Assert.Null(await validator.ValidateAsync(IdToken(GoogleKey), CancellationToken.None)); + } + + [Fact] + public async Task Malformed_key_material_refuses_rather_than_throws() + { + var (validator, _) = Build(jwksBody: "{ not json"); + + Assert.Null(await validator.ValidateAsync(IdToken(GoogleKey), CancellationToken.None)); + } + + [Fact] + public async Task The_key_set_is_fetched_once_and_reused() + { + var (validator, jwks) = Build(); + + await validator.ValidateAsync(IdToken(GoogleKey), CancellationToken.None); + await validator.ValidateAsync(IdToken(GoogleKey), CancellationToken.None); + await validator.ValidateAsync(IdToken(GoogleKey), CancellationToken.None); + + // Google's keys rotate slowly; refetching per sign-in would be a self-inflicted rate limit. + Assert.Equal(1, jwks.Calls); + } + + private sealed class StubJwks(HttpStatusCode status, string body) : HttpMessageHandler + { + public int Calls { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Calls++; + return Task.FromResult(new HttpResponseMessage(status) { Content = new StringContent(body) }); + } + } +} diff --git a/tests/WidgetWorks.UnitTests/OrderQueryTests.cs b/tests/WidgetWorks.UnitTests/OrderQueryTests.cs new file mode 100644 index 0000000..ab2846a --- /dev/null +++ b/tests/WidgetWorks.UnitTests/OrderQueryTests.cs @@ -0,0 +1,404 @@ +using Microsoft.Extensions.Time.Testing; +using WidgetWorks.Application.Carts.GetCart; +using WidgetWorks.Application.Carts.RemoveItem; +using WidgetWorks.Application.Orders; +using WidgetWorks.Application.Orders.Admin; +using WidgetWorks.Application.Orders.GetMine; +using WidgetWorks.Application.Orders.ListMine; +using WidgetWorks.Application.Orders.ListRecent; +using WidgetWorks.Application.Orders.Lookup; +using WidgetWorks.Application.TwoFactor.Enroll; +using WidgetWorks.Domain.Carts; +using WidgetWorks.Domain.Catalog; +using WidgetWorks.Domain.Orders; +using WidgetWorks.Domain.Users; +using WidgetWorks.UnitTests.Fakes; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// Read-side handlers and the projections they return. The case that matters beyond mapping is +/// ownership: "my order" must be unreachable by anyone else's id, and a guest lookup must require +/// the email that placed it. +/// +public class OrderQueryTests +{ + private static readonly DateTimeOffset Now = new(2026, 5, 1, 8, 0, 0, TimeSpan.Zero); + + private static Order MakeOrder(Guid? userId, string email = "jane@example.com", string number = "WW-20260501-ABC123", + string status = OrderStatus.Paid, DateTimeOffset? createdAt = null, params (string Sku, int Qty, decimal Price)[] lines) + { + var items = (lines.Length == 0 ? [("WW-1", 2, 12.50m)] : lines) + .Select(l => new OrderItem + { + Id = Guid.NewGuid(), + WidgetId = Guid.NewGuid(), + Sku = l.Item1, + Name = "Widget " + l.Item1, + UnitPrice = l.Item3, + Quantity = l.Item2, + LineSubtotal = l.Item3 * l.Item2, + }) + .ToList(); + + return new Order + { + Id = Guid.NewGuid(), + OrderNumber = number, + UserId = userId, + Email = email, + Subtotal = items.Sum(i => i.LineSubtotal), + ShippingMethod = "Standard", + Shipping = 6.99m, + TaxState = "CA", + TaxRate = 0.0725m, + Tax = 1.81m, + Total = items.Sum(i => i.LineSubtotal) + 6.99m + 1.81m, + Status = status, + PaymentProvider = "Mock", + PaymentReference = "mock_ref_1", + TrackingNumber = "1Z999AA10123456784", + CreatedAt = createdAt ?? Now, + UpdatedAt = createdAt ?? Now, + Items = items, + }; + } + + private static InMemoryOrderRepository Repo(params Order[] orders) + { + var repo = new InMemoryOrderRepository(new InMemoryWidgetRepository()); + repo.Orders.AddRange(orders); + return repo; + } + + // ---- my orders ----------------------------------------------------------------------- + + [Fact] + public async Task My_order_returns_the_full_view() + { + var userId = Guid.NewGuid(); + var order = MakeOrder(userId); + var result = await new GetMyOrderHandler(Repo(order)).Handle(new GetMyOrderQuery(userId, order.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + var view = result.Value!; + Assert.Equal(order.OrderNumber, view.OrderNumber); + Assert.Equal("CA", view.TaxState); + Assert.Equal(0.0725m, view.TaxRate); + Assert.Equal(order.Total, view.Total); + Assert.Equal("1Z999AA10123456784", view.TrackingNumber); + Assert.Single(view.Items); + } + + [Fact] + public async Task My_order_refuses_to_return_someone_elses_order() + { + var owner = Guid.NewGuid(); + var order = MakeOrder(owner); + + var result = await new GetMyOrderHandler(Repo(order)) + .Handle(new GetMyOrderQuery(Guid.NewGuid(), order.Id), CancellationToken.None); + + // Same wording as a missing order: knowing an id exists is itself a leak. + Assert.False(result.IsSuccess); + Assert.Equal("Order not found.", result.Error); + } + + [Fact] + public async Task My_order_refuses_a_guest_order_that_has_no_owner() + { + var order = MakeOrder(userId: null); + var result = await new GetMyOrderHandler(Repo(order)) + .Handle(new GetMyOrderQuery(Guid.NewGuid(), order.Id), CancellationToken.None); + + Assert.False(result.IsSuccess); + } + + [Fact] + public async Task My_order_fails_for_an_id_that_does_not_exist() + { + var result = await new GetMyOrderHandler(Repo()) + .Handle(new GetMyOrderQuery(Guid.NewGuid(), Guid.NewGuid()), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Order not found.", result.Error); + } + + [Fact] + public async Task My_orders_lists_only_mine_newest_first() + { + var me = Guid.NewGuid(); + var older = MakeOrder(me, number: "WW-1", createdAt: Now.AddDays(-2)); + var newer = MakeOrder(me, number: "WW-2", createdAt: Now); + var theirs = MakeOrder(Guid.NewGuid(), number: "WW-3"); + + var list = await new ListMyOrdersHandler(Repo(older, newer, theirs)) + .Handle(new ListMyOrdersQuery(me), CancellationToken.None); + + Assert.Equal(["WW-2", "WW-1"], list.Select(o => o.OrderNumber)); + } + + [Fact] + public async Task My_orders_is_empty_for_someone_with_no_orders() + { + var list = await new ListMyOrdersHandler(Repo(MakeOrder(Guid.NewGuid()))) + .Handle(new ListMyOrdersQuery(Guid.NewGuid()), CancellationToken.None); + + Assert.Empty(list); + } + + // ---- admin --------------------------------------------------------------------------- + + [Fact] + public async Task Admin_can_open_any_order_regardless_of_owner() + { + var order = MakeOrder(Guid.NewGuid()); + var result = await new GetOrderByIdHandler(Repo(order)).Handle(new GetOrderByIdQuery(order.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(order.OrderNumber, result.Value!.OrderNumber); + } + + [Fact] + public async Task Admin_order_lookup_fails_for_an_unknown_id() + { + var result = await new GetOrderByIdHandler(Repo()).Handle(new GetOrderByIdQuery(Guid.NewGuid()), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Order not found.", result.Error); + } + + [Fact] + public async Task Recent_orders_carries_the_item_count_from_the_lines() + { + var order = MakeOrder(Guid.NewGuid(), lines: [("A", 2, 5m), ("B", 3, 5m)]); + + var list = await new ListRecentOrdersHandler(Repo(order)).Handle(new ListRecentOrdersQuery(50), CancellationToken.None); + + // Regression: an "optimization" once skipped loading item rows, and every row showed 0. + Assert.Equal(5, Assert.Single(list).ItemCount); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(5000)] + public async Task Recent_orders_clamps_a_nonsense_limit_to_the_default(int limit) + { + var repo = Repo(Enumerable.Range(0, 60).Select(i => MakeOrder(Guid.NewGuid(), number: $"WW-{i}")).ToArray()); + + var list = await new ListRecentOrdersHandler(repo).Handle(new ListRecentOrdersQuery(limit), CancellationToken.None); + + Assert.Equal(50, list.Count); + } + + [Fact] + public async Task Recent_orders_honours_a_sensible_limit() + { + var repo = Repo(Enumerable.Range(0, 10).Select(i => MakeOrder(Guid.NewGuid(), number: $"WW-{i}")).ToArray()); + + var list = await new ListRecentOrdersHandler(repo).Handle(new ListRecentOrdersQuery(3), CancellationToken.None); + + Assert.Equal(3, list.Count); + } + + // ---- guest lookup -------------------------------------------------------------------- + + [Theory] + [InlineData("", "jane@example.com")] + [InlineData("WW-1", "")] + [InlineData(" ", " ")] + public async Task Guest_lookup_requires_both_fields(string number, string email) + { + var result = await new GuestOrderLookupHandler(Repo()) + .Handle(new GuestOrderLookupQuery(number, email), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Order number and email are required.", result.Error); + } + + [Fact] + public async Task Guest_lookup_finds_an_order_by_number_and_email() + { + var order = MakeOrder(userId: null, email: "guest@example.com", number: "WW-20260501-XYZ999"); + + var result = await new GuestOrderLookupHandler(Repo(order)) + .Handle(new GuestOrderLookupQuery(" WW-20260501-XYZ999 ", " guest@example.com "), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(order.Id, result.Value!.Id); + } + + [Fact] + public async Task Guest_lookup_with_the_wrong_email_finds_nothing() + { + var order = MakeOrder(userId: null, email: "guest@example.com"); + + var result = await new GuestOrderLookupHandler(Repo(order)) + .Handle(new GuestOrderLookupQuery(order.OrderNumber, "someone-else@example.com"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Order not found.", result.Error); + } + + // ---- projections --------------------------------------------------------------------- + + [Fact] + public void OrderView_copies_every_money_field_and_all_lines() + { + var order = MakeOrder(Guid.NewGuid(), lines: [("A", 1, 10m), ("B", 4, 2.50m)]); + + var view = OrderView.From(order); + + Assert.Equal(order.Subtotal, view.Subtotal); + Assert.Equal(order.Shipping, view.Shipping); + Assert.Equal(order.Tax, view.Tax); + Assert.Equal(order.Total, view.Total); + Assert.Equal(order.ShippingMethod, view.ShippingMethod); + Assert.Equal(order.PaymentProvider, view.PaymentProvider); + Assert.Equal(order.PaymentReference, view.PaymentReference); + Assert.Equal(order.CreatedAt, view.CreatedAt); + Assert.Equal(2, view.Items.Count); + Assert.Equal(10m, view.Items[0].LineSubtotal); + Assert.Equal(10m, view.Items[1].LineSubtotal); + } + + [Fact] + public void OrderSummary_counts_units_not_lines() + { + var order = MakeOrder(Guid.NewGuid(), lines: [("A", 2, 5m), ("B", 3, 5m)]); + + var summary = OrderSummary.From(order); + + Assert.Equal(5, summary.ItemCount); + Assert.Equal(order.Total, summary.Total); + Assert.Equal(order.Status, summary.Status); + } + + [Fact] + public void OrderView_of_an_order_with_no_tracking_leaves_it_null() + { + var order = MakeOrder(Guid.NewGuid()); + order.TrackingNumber = null; + + Assert.Null(OrderView.From(order).TrackingNumber); + } + + // ---- cart reads ---------------------------------------------------------------------- + + private sealed record CartCtx(InMemoryCartRepository Carts, InMemoryWidgetRepository Widgets, Cart Cart, Widget Widget); + + private static CartCtx CartSetup() + { + var widgets = new InMemoryWidgetRepository(); + var widget = new Widget + { + Id = Guid.NewGuid(), + Sku = "WW-1", + Name = "Standard Widget", + Price = 12.50m, + QuantityOnHand = 10, + IsActive = true, + }; + widgets.Store[widget.Id] = widget; + + var carts = new InMemoryCartRepository(); + var cart = new Cart { Id = Guid.NewGuid(), CreatedAt = Now, UpdatedAt = Now }; + cart.Items.Add(new CartItem { CartId = cart.Id, WidgetId = widget.Id, Quantity = 2 }); + carts.Store[cart.Id] = cart; + + return new CartCtx(carts, widgets, cart, widget); + } + + [Fact] + public async Task Get_cart_prices_the_lines() + { + var c = CartSetup(); + var result = await new GetCartHandler(c.Carts, c.Widgets).Handle(new GetCartQuery(c.Cart.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(2, result.Value!.ItemCount); + Assert.Equal(25.00m, result.Value.Subtotal); + } + + [Fact] + public async Task Get_cart_fails_for_an_unknown_cart() + { + var c = CartSetup(); + var result = await new GetCartHandler(c.Carts, c.Widgets).Handle(new GetCartQuery(Guid.NewGuid()), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Cart not found.", result.Error); + } + + [Fact] + public async Task Removing_an_item_empties_the_cart_and_touches_it() + { + var c = CartSetup(); + var clock = new FakeTimeProvider(Now.AddHours(1)); + + var result = await new RemoveCartItemHandler(c.Carts, c.Widgets, clock) + .Handle(new RemoveCartItemCommand(c.Cart.Id, c.Widget.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(0, result.Value!.ItemCount); + Assert.Empty(result.Value.Items); + Assert.Equal(Now.AddHours(1), c.Carts.Store[c.Cart.Id].UpdatedAt); + } + + [Fact] + public async Task Removing_an_item_that_is_not_in_the_cart_is_a_no_op() + { + var c = CartSetup(); + + var result = await new RemoveCartItemHandler(c.Carts, c.Widgets, new FakeTimeProvider(Now)) + .Handle(new RemoveCartItemCommand(c.Cart.Id, Guid.NewGuid()), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(2, result.Value!.ItemCount); + } + + [Fact] + public async Task Removing_from_an_unknown_cart_fails() + { + var c = CartSetup(); + + var result = await new RemoveCartItemHandler(c.Carts, c.Widgets, new FakeTimeProvider(Now)) + .Handle(new RemoveCartItemCommand(Guid.NewGuid(), c.Widget.Id), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Cart not found.", result.Error); + } + + // ---- 2FA enrollment start ------------------------------------------------------------ + + [Fact] + public async Task Enroll_stores_a_pending_secret_and_returns_the_otpauth_uri() + { + var users = new InMemoryUserRepository(); + var user = new User { Id = Guid.NewGuid(), Email = "jane@example.com", NormalizedEmail = "JANE@EXAMPLE.COM" }; + users.Store[user.Id] = user; + var twoFactor = new InMemoryTwoFactorRepository(); + + var result = await new EnrollHandler(users, twoFactor, new FakeTotpService()) + .Handle(new EnrollCommand(user.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("SECRETBASE32", result.Value!.SecretBase32); + Assert.StartsWith("otpauth://", result.Value.OtpAuthUri); + + // Pending, not confirmed: the code still has to be proven. + Assert.False(twoFactor.Secrets[user.Id].IsConfirmed); + } + + [Fact] + public async Task Enroll_fails_for_an_unknown_user() + { + var result = await new EnrollHandler(new InMemoryUserRepository(), new InMemoryTwoFactorRepository(), new FakeTotpService()) + .Handle(new EnrollCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("User not found.", result.Error); + } +} diff --git a/tests/WidgetWorks.UnitTests/OrderStateMachineTests.cs b/tests/WidgetWorks.UnitTests/OrderStateMachineTests.cs new file mode 100644 index 0000000..5b45131 --- /dev/null +++ b/tests/WidgetWorks.UnitTests/OrderStateMachineTests.cs @@ -0,0 +1,248 @@ +using WidgetWorks.Application.Carts; +using WidgetWorks.Application.Checkout.PlaceOrder; +using WidgetWorks.Application.Pricing; +using WidgetWorks.Domain.Orders; +using WidgetWorks.Infrastructure.Pricing; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// The fulfilment state machine, now owned by the order itself, and the pricer both quoting and +/// checkout share. The pricer is the interesting one: it exists so the total a shopper is shown and +/// the total they are charged cannot drift apart. +/// +public class OrderStateMachineTests +{ + private static readonly DateTimeOffset Now = new(2026, 6, 1, 10, 0, 0, TimeSpan.Zero); + + private static Order OrderIn(string status) => new() + { + Id = Guid.NewGuid(), + Status = status, + CreatedAt = Now.AddDays(-1), + UpdatedAt = Now.AddDays(-1), + }; + + // ---- allowed transitions ------------------------------------------------------------- + + [Theory] + [InlineData(OrderStatus.Paid, OrderStatus.Shipped)] + [InlineData(OrderStatus.Paid, OrderStatus.Cancelled)] + [InlineData(OrderStatus.Shipped, OrderStatus.Delivered)] + public void A_legal_transition_is_allowed(string from, string to) + { + Assert.True(OrderStatus.CanTransition(from, to)); + Assert.True(OrderIn(from).CanTransitionTo(to)); + } + + [Theory] + // Nothing ships before it is paid for -- including an order still settling. + [InlineData(OrderStatus.Pending, OrderStatus.Shipped)] + [InlineData(OrderStatus.AwaitingPayment, OrderStatus.Shipped)] + [InlineData(OrderStatus.AwaitingPayment, OrderStatus.Delivered)] + [InlineData(OrderStatus.PaymentFailed, OrderStatus.Shipped)] + // No skipping a step, and no going backwards. + [InlineData(OrderStatus.Paid, OrderStatus.Delivered)] + [InlineData(OrderStatus.Shipped, OrderStatus.Cancelled)] + [InlineData(OrderStatus.Shipped, OrderStatus.Paid)] + // Terminal states are terminal. + [InlineData(OrderStatus.Delivered, OrderStatus.Shipped)] + [InlineData(OrderStatus.Cancelled, OrderStatus.Paid)] + [InlineData(OrderStatus.Cancelled, OrderStatus.Shipped)] + // A status is not a transition to itself. + [InlineData(OrderStatus.Paid, OrderStatus.Paid)] + public void An_illegal_transition_is_refused(string from, string to) + { + Assert.False(OrderStatus.CanTransition(from, to)); + Assert.False(OrderIn(from).CanTransitionTo(to)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("Shipped ")] + [InlineData("shipped")] + [InlineData("Nonsense")] + public void An_unrecognized_target_is_refused_rather_than_matched_loosely(string? target) + { + Assert.False(OrderIn(OrderStatus.Paid).CanTransitionTo(target)); + } + + [Fact] + public void Allowed_next_reports_the_options_for_a_status() + { + Assert.Equal([OrderStatus.Shipped, OrderStatus.Cancelled], OrderStatus.AllowedNext(OrderStatus.Paid)); + Assert.Equal([OrderStatus.Delivered], OrderStatus.AllowedNext(OrderStatus.Shipped)); + Assert.Empty(OrderStatus.AllowedNext(OrderStatus.Delivered)); + Assert.Empty(OrderStatus.AllowedNext("not-a-status")); + Assert.Empty(OrderStatus.AllowedNext(null)); + } + + // ---- applying a transition ----------------------------------------------------------- + + [Fact] + public void Transitioning_sets_the_status_tracking_and_timestamp() + { + var order = OrderIn(OrderStatus.Paid); + + order.TransitionTo(OrderStatus.Shipped, " 1Z999AA10123456784 ", Now); + + Assert.Equal(OrderStatus.Shipped, order.Status); + Assert.Equal("1Z999AA10123456784", order.TrackingNumber); + Assert.Equal(Now, order.UpdatedAt); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Transitioning_without_a_tracking_number_keeps_the_existing_one(string? tracking) + { + var order = OrderIn(OrderStatus.Shipped); + order.TrackingNumber = "1Z-ORIGINAL"; + + order.TransitionTo(OrderStatus.Delivered, tracking, Now); + + // Marking delivered must not wipe the number the customer is tracking with. + Assert.Equal("1Z-ORIGINAL", order.TrackingNumber); + } + + [Fact] + public void Transitioning_illegally_throws_and_changes_nothing() + { + var order = OrderIn(OrderStatus.AwaitingPayment); + + var ex = Assert.Throws( + () => order.TransitionTo(OrderStatus.Shipped, "1Z-NEW", Now)); + + Assert.Contains("AwaitingPayment", ex.Message); + Assert.Equal(OrderStatus.AwaitingPayment, order.Status); + Assert.Null(order.TrackingNumber); + Assert.Equal(Now.AddDays(-1), order.UpdatedAt); + } + + [Fact] + public void Unit_count_sums_quantities_rather_than_counting_lines() + { + var order = OrderIn(OrderStatus.Paid); + order.Items = + [ + new OrderItem { Quantity = 2 }, + new OrderItem { Quantity = 3 }, + ]; + + Assert.Equal(5, order.UnitCount); + Assert.Equal(0, OrderIn(OrderStatus.Paid).UnitCount); + } + + // ---- the shared pricer --------------------------------------------------------------- + + private static readonly OrderPricer Pricer = new( + new FlatRateShippingCalculator(), + new StateSalesTaxCalculator(new StaticStateTaxRateProvider())); + + private static CartView Cart(decimal subtotal, int itemCount) => + new(Guid.NewGuid(), null, [], subtotal, itemCount); + + [Fact] + public void Pricing_adds_shipping_and_tax_to_the_subtotal() + { + var priced = Pricer.Price(Cart(20m, 2), "CA", "Standard"); + + Assert.Equal(20m, priced.Subtotal); + Assert.Equal(7.74m, priced.Shipping); // 6.99 + one extra item at 0.75 + Assert.Equal(0.0725m, priced.TaxRate); + Assert.Equal(1.45m, priced.Tax); // 20 * 0.0725 + Assert.Equal(29.19m, priced.Total); + Assert.False(priced.IsEmpty); + } + + [Fact] + public void Tax_is_charged_on_the_subtotal_only_never_on_shipping() + { + var standard = Pricer.Price(Cart(20m, 1), "CA", "Standard"); + var express = Pricer.Price(Cart(20m, 1), "CA", "Express"); + + Assert.NotEqual(standard.Shipping, express.Shipping); + Assert.Equal(standard.Tax, express.Tax); + } + + [Theory] + [InlineData("OR")] + [InlineData("AK")] + [InlineData("DE")] + [InlineData("MT")] + [InlineData("NH")] + [InlineData("XX")] + [InlineData("")] + [InlineData(null)] + public void A_state_with_no_rate_costs_no_tax(string? state) + { + var priced = Pricer.Price(Cart(100m, 1), state, "Standard"); + + Assert.Equal(0m, priced.Tax); + Assert.Equal(0m, priced.TaxRate); + Assert.Equal(100m, priced.Total); // free shipping over 75, no tax + } + + [Fact] + public void An_empty_cart_is_not_charged_for_delivery() + { + var priced = Pricer.Price(Cart(0m, 0), "CA", "Express"); + + Assert.True(priced.IsEmpty); + Assert.Equal(0m, priced.Shipping); + Assert.Equal(0m, priced.Total); + } + + [Fact] + public void The_quote_and_the_charge_are_the_same_calculation() + { + // Same inputs through the one component both paths use: they cannot drift. + var cart = Cart(89.97m, 3); + + var shown = Pricer.Price(cart, "CA", "Standard"); + var charged = Pricer.Price(cart, "CA", "Standard"); + + Assert.Equal(shown, charged); + Assert.Equal(0m, shown.Shipping); // over the free threshold + Assert.Equal(6.52m, shown.Tax); // round(89.97 * 0.0725) + Assert.Equal(96.49m, shown.Total); + } + + // ---- order drafting ------------------------------------------------------------------ + + [Fact] + public void An_order_number_is_dated_and_short_enough_to_read_out() + { + var id = Guid.Parse("a1b2c3d4-0000-0000-0000-000000000000"); + + var number = OrderDraft.NumberFor(new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero), id); + + Assert.Equal("WW-20260501-A1B2C3", number); + } + + [Fact] + public void A_drafted_order_snapshots_the_price_and_normalizes_the_address() + { + var cart = new CartView(Guid.NewGuid(), null, + [new CartLineView(Guid.NewGuid(), "WW-1", "Standard Widget", 10m, 2, 5, 20m)], 20m, 2); + var priced = Pricer.Price(cart, "ca", "Standard"); + var address = new ShippingAddressInput(" Jane Doe ", " 1 Main St ", " ", " Springfield ", " ca ", " 90210 ", null); + + var order = OrderDraft.Create(cart, priced, address, "jane@example.com", null, Now, Guid.NewGuid(), Guid.NewGuid); + + Assert.Equal("Jane Doe", order.ShipName); + Assert.Equal("1 Main St", order.ShipLine1); + Assert.Null(order.ShipLine2); // whitespace-only becomes absent, not blank + Assert.Equal("CA", order.ShipState); + Assert.Equal("US", order.ShipCountry); // defaulted + Assert.Equal(OrderStatus.Pending, order.Status); + Assert.Equal(priced.Tax, order.Tax); + Assert.Equal(priced.TaxRate, order.TaxRate); + Assert.Equal(priced.Total, order.Total); + Assert.Equal(2, order.UnitCount); + Assert.Equal(Now, order.CreatedAt); + } +} diff --git a/tests/WidgetWorks.UnitTests/PricingTests.cs b/tests/WidgetWorks.UnitTests/PricingTests.cs index d29b2e0..d036d37 100644 --- a/tests/WidgetWorks.UnitTests/PricingTests.cs +++ b/tests/WidgetWorks.UnitTests/PricingTests.cs @@ -1,4 +1,5 @@ using WidgetWorks.Application.Checkout.Quote; +using WidgetWorks.Application.Pricing; using WidgetWorks.Domain.Catalog; using WidgetWorks.Infrastructure.Pricing; using WidgetWorks.UnitTests.Fakes; @@ -73,7 +74,7 @@ public async Task Quote_assembles_subtotal_shipping_tax_total() var cart = await carts.CreateAsync(null, CancellationToken.None); await carts.UpsertItemAsync(cart.Id, widget.Id, 2, default, CancellationToken.None); - var handler = new QuoteCartHandler(carts, widgets, _shipping, _tax); + var handler = new QuoteCartHandler(carts, widgets, new OrderPricer(_shipping, _tax)); var result = await handler.Handle(new QuoteCartCommand(cart.Id, "CA", "Standard"), CancellationToken.None); Assert.True(result.IsSuccess); @@ -91,7 +92,7 @@ public async Task Quote_for_empty_cart_is_all_zero() var carts = new InMemoryCartRepository(); var cart = await carts.CreateAsync(null, CancellationToken.None); - var handler = new QuoteCartHandler(carts, widgets, _shipping, _tax); + var handler = new QuoteCartHandler(carts, widgets, new OrderPricer(_shipping, _tax)); var result = await handler.Handle(new QuoteCartCommand(cart.Id, "CA", "Standard"), CancellationToken.None); Assert.True(result.IsSuccess); diff --git a/tests/WidgetWorks.UnitTests/SmallServicesTests.cs b/tests/WidgetWorks.UnitTests/SmallServicesTests.cs new file mode 100644 index 0000000..02d66f5 --- /dev/null +++ b/tests/WidgetWorks.UnitTests/SmallServicesTests.cs @@ -0,0 +1,243 @@ +using Microsoft.Extensions.Time.Testing; +using WidgetWorks.Application.Carts.UpdateItem; +using WidgetWorks.Application.Catalog; +using WidgetWorks.Domain.Carts; +using WidgetWorks.Domain.Catalog; +using WidgetWorks.Infrastructure.Security; +using WidgetWorks.UnitTests.Fakes; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// The small pieces that are easy to leave untested and expensive to get wrong: the two token +/// generators (where "random" and "one-way" are the whole contract), the catalog read model, and +/// the cart quantity rules. +/// +public class SmallServicesTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 1, 12, 0, 0, TimeSpan.Zero); + + // ---- recovery codes ------------------------------------------------------------------ + + [Fact] + public void Recovery_codes_are_unique_lowercase_hex_and_stored_only_as_hashes() + { + var codes = new RecoveryCodeService().Generate(10); + + Assert.Equal(10, codes.Count); + Assert.Equal(10, codes.Select(c => c.Plain).Distinct().Count()); + Assert.All(codes, c => + { + Assert.Equal(10, c.Plain.Length); // 5 bytes as hex + Assert.Equal(c.Plain.ToLowerInvariant(), c.Plain); // typed by a human under stress + Assert.NotEqual(c.Plain, c.Hash); // never stored in the clear + }); + } + + [Fact] + public void Recovery_code_hashing_is_deterministic_and_case_sensitive() + { + var service = new RecoveryCodeService(); + + Assert.Equal(service.Hash("abc123"), service.Hash("abc123")); + Assert.NotEqual(service.Hash("abc123"), service.Hash("abc124")); + + // The login handler lowercases before hashing; the hash itself must not do it silently. + Assert.NotEqual(service.Hash("ABC123"), service.Hash("abc123")); + } + + [Fact] + public void Generating_zero_codes_is_allowed_and_yields_nothing() + { + Assert.Empty(new RecoveryCodeService().Generate(0)); + } + + // ---- opaque tokens ------------------------------------------------------------------- + + [Fact] + public void Secure_tokens_are_url_safe_and_never_repeat() + { + var generator = new SecureTokenGenerator(); + + var tokens = Enumerable.Range(0, 50).Select(_ => generator.Generate()).ToList(); + + Assert.Equal(50, tokens.Distinct().Count()); + Assert.All(tokens, t => + { + // base64url: it ends up in a reset link, so + / = would need escaping. + Assert.DoesNotContain('+', t); + Assert.DoesNotContain('/', t); + Assert.DoesNotContain('=', t); + Assert.True(t.Length >= 42); + }); + } + + [Fact] + public void Secure_token_hashing_is_deterministic_and_one_way() + { + var generator = new SecureTokenGenerator(); + var raw = generator.Generate(); + + var hash = generator.Hash(raw); + + Assert.Equal(hash, generator.Hash(raw)); + Assert.NotEqual(raw, hash); + Assert.Equal(64, hash.Length); // SHA-256 as hex + Assert.NotEqual(hash, generator.Hash(raw + "x")); + } + + // ---- catalog read model -------------------------------------------------------------- + + [Fact] + public void The_widget_view_reports_availability_net_of_reservations() + { + var widget = new Widget + { + Id = Guid.NewGuid(), + Sku = "WW-001", + Name = "Standard Widget", + Description = "Dependable.", + ImageUrl = null, + Price = 9.99m, + IsActive = true, + QuantityOnHand = 10, + QuantityReserved = 4, + }; + + var view = WidgetView.From(widget); + + Assert.Equal(widget.Id, view.Id); + Assert.Equal("WW-001", view.Sku); + Assert.Equal(9.99m, view.Price); + Assert.True(view.IsActive); + Assert.Equal(10, view.QuantityOnHand); + Assert.Equal(4, view.QuantityReserved); + + // What a shopper can actually buy — reserved stock belongs to someone else's order. + Assert.Equal(6, view.QuantityAvailable); + } + + [Fact] + public void Availability_never_goes_negative_even_if_reservations_exceed_stock() + { + var widget = new Widget { Id = Guid.NewGuid(), QuantityOnHand = 2, QuantityReserved = 5 }; + + Assert.Equal(0, WidgetView.From(widget).QuantityAvailable); + } + + // ---- cart quantity rules ------------------------------------------------------------- + + private sealed record Ctx(InMemoryCartRepository Carts, InMemoryWidgetRepository Widgets, Cart Cart, Widget Widget); + + private static Ctx Setup(int available = 5, bool active = true) + { + var widgets = new InMemoryWidgetRepository(); + var widget = new Widget + { + Id = Guid.NewGuid(), + Sku = "WW-001", + Name = "Standard Widget", + Price = 10m, + QuantityOnHand = available, + IsActive = active, + }; + widgets.Store[widget.Id] = widget; + + var carts = new InMemoryCartRepository(); + var cart = new Cart { Id = Guid.NewGuid(), CreatedAt = Now, UpdatedAt = Now }; + cart.Items.Add(new CartItem { CartId = cart.Id, WidgetId = widget.Id, Quantity = 2 }); + carts.Store[cart.Id] = cart; + + return new Ctx(carts, widgets, cart, widget); + } + + private static UpdateCartItemHandler Handler(Ctx c) => new(c.Carts, c.Widgets, new FakeTimeProvider(Now)); + + [Fact] + public async Task Setting_a_quantity_of_zero_removes_the_line() + { + var c = Setup(); + + var result = await Handler(c).Handle(new UpdateCartItemCommand(c.Cart.Id, c.Widget.Id, 0), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Empty(result.Value!.Items); + } + + [Fact] + public async Task A_negative_quantity_removes_the_line_rather_than_erroring() + { + var c = Setup(); + + var result = await Handler(c).Handle(new UpdateCartItemCommand(c.Cart.Id, c.Widget.Id, -3), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Empty(result.Value!.Items); + } + + [Fact] + public async Task Asking_for_more_than_exists_caps_at_what_is_available() + { + var c = Setup(available: 5); + + var result = await Handler(c).Handle(new UpdateCartItemCommand(c.Cart.Id, c.Widget.Id, 99), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(5, result.Value!.Items.Single().Quantity); + } + + [Fact] + public async Task An_out_of_stock_widget_is_refused_with_a_reason() + { + var c = Setup(available: 0); + + var result = await Handler(c).Handle(new UpdateCartItemCommand(c.Cart.Id, c.Widget.Id, 1), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("This widget is out of stock.", result.Error); + } + + [Fact] + public async Task A_hidden_widget_cannot_be_added_to_a_cart() + { + var c = Setup(active: false); + + var result = await Handler(c).Handle(new UpdateCartItemCommand(c.Cart.Id, c.Widget.Id, 1), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Widget not found.", result.Error); + } + + [Fact] + public async Task An_unknown_widget_is_refused() + { + var c = Setup(); + + var result = await Handler(c).Handle(new UpdateCartItemCommand(c.Cart.Id, Guid.NewGuid(), 1), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Widget not found.", result.Error); + } + + [Fact] + public async Task An_unknown_cart_is_refused() + { + var c = Setup(); + + var result = await Handler(c).Handle(new UpdateCartItemCommand(Guid.NewGuid(), c.Widget.Id, 1), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Cart not found.", result.Error); + } + + [Fact] + public async Task Updating_a_quantity_touches_the_cart() + { + var c = Setup(); + + await Handler(c).Handle(new UpdateCartItemCommand(c.Cart.Id, c.Widget.Id, 3), CancellationToken.None); + + Assert.Equal(Now, c.Carts.Store[c.Cart.Id].UpdatedAt); + } +} diff --git a/tests/WidgetWorks.UnitTests/StripeGatewayTests.cs b/tests/WidgetWorks.UnitTests/StripeGatewayTests.cs new file mode 100644 index 0000000..dc71f43 --- /dev/null +++ b/tests/WidgetWorks.UnitTests/StripeGatewayTests.cs @@ -0,0 +1,215 @@ +using System.Net; +using Microsoft.Extensions.Options; +using WidgetWorks.Application.Abstractions; +using WidgetWorks.Infrastructure.Payments; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// The Stripe adapter, driven through a stub transport. Two things are worth pinning down: the +/// request Stripe actually receives (amount in minor units, the order number in metadata so the +/// webhook can find the order again), and the mapping from PaymentIntent status to the three +/// outcomes checkout branches on — because a status mapped to the wrong branch either ships goods +/// that were never paid for or cancels an order that was. +/// +public class StripeGatewayTests +{ + private static (StripePaymentGateway Gateway, StubHandler Handler) Build( + HttpStatusCode status = HttpStatusCode.OK, + string body = """{"id":"pi_1","status":"succeeded"}""", + string secretKey = "sk_test_key") + { + var handler = new StubHandler(status, body); + var gateway = new StripePaymentGateway( + new HttpClient(handler), + Options.Create(new StripeOptions { SecretKey = secretKey, ApiBase = "https://api.stripe.test" })); + return (gateway, handler); + } + + private static PaymentRequest Request(decimal amount = 29.19m, string? token = "pm_card_visa") + => new("WW-20260501-ABC123", amount, "usd", "jane@example.com", token); + + [Fact] + public async Task It_declines_without_calling_stripe_when_no_key_is_configured() + { + var (gateway, handler) = Build(secretKey: ""); + + var result = await gateway.ChargeAsync(Request(), CancellationToken.None); + + Assert.Equal(PaymentStatus.Declined, result.Status); + Assert.Equal("Stripe is not configured.", result.Error); + Assert.Equal(0, handler.Calls); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task It_refuses_a_non_positive_amount_without_calling_stripe(decimal amount) + { + var (gateway, handler) = Build(); + + var result = await gateway.ChargeAsync(Request(amount), CancellationToken.None); + + Assert.Equal(PaymentStatus.Declined, result.Status); + Assert.Equal("Amount must be positive.", result.Error); + Assert.Equal(0, handler.Calls); + } + + [Fact] + public async Task It_sends_the_amount_in_minor_units_and_the_order_number_in_metadata() + { + var (gateway, handler) = Build(); + + await gateway.ChargeAsync(Request(29.19m), CancellationToken.None); + + Assert.Contains("amount=2919", handler.LastBody); + Assert.Contains("currency=usd", handler.LastBody); + Assert.Contains("payment_method=pm_card_visa", handler.LastBody); + + // How the webhook correlates back to the order later. + Assert.Contains("WW-20260501-ABC123", handler.LastBody); + Assert.Equal("Bearer sk_test_key", handler.LastAuthorization); + Assert.Contains("/v1/payment_intents", handler.LastUrl); + } + + [Fact] + public async Task Rounding_to_minor_units_is_half_away_from_zero() + { + var (gateway, handler) = Build(); + + await gateway.ChargeAsync(Request(10.005m), CancellationToken.None); + + // 1000.5 minor units must bill as 1001, not 1000. + Assert.Contains("amount=1001", handler.LastBody); + } + + [Fact] + public async Task A_missing_token_falls_back_to_the_test_card() + { + var (gateway, handler) = Build(); + + await gateway.ChargeAsync(Request(token: null), CancellationToken.None); + + Assert.Contains("payment_method=pm_card_visa", handler.LastBody); + } + + [Fact] + public async Task A_succeeded_intent_is_a_completed_payment() + { + var (gateway, _) = Build(body: """{"id":"pi_abc","status":"succeeded"}"""); + + var result = await gateway.ChargeAsync(Request(), CancellationToken.None); + + Assert.Equal(PaymentStatus.Succeeded, result.Status); + Assert.Equal("pi_abc", result.Reference); + Assert.Equal("Stripe", result.Provider); + } + + [Theory] + [InlineData("requires_action")] + [InlineData("requires_confirmation")] + [InlineData("processing")] + public async Task An_unsettled_intent_parks_the_order_rather_than_failing_it(string status) + { + var (gateway, _) = Build(body: $$"""{"id":"pi_abc","status":"{{status}}","client_secret":"cs_123"}"""); + + var result = await gateway.ChargeAsync(Request(), CancellationToken.None); + + Assert.Equal(PaymentStatus.Pending, result.Status); + Assert.Equal("cs_123", result.ClientSecret); + } + + [Fact] + public async Task A_redirect_url_is_pulled_out_of_next_action() + { + const string body = """ + {"id":"pi_abc","status":"requires_action","next_action":{"redirect_to_url":{"url":"https://hooks.test/go"}}} + """; + var (gateway, _) = Build(body: body); + + var result = await gateway.ChargeAsync(Request(), CancellationToken.None); + + Assert.Equal("https://hooks.test/go", result.NextActionUrl); + } + + [Theory] + [InlineData("""{"id":"pi_abc","status":"requires_action"}""")] + [InlineData("""{"id":"pi_abc","status":"requires_action","next_action":null}""")] + [InlineData("""{"id":"pi_abc","status":"requires_action","next_action":{"type":"use_stripe_sdk"}}""")] + public async Task A_missing_redirect_is_null_rather_than_a_crash(string body) + { + var (gateway, _) = Build(body: body); + + var result = await gateway.ChargeAsync(Request(), CancellationToken.None); + + Assert.Equal(PaymentStatus.Pending, result.Status); + Assert.Null(result.NextActionUrl); + } + + [Theory] + [InlineData("canceled")] + [InlineData("requires_payment_method")] + [InlineData("")] + public async Task Any_other_status_is_a_decline(string status) + { + var (gateway, _) = Build(body: $$"""{"id":"pi_abc","status":"{{status}}"}"""); + + var result = await gateway.ChargeAsync(Request(), CancellationToken.None); + + Assert.Equal(PaymentStatus.Declined, result.Status); + Assert.Contains(status, result.Error); + } + + [Fact] + public async Task An_http_error_is_a_decline_carrying_the_status_code() + { + var (gateway, _) = Build(HttpStatusCode.PaymentRequired, """{"error":{"message":"card declined"}}"""); + + var result = await gateway.ChargeAsync(Request(), CancellationToken.None); + + Assert.Equal(PaymentStatus.Declined, result.Status); + Assert.Equal("Stripe returned 402.", result.Error); + } + + [Fact] + public async Task An_intent_with_no_id_still_produces_a_usable_reference() + { + var (gateway, _) = Build(body: """{"status":"succeeded"}"""); + + var result = await gateway.ChargeAsync(Request(), CancellationToken.None); + + Assert.Equal(PaymentStatus.Succeeded, result.Status); + Assert.Equal("unknown", result.Reference); + } + + [Fact] + public void The_adapter_names_itself_so_the_webhook_route_can_match_it() + { + var (gateway, _) = Build(); + + Assert.Equal("Stripe", gateway.Name); + } + + /// Records what was sent and replies with a canned response — no network involved. + private sealed class StubHandler(HttpStatusCode status, string body) : HttpMessageHandler + { + public int Calls { get; private set; } + + public string LastBody { get; private set; } = string.Empty; + + public string LastUrl { get; private set; } = string.Empty; + + public string? LastAuthorization { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Calls++; + LastUrl = request.RequestUri?.ToString() ?? string.Empty; + LastAuthorization = request.Headers.Authorization?.ToString(); + LastBody = request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken); + + return new HttpResponseMessage(status) { Content = new StringContent(body) }; + } + } +} diff --git a/tests/WidgetWorks.UnitTests/TwoFactorManagementTests.cs b/tests/WidgetWorks.UnitTests/TwoFactorManagementTests.cs new file mode 100644 index 0000000..c286100 --- /dev/null +++ b/tests/WidgetWorks.UnitTests/TwoFactorManagementTests.cs @@ -0,0 +1,271 @@ +using Microsoft.Extensions.Time.Testing; +using WidgetWorks.Application.Abstractions; +using WidgetWorks.Application.TwoFactor.Confirm; +using WidgetWorks.Application.TwoFactor.Disable; +using WidgetWorks.Application.TwoFactor.Recovery; +using WidgetWorks.Domain.Users; +using WidgetWorks.UnitTests.Fakes; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// Confirming enrollment, disabling 2FA, and signing in with a recovery code. Two invariants are +/// under test throughout: any change to a factor rotates the security stamp (killing other +/// sessions), and a recovery code works exactly once. +/// +public class TwoFactorManagementTests +{ + private static readonly DateTimeOffset Now = new(2026, 4, 1, 9, 30, 0, TimeSpan.Zero); + + private sealed record Ctx( + FakeTimeProvider Clock, + InMemoryUserRepository Users, + InMemoryTwoFactorRepository TwoFactor, + InMemoryRefreshTokenRepository Refresh, + FakeTotpService Totp, + FakeRecoveryCodes Recovery, + StubTokenService Tokens, + RecordingAuditLog Audit, + User User); + + private static Ctx Setup(bool twoFactorEnabled = false) + { + var users = new InMemoryUserRepository(); + var user = new User + { + Id = Guid.NewGuid(), + Email = "jane@example.com", + NormalizedEmail = "JANE@EXAMPLE.COM", + PasswordHash = "hash:pw", + Role = UserRoles.Customer, + SecurityStamp = Guid.NewGuid(), + TwoFactorEnabled = twoFactorEnabled, + }; + users.Store[user.Id] = user; + return new Ctx(new FakeTimeProvider(Now), users, new InMemoryTwoFactorRepository(), + new InMemoryRefreshTokenRepository(), new FakeTotpService(), new FakeRecoveryCodes(), + new StubTokenService(), new RecordingAuditLog(), user); + } + + private static ConfirmEnrollHandler Confirm(Ctx c) + => new(c.Users, c.TwoFactor, c.Totp, c.Recovery, c.Audit, c.Clock); + + private static DisableTwoFactorHandler Disable(Ctx c) + => new(c.Users, c.TwoFactor, c.Audit); + + private static RecoveryLoginHandler RecoveryLogin(Ctx c) + => new(c.Users, c.Refresh, c.TwoFactor, c.Recovery, c.Tokens, c.Audit, c.Clock); + + // ---- confirm enrollment -------------------------------------------------------------- + + [Fact] + public async Task Confirm_fails_for_an_unknown_user() + { + var c = Setup(); + var result = await Confirm(c).Handle(new ConfirmEnrollCommand(Guid.NewGuid(), "654321"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("User not found.", result.Error); + } + + [Fact] + public async Task Confirm_fails_when_enrollment_was_never_started() + { + var c = Setup(); + var result = await Confirm(c).Handle(new ConfirmEnrollCommand(c.User.Id, "654321"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("No pending 2FA enrollment. Start enrollment first.", result.Error); + } + + [Fact] + public async Task Confirm_rejects_a_wrong_code_and_leaves_2fa_off() + { + var c = Setup(); + await c.TwoFactor.UpsertPendingSecretAsync(c.User.Id, "SECRETBASE32", CancellationToken.None); + + var result = await Confirm(c).Handle(new ConfirmEnrollCommand(c.User.Id, "000000"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Invalid authenticator code.", result.Error); + Assert.False(c.User.TwoFactorEnabled); + Assert.False(c.TwoFactor.Secrets[c.User.Id].IsConfirmed); + } + + [Fact] + public async Task Confirm_enables_2fa_issues_recovery_codes_and_rotates_the_stamp() + { + var c = Setup(); + var stampBefore = c.User.SecurityStamp; + await c.TwoFactor.UpsertPendingSecretAsync(c.User.Id, "SECRETBASE32", CancellationToken.None); + + var result = await Confirm(c).Handle(new ConfirmEnrollCommand(c.User.Id, c.Totp.ValidCode), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(10, result.Value!.RecoveryCodes.Count); + Assert.Equal(10, result.Value.RecoveryCodes.Distinct().Count()); + Assert.True(c.User.TwoFactorEnabled); + Assert.True(c.TwoFactor.Secrets[c.User.Id].IsConfirmed); + + // Turning on a factor must sign other devices out. + Assert.NotEqual(stampBefore, c.User.SecurityStamp); + Assert.Contains("2fa.enabled", c.Audit.Actions); + } + + [Fact] + public async Task Confirm_replaces_any_previous_recovery_codes() + { + var c = Setup(); + await c.TwoFactor.UpsertPendingSecretAsync(c.User.Id, "SECRETBASE32", CancellationToken.None); + await c.TwoFactor.AddRecoveryCodesAsync(c.User.Id, [c.Recovery.Hash("stale-code")], Now, CancellationToken.None); + + await Confirm(c).Handle(new ConfirmEnrollCommand(c.User.Id, c.Totp.ValidCode), CancellationToken.None); + + // The old set is gone, not merged with the new one. + var stillUsable = await c.TwoFactor.ConsumeRecoveryCodeAsync( + c.User.Id, c.Recovery.Hash("stale-code"), Now, CancellationToken.None); + Assert.False(stillUsable); + } + + // ---- disable ------------------------------------------------------------------------- + + [Fact] + public async Task Disable_fails_for_an_unknown_user() + { + var c = Setup(); + var result = await Disable(c).Handle(new DisableTwoFactorCommand(Guid.NewGuid()), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("User not found.", result.Error); + } + + [Fact] + public async Task Disable_clears_the_secret_and_the_recovery_codes() + { + var c = Setup(twoFactorEnabled: true); + var stampBefore = c.User.SecurityStamp; + await c.TwoFactor.UpsertPendingSecretAsync(c.User.Id, "SECRETBASE32", CancellationToken.None); + await c.TwoFactor.AddRecoveryCodesAsync(c.User.Id, [c.Recovery.Hash("code-1")], Now, CancellationToken.None); + + var result = await Disable(c).Handle(new DisableTwoFactorCommand(c.User.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.False(c.User.TwoFactorEnabled); + Assert.Empty(c.TwoFactor.Secrets); + Assert.NotEqual(stampBefore, c.User.SecurityStamp); + Assert.Contains("2fa.disabled", c.Audit.Actions); + + var stillUsable = await c.TwoFactor.ConsumeRecoveryCodeAsync( + c.User.Id, c.Recovery.Hash("code-1"), Now, CancellationToken.None); + Assert.False(stillUsable); + } + + // ---- recovery-code login ------------------------------------------------------------- + + [Fact] + public async Task Recovery_login_rejects_an_unparseable_challenge() + { + var c = Setup(twoFactorEnabled: true); + var result = await RecoveryLogin(c).Handle(new RecoveryLoginCommand("not-a-challenge", "code-1"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Invalid or expired challenge.", result.Error); + } + + [Fact] + public async Task Recovery_login_rejects_a_challenge_for_a_user_who_is_gone() + { + var c = Setup(twoFactorEnabled: true); + var challenge = c.Tokens.CreateChallengeToken(c.User); + c.Users.Store.Clear(); + + var result = await RecoveryLogin(c).Handle(new RecoveryLoginCommand(challenge, "code-1"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Invalid challenge.", result.Error); + } + + [Fact] + public async Task Recovery_login_rejects_a_user_who_never_enabled_2fa() + { + var c = Setup(twoFactorEnabled: false); + var challenge = c.Tokens.CreateChallengeToken(c.User); + + var result = await RecoveryLogin(c).Handle(new RecoveryLoginCommand(challenge, "code-1"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Invalid challenge.", result.Error); + } + + [Fact] + public async Task Recovery_login_with_an_unknown_code_is_audited_and_refused() + { + var c = Setup(twoFactorEnabled: true); + var challenge = c.Tokens.CreateChallengeToken(c.User); + + var result = await RecoveryLogin(c).Handle(new RecoveryLoginCommand(challenge, "never-issued"), CancellationToken.None); + + Assert.False(result.IsSuccess); + Assert.Equal("Invalid recovery code.", result.Error); + Assert.Contains("2fa.recovery_failed", c.Audit.Actions); + Assert.Empty(c.Refresh.Tokens); + } + + [Fact] + public async Task Recovery_login_signs_in_and_issues_a_refresh_token() + { + var c = Setup(twoFactorEnabled: true); + await c.TwoFactor.AddRecoveryCodesAsync(c.User.Id, [c.Recovery.Hash("code-1")], Now, CancellationToken.None); + var challenge = c.Tokens.CreateChallengeToken(c.User); + + var result = await RecoveryLogin(c).Handle(new RecoveryLoginCommand(challenge, "code-1"), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal("access-token", result.Value!.AccessToken); + Assert.Equal(UserRoles.Customer, result.Value.Role); + var issued = Assert.Single(c.Refresh.Tokens); + Assert.Equal(c.User.Id, issued.UserId); + Assert.Equal(Now, issued.CreatedAt); + Assert.Contains("2fa.recovery_success", c.Audit.Actions); + } + + [Theory] + [InlineData(" CODE-1 ")] + [InlineData("Code-1")] + public async Task Recovery_codes_are_matched_case_and_whitespace_insensitively(string entered) + { + var c = Setup(twoFactorEnabled: true); + await c.TwoFactor.AddRecoveryCodesAsync(c.User.Id, [c.Recovery.Hash("code-1")], Now, CancellationToken.None); + var challenge = c.Tokens.CreateChallengeToken(c.User); + + var result = await RecoveryLogin(c).Handle(new RecoveryLoginCommand(challenge, entered), CancellationToken.None); + + Assert.True(result.IsSuccess); + } + + [Fact] + public async Task A_recovery_code_works_exactly_once() + { + var c = Setup(twoFactorEnabled: true); + await c.TwoFactor.AddRecoveryCodesAsync(c.User.Id, [c.Recovery.Hash("code-1")], Now, CancellationToken.None); + var challenge = c.Tokens.CreateChallengeToken(c.User); + + var first = await RecoveryLogin(c).Handle(new RecoveryLoginCommand(challenge, "code-1"), CancellationToken.None); + var second = await RecoveryLogin(c).Handle(new RecoveryLoginCommand(challenge, "code-1"), CancellationToken.None); + + Assert.True(first.IsSuccess); + Assert.False(second.IsSuccess); + Assert.Equal("Invalid recovery code.", second.Error); + Assert.Single(c.Refresh.Tokens); + } + + /// Deterministic stand-in for the real generator: the "hash" is just a marked prefix. + private sealed class FakeRecoveryCodes : IRecoveryCodes + { + public IReadOnlyList Generate(int count) + => Enumerable.Range(1, count).Select(i => new RecoveryCode($"code-{i}", Hash($"code-{i}"))).ToList(); + + public string Hash(string code) => "rc:" + code; + } +} diff --git a/tests/WidgetWorks.UnitTests/WebhookParserTests.cs b/tests/WidgetWorks.UnitTests/WebhookParserTests.cs new file mode 100644 index 0000000..d6d0882 --- /dev/null +++ b/tests/WidgetWorks.UnitTests/WebhookParserTests.cs @@ -0,0 +1,293 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Options; +using WidgetWorks.Application.Abstractions; +using WidgetWorks.Infrastructure.Payments; +using Xunit; + +namespace WidgetWorks.UnitTests; + +/// +/// Webhook verification and parsing. This is the app's only unauthenticated write path — anyone on +/// the internet can POST to it — so the signature check is the security boundary, and every way it +/// can be fooled is worth a test: no header, no signature, a signature for a different payload, a +/// signature for a different secret. +/// +public class WebhookParserTests +{ + private const string Secret = "whsec_test_do_not_use"; + + private static StripePaymentWebhookParser Stripe(string secret = Secret) + => new(Options.Create(new StripeOptions { WebhookSecret = secret })); + + private static MockPaymentWebhookParser Mock(string secret = "") + => new(Options.Create(new MockPaymentOptions { WebhookSecret = secret })); + + /// Builds the header Stripe would send: t=timestamp, v1=HMAC-SHA256 of "{t}.{payload}". + private static string SignatureFor(string payload, string secret = Secret, string timestamp = "1735689600") + { + var signed = $"{timestamp}.{payload}"; + var hex = Convert.ToHexStringLower( + HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(signed))); + return $"t={timestamp},v1={hex}"; + } + + // Built by substitution rather than interpolation: the JSON ends in three braces, which an + // interpolated raw string reads as an interpolation hole. + private static string Intent(string type, string id = "pi_3ABC123") => + """{"type":"__TYPE__","data":{"object":{"id":"__ID__","object":"payment_intent"}}}""" + .Replace("__TYPE__", type) + .Replace("__ID__", id); + + // ---- stripe: signature --------------------------------------------------------------- + + [Fact] + public void Stripe_refuses_to_run_without_a_configured_secret() + { + var payload = Intent("payment_intent.succeeded"); + + var ok = Stripe(secret: "").TryParse(payload, SignatureFor(payload), out var evt, out var error); + + // Fail closed: an unconfigured secret must never mean "accept everything". + Assert.False(ok); + Assert.Null(evt); + Assert.Equal("Stripe webhook secret is not configured.", error); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("garbage")] + [InlineData("t=1735689600")] // timestamp but no signature + [InlineData("v1=abc123")] // signature but no timestamp + [InlineData("t=1735689600,v1")] // malformed pair, skipped -> no signatures left + public void Stripe_rejects_a_header_it_cannot_verify(string? header) + { + var payload = Intent("payment_intent.succeeded"); + + var ok = Stripe().TryParse(payload, header, out var evt, out var error); + + Assert.False(ok); + Assert.Null(evt); + Assert.Equal("Invalid webhook signature.", error); + } + + [Fact] + public void Stripe_rejects_a_signature_made_with_a_different_secret() + { + var payload = Intent("payment_intent.succeeded"); + + var ok = Stripe().TryParse(payload, SignatureFor(payload, secret: "whsec_someone_elses"), out _, out var error); + + Assert.False(ok); + Assert.Equal("Invalid webhook signature.", error); + } + + [Fact] + public void Stripe_rejects_a_valid_signature_for_a_different_payload() + { + var signed = Intent("payment_intent.succeeded", "pi_ORIGINAL"); + var tampered = Intent("payment_intent.succeeded", "pi_ATTACKER"); + + // The signature is genuine — but for the body the attacker replaced. + var ok = Stripe().TryParse(tampered, SignatureFor(signed), out _, out var error); + + Assert.False(ok); + Assert.Equal("Invalid webhook signature.", error); + } + + [Fact] + public void Stripe_rejects_a_signature_bound_to_a_different_timestamp() + { + var payload = Intent("payment_intent.succeeded"); + var header = SignatureFor(payload, timestamp: "1735689600").Replace("t=1735689600", "t=1735689999"); + + var ok = Stripe().TryParse(payload, header, out _, out var error); + + Assert.False(ok); + Assert.Equal("Invalid webhook signature.", error); + } + + [Fact] + public void Stripe_accepts_the_correct_signature() + { + var payload = Intent("payment_intent.succeeded"); + + var ok = Stripe().TryParse(payload, SignatureFor(payload), out var evt, out var error); + + Assert.True(ok); + Assert.Null(error); + Assert.Equal("Stripe", evt!.Provider); + Assert.Equal("pi_3ABC123", evt.Reference); + Assert.Equal(PaymentEventType.Succeeded, evt.Type); + } + + [Fact] + public void Stripe_accepts_when_one_of_several_signatures_matches() + { + var payload = Intent("payment_intent.succeeded"); + var real = SignatureFor(payload); + + // During a secret rotation Stripe sends more than one v1. + var header = real + ",v1=" + new string('a', 64); + + Assert.True(Stripe().TryParse(payload, header, out _, out _)); + } + + [Fact] + public void Stripe_accepts_an_uppercase_hex_signature() + { + var payload = Intent("payment_intent.succeeded"); + var header = SignatureFor(payload).ToUpperInvariant().Replace("T=", "t=").Replace("V1=", "v1="); + + Assert.True(Stripe().TryParse(payload, header, out _, out _)); + } + + [Fact] + public void Stripe_rejects_a_signature_of_the_wrong_length_without_throwing() + { + var payload = Intent("payment_intent.succeeded"); + + var ok = Stripe().TryParse(payload, "t=1735689600,v1=abcd", out _, out var error); + + Assert.False(ok); + Assert.Equal("Invalid webhook signature.", error); + } + + // ---- stripe: event mapping ----------------------------------------------------------- + + [Theory] + [InlineData("payment_intent.succeeded", PaymentEventType.Succeeded)] + [InlineData("payment_intent.payment_failed", PaymentEventType.Failed)] + [InlineData("payment_intent.canceled", PaymentEventType.Failed)] + public void Stripe_maps_the_intent_events_it_cares_about(string type, PaymentEventType expected) + { + var payload = Intent(type); + + var ok = Stripe().TryParse(payload, SignatureFor(payload), out var evt, out _); + + Assert.True(ok); + Assert.Equal(expected, evt!.Type); + } + + [Theory] + [InlineData("charge.refunded")] + [InlineData("customer.created")] + [InlineData("")] + public void Stripe_declines_events_it_does_not_handle(string type) + { + var payload = Intent(type); + + var ok = Stripe().TryParse(payload, SignatureFor(payload), out var evt, out var error); + + Assert.False(ok); + Assert.Null(evt); + Assert.Contains("Unhandled event type", error); + } + + [Theory] + [InlineData("""{"type":"payment_intent.succeeded"}""")] + [InlineData("""{"type":"payment_intent.succeeded","data":{}}""")] + [InlineData("""{"type":"payment_intent.succeeded","data":{"object":{}}}""")] + [InlineData("""{"type":"payment_intent.succeeded","data":{"object":{"id":""}}}""")] + public void Stripe_requires_a_payment_intent_id(string payload) + { + var ok = Stripe().TryParse(payload, SignatureFor(payload), out var evt, out var error); + + Assert.False(ok); + Assert.Null(evt); + Assert.Equal("Missing PaymentIntent id.", error); + } + + [Fact] + public void Stripe_reports_malformed_json_rather_than_throwing() + { + const string payload = "{not json"; + + var ok = Stripe().TryParse(payload, SignatureFor(payload), out var evt, out var error); + + Assert.False(ok); + Assert.Null(evt); + Assert.Equal("Malformed webhook payload.", error); + } + + // ---- mock provider ------------------------------------------------------------------- + + [Fact] + public void Mock_skips_verification_when_no_secret_is_configured() + { + var ok = Mock().TryParse("""{"reference":"mock_pi_1","outcome":"succeeded"}""", null, out var evt, out var error); + + Assert.True(ok); + Assert.Null(error); + Assert.Equal("Mock", evt!.Provider); + Assert.Equal("mock_pi_1", evt.Reference); + } + + [Fact] + public void Mock_enforces_the_shared_secret_once_one_is_configured() + { + var parser = Mock(secret: "shhh"); + + Assert.False(parser.TryParse("""{"reference":"mock_pi_1"}""", "wrong", out _, out var error)); + Assert.Equal("Invalid webhook signature.", error); + + Assert.False(parser.TryParse("""{"reference":"mock_pi_1"}""", null, out _, out _)); + Assert.True(parser.TryParse("""{"reference":"mock_pi_1"}""", "shhh", out _, out _)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Mock_rejects_an_empty_body(string payload) + { + var ok = Mock().TryParse(payload, null, out _, out var error); + + Assert.False(ok); + Assert.Equal("Empty webhook payload.", error); + } + + [Theory] + [InlineData("""{"outcome":"succeeded"}""")] + [InlineData("""{"reference":""}""")] + [InlineData("""{"reference":" "}""")] + [InlineData("""{"reference":null}""")] + public void Mock_requires_a_reference(string payload) + { + var ok = Mock().TryParse(payload, null, out _, out var error); + + Assert.False(ok); + Assert.Equal("Missing 'reference'.", error); + } + + [Theory] + [InlineData("""{"reference":"r","outcome":"failed"}""", PaymentEventType.Failed)] + [InlineData("""{"reference":"r","outcome":"FAILED"}""", PaymentEventType.Failed)] + [InlineData("""{"reference":"r","outcome":"succeeded"}""", PaymentEventType.Succeeded)] + [InlineData("""{"reference":"r"}""", PaymentEventType.Succeeded)] + [InlineData("""{"reference":"r","outcome":"anything-else"}""", PaymentEventType.Succeeded)] + public void Mock_treats_only_an_explicit_failure_as_a_failure(string payload, PaymentEventType expected) + { + var ok = Mock().TryParse(payload, null, out var evt, out _); + + Assert.True(ok); + Assert.Equal(expected, evt!.Type); + } + + [Fact] + public void Mock_reports_malformed_json_rather_than_throwing() + { + var ok = Mock().TryParse("{oops", null, out _, out var error); + + Assert.False(ok); + Assert.Equal("Malformed webhook payload.", error); + } + + [Fact] + public void Parsers_declare_the_provider_key_the_route_matches_on() + { + Assert.Equal("Stripe", Stripe().Provider); + Assert.Equal("Mock", Mock().Provider); + } +} diff --git a/tests/WidgetWorks.UnitTests/WidgetWorks.UnitTests.csproj b/tests/WidgetWorks.UnitTests/WidgetWorks.UnitTests.csproj index 77c48f2..a465b92 100644 --- a/tests/WidgetWorks.UnitTests/WidgetWorks.UnitTests.csproj +++ b/tests/WidgetWorks.UnitTests/WidgetWorks.UnitTests.csproj @@ -5,6 +5,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/web/package-lock.json b/web/package-lock.json index 10a42c2..3fd0f6a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,14 +13,326 @@ "react-router-dom": "^7.18.2" }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.5", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^6.0.5", + "@vitest/coverage-v8": "^4.1.11", + "jsdom": "^30.0.1", "typescript": "^5.6.2", "vite": "^8.2.1", "vitest": "^4.1.11" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -28,6 +340,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@oxc-project/types": { "version": "0.144.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", @@ -290,6 +613,113 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.5", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.5.tgz", + "integrity": "sha512-FhqjldLTpteueBaKflhNFlMT3+PM0O5fiBUivht6b9CZ1eesJyy7+g3Jr7XwJzt/Hip3ZG5hWwK1MX1FuDiE4w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -369,6 +799,37 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.11", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", @@ -482,6 +943,41 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -492,6 +988,35 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -499,36 +1024,103 @@ "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=6" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -539,6 +1131,27 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -599,12 +1212,139 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -878,6 +1618,27 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -888,6 +1649,51 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -921,6 +1727,19 @@ "node": ">=12.20.0" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -977,6 +1796,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1002,6 +1847,14 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/react-router": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", @@ -1040,6 +1893,30 @@ "react-dom": ">=18" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", @@ -1073,6 +1950,19 @@ "@rolldown/binding-win32-x64-msvc": "1.2.4" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -1082,6 +1972,19 @@ "loose-envify": "^1.1.0" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -1119,6 +2022,39 @@ "dev": true, "license": "MIT" }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1163,6 +2099,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1177,6 +2159,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/vite": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", @@ -1345,6 +2337,54 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -1361,6 +2401,23 @@ "engines": { "node": ">=8" } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/web/package.json b/web/package.json index 78b5d90..6e44307 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "typecheck": "tsc -b --noEmit", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "react": "^18.3.1", @@ -17,9 +18,14 @@ "react-router-dom": "^7.18.2" }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.5", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^6.0.5", + "@vitest/coverage-v8": "^4.1.11", + "jsdom": "^30.0.1", "typescript": "^5.6.2", "vite": "^8.2.1", "vitest": "^4.1.11" diff --git a/web/src/components/AddToCartButton.test.tsx b/web/src/components/AddToCartButton.test.tsx new file mode 100644 index 0000000..c083ab0 --- /dev/null +++ b/web/src/components/AddToCartButton.test.tsx @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { AddToCartButton } from './AddToCartButton' +import { renderWithProviders, stubFetch } from '../test/render' + +/** + * The control every product surface shares. Its whole reason to exist is feedback while the + * request is in flight, so the states — idle, busy, done, error — are what get asserted, along + * with the guard that stops a double-click becoming two line items. + */ +describe('AddToCartButton', () => { + const cart = { + id: 'cart-1', + userId: null, + items: [{ widgetId: 'w-1', sku: 'WW-001', name: 'Standard Widget', unitPrice: 10, quantity: 1, quantityAvailable: 4, lineSubtotal: 10 }], + subtotal: 10, + itemCount: 1, + } + + it('adds the widget and reports success', async () => { + const calls = stubFetch([['/cart', () => cart]]) + const user = userEvent.setup() + + renderWithProviders() + await user.click(screen.getByRole('button', { name: 'Add to cart' })) + + expect(await screen.findByRole('button', { name: '✓ Added to cart' })).toBeInTheDocument() + const post = calls.find((c) => c.init?.method === 'POST') + expect(JSON.parse(String(post?.init?.body))).toMatchObject({ widgetId: 'w-1', quantity: 1 }) + }) + + it('sends the quantity it was given', async () => { + const calls = stubFetch([['/cart', () => cart]]) + const user = userEvent.setup() + + renderWithProviders() + await user.click(screen.getByRole('button', { name: 'Add to cart' })) + + await waitFor(() => { + const post = calls.find((c) => c.init?.method === 'POST') + expect(JSON.parse(String(post?.init?.body))).toMatchObject({ quantity: 3 }) + }) + }) + + it('ignores a second click while the first is still in flight', async () => { + // Declared via the resolver rather than a mutable local: TypeScript narrows a variable only + // assigned inside the executor to `never`, making it uncallable afterwards. + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + + vi.stubGlobal('fetch', vi.fn(async () => { + await gate + return new Response(JSON.stringify(cart), { status: 200, headers: { 'Content-Type': 'application/json' } }) + })) + const user = userEvent.setup() + + renderWithProviders() + const button = screen.getByRole('button') + await user.click(button) + + // Disabled while busy: an impatient double-click must not order two. + expect(await screen.findByRole('button', { name: 'Adding…' })).toBeDisabled() + await user.click(button) + + release() + await waitFor(() => expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1)) + }) + + it('shows the reason when the API refuses', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'Only 2 left in stock.' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ))) + const user = userEvent.setup() + + renderWithProviders() + await user.click(screen.getByRole('button', { name: 'Add to cart' })) + + expect(await screen.findByText('Only 2 left in stock.')).toBeInTheDocument() + }) + + it('is inert and self-explanatory when out of stock', async () => { + stubFetch([['/cart', () => cart]]) + const user = userEvent.setup() + + renderWithProviders() + const button = screen.getByRole('button', { name: 'Out of stock' }) + + expect(button).toBeDisabled() + await user.click(button) + expect(screen.queryByText('Adding…')).not.toBeInTheDocument() + }) + + it('honours an explicit disabled without claiming to be out of stock', () => { + renderWithProviders() + + expect(screen.getByRole('button', { name: 'Add to cart' })).toBeDisabled() + }) + + it('uses a caller-supplied label', () => { + renderWithProviders() + + expect(screen.getByRole('button', { name: 'Buy now' })).toBeInTheDocument() + }) + + it('notifies the caller after a successful add', async () => { + stubFetch([['/cart', () => cart]]) + const onAdded = vi.fn() + const user = userEvent.setup() + + renderWithProviders() + await user.click(screen.getByRole('button', { name: 'Add to cart' })) + + await waitFor(() => expect(onAdded).toHaveBeenCalledOnce()) + }) + + it('does not notify the caller when the add failed', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'nope' }), { status: 400, headers: { 'Content-Type': 'application/json' } }, + ))) + const onAdded = vi.fn() + const user = userEvent.setup() + + renderWithProviders() + await user.click(screen.getByRole('button', { name: 'Add to cart' })) + + await screen.findByText('nope') + expect(onAdded).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/components/Layout.test.tsx b/web/src/components/Layout.test.tsx new file mode 100644 index 0000000..2ac6c03 --- /dev/null +++ b/web/src/components/Layout.test.tsx @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Layout } from './Layout' +import { renderWithProviders, signIn, stubFetch, REFRESH_KEY } from '../test/render' + +/** + * The chrome every page sits in. It is where role leaks would show first: an Admin link visible + * to a customer, or an order-history link offered to someone with no session. + */ +describe('Layout', () => { + const render = () => renderWithProviders(, { at: '/store' }) + + it('offers sign-in and no account links to a visitor', () => { + stubFetch([['/cart', () => ({ id: 'c', userId: null, items: [], subtotal: 0, itemCount: 0 })]]) + render() + + expect(screen.getAllByRole('link', { name: /Sign in/i }).length).toBeGreaterThan(0) + expect(screen.queryByRole('link', { name: /Admin/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Sign out' })).not.toBeInTheDocument() + }) + + it('does not show the admin entry point to a customer', () => { + signIn('Customer') + stubFetch([['/cart', () => ({ id: 'c', userId: null, items: [], subtotal: 0, itemCount: 0 })]]) + render() + + expect(screen.queryByRole('link', { name: /Admin/i })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Sign out' })).toBeInTheDocument() + }) + + it.each(['Manager', 'Administrator'] as const)('shows the admin entry point to a %s', (role) => { + signIn(role) + stubFetch([['/cart', () => ({ id: 'c', userId: null, items: [], subtotal: 0, itemCount: 0 })]]) + render() + + expect(screen.getByRole('link', { name: /Admin/i })).toBeInTheDocument() + }) + + it('signing out clears the stored session', async () => { + signIn('Customer') + stubFetch([['/cart', () => ({ id: 'c', userId: null, items: [], subtotal: 0, itemCount: 0 })]]) + const user = userEvent.setup() + render() + + await user.click(screen.getByRole('button', { name: 'Sign out' })) + + expect(localStorage.getItem(REFRESH_KEY)).toBeNull() + expect(screen.getAllByRole('link', { name: /Sign in/i }).length).toBeGreaterThan(0) + }) + + it('announces the cart count for screen readers, singular and plural', async () => { + stubFetch([['/cart', () => ({ + id: 'c', + userId: null, + items: [{ widgetId: 'w-1', sku: 'WW-001', name: 'W', unitPrice: 1, quantity: 1, quantityAvailable: 5, lineSubtotal: 1 }], + subtotal: 1, + itemCount: 1, + })]]) + localStorage.setItem('ww.cartId', 'c') + render() + + expect(await screen.findByRole('link', { name: 'Cart, 1 item' })).toBeInTheDocument() + }) + + it('links back to the demo guide from the promo bar', () => { + stubFetch([['/cart', () => ({ id: 'c', userId: null, items: [], subtotal: 0, itemCount: 0 })]]) + render() + + expect(screen.getByRole('link', { name: /read the guide/i })).toHaveAttribute('href', '/') + }) +}) diff --git a/web/src/components/ProtectedRoute.test.tsx b/web/src/components/ProtectedRoute.test.tsx new file mode 100644 index 0000000..e021757 --- /dev/null +++ b/web/src/components/ProtectedRoute.test.tsx @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { screen } from '@testing-library/react' +import { ProtectedRoute } from './ProtectedRoute' +import { renderWithProviders, signIn } from '../test/render' + +/** + * The client-side half of authorization. It is not the security boundary — the API enforces the + * real thing — but a hole here shows a signed-out visitor an admin screen, which is its own kind + * of broken. Every combination of (signed in?, staff route?, role) is checked. + */ +describe('ProtectedRoute', () => { + const Secret =

Order history

+ const routes = { + '/login':

Sign in

, + '/store':

Storefront

, + } + + it('sends a signed-out visitor to the sign-in page', () => { + renderWithProviders({Secret}, { at: '/orders', routes }) + + expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument() + expect(screen.queryByText('Order history')).not.toBeInTheDocument() + }) + + it('lets a signed-in customer through an ordinary protected route', () => { + signIn('Customer') + renderWithProviders({Secret}, { at: '/orders', routes }) + + expect(screen.getByRole('heading', { name: 'Order history' })).toBeInTheDocument() + }) + + it('bounces a customer off a staff route to the store, not to sign-in', () => { + signIn('Customer') + renderWithProviders({Secret}, { at: '/admin/widgets', routes }) + + // Signed in but not permitted: sending them to /login would be a confusing dead end. + expect(screen.getByRole('heading', { name: 'Storefront' })).toBeInTheDocument() + expect(screen.queryByText('Order history')).not.toBeInTheDocument() + }) + + it.each(['Manager', 'Administrator'] as const)('lets a %s into a staff route', (role) => { + signIn(role) + renderWithProviders({Secret}, { at: '/admin/widgets', routes }) + + expect(screen.getByRole('heading', { name: 'Order history' })).toBeInTheDocument() + }) + + it('sends a signed-out visitor to sign-in even for a staff route', () => { + renderWithProviders({Secret}, { at: '/admin/widgets', routes }) + + expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument() + }) + + it('treats a session with a token but no role as not staff', () => { + // Half-written session: a refresh token without a role must not open admin screens. + localStorage.setItem('ww.refreshToken', 'refresh-token-for-tests') + + renderWithProviders({Secret}, { at: '/admin/widgets', routes }) + + expect(screen.getByRole('heading', { name: 'Storefront' })).toBeInTheDocument() + }) +}) diff --git a/web/src/pages/AccountPages.test.tsx b/web/src/pages/AccountPages.test.tsx new file mode 100644 index 0000000..e6cf37c --- /dev/null +++ b/web/src/pages/AccountPages.test.tsx @@ -0,0 +1,136 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ForgotPasswordPage } from './ForgotPasswordPage' +import { ResetPasswordPage } from './ResetPasswordPage' +import { OrderDetailPage } from './OrderDetailPage' +import { renderWithProviders, signIn, stubFetch } from '../test/render' + +describe('ForgotPasswordPage', () => { + it('sends the address and confirms without revealing whether it exists', async () => { + const calls = stubFetch([['/auth/forgot-password', () => ({})]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/forgot-password' }) + await user.type(screen.getByLabelText('Email address'), 'jane@example.com') + await user.click(screen.getByRole('button', { name: /Send|Reset|Email/i })) + + await waitFor(() => { + const post = calls.find((c) => c.url.includes('/auth/forgot-password')) + expect(JSON.parse(String(post?.init?.body))).toEqual({ email: 'jane@example.com' }) + }) + }) + + it('answers identically for an unknown address', async () => { + // Account enumeration guard: a failure must look exactly like a success. + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'no such user' }), + { status: 404, headers: { 'Content-Type': 'application/json' } }, + ))) + const user = userEvent.setup() + + renderWithProviders(, { at: '/forgot-password' }) + await user.type(screen.getByLabelText('Email address'), 'nobody@example.com') + await user.click(screen.getByRole('button', { name: /Send|Reset|Email/i })) + + await waitFor(() => expect(screen.queryByText(/no such user/i)).not.toBeInTheDocument()) + }) +}) + +describe('ResetPasswordPage', () => { + it('refuses to submit without a token in the link', () => { + renderWithProviders(, { at: '/reset-password' }) + + expect(screen.getByRole('button', { name: /Reset|Save|Change/i })).toBeDisabled() + }) + + it('posts the token from the query string with the new password', async () => { + const calls = stubFetch([['/auth/reset-password', () => ({})]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/reset-password?token=tok-123', path: '/reset-password' }) + await user.type(screen.getByLabelText(/New password/), 'a-brand-new-password') + await user.click(screen.getByRole('button', { name: /Reset|Save|Change/i })) + + await waitFor(() => { + const post = calls.find((c) => c.url.includes('/auth/reset-password')) + expect(JSON.parse(String(post?.init?.body))).toEqual({ token: 'tok-123', newPassword: 'a-brand-new-password' }) + }) + }) + + it('shows an expired or used token as the reason', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'That reset link has expired.' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ))) + const user = userEvent.setup() + + renderWithProviders(, { at: '/reset-password?token=stale', path: '/reset-password' }) + await user.type(screen.getByLabelText(/New password/), 'a-brand-new-password') + await user.click(screen.getByRole('button', { name: /Reset|Save|Change/i })) + + expect(await screen.findByText(/That reset link has expired/)).toBeInTheDocument() + }) +}) + +describe('OrderDetailPage', () => { + const order = { + id: 'o-1', + orderNumber: 'WW-20260501-ABC123', + status: 'Paid', + email: 'jane@example.com', + subtotal: 20, + shippingMethod: 'Standard', + shipping: 7.74, + taxState: 'CA', + taxRate: 0.0725, + tax: 1.45, + total: 29.19, + paymentProvider: 'Mock', + paymentReference: 'mock_1', + trackingNumber: '1Z999AA10123456784', + createdAt: '2026-05-01T08:00:00Z', + items: [{ widgetId: 'w-1', sku: 'WW-001', name: 'Standard Widget', unitPrice: 10, quantity: 2, lineSubtotal: 20 }], + } + + it('is the receipt: every money line and the tracking number', async () => { + signIn('Customer') + stubFetch([['/orders/o-1', () => order]]) + + renderWithProviders(, { at: '/orders/o-1', path: '/orders/:id' }) + + expect((await screen.findAllByText(/WW-20260501-ABC123/)).length).toBeGreaterThan(0) + // $20.00 is both the line subtotal and the order subtotal. + expect(screen.getAllByText('$20.00').length).toBeGreaterThan(0) + expect(screen.getByText('$7.74')).toBeInTheDocument() + expect(screen.getByText('$1.45')).toBeInTheDocument() + expect(screen.getByText('$29.19')).toBeInTheDocument() + expect(screen.getByText('1Z999AA10123456784')).toBeInTheDocument() + expect(screen.getByText('Standard Widget')).toBeInTheDocument() + }) + + it('reports an order it cannot load', async () => { + signIn('Customer') + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'Order not found.' }), + { status: 404, headers: { 'Content-Type': 'application/json' } }, + ))) + + renderWithProviders(, { at: '/orders/nope', path: '/orders/:id' }) + + expect(await screen.findByText(/Order not found/)).toBeInTheDocument() + }) + + it('offers a printable receipt', async () => { + signIn('Customer') + stubFetch([['/orders/o-1', () => order]]) + const print = vi.fn() + vi.stubGlobal('print', print) + const user = userEvent.setup() + + renderWithProviders(, { at: '/orders/o-1', path: '/orders/:id' }) + await user.click(await screen.findByRole('button', { name: /Print/i })) + + expect(print).toHaveBeenCalled() + }) +}) diff --git a/web/src/pages/CartAndOrders.test.tsx b/web/src/pages/CartAndOrders.test.tsx new file mode 100644 index 0000000..5d0efa5 --- /dev/null +++ b/web/src/pages/CartAndOrders.test.tsx @@ -0,0 +1,284 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { CartPage } from './CartPage' +import { OrdersPage } from './OrdersPage' +import { AdminOrderPage } from './admin/AdminOrderPage' +import { renderWithProviders, signIn, stubFetch, useCartId } from '../test/render' + +const line = { + widgetId: 'w-1', + sku: 'WW-001', + name: 'Standard Widget', + unitPrice: 10, + quantity: 2, + quantityAvailable: 5, + lineSubtotal: 20, +} + +const cart = { id: 'cart-1', userId: null, items: [line], subtotal: 20, itemCount: 2 } + +describe('CartPage', () => { + it('lists what is in the basket', async () => { + useCartId('cart-1') + stubFetch([['/cart/cart-1', () => cart]]) + + renderWithProviders(, { at: '/cart' }) + + expect(await screen.findByText('Standard Widget')).toBeInTheDocument() + expect(screen.getByText('$20.00')).toBeInTheDocument() + }) + + it('increasing a quantity sends the new absolute quantity, not a delta', async () => { + useCartId('cart-1') + const calls = stubFetch([['/cart/cart-1', () => cart]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/cart' }) + await user.click(await screen.findByRole('button', { name: 'Increase quantity of Standard Widget' })) + + await waitFor(() => { + const put = calls.find((c) => c.init?.method === 'PUT') + expect(JSON.parse(String(put?.init?.body))).toMatchObject({ quantity: 3 }) + }) + }) + + it('decreasing a quantity sends one fewer', async () => { + useCartId('cart-1') + const calls = stubFetch([['/cart/cart-1', () => cart]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/cart' }) + await user.click(await screen.findByRole('button', { name: 'Decrease quantity of Standard Widget' })) + + await waitFor(() => { + const put = calls.find((c) => c.init?.method === 'PUT') + expect(JSON.parse(String(put?.init?.body))).toMatchObject({ quantity: 1 }) + }) + }) + + it('removing a line calls DELETE for that widget', async () => { + useCartId('cart-1') + const calls = stubFetch([['/cart/cart-1', () => cart]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/cart' }) + await user.click(await screen.findByRole('button', { name: 'Remove' })) + + await waitFor(() => { + const del = calls.find((c) => c.init?.method === 'DELETE') + expect(del?.url).toContain('w-1') + }) + }) + + it('sends the shopper to checkout', async () => { + useCartId('cart-1') + stubFetch([['/cart/cart-1', () => cart]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/cart', routes: { '/checkout':

Secure checkout

} }) + await user.click(await screen.findByRole('button', { name: /Proceed to checkout|Checkout/i })) + + expect(await screen.findByRole('heading', { name: 'Secure checkout' })).toBeInTheDocument() + }) + + it('offers a way back to the store when empty', async () => { + stubFetch([['/cart/', () => ({ ...cart, items: [], itemCount: 0, subtotal: 0 })]]) + + renderWithProviders(, { at: '/cart' }) + + expect(await screen.findByText('Your cart is empty')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Start shopping' })).toHaveAttribute('href', '/store') + }) +}) + +describe('OrdersPage', () => { + const order = { + id: 'o-1', + orderNumber: 'WW-20260501-ABC123', + status: 'Paid', + total: 29.19, + itemCount: 2, + createdAt: '2026-05-01T08:00:00Z', + } + + it('lists the account orders with their status', async () => { + signIn('Customer') + stubFetch([['/orders', () => [order]]]) + + renderWithProviders(, { at: '/orders' }) + + expect(await screen.findByText('WW-20260501-ABC123')).toBeInTheDocument() + expect(screen.getByText('$29.19')).toBeInTheDocument() + expect(screen.getByText('Paid')).toBeInTheDocument() + }) + + it('says so plainly when there are none', async () => { + signIn('Customer') + stubFetch([['/orders', () => []]]) + + renderWithProviders(, { at: '/orders' }) + + expect(await screen.findByText('No orders yet')).toBeInTheDocument() + }) + + it('surfaces a load failure rather than an endless skeleton', async () => { + signIn('Customer') + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'Orders unavailable.' }), + { status: 500, headers: { 'Content-Type': 'application/json' } }, + ))) + + renderWithProviders(, { at: '/orders' }) + + expect(await screen.findByText(/Orders unavailable/)).toBeInTheDocument() + }) +}) + +describe('AdminOrderPage', () => { + const summary = { + id: 'o-1', + orderNumber: 'WW-20260501-ABC123', + status: 'Paid', + total: 29.19, + itemCount: 2, + createdAt: '2026-05-01T08:00:00Z', + } + + const detail = { + ...summary, + email: 'jane@example.com', + subtotal: 20, + shippingMethod: 'Standard', + shipping: 7.74, + taxState: 'CA', + taxRate: 0.0725, + tax: 1.45, + paymentProvider: 'Mock', + paymentReference: 'mock_1', + trackingNumber: null, + items: [{ widgetId: 'w-1', sku: 'WW-001', name: 'Standard Widget', unitPrice: 10, quantity: 2, lineSubtotal: 20 }], + } + + it('lists recent orders so staff can find one without knowing its id', async () => { + signIn('Manager') + stubFetch([['/admin/orders', () => [summary]]]) + + renderWithProviders(, { at: '/admin/orders' }) + + // The regression this page exists for: lookup used to require a GUID nobody has. + expect(await screen.findByText('WW-20260501-ABC123')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Open' })).toBeInTheDocument() + }) + + it('opening an order shows its detail and fulfilment controls', async () => { + signIn('Manager') + stubFetch([ + ['/admin/orders/o-1', () => detail], + ['/admin/orders', () => [summary]], + ]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/orders' }) + await user.click(await screen.findByRole('button', { name: 'Open' })) + + expect(await screen.findByText('jane@example.com')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Mark shipped' })).toBeInTheDocument() + }) + + it('marking shipped posts the status with the tracking number typed in', async () => { + signIn('Manager') + const calls = stubFetch([ + ['/admin/orders/o-1/status', () => ({ ...detail, status: 'Shipped', trackingNumber: '1Z-NEW' })], + ['/admin/orders/o-1', () => detail], + ['/admin/orders', () => [summary]], + ]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/orders' }) + await user.click(await screen.findByRole('button', { name: 'Open' })) + await user.type(await screen.findByLabelText('Tracking number'), '1Z-NEW') + await user.click(screen.getByRole('button', { name: 'Mark shipped' })) + + await waitFor(() => { + const post = calls.find((c) => c.url.includes('/status')) + expect(JSON.parse(String(post?.init?.body))).toEqual({ status: 'Shipped', trackingNumber: '1Z-NEW' }) + }) + }) + + it('sends null rather than an empty string when no tracking was entered', async () => { + signIn('Manager') + const calls = stubFetch([ + ['/admin/orders/o-1/status', () => ({ ...detail, status: 'Cancelled' })], + ['/admin/orders/o-1', () => detail], + ['/admin/orders', () => [summary]], + ]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/orders' }) + await user.click(await screen.findByRole('button', { name: 'Open' })) + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + await waitFor(() => { + const post = calls.find((c) => c.url.includes('/status')) + expect(JSON.parse(String(post?.init?.body))).toEqual({ status: 'Cancelled', trackingNumber: null }) + }) + }) + + it('shows the API refusal when a transition is not allowed', async () => { + signIn('Manager') + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/status')) { + return new Response(JSON.stringify({ error: "Cannot change status from AwaitingPayment to 'Shipped'." }), { + status: 400, headers: { 'Content-Type': 'application/json' }, + }) + } + if (url.includes('/admin/orders/o-1')) { + return new Response(JSON.stringify(detail), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + return new Response(JSON.stringify([summary]), { status: 200, headers: { 'Content-Type': 'application/json' } }) + })) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/orders' }) + await user.click(await screen.findByRole('button', { name: 'Open' })) + await user.click(await screen.findByRole('button', { name: 'Mark shipped' })) + + expect(await screen.findByText(/Cannot change status/)).toBeInTheDocument() + }) + + it('says there is nothing to fulfil when the list is empty', async () => { + signIn('Manager') + stubFetch([['/admin/orders', () => []]]) + + renderWithProviders(, { at: '/admin/orders' }) + + expect(await screen.findByText('No orders yet')).toBeInTheDocument() + }) + + it('refreshes the list on demand', async () => { + signIn('Manager') + const calls = stubFetch([['/admin/orders', () => [summary]]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/orders' }) + await screen.findByText('WW-20260501-ABC123') + const before = calls.filter((c) => c.url.includes('/admin/orders')).length + + await user.click(screen.getByRole('button', { name: 'Refresh' })) + + await waitFor(() => expect(calls.filter((c) => c.url.includes('/admin/orders')).length).toBeGreaterThan(before)) + }) + + it('prompts staff to pick an order before showing controls', async () => { + signIn('Manager') + stubFetch([['/admin/orders', () => [summary]]]) + + renderWithProviders(, { at: '/admin/orders' }) + + const aside = await screen.findByText('No order selected') + expect(within(aside.closest('.panel') as HTMLElement).getByText(/Pick an order/)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Mark shipped' })).not.toBeInTheDocument() + }) +}) diff --git a/web/src/pages/CheckoutPage.test.tsx b/web/src/pages/CheckoutPage.test.tsx new file mode 100644 index 0000000..4777ea3 --- /dev/null +++ b/web/src/pages/CheckoutPage.test.tsx @@ -0,0 +1,202 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { CheckoutPage } from './CheckoutPage' +import { renderWithProviders, stubFetch, useCartId } from '../test/render' + +/** + * The screen where a mistake costs money. Three things are worth guarding: the totals shown come + * from the server and are re-fetched when the inputs that change them change; the selected payment + * method is the one actually submitted; and a decline leaves the shopper on the page with the + * reason, rather than dropping them somewhere with an empty cart. + */ +describe('CheckoutPage', () => { + const cart = { + id: 'cart-1', + userId: null, + items: [{ widgetId: 'w-1', sku: 'WW-001', name: 'Standard Widget', unitPrice: 10, quantity: 2, quantityAvailable: 5, lineSubtotal: 20 }], + subtotal: 20, + itemCount: 2, + } + + const quoteFor = (state: string, method: string) => ({ + subtotal: 20, + shippingMethod: method, + shipping: method === 'Express' ? 21.49 : 7.74, + stateCode: state, + taxRate: state === 'CA' ? 0.0725 : 0, + tax: state === 'CA' ? 1.45 : 0, + total: 20 + (method === 'Express' ? 21.49 : 7.74) + (state === 'CA' ? 1.45 : 0), + itemCount: 2, + isEmpty: false, + }) + + function stubCheckout(onCheckout?: (init?: RequestInit) => unknown) { + return stubFetch([ + ['/checkout/quote', (init) => { + const body = JSON.parse(String(init?.body)) + return quoteFor(body.stateCode, body.shippingMethod) + }], + ['/checkout', onCheckout ?? (() => ({ + orderNumber: 'WW-20260501-ABC123', + orderId: 'o-1', + status: 'Paid', + total: 29.19, + paymentProvider: 'Mock', + paymentReference: 'mock_1', + }))], + [`/cart/cart-1`, () => cart], + ]) + } + + // The page offers the same submit twice — inline under the form and in the sticky summary. + const placeOrderButton = () => screen.getAllByRole('button', { name: /Place your order/i })[0] + + async function fillAddress(user: ReturnType) { + await user.type(screen.getByLabelText('Email address'), 'jane@example.com') + await user.type(screen.getByLabelText('Full name'), 'Jane Doe') + await user.type(screen.getByLabelText('Address line 1'), '1 Main St') + await user.type(screen.getByLabelText('City'), 'Springfield') + await user.type(screen.getByLabelText('ZIP code'), '90210') + } + + it('shows the server-calculated totals rather than adding up in the browser', async () => { + useCartId('cart-1') + stubCheckout() + + renderWithProviders(, { at: '/checkout' }) + + expect(await screen.findByText('$29.19')).toBeInTheDocument() // 20 + 7.74 + 1.45 + expect(screen.getByText(/7\.25% CA/)).toBeInTheDocument() + expect(screen.getByText('$1.45')).toBeInTheDocument() + }) + + it('re-quotes when the destination state changes', async () => { + useCartId('cart-1') + const calls = stubCheckout() + const user = userEvent.setup() + + renderWithProviders(, { at: '/checkout' }) + await screen.findByText('$29.19') + + await user.selectOptions(screen.getByLabelText('State'), 'OR') + + // Oregon has no sales tax, so the total must drop — and it must come from a new quote call. + await waitFor(() => expect(screen.getByText('$27.74')).toBeInTheDocument()) + const quotes = calls.filter((c) => c.url.includes('/checkout/quote')) + expect(quotes.length).toBeGreaterThan(1) + // Indexed rather than .at(-1): that is an ES2022 API and the project targets ES2020. + const latest = quotes[quotes.length - 1] + expect(JSON.parse(String(latest.init?.body))).toMatchObject({ stateCode: 'OR' }) + }) + + it('re-quotes when the shipping method changes', async () => { + useCartId('cart-1') + const calls = stubCheckout() + const user = userEvent.setup() + + renderWithProviders(, { at: '/checkout' }) + await screen.findByText('$29.19') + + await user.click(screen.getByLabelText(/Express shipping/)) + + await waitFor(() => expect(screen.getByText('$42.94')).toBeInTheDocument()) + expect(calls.filter((c) => c.url.includes('/checkout/quote')).length).toBeGreaterThan(1) + }) + + it('submits the token for the payment method the shopper picked', async () => { + useCartId('cart-1') + const calls = stubCheckout() + const user = userEvent.setup() + + renderWithProviders(, { at: '/checkout', routes: { '/order-confirmation':

Thank you

} }) + await screen.findByText('$29.19') + + await fillAddress(user) + await user.click(screen.getByLabelText(/Klarna/)) + await user.click(placeOrderButton()) + + await waitFor(() => { + const order = calls.find((c) => c.init?.method === 'POST' && c.url.endsWith('/checkout')) + expect(JSON.parse(String(order?.init?.body))).toMatchObject({ + paymentToken: 'klarna_demo', + email: 'jane@example.com', + state: 'CA', + }) + }) + }) + + it('defaults to the card token when nothing is picked', async () => { + useCartId('cart-1') + const calls = stubCheckout() + const user = userEvent.setup() + + renderWithProviders(, { at: '/checkout', routes: { '/order-confirmation':

Thank you

} }) + await screen.findByText('$29.19') + + await fillAddress(user) + await user.click(placeOrderButton()) + + await waitFor(() => { + const order = calls.find((c) => c.init?.method === 'POST' && c.url.endsWith('/checkout')) + expect(JSON.parse(String(order?.init?.body))).toMatchObject({ paymentToken: 'tok_visa_ok' }) + }) + }) + + it('moves to the confirmation page once the order is placed', async () => { + useCartId('cart-1') + stubCheckout() + const user = userEvent.setup() + + renderWithProviders(, { at: '/checkout', routes: { '/order-confirmation':

Thank you

} }) + await screen.findByText('$29.19') + + await fillAddress(user) + await user.click(placeOrderButton()) + + expect(await screen.findByRole('heading', { name: 'Thank you' })).toBeInTheDocument() + }) + + it('keeps the shopper on the page with the reason when payment is declined', async () => { + useCartId('cart-1') + stubFetch([ + ['/checkout/quote', (init) => { + const body = JSON.parse(String(init?.body)) + return quoteFor(body.stateCode, body.shippingMethod) + }], + ['/cart/cart-1', () => cart], + ]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/checkout', routes: { '/order-confirmation':

Thank you

} }) + await screen.findByText('$29.19') + await fillAddress(user) + + // Swap in a declining gateway only for the order call. + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith('/checkout') && init?.method === 'POST') { + return new Response(JSON.stringify({ error: 'Your card was declined.' }), { + status: 400, headers: { 'Content-Type': 'application/json' }, + }) + } + return new Response(JSON.stringify(quoteFor('CA', 'Standard')), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }) + })) + + await user.click(placeOrderButton()) + + expect(await screen.findByText('Your card was declined.')).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Thank you' })).not.toBeInTheDocument() + }) + + it('offers nothing to check out when the cart is empty', async () => { + stubFetch([['/cart/', () => ({ ...cart, items: [], itemCount: 0, subtotal: 0 })]]) + + renderWithProviders(, { at: '/checkout' }) + + expect(await screen.findByText('There is nothing to check out')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Place your order/i })).not.toBeInTheDocument() + }) +}) diff --git a/web/src/pages/DemoGuidePage.test.tsx b/web/src/pages/DemoGuidePage.test.tsx new file mode 100644 index 0000000..91c9df6 --- /dev/null +++ b/web/src/pages/DemoGuidePage.test.tsx @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { DemoGuidePage } from './DemoGuidePage' +import { renderWithProviders } from '../test/render' + +/** + * The page everyone lands on. Its job is not decorative: it has to state plainly that no money + * changes hands, hand over working credentials for all three roles, and say what each role can do. + * If the reassurance or a credential silently disappears, the demo becomes untrustworthy — so + * they are asserted rather than eyeballed. + */ +describe('DemoGuidePage', () => { + const render = () => renderWithProviders(, { + at: '/', + routes: { '/store':

Storefront

}, + }) + + it('states up front that no payment is ever taken', () => { + render() + + expect(screen.getByText(/No payment is ever taken/i)).toBeInTheDocument() + expect(screen.getByText(/mock payment gateway/i)).toBeInTheDocument() + expect(screen.getByText(/test mode/i)).toBeInTheDocument() + }) + + it('publishes all three demo accounts', () => { + render() + + for (const email of ['demo@widgetworks.demo', 'manager@widgetworks.demo', 'admin@widgetworks.demo']) { + expect(screen.getByText(email)).toBeInTheDocument() + } + + expect(screen.getByRole('heading', { name: 'Customer' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Manager' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Administrator' })).toBeInTheDocument() + }) + + it('explains what separates the roles, not just that they exist', () => { + render() + + // The distinction a reviewer is most likely to probe. + expect(screen.getByText(/Delete or retire a widget/i)).toBeInTheDocument() + expect(screen.getByText(/Manage users/i)).toBeInTheDocument() + }) + + it('copies a credential to the clipboard and confirms it did', async () => { + // user-event installs its own clipboard, so this goes through the real navigator API. + const user = userEvent.setup() + render() + + await user.click(screen.getAllByRole('button', { name: 'Copy' })[0]) + + expect(await navigator.clipboard.readText()).toBe('demo@widgetworks.demo') + await waitFor(() => expect(screen.getByRole('button', { name: '✓ Copied' })).toBeInTheDocument()) + }) + + it('survives a browser that refuses clipboard access', async () => { + const user = userEvent.setup() + render() + vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('denied')) + + await user.click(screen.getAllByRole('button', { name: 'Copy' })[0]) + + // No crash, and the value stays on screen so it can be typed instead. + expect(screen.getByText('demo@widgetworks.demo')).toBeInTheDocument() + }) + + it('points at the store and at the order history for receipts', () => { + render() + + expect(screen.getAllByRole('link', { name: /Enter the store/i }).length).toBeGreaterThan(0) + expect(screen.getByRole('link', { name: /Your orders/i })).toBeInTheDocument() + }) + + it('explains where the email goes instead of an inbox', () => { + render() + + expect(screen.getByText(/written to the application log/i)).toBeInTheDocument() + }) +}) diff --git a/web/src/pages/LoginPage.test.tsx b/web/src/pages/LoginPage.test.tsx new file mode 100644 index 0000000..6dc3dac --- /dev/null +++ b/web/src/pages/LoginPage.test.tsx @@ -0,0 +1,174 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { LoginPage } from './LoginPage' +import { renderWithProviders, stubFetch, useCartId, REFRESH_KEY, ROLE_KEY } from '../test/render' + +/** + * Sign-in, including the two-step branch and the guest-cart merge. The merge is the subtle one: + * a shopper who filled a basket before signing in must not lose it, and a merge failure must not + * strand them on the sign-in page after their credentials were accepted. + */ +describe('LoginPage', () => { + const session = { accessToken: 'access', refreshToken: 'refresh', role: 'Customer', twoFactorRequired: false } + + const routes = { '/store':

Storefront

} + + async function signInAs(user: ReturnType) { + await user.type(screen.getByLabelText('Email address'), 'jane@example.com') + await user.type(screen.getByLabelText('Password'), 'correct-horse') + await user.click(screen.getByRole('button', { name: 'Sign in' })) + } + + it('stores the session and lands on the store', async () => { + stubFetch([['/auth/login', () => session]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/login', routes }) + await signInAs(user) + + expect(await screen.findByRole('heading', { name: 'Storefront' })).toBeInTheDocument() + expect(localStorage.getItem(REFRESH_KEY)).toBe('refresh') + expect(localStorage.getItem(ROLE_KEY)).toBe('Customer') + }) + + it('sends the typed credentials, not something stale', async () => { + const calls = stubFetch([['/auth/login', () => session]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/login', routes }) + await signInAs(user) + + await waitFor(() => { + const login = calls.find((c) => c.url.includes('/auth/login')) + expect(JSON.parse(String(login?.init?.body))).toEqual({ email: 'jane@example.com', password: 'correct-horse' }) + }) + }) + + it('shows the failure and stays put when credentials are rejected', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'Invalid email or password.' }), + { status: 401, headers: { 'Content-Type': 'application/json' } }, + ))) + const user = userEvent.setup() + + renderWithProviders(, { at: '/login', routes }) + await signInAs(user) + + expect(await screen.findByText('Invalid email or password.')).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Storefront' })).not.toBeInTheDocument() + expect(localStorage.getItem(REFRESH_KEY)).toBeNull() + }) + + it('asks for the second factor instead of signing in, and stores nothing yet', async () => { + stubFetch([['/auth/login', () => ({ twoFactorRequired: true, challengeToken: 'challenge-1' })]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/login', routes }) + await signInAs(user) + + expect(await screen.findByRole('heading', { name: 'Two-step verification' })).toBeInTheDocument() + + // A password alone must not leave a usable session behind. + expect(localStorage.getItem(REFRESH_KEY)).toBeNull() + expect(screen.queryByRole('heading', { name: 'Storefront' })).not.toBeInTheDocument() + }) + + it('completes the second factor with the challenge it was handed', async () => { + const calls = stubFetch([ + ['/auth/2fa', () => session], + ['/auth/login', () => ({ twoFactorRequired: true, challengeToken: 'challenge-1' })], + ]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/login', routes }) + await signInAs(user) + + await user.type(await screen.findByLabelText('Verification code'), '654321') + await user.click(screen.getByRole('button', { name: 'Verify' })) + + expect(await screen.findByRole('heading', { name: 'Storefront' })).toBeInTheDocument() + const second = calls.find((c) => c.url.includes('/auth/2fa')) + expect(JSON.parse(String(second?.init?.body))).toEqual({ challengeToken: 'challenge-1', code: '654321' }) + }) + + it('reports a wrong code without losing the challenge', async () => { + let calls = 0 + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/auth/2fa')) { + calls++ + return new Response(JSON.stringify({ error: 'Invalid code.' }), { + status: 400, headers: { 'Content-Type': 'application/json' }, + }) + } + return new Response(JSON.stringify({ twoFactorRequired: true, challengeToken: 'challenge-1' }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }) + })) + const user = userEvent.setup() + + renderWithProviders(, { at: '/login', routes }) + await signInAs(user) + await user.type(await screen.findByLabelText('Verification code'), '000000') + await user.click(screen.getByRole('button', { name: 'Verify' })) + + expect(await screen.findByText('Invalid code.')).toBeInTheDocument() + + // Still on the code step, so a second attempt does not need a fresh password. + expect(screen.getByRole('heading', { name: 'Two-step verification' })).toBeInTheDocument() + expect(calls).toBe(1) + }) + + it('merges a guest cart into the account on the way in', async () => { + useCartId('cart-1') + const calls = stubFetch([ + ['/cart/merge', () => ({ id: 'cart-1', userId: 'u-1', items: [], subtotal: 0, itemCount: 0 })], + ['/auth/login', () => session], + ['/cart/cart-1', () => ({ id: 'cart-1', userId: null, items: [], subtotal: 0, itemCount: 0 })], + ]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/login', routes }) + await signInAs(user) + + await waitFor(() => { + const merge = calls.find((c) => c.url.includes('/cart/merge')) + expect(JSON.parse(String(merge?.init?.body))).toEqual({ guestCartId: 'cart-1' }) + }) + expect(await screen.findByRole('heading', { name: 'Storefront' })).toBeInTheDocument() + }) + + it('still signs in when the cart merge fails', async () => { + useCartId('cart-1') + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/cart/merge')) { + return new Response(JSON.stringify({ error: 'merge blew up' }), { + status: 500, headers: { 'Content-Type': 'application/json' }, + }) + } + if (url.includes('/auth/login')) { + return new Response(JSON.stringify(session), { status: 200, headers: { 'Content-Type': 'application/json' } }) + } + return new Response(JSON.stringify({ id: 'cart-1', userId: null, items: [], subtotal: 0, itemCount: 0 }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }) + })) + const user = userEvent.setup() + + renderWithProviders(, { at: '/login', routes }) + await signInAs(user) + + // Credentials were accepted; a basket problem must not undo that. + expect(await screen.findByRole('heading', { name: 'Storefront' })).toBeInTheDocument() + expect(localStorage.getItem(REFRESH_KEY)).toBe('refresh') + }) + + it('offers the way out for a forgotten password and a new account', () => { + renderWithProviders(, { at: '/login', routes }) + + expect(screen.getByRole('link', { name: /Forgot your password/i })).toHaveAttribute('href', '/forgot-password') + expect(screen.getByRole('link', { name: /Create an account/i })).toHaveAttribute('href', '/register') + }) +}) diff --git a/web/src/pages/StorefrontPages.test.tsx b/web/src/pages/StorefrontPages.test.tsx new file mode 100644 index 0000000..bbbfd74 --- /dev/null +++ b/web/src/pages/StorefrontPages.test.tsx @@ -0,0 +1,193 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { CatalogPage } from './CatalogPage' +import { ProductPage } from './ProductPage' +import { OrderConfirmationPage } from './OrderConfirmationPage' +import { RegisterPage } from './RegisterPage' +import { renderWithProviders, stubFetch, REFRESH_KEY } from '../test/render' + +const widget = { + id: 'w-1', + sku: 'WW-001', + name: 'Standard Widget', + description: 'A dependable widget for everyday jobs.', + imageUrl: null, + price: 12.5, + quantityOnHand: 10, + quantityReserved: 0, + quantityAvailable: 10, + isActive: true, +} + +const soldOut = { ...widget, id: 'w-2', sku: 'WW-002', name: 'Mega Widget', quantityAvailable: 0, price: 99 } + +describe('CatalogPage', () => { + const paged = (items = [widget, soldOut]) => () => ({ items, page: 1, pageSize: 24, total: items.length }) + + it('renders the widgets it loads', async () => { + stubFetch([['/catalog/widgets', paged()]]) + + renderWithProviders(, { at: '/store' }) + + expect(await screen.findByText('Standard Widget')).toBeInTheDocument() + expect(screen.getByText('Mega Widget')).toBeInTheDocument() + }) + + it('says so instead of showing an empty grid when nothing matches', async () => { + stubFetch([['/catalog/widgets', paged([])]]) + + renderWithProviders(, { at: '/store' }) + + expect(await screen.findByText('No widgets matched')).toBeInTheDocument() + }) + + it('marks an out-of-stock widget as unbuyable', async () => { + stubFetch([['/catalog/widgets', paged()]]) + + renderWithProviders(, { at: '/store' }) + await screen.findByText('Mega Widget') + + expect(screen.getByRole('button', { name: 'Out of stock' })).toBeDisabled() + }) + + it('surfaces a catalog failure', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'Catalog is down.' }), + { status: 503, headers: { 'Content-Type': 'application/json' } }, + ))) + + renderWithProviders(, { at: '/store' }) + + expect(await screen.findByText(/Catalog is down/)).toBeInTheDocument() + }) +}) + +describe('ProductPage', () => { + it('shows the product and lets it be bought', async () => { + stubFetch([['/catalog/widgets/w-1', () => widget]]) + + renderWithProviders(, { at: '/widgets/w-1', path: '/widgets/:id' }) + + expect(await screen.findByRole('heading', { name: 'Standard Widget' })).toBeInTheDocument() + expect(screen.getByText(/A dependable widget/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Add to cart/i })).toBeEnabled() + }) + + it('reports a widget it cannot load rather than rendering a blank page', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'Widget not found.' }), + { status: 404, headers: { 'Content-Type': 'application/json' } }, + ))) + + renderWithProviders(, { at: '/widgets/nope', path: '/widgets/:id' }) + + expect(await screen.findByText(/couldn.t load that widget/i)).toBeInTheDocument() + }) + + it('cannot be added when it is out of stock', async () => { + stubFetch([['/catalog/widgets/w-2', () => soldOut]]) + + renderWithProviders(, { at: '/widgets/w-2', path: '/widgets/:id' }) + await screen.findByRole('heading', { name: 'Mega Widget' }) + + expect(screen.getByRole('button', { name: 'Out of stock' })).toBeDisabled() + }) +}) + +describe('OrderConfirmationPage', () => { + const paid = { + orderNumber: 'WW-20260501-ABC123', + orderId: 'o-1', + status: 'Paid', + total: 29.19, + paymentProvider: 'Mock', + paymentReference: 'mock_1', + email: 'jane@example.com', + } + + const awaiting = { ...paid, status: 'AwaitingPayment', paymentProvider: 'Klarna' } + + it('confirms a paid order with its number and total', () => { + renderWithProviders(, { at: '/order-confirmation', state: paid }) + + expect(screen.getAllByText('WW-20260501-ABC123').length).toBeGreaterThan(0) + expect(screen.getByText('$29.19')).toBeInTheDocument() + }) + + it('explains what to do when someone lands here with no order', () => { + renderWithProviders(, { at: '/order-confirmation' }) + + expect(screen.getByText('No recent order to show')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Your orders' })).toHaveAttribute('href', '/orders') + }) + + it('offers to settle an order that is awaiting the provider', () => { + renderWithProviders(, { at: '/order-confirmation', state: awaiting }) + + expect(screen.getByText(/Waiting on Klarna/)).toBeInTheDocument() + expect(screen.getAllByRole('button').length).toBeGreaterThan(0) + }) + + it('settling posts the reference to the mock webhook and updates the status', async () => { + const calls = stubFetch([['/webhooks/payments/mock', () => ({ status: 'Paid' })]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/order-confirmation', state: awaiting }) + const [approve] = screen.getAllByRole('button') + await user.click(approve) + + await waitFor(() => { + const hook = calls.find((c) => c.url.includes('/webhooks/payments/mock')) + expect(JSON.parse(String(hook?.init?.body))).toMatchObject({ reference: 'mock_1', outcome: 'succeeded' }) + }) + }) + + it('reports a webhook failure instead of pretending it settled', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'Invalid webhook signature.' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ))) + const user = userEvent.setup() + + renderWithProviders(, { at: '/order-confirmation', state: awaiting }) + const [approve] = screen.getAllByRole('button') + await user.click(approve) + + expect(await screen.findByText(/Invalid webhook signature/)).toBeInTheDocument() + }) +}) + +describe('RegisterPage', () => { + it('creates the account and sends the new customer to sign in', async () => { + const calls = stubFetch([['/auth/register', () => ({})]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/register', routes: { '/login':

Sign in

} }) + + await user.type(screen.getByLabelText(/Email/i), 'new@example.com') + await user.type(screen.getByLabelText(/Password/i), 'long-enough-pw') + await user.click(screen.getByRole('button', { name: /Create account|Create your account|Sign up/i })) + + await waitFor(() => { + const post = calls.find((c) => c.url.includes('/auth/register')) + expect(JSON.parse(String(post?.init?.body))).toMatchObject({ email: 'new@example.com' }) + }) + }) + + it('shows the API rejection and leaves no session behind', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'Unable to register with the provided details.' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ))) + const user = userEvent.setup() + + renderWithProviders(, { at: '/register', routes: { '/login':

Sign in

} }) + await user.type(screen.getByLabelText(/Email/i), 'taken@example.com') + await user.type(screen.getByLabelText(/Password/i), 'long-enough-pw') + await user.click(screen.getByRole('button', { name: /Create account|Create your account|Sign up/i })) + + expect(await screen.findByText(/Unable to register/)).toBeInTheDocument() + expect(localStorage.getItem(REFRESH_KEY)).toBeNull() + }) +}) diff --git a/web/src/pages/admin/AdminWidgetsPage.test.tsx b/web/src/pages/admin/AdminWidgetsPage.test.tsx new file mode 100644 index 0000000..af7ac0b --- /dev/null +++ b/web/src/pages/admin/AdminWidgetsPage.test.tsx @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from 'vitest' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { AdminWidgetsPage } from './AdminWidgetsPage' +import { renderWithProviders, signIn, stubFetch } from '../../test/render' + +/** + * Catalog administration. The delete path is the one that can destroy data, so what matters is + * that nothing is sent before the confirmation, that cancelling sends nothing at all, and that a + * Manager is never shown the control in the first place. + */ +describe('AdminWidgetsPage', () => { + const widget = { + id: 'w-1', + sku: 'WW-001', + name: 'Standard Widget', + description: 'A dependable widget.', + imageUrl: null, + price: 12.5, + quantityOnHand: 10, + quantityReserved: 0, + quantityAvailable: 10, + isActive: true, + } + + const catalog = () => ({ items: [widget], page: 1, pageSize: 100, total: 1 }) + + it('lists the catalog it loads', async () => { + signIn('Administrator') + stubFetch([['/admin/catalog/widgets', catalog]]) + + renderWithProviders(, { at: '/admin/widgets' }) + + expect(await screen.findByText('Standard Widget')).toBeInTheDocument() + expect(screen.getByText('WW-001')).toBeInTheDocument() + }) + + it('asks before deleting, and sends nothing until confirmed', async () => { + signIn('Administrator') + const calls = stubFetch([['/admin/catalog/widgets', catalog]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/widgets' }) + await user.click(await screen.findByRole('button', { name: 'Delete Standard Widget' })) + + // The dialog names the widget and explains the two outcomes before anything happens. + const dialog = await screen.findByRole('dialog') + expect(within(dialog).getByText(/Delete this widget\?/)).toBeInTheDocument() + expect(within(dialog).getByText(/archived/)).toBeInTheDocument() + expect(calls.some((c) => c.init?.method === 'DELETE')).toBe(false) + }) + + it('cancelling closes the dialog without touching the API', async () => { + signIn('Administrator') + const calls = stubFetch([['/admin/catalog/widgets', catalog]]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/widgets' }) + await user.click(await screen.findByRole('button', { name: 'Delete Standard Widget' })) + await user.click(await screen.findByRole('button', { name: 'Cancel' })) + + await waitFor(() => expect(screen.queryByText('Delete this widget?')).not.toBeVisible()) + expect(calls.some((c) => c.init?.method === 'DELETE')).toBe(false) + expect(screen.getByText('Standard Widget')).toBeInTheDocument() + }) + + it('confirming deletes and reports that it was removed outright', async () => { + signIn('Administrator') + const calls = stubFetch([ + ['/admin/catalog/widgets/w-1', () => ({ outcome: 'Deleted' })], + ['/admin/catalog/widgets', catalog], + ]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/widgets' }) + await user.click(await screen.findByRole('button', { name: 'Delete Standard Widget' })) + await user.click(await screen.findByRole('button', { name: 'Delete widget' })) + + await waitFor(() => expect(screen.getByText(/was deleted/)).toBeInTheDocument()) + expect(screen.getByText(/no order history/)).toBeInTheDocument() + + const del = calls.find((c) => c.init?.method === 'DELETE') + expect(del?.url).toContain('/admin/catalog/widgets/w-1') + }) + + it('reports an archive differently from a delete, because the outcome differs', async () => { + signIn('Administrator') + stubFetch([ + ['/admin/catalog/widgets/w-1', () => ({ outcome: 'Archived', orderLineCount: 3 })], + ['/admin/catalog/widgets', catalog], + ]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/widgets' }) + await user.click(await screen.findByRole('button', { name: 'Delete Standard Widget' })) + await user.click(await screen.findByRole('button', { name: 'Delete widget' })) + + // Staff need to know the widget still exists behind past orders, and on how many. + await waitFor(() => expect(screen.getByText(/archived instead/)).toBeInTheDocument()) + expect(screen.getByText(/3 order lines/)).toBeInTheDocument() + }) + + it('does not offer delete to a manager', async () => { + signIn('Manager') + stubFetch([['/admin/catalog/widgets', catalog]]) + + renderWithProviders(, { at: '/admin/widgets' }) + + expect(await screen.findByText('Standard Widget')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Delete Standard Widget' })).not.toBeInTheDocument() + }) + + it('shows the API error instead of failing silently', async () => { + signIn('Administrator') + vi.stubGlobal('fetch', vi.fn(async () => new Response( + JSON.stringify({ error: 'Catalog unavailable.' }), + { status: 500, headers: { 'Content-Type': 'application/json' } }, + ))) + + renderWithProviders(, { at: '/admin/widgets' }) + + expect(await screen.findByText(/Catalog unavailable/)).toBeInTheDocument() + }) + + it('adjusting stock posts the delta', async () => { + signIn('Administrator') + const calls = stubFetch([ + ['/inventory', () => ({ ...widget, quantityOnHand: 20, quantityAvailable: 20 })], + ['/admin/catalog/widgets', catalog], + ]) + const user = userEvent.setup() + + renderWithProviders(, { at: '/admin/widgets' }) + await user.click(await screen.findByRole('button', { name: 'Add 10 to Standard Widget' })) + + await waitFor(() => expect(calls.some((c) => c.url.includes('/inventory'))).toBe(true)) + const call = calls.find((c) => c.url.includes('/inventory')) + expect(JSON.parse(String(call?.init?.body))).toMatchObject({ quantityOnHandDelta: 10 }) + }) +}) diff --git a/web/src/test/render.tsx b/web/src/test/render.tsx new file mode 100644 index 0000000..4c31aca --- /dev/null +++ b/web/src/test/render.tsx @@ -0,0 +1,73 @@ +import { render, type RenderResult } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import type { ReactElement, ReactNode } from 'react' +import { vi } from 'vitest' +import { AuthProvider } from '../auth/AuthContext' +import { CartProvider } from '../cart/CartContext' + +export const REFRESH_KEY = 'ww.refreshToken' +export const ROLE_KEY = 'ww.role' +export const CART_KEY = 'ww.cartId' + +/** Puts a signed-in session in storage, the way a real login would. */ +export function signIn(role: 'Customer' | 'Manager' | 'Administrator' = 'Customer') { + localStorage.setItem(REFRESH_KEY, 'refresh-token-for-tests') + localStorage.setItem(ROLE_KEY, role) +} + +export function useCartId(id: string) { + localStorage.setItem(CART_KEY, id) +} + +/** + * Renders a component inside the providers it expects, on a memory router so navigation is + * observable without a browser. `at` sets the starting URL; any route in `routes` renders a + * marker so a redirect can be asserted by what lands on screen; `state` seeds router location + * state, which is how the confirmation page receives its order. `path` supplies the route + * pattern when the URL carries params (e.g. at='/widgets/w-1', path='/widgets/:id'). + */ +export function renderWithProviders( + ui: ReactElement, + { at = '/', path = '', routes = {} as Record, state = undefined as unknown } = {}, +): RenderResult { + return render( + + + + + + {Object.entries(routes).map(([path, element]) => ( + + ))} + + + + , + ) +} + +/** + * Stubs fetch with a table of [url fragment, responder] pairs. Anything unmatched fails loudly + * rather than returning undefined — a silent 200-with-nothing hides more bugs than it catches. + */ +export function stubFetch(routes: Array<[string, (init?: RequestInit) => unknown]>) { + const calls: Array<{ url: string; init?: RequestInit }> = [] + + const impl = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + calls.push({ url, init }) + const match = routes.find(([fragment]) => url.includes(fragment)) + if (!match) { + throw new Error(`Unstubbed request: ${init?.method ?? 'GET'} ${url}`) + } + + const body = match[1](init) + return new Response(JSON.stringify(body ?? {}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + } + + vi.stubGlobal('fetch', vi.fn(impl)) + return calls +} diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts new file mode 100644 index 0000000..915f09c --- /dev/null +++ b/web/src/test/setup.ts @@ -0,0 +1,27 @@ +import '@testing-library/jest-dom/vitest' +import { cleanup } from '@testing-library/react' +import { afterEach } from 'vitest' + +// jsdom ships without its behaviour: showModal/close are absent, so any component +// built on the native modal throws on mount. Minimal stand-in — enough for `open` to reflect +// reality, which is what assertions look at. +if (typeof HTMLDialogElement !== 'undefined' && !HTMLDialogElement.prototype.showModal) { + HTMLDialogElement.prototype.showModal = function showModal(this: HTMLDialogElement) { + this.open = true + } + HTMLDialogElement.prototype.show = function show(this: HTMLDialogElement) { + this.open = true + } + HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) { + this.open = false + this.dispatchEvent(new Event('close')) + } +} + +// Every test gets a clean document and clean storage; a token leaked from one test quietly +// changing another test's auth state is the classic way a suite starts lying. +afterEach(() => { + cleanup() + localStorage.clear() + sessionStorage.clear() +}) diff --git a/web/vitest.config.ts b/web/vitest.config.ts index e3da676..ea2a722 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -1,14 +1,36 @@ import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' // Kept separate from vite.config.ts so a production build never needs vitest // installed (e.g. `npm ci --omit=dev` followed by `npm run build`). export default defineConfig({ + plugins: [react()], test: { - // The suites under test are plain modules — no DOM required, so we skip - // jsdom and stub the couple of browser globals the API client touches. - environment: 'node', - include: ['src/**/*.test.ts'], + // jsdom for the component suites; the pure-module suites don't care either way. + environment: 'jsdom', + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + setupFiles: ['src/test/setup.ts'], restoreMocks: true, unstubGlobals: true, + coverage: { + provider: 'v8', + reporter: ['text-summary', 'json-summary', 'lcov'], + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/**/*.test.{ts,tsx}', + 'src/test/**', + // Composition root and declaration-only modules: nothing to assert. + 'src/main.tsx', + 'src/api/types.ts', + ], + // A floor, not a target: it catches a regression rather than inviting tests written to + // hit a number. `npm run test:coverage` fails the run when coverage drops below it. + thresholds: { + statements: 80, + branches: 70, + functions: 80, + lines: 82, + }, + }, }, })