diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08057e0..c6e0539 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,3 +24,5 @@ jobs: - run: npm run test - run: npm run build + + - run: npm run check:dist diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index ea649ec..d58953c 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -25,6 +25,13 @@ those first if you're new to the org, not just this file. ## Where things live - `src/services/` — API client calls to `docforum-core`. `api-client.ts` is the only place fetch/base-URL/error-envelope details live. +- `src/mocks/` — MSW mock API for `npm run dev:mock` (issue #95). Handlers + mirror the same real/assumed contracts `src/services/` calls, over + obviously fake `example.test` fixtures. Loaded only behind the + `import.meta.env.DEV && VITE_ENABLE_MOCKS` guard in `main.tsx`, before + the first render; production bundles must never contain it — CI enforces + this via `npm run check:dist`. When an assumed contract in this file is + reconciled, update `src/mocks/` fixtures/handlers in the same PR. - `src/features/` — flow-specific logic (booking, referral-accept, order fulfillment selection, etc.) — one feature folder per PRD core flow. - `src/pages/` — route-level components. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e3e2051..5c5d33c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,33 @@ to actually do anything: run `docforum-core`'s backend yourself (see its `CONTRIBUTING.md`), or point `VITE_API_BASE_URL` at a hosted instance. `npm run dev` defaults to `http://localhost:4000`. +### Run with no backend at all: the mock API + +`npm run dev:mock` starts the same Vite dev server with an +[MSW](https://mswjs.io)-backed mock of every endpoint the app calls — +no `docforum-core` needed: + +- **Role switcher** — jump straight into the patient, doctor, facility or + admin experience (seeded personas with obviously fake data, all + `@example.test` emails). Logging in through the real login screen works + too; any email logs you in as the selected role. +- **Scenarios** — toggle empty datasets and forced 500s to see empty and + error states without hand-crafting backend conditions. +- **Latency** — delay every response (0–3 s) to check loading states. +- **Reset data** — restore the fixture database after in-session + mutations (nothing is persisted; fixtures reset on reload). + +Mock handlers live in `src/mocks/` and sit on top of the fixtures in +`src/mocks/fixtures.ts`. The app still reaches them through the normal +service layer (`src/services/`), so screens behave exactly as they do +against the real API. Mock code is excluded from production builds, and +CI fails if it ever leaks (`npm run check:dist`). + +**When you add a service call to a new endpoint**, add a handler and +fixtures in `src/mocks/` in the same PR, and cover it in +`src/mocks/handlers.test.ts` — that's what keeps `dev:mock` honest as the +API grows. + ## 2. Find something to work on Open issues are scoped, real, and tagged with a complexity/point value diff --git a/ROADMAP.md b/ROADMAP.md index e1d6738..71093d5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -86,6 +86,12 @@ real API" (no refresh-on-reload flow). a session exists, same caveat as the facility dashboard already had. - [ ] Link transaction hashes/escrows to the Stellar testnet explorer. Tracked as [issue #10](https://github.com/DocForum/docforum-web/issues/10) (Trivial, 100 pts). +## Phase W7 — Platform quality: reliability, accessibility, performance, testing +- [x] Mock API for local development (MSW). Tracked as [issue #95](https://github.com/DocForum/docforum-web/issues/95) (High, 200 pts) — `npm run dev:mock` serves every endpoint the app uses through the normal service layer via `src/mocks/`: seeded obviously-fake fixtures, a dev-panel role switcher (patient/doctor/facility/admin), scenario toggles (empty states, forced errors), a latency slider, and a fixture reset. 26 handler tests drive the real `src/services/` layer against MSW; CI fails if mock code ever enters a production bundle (`npm run check:dist`). Documented in `CONTRIBUTING.md`. +- [ ] Generate MSW handlers and fixtures from the backend OpenAPI spec. Tracked as [issue #96](https://github.com/DocForum/docforum-web/issues/96) (High, 200 pts) — **blocked**: depends on [docforum-core#112](https://github.com/DocForum/docforum-core/issues/112) (OpenAPI 3.1 spec generated from the Zod schemas), which is still open — there is no authoritative `openapi.json` to generate from yet. Do not fabricate one; see that issue. +- [ ] Storybook component library with interaction and accessibility tests. Tracked as [issue #97](https://github.com/DocForum/docforum-web/issues/97) (High, 200 pts). +- [ ] Performance budget: route-level code splitting, lazy GSAP, and Lighthouse CI. Tracked as [issue #99](https://github.com/DocForum/docforum-web/issues/99) (High, 200 pts). + ## Changelog - 2026-09-09 — Roadmap created, carved out of docforum-core Phase 7 as part of the 3-repo org split. diff --git a/package-lock.json b/package-lock.json index fdefee5..5283737 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^4.7.0", "happy-dom": "^20.14.5", + "msw": "^2.15.0", "typescript": "^7.0.2", "vite": "^6.4.3", "vitest": "^4.1.11" @@ -769,6 +770,93 @@ "node": ">=18" } }, + "node_modules/@inquirer/ansi": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.9.tgz", + "integrity": "sha512-0Ywt3S8tqM6TWUcsRlBFQ1x+X3/o8kktX0ZiKAnByYb9GFDknnOhse1PVtfiD2BXPI8ar+BWx9PV9R0lQIcygw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.3.tgz", + "integrity": "sha512-ImOBgpl/sJTFp8naeSkNu4S0Sf8F8pxv3/CUx+LoBAdQvCVqFz6rSB2A/oZYsJge3+yphbEDxEkfaRNYq1OU0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^12.0.4", + "@inquirer/type": "4.2.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "12.0.4", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.4.tgz", + "integrity": "sha512-M5fB67y7jPObI45vhD82RskWwCSm0scOvenV/I6K8tIi2gfr9DXqQqJ+iw+Wi6SgapikdOiJibYzz1gW47fsDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.9", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.2.0", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", + "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/type": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.2.0.tgz", + "integrity": "sha512-2rnHX8RPo4glJYqnFc/v3nQeIhDcjyRRyaT7MGcfL/8xv/8K03SXYoA2tX06WVSKywWOE/ncbG3dtuzLjR5TCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -819,6 +907,31 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.9", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.9.tgz", + "integrity": "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mswjs/interceptors/node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -836,6 +949,31 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", + "integrity": "sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1424,6 +1562,23 @@ "@types/react": "^19.3.0" } }, + "node_modules/@types/set-cookie-parser": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@types/set-cookie-parser/-/set-cookie-parser-2.4.10.tgz", + "integrity": "sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -1921,7 +2076,6 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -2051,6 +2205,51 @@ "node": ">=18" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2128,6 +2327,13 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -2207,6 +2413,33 @@ "node": ">=12.0.0" } }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2250,6 +2483,26 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/gsap": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", @@ -2298,6 +2551,24 @@ "node": ">=12" } }, + "node_modules/headers-polyfill": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", + "integrity": "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/set-cookie-parser": "^2.4.10", + "set-cookie-parser": "^3.0.1" + } + }, + "node_modules/headers-polyfill/node_modules/set-cookie-parser": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.4.tgz", + "integrity": "sha512-iEmdEew+Tn1BwMGMP8LzGBEPC7Mf1sySjglCeEPb/Vchm9bnGezePvacYcId+fGprQzIng+TBkzqfJ/qS7AsBg==", + "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", @@ -2308,6 +2579,23 @@ "node": ">=8" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2389,6 +2677,61 @@ "dev": true, "license": "MIT" }, + "node_modules/msw": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.15.0.tgz", + "integrity": "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^6.0.11", + "@mswjs/interceptors": "^0.41.3", + "@open-draft/deferred-promise": "^3.0.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.1.1", + "graphql": "^16.13.2", + "headers-polyfill": "^5.0.1", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.11.11", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.1", + "type-fest": "^5.5.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/nanoid": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", @@ -2432,6 +2775,20 @@ "node": ">=12.20.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2595,6 +2952,23 @@ "node": ">=8" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rettime": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.12.tgz", + "integrity": "sha512-5ExtR1ppiDBQkVoPw5NAqdbfH4Iq2VOmQKHMtUoaFxA9bo2krzFYBtyq6K40xxbYp+2w/YOr6D+1ToF6tTN53A==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.63.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.2.tgz", @@ -2670,6 +3044,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2687,6 +3074,16 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -2694,6 +3091,41 @@ "dev": true, "license": "MIT" }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -2707,6 +3139,19 @@ "node": ">=8" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2751,6 +3196,55 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.18" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", + "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/type-fest": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz", + "integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -2793,6 +3287,16 @@ "dev": true, "license": "MIT" }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", @@ -3006,6 +3510,40 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/ws": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", @@ -3028,6 +3566,16 @@ } } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -3035,6 +3583,35 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/zustand": { "version": "5.0.15", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", diff --git a/package.json b/package.json index 399f8e9..207a770 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,10 @@ "type": "module", "scripts": { "dev": "vite", + "dev:mock": "VITE_ENABLE_MOCKS=true vite", "build": "tsc -b && vite build", "build:pages": "tsc -b && GITHUB_PAGES=true vite build", + "check:dist": "node scripts/check-dist-no-mocks.mjs", "preview": "vite preview", "typecheck": "tsc -b --noEmit", "test": "vitest run" @@ -27,6 +29,7 @@ "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^4.7.0", "happy-dom": "^20.14.5", + "msw": "^2.15.0", "typescript": "^7.0.2", "vite": "^6.4.3", "vitest": "^4.1.11" diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 0000000..0c970ef --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,361 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.15.0' +const INTEGRITY_CHECKSUM = '03cb67ac84128e63d7cd722a6e5b7f1e' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Omit the body of server-sent event stream responses. + // Cloning such responses would prevent client-side stream cancelations + // from reaching the original stream (a teed stream only cancels its + // source once both of its branches cancel) and would buffer the + // entire stream into the unconsumed clone indefinitely. + const isEventStreamResponse = response.headers + .get('content-type') + ?.toLowerCase() + .startsWith('text/event-stream') + + // Clone the response so both the client and the library could consume it. + const responseClone = isEventStreamResponse ? null : response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: response.type, + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: responseClone ? responseClone.body : null, + }, + }, + }, + responseClone && responseClone.body + ? [serializedRequest.body, responseClone.body] + : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/screenshots/mock-dev-panel-desktop.png b/screenshots/mock-dev-panel-desktop.png new file mode 100644 index 0000000..a4545be Binary files /dev/null and b/screenshots/mock-dev-panel-desktop.png differ diff --git a/screenshots/mock-dev-panel-mobile.png b/screenshots/mock-dev-panel-mobile.png new file mode 100644 index 0000000..71a53b2 Binary files /dev/null and b/screenshots/mock-dev-panel-mobile.png differ diff --git a/screenshots/mock-doctor-dashboard-desktop.png b/screenshots/mock-doctor-dashboard-desktop.png new file mode 100644 index 0000000..58d8f94 Binary files /dev/null and b/screenshots/mock-doctor-dashboard-desktop.png differ diff --git a/screenshots/mock-patient-dashboard-mobile.png b/screenshots/mock-patient-dashboard-mobile.png new file mode 100644 index 0000000..2ee41ab Binary files /dev/null and b/screenshots/mock-patient-dashboard-mobile.png differ diff --git a/screenshots/mock-patient-dependents-desktop.png b/screenshots/mock-patient-dependents-desktop.png new file mode 100644 index 0000000..7b71898 Binary files /dev/null and b/screenshots/mock-patient-dependents-desktop.png differ diff --git a/screenshots/mock-scenario-empty-desktop.png b/screenshots/mock-scenario-empty-desktop.png new file mode 100644 index 0000000..83cc811 Binary files /dev/null and b/screenshots/mock-scenario-empty-desktop.png differ diff --git a/screenshots/mock-scenario-error-desktop.png b/screenshots/mock-scenario-error-desktop.png new file mode 100644 index 0000000..26bcd80 Binary files /dev/null and b/screenshots/mock-scenario-error-desktop.png differ diff --git a/scripts/check-dist-no-mocks.mjs b/scripts/check-dist-no-mocks.mjs new file mode 100644 index 0000000..0083060 --- /dev/null +++ b/scripts/check-dist-no-mocks.mjs @@ -0,0 +1,49 @@ +// Fails (exit 1) if any string that only exists inside src/mocks/** shows +// up in the production bundle in dist/. Run as `npm run check:dist` after +// `npm run build`; wired into CI so mock-mode code can never silently +// ship to production (issue #95). +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const DIST = join(process.cwd(), 'dist'); + +// Unique literals from src/mocks/** — one per mock-only module. +const FORBIDDEN_MARKERS = [ + 'Mock scenario: forced server error (dev panel)', // mocks/helpers.ts + 'mock-access-token', // mocks/fixtures.ts + 'mock-dev-panel-root', // mocks/setup.ts + 'MOCK API', // mocks/DevPanel.tsx (panel title/pill) + 'setupWorker', // msw/browser entry +]; + +function* walk(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) yield* walk(path); + else if (/\.(js|css|html)$/.test(entry.name)) yield path; + } +} + +let leaked = false; +let scanned = 0; + +for (const file of walk(DIST)) { + scanned += 1; + const content = readFileSync(file, 'utf8'); + for (const marker of FORBIDDEN_MARKERS) { + if (content.includes(marker)) { + leaked = true; + console.error(`✗ mock marker ${JSON.stringify(marker)} found in ${file}`); + } + } +} + +if (leaked) { + console.error( + '\nMock-mode code leaked into the production bundle. Ensure src/mocks/** is only ' + + 'imported behind the `import.meta.env.DEV && VITE_ENABLE_MOCKS` guard in src/main.tsx.', + ); + process.exit(1); +} + +console.log(`✓ no mock code in production bundle (${scanned} files scanned)`); diff --git a/src/main.tsx b/src/main.tsx index e67f490..82241c5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,19 +5,24 @@ import { createRoot } from 'react-dom/client'; // under history-API routing. Hash-based routing needs no server config. // Revisit if this ever deploys somewhere with a real rewrite rule. import { HashRouter } from 'react-router-dom'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { queryClient } from './query-client'; import { useAuthStore } from './store/auth-store'; import App from './App'; import './styles/global.css'; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: 1, - refetchOnWindowFocus: false, - }, - }, -}); +// Mock API for local development (issue #95) — only runs under +// `npm run dev:mock` (a DEV build with VITE_ENABLE_MOCKS=true). In +// production builds import.meta.env.DEV is statically false, so this +// branch is dead code and the mocks chunk is never emitted — CI enforces +// that with `npm run check:dist` (scripts/check-dist-no-mocks.mjs). +// Awaited before the first render so the persona session exists when +// route guards first evaluate (deep links would otherwise bounce to the +// login screen mid-boot). +if (import.meta.env.DEV && import.meta.env.VITE_ENABLE_MOCKS === 'true') { + const { startMockApi } = await import('./mocks/setup'); + startMockApi(); +} // Enable preview session when previewRole query param is set (useful for screenshots & review) const urlParams = new URLSearchParams(window.location.search); diff --git a/src/mocks/DevPanel.module.css b/src/mocks/DevPanel.module.css new file mode 100644 index 0000000..0f059b0 --- /dev/null +++ b/src/mocks/DevPanel.module.css @@ -0,0 +1,159 @@ +.panel { + position: fixed; + bottom: var(--space-4); + left: var(--space-4); + z-index: 9999; + width: 264px; + padding: var(--space-4); + background: var(--color-surface); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + font-family: var(--font-sans); + font-size: 0.8125rem; + color: var(--color-text); +} + +.pill { + position: fixed; + bottom: var(--space-4); + left: var(--space-4); + z-index: 9999; + padding: var(--space-2) var(--space-3); + background: var(--color-accent); + color: var(--color-accent-contrast); + border: none; + border-radius: var(--radius-full); + box-shadow: var(--shadow-md); + font-family: var(--font-sans); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + cursor: pointer; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.title { + font-weight: 600; +} + +.warning { + margin: 0 0 var(--space-3); + padding: var(--space-2); + background: var(--color-warning-surface); + color: var(--color-text); + border-radius: var(--radius-sm); + font-size: 0.75rem; +} + +.group { + margin-bottom: var(--space-3); +} + +.groupLabel { + display: block; + margin-bottom: var(--space-1); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-muted); +} + +.segmented { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); +} + +.option { + flex: 1 1 auto; + padding: var(--space-1) var(--space-2); + background: var(--color-surface-alt); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-family: inherit; + font-size: 0.75rem; + cursor: pointer; +} + +.option:hover { + background: var(--color-surface-hover); +} + +.optionActive { + background: var(--color-accent); + border-color: var(--color-accent); + color: var(--color-accent-contrast); + font-weight: 600; +} + +.latencyRow { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.latencyRow input[type='range'] { + flex: 1; + accent-color: var(--color-accent); +} + +.latencyValue { + min-width: 3.5rem; + text-align: right; + font-variant-numeric: tabular-nums; + color: var(--color-text-muted); + font-size: 0.75rem; +} + +.actions { + display: flex; + gap: var(--space-2); +} + +.action { + flex: 1; + padding: var(--space-2); + background: var(--color-surface-alt); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-family: inherit; + font-size: 0.75rem; + cursor: pointer; +} + +.action:hover { + background: var(--color-surface-hover); +} + +.close { + padding: var(--space-1) var(--space-2); + background: none; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text-muted); + font-family: inherit; + font-size: 0.75rem; + cursor: pointer; +} + +.close:hover { + color: var(--color-text); +} + +@media (prefers-reduced-motion: reduce) { + .panel, + .pill { + transition: none; + } +} diff --git a/src/mocks/DevPanel.tsx b/src/mocks/DevPanel.tsx new file mode 100644 index 0000000..bf3f93f --- /dev/null +++ b/src/mocks/DevPanel.tsx @@ -0,0 +1,129 @@ +// Development-only panel for mock mode (issue #95): role switcher, scenario +// toggles (empty states / forced errors), artificial latency, and fixture +// reset. Rendered into its own React root from src/mocks/setup.ts — the +// application tree itself never imports anything under src/mocks/. +import { useState } from 'react'; +import { queryClient } from '../query-client'; +import { resetDb } from './db'; +import styles from './DevPanel.module.css'; +import { useMockStore, type MockScenario } from './mock-store'; +import { activateMockRole, mockSignOut } from './roles'; +import type { Role } from '../types/models'; + +const ROLES: Role[] = ['patient', 'doctor', 'facility', 'admin']; +const SCENARIOS: MockScenario[] = ['normal', 'empty', 'error']; + +export function DevPanel() { + const [open, setOpen] = useState(true); + const scenario = useMockStore((s) => s.scenario); + const latencyMs = useMockStore((s) => s.latencyMs); + const activeRole = useMockStore((s) => s.activeRole); + const setScenario = useMockStore((s) => s.setScenario); + const setLatencyMs = useMockStore((s) => s.setLatencyMs); + + if (!open) { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts new file mode 100644 index 0000000..aa4c01a --- /dev/null +++ b/src/mocks/browser.ts @@ -0,0 +1,9 @@ +// MSW service worker for browser mock mode. Only ever loaded from +// src/mocks/setup.ts, which itself is only dynamically imported by +// main.tsx when running `npm run dev:mock` (DEV build + VITE_ENABLE_MOCKS). +// Production builds eliminate that import entirely — verified in CI by +// `npm run check:dist` (scripts/check-dist-no-mocks.mjs). +import { setupWorker } from 'msw/browser'; +import { handlers } from './handlers'; + +export const worker = setupWorker(...handlers); diff --git a/src/mocks/db.ts b/src/mocks/db.ts new file mode 100644 index 0000000..194ae27 --- /dev/null +++ b/src/mocks/db.ts @@ -0,0 +1,12 @@ +// Mutable in-memory "database" backing the mock API. Handlers mutate this +// directly (start a visit, add a dependent, fund an intent) so state +// persists across requests for the lifetime of the dev server — and resets +// on `Reset fixture data` in the dev panel or on reload. Never imported by +// application code; only by src/mocks/**. +import { createSeedDb, type MockDb } from './fixtures'; + +export let db: MockDb = createSeedDb(); + +export function resetDb(): void { + db = createSeedDb(); +} diff --git a/src/mocks/fixtures.ts b/src/mocks/fixtures.ts new file mode 100644 index 0000000..8b0cd99 --- /dev/null +++ b/src/mocks/fixtures.ts @@ -0,0 +1,665 @@ +// Seed fixtures for the mock API (issue #95). Everything here is +// DELIBERATELY FAKE: names use Placeholder/Example/Demo personas and all +// emails use the RFC 2606 reserved `example.test` domain so no fixture +// can ever be mistaken for a real person or address. Fixtures mirror the +// shapes in src/types/models.ts (which mirror docforum-core's schema) — +// see ARCHITECTURE_ESSENTIALS.md "Known assumptions" for which of those +// shapes are still assumed contracts. +import type { + Appointment, + CareThread, + ConsultationAddendum, + ConsultationNote, + DataExport, + Dependent, + DoctorPracticeInsights, + FacilityInvite, + FacilityMember, + FacilityWithDistance, + FulfillmentQueueItem, + HealthEntry, + MessageThread, + PaymentIntent, + RunningDelay, + User, + WalletLink, +} from '../types/models'; + +// --- Personas (one per role the dev panel can switch to) -------------------- + +export const MOCK_ACCESS_TOKEN = 'mock-access-token'; + +export const PATIENT_USER: User = { + id: 'usr-patient-demo', + email: 'pat.placeholder@example.test', + role: 'patient', + createdAt: '2026-01-05T09:00:00.000Z', + updatedAt: '2026-01-05T09:00:00.000Z', +}; + +export const DOCTOR_USER: User = { + id: 'usr-doctor-demo', + email: 'demo.doctor@example.test', + role: 'doctor', + createdAt: '2026-01-06T09:00:00.000Z', + updatedAt: '2026-01-06T09:00:00.000Z', +}; + +export const FACILITY_USER: User = { + id: 'usr-facility-demo', + email: 'frank.facility@example.test', + role: 'facility', + createdAt: '2026-01-07T09:00:00.000Z', + updatedAt: '2026-01-07T09:00:00.000Z', +}; + +export const ADMIN_USER: User = { + id: 'usr-admin-demo', + email: 'al.admin@example.test', + role: 'admin', + createdAt: '2026-01-08T09:00:00.000Z', + updatedAt: '2026-01-08T09:00:00.000Z', +}; + +/** Fixed ids used across fixtures so profile-scoped calls line up. */ +export const PATIENT_PROFILE_ID = 'prf-patient-demo'; +export const DEPENDENT_PROFILE_ID = 'prf-dependent-demo'; +export const DOCTOR_PROFILE_ID = 'prf-doctor-demo'; +export const FACILITY_ID = 'fac-demo-pharmacy'; + +// --- Seed database ----------------------------------------------------------- + +export interface MockDb { + dependents: Dependent[]; + healthEntries: HealthEntry[]; + dataExport: DataExport | null; + careThreads: CareThread[]; + appointments: Appointment[]; + runningDelay: RunningDelay; + messageThreads: MessageThread[]; + consultationNotes: ConsultationNote[]; + insights: DoctorPracticeInsights; + facilities: FacilityWithDistance[]; + facilityMembers: FacilityMember[]; + facilityInvites: FacilityInvite[]; + fulfillmentQueue: FulfillmentQueueItem[]; + walletLink: WalletLink | null; + paymentIntents: PaymentIntent[]; + consultationAddenda: ConsultationAddendum[]; // flattened, noteId-keyed lookups below +} + +export function createSeedDb(): MockDb { + const dependents: Dependent[] = [ + { + id: 'dep-demo-1', + guardianProfileId: PATIENT_PROFILE_ID, + fullName: 'Tyke Placeholder', + dateOfBirth: '2015-04-01', + relationship: 'parent', + handoverStatus: 'invited', + claimEmail: 'tyke.placeholder@example.test', + claimInviteExpiresAt: '2026-10-25T09:00:00.000Z', + createdAt: '2026-02-01T10:00:00.000Z', + updatedAt: '2026-10-10T10:00:00.000Z', + }, + { + id: 'dep-demo-2', + guardianProfileId: PATIENT_PROFILE_ID, + fullName: 'Junior Placeholder', + dateOfBirth: '2010-07-15', + relationship: 'guardian', + handoverStatus: 'not_eligible', + createdAt: '2026-03-01T10:00:00.000Z', + updatedAt: '2026-03-01T10:00:00.000Z', + }, + ]; + + const healthEntries: HealthEntry[] = [ + { + id: 'hev-demo-allergy-1', + patientProfileId: PATIENT_PROFILE_ID, + section: 'allergies', + name: 'Peanuts (fake)', + details: 'Demo fixture — severe reaction, carries an auto-injector (not real).', + version: 1, + updatedAt: '2026-05-01T10:00:00.000Z', + createdAt: '2026-05-01T10:00:00.000Z', + }, + { + id: 'hev-demo-condition-1', + patientProfileId: PATIENT_PROFILE_ID, + section: 'conditions', + name: 'Sample asthma (fake)', + details: 'Demo fixture — mild, exercise-induced.', + version: 2, + previousValue: 'Sample asthma (fake), mild', + updatedAt: '2026-06-01T10:00:00.000Z', + createdAt: '2026-06-01T10:00:00.000Z', + }, + { + id: 'hev-demo-medication-1', + patientProfileId: PATIENT_PROFILE_ID, + section: 'medications', + name: 'Demo Vitamin D 1000 IU', + details: 'Demo fixture — once daily.', + version: 1, + updatedAt: '2026-07-01T10:00:00.000Z', + createdAt: '2026-07-01T10:00:00.000Z', + }, + ]; + + const dataExport: DataExport = { + id: 'exp-demo-1', + patientProfileId: PATIENT_PROFILE_ID, + status: 'ready', + requestedAt: '2026-10-17T09:00:00.000Z', + readyAt: '2026-10-17T09:02:00.000Z', + expiresAt: '2026-10-18T09:02:00.000Z', + fileSizeBytes: 20480, + downloadUrl: null, + errorMessage: null, + }; + + const careThreads: CareThread[] = [ + { + id: 'cth-demo-1', + patientProfileId: PATIENT_PROFILE_ID, + title: 'Demo cough consult (fake)', + doctorName: 'Dr. Ficticia Example', + specialty: 'General practice (demo)', + startedAt: '2026-10-01T09:00:00.000Z', + updatedAt: '2026-10-09T12:00:00.000Z', + status: 'active', + steps: [ + { + id: 'st-demo-1', + stepType: 'appointment', + title: 'Appointment booked (demo)', + description: 'Demo fixture appointment with Dr. Ficticia Example.', + status: 'completed', + timestamp: '2026-10-01T09:00:00.000Z', + }, + { + id: 'st-demo-2', + stepType: 'consultation', + title: 'Consultation completed (demo)', + description: 'Demo consultation notes recorded.', + status: 'completed', + timestamp: '2026-10-08T10:00:00.000Z', + }, + { + id: 'st-demo-3', + stepType: 'order', + title: 'Lab order placed (demo)', + description: 'Fake full blood count sent to Demo Lab.', + status: 'in_progress', + timestamp: '2026-10-08T10:15:00.000Z', + }, + { + id: 'st-demo-4', + stepType: 'payment', + title: 'Escrow funded (demo)', + description: 'Fake payment intent funded and held in escrow.', + status: 'pending', + }, + ], + }, + { + id: 'cth-demo-2', + patientProfileId: PATIENT_PROFILE_ID, + title: 'Demo vaccination visit (fake)', + doctorName: 'Dr. Ficticia Example', + specialty: 'General practice (demo)', + startedAt: '2026-08-10T09:00:00.000Z', + updatedAt: '2026-08-10T11:00:00.000Z', + status: 'completed', + steps: [ + { + id: 'st-demo-5', + stepType: 'appointment', + title: 'Appointment completed (demo)', + description: 'Demo fixture vaccination visit.', + status: 'completed', + timestamp: '2026-08-10T11:00:00.000Z', + }, + ], + }, + ]; + + const appointments: Appointment[] = [ + { + id: 'apt-demo-1', + doctorProfileId: DOCTOR_PROFILE_ID, + patientProfileId: PATIENT_PROFILE_ID, + patientName: 'Pat Placeholder', + startsAt: '2026-10-18T08:00:00.000Z', + endsAt: '2026-10-18T08:30:00.000Z', + status: 'completed', + reason: 'Demo follow-up (fake)', + checkInAt: '2026-10-18T07:55:00.000Z', + startedAt: '2026-10-18T08:05:00.000Z', + completedAt: '2026-10-18T08:28:00.000Z', + }, + { + id: 'apt-demo-2', + doctorProfileId: DOCTOR_PROFILE_ID, + patientProfileId: 'prf-patient-2', + patientName: 'Sam Example', + startsAt: '2026-10-18T09:00:00.000Z', + endsAt: '2026-10-18T09:30:00.000Z', + status: 'in_progress', + reason: 'Demo chest pain assessment (fake)', + checkInAt: '2026-10-18T08:52:00.000Z', + startedAt: '2026-10-18T09:07:00.000Z', + completedAt: null, + }, + { + id: 'apt-demo-3', + doctorProfileId: DOCTOR_PROFILE_ID, + patientProfileId: 'prf-patient-3', + patientName: 'Quinn Example', + startsAt: '2026-10-18T09:30:00.000Z', + endsAt: '2026-10-18T10:00:00.000Z', + status: 'checked_in', + reason: 'Demo prescription review (fake)', + checkInAt: '2026-10-18T09:20:00.000Z', + startedAt: null, + completedAt: null, + }, + { + id: 'apt-demo-4', + doctorProfileId: DOCTOR_PROFILE_ID, + patientProfileId: 'prf-patient-4', + patientName: 'Robin Sample', + startsAt: '2026-10-18T10:00:00.000Z', + endsAt: '2026-10-18T10:30:00.000Z', + status: 'scheduled', + reason: 'Demo new concern (fake)', + checkInAt: null, + startedAt: null, + completedAt: null, + }, + { + id: 'apt-demo-5', + doctorProfileId: DOCTOR_PROFILE_ID, + patientProfileId: 'prf-patient-5', + patientName: 'Morgan Testdata', + startsAt: '2026-10-18T10:30:00.000Z', + endsAt: '2026-10-18T11:00:00.000Z', + status: 'no_show', + reason: 'Demo no-show (fake)', + checkInAt: null, + startedAt: null, + completedAt: null, + }, + ]; + + const runningDelay: RunningDelay = { + computedDelayMin: 12, + manualOverrideMin: null, + effectiveDelayMin: 12, + }; + + const messageThreads: MessageThread[] = [ + { + id: 'mth-demo-1', + patientProfileId: PATIENT_PROFILE_ID, + patientName: 'Pat Placeholder', + lastMessageAt: '2026-10-18T07:10:00.000Z', + unreadCount: 3, + lastPreview: 'Demo message (fake): my cough got worse overnight…', + waitingMinutes: 185, + closingSoon: true, + }, + { + id: 'mth-demo-2', + patientProfileId: 'prf-patient-2', + patientName: 'Sam Example', + lastMessageAt: '2026-10-18T08:45:00.000Z', + unreadCount: 1, + lastPreview: 'Demo message (fake): when should I take the new tablets?', + waitingMinutes: 90, + closingSoon: false, + }, + { + id: 'mth-demo-3', + patientProfileId: 'prf-patient-3', + patientName: 'Quinn Example', + lastMessageAt: '2026-10-17T16:20:00.000Z', + unreadCount: 0, + lastPreview: 'Demo message (fake): thanks doctor!', + waitingMinutes: null, + closingSoon: false, + }, + { + id: 'mth-demo-4', + patientProfileId: 'prf-patient-6', + patientName: 'Casey Fixture', + lastMessageAt: '2026-10-18T08:00:00.000Z', + unreadCount: 2, + lastPreview: 'Demo message (fake): pharmacy says my order is not ready…', + waitingMinutes: 120, + closingSoon: true, + }, + ]; + + const consultationAddenda: ConsultationAddendum[] = [ + { + id: 'cad-demo-1', + noteId: 'not-demo-signed', + content: 'Demo addendum (fake): patient called to confirm dosage.', + createdAt: '2026-10-16T12:00:00.000Z', + }, + ]; + + const consultationNotes: ConsultationNote[] = [ + { + id: 'not-demo-unsigned', + appointmentId: 'apt-demo-2', + content: + 'Demo consultation note (fake). Patient reports mild symptoms. Plan: rest, fluids, review in one week. No real medical data lives in this fixture.', + signedAt: null, + signedBy: null, + autoSignOffAt: '2026-10-19T09:00:00.000Z', + addenda: [], + }, + { + id: 'not-demo-signed', + appointmentId: 'apt-demo-1', + content: 'Demo consultation note (fake), already signed off.', + signedAt: '2026-10-18T08:35:00.000Z', + signedBy: 'Dr. Demo Doctor (fake)', + autoSignOffAt: null, + addenda: consultationAddenda.filter((a) => a.noteId === 'not-demo-signed'), + }, + ]; + + const insights: DoctorPracticeInsights = { + doctorProfileId: DOCTOR_PROFILE_ID, + range: { preset: '30d', startDate: '2026-09-18', endDate: '2026-10-18' }, + generatedAt: '2026-10-18T06:00:00.000Z', + utilisation: { + weeks: [ + { + weekLabel: 'Sep 21', + startDate: '2026-09-21', + endDate: '2026-09-27', + offeredSlots: 40, + bookedSlots: 31, + completedSlots: 28, + utilisationRate: 77.5, + }, + { + weekLabel: 'Sep 28', + startDate: '2026-09-28', + endDate: '2026-10-04', + offeredSlots: 40, + bookedSlots: 34, + completedSlots: 30, + utilisationRate: 85, + }, + { + weekLabel: 'Oct 5', + startDate: '2026-10-05', + endDate: '2026-10-11', + offeredSlots: 40, + bookedSlots: 26, + completedSlots: 24, + utilisationRate: 65, + }, + { + weekLabel: 'Oct 12', + startDate: '2026-10-12', + endDate: '2026-10-18', + offeredSlots: 40, + bookedSlots: 33, + completedSlots: 21, + utilisationRate: 82.5, + }, + ], + overallOfferedSlots: 160, + overallBookedSlots: 124, + overallUtilisationRate: 77.5, + sampleSizeWarning: false, + }, + noShows: { + totalScheduled: 124, + completedVisits: 103, + noShows: 9, + lateCancellations: 5, + onTimeCancellations: 7, + noShowRate: 7.3, + lateCancelRate: 4, + sampleSizeWarning: false, + }, + referrals: { + totalSent: 14, + totalAccepted: 10, + totalPending: 4, + averageAcceptanceHours: 30.5, + medianAcceptanceHours: 22, + breakdownUnder24h: 6, + breakdown24to48h: 3, + breakdownOver48h: 1, + sampleSizeWarning: false, + }, + messages: { + totalThreads: 38, + averageFirstResponseMinutes: 74, + medianFirstResponseMinutes: 41, + respondedWithin2hCount: 24, + respondedWithin24hCount: 35, + rateWithin2h: 63.2, + sampleSizeWarning: false, + }, + }; + + const facilities: FacilityWithDistance[] = [ + { + id: FACILITY_ID, + userId: 'usr-facility-demo', + name: 'Demo Pharmacy (fake)', + type: 'pharmacy', + location: { + address: '1 Fixture Lane', + city: 'Sampleton', + latitude: 6.5244, + longitude: 3.3792, + }, + distanceKm: null, + isOpen: true, + phone: '+000-000-0001 (fake)', + rating: 4.6, + }, + { + id: 'fac-demo-lab', + userId: 'usr-facility-lab', + name: 'Demo Lab (fake)', + type: 'lab', + location: { + address: '2 Fixture Lane', + city: 'Sampleton', + latitude: 6.5312, + longitude: 3.3915, + }, + distanceKm: null, + isOpen: true, + phone: '+000-000-0002 (fake)', + rating: 4.2, + }, + { + id: 'fac-demo-pharmacy-2', + userId: 'usr-facility-pharmacy-2', + name: 'Example Chemist (fake)', + type: 'pharmacy', + location: { + address: '3 Example Street', + city: 'Mockford', + latitude: 6.6, + longitude: 3.45, + }, + distanceKm: null, + isOpen: false, + phone: '+000-000-0003 (fake)', + rating: 3.9, + }, + ]; + + const facilityMembers: FacilityMember[] = [ + { + id: 'fmb-demo-owner', + facilityId: FACILITY_ID, + userId: 'usr-facility-demo', + fullName: 'Frank Facility (fake)', + email: 'frank.facility@example.test', + role: 'owner', + joinedAt: '2026-01-10T09:00:00.000Z', + isCurrentUser: true, + }, + { + id: 'fmb-demo-staff', + facilityId: FACILITY_ID, + userId: 'usr-facility-staff', + fullName: 'Mo Example (fake)', + email: 'mo.example@example.test', + role: 'staff', + joinedAt: '2026-02-14T09:00:00.000Z', + isCurrentUser: false, + }, + ]; + + const facilityInvites: FacilityInvite[] = [ + { + id: 'inv-demo-1', + facilityId: FACILITY_ID, + email: 'new.staff@example.test', + role: 'staff', + invitedBy: 'usr-facility-demo', + invitedAt: '2026-10-15T09:00:00.000Z', + expiresAt: '2026-10-22T09:00:00.000Z', + status: 'pending', + }, + ]; + + const fulfillmentQueue: FulfillmentQueueItem[] = [ + { + id: 'fuq-demo-1', + facilityId: FACILITY_ID, + orderId: 'ord-demo-rx-1', + orderType: 'prescription', + patientName: 'Pat Placeholder', + patientProfileId: PATIENT_PROFILE_ID, + itemName: 'Demo Amoxicillin 500mg (fake)', + instructions: 'Demo instructions (fake): one capsule three times daily for five days.', + status: 'pending', + receivedAt: '2026-10-18T07:30:00.000Z', + attribution: null, + }, + { + id: 'fuq-demo-2', + facilityId: FACILITY_ID, + orderId: 'ord-demo-lab-1', + orderType: 'lab', + patientName: 'Sam Example', + patientProfileId: 'prf-patient-2', + itemName: 'Demo blood panel (fake)', + instructions: 'Demo instructions (fake): fasting sample required.', + status: 'pending', + receivedAt: '2026-10-18T08:05:00.000Z', + attribution: null, + }, + { + id: 'fuq-demo-3', + facilityId: FACILITY_ID, + orderId: 'ord-demo-rx-2', + orderType: 'prescription', + patientName: 'Quinn Example', + patientProfileId: 'prf-patient-3', + itemName: 'Demo Metformin 850mg (fake)', + instructions: null, + status: 'fulfilled', + receivedAt: '2026-10-17T14:00:00.000Z', + attribution: { + action: 'dispensed', + performedByUserId: 'usr-facility-staff', + performedByName: 'Mo Example (fake)', + performedByEmail: 'mo.example@example.test', + performedByRole: 'staff', + timestamp: '2026-10-17T14:40:00.000Z', + notes: 'Demo attribution (fake).', + }, + }, + ]; + + const walletLink: WalletLink = { + id: 'wln-demo-1', + facilityId: FACILITY_ID, + stellarPublicKey: 'GDEMOFAKEKEY000000000000000000000000000000000000000000000000', + createdAt: '2026-09-20T09:00:00.000Z', + updatedAt: '2026-09-20T09:00:00.000Z', + }; + + const paymentIntents: PaymentIntent[] = [ + { + id: 'pin-demo-1', + orderType: 'prescription', + orderId: 'ord-demo-rx-1', + status: 'escrowed', + patientProfileId: PATIENT_PROFILE_ID, + facilityId: FACILITY_ID, + amountStroops: '500000000', + escrowId: 'esc-demo-1 (fake)', + stellarTxHash: 'txdemo000000000000000000000000000000000000000000000000000000000001', + sorobanContractId: 'CD demo contract (fake)', + createdAt: '2026-10-17T10:00:00.000Z', + updatedAt: '2026-10-17T10:05:00.000Z', + }, + { + id: 'pin-demo-2', + orderType: 'lab_order', + orderId: 'ord-demo-lab-1', + status: 'created', + patientProfileId: 'prf-patient-2', + facilityId: FACILITY_ID, + amountStroops: '250000000', + escrowId: null, + stellarTxHash: null, + sorobanContractId: null, + createdAt: '2026-10-18T08:10:00.000Z', + updatedAt: '2026-10-18T08:10:00.000Z', + }, + { + id: 'pin-demo-3', + orderType: 'prescription', + orderId: 'ord-demo-rx-2', + status: 'released', + patientProfileId: 'prf-patient-3', + facilityId: FACILITY_ID, + amountStroops: '750000000', + escrowId: 'esc-demo-3 (fake)', + stellarTxHash: 'txdemo000000000000000000000000000000000000000000000000000000000003', + sorobanContractId: 'CD demo contract (fake)', + createdAt: '2026-10-15T10:00:00.000Z', + updatedAt: '2026-10-16T09:00:00.000Z', + }, + ]; + + return { + dependents, + healthEntries, + dataExport, + careThreads, + appointments, + runningDelay, + messageThreads, + consultationNotes, + insights, + facilities, + facilityMembers, + facilityInvites, + fulfillmentQueue, + walletLink, + paymentIntents, + consultationAddenda, + }; +} diff --git a/src/mocks/handlers.test.ts b/src/mocks/handlers.test.ts new file mode 100644 index 0000000..53932ad --- /dev/null +++ b/src/mocks/handlers.test.ts @@ -0,0 +1,414 @@ +// Tests for the mock API (issue #95). Each test drives the app's REAL +// service layer (src/services/*) through apiFetch → fetch → MSW, proving +// the handlers cover every endpoint the application uses and honour the +// dev panel's scenario/latency switches. Runs in the node environment so +// MSW's fetch interceptor wraps Node's global fetch. +// @vitest-environment node +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { setupServer } from 'msw/node'; +import { handlers } from './handlers'; +import { resetDb } from './db'; +import { useMockStore } from './mock-store'; +import { DOCTOR_USER, MOCK_ACCESS_TOKEN } from './fixtures'; +import { ApiError } from '../services/api-client'; +import * as authService from '../services/auth-service'; +import * as accountService from '../services/account-service'; +import * as careThreadService from '../services/care-thread-service'; +import * as consultationService from '../services/consultation-service'; +import * as dependentsService from '../services/dependents-service'; +import * as facilityService from '../services/facility-service'; +import * as facilityTeamService from '../services/facility-team-service'; +import * as healthService from '../services/health-profile-service'; +import * as insightsService from '../services/insights-service'; +import * as messagingService from '../services/messaging-service'; +import * as paymentsService from '../services/payments-service'; +import * as queueService from '../services/queue-service'; +import { FACILITY_ID, PATIENT_PROFILE_ID } from './fixtures'; + +const server = setupServer(...handlers); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterAll(() => server.close()); +beforeEach(() => { + resetDb(); + useMockStore.setState({ scenario: 'normal', latencyMs: 0, activeRole: 'patient' }); +}); + +describe('auth', () => { + it('logs in a seeded persona by email', async () => { + const session = await authService.login({ + email: DOCTOR_USER.email, + password: 'whatever', + }); + expect(session.user.role).toBe('doctor'); + expect(session.accessToken).toBe(MOCK_ACCESS_TOKEN); + }); + + it('logs an unknown email in as the dev panel active role', async () => { + useMockStore.setState({ activeRole: 'doctor' }); + const session = await authService.login({ + email: 'someone@example.test', + password: 'whatever', + }); + expect(session.user.role).toBe('doctor'); + expect(session.user.email).toBe('someone@example.test'); + }); + + it('rejects missing credentials with a 400 envelope', async () => { + await expect( + authService.login({ email: '', password: '' }), + ).rejects.toMatchObject({ status: 400 }); + }); + + it('signs up a patient and logs out', async () => { + const session = await authService.signup({ + role: 'patient', + email: 'new.person@example.test', + password: 'x', + fullName: 'New Person (fake)', + }); + expect(session.user.role).toBe('patient'); + // apiFetch returns the parsed JSON body — `null` for void endpoints. + await expect(authService.logout(session.accessToken)).resolves.toBeNull(); + }); + + it('claims an account', async () => { + await expect( + dependentsService.claimAccount({ token: 't', email: 'a@example.test', password: 'x' }), + ).resolves.toBeNull(); + }); +}); + +describe('dependents', () => { + it('lists seeded dependents and adds a new one', async () => { + const seeded = await dependentsService.listDependents(MOCK_ACCESS_TOKEN); + expect(seeded).toHaveLength(2); + + await dependentsService.addDependent( + { fullName: 'New Fake Dependent', dateOfBirth: '2012-01-01', relationship: 'parent' }, + MOCK_ACCESS_TOKEN, + ); + const after = await dependentsService.listDependents(MOCK_ACCESS_TOKEN); + expect(after).toHaveLength(3); + }); + + it('updates a dependent and manages handover invites', async () => { + const updated = await dependentsService.updateDependent( + 'dep-demo-2', + { fullName: 'Renamed Placeholder', dateOfBirth: '2010-07-15', relationship: 'guardian' }, + MOCK_ACCESS_TOKEN, + ); + expect(updated.fullName).toBe('Renamed Placeholder'); + + const invited = await dependentsService.sendHandoverInvite( + 'dep-demo-2', + 'junior.claim@example.test', + MOCK_ACCESS_TOKEN, + ); + expect(invited.handoverStatus).toBe('invited'); + expect(invited.claimEmail).toBe('junior.claim@example.test'); + + const resent = await dependentsService.resendHandoverInvite('dep-demo-2', MOCK_ACCESS_TOKEN); + expect(resent.claimInviteExpiresAt).toBeTruthy(); + }); + + it('404s on an unknown dependent', async () => { + await expect( + dependentsService.updateDependent( + 'nope', + { fullName: 'x', dateOfBirth: '2000-01-01', relationship: 'parent' }, + MOCK_ACCESS_TOKEN, + ), + ).rejects.toMatchObject({ status: 404 }); + }); +}); + +describe('health profile', () => { + it('lists, adds, versions and deletes entries per section', async () => { + const allergies = await healthService.listHealthEntries( + PATIENT_PROFILE_ID, + 'allergies', + MOCK_ACCESS_TOKEN, + ); + expect(allergies).toHaveLength(1); + + const added = await healthService.addHealthEntry( + PATIENT_PROFILE_ID, + { section: 'allergies', name: 'Dust (fake)' }, + MOCK_ACCESS_TOKEN, + ); + expect(added.version).toBe(1); + + const patched = await healthService.updateHealthEntry( + PATIENT_PROFILE_ID, + added.id, + { name: 'Dust mites (fake)' }, + MOCK_ACCESS_TOKEN, + ); + expect(patched.version).toBe(2); + expect(patched.previousValue).toBe('Dust (fake)'); + + await healthService.removeHealthEntry(PATIENT_PROFILE_ID, added.id, MOCK_ACCESS_TOKEN); + const after = await healthService.listHealthEntries( + PATIENT_PROFILE_ID, + 'allergies', + MOCK_ACCESS_TOKEN, + ); + expect(after).toHaveLength(1); + }); +}); + +describe('account', () => { + it('requests, reads and downloads a data export', async () => { + const requested = await accountService.requestDataExport('pw', MOCK_ACCESS_TOKEN); + expect(requested.status).toBe('ready'); + + const current = await accountService.getDataExportStatus(MOCK_ACCESS_TOKEN); + expect(current?.id).toBe(requested.id); + + const blob = await accountService.downloadDataExport(requested.id, MOCK_ACCESS_TOKEN); + const text = await blob.text(); + expect(text).toContain('FAKE DATA EXPORT'); + }); + + it('deletes the account with a confirmation message', async () => { + const response = await accountService.deleteAccount('pw', 'DELETE', MOCK_ACCESS_TOKEN); + expect(response.success).toBe(true); + expect(response.message).toContain('Mock API'); + }); +}); + +describe('care threads', () => { + it('lists threads for a profile and fetches one by id', async () => { + const threads = await careThreadService.getCareThreads(PATIENT_PROFILE_ID, MOCK_ACCESS_TOKEN); + expect(threads.length).toBeGreaterThan(0); + + const thread = await careThreadService.getCareThreadById(threads[0].id, MOCK_ACCESS_TOKEN); + expect(thread.steps.length).toBeGreaterThan(0); + }); +}); + +describe('consultation notes', () => { + it('fetches by appointment, signs off, and appends addenda', async () => { + const note = await consultationService.getConsultationNote('apt-demo-2', MOCK_ACCESS_TOKEN); + expect(note.signedAt).toBeNull(); + + const signed = await consultationService.signOffNote(note.id, MOCK_ACCESS_TOKEN); + expect(signed.signedAt).toBeTruthy(); + + const addendum = await consultationService.addAddendum( + note.id, + { content: 'Demo addendum (fake).' }, + MOCK_ACCESS_TOKEN, + ); + expect(addendum.noteId).toBe(note.id); + }); + + it('409s when signing off twice', async () => { + await expect( + consultationService.signOffNote('not-demo-signed', MOCK_ACCESS_TOKEN), + ).rejects.toMatchObject({ status: 409 }); + }); +}); + +describe('doctor queue', () => { + it('lists appointments, derives the queue, and starts/complete visits', async () => { + const appointments = await queueService.listTodayAppointments(MOCK_ACCESS_TOKEN); + expect(appointments.length).toBe(5); + + const queue = await queueService.listQueue(MOCK_ACCESS_TOKEN); + expect(queue[0].queuePosition).toBe(1); + + const started = await queueService.startVisit('apt-demo-3', MOCK_ACCESS_TOKEN); + expect(started.status).toBe('in_progress'); + + const completed = await queueService.completeVisit('apt-demo-3', MOCK_ACCESS_TOKEN); + expect(completed.status).toBe('completed'); + + await expect(queueService.completeVisit('apt-demo-4', MOCK_ACCESS_TOKEN)).rejects.toMatchObject({ + status: 409, + }); + }); + + it('applies the running-delay override', async () => { + const delay = await queueService.getRunningDelay(MOCK_ACCESS_TOKEN); + expect(delay.effectiveDelayMin).toBe(delay.computedDelayMin); + + const overridden = await queueService.updateRunningDelay( + { manualOverrideMin: 25 }, + MOCK_ACCESS_TOKEN, + ); + expect(overridden.effectiveDelayMin).toBe(25); + + const cleared = await queueService.updateRunningDelay( + { manualOverrideMin: null }, + MOCK_ACCESS_TOKEN, + ); + expect(cleared.manualOverrideMin).toBeNull(); + expect(cleared.effectiveDelayMin).toBe(cleared.computedDelayMin); + }); +}); + +describe('messaging', () => { + it('filters threads and marks them read', async () => { + const all = await messagingService.listThreads({}, MOCK_ACCESS_TOKEN); + expect(all.threads.length).toBe(4); + expect(messagingService.totalUnread(all.threads)).toBe(6); + + const unread = await messagingService.listThreads({ filter: 'unread' }, MOCK_ACCESS_TOKEN); + expect(unread.threads.every((t) => t.unreadCount > 0)).toBe(true); + + await messagingService.markThreadRead('mth-demo-1', MOCK_ACCESS_TOKEN); + const after = await messagingService.listThreads({}, MOCK_ACCESS_TOKEN); + expect(after.threads.find((t) => t.id === 'mth-demo-1')?.unreadCount).toBe(0); + }); +}); + +describe('insights', () => { + it('returns seeded insights and echoes the requested range', async () => { + const insights = await insightsService.getDoctorPracticeInsights(MOCK_ACCESS_TOKEN); + expect(insights.utilisation.weeks.length).toBeGreaterThan(0); + + const ranged = await insightsService.getDoctorPracticeInsights(MOCK_ACCESS_TOKEN, { + preset: '7d', + startDate: '2026-10-11', + endDate: '2026-10-18', + }); + expect(ranged.range.preset).toBe('7d'); + expect(ranged.range.startDate).toBe('2026-10-11'); + }); +}); + +describe('facility finder', () => { + it('filters by type and sorts by distance when coordinates are given', async () => { + const all = await facilityService.getFacilities({}, MOCK_ACCESS_TOKEN); + expect(all.length).toBe(3); + + const labs = await facilityService.getFacilities({ type: 'lab' }, MOCK_ACCESS_TOKEN); + expect(labs.every((f) => f.type === 'lab')).toBe(true); + + const nearby = await facilityService.getFacilities( + { lat: 6.5244, lng: 3.3792 }, + MOCK_ACCESS_TOKEN, + ); + expect(nearby[0].id).toBe('fac-demo-pharmacy'); + expect(nearby[0].distanceKm).toBeLessThan(0.1); + }); +}); + +describe('facility team', () => { + it('lists members and invites, and sends an invite', async () => { + const members = await facilityTeamService.listFacilityMembers(FACILITY_ID, MOCK_ACCESS_TOKEN); + expect(members).toHaveLength(2); + + const invites = await facilityTeamService.listFacilityInvites(FACILITY_ID, MOCK_ACCESS_TOKEN); + expect(invites).toHaveLength(1); + + const invite = await facilityTeamService.inviteFacilityMember( + FACILITY_ID, + { email: 'more.staff@example.test', role: 'staff' }, + MOCK_ACCESS_TOKEN, + ); + expect(invite.status).toBe('pending'); + + await facilityTeamService.resendFacilityInvite(FACILITY_ID, invite.id, MOCK_ACCESS_TOKEN); + await facilityTeamService.revokeFacilityInvite(FACILITY_ID, invite.id, MOCK_ACCESS_TOKEN); + const after = await facilityTeamService.listFacilityInvites(FACILITY_ID, MOCK_ACCESS_TOKEN); + expect(after).toHaveLength(1); + }); + + it('refuses to remove the last owner', async () => { + await expect( + facilityTeamService.removeFacilityMember(FACILITY_ID, 'fmb-demo-owner', MOCK_ACCESS_TOKEN), + ).rejects.toMatchObject({ status: 409 }); + + const removed = await facilityTeamService.removeFacilityMember( + FACILITY_ID, + 'fmb-demo-staff', + MOCK_ACCESS_TOKEN, + ); + expect(removed.sessionsRevoked).toBe(true); + }); + + it('fulfills queue items with attribution', async () => { + const queue = await facilityTeamService.getFulfillmentQueue(FACILITY_ID, MOCK_ACCESS_TOKEN); + expect(queue).toHaveLength(3); + + const updated = await facilityTeamService.updateFulfillmentItem( + FACILITY_ID, + 'fuq-demo-1', + 'fulfilled', + MOCK_ACCESS_TOKEN, + 'Demo note (fake)', + ); + expect(updated.status).toBe('fulfilled'); + expect(updated.attribution?.performedByRole).toBe('owner'); + expect(updated.attribution?.action).toBe('dispensed'); + }); +}); + +describe('payments', () => { + it('links a wallet and drives an intent through fund → release', async () => { + const wallet = await paymentsService.getOwnWalletLink(MOCK_ACCESS_TOKEN); + expect(wallet?.stellarPublicKey).toContain('GDEMOFAKE'); + + const relinked = await paymentsService.linkWallet('GNEWFAKEKEY', MOCK_ACCESS_TOKEN); + expect(relinked.stellarPublicKey).toBe('GNEWFAKEKEY'); + + const created = await paymentsService.createPaymentIntent( + { + orderType: 'prescription', + orderId: 'ord-mock-new', + patientProfileId: PATIENT_PROFILE_ID, + facilityId: FACILITY_ID, + amountStroops: '123456789', + }, + MOCK_ACCESS_TOKEN, + ); + expect(created.status).toBe('created'); + expect(created.amountStroops).toBe('123456789'); // BigInt-as-string preserved + + const funded = await paymentsService.fundPaymentIntent(created.id, MOCK_ACCESS_TOKEN); + expect(funded.status).toBe('escrowed'); + + const released = await paymentsService.releasePaymentIntent(created.id, MOCK_ACCESS_TOKEN); + expect(released.status).toBe('released'); + + await expect( + paymentsService.refundPaymentIntent(created.id, MOCK_ACCESS_TOKEN), + ).rejects.toMatchObject({ status: 409 }); + }); +}); + +describe('dev panel scenarios', () => { + it('forces 500s in the "error" scenario', async () => { + useMockStore.setState({ scenario: 'error' }); + const error = await dependentsService + .listDependents(MOCK_ACCESS_TOKEN) + .then(() => null) + .catch((e: unknown) => e); + expect(error).toBeInstanceOf(ApiError); + expect((error as ApiError).status).toBe(500); + expect((error as ApiError).message).toContain('forced server error'); + }); + + it('returns empty lists and null singletons in the "empty" scenario', async () => { + useMockStore.setState({ scenario: 'empty' }); + await expect(dependentsService.listDependents(MOCK_ACCESS_TOKEN)).resolves.toEqual([]); + await expect( + queueService.listTodayAppointments(MOCK_ACCESS_TOKEN), + ).resolves.toEqual([]); + await expect( + facilityTeamService.getFulfillmentQueue(FACILITY_ID, MOCK_ACCESS_TOKEN), + ).resolves.toEqual([]); + await expect(accountService.getDataExportStatus(MOCK_ACCESS_TOKEN)).resolves.toBeNull(); + await expect(paymentsService.getOwnWalletLink(MOCK_ACCESS_TOKEN)).resolves.toBeNull(); + }); + + it('delays responses by the configured latency', async () => { + useMockStore.setState({ latencyMs: 200 }); + const started = Date.now(); + await dependentsService.listDependents(MOCK_ACCESS_TOKEN); + expect(Date.now() - started).toBeGreaterThanOrEqual(180); + }); +}); diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts new file mode 100644 index 0000000..b1bdb11 --- /dev/null +++ b/src/mocks/handlers.ts @@ -0,0 +1,29 @@ +// All mock handlers, composed. Order matters only where paths could +// overlap — specific (`/patients/me/…`) before generic (`/patients/:id/…`). +import { accountHandlers } from './handlers/account'; +import { authHandlers } from './handlers/auth'; +import { careThreadHandlers } from './handlers/care-thread'; +import { consultationHandlers } from './handlers/consultation'; +import { dependentHandlers } from './handlers/dependents'; +import { facilityTeamHandlers } from './handlers/facility-team'; +import { facilityHandlers } from './handlers/facility'; +import { healthProfileHandlers } from './handlers/health-profile'; +import { insightsHandlers } from './handlers/insights'; +import { messagingHandlers } from './handlers/messaging'; +import { paymentsHandlers } from './handlers/payments'; +import { queueHandlers } from './handlers/queue'; + +export const handlers = [ + ...authHandlers, + ...accountHandlers, + ...dependentHandlers, + ...healthProfileHandlers, + ...careThreadHandlers, + ...consultationHandlers, + ...queueHandlers, + ...messagingHandlers, + ...insightsHandlers, + ...facilityHandlers, + ...facilityTeamHandlers, + ...paymentsHandlers, +]; diff --git a/src/mocks/handlers/account.ts b/src/mocks/handlers/account.ts new file mode 100644 index 0000000..3f4aab6 --- /dev/null +++ b/src/mocks/handlers/account.ts @@ -0,0 +1,69 @@ +// Account endpoints: data export lifecycle and account deletion. +import { http, HttpResponse } from 'msw'; +import { db } from '../db'; +import { respond } from '../helpers'; +import { PATIENT_PROFILE_ID } from '../fixtures'; +import { useMockStore } from '../mock-store'; +import type { DataExport } from '../../types/models'; + +export const accountHandlers = [ + http.post( + '*/account/data-export', + respond('json', () => { + const now = new Date(); + const ready: DataExport = { + id: `exp-mock-${Date.now()}`, + patientProfileId: PATIENT_PROFILE_ID, + status: 'ready', + requestedAt: now.toISOString(), + readyAt: now.toISOString(), + expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(), + fileSizeBytes: 20480, + downloadUrl: null, + errorMessage: null, + }; + db.dataExport = ready; + return ready; + }), + ), + + http.get('*/account/data-export', respond('nullable', () => db.dataExport)), + + http.get( + '*/account/data-export/:id/download', + async ({ params }) => { + const { scenario, latencyMs } = useMockStore.getState(); + if (latencyMs > 0) await new Promise((r) => setTimeout(r, latencyMs)); + if (scenario === 'error') { + return HttpResponse.json( + { message: 'Mock scenario: forced server error (dev panel)' }, + { status: 500 }, + ); + } + const payload = { + note: 'FAKE DATA EXPORT — generated by docforum-web mock API. Contains no real records.', + exportId: String(params.id), + generatedAt: new Date().toISOString(), + profile: { fullName: 'Pat Placeholder (fake)', dateOfBirth: '1990-01-01' }, + allergies: ['Peanuts (fake)'], + medications: ['Demo Vitamin D 1000 IU (fake)'], + }; + return new HttpResponse(JSON.stringify(payload, null, 2), { + headers: { + 'Content-Type': 'application/json', + 'Content-Disposition': 'attachment; filename="docforum-fake-export.json"', + }, + }); + }, + ), + + http.post( + '*/account/delete', + respond('json', () => ({ + success: true, + message: + 'Mock API: account "deleted" (nothing is persisted — fixtures reset on reload).', + deletedAt: new Date().toISOString(), + })), + ), +]; diff --git a/src/mocks/handlers/auth.ts b/src/mocks/handlers/auth.ts new file mode 100644 index 0000000..04f6be9 --- /dev/null +++ b/src/mocks/handlers/auth.ts @@ -0,0 +1,79 @@ +// Auth endpoints (signup / login / logout / claim-account). +import { http, HttpResponse } from 'msw'; +import { ADMIN_USER, DOCTOR_USER, FACILITY_USER, MOCK_ACCESS_TOKEN, PATIENT_USER } from '../fixtures'; +import { respond } from '../helpers'; +import { useMockStore } from '../mock-store'; +import type { Role, User } from '../../types/models'; + +const PERSONAS: Record = { + patient: PATIENT_USER, + doctor: DOCTOR_USER, + facility: FACILITY_USER, + admin: ADMIN_USER, +}; + +/** The seeded persona for a role, used by the dev panel role switcher. */ +export function personaForRole(role: Role): User { + return PERSONAS[role]; +} + +/** Find a seeded persona by email (so logging in as one "just works"). */ +export function personaByEmail(email: string): User | undefined { + return Object.values(PERSONAS).find((user) => user.email === email); +} + +function sessionFor(user: User) { + return { user, accessToken: MOCK_ACCESS_TOKEN }; +} + +export const authHandlers = [ + http.post( + '*/auth/signup', + respond('json', async ({ request }) => { + const body = (await request.json().catch(() => ({}))) as { + role?: Role; + email?: string; + password?: string; + fullName?: string; + }; + if (!body.email || !body.password) { + return HttpResponse.json({ message: 'Mock API: email and password are required' }, { status: 400 }); + } + const role: Role = body.role === 'doctor' ? 'doctor' : 'patient'; + return sessionFor({ + ...PERSONAS[role], + id: `usr-mock-${role}-new`, + email: body.email, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }), + ), + + http.post( + '*/auth/login', + respond('json', async ({ request }) => { + const body = (await request.json().catch(() => ({}))) as { + email?: string; + password?: string; + }; + if (!body.email || !body.password) { + return HttpResponse.json({ message: 'Mock API: email and password are required' }, { status: 400 }); + } + // Any seeded persona email logs in as that persona directly. + const known = personaByEmail(body.email); + if (known) return sessionFor(known); + // Anything else logs in as the dev panel's active role, keeping the + // submitted email so the UI feels real. + const role = useMockStore.getState().activeRole; + return sessionFor({ ...PERSONAS[role], email: body.email }); + }), + ), + + http.post('*/auth/logout', respond('void', () => null)), + + http.post( + '*/auth/claim-account', + respond('void', () => null), + ), +]; diff --git a/src/mocks/handlers/care-thread.ts b/src/mocks/handlers/care-thread.ts new file mode 100644 index 0000000..d6c905d --- /dev/null +++ b/src/mocks/handlers/care-thread.ts @@ -0,0 +1,20 @@ +// Care thread timeline endpoints. +import { http } from 'msw'; +import { db } from '../db'; +import { notFound, paramId, respond } from '../helpers'; + +export const careThreadHandlers = [ + http.get( + '*/patients/:profileId/care-threads', + respond('list', () => db.careThreads), + ), + + http.get( + '*/care-threads/:id', + respond('json', ({ params }) => { + const thread = db.careThreads.find((t) => t.id === paramId(params)); + if (!thread) return notFound('care thread'); + return thread; + }), + ), +]; diff --git a/src/mocks/handlers/consultation.ts b/src/mocks/handlers/consultation.ts new file mode 100644 index 0000000..1471382 --- /dev/null +++ b/src/mocks/handlers/consultation.ts @@ -0,0 +1,49 @@ +// Consultation note sign-off and addenda endpoints. +import { http } from 'msw'; +import { db } from '../db'; +import { conflict, notFound, paramId, respond } from '../helpers'; +import type { ConsultationAddendum } from '../../types/models'; + +export const consultationHandlers = [ + http.get( + '*/consultation-notes/:appointmentId', + respond('json', ({ params }) => { + const note = db.consultationNotes.find( + (n) => n.appointmentId === paramId(params, 'appointmentId'), + ); + if (!note) return notFound('consultation note'); + return note; + }), + ), + + http.post( + '*/consultation-notes/:id/sign-off', + respond('json', ({ params }) => { + const note = db.consultationNotes.find((n) => n.id === paramId(params)); + if (!note) return notFound('consultation note'); + if (note.signedAt) return conflict('note is already signed off and immutable'); + note.signedAt = new Date().toISOString(); + note.signedBy = 'Dr. Demo Doctor (fake)'; + note.autoSignOffAt = null; + return note; + }), + ), + + http.post( + '*/consultation-notes/:id/addenda', + respond('json', async ({ params, request }) => { + const note = db.consultationNotes.find((n) => n.id === paramId(params)); + if (!note) return notFound('consultation note'); + const body = (await request.json().catch(() => ({}))) as { content?: string }; + if (!body.content) return conflict('addendum content is required'); + const addendum: ConsultationAddendum = { + id: `cad-mock-${db.consultationNotes.length + 1}-${Date.now()}`, + noteId: note.id, + content: body.content, + createdAt: new Date().toISOString(), + }; + note.addenda = [...note.addenda, addendum]; + return addendum; + }), + ), +]; diff --git a/src/mocks/handlers/dependents.ts b/src/mocks/handlers/dependents.ts new file mode 100644 index 0000000..a4576b1 --- /dev/null +++ b/src/mocks/handlers/dependents.ts @@ -0,0 +1,70 @@ +// Dependents CRUD + handover invite endpoints. Specific `/patients/me/…` +// paths must be registered before generic `/patients/:profileId/…` ones. +import { http } from 'msw'; +import { db } from '../db'; +import { notFound, paramId, respond } from '../helpers'; +import type { Dependent, DependentInput } from '../../types/models'; + +export const dependentHandlers = [ + http.get('*/patients/me/dependents', respond('list', () => db.dependents)), + + http.post( + '*/patients/me/dependents', + respond('json', async ({ request }) => { + const body = (await request.json().catch(() => ({}))) as DependentInput; + const now = new Date().toISOString(); + const dependent: Dependent = { + id: `dep-mock-${Date.now()}`, + guardianProfileId: db.dependents[0]?.guardianProfileId ?? 'prf-patient-demo', + fullName: body.fullName ?? 'Unnamed (fake)', + dateOfBirth: body.dateOfBirth ?? '2000-01-01', + relationship: body.relationship ?? 'guardian', + handoverStatus: 'not_eligible', + createdAt: now, + updatedAt: now, + }; + db.dependents.push(dependent); + return dependent; + }), + ), + + http.patch( + '*/patients/me/dependents/:id', + respond('json', async ({ params, request }) => { + const dependent = db.dependents.find((d) => d.id === paramId(params)); + if (!dependent) return notFound('dependent'); + const body = (await request.json().catch(() => ({}))) as Partial; + Object.assign(dependent, body, { updatedAt: new Date().toISOString() }); + return dependent; + }), + ), + + http.post( + '*/patients/me/dependents/:id/handover-invite', + respond('json', async ({ params, request }) => { + const dependent = db.dependents.find((d) => d.id === paramId(params)); + if (!dependent) return notFound('dependent'); + const body = (await request.json().catch(() => ({}))) as { email?: string }; + dependent.handoverStatus = 'invited'; + dependent.claimEmail = body.email ?? dependent.claimEmail; + dependent.claimInviteExpiresAt = new Date( + Date.now() + 7 * 24 * 60 * 60 * 1000, + ).toISOString(); + dependent.updatedAt = new Date().toISOString(); + return dependent; + }), + ), + + http.post( + '*/patients/me/dependents/:id/handover-invite/resend', + respond('json', ({ params }) => { + const dependent = db.dependents.find((d) => d.id === paramId(params)); + if (!dependent) return notFound('dependent'); + dependent.claimInviteExpiresAt = new Date( + Date.now() + 7 * 24 * 60 * 60 * 1000, + ).toISOString(); + dependent.updatedAt = new Date().toISOString(); + return dependent; + }), + ), +]; diff --git a/src/mocks/handlers/facility-team.ts b/src/mocks/handlers/facility-team.ts new file mode 100644 index 0000000..11bb716 --- /dev/null +++ b/src/mocks/handlers/facility-team.ts @@ -0,0 +1,115 @@ +// Facility team management and fulfillment queue endpoints. +import { http } from 'msw'; +import { db } from '../db'; +import { conflict, notFound, paramId, respond } from '../helpers'; +import { FACILITY_USER } from '../fixtures'; +import type { + FacilityInvite, + FulfillmentActionAttribution, + FulfillmentQueueItem, +} from '../../types/models'; + +export const facilityTeamHandlers = [ + http.get('*/facilities/:facilityId/members', respond('list', () => db.facilityMembers)), + + http.get('*/facilities/:facilityId/invites', respond('list', () => db.facilityInvites)), + + http.post( + '*/facilities/:facilityId/invites', + respond('json', async ({ params, request }) => { + const facilityId = paramId(params, 'facilityId'); + const body = (await request.json().catch(() => ({}))) as { + email?: string; + role?: FacilityInvite['role']; + }; + const invite: FacilityInvite = { + id: `inv-mock-${Date.now()}`, + facilityId, + email: body.email ?? 'mystery@example.test', + role: body.role ?? 'staff', + invitedBy: FACILITY_USER.id, + invitedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), + status: 'pending', + }; + db.facilityInvites.push(invite); + return invite; + }), + ), + + http.post( + '*/facilities/:facilityId/invites/:inviteId/resend', + respond('json', ({ params }) => { + const invite = db.facilityInvites.find((i) => i.id === paramId(params, 'inviteId')); + if (!invite) return notFound('invite'); + invite.expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + return invite; + }), + ), + + http.delete( + '*/facilities/:facilityId/invites/:inviteId', + respond('json', ({ params }) => { + const inviteId = paramId(params, 'inviteId'); + db.facilityInvites = db.facilityInvites.filter((i) => i.id !== inviteId); + return { success: true }; + }), + ), + + http.delete( + '*/facilities/:facilityId/members/:memberId', + respond('json', ({ params }) => { + const memberId = paramId(params, 'memberId'); + const member = db.facilityMembers.find((m) => m.id === memberId); + if (!member) return notFound('member'); + const owners = db.facilityMembers.filter((m) => m.role === 'owner'); + if (member.role === 'owner' && owners.length <= 1) { + return conflict('cannot remove the last owner of a facility'); + } + db.facilityMembers = db.facilityMembers.filter((m) => m.id !== memberId); + return { + success: true, + memberId, + sessionsRevoked: true, + message: 'Mock API: member removed and their sessions revoked (fixture only).', + }; + }), + ), + + http.get( + '*/facilities/:facilityId/fulfillment-queue', + respond('list', () => db.fulfillmentQueue), + ), + + http.post( + '*/facilities/:facilityId/fulfillment-queue/:itemId/action', + respond('json', async ({ params, request }) => { + const item = db.fulfillmentQueue.find( + (q) => q.id === paramId(params, 'itemId'), + ); + if (!item) return notFound('fulfillment item'); + const body = (await request.json().catch(() => ({}))) as { + action?: 'fulfilled' | 'rejected'; + notes?: string; + }; + const action = body.action === 'rejected' ? 'rejected' : 'fulfilled'; + const attribution: FulfillmentActionAttribution = { + action: + action === 'rejected' + ? 'rejected' + : item.orderType === 'prescription' + ? 'dispensed' + : 'sample_collected', + performedByUserId: FACILITY_USER.id, + performedByName: 'Frank Facility (fake)', + performedByEmail: FACILITY_USER.email, + performedByRole: 'owner', + timestamp: new Date().toISOString(), + notes: body.notes ?? null, + }; + const updated: FulfillmentQueueItem = { ...item, status: action, attribution }; + db.fulfillmentQueue = db.fulfillmentQueue.map((q) => (q.id === item.id ? updated : q)); + return updated; + }), + ), +]; diff --git a/src/mocks/handlers/facility.ts b/src/mocks/handlers/facility.ts new file mode 100644 index 0000000..160b8e1 --- /dev/null +++ b/src/mocks/handlers/facility.ts @@ -0,0 +1,57 @@ +// Facility finder endpoint with optional distance sorting. +import { http } from 'msw'; +import { db } from '../db'; +import { respond } from '../helpers'; +import type { FacilityWithDistance } from '../../types/models'; + +const EARTH_RADIUS_KM = 6371; + +function haversineKm( + lat1: number, + lon1: number, + lat2: number, + lon2: number, +): number { + const toRad = (deg: number) => (deg * Math.PI) / 180; + const dLat = toRad(lat2 - lat1); + const dLon = toRad(lon2 - lon1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; + return 2 * EARTH_RADIUS_KM * Math.asin(Math.sqrt(a)); +} + +export const facilityHandlers = [ + http.get( + '*/facilities', + respond('list', ({ request }) => { + const url = new URL(request.url); + const type = url.searchParams.get('type'); + const query = url.searchParams.get('query'); + const lat = url.searchParams.get('lat'); + const lng = url.searchParams.get('lng'); + + let facilities: FacilityWithDistance[] = db.facilities.map((f) => ({ ...f })); + if (type) facilities = facilities.filter((f) => f.type === type); + if (query) { + const q = query.toLowerCase(); + facilities = facilities.filter( + (f) => f.name.toLowerCase().includes(q) || f.location?.city.toLowerCase().includes(q), + ); + } + if (lat !== null && lng !== null) { + const userLat = Number(lat); + const userLng = Number(lng); + for (const facility of facilities) { + facility.distanceKm = facility.location + ? Number( + haversineKm(userLat, userLng, facility.location.latitude, facility.location.longitude).toFixed(2), + ) + : null; + } + facilities.sort((a, b) => (a.distanceKm ?? Infinity) - (b.distanceKm ?? Infinity)); + } + return facilities; + }), + ), +]; diff --git a/src/mocks/handlers/health-profile.ts b/src/mocks/handlers/health-profile.ts new file mode 100644 index 0000000..8661aba --- /dev/null +++ b/src/mocks/handlers/health-profile.ts @@ -0,0 +1,69 @@ +// Health profile entries per section. +import { http } from 'msw'; +import { db } from '../db'; +import { notFound, paramId, respond } from '../helpers'; +import type { HealthEntry, HealthEntryInput } from '../../types/models'; + +const SECTIONS = ['allergies', 'conditions', 'medications']; + +export const healthProfileHandlers = [ + http.get( + '*/patients/:profileId/health/:section', + respond('list', ({ params }) => { + const profileId = paramId(params, 'profileId'); + const section = paramId(params, 'section'); + if (!SECTIONS.includes(section)) return notFound('health section'); + return db.healthEntries.filter( + (e) => e.patientProfileId === profileId && e.section === section, + ); + }), + ), + + http.post( + '*/patients/:profileId/health', + respond('json', async ({ params, request }) => { + const profileId = paramId(params, 'profileId'); + const body = (await request.json().catch(() => ({}))) as HealthEntryInput; + const now = new Date().toISOString(); + const entry: HealthEntry = { + id: `hev-mock-${Date.now()}`, + patientProfileId: profileId, + section: body.section ?? 'allergies', + name: body.name ?? 'Unnamed (fake)', + details: body.details, + version: 1, + updatedAt: now, + createdAt: now, + }; + db.healthEntries.push(entry); + return entry; + }), + ), + + http.patch( + '*/patients/:profileId/health/:entryId', + respond('json', async ({ params, request }) => { + const entry = db.healthEntries.find((e) => e.id === paramId(params, 'entryId')); + if (!entry) return notFound('health entry'); + const body = (await request.json().catch(() => ({}))) as { + name?: string; + details?: string; + }; + entry.previousValue = entry.name; + if (body.name !== undefined) entry.name = body.name; + if (body.details !== undefined) entry.details = body.details; + entry.version += 1; + entry.updatedAt = new Date().toISOString(); + return entry; + }), + ), + + http.delete( + '*/patients/:profileId/health/:entryId', + respond('void', ({ params }) => { + const id = paramId(params, 'entryId'); + db.healthEntries = db.healthEntries.filter((e) => e.id !== id); + return null; + }), + ), +]; diff --git a/src/mocks/handlers/insights.ts b/src/mocks/handlers/insights.ts new file mode 100644 index 0000000..7fd31ef --- /dev/null +++ b/src/mocks/handlers/insights.ts @@ -0,0 +1,28 @@ +// Doctor practice insights endpoint. +import { http } from 'msw'; +import { db } from '../db'; +import { respond } from '../helpers'; + +export const insightsHandlers = [ + http.get( + '*/doctor/insights', + respond('json', ({ request }) => { + const url = new URL(request.url); + const preset = url.searchParams.get('preset'); + const startDate = url.searchParams.get('startDate'); + const endDate = url.searchParams.get('endDate'); + const { insights } = db; + if (preset || startDate || endDate) { + return { + ...insights, + range: { + preset: (preset ?? insights.range.preset) as typeof insights.range.preset, + startDate: startDate ?? insights.range.startDate, + endDate: endDate ?? insights.range.endDate, + }, + }; + } + return insights; + }), + ), +]; diff --git a/src/mocks/handlers/messaging.ts b/src/mocks/handlers/messaging.ts new file mode 100644 index 0000000..e375d35 --- /dev/null +++ b/src/mocks/handlers/messaging.ts @@ -0,0 +1,31 @@ +// Doctor messaging inbox endpoints. +import { http } from 'msw'; +import { db } from '../db'; +import { notFound, paramId, respond } from '../helpers'; +import type { MessageThread } from '../../types/models'; + +export const messagingHandlers = [ + http.get( + '*/doctor/messages/threads', + respond('list', ({ request }) => { + const url = new URL(request.url); + const filter = url.searchParams.get('filter'); + let threads: MessageThread[] = [...db.messageThreads]; + if (filter === 'unread') threads = threads.filter((t) => t.unreadCount > 0); + if (filter === 'closing_soon') threads = threads.filter((t) => t.closingSoon); + // Assumed contract: oldest unanswered first. + threads.sort((a, b) => a.lastMessageAt.localeCompare(b.lastMessageAt)); + return { threads, nextCursor: null }; + }), + ), + + http.post( + '*/message-threads/:id/read', + respond('void', ({ params }) => { + const thread = db.messageThreads.find((t) => t.id === paramId(params)); + if (!thread) return notFound('message thread'); + thread.unreadCount = 0; + return null; + }), + ), +]; diff --git a/src/mocks/handlers/payments.ts b/src/mocks/handlers/payments.ts new file mode 100644 index 0000000..021094f --- /dev/null +++ b/src/mocks/handlers/payments.ts @@ -0,0 +1,94 @@ +// Payments endpoints: wallet link and the payment-intent lifecycle. +// Everything goes through docforum-core's API shape — no Stellar calls +// here (hard rule 1). Amounts stay BigInt-as-string. +import { http } from 'msw'; +import { db } from '../db'; +import { conflict, notFound, paramId, respond } from '../helpers'; +import { FACILITY_ID } from '../fixtures'; +import type { PaymentIntent } from '../../types/models'; + +const TRANSITIONS: Record = { + fund: 'escrowed', + release: 'released', + refund: 'refunded', +}; + +export const paymentsHandlers = [ + http.post( + '*/payments/wallet-link', + respond('json', async ({ request }) => { + const body = (await request.json().catch(() => ({}))) as { + stellarPublicKey?: string; + }; + if (!body.stellarPublicKey) { + return conflict('stellarPublicKey is required'); + } + const now = new Date().toISOString(); + db.walletLink = { + id: db.walletLink?.id ?? `wln-mock-${Date.now()}`, + facilityId: FACILITY_ID, + stellarPublicKey: body.stellarPublicKey, + createdAt: db.walletLink?.createdAt ?? now, + updatedAt: now, + }; + return db.walletLink; + }), + ), + + http.get('*/payments/wallet-link', respond('nullable', () => db.walletLink)), + + http.post( + '*/payments/intents', + respond('json', async ({ request }) => { + const body = (await request.json().catch(() => ({}))) as Partial; + const now = new Date().toISOString(); + const intent: PaymentIntent = { + id: `pin-mock-${Date.now()}`, + orderType: body.orderType ?? 'prescription', + orderId: body.orderId ?? `ord-mock-${Date.now()}`, + status: 'created', + patientProfileId: body.patientProfileId ?? 'prf-patient-demo', + facilityId: body.facilityId ?? FACILITY_ID, + amountStroops: body.amountStroops ?? '100000000', + escrowId: null, + stellarTxHash: null, + sorobanContractId: null, + createdAt: now, + updatedAt: now, + }; + db.paymentIntents.unshift(intent); + return intent; + }), + ), + + http.get( + '*/payments/intents/:id', + respond('json', ({ params }) => { + const intent = db.paymentIntents.find((i) => i.id === paramId(params)); + if (!intent) return notFound('payment intent'); + return intent; + }), + ), + + ...(['fund', 'release', 'refund'] as const).map((action) => + http.post( + `*/payments/intents/:id/${action}`, + respond('json', ({ params }) => { + const intent = db.paymentIntents.find((i) => i.id === paramId(params)); + if (!intent) return notFound('payment intent'); + if (intent.status !== 'created' && action === 'fund') { + return conflict(`cannot fund an intent in status "${intent.status}"`); + } + if (intent.status !== 'escrowed' && (action === 'release' || action === 'refund')) { + return conflict(`cannot ${action} an intent in status "${intent.status}"`); + } + intent.status = TRANSITIONS[action]; + intent.escrowId = intent.escrowId ?? `esc-mock-${Date.now()} (fake)`; + intent.stellarTxHash = `txdemo${Date.now().toString().padStart(52, '0')}`; + intent.sorobanContractId = intent.sorobanContractId ?? 'CD mock contract (fake)'; + intent.updatedAt = new Date().toISOString(); + return intent; + }), + ), + ), +]; diff --git a/src/mocks/handlers/queue.ts b/src/mocks/handlers/queue.ts new file mode 100644 index 0000000..976ba95 --- /dev/null +++ b/src/mocks/handlers/queue.ts @@ -0,0 +1,83 @@ +// Doctor queue endpoints: today's appointments, live queue, start/complete +// visits, running delay with manual override. +import { http } from 'msw'; +import { db } from '../db'; +import { conflict, notFound, paramId, respond } from '../helpers'; +import type { Appointment, QueueEntry } from '../../types/models'; + +function deriveQueue(appointments: Appointment[]): QueueEntry[] { + return appointments + .filter((apt) => apt.checkInAt !== null) + .sort((a, b) => String(a.checkInAt).localeCompare(String(b.checkInAt))) + .map((apt, index) => ({ + appointmentId: apt.id, + patientProfileId: apt.patientProfileId, + fullName: apt.patientName, + checkedInAt: String(apt.checkInAt), + queuePosition: index + 1, + status: + apt.status === 'in_progress' + ? 'in_progress' + : apt.status === 'completed' + ? 'completed' + : 'checked_in', + })); +} + +export const queueHandlers = [ + http.get( + '*/doctor/appointments/today', + respond('list', () => + [...db.appointments].sort((a, b) => a.startsAt.localeCompare(b.startsAt)), + ), + ), + + http.get('*/doctor/queue/today', respond('list', () => deriveQueue(db.appointments))), + + http.post( + '*/appointments/:id/start', + respond('json', ({ params }) => { + const apt = db.appointments.find((a) => a.id === paramId(params)); + if (!apt) return notFound('appointment'); + if (apt.status === 'completed') return conflict('cannot start a completed visit'); + if (apt.status === 'in_progress') return apt; // idempotent, per assumed contract + apt.status = 'in_progress'; + apt.startedAt = new Date().toISOString(); + return apt; + }), + ), + + http.post( + '*/appointments/:id/complete', + respond('json', ({ params }) => { + const apt = db.appointments.find((a) => a.id === paramId(params)); + if (!apt) return notFound('appointment'); + if (apt.status === 'scheduled' || apt.status === 'checked_in') { + return conflict('visit has not started yet'); + } + apt.status = 'completed'; + apt.completedAt = new Date().toISOString(); + return apt; + }), + ), + + http.get('*/doctor/queue/running-delay', respond('json', () => db.runningDelay)), + + http.patch( + '*/doctor/queue/running-delay', + respond('json', async ({ request }) => { + const body = (await request.json().catch(() => ({}))) as { + manualOverrideMin?: number | null; + }; + const override = body.manualOverrideMin ?? null; + db.runningDelay = { + ...db.runningDelay, + manualOverrideMin: override, + effectiveDelayMin: override ?? db.runningDelay.computedDelayMin, + }; + return db.runningDelay; + }), + ), +]; + +export { deriveQueue }; diff --git a/src/mocks/helpers.ts b/src/mocks/helpers.ts new file mode 100644 index 0000000..ca1532e --- /dev/null +++ b/src/mocks/helpers.ts @@ -0,0 +1,72 @@ +// Shared plumbing for mock handlers: applies the dev-panel scenario +// (forced errors, empty datasets) and artificial latency uniformly, so +// individual handlers only express the happy path + domain logic. +import { HttpResponse, type PathParams } from 'msw'; +import { useMockStore } from './mock-store'; + +type AnyHttpResponse = HttpResponse; + +export interface MockResolverArgs { + params: PathParams; + request: Request; +} + +export type MockResolveFn = ( + args: MockResolverArgs, +) => unknown | Promise; + +export type ResponseKind = + | 'json' // normal JSON body (mutations and singletons) + | 'list' // array body — becomes [] under the "empty" scenario + | 'nullable' // optional singleton — becomes null under the "empty" scenario + | 'void'; // body-less success + +const FORCED_ERROR_MESSAGE = 'Mock scenario: forced server error (dev panel)'; + +async function applyScenario(): Promise { + const { scenario, latencyMs } = useMockStore.getState(); + if (latencyMs > 0) { + await new Promise((resolve) => setTimeout(resolve, latencyMs)); + } + if (scenario === 'error') { + return HttpResponse.json({ message: FORCED_ERROR_MESSAGE }, { status: 500 }); + } + return null; +} + +/** + * Wrap a mock resolver so every endpoint honours the dev panel's scenario + * and latency settings. Resolvers may return a bare `HttpResponse` to + * control status codes (404/409); anything else is JSON-serialized. + */ +export function respond( + kind: ResponseKind, + resolve: MockResolveFn, +): (args: MockResolverArgs) => Promise { + return async ({ params, request }) => { + const { scenario } = useMockStore.getState(); + const forced = await applyScenario(); + if (forced) return forced; + if (scenario === 'empty' && kind === 'list') return HttpResponse.json([]); + if (scenario === 'empty' && kind === 'nullable') return HttpResponse.json(null); + + const body = await resolve({ params, request }); + if (body instanceof HttpResponse) return body; + if (kind === 'void') return HttpResponse.json(null); + return HttpResponse.json(body ?? null); + }; +} + +/** 404 helper with the `{ message }` envelope api-client.ts expects. */ +export function notFound(what: string): AnyHttpResponse { + return HttpResponse.json({ message: `Mock API: ${what} not found` }, { status: 404 }); +} + +/** 409 helper with the `{ message }` envelope api-client.ts expects. */ +export function conflict(message: string): AnyHttpResponse { + return HttpResponse.json({ message: `Mock API: ${message}` }, { status: 409 }); +} + +export function paramId(params: PathParams, name = 'id'): string { + return String(params[name]); +} diff --git a/src/mocks/mock-store.ts b/src/mocks/mock-store.ts new file mode 100644 index 0000000..e4476f1 --- /dev/null +++ b/src/mocks/mock-store.ts @@ -0,0 +1,25 @@ +// Dev-panel state for mock mode (issue #95). This is client-only UI state +// for the mock tooling itself — not application state, and not part of +// src/store/ (that directory is app state per ARCHITECTURE_ESSENTIALS.md). +import { create } from 'zustand'; +import type { Role } from '../types/models'; + +export type MockScenario = 'normal' | 'empty' | 'error'; + +interface MockState { + scenario: MockScenario; + latencyMs: number; + activeRole: Role; + setScenario: (scenario: MockScenario) => void; + setLatencyMs: (ms: number) => void; + setActiveRole: (role: Role) => void; +} + +export const useMockStore = create((set) => ({ + scenario: 'normal', + latencyMs: 0, + activeRole: 'patient', + setScenario: (scenario) => set({ scenario }), + setLatencyMs: (latencyMs) => set({ latencyMs }), + setActiveRole: (activeRole) => set({ activeRole }), +})); diff --git a/src/mocks/roles.ts b/src/mocks/roles.ts new file mode 100644 index 0000000..d553167 --- /dev/null +++ b/src/mocks/roles.ts @@ -0,0 +1,45 @@ +// Role-switching helpers for mock mode: applies a seeded persona to the +// app's real auth/profile stores and routes to that role's home screen. +import { useAuthStore } from '../store/auth-store'; +import { useProfileStore } from '../store/profile-store'; +import { DEPENDENT_PROFILE_ID, MOCK_ACCESS_TOKEN, PATIENT_PROFILE_ID } from './fixtures'; +import { personaForRole } from './handlers/auth'; +import { useMockStore } from './mock-store'; +import type { Role } from '../types/models'; + +const HOME_BY_ROLE: Record = { + patient: '#/patient', + doctor: '#/doctor', + facility: '#/facility', + admin: '#/admin', +}; + +/** + * Sign in as the seeded persona for `role` (or switch to it). Sets the + * app's real auth session — mock handlers only care about the route, not + * the token — and routes to the role's dashboard. + */ +export function activateMockRole( + role: Role, + options: { navigate?: boolean } = {}, +): void { + const { navigate = true } = options; + useMockStore.getState().setActiveRole(role); + const persona = personaForRole(role); + useAuthStore.getState().setSession(persona, MOCK_ACCESS_TOKEN); + useProfileStore.getState().setProfiles( + role === 'patient' + ? [ + { id: PATIENT_PROFILE_ID, fullName: 'Pat Placeholder (fake)', isSelf: true }, + { id: DEPENDENT_PROFILE_ID, fullName: 'Tyke Placeholder (fake)', isSelf: false }, + ] + : [], + ); + if (navigate) window.location.hash = HOME_BY_ROLE[role]; +} + +/** Clear the session and land on the login screen (to test the real flow). */ +export function mockSignOut(): void { + useAuthStore.getState().clearSession(); + window.location.hash = '#/login'; +} diff --git a/src/mocks/setup.tsx b/src/mocks/setup.tsx new file mode 100644 index 0000000..9a5e37e --- /dev/null +++ b/src/mocks/setup.tsx @@ -0,0 +1,32 @@ +// Mock-mode bootstrap. Dynamically imported by main.tsx only when running +// `npm run dev:mock` (a DEV build with VITE_ENABLE_MOCKS=true) — this +// keeps MSW, the fixtures, and the dev panel entirely out of production +// bundles (enforced in CI by `npm run check:dist`). +import { createRoot } from 'react-dom/client'; +import { worker } from './browser'; +import { DevPanel } from './DevPanel'; +import { activateMockRole } from './roles'; +import { useMockStore } from './mock-store'; + +/** + * Start the mock API and mount the dev panel. Called by main.tsx via + * top-level await BEFORE the first React render, so the persona session + * exists when route guards first evaluate — otherwise RequireAuth would + * bounce a deep-linked protected route to #/login before the mocks boot. + */ +export function startMockApi(): void { + worker.start({ onUnhandledRequest: 'bypass' }); + + // Sign the persona in immediately. Navigate to the role's home screen + // unless the URL already deep-links a route, which we leave untouched. + // Logging in through the real login screen works too (handlers answer + // any credentials). + const hash = window.location.hash; + const deepLinked = hash !== '' && hash !== '#/' && hash !== '#'; + activateMockRole(useMockStore.getState().activeRole, { navigate: !deepLinked }); + + const panelRoot = document.createElement('div'); + panelRoot.id = 'mock-dev-panel-root'; + document.body.appendChild(panelRoot); + createRoot(panelRoot).render(); +} diff --git a/src/query-client.ts b/src/query-client.ts new file mode 100644 index 0000000..ef21bc0 --- /dev/null +++ b/src/query-client.ts @@ -0,0 +1,13 @@ +// Single shared React Query client. Exported so the mock dev panel +// (issue #95) can invalidate every cache when the scenario/role changes — +// main.tsx mounts the same instance, so app behaviour is unchanged. +import { QueryClient } from '@tanstack/react-query'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index d43868c..ebce044 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -2,6 +2,8 @@ interface ImportMetaEnv { readonly VITE_API_BASE_URL?: string; + /** Set only by `npm run dev:mock` — enables the MSW mock API (issue #95). */ + readonly VITE_ENABLE_MOCKS?: string; } interface ImportMeta {