From 4443c77f227d9b5cd9e8d5dde34c15f6199635fb Mon Sep 17 00:00:00 2001 From: Karina Gonzalez Date: Wed, 29 Jul 2026 18:41:35 -0500 Subject: [PATCH 01/10] [#1034-T1] Add MSW and initialize the service-worker asset Add Mock Service Worker (msw ^2.15.0) and @mswjs/source ^0.5.0 to the website package and commit the generated public/mockServiceWorker.js worker asset (via `msw init`), so later tickets can intercept Swagger UI "Try it out" requests entirely client-side. The existing js-yaml dependency already provides the spec-parsing path, so no new YAML parser was added. The vendored worker script is excluded from prettier and cspell. Verified by a green `pnpm --filter website run build` (which produces dist/mockServiceWorker.js) and a passing `pnpm --filter website run checks`. Refs #1034 Files changed: - website/package.json - pnpm-lock.yaml - website/.prettierignore - website/.cspell.json - website/public/mockServiceWorker.js --- pnpm-lock.yaml | 275 ++++++++++++++++++++- website/.cspell.json | 3 +- website/.prettierignore | 3 + website/package.json | 7 + website/public/mockServiceWorker.js | 361 ++++++++++++++++++++++++++++ 5 files changed, 639 insertions(+), 10 deletions(-) create mode 100644 website/public/mockServiceWorker.js diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f92943e6b..2894c5766 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,7 +136,7 @@ importers: version: 8.61.1(eslint@10.5.0)(typescript@5.9.3) vitest: specifier: 'catalog:' - version: 4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(vite@8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3)) + version: 4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(msw@2.15.0(@types/node@22.20.0)(typescript@5.9.3))(vite@8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3)) lib/cli: dependencies: @@ -245,7 +245,7 @@ importers: version: 8.61.1(eslint@10.5.0)(typescript@5.9.3) vitest: specifier: 'catalog:' - version: 4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(vite@8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3)) + version: 4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(msw@2.15.0(@types/node@22.20.0)(typescript@5.9.3))(vite@8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3)) lib/core: dependencies: @@ -384,7 +384,7 @@ importers: version: 8.61.1(eslint@10.5.0)(typescript@5.9.3) vitest: specifier: 'catalog:' - version: 4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(vite@8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3)) + version: 4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(msw@2.15.0(@types/node@22.20.0)(typescript@5.9.3))(vite@8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3)) website: dependencies: @@ -406,6 +406,9 @@ importers: '@jsonforms/vanilla-renderers': specifier: ^3.8.0 version: 3.8.0(@jsonforms/core@3.8.0)(@jsonforms/react@3.8.0(@jsonforms/core@3.8.0)(react@18.3.1))(react@18.3.1) + '@mswjs/source': + specifier: ^0.5.0 + version: 0.5.0(msw@2.15.0(@types/node@22.20.0)(typescript@5.9.3)) '@types/react': specifier: ^18.3.28 version: 18.3.28 @@ -424,6 +427,9 @@ importers: json-schema-merge-allof: specifier: ^0.8.1 version: 0.8.1 + msw: + specifier: ^2.15.0 + version: 2.15.0(@types/node@22.20.0)(typescript@5.9.3) openapi-sampler: specifier: ^1.7.4 version: 1.7.4 @@ -523,7 +529,7 @@ importers: version: 8.61.1(eslint@10.5.0)(typescript@5.9.3) vitest: specifier: catalog:website - version: 4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(vite@8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3)) + version: 4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(msw@2.15.0(@types/node@22.20.0)(typescript@5.9.3))(vite@8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3)) wrangler: specifier: ^4.114.0 version: 4.114.0 @@ -1428,6 +1434,10 @@ packages: '@expressive-code/plugin-text-markers@0.44.1': resolution: {integrity: sha512-B3BsJoJ8CFMlcIX9f+X9tcI3C4zPDO601+YuLi9GheSTNro7ZfqSjLptMQKBHOWZvxnAtY5zvIX7iO/qtBhNBg==} + '@faker-js/faker@8.4.1': + resolution: {integrity: sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0, npm: '>=6.14.13'} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -1966,6 +1976,17 @@ packages: '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + '@mswjs/interceptors@0.41.9': + resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} + engines: {node: '>=18'} + + '@mswjs/source@0.5.0': + resolution: {integrity: sha512-0zZT5wHnutBWInb/IAl2wGCy9M4i5nm4mRdunw7Pe5tyei07MvLATZEckC/uvNVTSEJMgJ0Kgj5GKzL/I9ZIMQ==} + engines: {node: '>=20'} + deprecated: This package is renamed to . It will no longer receive any updates. Please migrate to the new package name. Thank you. + peerDependencies: + msw: ^2.10.0 + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -1991,6 +2012,18 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/deferred-promise@3.0.0': + resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} @@ -2367,6 +2400,22 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stoplight/json@3.21.7': + resolution: {integrity: sha512-xcJXgKFqv/uCEgtGlPxy3tPA+4I+ZI4vAuMJ885+ThkTHFVkC+0Fm58lA9NlsyjnkpxFh4YiQWpH+KefHdbA0A==} + engines: {node: '>=8.3.0'} + + '@stoplight/ordered-object-literal@1.0.5': + resolution: {integrity: sha512-COTiuCU5bgMUtbIFBuyyh2/yVVzlr5Om0v5utQDgBCuQUOPgU1DwoffkTfg4UBQOvByi5foF4w4T+H9CoRe5wg==} + engines: {node: '>=8'} + + '@stoplight/path@1.3.2': + resolution: {integrity: sha512-lyIc6JUlUA8Ve5ELywPC8I2Sdnh1zc1zmbYgVarhXIp9YeAB0ReeqmGEOWNtlHkbP2DAA1AL65Wfn2ncjK/jtQ==} + engines: {node: '>=8'} + + '@stoplight/types@13.20.0': + resolution: {integrity: sha512-2FNTv05If7ib79VPDA/r9eUet76jewXFH2y2K5vuge6SXbRHtWBhcaRmu+6QpF4/WRNoJj5XYRSwLGXDxysBGA==} + engines: {node: ^12.20 || >=14.13} + '@swagger-api/apidom-ast@1.11.3': resolution: {integrity: sha512-Nfi/0vy+cIHClX7raXamtHnCMBbwI1PEg+yroIzyy8LcCH7zcS0Xi4ARG3CkDQswOnWO2gLDAUAFjDvkQWdZ+A==} @@ -2554,6 +2603,9 @@ packages: '@types/express@4.17.25': resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} + '@types/har-format@1.2.16': + resolution: {integrity: sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==} + '@types/hast@3.0.5': resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} @@ -2643,6 +2695,12 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/set-cookie-parser@2.4.10': + resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/superagent@8.1.9': resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} @@ -2916,6 +2974,9 @@ packages: '@vscode/l10n@0.0.18': resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + '@yellow-ticket/seed-json-schema@0.1.8': + resolution: {integrity: sha512-l4XbrxeJyswv5cF3ugKDkLs1DdeVotkK6F4J1i6SQp6AwgQCRbxIQB07UgnyzWqrtixTmV7pE/T1Zgyw0RrfdQ==} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -4042,6 +4103,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphql@16.14.2: + resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + h3@1.15.11: resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} @@ -4132,6 +4197,9 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + headers-polyfill@5.0.1: + resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} + highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -4271,6 +4339,9 @@ packages: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -4377,6 +4448,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@2.2.1: + resolution: {integrity: sha512-o6/yDBYccGvTz1+QFevz6l6OBZ2+fMVu2JZ9CIhzsYRX4mjaK5IyX9eldUdCmga16zlgQxyrj5pt9kzuj2C02w==} + jsonc-parser@2.3.1: resolution: {integrity: sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==} @@ -4796,6 +4870,16 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msw@2.15.0: + resolution: {integrity: sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} @@ -4931,6 +5015,9 @@ packages: outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} @@ -5383,6 +5470,9 @@ packages: retext@9.0.0: resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + rettime@0.11.11: + resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -5417,6 +5507,9 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-stable-stringify@1.1.1: + resolution: {integrity: sha512-ERq4hUjKDbJfE4+XtZLFPCDi8Vb1JqaxAPTxWFLBx8XcAlf9Bda/ZJdVezs/NAfsMQScyIlUMx+Yeu7P7rx5jw==} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -5459,6 +5552,9 @@ packages: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -5586,6 +5682,9 @@ packages: stream-replace-string@2.0.0: resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==} + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -5676,6 +5775,10 @@ packages: resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} engines: {node: ^14.18.0 || >=16.0.0} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tar@7.5.21: resolution: {integrity: sha512-XdhtCvlMywwxpCW8YEq3lOXBJpUPTR2OHHcwLPO3HwsJqOHa2Ok/oJ7ruGzp+JrKoRPVCzJwAdEjqLW/vNRPHA==} engines: {node: '>=18'} @@ -5720,6 +5823,13 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tldts-core@7.4.9: + resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} + + tldts@7.4.9: + resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} + hasBin: true + to-buffer@1.2.2: resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} engines: {node: '>= 0.4'} @@ -5735,6 +5845,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -5804,6 +5918,10 @@ packages: resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} engines: {node: '>=10'} + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -5967,6 +6085,9 @@ packages: uploadthing: optional: true + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -5991,6 +6112,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utility-types@3.11.0: + resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==} + engines: {node: '>= 4'} + utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} @@ -7401,6 +7526,8 @@ snapshots: dependencies: '@expressive-code/core': 0.44.1 + '@faker-js/faker@8.4.1': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -7862,6 +7989,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@mswjs/interceptors@0.41.9': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@mswjs/source@0.5.0(msw@2.15.0(@types/node@22.20.0)(typescript@5.9.3))': + dependencies: + '@stoplight/json': 3.21.7 + '@types/har-format': 1.2.16 + '@yellow-ticket/seed-json-schema': 0.1.8 + msw: 2.15.0(@types/node@22.20.0)(typescript@5.9.3) + openapi-types: 12.1.3 + outvariant: 1.4.3 + yaml: 2.8.3 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -7892,6 +8038,17 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/deferred-promise@3.0.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + '@oslojs/encoding@1.1.0': {} '@oxc-project/types@0.139.0': {} @@ -8151,6 +8308,24 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@stoplight/json@3.21.7': + dependencies: + '@stoplight/ordered-object-literal': 1.0.5 + '@stoplight/path': 1.3.2 + '@stoplight/types': 13.20.0 + jsonc-parser: 2.2.1 + lodash: 4.18.1 + safe-stable-stringify: 1.1.1 + + '@stoplight/ordered-object-literal@1.0.5': {} + + '@stoplight/path@1.3.2': {} + + '@stoplight/types@13.20.0': + dependencies: + '@types/json-schema': 7.0.15 + utility-types: 3.11.0 + '@swagger-api/apidom-ast@1.11.3': dependencies: '@babel/runtime-corejs3': 7.29.7 @@ -8672,6 +8847,8 @@ snapshots: '@types/qs': 6.15.0 '@types/serve-static': 1.15.10 + '@types/har-format@1.2.16': {} + '@types/hast@3.0.5': dependencies: '@types/unist': 3.0.3 @@ -8768,6 +8945,12 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 25.2.3 + '@types/set-cookie-parser@2.4.10': + dependencies: + '@types/node': 25.2.3 + + '@types/statuses@2.0.6': {} + '@types/superagent@8.1.9': dependencies: '@types/cookiejar': 2.1.5 @@ -9002,7 +9185,7 @@ snapshots: obug: 2.1.2 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(vite@8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3)) + vitest: 4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(msw@2.15.0(@types/node@22.20.0)(typescript@5.9.3))(vite@8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3)) '@vitest/eslint-plugin@1.6.20(@typescript-eslint/eslint-plugin@8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0)(typescript@5.9.3))(eslint@10.5.0)(typescript@5.9.3))(eslint@10.5.0)(typescript@5.9.3)(vitest@4.1.9)': dependencies: @@ -9012,7 +9195,7 @@ snapshots: optionalDependencies: '@typescript-eslint/eslint-plugin': 8.61.1(@typescript-eslint/parser@8.61.1(eslint@10.5.0)(typescript@5.9.3))(eslint@10.5.0)(typescript@5.9.3) typescript: 5.9.3 - vitest: 4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(vite@8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3)) + vitest: 4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(msw@2.15.0(@types/node@22.20.0)(typescript@5.9.3))(vite@8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3)) transitivePeerDependencies: - supports-color @@ -9025,12 +9208,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3))': + '@vitest/mocker@4.1.9(msw@2.15.0(@types/node@22.20.0)(typescript@5.9.3))(vite@8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: + msw: 2.15.0(@types/node@22.20.0)(typescript@5.9.3) vite: 8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3) '@vitest/pretty-format@4.1.9': @@ -9107,6 +9291,13 @@ snapshots: '@vscode/l10n@0.0.18': {} + '@yellow-ticket/seed-json-schema@0.1.8': + dependencies: + '@faker-js/faker': 8.4.1 + '@types/json-schema': 7.0.15 + outvariant: 1.4.3 + randexp: 0.5.3 + accepts@1.3.8: dependencies: mime-types: 2.1.35 @@ -10391,6 +10582,8 @@ snapshots: graceful-fs@4.2.11: {} + graphql@16.14.2: {} + h3@1.15.11: dependencies: cookie-es: 1.2.3 @@ -10614,6 +10807,11 @@ snapshots: property-information: 7.2.0 space-separated-tokens: 2.0.2 + headers-polyfill@5.0.1: + dependencies: + '@types/set-cookie-parser': 2.4.10 + set-cookie-parser: 3.1.2 + highlight.js@10.7.3: {} highlightjs-vue@1.0.0: {} @@ -10732,6 +10930,8 @@ snapshots: is-interactive@1.0.0: {} + is-node-process@1.2.0: {} + is-number@7.0.0: {} is-plain-obj@4.1.0: {} @@ -10818,6 +11018,8 @@ snapshots: json5@2.2.3: {} + jsonc-parser@2.2.1: {} + jsonc-parser@2.3.1: {} jsonc-parser@3.3.1: {} @@ -11476,6 +11678,31 @@ snapshots: ms@2.1.3: {} + msw@2.15.0(@types/node@22.20.0)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 6.1.1(@types/node@22.20.0) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.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.2 + type-fest: 5.8.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + muggle-string@0.4.1: {} mustache@4.2.0: {} @@ -11595,6 +11822,8 @@ snapshots: outdent@0.5.0: {} + outvariant@1.4.3: {} + p-filter@2.1.0: dependencies: p-map: 2.1.0 @@ -12095,6 +12324,8 @@ snapshots: retext-stringify: 4.0.0 unified: 11.0.5 + rettime@0.11.11: {} + reusify@1.1.0: {} rimraf@5.0.10: @@ -12168,6 +12399,8 @@ snapshots: safe-buffer@5.2.1: {} + safe-stable-stringify@1.1.1: {} + safer-buffer@2.1.2: {} sass-formatter@0.7.9: @@ -12234,6 +12467,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@3.1.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -12427,6 +12662,8 @@ snapshots: stream-replace-string@2.0.0: {} + strict-event-emitter@0.5.1: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -12623,6 +12860,8 @@ snapshots: dependencies: '@pkgr/core': 0.3.6 + tagged-tag@1.0.0: {} + tar@7.5.21: dependencies: '@isaacs/fs-minipass': 4.0.1 @@ -12664,6 +12903,12 @@ snapshots: tinyrainbow@3.1.0: {} + tldts-core@7.4.9: {} + + tldts@7.4.9: + dependencies: + tldts-core: 7.4.9 + to-buffer@1.2.2: dependencies: isarray: 2.0.5 @@ -12678,6 +12923,10 @@ snapshots: toidentifier@1.0.1: {} + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.9 + tr46@0.0.3: {} tree-sitter-json@0.24.8(tree-sitter@0.21.1): @@ -12746,6 +12995,10 @@ snapshots: type-fest@0.21.3: {} + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -12877,6 +13130,8 @@ snapshots: ofetch: 1.5.1 ufo: 1.6.4 + until-async@3.0.2: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -12900,6 +13155,8 @@ snapshots: util-deprecate@1.0.2: {} + utility-types@3.11.0: {} + utils-merge@1.0.1: {} v8-compile-cache-lib@3.0.1: {} @@ -12954,10 +13211,10 @@ snapshots: optionalDependencies: vite: 8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3) - vitest@4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(vite@8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3)): + vitest@4.1.9(@types/node@22.20.0)(@vitest/coverage-v8@4.1.9)(msw@2.15.0(@types/node@22.20.0)(typescript@5.9.3))(vite@8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3)) + '@vitest/mocker': 4.1.9(msw@2.15.0(@types/node@22.20.0)(typescript@5.9.3))(vite@8.1.5(@types/node@22.20.0)(esbuild@0.28.1)(tsx@4.23.1)(yaml@2.8.3)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 diff --git a/website/.cspell.json b/website/.cspell.json index 68b507ae9..ded1039ac 100644 --- a/website/.cspell.json +++ b/website/.cspell.json @@ -6,7 +6,8 @@ "*.svg", "src/content/forms/", "cache/", - ".docs/" + ".docs/", + "public/mockServiceWorker.js" ], "dictionaryDefinitions": [], "dictionaries": [], diff --git a/website/.prettierignore b/website/.prettierignore index 226b1c8e9..389e7b8ab 100644 --- a/website/.prettierignore +++ b/website/.prettierignore @@ -4,5 +4,8 @@ dist/ node_modules/ public/openapi/*openapi*.yaml +# Generated by `msw init` — vendored MSW worker script, not ours to format +public/mockServiceWorker.js + # Generated cache files cache/ diff --git a/website/package.json b/website/package.json index 87d0d5447..16f203ff0 100644 --- a/website/package.json +++ b/website/package.json @@ -40,12 +40,14 @@ "@jsonforms/core": "^3.8.0", "@jsonforms/react": "^3.8.0", "@jsonforms/vanilla-renderers": "^3.8.0", + "@mswjs/source": "^0.5.0", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", "ajv": "^8.20.0", "astro": "^7.1.3", "js-yaml": "^4.3.0", "json-schema-merge-allof": "^0.8.1", + "msw": "^2.15.0", "openapi-sampler": "^1.7.4", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -85,5 +87,10 @@ }, "overrides": { "js-yaml": "^4.3.0" + }, + "msw": { + "workerDirectory": [ + "public" + ] } } diff --git a/website/public/mockServiceWorker.js b/website/public/mockServiceWorker.js new file mode 100644 index 000000000..0c970efc9 --- /dev/null +++ b/website/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, + } +} From 4ed1a57b41e305ae52c5cc7f0d41f26c594ebfd4 Mon Sep 17 00:00:00 2001 From: Karina Gonzalez Date: Wed, 29 Jul 2026 20:32:57 -0500 Subject: [PATCH 02/10] [#1034-T2] Throwaway /protocol/mock-playground route with a single hand-written handler Adds a throwaway MSW playground that proves client-side interception of Swagger UI "Try it out" requests. A new /protocol/mock-playground route renders the shared OpenApiDocs island with "Try it out" enabled and starts a Mock Service Worker that answers GET /common-grants/opportunities with a schema-valid example body sampled from the rendered OpenAPI spec via openapi-sampler. The shipping /protocol/api-docs page is unchanged (OpenApiDocs gains a backwards-compatible enableTryItOut prop defaulting to false). Refs #1034 Files changed: - website/src/lib/mock/opportunities-handler.ts - website/__tests__/lib/mock/opportunities-handler.spec.ts - website/src/components/MockPlayground.tsx - website/src/pages/protocol/mock-playground.astro - website/src/components/OpenApiDocs.tsx --- .../lib/mock/opportunities-handler.spec.ts | 55 +++ website/src/components/MockPlayground.tsx | 58 +++ website/src/components/OpenApiDocs.tsx | 18 +- website/src/lib/mock/opportunities-handler.ts | 84 +++++ .../src/pages/protocol/mock-playground.astro | 329 ++++++++++++++++++ 5 files changed, 540 insertions(+), 4 deletions(-) create mode 100644 website/__tests__/lib/mock/opportunities-handler.spec.ts create mode 100644 website/src/components/MockPlayground.tsx create mode 100644 website/src/lib/mock/opportunities-handler.ts create mode 100644 website/src/pages/protocol/mock-playground.astro diff --git a/website/__tests__/lib/mock/opportunities-handler.spec.ts b/website/__tests__/lib/mock/opportunities-handler.spec.ts new file mode 100644 index 000000000..494eac318 --- /dev/null +++ b/website/__tests__/lib/mock/opportunities-handler.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +import yaml from "js-yaml"; +import { + buildOpportunitiesExample, + opportunitiesHandler, + OPPORTUNITIES_PATH, +} from "@/lib/mock/opportunities-handler"; + +interface OpenApiSpec { + paths: Record; + components?: Record; + [key: string]: unknown; +} + +function loadSpec(): OpenApiSpec { + const content = readFileSync( + resolve(process.cwd(), "public/openapi/openapi.0.3.0.yaml"), + "utf-8", + ); + return yaml.load(content, { schema: yaml.CORE_SCHEMA }) as OpenApiSpec; +} + +describe("buildOpportunitiesExample", () => { + it("returns a schema-valid example body for GET /common-grants/opportunities", () => { + const spec = loadSpec(); + expect(OPPORTUNITIES_PATH).toBe("/common-grants/opportunities"); + + const result = buildOpportunitiesExample(spec) as Record; + + expect(result).not.toBeNull(); + expect(typeof result).toBe("object"); + expect(Array.isArray(result.items)).toBe(true); + expect((result.items as unknown[]).length).toBeGreaterThanOrEqual(1); + expect(result.paginationInfo).toBeDefined(); + expect(typeof result.paginationInfo).toBe("object"); + expect(typeof result.status).toBe("number"); + expect(typeof result.message).toBe("string"); + }); + + it("throws when the spec has no 200 schema for the path", () => { + expect(() => buildOpportunitiesExample({ paths: {} })).toThrow( + /GET \/common-grants\/opportunities/, + ); + }); +}); + +describe("opportunitiesHandler", () => { + it("builds an MSW GET handler for the opportunities path", () => { + const handler = opportunitiesHandler(loadSpec()); + expect(handler.info.method).toBe("GET"); + expect(handler.info.path).toBe(OPPORTUNITIES_PATH); + }); +}); diff --git a/website/src/components/MockPlayground.tsx b/website/src/components/MockPlayground.tsx new file mode 100644 index 000000000..c1f187ba3 --- /dev/null +++ b/website/src/components/MockPlayground.tsx @@ -0,0 +1,58 @@ +import React, { useEffect, useState } from "react"; +import yaml from "js-yaml"; +import { setupWorker } from "msw/browser"; +import OpenApiDocs, { defaultVersion } from "./OpenApiDocs"; +import { + opportunitiesHandler, + type OpenApiSpec, +} from "@/lib/mock/opportunities-handler"; + +/** + * Throwaway MSW playground (#1034-T2): starts a Mock Service Worker that + * answers `GET /common-grants/opportunities` from the rendered OpenAPI spec, + * then renders the shared `OpenApiDocs` island with "Try it out" enabled. + * + * The worker registers at root scope with `onUnhandledRequest: 'bypass'`, so + * only the one mocked path is intercepted; everything else passes through. + * It is stopped on unmount to avoid leaking across page navigation. + */ +export default function MockPlayground() { + const [ready, setReady] = useState(false); + + useEffect(() => { + let cancelled = false; + let worker: ReturnType | undefined; + // Tracks the in-flight start() so cleanup stops the worker only after it + // has finished registering — avoids stop()-before-start() if the component + // unmounts mid-startup. The default version matches what OpenApiDocs renders. + let startup: Promise | undefined; + + (async () => { + const response = await fetch(`/openapi/openapi.${defaultVersion}.yaml`); + const spec = yaml.load(await response.text(), { + schema: yaml.CORE_SCHEMA, + }) as OpenApiSpec; + if (cancelled) return; + + worker = setupWorker(opportunitiesHandler(spec)); + startup = worker.start({ + onUnhandledRequest: "bypass", + serviceWorker: { url: "/mockServiceWorker.js" }, + }); + await startup; + if (!cancelled) setReady(true); + })(); + + return () => { + cancelled = true; + void startup?.then(() => worker?.stop()); + }; + }, []); + + // Wait for the worker to activate so the first "Execute" is intercepted. + if (!ready) { + return

Starting mock service worker…

; + } + + return ; +} diff --git a/website/src/components/OpenApiDocs.tsx b/website/src/components/OpenApiDocs.tsx index b2a29477a..f1248ed1d 100644 --- a/website/src/components/OpenApiDocs.tsx +++ b/website/src/components/OpenApiDocs.tsx @@ -9,11 +9,18 @@ const availableVersions = [ { version: "0.1.0", label: "v0.1.0" }, ]; -// Get the default version (latest) -const defaultVersion = availableVersions[0].version; +// Get the default version (latest). Exported so callers that pre-load a spec +// (e.g. MockPlayground's MSW handler) stay in sync with the version rendered. +export const defaultVersion = availableVersions[0].version; interface OpenApiDocsProps { className?: string; + /** + * When true, enables Swagger UI's "Try it out" by passing the default + * `supportedSubmitMethods`. Defaults to false, keeping the shipping + * `/protocol/api-docs` page's submit buttons disabled. + */ + enableTryItOut?: boolean; } // ######################################################### @@ -71,7 +78,10 @@ const styles = { }, }; -export default function OpenApiDocs({ className }: OpenApiDocsProps) { +export default function OpenApiDocs({ + className, + enableTryItOut = false, +}: OpenApiDocsProps) { // ######################################################### // Set up state management // ######################################################### @@ -128,7 +138,7 @@ export default function OpenApiDocs({ className }: OpenApiDocsProps) { )} diff --git a/website/src/lib/mock/opportunities-handler.ts b/website/src/lib/mock/opportunities-handler.ts new file mode 100644 index 000000000..132eb0d03 --- /dev/null +++ b/website/src/lib/mock/opportunities-handler.ts @@ -0,0 +1,84 @@ +import { http, HttpResponse, type HttpHandler } from "msw"; +import * as OpenAPISampler from "openapi-sampler"; + +/** + * Same-origin relative path Swagger UI targets for the "list opportunities" + * operation. The specs declare no `servers:` block, so Try-it-out requests + * resolve against the current page origin + this path. + */ +export const OPPORTUNITIES_PATH = "/common-grants/opportunities"; + +/** + * Minimal shape of a parsed OpenAPI document this module reads from. Kept + * loose (index signature) because we only navigate a few known keys and pass + * the whole document to `openapi-sampler` for `$ref` resolution. + */ +export interface OpenApiSpec { + paths?: Record; + components?: Record; + [key: string]: unknown; +} + +/** + * Extracts the `200` JSON response schema for `GET /common-grants/opportunities` + * from a parsed OpenAPI document. + * + * @throws If the path, GET operation, or 200 JSON response schema is absent. + */ +function getOpportunitiesResponseSchema(spec: OpenApiSpec): object { + const schema = ( + spec.paths?.[OPPORTUNITIES_PATH] as + | { + get?: { + responses?: { + "200"?: { + content?: { "application/json"?: { schema?: object } }; + }; + }; + }; + } + | undefined + )?.get?.responses?.["200"]?.content?.["application/json"]?.schema; + + if (!schema) { + throw new Error( + `No 200 JSON response schema found for GET ${OPPORTUNITIES_PATH} in the provided spec`, + ); + } + return schema; +} + +/** + * Samples the 200 response body for `GET /common-grants/opportunities` from a + * parsed OpenAPI document. The full `spec` is passed to `OpenAPISampler.sample` + * as its third argument so component `$ref`s (e.g. `OpportunityBase`, + * `PaginatedResultsInfo`) resolve against the document. + * + * @param spec - A parsed OpenAPI document (YAML already loaded to an object). + * @returns A schema-valid example body for the paginated opportunities list. + */ +export function buildOpportunitiesExample( + spec: OpenApiSpec, +): Record { + const schema = getOpportunitiesResponseSchema(spec); + const body = OpenAPISampler.sample(schema, {}, spec as object); + if (typeof body !== "object" || body === null || Array.isArray(body)) { + throw new Error( + `Sampled body for GET ${OPPORTUNITIES_PATH} was not a JSON object`, + ); + } + return body as Record; +} + +/** + * Builds an MSW handler for `GET /common-grants/opportunities` that answers + * with a sampled, schema-valid example body. Registered against the + * same-origin relative path so it matches the requests Swagger UI's + * "Try it out" fires. + * + * @param spec - A parsed OpenAPI document used to generate the example body. + */ +export function opportunitiesHandler(spec: OpenApiSpec): HttpHandler { + const body = buildOpportunitiesExample(spec); + return http.get(OPPORTUNITIES_PATH, () => HttpResponse.json(body)); +} diff --git a/website/src/pages/protocol/mock-playground.astro b/website/src/pages/protocol/mock-playground.astro new file mode 100644 index 000000000..efe79b86d --- /dev/null +++ b/website/src/pages/protocol/mock-playground.astro @@ -0,0 +1,329 @@ +--- +// Throwaway MSW mock playground (#1034-T2). Mirrors /protocol/api-docs but +// enables Swagger UI "Try it out" and starts a Mock Service Worker so requests +// are answered client-side from the rendered OpenAPI spec. Unlinked orphan +// page (not in the nav), so starlight-links-validator needs no exclude entry. +import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro"; +import MockPlayground from "@/components/MockPlayground"; +--- + + + + + + From 31da1239d828ed2a515d699dc988e3bddb2841d1 Mon Sep 17 00:00:00 2001 From: Karina Gonzalez Date: Wed, 29 Jul 2026 20:53:13 -0500 Subject: [PATCH 03/10] [#1034-T3] Generalize to fromOpenApi() and swap handlers by version (all three versions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces T2's single hand-written handler with handlers generated from the rendered OpenAPI spec via @mswjs/source fromOpenApi(), and swaps the active handler set whenever the version dropdown changes so Try-it-out reflects the selected version (0.1.0 / 0.2.0 / 0.3.0). Resolves the spike's central base-URL question: because the specs declare no `servers:` block, @mswjs/source falls back to a `/` base and emits same-origin relative-path handlers matching exactly what Swagger UI targets — no base-URL patching needed. OpenApiDocs gains an optional onVersionChange callback; MockPlayground regenerates handlers via worker.resetHandlers with a latest-wins guard and fetch/parse error handling. The now-superseded T2 opportunities-handler module is removed (its OpenApiSpec type moved into spec-handlers). Refs #1034 Files changed: - website/src/lib/mock/spec-handlers.ts (new) - website/__tests__/lib/mock/spec-handlers.spec.ts (new) - website/src/components/MockPlayground.tsx - website/src/components/OpenApiDocs.tsx - website/src/lib/mock/opportunities-handler.ts (removed) - website/__tests__/lib/mock/opportunities-handler.spec.ts (removed) --- .../lib/mock/opportunities-handler.spec.ts | 55 --------- .../__tests__/lib/mock/spec-handlers.spec.ts | 58 ++++++++++ website/src/components/MockPlayground.tsx | 107 ++++++++++++------ website/src/components/OpenApiDocs.tsx | 11 ++ website/src/lib/mock/opportunities-handler.ts | 84 -------------- website/src/lib/mock/spec-handlers.ts | 35 ++++++ 6 files changed, 177 insertions(+), 173 deletions(-) delete mode 100644 website/__tests__/lib/mock/opportunities-handler.spec.ts create mode 100644 website/__tests__/lib/mock/spec-handlers.spec.ts delete mode 100644 website/src/lib/mock/opportunities-handler.ts create mode 100644 website/src/lib/mock/spec-handlers.ts diff --git a/website/__tests__/lib/mock/opportunities-handler.spec.ts b/website/__tests__/lib/mock/opportunities-handler.spec.ts deleted file mode 100644 index 494eac318..000000000 --- a/website/__tests__/lib/mock/opportunities-handler.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { readFileSync } from "fs"; -import { resolve } from "path"; -import yaml from "js-yaml"; -import { - buildOpportunitiesExample, - opportunitiesHandler, - OPPORTUNITIES_PATH, -} from "@/lib/mock/opportunities-handler"; - -interface OpenApiSpec { - paths: Record; - components?: Record; - [key: string]: unknown; -} - -function loadSpec(): OpenApiSpec { - const content = readFileSync( - resolve(process.cwd(), "public/openapi/openapi.0.3.0.yaml"), - "utf-8", - ); - return yaml.load(content, { schema: yaml.CORE_SCHEMA }) as OpenApiSpec; -} - -describe("buildOpportunitiesExample", () => { - it("returns a schema-valid example body for GET /common-grants/opportunities", () => { - const spec = loadSpec(); - expect(OPPORTUNITIES_PATH).toBe("/common-grants/opportunities"); - - const result = buildOpportunitiesExample(spec) as Record; - - expect(result).not.toBeNull(); - expect(typeof result).toBe("object"); - expect(Array.isArray(result.items)).toBe(true); - expect((result.items as unknown[]).length).toBeGreaterThanOrEqual(1); - expect(result.paginationInfo).toBeDefined(); - expect(typeof result.paginationInfo).toBe("object"); - expect(typeof result.status).toBe("number"); - expect(typeof result.message).toBe("string"); - }); - - it("throws when the spec has no 200 schema for the path", () => { - expect(() => buildOpportunitiesExample({ paths: {} })).toThrow( - /GET \/common-grants\/opportunities/, - ); - }); -}); - -describe("opportunitiesHandler", () => { - it("builds an MSW GET handler for the opportunities path", () => { - const handler = opportunitiesHandler(loadSpec()); - expect(handler.info.method).toBe("GET"); - expect(handler.info.path).toBe(OPPORTUNITIES_PATH); - }); -}); diff --git a/website/__tests__/lib/mock/spec-handlers.spec.ts b/website/__tests__/lib/mock/spec-handlers.spec.ts new file mode 100644 index 000000000..db5e5a153 --- /dev/null +++ b/website/__tests__/lib/mock/spec-handlers.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +import yaml from "js-yaml"; +import { buildHandlersFromSpec } from "@/lib/mock/spec-handlers"; + +interface OpenApiSpec { + paths: Record; + components?: Record; + [key: string]: unknown; +} + +function loadSpec(version: string): OpenApiSpec { + const content = readFileSync( + resolve(process.cwd(), `public/openapi/openapi.${version}.yaml`), + "utf-8", + ); + return yaml.load(content, { schema: yaml.CORE_SCHEMA }) as OpenApiSpec; +} + +const OPPORTUNITIES_URL = "http://localhost/common-grants/opportunities"; + +describe("buildHandlersFromSpec", () => { + const versions = ["0.1.0", "0.2.0", "0.3.0"]; + + it.each(versions)( + "yields a non-empty handler set and a matched request returns a schema-valid body for v%s", + async (version) => { + const spec = loadSpec(version); + + const handlers = await buildHandlersFromSpec(spec); + expect(handlers.length).toBeGreaterThan(0); + + // The specs have no `servers:` block, so handlers match same-origin + // RELATIVE paths (e.g. /common-grants/opportunities). In the browser MSW + // resolves those against `location.origin`; node has no location, so we + // supply the equivalent base via `resolutionContext.baseUrl` to exercise + // the real (production) relative-path handler. + const handler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities", + ); + expect(handler).toBeDefined(); + + const result = await handler!.run({ + request: new Request(OPPORTUNITIES_URL), + requestId: `test-${version}`, + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(200); + + const body = (await result!.response!.json()) as Record; + expect(Array.isArray(body.items)).toBe(true); + expect(typeof body.paginationInfo).toBe("object"); + }, + ); +}); diff --git a/website/src/components/MockPlayground.tsx b/website/src/components/MockPlayground.tsx index c1f187ba3..6c5390e9e 100644 --- a/website/src/components/MockPlayground.tsx +++ b/website/src/components/MockPlayground.tsx @@ -1,58 +1,97 @@ -import React, { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import yaml from "js-yaml"; -import { setupWorker } from "msw/browser"; -import OpenApiDocs, { defaultVersion } from "./OpenApiDocs"; +import { setupWorker, type SetupWorker } from "msw/browser"; +import OpenApiDocs from "./OpenApiDocs"; import { - opportunitiesHandler, + buildHandlersFromSpec, type OpenApiSpec, -} from "@/lib/mock/opportunities-handler"; +} from "@/lib/mock/spec-handlers"; /** - * Throwaway MSW playground (#1034-T2): starts a Mock Service Worker that - * answers `GET /common-grants/opportunities` from the rendered OpenAPI spec, - * then renders the shared `OpenApiDocs` island with "Try it out" enabled. + * Throwaway MSW playground (#1034-T3): starts a Mock Service Worker and answers + * Swagger UI "Try it out" requests from handlers generated off the rendered + * OpenAPI spec via `@mswjs/source`. The active handler set is swapped whenever + * the version dropdown changes, so Try-it-out reflects the selected version + * (0.1.0 / 0.2.0 / 0.3.0). * * The worker registers at root scope with `onUnhandledRequest: 'bypass'`, so - * only the one mocked path is intercepted; everything else passes through. - * It is stopped on unmount to avoid leaking across page navigation. + * only spec'd paths are intercepted; everything else passes through. It is + * stopped on unmount to avoid leaking across page navigation. */ export default function MockPlayground() { const [ready, setReady] = useState(false); + const [error, setError] = useState(null); + const workerRef = useRef(undefined); + // Monotonic token so a slow spec fetch can't overwrite handlers for a version + // the user has since switched away from (rapid dropdown toggles: latest wins). + const versionTokenRef = useRef(0); + // Set on unmount so an in-flight version change can't touch a stopped worker. + const cancelledRef = useRef(false); + // Start the worker once on mount. useEffect(() => { - let cancelled = false; - let worker: ReturnType | undefined; - // Tracks the in-flight start() so cleanup stops the worker only after it - // has finished registering — avoids stop()-before-start() if the component - // unmounts mid-startup. The default version matches what OpenApiDocs renders. - let startup: Promise | undefined; - - (async () => { - const response = await fetch(`/openapi/openapi.${defaultVersion}.yaml`); - const spec = yaml.load(await response.text(), { - schema: yaml.CORE_SCHEMA, - }) as OpenApiSpec; - if (cancelled) return; + cancelledRef.current = false; + const worker = setupWorker(); + workerRef.current = worker; - worker = setupWorker(opportunitiesHandler(spec)); - startup = worker.start({ - onUnhandledRequest: "bypass", - serviceWorker: { url: "/mockServiceWorker.js" }, - }); - await startup; - if (!cancelled) setReady(true); - })(); + const startup = worker.start({ + onUnhandledRequest: "bypass", + serviceWorker: { url: "/mockServiceWorker.js" }, + }); + startup.then(() => { + if (!cancelledRef.current) setReady(true); + }); return () => { - cancelled = true; - void startup?.then(() => worker?.stop()); + cancelledRef.current = true; + startup.then(() => worker.stop()); + workerRef.current = undefined; }; }, []); + // Fetch + parse the selected version's spec, generate handlers, and swap the + // worker's active set. Wired to OpenApiDocs' version dropdown (and its + // initial mount) via onVersionChange. + const handleVersionChange = useCallback(async (version: string) => { + const worker = workerRef.current; + if (!worker) return; + + const token = ++versionTokenRef.current; + try { + const response = await fetch(`/openapi/openapi.${version}.yaml`); + if (!response.ok) { + throw new Error( + `Failed to fetch spec v${version} (${response.status})`, + ); + } + const spec = yaml.load(await response.text(), { + schema: yaml.CORE_SCHEMA, + }) as OpenApiSpec; + const handlers = await buildHandlersFromSpec(spec); + + // Skip if unmounted or superseded by a newer version toggle. + if (cancelledRef.current || token !== versionTokenRef.current) return; + worker.resetHandlers(...handlers); + setError(null); + } catch (err) { + if (cancelledRef.current || token !== versionTokenRef.current) return; + console.warn( + `[MockPlayground] Could not set up mocks for v${version}`, + err, + ); + setError(`Could not set up mocks for v${version}.`); + } + }, []); + // Wait for the worker to activate so the first "Execute" is intercepted. if (!ready) { return

Starting mock service worker…

; } - return ; + return ( + <> + {error &&

{error}

} + + + ); } diff --git a/website/src/components/OpenApiDocs.tsx b/website/src/components/OpenApiDocs.tsx index f1248ed1d..bbbe46785 100644 --- a/website/src/components/OpenApiDocs.tsx +++ b/website/src/components/OpenApiDocs.tsx @@ -21,6 +21,12 @@ interface OpenApiDocsProps { * `/protocol/api-docs` page's submit buttons disabled. */ enableTryItOut?: boolean; + /** + * Called with the resolved version on mount and on every dropdown change. + * Lets a wrapper (e.g. MockPlayground) regenerate MSW handlers for the + * selected spec version. Optional — the shipping page omits it. + */ + onVersionChange?: (version: string) => void; } // ######################################################### @@ -81,6 +87,7 @@ const styles = { export default function OpenApiDocs({ className, enableTryItOut = false, + onVersionChange, }: OpenApiDocsProps) { // ######################################################### // Set up state management @@ -102,6 +109,9 @@ export default function OpenApiDocs({ setSelectedVersion(versionFromUrl); setKey((prev) => prev + 1); // Force SwaggerUI to re-render } + + // Report the resolved initial version so a wrapper can prime its handlers. + onVersionChange?.(versionFromUrl); }, []); // Empty dependency array - only run on mount // ######################################################### @@ -112,6 +122,7 @@ export default function OpenApiDocs({ setSelectedVersion(newVersion); setKey((prev) => prev + 1); // Force SwaggerUI to re-render updateUrlParams(newVersion); + onVersionChange?.(newVersion); }; return ( diff --git a/website/src/lib/mock/opportunities-handler.ts b/website/src/lib/mock/opportunities-handler.ts deleted file mode 100644 index 132eb0d03..000000000 --- a/website/src/lib/mock/opportunities-handler.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { http, HttpResponse, type HttpHandler } from "msw"; -import * as OpenAPISampler from "openapi-sampler"; - -/** - * Same-origin relative path Swagger UI targets for the "list opportunities" - * operation. The specs declare no `servers:` block, so Try-it-out requests - * resolve against the current page origin + this path. - */ -export const OPPORTUNITIES_PATH = "/common-grants/opportunities"; - -/** - * Minimal shape of a parsed OpenAPI document this module reads from. Kept - * loose (index signature) because we only navigate a few known keys and pass - * the whole document to `openapi-sampler` for `$ref` resolution. - */ -export interface OpenApiSpec { - paths?: Record; - components?: Record; - [key: string]: unknown; -} - -/** - * Extracts the `200` JSON response schema for `GET /common-grants/opportunities` - * from a parsed OpenAPI document. - * - * @throws If the path, GET operation, or 200 JSON response schema is absent. - */ -function getOpportunitiesResponseSchema(spec: OpenApiSpec): object { - const schema = ( - spec.paths?.[OPPORTUNITIES_PATH] as - | { - get?: { - responses?: { - "200"?: { - content?: { "application/json"?: { schema?: object } }; - }; - }; - }; - } - | undefined - )?.get?.responses?.["200"]?.content?.["application/json"]?.schema; - - if (!schema) { - throw new Error( - `No 200 JSON response schema found for GET ${OPPORTUNITIES_PATH} in the provided spec`, - ); - } - return schema; -} - -/** - * Samples the 200 response body for `GET /common-grants/opportunities` from a - * parsed OpenAPI document. The full `spec` is passed to `OpenAPISampler.sample` - * as its third argument so component `$ref`s (e.g. `OpportunityBase`, - * `PaginatedResultsInfo`) resolve against the document. - * - * @param spec - A parsed OpenAPI document (YAML already loaded to an object). - * @returns A schema-valid example body for the paginated opportunities list. - */ -export function buildOpportunitiesExample( - spec: OpenApiSpec, -): Record { - const schema = getOpportunitiesResponseSchema(spec); - const body = OpenAPISampler.sample(schema, {}, spec as object); - if (typeof body !== "object" || body === null || Array.isArray(body)) { - throw new Error( - `Sampled body for GET ${OPPORTUNITIES_PATH} was not a JSON object`, - ); - } - return body as Record; -} - -/** - * Builds an MSW handler for `GET /common-grants/opportunities` that answers - * with a sampled, schema-valid example body. Registered against the - * same-origin relative path so it matches the requests Swagger UI's - * "Try it out" fires. - * - * @param spec - A parsed OpenAPI document used to generate the example body. - */ -export function opportunitiesHandler(spec: OpenApiSpec): HttpHandler { - const body = buildOpportunitiesExample(spec); - return http.get(OPPORTUNITIES_PATH, () => HttpResponse.json(body)); -} diff --git a/website/src/lib/mock/spec-handlers.ts b/website/src/lib/mock/spec-handlers.ts new file mode 100644 index 000000000..259fd3f12 --- /dev/null +++ b/website/src/lib/mock/spec-handlers.ts @@ -0,0 +1,35 @@ +import { fromOpenApi } from "@mswjs/source/open-api"; +import type { HttpHandler } from "msw"; + +/** + * Minimal shape of a parsed OpenAPI document. Kept loose (index signature) + * because we hand the whole document to `@mswjs/source`, which dereferences + * and walks it itself. + */ +export interface OpenApiSpec { + paths?: Record; + components?: Record; + [key: string]: unknown; +} + +/** + * Generates MSW request handlers for every operation in a parsed OpenAPI + * document via `@mswjs/source`'s `fromOpenApi`. Response bodies are seeded from + * each operation's response JSON Schema, so they are schema-valid by + * construction (example/schema fidelity, not request validation — per the ADR). + * + * Base-URL note (the spike's central question): our specs declare no `servers:` + * block, so `@mswjs/source` falls back to a `/` base and emits handlers with + * same-origin **relative** paths (e.g. `/common-grants/opportunities`) — exactly + * the origin Swagger UI's "Try it out" targets. No base-URL patching is needed. + * If a `@server` is ever added upstream, handlers would gain that absolute base + * and this assumption must be revisited. + * + * @param spec - A parsed OpenAPI document (YAML already loaded to an object). + * @returns A promise resolving to the generated MSW handlers. + */ +export function buildHandlersFromSpec( + spec: OpenApiSpec, +): Promise { + return fromOpenApi(spec as Parameters[0]); +} From 679e5de750344cf48cdf935217e75a6ccb835f79 Mon Sep 17 00:00:00 2001 From: Karina Gonzalez Date: Mon, 3 Aug 2026 17:15:23 -0500 Subject: [PATCH 04/10] [#1034-T4] Shared, semantic, version-aware opportunity fixture Adds a hand-authored, frozen fixture of 10 funding opportunities spanning all four statuses (forecasted/open/closed/custom), varied funding amounts, and spread-out close dates, so filters and sorting will visibly change results in the deterministic handlers built on top of it (#1034-T5/T6). Values are assembled from the TypeSpec @example decorators; the first three records keep their ids/titles from lib/ts-sdk/examples/mock-api-server.ts so cross-repo examples stay recognizable. shapeOpportunityForVersion() projects a full (v0.3, detail) record down to the version/variant a given endpoint should emit (stripping acceptedApplicantTypes and competitions for v0.1, and competitions from any list-variant projection). Refs #1034 --- .gitignore | 7 +- website/.cspell.json | 1 + .../lib/mock/opportunities-fixtures.spec.ts | 117 ++++ .../src/lib/mock/opportunities/fixtures.ts | 537 ++++++++++++++++++ 4 files changed, 661 insertions(+), 1 deletion(-) create mode 100644 website/__tests__/lib/mock/opportunities-fixtures.spec.ts create mode 100644 website/src/lib/mock/opportunities/fixtures.ts diff --git a/.gitignore b/.gitignore index 41a3c2ea1..ba1335175 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,15 @@ # Nodejs modules node_modules/ -# pnpm +# pnpm .pnpm-store/ .pnpm-debug.log # AI .cursor +.claude +CLAUDE.md +PLAN.md # Environment variables .env @@ -16,3 +19,5 @@ node_modules/ # Local tooling configuration .tool-versions + +mock-playground-adr.md diff --git a/website/.cspell.json b/website/.cspell.json index ded1039ac..887fa4229 100644 --- a/website/.cspell.json +++ b/website/.cspell.json @@ -47,6 +47,7 @@ "tspconfig", "typespec", "underrepresents", + "underserved", "USCDI", "rescope", "rescoped", diff --git a/website/__tests__/lib/mock/opportunities-fixtures.spec.ts b/website/__tests__/lib/mock/opportunities-fixtures.spec.ts new file mode 100644 index 000000000..5a22b0390 --- /dev/null +++ b/website/__tests__/lib/mock/opportunities-fixtures.spec.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; +import { + OPPORTUNITY_FIXTURES, + shapeOpportunityForVersion, + getById, + allForVersion, + type Opportunity, +} from "@/lib/mock/opportunities/fixtures"; + +const STATUS_VALUES = ["forecasted", "open", "closed", "custom"]; + +describe("OPPORTUNITY_FIXTURES", () => { + it("contains between 8 and 12 detail-shaped records", () => { + expect(OPPORTUNITY_FIXTURES.length).toBeGreaterThanOrEqual(8); + expect(OPPORTUNITY_FIXTURES.length).toBeLessThanOrEqual(12); + }); + + it("gives every record the OpportunityBase-emitted shape", () => { + for (const opp of OPPORTUNITY_FIXTURES) { + expect(typeof opp.id).toBe("string"); + expect(typeof opp.title).toBe("string"); + expect(typeof opp.description).toBe("string"); + expect(typeof opp.createdAt).toBe("string"); + expect(typeof opp.lastModifiedAt).toBe("string"); + + expect(typeof opp.status).toBe("object"); + expect(STATUS_VALUES).toContain(opp.status.value); + + expect(opp.funding).toBeDefined(); + const amounts = [ + opp.funding?.totalAmountAvailable, + opp.funding?.minAwardAmount, + opp.funding?.maxAwardAmount, + ].filter(Boolean); + expect(amounts.length).toBeGreaterThan(0); + expect(typeof amounts[0]?.amount).toBe("string"); + expect(typeof amounts[0]?.currency).toBe("string"); + + expect(opp.keyDates?.closeDate).toBeDefined(); + } + }); + + it("includes all four status values, and 'custom' records carry a customValue", () => { + const values = OPPORTUNITY_FIXTURES.map((opp) => opp.status.value); + for (const status of STATUS_VALUES) { + expect(values).toContain(status); + } + + const customRecords = OPPORTUNITY_FIXTURES.filter( + (opp) => opp.status.value === "custom", + ); + expect(customRecords.length).toBeGreaterThan(0); + for (const opp of customRecords) { + expect(typeof opp.status.customValue).toBe("string"); + } + }); +}); + +describe("shapeOpportunityForVersion", () => { + const detailRecord = OPPORTUNITY_FIXTURES.find( + (opp) => Boolean(opp.competitions) && Boolean(opp.acceptedApplicantTypes), + ) as Opportunity; + + it("strips v0.2+-only fields for v0.1 detail records", () => { + expect(detailRecord).toBeDefined(); + + const shaped = shapeOpportunityForVersion(detailRecord, "0.1.0", "detail"); + + expect(shaped).not.toHaveProperty("competitions"); + expect(shaped).not.toHaveProperty("acceptedApplicantTypes"); + }); + + it("retains acceptedApplicantTypes and competitions for v0.2 detail records", () => { + const shaped = shapeOpportunityForVersion(detailRecord, "0.2.0", "detail"); + + expect(shaped).toHaveProperty("acceptedApplicantTypes"); + expect(shaped).toHaveProperty("competitions"); + }); + + it("never includes competitions on a v0.3 list-variant record", () => { + const shaped = shapeOpportunityForVersion(detailRecord, "0.3.0", "list"); + + expect(shaped).not.toHaveProperty("competitions"); + }); + + it("allForVersion('0.1.0') strips acceptedApplicantTypes/competitions from every record", () => { + const shaped = allForVersion("0.1.0"); + + for (const opp of shaped) { + expect(opp).not.toHaveProperty("acceptedApplicantTypes"); + expect(opp).not.toHaveProperty("competitions"); + } + }); + + it("allForVersion('0.3.0') returns the same number of (list-shaped) records", () => { + const shaped = allForVersion("0.3.0"); + + expect(shaped.length).toBe(OPPORTUNITY_FIXTURES.length); + }); +}); + +describe("getById", () => { + it("has unique ids across the fixture set", () => { + const ids = OPPORTUNITY_FIXTURES.map((opp) => opp.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it("resolves the STEM Education Grant Program seeded from the mock API server", () => { + const opp = getById("573525f2-8e15-4405-83fb-e6523511d893"); + + expect(opp?.title).toBe("STEM Education Grant Program"); + }); + + it("returns undefined for an unknown id", () => { + expect(getById("does-not-exist")).toBeUndefined(); + }); +}); diff --git a/website/src/lib/mock/opportunities/fixtures.ts b/website/src/lib/mock/opportunities/fixtures.ts new file mode 100644 index 000000000..ba26c08c3 --- /dev/null +++ b/website/src/lib/mock/opportunities/fixtures.ts @@ -0,0 +1,537 @@ +/** + * Shared opportunity fixture for the MSW mock playground (#1034-T4). + * + * A fixed, hand-authored dataset that the deterministic opportunity handlers + * (#1034-T5/T6) draw from, so the list, detail, and search endpoints stay + * mutually consistent and return the same body on every call. Values are + * assembled from the TypeSpec `@example` decorators under + * `lib/core/lib/core/models/opportunity/` (semantic, not faker noise), and the + * first three records are carried over verbatim (id + title) from + * `lib/ts-sdk/examples/mock-api-server.ts` so cross-repo examples stay + * recognizable. + * + * Records are stored in their fullest (v0.3, detail) shape. + * `shapeOpportunityForVersion` projects them down to the version/variant a given + * endpoint should emit: + * - `acceptedApplicantTypes` was added in v0.2 → stripped for v0.1. + * - `competitions` lives only on `OpportunityDetails` (added v0.2) → stripped + * for v0.1 and from any `list`-variant projection. + */ + +/** Supported protocol versions, matching the OpenApiDocs dropdown. */ +export type Version = "0.1.0" | "0.2.0" | "0.3.0"; + +/** Which endpoint shape to project: the list returns `OpportunityBase`, the + * single-item read returns `OpportunityDetails` (v0.2+). */ +export type ShapeVariant = "list" | "detail"; + +/** A monetary amount and its ISO 4217 currency (mirrors `Fields.Money`). */ +export interface Money { + amount: string; + currency: string; +} + +/** Opportunity status (mirrors `Models.OppStatus`). */ +export interface OppStatus { + value: "forecasted" | "open" | "closed" | "custom"; + customValue?: string; + description?: string; +} + +/** A single-date event (the only `Event` variant the fixture uses). */ +export interface SingleDateEvent { + name: string; + eventType: "singleDate"; + date: string; + time?: string; + description?: string; +} + +/** Key dates for an opportunity (mirrors `Models.OppTimeline`). */ +export interface OppTimeline { + postDate?: SingleDateEvent; + closeDate?: SingleDateEvent; + otherDates?: Record; +} + +/** Funding details for an opportunity (mirrors `Models.OppFunding`). */ +export interface OppFunding { + details?: string; + totalAmountAvailable?: Money; + minAwardAmount?: Money; + maxAwardAmount?: Money; + minAwardCount?: number; + maxAwardCount?: number; + estimatedAwardCount?: number; +} + +/** An accepted applicant type (mirrors `Models.ApplicantType`, added v0.2). */ +export interface ApplicantType { + value: string; + customValue?: string; + description?: string; +} + +/** A custom field (mirrors `Fields.CustomField`). */ +export interface CustomField { + name: string; + fieldType: string; + value: unknown; + description?: string; +} + +/** Competition status (mirrors `Models.CompetitionStatus`, added v0.2). */ +export interface CompetitionStatus { + value: "open" | "closed" | "custom"; + customValue?: string; + description?: string; +} + +/** + * Key dates for a competition (mirrors `Models.CompetitionTimeline`, added + * v0.2). Distinct from `OppTimeline`: competitions use `openDate` (not the + * opportunity's `postDate`). + */ +export interface CompetitionTimeline { + openDate?: SingleDateEvent; + closeDate?: SingleDateEvent; + otherDates?: Record; +} + +/** + * A trimmed competition (mirrors the identifying fields of + * `Models.CompetitionBase`, added v0.2). The full model carries a required + * `forms` object; the mock omits it deliberately since these bodies are served + * as-is (not schema-validated at runtime) and the deep form nesting adds no + * demo value. + */ +export interface Competition { + id: string; + opportunityId: string; + title: string; + description?: string; + status: CompetitionStatus; + keyDates?: CompetitionTimeline; +} + +/** A funding opportunity in its fullest (v0.3, detail) shape. */ +export interface Opportunity { + id: string; + title: string; + status: OppStatus; + description: string; + funding?: OppFunding; + keyDates?: OppTimeline; + acceptedApplicantTypes?: ApplicantType[]; + source?: string; + customFields?: Record; + competitions?: Competition[]; + createdAt: string; + lastModifiedAt: string; +} + +/** Convenience builder for a `Money` amount in USD. */ +function usd(amount: string): Money { + return { amount, currency: "USD" }; +} + +/** Convenience builder for a single-date `closeDate` event. */ +function closeOn(date: string): SingleDateEvent { + return { + name: "Opportunity close date", + eventType: "singleDate", + date, + time: "17:00:00", + description: "Opportunity closes for all applications", + }; +} + +/** Convenience builder for a single-date `postDate` event. */ +function postOn(date: string): SingleDateEvent { + return { + name: "Opportunity posted date", + eventType: "singleDate", + date, + description: "Opportunity is posted publicly", + }; +} + +const legacyId = (value: number): CustomField => ({ + name: "legacyId", + fieldType: "integer", + value, + description: "Legacy system opportunity ID", +}); + +const programCode = (value: string): CustomField => ({ + name: "programCode", + fieldType: "string", + value, + description: "Funding program code", +}); + +/** + * The fixture set: 10 opportunities spanning all four statuses, a range of + * funding amounts, and varied close dates so filtering and sorting visibly + * change results. Sorted newest-first by `lastModifiedAt` to match the list + * endpoint's default ordering. + */ +export const OPPORTUNITY_FIXTURES: readonly Opportunity[] = Object.freeze([ + // ---- Carried over from lib/ts-sdk/examples/mock-api-server.ts (id + title) ---- + { + id: "573525f2-8e15-4405-83fb-e6523511d893", + title: "STEM Education Grant Program", + status: { value: "open", description: "Currently accepting applications" }, + description: + "A grant program focused on improving STEM education in under-resourced schools.", + funding: { + totalAmountAvailable: usd("2000000.00"), + minAwardAmount: usd("25000.00"), + maxAwardAmount: usd("250000.00"), + minAwardCount: 8, + maxAwardCount: 40, + estimatedAwardCount: 20, + }, + keyDates: { + postDate: postOn("2025-01-15"), + closeDate: closeOn("2025-06-30"), + }, + acceptedApplicantTypes: [ + { + value: "school_district_independent", + description: "Independent school districts", + }, + { value: "higher_education_public", description: "Public universities" }, + ], + source: "https://grants.example.gov/opportunities/stem-education", + customFields: { + legacyId: legacyId(12345), + programCode: programCode("STEM-ED"), + }, + competitions: [ + { + id: "c1a2b3c4-d5e6-4f70-8a1b-2c3d4e5f6a70", + opportunityId: "573525f2-8e15-4405-83fb-e6523511d893", + title: "STEM Education Grant — 2025 Cohort", + description: "The primary application cycle for the 2025 STEM cohort.", + status: { value: "open", description: "Accepting applications" }, + keyDates: { closeDate: closeOn("2025-06-30") }, + }, + ], + createdAt: "2025-01-01T00:00:00Z", + lastModifiedAt: "2025-01-15T00:00:00Z", + }, + { + id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + title: "Community Development Grant", + status: { value: "open", description: "Currently accepting applications" }, + description: "Funding for community development projects in rural areas.", + funding: { + totalAmountAvailable: usd("500000.00"), + minAwardAmount: usd("10000.00"), + maxAwardAmount: usd("50000.00"), + minAwardCount: 5, + maxAwardCount: 20, + estimatedAwardCount: 10, + }, + keyDates: { + postDate: postOn("2025-01-16"), + closeDate: closeOn("2025-08-15"), + }, + acceptedApplicantTypes: [ + { value: "non_profit_with_501c3", description: "501(c)(3) non-profits" }, + { + value: "government_municipal", + description: "City or township governments", + }, + ], + customFields: { + legacyId: legacyId(12346), + programCode: programCode("COMM-DEV"), + }, + createdAt: "2025-01-02T00:00:00Z", + lastModifiedAt: "2025-01-16T00:00:00Z", + }, + { + id: "b2c3d4e5-f6a7-8901-bcde-f12345678901", + title: "Education Initiative", + status: { + value: "forecasted", + description: "Not yet open for applications", + }, + description: "Support for education initiatives, opening later this year.", + funding: { + totalAmountAvailable: usd("1000000.00"), + minAwardAmount: usd("20000.00"), + maxAwardAmount: usd("100000.00"), + estimatedAwardCount: 15, + }, + keyDates: { + postDate: postOn("2025-01-17"), + closeDate: closeOn("2025-11-01"), + }, + acceptedApplicantTypes: [ + { + value: "higher_education_private", + description: "Private universities", + }, + ], + customFields: { + legacyId: legacyId(12347), + programCode: programCode("EDU-INIT"), + }, + createdAt: "2025-01-03T00:00:00Z", + lastModifiedAt: "2025-01-17T00:00:00Z", + }, + // ---- Additional records for filter/sort variety ---- + { + id: "1f0a2b3c-4d5e-4f60-8a1b-2c3d4e5f6a7b", + title: "Rural Broadband Expansion", + status: { value: "forecasted", description: "Anticipated to open in Q3" }, + description: + "Expanding high-speed broadband access to unserved rural communities.", + funding: { + details: "Large infrastructure awards for multi-county deployments.", + totalAmountAvailable: usd("10000000.00"), + minAwardAmount: usd("100000.00"), + maxAwardAmount: usd("2000000.00"), + minAwardCount: 3, + maxAwardCount: 12, + estimatedAwardCount: 6, + }, + keyDates: { + postDate: postOn("2025-02-01"), + closeDate: closeOn("2026-01-31"), + }, + acceptedApplicantTypes: [ + { value: "government_county", description: "County governments" }, + { + value: "for_profit_small_business", + description: "Small telecom providers", + }, + ], + createdAt: "2025-02-01T00:00:00Z", + lastModifiedAt: "2025-02-10T00:00:00Z", + }, + { + id: "2a3b4c5d-6e7f-4081-9b2c-3d4e5f6a7b8c", + title: "Small Business Recovery Fund", + status: { value: "open", description: "Currently accepting applications" }, + description: + "Grants to help small businesses recover from economic disruption.", + funding: { + details: "We'll be awarding between $5,000 and $25,000 per recipient", + minAwardAmount: usd("5000.00"), + maxAwardAmount: usd("25000.00"), + minAwardCount: 20, + maxAwardCount: 100, + estimatedAwardCount: 60, + }, + keyDates: { + postDate: postOn("2025-02-15"), + closeDate: closeOn("2025-07-15"), + }, + acceptedApplicantTypes: [ + { + value: "for_profit_small_business", + description: "For-profit small businesses", + }, + ], + createdAt: "2025-02-15T00:00:00Z", + lastModifiedAt: "2025-02-20T00:00:00Z", + }, + { + id: "3b4c5d6e-7f80-4192-8c3d-4e5f6a7b8c9d", + title: "Coastal Resilience Grant", + status: { + value: "closed", + description: "No longer accepting applications", + }, + description: + "Funding for coastal communities to build resilience against flooding.", + funding: { + totalAmountAvailable: usd("250000.00"), + minAwardAmount: usd("15000.00"), + maxAwardAmount: usd("75000.00"), + estimatedAwardCount: 5, + }, + keyDates: { + postDate: postOn("2024-06-01"), + closeDate: closeOn("2024-12-31"), + }, + acceptedApplicantTypes: [ + { value: "government_municipal", description: "Coastal municipalities" }, + ], + createdAt: "2024-06-01T00:00:00Z", + lastModifiedAt: "2025-01-05T00:00:00Z", + }, + { + id: "4c5d6e7f-8091-42a3-9d4e-5f6a7b8c9d0e", + title: "Clean Energy Innovation", + status: { value: "open", description: "Currently accepting applications" }, + description: + "Supporting research and deployment of clean energy technologies.", + funding: { + totalAmountAvailable: usd("5000000.00"), + minAwardAmount: usd("50000.00"), + maxAwardAmount: usd("500000.00"), + minAwardCount: 5, + maxAwardCount: 25, + estimatedAwardCount: 12, + }, + keyDates: { + postDate: postOn("2025-03-01"), + closeDate: closeOn("2025-09-30"), + }, + acceptedApplicantTypes: [ + { + value: "higher_education_public", + description: "Public research universities", + }, + { + value: "for_profit_not_small_business", + description: "Energy companies", + }, + ], + competitions: [ + { + id: "c2b3c4d5-e6f7-4081-9b2c-3d4e5f6a7b81", + opportunityId: "4c5d6e7f-8091-42a3-9d4e-5f6a7b8c9d0e", + title: "Clean Energy Innovation — Research Track", + description: "Applications for early-stage research projects.", + status: { value: "open", description: "Accepting applications" }, + keyDates: { closeDate: closeOn("2025-09-30") }, + }, + ], + createdAt: "2025-03-01T00:00:00Z", + lastModifiedAt: "2025-03-12T00:00:00Z", + }, + { + id: "5d6e7f80-91a2-43b4-8e5f-6a7b8c9d0e1f", + title: "Workforce Apprenticeship Program", + status: { + value: "closed", + description: "No longer accepting applications", + }, + description: + "Establishing registered apprenticeships in high-demand trades.", + funding: { + totalAmountAvailable: usd("1200000.00"), + minAwardAmount: usd("30000.00"), + maxAwardAmount: usd("120000.00"), + estimatedAwardCount: 10, + }, + keyDates: { + postDate: postOn("2024-09-01"), + closeDate: closeOn("2025-02-28"), + }, + acceptedApplicantTypes: [ + { + value: "organization", + description: "Workforce development organizations", + }, + ], + createdAt: "2024-09-01T00:00:00Z", + lastModifiedAt: "2025-02-28T00:00:00Z", + }, + { + id: "6e7f8091-a2b3-44c5-9f60-7b8c9d0e1f20", + title: "Community Health Outreach", + status: { + value: "custom", + customValue: "under_review", + description: "Applications received are under review by the committee", + }, + description: + "Expanding preventive health services in underserved neighborhoods.", + funding: { + totalAmountAvailable: usd("750000.00"), + minAwardAmount: usd("10000.00"), + maxAwardAmount: usd("60000.00"), + estimatedAwardCount: 15, + }, + keyDates: { + postDate: postOn("2025-01-10"), + closeDate: closeOn("2025-05-15"), + }, + acceptedApplicantTypes: [ + { + value: "non_profit_with_501c3", + description: "Community health non-profits", + }, + ], + createdAt: "2025-01-10T00:00:00Z", + lastModifiedAt: "2025-05-16T00:00:00Z", + }, + { + id: "7f8091a2-b3c4-45d6-8071-8c9d0e1f2031", + title: "Arts & Culture Preservation", + status: { + value: "custom", + customValue: "archived", + description: + "The opportunity is archived and shouldn't appear in search results", + }, + description: + "Preserving local arts, cultural heritage, and historic landmarks.", + funding: { + totalAmountAvailable: usd("300000.00"), + minAwardAmount: usd("5000.00"), + maxAwardAmount: usd("30000.00"), + estimatedAwardCount: 20, + }, + keyDates: { + postDate: postOn("2024-04-01"), + closeDate: closeOn("2024-10-31"), + }, + acceptedApplicantTypes: [ + { value: "non_profit_with_501c3", description: "Arts non-profits" }, + { value: "individual", description: "Individual artists" }, + ], + createdAt: "2024-04-01T00:00:00Z", + lastModifiedAt: "2024-11-01T00:00:00Z", + }, +]); + +/** + * Projects an opportunity down to the shape a given protocol version + endpoint + * variant should emit. Strips fields that don't exist in the target version and + * removes `competitions` from list-variant projections. + * + * @param opp - A full (v0.3, detail) fixture record. + * @param version - Target protocol version. + * @param variant - `"list"` (OpportunityBase) or `"detail"` (OpportunityDetails). + * @returns A shallow copy projected to the target shape (original is untouched). + */ +export function shapeOpportunityForVersion( + opp: Opportunity, + version: Version, + variant: ShapeVariant = "list", +): Opportunity { + const shaped: Opportunity = { ...opp }; + + // `competitions` only exists on the detail (OpportunityDetails) shape. + if (variant === "list") { + delete shaped.competitions; + } + + // v0.1 predates both `acceptedApplicantTypes` and `OpportunityDetails`. + if (version === "0.1.0") { + delete shaped.acceptedApplicantTypes; + delete shaped.competitions; + } + + return shaped; +} + +/** Looks up a fixture record by its exact id. */ +export function getById(id: string): Opportunity | undefined { + return OPPORTUNITY_FIXTURES.find((opp) => opp.id === id); +} + +/** Returns every fixture projected to the list (OpportunityBase) shape for a version. */ +export function allForVersion(version: Version): Opportunity[] { + return OPPORTUNITY_FIXTURES.map((opp) => + shapeOpportunityForVersion(opp, version, "list"), + ); +} From 33757ccdfba69cef0a2c00e86d0dcfeae1d642cb Mon Sep 17 00:00:00 2001 From: Karina Gonzalez Date: Mon, 3 Aug 2026 18:29:05 -0500 Subject: [PATCH 05/10] [#1034-T6] Search handler: filters + sorting + pagination Adds the POST /common-grants/opportunities/search handler to buildOpportunityHandlers: applies the status (in/notIn), closeDateRange, totalFundingAvailableRange, minAwardAmountRange, and maxAwardAmountRange filters (currency-aware, partial bounds supported) plus OppSortBy sorting and body pagination over the shared opportunity fixture, and echoes sortInfo/filterInfo per the protocol envelope. Malformed bodies, unknown sort fields/operators, and malformed filter shapes return a 400 with the protocol Error shape instead of throwing. Note: this file (handlers.ts) and its test file also carry the pre-existing list/detail handlers and tests from #1034-T5, which were already present in the working tree uncommitted; they land in this commit since git can't split a single file's hunks into two commits atomically here. Refs #1034 --- .../lib/mock/opportunities-handlers.spec.ts | 738 ++++++++++++++++++ .../src/lib/mock/opportunities/handlers.ts | 450 +++++++++++ 2 files changed, 1188 insertions(+) create mode 100644 website/__tests__/lib/mock/opportunities-handlers.spec.ts create mode 100644 website/src/lib/mock/opportunities/handlers.ts diff --git a/website/__tests__/lib/mock/opportunities-handlers.spec.ts b/website/__tests__/lib/mock/opportunities-handlers.spec.ts new file mode 100644 index 000000000..f8e371675 --- /dev/null +++ b/website/__tests__/lib/mock/opportunities-handlers.spec.ts @@ -0,0 +1,738 @@ +import { describe, it, expect } from "vitest"; +import type { HttpHandler } from "msw"; +import { buildOpportunityHandlers } from "@/lib/mock/opportunities/handlers"; +import { OPPORTUNITY_FIXTURES } from "@/lib/mock/opportunities/fixtures"; + +const OPPORTUNITIES_URL = "http://localhost/common-grants/opportunities"; + +describe("buildOpportunityHandlers", () => { + describe("GET /common-grants/opportunities (list)", () => { + it("returns a 200 with all fixtures sorted by lastModifiedAt descending and default pagination", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const handler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities", + ); + expect(handler).toBeDefined(); + + const result = await handler!.run({ + request: new Request(OPPORTUNITIES_URL), + requestId: "test-list", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(200); + + const body = (await result!.response!.json()) as { + status: number; + message: string; + items: Array<{ lastModifiedAt: string }>; + paginationInfo: { + page: number; + pageSize: number; + totalItems: number; + totalPages: number; + }; + }; + + expect(body.status).toBe(200); + expect(typeof body.message).toBe("string"); + + expect(Array.isArray(body.items)).toBe(true); + expect(body.items).toHaveLength(OPPORTUNITY_FIXTURES.length); + + for (let i = 1; i < body.items.length; i++) { + expect( + new Date(body.items[i - 1].lastModifiedAt).getTime(), + ).toBeGreaterThanOrEqual( + new Date(body.items[i].lastModifiedAt).getTime(), + ); + } + + expect(body.paginationInfo).toEqual({ + page: 1, + pageSize: 100, + totalItems: 10, + totalPages: 1, + }); + }); + }); + + describe("GET /common-grants/opportunities/:oppId (detail)", () => { + // Picks a fixture that carries `competitions` (present on the + // OpportunityDetails shape, stripped from the OpportunityBase/list + // projection) so this test can't pass by accident on a record where the + // two shapes happen to coincide. + const STEM_ID = "573525f2-8e15-4405-83fb-e6523511d893"; + + it("echoes the requested oppId and matches the list item's shared fields, while adding the detail-only competitions field", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const listHandler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities", + ); + expect(listHandler).toBeDefined(); + + const detailHandler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities/:oppId", + ); + expect(detailHandler).toBeDefined(); + + const listResult = await listHandler!.run({ + request: new Request(OPPORTUNITIES_URL), + requestId: "test-list-for-detail", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + const listBody = (await listResult!.response!.json()) as { + items: Array & { id: string }>; + }; + const listItem = listBody.items.find((item) => item.id === STEM_ID); + expect(listItem).toBeDefined(); + // The list (OpportunityBase) projection never carries `competitions`. + expect(listItem).not.toHaveProperty("competitions"); + + const detailResult = await detailHandler!.run({ + request: new Request( + `http://localhost/common-grants/opportunities/${STEM_ID}`, + ), + requestId: "test-detail", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(detailResult).not.toBeNull(); + expect(detailResult!.response?.status).toBe(200); + + const detailBody = (await detailResult!.response!.json()) as { + status: number; + message: string; + data: Record & { id: string; competitions: unknown }; + }; + + expect(detailBody.status).toBe(200); + // id echo: the returned record's id matches the requested :oppId. + expect(detailBody.data.id).toBe(STEM_ID); + // consistency: every field the list projection carries matches the + // detail record's value for that same field (same underlying record). + for (const [field, value] of Object.entries(listItem!)) { + expect(detailBody.data[field]).toEqual(value); + } + // detail-only field: OpportunityDetails adds `competitions`. + expect(detailBody.data.competitions).toBeDefined(); + }); + + it("returns 404 with the protocol Error shape for a well-formed but unknown UUID", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const detailHandler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities/:oppId", + ); + expect(detailHandler).toBeDefined(); + + const UNKNOWN_ID = "00000000-0000-0000-0000-000000000000"; + + const result = await detailHandler!.run({ + request: new Request( + `http://localhost/common-grants/opportunities/${UNKNOWN_ID}`, + ), + requestId: "test-detail-unknown", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(404); + + const body = (await result!.response!.json()) as { + status: number; + message: string; + errors: unknown[]; + }; + + expect(body.status).toBe(404); + expect(typeof body.message).toBe("string"); + expect(body.message.length).toBeGreaterThan(0); + expect(Array.isArray(body.errors)).toBe(true); + expect(body.errors.length).toBeGreaterThan(0); + }); + + it("returns 400 with a field-level validation error for a malformed (non-UUID) oppId", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const detailHandler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities/:oppId", + ); + expect(detailHandler).toBeDefined(); + + const result = await detailHandler!.run({ + request: new Request( + "http://localhost/common-grants/opportunities/not-a-uuid", + ), + requestId: "test-detail-malformed", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(400); + + const body = (await result!.response!.json()) as { + status: number; + message: string; + errors: Array<{ field: string; message: string }>; + }; + + expect(body.status).toBe(400); + expect(typeof body.message).toBe("string"); + expect(body.message.length).toBeGreaterThan(0); + expect(Array.isArray(body.errors)).toBe(true); + expect(body.errors.length).toBeGreaterThan(0); + + const oppIdError = body.errors.find((error) => error.field === "oppId"); + expect(oppIdError).toBeDefined(); + expect(typeof oppIdError!.message).toBe("string"); + expect(oppIdError!.message.length).toBeGreaterThan(0); + }); + + it("omits competitions and acceptedApplicantTypes from a v0.1 detail response", async () => { + const handlers = buildOpportunityHandlers("0.1.0"); + + const detailHandler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities/:oppId", + ); + expect(detailHandler).toBeDefined(); + + const result = await detailHandler!.run({ + request: new Request( + `http://localhost/common-grants/opportunities/${STEM_ID}`, + ), + requestId: "test-detail-v0.1", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(200); + + const body = (await result!.response!.json()) as { + status: number; + message: string; + data: Record & { id: string }; + }; + + expect(body.data).not.toHaveProperty("competitions"); + expect(body.data).not.toHaveProperty("acceptedApplicantTypes"); + expect(body.data.id).toBe(STEM_ID); + }); + }); + + describe("determinism", () => { + it("returns identical bodies across two calls to the list endpoint", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const listHandler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities", + ); + expect(listHandler).toBeDefined(); + + const resultA = await listHandler!.run({ + request: new Request(OPPORTUNITIES_URL), + requestId: "test-determinism-list-a", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + const resultB = await listHandler!.run({ + request: new Request(OPPORTUNITIES_URL), + requestId: "test-determinism-list-b", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + const bodyA = await resultA!.response!.json(); + const bodyB = await resultB!.response!.json(); + + expect(bodyA).toEqual(bodyB); + }); + + it("returns identical bodies across two calls to the detail endpoint for the same oppId", async () => { + const STEM_ID = "573525f2-8e15-4405-83fb-e6523511d893"; + const handlers = buildOpportunityHandlers("0.3.0"); + + const detailHandler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities/:oppId", + ); + expect(detailHandler).toBeDefined(); + + const resultA = await detailHandler!.run({ + request: new Request( + `http://localhost/common-grants/opportunities/${STEM_ID}`, + ), + requestId: "test-determinism-detail-a", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + const resultB = await detailHandler!.run({ + request: new Request( + `http://localhost/common-grants/opportunities/${STEM_ID}`, + ), + requestId: "test-determinism-detail-b", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + const bodyA = await resultA!.response!.json(); + const bodyB = await resultB!.response!.json(); + + expect(bodyA).toEqual(bodyB); + }); + }); + + describe("POST /common-grants/opportunities/search", () => { + const SEARCH_URL = "http://localhost/common-grants/opportunities/search"; + + async function runSearch(handlers: HttpHandler[], body: unknown) { + const handler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities/search", + ); + expect(handler).toBeDefined(); + + const result = await handler!.run({ + request: new Request(SEARCH_URL, { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }), + requestId: "test-search", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(200); + + return (await result!.response!.json()) as { + status: number; + message: string; + items: Array<{ id: string; status: { value: string } }>; + paginationInfo: unknown; + sortInfo: unknown; + filterInfo: { + filters: Record & { + status?: { operator: string; value: string[] }; + customFilters?: Record; + }; + errors?: unknown[]; + }; + }; + } + + it("filters to a proper subset matching the status filter, relative to the unfiltered result set", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const unfilteredBody = await runSearch(handlers, {}); + expect(Array.isArray(unfilteredBody.items)).toBe(true); + expect(unfilteredBody.items.length).toBe(OPPORTUNITY_FIXTURES.length); + + const inBody = await runSearch(handlers, { + filters: { status: { operator: "in", value: ["open"] } }, + }); + + expect(inBody.items.length).toBeLessThan(OPPORTUNITY_FIXTURES.length); + for (const item of inBody.items) { + expect(item.status.value).toBe("open"); + } + + const notInBody = await runSearch(handlers, { + filters: { status: { operator: "notIn", value: ["open"] } }, + }); + + for (const item of notInBody.items) { + expect(item.status.value).not.toBe("open"); + } + }); + + it("reorders items in reverse when sortOrder flips from asc to desc, for the same sortBy field", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + // `funding.maxAwardAmount` is present (and distinct) on every fixture + // record, so this sort key has no ties/undefined-handling ambiguity. + const ascBody = await runSearch(handlers, { + sorting: { sortBy: "funding.maxAwardAmount", sortOrder: "asc" }, + }); + const descBody = await runSearch(handlers, { + sorting: { sortBy: "funding.maxAwardAmount", sortOrder: "desc" }, + }); + + const ascIds = ascBody.items.map((item) => item.id); + const descIds = descBody.items.map((item) => item.id); + + // Guard against a no-op sort implementation trivially "passing" by both + // orderings being identical (e.g. both left in fixture/insertion order). + expect(ascIds).not.toEqual(descIds); + expect(ascIds).toEqual([...descIds].reverse()); + }); + + it("returns 400 with the protocol Error shape for an unknown sortBy value", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const handler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities/search", + ); + expect(handler).toBeDefined(); + + const result = await handler!.run({ + request: new Request(SEARCH_URL, { + method: "POST", + body: JSON.stringify({ + sorting: { sortBy: "not_a_real_sort_field" }, + }), + headers: { "Content-Type": "application/json" }, + }), + requestId: "test-search-invalid-sortby", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(400); + + const body = (await result!.response!.json()) as { + status: number; + message: string; + errors: Array<{ field: string; message: string }>; + }; + + expect(body.status).toBe(400); + expect(typeof body.message).toBe("string"); + expect(body.message.length).toBeGreaterThan(0); + expect(Array.isArray(body.errors)).toBe(true); + expect(body.errors.length).toBeGreaterThan(0); + + const sortByError = body.errors.find((error) => "field" in error); + expect(sortByError).toBeDefined(); + }); + + it("echoes customFilters in filterInfo.filters without letting them narrow results beyond the applied status filter", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const requestFilters = { + status: { operator: "in" as const, value: ["open"] }, + customFilters: { legacyId: { operator: "eq", value: 12345 } }, + }; + + const body = await runSearch(handlers, { filters: requestFilters }); + + expect(body.filterInfo.filters).toEqual(requestFilters); + expect(body.filterInfo.filters.customFilters).toEqual({ + legacyId: { operator: "eq", value: 12345 }, + }); + expect(body.filterInfo.filters.status).toEqual({ + operator: "in", + value: ["open"], + }); + + // customFilters is echoed only, so results are still narrowed purely by + // the applied `status` filter. + expect(body.items.length).toBeGreaterThan(0); + for (const item of body.items) { + expect(item.status.value).toBe("open"); + } + }); + + it("returns 400 with the protocol Error shape for a malformed JSON body", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const handler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities/search", + ); + expect(handler).toBeDefined(); + + const result = await handler!.run({ + request: new Request(SEARCH_URL, { + method: "POST", + body: "{not valid json", + headers: { "Content-Type": "application/json" }, + }), + requestId: "test-search-malformed", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(400); + + const body = (await result!.response!.json()) as { + status: number; + message: string; + errors: unknown[]; + }; + + expect(body.status).toBe(400); + expect(typeof body.message).toBe("string"); + expect(body.message.length).toBeGreaterThan(0); + expect(Array.isArray(body.errors)).toBe(true); + expect(body.errors.length).toBeGreaterThan(0); + }); + + it("partitions the fixture set into disjoint, complementary halves when the same maxAwardAmountRange bound is queried with `between` vs `outside`", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + // Bound chosen against the real fixture data (see fixtures.ts): splits + // the 10 records' `funding.maxAwardAmount` values (25k, 30k, 50k, 60k, + // 75k, 100k, 120k, 250k, 500k, 2M) into 6 inside [50000, 250000] and 4 + // outside it, so neither half is degenerate. + const bound = { + min: { amount: "50000.00", currency: "USD" }, + max: { amount: "250000.00", currency: "USD" }, + }; + + const recordsWithMaxAward = OPPORTUNITY_FIXTURES.filter( + (opp) => opp.funding?.maxAwardAmount !== undefined, + ); + + const betweenBody = await runSearch(handlers, { + filters: { + maxAwardAmountRange: { operator: "between", value: bound }, + }, + }); + const outsideBody = await runSearch(handlers, { + filters: { + maxAwardAmountRange: { operator: "outside", value: bound }, + }, + }); + + const betweenIds = new Set(betweenBody.items.map((item) => item.id)); + const outsideIds = new Set(outsideBody.items.map((item) => item.id)); + + expect(betweenIds.size).toBeGreaterThan(0); + expect(outsideIds.size).toBeGreaterThan(0); + + // Disjoint: no id appears in both the "between" and "outside" results. + for (const id of betweenIds) { + expect(outsideIds.has(id)).toBe(false); + } + + // Complementary: together they cover every fixture record that has a + // `funding.maxAwardAmount` value at all. + const unionIds = new Set([...betweenIds, ...outsideIds]); + expect(unionIds.size).toBe(recordsWithMaxAward.length); + for (const opp of recordsWithMaxAward) { + expect(unionIds.has(opp.id)).toBe(true); + } + }); + + it("returns an empty items array with a well-formed envelope when a maxAwardAmountRange bound matches no fixture record", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + // Every fixture's `funding.maxAwardAmount` tops out at 2,000,000.00, so + // this bound is clearly outside all of them. + const body = await runSearch(handlers, { + filters: { + maxAwardAmountRange: { + operator: "between", + value: { + min: { amount: "999999999.00", currency: "USD" }, + max: { amount: "1000000000.00", currency: "USD" }, + }, + }, + }, + }); + + expect(body.status).toBe(200); + expect(Array.isArray(body.items)).toBe(true); + expect(body.items).toHaveLength(0); + + expect(body.paginationInfo).toEqual({ + page: 1, + pageSize: 100, + totalItems: 0, + // The handler falls back to `totalPages: 1` (via `|| 1`) rather than + // 0 when there are no matching items, even though + // Math.ceil(0 / pageSize) is 0. + totalPages: 1, + }); + + expect(body.sortInfo).toBeTypeOf("object"); + expect(body.sortInfo).not.toBeNull(); + expect(body.filterInfo).toBeTypeOf("object"); + expect(body.filterInfo).not.toBeNull(); + expect(body.filterInfo.filters).toBeDefined(); + }); + + it("excludes every fixture record from a maxAwardAmountRange bound denominated in a different currency, regardless of operator", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + // Every fixture's `funding.maxAwardAmount` is USD-denominated (see + // fixtures.ts's `usd()` helper), and this EUR-denominated bound is wide + // enough to numerically contain every fixture's amount if currency were + // ignored. Per the protocol (`lib/core/lib/core/models/opportunity/search.tsp`), + // a currency mismatch excludes the record regardless of `operator`. + const bound = { + min: { amount: "0.00", currency: "EUR" }, + max: { amount: "999999999.00", currency: "EUR" }, + }; + + const betweenBody = await runSearch(handlers, { + filters: { + maxAwardAmountRange: { operator: "between", value: bound }, + }, + }); + + expect(betweenBody.status).toBe(200); + expect(Array.isArray(betweenBody.items)).toBe(true); + expect(betweenBody.items).toHaveLength(0); + expect(betweenBody.paginationInfo).toBeTypeOf("object"); + expect(betweenBody.sortInfo).toBeTypeOf("object"); + expect(betweenBody.filterInfo).toBeTypeOf("object"); + expect(betweenBody.filterInfo.filters).toBeDefined(); + + const outsideBody = await runSearch(handlers, { + filters: { + maxAwardAmountRange: { operator: "outside", value: bound }, + }, + }); + + // The currency mismatch excludes the record unconditionally, before the + // operator is even considered, so `outside` also returns zero items + // rather than inverting to all 10. + expect(outsideBody.items).toHaveLength(0); + }); + + it("returns 400 with the protocol Error shape for an unknown filter operator", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const handler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities/search", + ); + expect(handler).toBeDefined(); + + const result = await handler!.run({ + request: new Request(SEARCH_URL, { + method: "POST", + body: JSON.stringify({ + filters: { status: { operator: "contains", value: ["open"] } }, + }), + headers: { "Content-Type": "application/json" }, + }), + requestId: "test-search-invalid-operator", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(400); + + const body = (await result!.response!.json()) as { + status: number; + message: string; + errors: unknown[]; + }; + + expect(body.status).toBe(400); + expect(typeof body.message).toBe("string"); + expect(body.message.length).toBeGreaterThan(0); + expect(Array.isArray(body.errors)).toBe(true); + expect(body.errors.length).toBeGreaterThan(0); + }); + + it("returns 400 with the protocol Error shape for a status filter whose value is not an array", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const handler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities/search", + ); + expect(handler).toBeDefined(); + + const result = await handler!.run({ + request: new Request(SEARCH_URL, { + method: "POST", + body: JSON.stringify({ + filters: { status: { operator: "in", value: 5 } }, + }), + headers: { "Content-Type": "application/json" }, + }), + requestId: "test-search-malformed-status-value", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(400); + + const body = (await result!.response!.json()) as { + status: number; + message: string; + errors: unknown[]; + }; + + expect(body.status).toBe(400); + expect(typeof body.message).toBe("string"); + expect(body.message.length).toBeGreaterThan(0); + expect(Array.isArray(body.errors)).toBe(true); + expect(body.errors.length).toBeGreaterThan(0); + }); + + it("returns 400 with the protocol Error shape for a range filter with no value", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const handler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities/search", + ); + expect(handler).toBeDefined(); + + const result = await handler!.run({ + request: new Request(SEARCH_URL, { + method: "POST", + body: JSON.stringify({ + filters: { closeDateRange: { operator: "between" } }, + }), + headers: { "Content-Type": "application/json" }, + }), + requestId: "test-search-range-filter-missing-value", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(400); + + const body = (await result!.response!.json()) as { + status: number; + message: string; + errors: unknown[]; + }; + + expect(body.status).toBe(400); + expect(typeof body.message).toBe("string"); + expect(body.message.length).toBeGreaterThan(0); + expect(Array.isArray(body.errors)).toBe(true); + expect(body.errors.length).toBeGreaterThan(0); + }); + + it("filters using only the given bound when a maxAwardAmountRange filter omits max", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + // Bound chosen against the real fixture data (see fixtures.ts): the 10 + // records' `funding.maxAwardAmount` values (25k, 30k, 50k, 60k, 75k, + // 100k, 120k, 250k, 500k, 2M) split into 8 at-or-above 50000 and 2 + // below it, so this is a proper, non-degenerate subset. + const body = await runSearch(handlers, { + filters: { + maxAwardAmountRange: { + operator: "between", + value: { min: { amount: "50000.00", currency: "USD" } }, + }, + }, + }); + + expect(body.items.length).toBeGreaterThan(0); + expect(body.items.length).toBeLessThan(OPPORTUNITY_FIXTURES.length); + + const fullBody = await runSearch(handlers, {}); + const itemsById = new Map( + fullBody.items.map((item) => [ + item.id, + item as unknown as { + funding?: { maxAwardAmount?: { amount: string } }; + }, + ]), + ); + + for (const item of body.items) { + const fullItem = itemsById.get(item.id); + expect(fullItem?.funding?.maxAwardAmount?.amount).toBeDefined(); + expect( + Number(fullItem!.funding!.maxAwardAmount!.amount), + ).toBeGreaterThanOrEqual(50000); + } + }); + }); +}); diff --git a/website/src/lib/mock/opportunities/handlers.ts b/website/src/lib/mock/opportunities/handlers.ts new file mode 100644 index 000000000..0b0a4fc14 --- /dev/null +++ b/website/src/lib/mock/opportunities/handlers.ts @@ -0,0 +1,450 @@ +import { http, HttpResponse, type HttpHandler } from "msw"; +import { + OPPORTUNITY_FIXTURES, + allForVersion, + getById, + shapeOpportunityForVersion, + type Opportunity, + type Version, +} from "./fixtures"; + +const LIST_PATH = "/common-grants/opportunities"; +const DETAIL_PATH = "/common-grants/opportunities/:oppId"; +const SEARCH_PATH = "/common-grants/opportunities/search"; + +const DEFAULT_PAGE = 1; +const DEFAULT_PAGE_SIZE = 100; + +/** Wire values of `Models.OppSortBy` (`lib/core/lib/core/models/opportunity/search.tsp`). */ +const VALID_SORT_BY = new Set([ + "lastModifiedAt", + "createdAt", + "title", + "status.value", + "keyDates.closeDate", + "funding.maxAwardAmount", + "funding.minAwardAmount", + "funding.totalAmountAvailable", + "funding.estimatedAwardCount", + "custom", +]); + +const VALID_SORT_ORDER = new Set(["asc", "desc"]); +const VALID_ARRAY_OPERATORS = new Set(["in", "notIn"]); +const VALID_RANGE_OPERATORS = new Set(["between", "outside"]); + +interface StringArrayFilter { + operator: string; + value: string[]; +} + +interface RangeFilter { + operator: string; + value: { + min: string | { amount: string; currency: string }; + max: string | { amount: string; currency: string }; + }; +} + +interface OppFilters { + status?: StringArrayFilter; + closeDateRange?: RangeFilter; + totalFundingAvailableRange?: RangeFilter; + minAwardAmountRange?: RangeFilter; + maxAwardAmountRange?: RangeFilter; + customFilters?: Record; +} + +interface OppSorting { + sortBy: string; + customSortBy?: string; + sortOrder?: string; +} + +interface SearchRequestBody { + search?: string; + filters?: OppFilters; + sorting?: OppSorting; + pagination?: { page?: number; pageSize?: number }; +} + +/** A single `{field, message}` validation error, matching the T5 error-envelope convention. */ +interface FieldError { + field: string; + message: string; +} + +function moneyAmount(value: unknown): number | undefined { + if (value && typeof value === "object" && "amount" in value) { + return Number((value as { amount: string }).amount); + } + return undefined; +} + +function dateTime(value: unknown): number | undefined { + if (typeof value !== "string") return undefined; + const ms = new Date(value).getTime(); + return Number.isNaN(ms) ? undefined : ms; +} + +/** Applies a `StringArrayFilter` (`in`/`notIn`) over the `status.value` field. */ +function applyStatusFilter( + items: Opportunity[], + filter: StringArrayFilter, +): Opportunity[] { + const allowed = new Set(filter.value); + return items.filter((opp) => { + const inSet = allowed.has(opp.status.value); + return filter.operator === "notIn" ? !inSet : inSet; + }); +} + +/** + * Applies a date `RangeFilter` (`between`/`outside`) over a field extracted by + * `getValue`. Either bound may be omitted (filters on the one given). + */ +function applyDateRangeFilter( + items: Opportunity[], + filter: RangeFilter, + getValue: (opp: Opportunity) => number | undefined, +): Opportunity[] { + const min = + filter.value.min !== undefined + ? dateTime(filter.value.min as string) + : undefined; + const max = + filter.value.max !== undefined + ? dateTime(filter.value.max as string) + : undefined; + + return items.filter((opp) => { + const value = getValue(opp); + if (value === undefined) return false; + const inRange = + (min === undefined || value >= min) && + (max === undefined || value <= max); + return filter.operator === "outside" ? !inRange : inRange; + }); +} + +/** + * Applies a `MoneyRangeFilter` (`between`/`outside`) over a `Money` field + * extracted by `getMoney`. Either bound may be omitted (filters on the one + * given). Per the protocol (`totalFundingAvailableRange` et al. in + * `lib/core/lib/core/models/opportunity/search.tsp`), amounts denominated in a + * different currency than the filter bound are excluded from the match + * regardless of `operator`. + */ +function applyMoneyRangeFilter( + items: Opportunity[], + filter: RangeFilter, + getMoney: ( + opp: Opportunity, + ) => { amount: string; currency: string } | undefined, +): Opportunity[] { + const minBound = filter.value.min as + | { amount: string; currency: string } + | undefined; + const maxBound = filter.value.max as + | { amount: string; currency: string } + | undefined; + const currency = minBound?.currency ?? maxBound?.currency; + const min = minBound ? Number(minBound.amount) : undefined; + const max = maxBound ? Number(maxBound.amount) : undefined; + + return items.filter((opp) => { + const money = getMoney(opp); + if (!money) return false; + if (currency !== undefined && money.currency !== currency) return false; + const value = Number(money.amount); + const inRange = + (min === undefined || value >= min) && + (max === undefined || value <= max); + return filter.operator === "outside" ? !inRange : inRange; + }); +} + +/** Extracts the field an `OppSortBy` wire value sorts on, as a string or number. */ +function sortKey(opp: Opportunity, sortBy: string): string | number { + switch (sortBy) { + case "lastModifiedAt": + return new Date(opp.lastModifiedAt).getTime(); + case "createdAt": + return new Date(opp.createdAt).getTime(); + case "title": + return opp.title; + case "status.value": + return opp.status.value; + case "keyDates.closeDate": + return dateTime(opp.keyDates?.closeDate?.date) ?? 0; + case "funding.maxAwardAmount": + return moneyAmount(opp.funding?.maxAwardAmount) ?? 0; + case "funding.minAwardAmount": + return moneyAmount(opp.funding?.minAwardAmount) ?? 0; + case "funding.totalAmountAvailable": + return moneyAmount(opp.funding?.totalAmountAvailable) ?? 0; + case "funding.estimatedAwardCount": + return opp.funding?.estimatedAwardCount ?? 0; + default: + // "custom" (application-defined field) - no built-in ordering to apply. + return 0; + } +} + +function compare(a: string | number, b: string | number): number { + if (typeof a === "string" || typeof b === "string") { + return String(a).localeCompare(String(b)); + } + return a - b; +} + +/** RFC 4122 UUID (any version/variant), matching the protocol's `uuid` format. */ +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** Builds the protocol `Error` envelope (`status`, `message`, `errors`). */ +function errorResponse( + status: number, + message: string, + errors: unknown[], +): Response { + return HttpResponse.json({ status, message, errors }, { status }); +} + +/** + * Deterministic, fixture-backed MSW handlers for the CommonGrants opportunity + * list and detail endpoints (#1034-T5). Bodies are static projections of + * `OPPORTUNITY_FIXTURES`, so repeat calls return identical results, and the + * list and detail endpoints resolve to the same records (must-do #1 and #2). + * + * @param version - Protocol version to shape responses for (v0.1 omits + * `acceptedApplicantTypes`/`competitions`; v0.1 detail returns the + * `OpportunityBase` shape rather than `OpportunityDetails`). + */ +export function buildOpportunityHandlers(version: Version): HttpHandler[] { + return [ + http.get(LIST_PATH, ({ request }) => { + const url = new URL(request.url); + const page = Math.max( + 1, + parseInt(url.searchParams.get("page") ?? String(DEFAULT_PAGE), 10) || + DEFAULT_PAGE, + ); + const pageSize = Math.max( + 1, + Math.min( + 100, + parseInt( + url.searchParams.get("pageSize") ?? String(DEFAULT_PAGE_SIZE), + 10, + ) || DEFAULT_PAGE_SIZE, + ), + ); + + const sorted = allForVersion(version).sort( + (a, b) => + new Date(b.lastModifiedAt).getTime() - + new Date(a.lastModifiedAt).getTime(), + ); + const start = (page - 1) * pageSize; + const items = sorted.slice(start, start + pageSize); + + return HttpResponse.json({ + status: 200, + message: "Success", + items, + paginationInfo: { + page, + pageSize, + totalItems: OPPORTUNITY_FIXTURES.length, + totalPages: Math.ceil(OPPORTUNITY_FIXTURES.length / pageSize), + }, + }); + }), + + http.get(DETAIL_PATH, ({ params }) => { + const oppId = String(params.oppId); + + if (!UUID_PATTERN.test(oppId)) { + return errorResponse(400, "Invalid opportunity id", [ + { field: "oppId", message: "Must be a valid UUID" }, + ]); + } + + const opp = getById(oppId); + if (!opp) { + return errorResponse(404, "Opportunity not found", [ + { field: "oppId", message: `No opportunity found with id ${oppId}` }, + ]); + } + + return HttpResponse.json({ + status: 200, + message: "Success", + data: shapeOpportunityForVersion(opp, version, "detail"), + }); + }), + + http.post(SEARCH_PATH, async ({ request }) => { + let body: SearchRequestBody; + try { + body = (await request.json()) as SearchRequestBody; + } catch { + return errorResponse(400, "Malformed JSON body", [ + { field: "body", message: "Request body must be valid JSON" }, + ]); + } + + const errors: FieldError[] = []; + const filters = body.filters ?? {}; + const sorting = body.sorting; + + if (sorting) { + if (!VALID_SORT_BY.has(sorting.sortBy)) { + errors.push({ + field: "sorting.sortBy", + message: `Unknown sort field: ${String(sorting.sortBy)}`, + }); + } + if ( + sorting.sortOrder !== undefined && + !VALID_SORT_ORDER.has(sorting.sortOrder) + ) { + errors.push({ + field: "sorting.sortOrder", + message: `Unknown sort order: ${String(sorting.sortOrder)}`, + }); + } + } + if (filters.status) { + if (!VALID_ARRAY_OPERATORS.has(filters.status.operator)) { + errors.push({ + field: "filters.status.operator", + message: `Unknown array operator: ${String(filters.status.operator)}`, + }); + } else if (!Array.isArray(filters.status.value)) { + errors.push({ + field: "filters.status.value", + message: "Must be an array of strings", + }); + } + } + for (const field of [ + "closeDateRange", + "totalFundingAvailableRange", + "minAwardAmountRange", + "maxAwardAmountRange", + ] as const) { + const filter = filters[field]; + if (!filter) continue; + if (!VALID_RANGE_OPERATORS.has(filter.operator)) { + errors.push({ + field: `filters.${field}.operator`, + message: `Unknown range operator: ${String(filter.operator)}`, + }); + } else if ( + !filter.value || + typeof filter.value !== "object" || + (filter.value.min === undefined && filter.value.max === undefined) + ) { + errors.push({ + field: `filters.${field}.value`, + message: "Must include at least one of min or max", + }); + } + } + + if (errors.length > 0) { + return errorResponse(400, "Invalid search request", errors); + } + + let items = allForVersion(version); + + if (body.search) { + const q = body.search.toLowerCase(); + items = items.filter( + (opp) => + opp.title.toLowerCase().includes(q) || + opp.description.toLowerCase().includes(q), + ); + } + if (filters.status) { + items = applyStatusFilter(items, filters.status); + } + if (filters.closeDateRange) { + items = applyDateRangeFilter(items, filters.closeDateRange, (opp) => + dateTime(opp.keyDates?.closeDate?.date), + ); + } + if (filters.totalFundingAvailableRange) { + items = applyMoneyRangeFilter( + items, + filters.totalFundingAvailableRange, + (opp) => opp.funding?.totalAmountAvailable, + ); + } + if (filters.minAwardAmountRange) { + items = applyMoneyRangeFilter( + items, + filters.minAwardAmountRange, + (opp) => opp.funding?.minAwardAmount, + ); + } + if (filters.maxAwardAmountRange) { + items = applyMoneyRangeFilter( + items, + filters.maxAwardAmountRange, + (opp) => opp.funding?.maxAwardAmount, + ); + } + + const sortBy = sorting?.sortBy ?? "lastModifiedAt"; + const sortOrder = sorting?.sortOrder ?? "desc"; + const sortErrors: string[] = []; + if (sortBy === "custom") { + sortErrors.push( + sorting?.customSortBy + ? `Custom sort field "${sorting.customSortBy}" is not supported by this mock; results are unsorted for it.` + : "Custom sort requested without customSortBy; results are unsorted.", + ); + } else { + items = [...items].sort((a, b) => { + const result = compare(sortKey(a, sortBy), sortKey(b, sortBy)); + return sortOrder === "asc" ? result : -result; + }); + } + + const page = Math.max(1, body.pagination?.page ?? DEFAULT_PAGE); + const pageSize = Math.max( + 1, + Math.min(100, body.pagination?.pageSize ?? DEFAULT_PAGE_SIZE), + ); + const start = (page - 1) * pageSize; + const totalItems = items.length; + const pageItems = items.slice(start, start + pageSize); + + return HttpResponse.json({ + status: 200, + message: "Success", + items: pageItems, + paginationInfo: { + page, + pageSize, + totalItems, + totalPages: Math.ceil(totalItems / pageSize) || 1, + }, + sortInfo: { + sortBy, + ...(sorting?.customSortBy !== undefined + ? { customSortBy: sorting.customSortBy } + : {}), + sortOrder, + ...(sortErrors.length > 0 ? { errors: sortErrors } : {}), + }, + filterInfo: { + filters, + }, + }); + }), + ]; +} From 55828658cd33810e8ae71bfedef071a5e6fd6ebf Mon Sep 17 00:00:00 2001 From: Karina Gonzalez Date: Mon, 3 Aug 2026 19:15:45 -0500 Subject: [PATCH 06/10] [#1034-T5] Deterministic list + detail handlers (consistency, id echo, 404/400) Rounds out the list/detail opportunity handlers (already landed inside the T6 commit, which couldn't be split from these pre-existing uncommitted changes) with review follow-ups: adds pagination edge-case tests (page past the end, pageSize clamping/defaults) that weren't previously locked in, extracts a shared resolvePagination() helper used by both the list and search handlers (unifying pageSize=0 to clamp to 1 instead of silently defaulting to 100), extracts a successResponse() envelope helper alongside the existing errorResponse() one, and corrects the UUID_PATTERN comment to not overclaim RFC 4122 conformance. Refs #1034 --- .../lib/mock/opportunities-handlers.spec.ts | 115 ++++++++++++++++++ .../src/lib/mock/opportunities/handlers.ts | 74 ++++++----- 2 files changed, 159 insertions(+), 30 deletions(-) diff --git a/website/__tests__/lib/mock/opportunities-handlers.spec.ts b/website/__tests__/lib/mock/opportunities-handlers.spec.ts index f8e371675..0cc31d6a8 100644 --- a/website/__tests__/lib/mock/opportunities-handlers.spec.ts +++ b/website/__tests__/lib/mock/opportunities-handlers.spec.ts @@ -57,6 +57,121 @@ describe("buildOpportunityHandlers", () => { totalPages: 1, }); }); + + it("returns an empty items array with valid paginationInfo when page is past the end", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const handler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities", + ); + expect(handler).toBeDefined(); + + const result = await handler!.run({ + request: new Request(`${OPPORTUNITIES_URL}?page=999`), + requestId: "test-list-page-past-end", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(200); + + const body = (await result!.response!.json()) as { + items: unknown[]; + paginationInfo: { + page: number; + pageSize: number; + totalItems: number; + totalPages: number; + }; + }; + + expect(Array.isArray(body.items)).toBe(true); + expect(body.items).toHaveLength(0); + + expect(body.paginationInfo).toEqual({ + page: 999, + pageSize: 100, + totalItems: 10, + totalPages: 1, + }); + }); + + it("clamps pageSize to 100 when a larger value is requested", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const handler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities", + ); + expect(handler).toBeDefined(); + + const result = await handler!.run({ + request: new Request(`${OPPORTUNITIES_URL}?pageSize=9999`), + requestId: "test-list-pagesize-too-large", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(200); + + const body = (await result!.response!.json()) as { + paginationInfo: { pageSize: number }; + }; + + expect(body.paginationInfo.pageSize).toBe(100); + }); + + it.each([0, -5])( + "clamps pageSize to at least 1 when pageSize=%i is requested", + async (pageSizeValue) => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const handler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities", + ); + expect(handler).toBeDefined(); + + const result = await handler!.run({ + request: new Request( + `${OPPORTUNITIES_URL}?pageSize=${pageSizeValue}`, + ), + requestId: `test-list-pagesize-${pageSizeValue}`, + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(200); + + const body = (await result!.response!.json()) as { + paginationInfo: { pageSize: number }; + }; + + expect(body.paginationInfo.pageSize).toBeGreaterThanOrEqual(1); + }, + ); + + it("falls back to the default pageSize when a non-numeric value is requested", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const handler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities", + ); + expect(handler).toBeDefined(); + + const result = await handler!.run({ + request: new Request(`${OPPORTUNITIES_URL}?pageSize=abc`), + requestId: "test-list-pagesize-non-numeric", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(200); + + const body = (await result!.response!.json()) as { + paginationInfo: { pageSize: number }; + }; + + expect(body.paginationInfo.pageSize).toBe(100); + }); }); describe("GET /common-grants/opportunities/:oppId (detail)", () => { diff --git a/website/src/lib/mock/opportunities/handlers.ts b/website/src/lib/mock/opportunities/handlers.ts index 0b0a4fc14..c8ce6e5c2 100644 --- a/website/src/lib/mock/opportunities/handlers.ts +++ b/website/src/lib/mock/opportunities/handlers.ts @@ -14,6 +14,7 @@ const SEARCH_PATH = "/common-grants/opportunities/search"; const DEFAULT_PAGE = 1; const DEFAULT_PAGE_SIZE = 100; +const MAX_PAGE_SIZE = 100; /** Wire values of `Models.OppSortBy` (`lib/core/lib/core/models/opportunity/search.tsp`). */ const VALID_SORT_BY = new Set([ @@ -198,7 +199,34 @@ function compare(a: string | number, b: string | number): number { return a - b; } -/** RFC 4122 UUID (any version/variant), matching the protocol's `uuid` format. */ +/** + * Resolves `page`/`pageSize` per the spec's defaults (page=1, pageSize=100) + * and bounds (both >= 1, pageSize <= 100). A missing or non-numeric raw value + * falls back to its default; a present, well-formed value outside the valid + * range is clamped to the nearest bound rather than replaced by the default + * (e.g. a requested `pageSize=0` becomes 1, not 100). + */ +function resolvePagination( + rawPage: number, + rawPageSize: number, +): { page: number; pageSize: number } { + return { + page: Math.max(1, Number.isFinite(rawPage) ? rawPage : DEFAULT_PAGE), + pageSize: Math.max( + 1, + Math.min( + MAX_PAGE_SIZE, + Number.isFinite(rawPageSize) ? rawPageSize : DEFAULT_PAGE_SIZE, + ), + ), + }; +} + +/** + * UUID-shaped value (8-4-4-4-12 hex, matching the protocol's `uuid` format). + * Not a full RFC 4122 conformance check — it deliberately accepts the nil + * UUID (`00000000-…`), used as this suite's "well-formed but unknown" case. + */ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -211,6 +239,11 @@ function errorResponse( return HttpResponse.json({ status, message, errors }, { status }); } +/** Builds the protocol success envelope (`status: 200`, `message`, plus the endpoint-specific body). */ +function successResponse(body: Record): Response { + return HttpResponse.json({ status: 200, message: "Success", ...body }); +} + /** * Deterministic, fixture-backed MSW handlers for the CommonGrants opportunity * list and detail endpoints (#1034-T5). Bodies are static projections of @@ -225,33 +258,19 @@ export function buildOpportunityHandlers(version: Version): HttpHandler[] { return [ http.get(LIST_PATH, ({ request }) => { const url = new URL(request.url); - const page = Math.max( - 1, - parseInt(url.searchParams.get("page") ?? String(DEFAULT_PAGE), 10) || - DEFAULT_PAGE, - ); - const pageSize = Math.max( - 1, - Math.min( - 100, - parseInt( - url.searchParams.get("pageSize") ?? String(DEFAULT_PAGE_SIZE), - 10, - ) || DEFAULT_PAGE_SIZE, - ), + const { page, pageSize } = resolvePagination( + parseInt(url.searchParams.get("page") ?? "", 10), + parseInt(url.searchParams.get("pageSize") ?? "", 10), ); const sorted = allForVersion(version).sort( (a, b) => - new Date(b.lastModifiedAt).getTime() - - new Date(a.lastModifiedAt).getTime(), + -compare(sortKey(a, "lastModifiedAt"), sortKey(b, "lastModifiedAt")), ); const start = (page - 1) * pageSize; const items = sorted.slice(start, start + pageSize); - return HttpResponse.json({ - status: 200, - message: "Success", + return successResponse({ items, paginationInfo: { page, @@ -278,9 +297,7 @@ export function buildOpportunityHandlers(version: Version): HttpHandler[] { ]); } - return HttpResponse.json({ - status: 200, - message: "Success", + return successResponse({ data: shapeOpportunityForVersion(opp, version, "detail"), }); }), @@ -414,18 +431,15 @@ export function buildOpportunityHandlers(version: Version): HttpHandler[] { }); } - const page = Math.max(1, body.pagination?.page ?? DEFAULT_PAGE); - const pageSize = Math.max( - 1, - Math.min(100, body.pagination?.pageSize ?? DEFAULT_PAGE_SIZE), + const { page, pageSize } = resolvePagination( + body.pagination?.page ?? NaN, + body.pagination?.pageSize ?? NaN, ); const start = (page - 1) * pageSize; const totalItems = items.length; const pageItems = items.slice(start, start + pageSize); - return HttpResponse.json({ - status: 200, - message: "Success", + return successResponse({ items: pageItems, paginationInfo: { page, From 93a7ff3f32464ca696b06bd1c6fa36d8ed8a8b51 Mon Sep 17 00:00:00 2001 From: Karina Gonzalez Date: Mon, 3 Aug 2026 20:00:37 -0500 Subject: [PATCH 07/10] [#1034-T7] Wire hand-authored handlers in; memoize the auto-generated long-tail Prepends buildOpportunityHandlers ahead of the generated fromOpenApi set in MockPlayground.handleVersionChange so opportunity endpoints override the long tail (first-match-wins). spec-handlers.ts gains a memoization wrapper so every other endpoint replays a byte-identical cached response, keyed on method+path+sorted-query+body, instead of reseeding faker on each call. Refs #1034 --- .../__tests__/lib/mock/spec-handlers.spec.ts | 77 ++++++++++++++++ website/src/components/MockPlayground.tsx | 9 +- website/src/lib/mock/spec-handlers.ts | 92 ++++++++++++++++++- 3 files changed, 172 insertions(+), 6 deletions(-) diff --git a/website/__tests__/lib/mock/spec-handlers.spec.ts b/website/__tests__/lib/mock/spec-handlers.spec.ts index db5e5a153..64af59532 100644 --- a/website/__tests__/lib/mock/spec-handlers.spec.ts +++ b/website/__tests__/lib/mock/spec-handlers.spec.ts @@ -2,7 +2,10 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "fs"; import { resolve } from "path"; import yaml from "js-yaml"; +import type { HttpHandler } from "msw"; import { buildHandlersFromSpec } from "@/lib/mock/spec-handlers"; +import { buildOpportunityHandlers } from "@/lib/mock/opportunities/handlers"; +import { OPPORTUNITY_FIXTURES } from "@/lib/mock/opportunities/fixtures"; interface OpenApiSpec { paths: Record; @@ -55,4 +58,78 @@ describe("buildHandlersFromSpec", () => { expect(typeof body.paginationInfo).toBe("object"); }, ); + + it("returns byte-identical bodies across two calls to the same generated (non-opportunity) handler", async () => { + const spec = loadSpec("0.3.0"); + + const handlers = await buildHandlersFromSpec(spec); + const handler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities", + ); + expect(handler).toBeDefined(); + + const firstResult = await handler!.run({ + request: new Request(OPPORTUNITIES_URL), + requestId: "test-memoization-1", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + const secondResult = await handler!.run({ + request: new Request(OPPORTUNITIES_URL), + requestId: "test-memoization-2", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(firstResult).not.toBeNull(); + expect(secondResult).not.toBeNull(); + + const firstBody = (await firstResult!.response!.json()) as Record< + string, + unknown + >; + const secondBody = (await secondResult!.response!.json()) as Record< + string, + unknown + >; + + expect(JSON.stringify(secondBody)).toBe(JSON.stringify(firstBody)); + }); + + it("opportunity path resolves to the hand-authored handler, not the generated one", async () => { + const spec = loadSpec("0.3.0"); + const specHandlers = await buildHandlersFromSpec(spec); + const opportunityHandlers = buildOpportunityHandlers("0.3.0"); + + // Production order (MockPlayground.handleVersionChange): hand-authored + // opportunity handlers registered before the generated `fromOpenApi` set, + // so MSW's first-match-wins semantics let them override the generated + // opportunity detail handler. + const combinedHandlers: HttpHandler[] = [ + ...opportunityHandlers, + ...specHandlers, + ]; + + const oppId = OPPORTUNITY_FIXTURES[0].id; + const request = new Request( + `http://localhost/common-grants/opportunities/${oppId}`, + ); + const resolutionContext = { baseUrl: "http://localhost/" }; + + let winner: HttpHandler | undefined; + for (const handler of combinedHandlers) { + const parsedResult = await handler.parse({ request, resolutionContext }); + const matches = await handler.predicate({ + request, + parsedResult, + resolutionContext, + }); + if (matches) { + winner = handler; + break; + } + } + + expect(winner).toBeDefined(); + expect(opportunityHandlers).toContain(winner); + expect(specHandlers).not.toContain(winner); + }); }); diff --git a/website/src/components/MockPlayground.tsx b/website/src/components/MockPlayground.tsx index 6c5390e9e..1a5e6ea29 100644 --- a/website/src/components/MockPlayground.tsx +++ b/website/src/components/MockPlayground.tsx @@ -6,6 +6,8 @@ import { buildHandlersFromSpec, type OpenApiSpec, } from "@/lib/mock/spec-handlers"; +import { buildOpportunityHandlers } from "@/lib/mock/opportunities/handlers"; +import type { Version } from "@/lib/mock/opportunities/fixtures"; /** * Throwaway MSW playground (#1034-T3): starts a Mock Service Worker and answers @@ -67,11 +69,14 @@ export default function MockPlayground() { const spec = yaml.load(await response.text(), { schema: yaml.CORE_SCHEMA, }) as OpenApiSpec; - const handlers = await buildHandlersFromSpec(spec); + const specHandlers = await buildHandlersFromSpec(spec); + const opportunityHandlers = buildOpportunityHandlers(version as Version); // Skip if unmounted or superseded by a newer version toggle. if (cancelledRef.current || token !== versionTokenRef.current) return; - worker.resetHandlers(...handlers); + // Opportunity handlers first: MSW resolves first-match-wins, so they + // override the generated `fromOpenApi` handlers for the same paths. + worker.resetHandlers(...opportunityHandlers, ...specHandlers); setError(null); } catch (err) { if (cancelledRef.current || token !== versionTokenRef.current) return; diff --git a/website/src/lib/mock/spec-handlers.ts b/website/src/lib/mock/spec-handlers.ts index 259fd3f12..ba6098129 100644 --- a/website/src/lib/mock/spec-handlers.ts +++ b/website/src/lib/mock/spec-handlers.ts @@ -1,5 +1,5 @@ import { fromOpenApi } from "@mswjs/source/open-api"; -import type { HttpHandler } from "msw"; +import type { HttpHandler, ResponseResolver } from "msw"; /** * Minimal shape of a parsed OpenAPI document. Kept loose (index signature) @@ -12,11 +12,94 @@ export interface OpenApiSpec { [key: string]: unknown; } +/** A cached response snapshot, replayed as a fresh `Response` on cache hits. */ +interface CachedResponse { + status: number; + statusText: string; + headers: [string, string][]; + body: ArrayBuffer; +} + +/** + * Cache key: method + pathname + sorted query string + body text (read via a + * clone, so the original request stream is untouched for the real resolver). + * The body must be part of the key — several generated endpoints (e.g. `POST + * /common-grants/applications/start`) take their real input via a JSON body + * at a fixed path with no distinguishing query params, so keying on the URL + * alone would replay one body's cached response for a different body. + */ +async function requestCacheKey(request: Request): Promise { + const url = new URL(request.url); + const sortedQuery = new URLSearchParams( + [...url.searchParams.entries()].sort(([a], [b]) => a.localeCompare(b)), + ); + const body = request.body ? await request.clone().text() : ""; + return `${request.method} ${url.pathname}?${sortedQuery.toString()}\n${body}`; +} + +async function snapshotResponse(response: Response): Promise { + return { + status: response.status, + statusText: response.statusText, + headers: [...response.headers.entries()], + body: await response.clone().arrayBuffer(), + }; +} + +/** Statuses that forbid a response body (the `Response` constructor throws if given one). */ +const NULL_BODY_STATUSES = new Set([204, 205, 304]); + +function replayResponse(cached: CachedResponse): Response { + const body = NULL_BODY_STATUSES.has(cached.status) + ? null + : cached.body.slice(0); + return new Response(body, { + status: cached.status, + statusText: cached.statusText, + headers: cached.headers, + }); +} + +/** + * Wraps each handler's resolver so a repeat request to the same method+path+query + * replays a byte-identical cached response instead of re-invoking the resolver — + * `fromOpenApi`-generated resolvers reseed `@faker-js/faker` on every call, so + * without this, repeat "Try it out" calls return different bodies each time. + * Mutates each handler's `resolver` in place (a plain instance property; not + * accessible off the public `HttpHandler` type, hence the cast) and returns the + * same array. The cache lives in this closure, so a fresh call to + * `buildHandlersFromSpec` (e.g. on a version swap) starts a fresh cache. + */ +function memoize(handlers: HttpHandler[]): HttpHandler[] { + const cache = new Map>(); + + for (const handler of handlers) { + const target = handler as unknown as { resolver: ResponseResolver }; + const original = target.resolver; + + target.resolver = async (info) => { + const key = await requestCacheKey(info.request); + let cached = cache.get(key); + if (!cached) { + cached = Promise.resolve(original(info)).then((result) => + result instanceof Response ? snapshotResponse(result) : undefined, + ); + cache.set(key, cached); + } + const snapshot = await cached; + return snapshot ? replayResponse(snapshot) : undefined; + }; + } + + return handlers; +} + /** * Generates MSW request handlers for every operation in a parsed OpenAPI * document via `@mswjs/source`'s `fromOpenApi`. Response bodies are seeded from * each operation's response JSON Schema, so they are schema-valid by * construction (example/schema fidelity, not request validation — per the ADR). + * Each handler is memoized (see `memoize`) so repeat calls are deterministic. * * Base-URL note (the spike's central question): our specs declare no `servers:` * block, so `@mswjs/source` falls back to a `/` base and emits handlers with @@ -26,10 +109,11 @@ export interface OpenApiSpec { * and this assumption must be revisited. * * @param spec - A parsed OpenAPI document (YAML already loaded to an object). - * @returns A promise resolving to the generated MSW handlers. + * @returns A promise resolving to the generated, memoized MSW handlers. */ -export function buildHandlersFromSpec( +export async function buildHandlersFromSpec( spec: OpenApiSpec, ): Promise { - return fromOpenApi(spec as Parameters[0]); + const handlers = await fromOpenApi(spec as Parameters[0]); + return memoize(handlers); } From 86a0c4e593801fb84945ee85f924627668c85405 Mon Sep 17 00:00:00 2001 From: Karina Gonzalez Date: Mon, 3 Aug 2026 20:15:29 -0500 Subject: [PATCH 08/10] [#1034-T8] Tests for determinism, consistency, filters, and errors Parametrizes opportunity handler tests (determinism, list-detail consistency, filter/sort correctness, 404/400 error shapes) across all three protocol versions via it.each, closing the version-coverage gap left after #1034-T5/T6. Test-only change; no production code touched. Refs #1034 --- .../lib/mock/opportunities-handlers.spec.ts | 421 +++++++++++------- 1 file changed, 251 insertions(+), 170 deletions(-) diff --git a/website/__tests__/lib/mock/opportunities-handlers.spec.ts b/website/__tests__/lib/mock/opportunities-handlers.spec.ts index 0cc31d6a8..75145c557 100644 --- a/website/__tests__/lib/mock/opportunities-handlers.spec.ts +++ b/website/__tests__/lib/mock/opportunities-handlers.spec.ts @@ -1,9 +1,13 @@ import { describe, it, expect } from "vitest"; import type { HttpHandler } from "msw"; import { buildOpportunityHandlers } from "@/lib/mock/opportunities/handlers"; -import { OPPORTUNITY_FIXTURES } from "@/lib/mock/opportunities/fixtures"; +import { + OPPORTUNITY_FIXTURES, + type Version, +} from "@/lib/mock/opportunities/fixtures"; const OPPORTUNITIES_URL = "http://localhost/common-grants/opportunities"; +const VERSIONS: Version[] = ["0.1.0", "0.2.0", "0.3.0"]; describe("buildOpportunityHandlers", () => { describe("GET /common-grants/opportunities (list)", () => { @@ -237,76 +241,82 @@ describe("buildOpportunityHandlers", () => { expect(detailBody.data.competitions).toBeDefined(); }); - it("returns 404 with the protocol Error shape for a well-formed but unknown UUID", async () => { - const handlers = buildOpportunityHandlers("0.3.0"); + it.each(VERSIONS)( + "returns 404 with the protocol Error shape for a well-formed but unknown UUID, for v%s", + async (version) => { + const handlers = buildOpportunityHandlers(version); - const detailHandler = handlers.find( - (h) => String(h.info.path) === "/common-grants/opportunities/:oppId", - ); - expect(detailHandler).toBeDefined(); + const detailHandler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities/:oppId", + ); + expect(detailHandler).toBeDefined(); - const UNKNOWN_ID = "00000000-0000-0000-0000-000000000000"; + const UNKNOWN_ID = "00000000-0000-0000-0000-000000000000"; - const result = await detailHandler!.run({ - request: new Request( - `http://localhost/common-grants/opportunities/${UNKNOWN_ID}`, - ), - requestId: "test-detail-unknown", - resolutionContext: { baseUrl: "http://localhost/" }, - }); + const result = await detailHandler!.run({ + request: new Request( + `http://localhost/common-grants/opportunities/${UNKNOWN_ID}`, + ), + requestId: "test-detail-unknown", + resolutionContext: { baseUrl: "http://localhost/" }, + }); - expect(result).not.toBeNull(); - expect(result!.response?.status).toBe(404); + expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(404); - const body = (await result!.response!.json()) as { - status: number; - message: string; - errors: unknown[]; - }; + const body = (await result!.response!.json()) as { + status: number; + message: string; + errors: unknown[]; + }; - expect(body.status).toBe(404); - expect(typeof body.message).toBe("string"); - expect(body.message.length).toBeGreaterThan(0); - expect(Array.isArray(body.errors)).toBe(true); - expect(body.errors.length).toBeGreaterThan(0); - }); + expect(body.status).toBe(404); + expect(typeof body.message).toBe("string"); + expect(body.message.length).toBeGreaterThan(0); + expect(Array.isArray(body.errors)).toBe(true); + expect(body.errors.length).toBeGreaterThan(0); + }, + ); - it("returns 400 with a field-level validation error for a malformed (non-UUID) oppId", async () => { - const handlers = buildOpportunityHandlers("0.3.0"); + it.each(VERSIONS)( + "returns 400 with a field-level validation error for a malformed (non-UUID) oppId, for v%s", + async (version) => { + const handlers = buildOpportunityHandlers(version); - const detailHandler = handlers.find( - (h) => String(h.info.path) === "/common-grants/opportunities/:oppId", - ); - expect(detailHandler).toBeDefined(); + const detailHandler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities/:oppId", + ); + expect(detailHandler).toBeDefined(); - const result = await detailHandler!.run({ - request: new Request( - "http://localhost/common-grants/opportunities/not-a-uuid", - ), - requestId: "test-detail-malformed", - resolutionContext: { baseUrl: "http://localhost/" }, - }); + const result = await detailHandler!.run({ + request: new Request( + "http://localhost/common-grants/opportunities/not-a-uuid", + ), + requestId: "test-detail-malformed", + resolutionContext: { baseUrl: "http://localhost/" }, + }); - expect(result).not.toBeNull(); - expect(result!.response?.status).toBe(400); + expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(400); - const body = (await result!.response!.json()) as { - status: number; - message: string; - errors: Array<{ field: string; message: string }>; - }; + const body = (await result!.response!.json()) as { + status: number; + message: string; + errors: Array<{ field: string; message: string }>; + }; - expect(body.status).toBe(400); - expect(typeof body.message).toBe("string"); - expect(body.message.length).toBeGreaterThan(0); - expect(Array.isArray(body.errors)).toBe(true); - expect(body.errors.length).toBeGreaterThan(0); + expect(body.status).toBe(400); + expect(typeof body.message).toBe("string"); + expect(body.message.length).toBeGreaterThan(0); + expect(Array.isArray(body.errors)).toBe(true); + expect(body.errors.length).toBeGreaterThan(0); - const oppIdError = body.errors.find((error) => error.field === "oppId"); - expect(oppIdError).toBeDefined(); - expect(typeof oppIdError!.message).toBe("string"); - expect(oppIdError!.message.length).toBeGreaterThan(0); - }); + const oppIdError = body.errors.find((error) => error.field === "oppId"); + expect(oppIdError).toBeDefined(); + expect(typeof oppIdError!.message).toBe("string"); + expect(oppIdError!.message.length).toBeGreaterThan(0); + }, + ); it("omits competitions and acceptedApplicantTypes from a v0.1 detail response", async () => { const handlers = buildOpportunityHandlers("0.1.0"); @@ -340,60 +350,122 @@ describe("buildOpportunityHandlers", () => { }); describe("determinism", () => { - it("returns identical bodies across two calls to the list endpoint", async () => { - const handlers = buildOpportunityHandlers("0.3.0"); + it.each(VERSIONS)( + "returns identical bodies across two calls to the list endpoint for v%s", + async (version) => { + const handlers = buildOpportunityHandlers(version); - const listHandler = handlers.find( - (h) => String(h.info.path) === "/common-grants/opportunities", - ); - expect(listHandler).toBeDefined(); + const listHandler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities", + ); + expect(listHandler).toBeDefined(); - const resultA = await listHandler!.run({ - request: new Request(OPPORTUNITIES_URL), - requestId: "test-determinism-list-a", - resolutionContext: { baseUrl: "http://localhost/" }, - }); - const resultB = await listHandler!.run({ - request: new Request(OPPORTUNITIES_URL), - requestId: "test-determinism-list-b", - resolutionContext: { baseUrl: "http://localhost/" }, - }); + const resultA = await listHandler!.run({ + request: new Request(OPPORTUNITIES_URL), + requestId: "test-determinism-list-a", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + const resultB = await listHandler!.run({ + request: new Request(OPPORTUNITIES_URL), + requestId: "test-determinism-list-b", + resolutionContext: { baseUrl: "http://localhost/" }, + }); - const bodyA = await resultA!.response!.json(); - const bodyB = await resultB!.response!.json(); + const bodyA = await resultA!.response!.json(); + const bodyB = await resultB!.response!.json(); - expect(bodyA).toEqual(bodyB); - }); + expect(bodyA).toEqual(bodyB); + }, + ); - it("returns identical bodies across two calls to the detail endpoint for the same oppId", async () => { - const STEM_ID = "573525f2-8e15-4405-83fb-e6523511d893"; - const handlers = buildOpportunityHandlers("0.3.0"); + it.each(VERSIONS)( + "returns identical bodies across two calls to the detail endpoint for the same oppId for v%s", + async (version) => { + const STEM_ID = "573525f2-8e15-4405-83fb-e6523511d893"; + const handlers = buildOpportunityHandlers(version); - const detailHandler = handlers.find( - (h) => String(h.info.path) === "/common-grants/opportunities/:oppId", - ); - expect(detailHandler).toBeDefined(); + const detailHandler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities/:oppId", + ); + expect(detailHandler).toBeDefined(); - const resultA = await detailHandler!.run({ - request: new Request( - `http://localhost/common-grants/opportunities/${STEM_ID}`, - ), - requestId: "test-determinism-detail-a", - resolutionContext: { baseUrl: "http://localhost/" }, - }); - const resultB = await detailHandler!.run({ - request: new Request( - `http://localhost/common-grants/opportunities/${STEM_ID}`, - ), - requestId: "test-determinism-detail-b", - resolutionContext: { baseUrl: "http://localhost/" }, - }); + const resultA = await detailHandler!.run({ + request: new Request( + `http://localhost/common-grants/opportunities/${STEM_ID}`, + ), + requestId: "test-determinism-detail-a", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + const resultB = await detailHandler!.run({ + request: new Request( + `http://localhost/common-grants/opportunities/${STEM_ID}`, + ), + requestId: "test-determinism-detail-b", + resolutionContext: { baseUrl: "http://localhost/" }, + }); - const bodyA = await resultA!.response!.json(); - const bodyB = await resultB!.response!.json(); + const bodyA = await resultA!.response!.json(); + const bodyB = await resultB!.response!.json(); - expect(bodyA).toEqual(bodyB); - }); + expect(bodyA).toEqual(bodyB); + }, + ); + + it.each(VERSIONS)( + "list→detail: every field on the list item deep-equals the corresponding detail field, and the detail echoes the requested id, for v%s", + async (version) => { + const STEM_ID = "573525f2-8e15-4405-83fb-e6523511d893"; + const handlers = buildOpportunityHandlers(version); + + const listHandler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities", + ); + expect(listHandler).toBeDefined(); + + const detailHandler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities/:oppId", + ); + expect(detailHandler).toBeDefined(); + + const listResult = await listHandler!.run({ + request: new Request(OPPORTUNITIES_URL), + requestId: "test-list-for-detail-consistency", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + const listBody = (await listResult!.response!.json()) as { + items: Array & { id: string }>; + }; + const listItem = listBody.items.find((item) => item.id === STEM_ID); + expect(listItem).toBeDefined(); + + const detailResult = await detailHandler!.run({ + request: new Request( + `http://localhost/common-grants/opportunities/${STEM_ID}`, + ), + requestId: "test-detail-for-consistency", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(detailResult).not.toBeNull(); + expect(detailResult!.response?.status).toBe(200); + + const detailBody = (await detailResult!.response!.json()) as { + status: number; + message: string; + data: Record & { id: string }; + }; + + expect(detailBody.status).toBe(200); + // id echo: the returned record's id matches the requested :oppId. + expect(detailBody.data.id).toBe(STEM_ID); + // consistency: every field the list projection carries matches the + // detail record's value for that same field (same underlying record). + for (const [field, value] of Object.entries(listItem!)) { + expect(detailBody.data[field]).toEqual(value); + } + }, + ); }); describe("POST /common-grants/opportunities/search", () => { @@ -434,90 +506,99 @@ describe("buildOpportunityHandlers", () => { }; } - it("filters to a proper subset matching the status filter, relative to the unfiltered result set", async () => { - const handlers = buildOpportunityHandlers("0.3.0"); + it.each(VERSIONS)( + "filters to a proper subset matching the status filter, relative to the unfiltered result set, for v%s", + async (version) => { + const handlers = buildOpportunityHandlers(version); - const unfilteredBody = await runSearch(handlers, {}); - expect(Array.isArray(unfilteredBody.items)).toBe(true); - expect(unfilteredBody.items.length).toBe(OPPORTUNITY_FIXTURES.length); + const unfilteredBody = await runSearch(handlers, {}); + expect(Array.isArray(unfilteredBody.items)).toBe(true); + expect(unfilteredBody.items.length).toBe(OPPORTUNITY_FIXTURES.length); - const inBody = await runSearch(handlers, { - filters: { status: { operator: "in", value: ["open"] } }, - }); + const inBody = await runSearch(handlers, { + filters: { status: { operator: "in", value: ["open"] } }, + }); - expect(inBody.items.length).toBeLessThan(OPPORTUNITY_FIXTURES.length); - for (const item of inBody.items) { - expect(item.status.value).toBe("open"); - } + expect(inBody.items.length).toBeLessThan(OPPORTUNITY_FIXTURES.length); + for (const item of inBody.items) { + expect(item.status.value).toBe("open"); + } - const notInBody = await runSearch(handlers, { - filters: { status: { operator: "notIn", value: ["open"] } }, - }); + const notInBody = await runSearch(handlers, { + filters: { status: { operator: "notIn", value: ["open"] } }, + }); - for (const item of notInBody.items) { - expect(item.status.value).not.toBe("open"); - } - }); + for (const item of notInBody.items) { + expect(item.status.value).not.toBe("open"); + } + }, + ); - it("reorders items in reverse when sortOrder flips from asc to desc, for the same sortBy field", async () => { - const handlers = buildOpportunityHandlers("0.3.0"); + it.each(VERSIONS)( + "reorders items in reverse when sortOrder flips from asc to desc, for the same sortBy field, for v%s", + async (version) => { + const handlers = buildOpportunityHandlers(version); - // `funding.maxAwardAmount` is present (and distinct) on every fixture - // record, so this sort key has no ties/undefined-handling ambiguity. - const ascBody = await runSearch(handlers, { - sorting: { sortBy: "funding.maxAwardAmount", sortOrder: "asc" }, - }); - const descBody = await runSearch(handlers, { - sorting: { sortBy: "funding.maxAwardAmount", sortOrder: "desc" }, - }); + // `funding.maxAwardAmount` is present (and distinct) on every fixture + // record, so this sort key has no ties/undefined-handling ambiguity. + const ascBody = await runSearch(handlers, { + sorting: { sortBy: "funding.maxAwardAmount", sortOrder: "asc" }, + }); + const descBody = await runSearch(handlers, { + sorting: { sortBy: "funding.maxAwardAmount", sortOrder: "desc" }, + }); - const ascIds = ascBody.items.map((item) => item.id); - const descIds = descBody.items.map((item) => item.id); + const ascIds = ascBody.items.map((item) => item.id); + const descIds = descBody.items.map((item) => item.id); - // Guard against a no-op sort implementation trivially "passing" by both - // orderings being identical (e.g. both left in fixture/insertion order). - expect(ascIds).not.toEqual(descIds); - expect(ascIds).toEqual([...descIds].reverse()); - }); + // Guard against a no-op sort implementation trivially "passing" by both + // orderings being identical (e.g. both left in fixture/insertion order). + expect(ascIds).not.toEqual(descIds); + expect(ascIds).toEqual([...descIds].reverse()); + }, + ); - it("returns 400 with the protocol Error shape for an unknown sortBy value", async () => { - const handlers = buildOpportunityHandlers("0.3.0"); + it.each(VERSIONS)( + "returns 400 with the protocol Error shape for an unknown sortBy value, for v%s", + async (version) => { + const handlers = buildOpportunityHandlers(version); - const handler = handlers.find( - (h) => String(h.info.path) === "/common-grants/opportunities/search", - ); - expect(handler).toBeDefined(); + const handler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities/search", + ); + expect(handler).toBeDefined(); - const result = await handler!.run({ - request: new Request(SEARCH_URL, { - method: "POST", - body: JSON.stringify({ - sorting: { sortBy: "not_a_real_sort_field" }, + const result = await handler!.run({ + request: new Request(SEARCH_URL, { + method: "POST", + body: JSON.stringify({ + sorting: { sortBy: "not_a_real_sort_field" }, + }), + headers: { "Content-Type": "application/json" }, }), - headers: { "Content-Type": "application/json" }, - }), - requestId: "test-search-invalid-sortby", - resolutionContext: { baseUrl: "http://localhost/" }, - }); + requestId: "test-search-invalid-sortby", + resolutionContext: { baseUrl: "http://localhost/" }, + }); - expect(result).not.toBeNull(); - expect(result!.response?.status).toBe(400); + expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(400); - const body = (await result!.response!.json()) as { - status: number; - message: string; - errors: Array<{ field: string; message: string }>; - }; + const body = (await result!.response!.json()) as { + status: number; + message: string; + errors: Array<{ field: string; message: string }>; + }; - expect(body.status).toBe(400); - expect(typeof body.message).toBe("string"); - expect(body.message.length).toBeGreaterThan(0); - expect(Array.isArray(body.errors)).toBe(true); - expect(body.errors.length).toBeGreaterThan(0); + expect(body.status).toBe(400); + expect(typeof body.message).toBe("string"); + expect(body.message.length).toBeGreaterThan(0); + expect(Array.isArray(body.errors)).toBe(true); + expect(body.errors.length).toBeGreaterThan(0); - const sortByError = body.errors.find((error) => "field" in error); - expect(sortByError).toBeDefined(); - }); + const sortByError = body.errors.find((error) => "field" in error); + expect(sortByError).toBeDefined(); + }, + ); it("echoes customFilters in filterInfo.filters without letting them narrow results beyond the applied status filter", async () => { const handlers = buildOpportunityHandlers("0.3.0"); From dbcaa4cf851bf0dc0da0b9dfe2141bdb6812922e Mon Sep 17 00:00:00 2001 From: Karina Gonzalez Date: Mon, 3 Aug 2026 21:33:17 -0500 Subject: [PATCH 09/10] [#1034] Address PR review: spec-example fixture, input validation, docs Follow-ups from the review of the mock playground implementation. Swagger UI pre-fills the `oppId` box from the specs' `Types.uuid` example (30a12e5e-...), so the first Execute a visitor ran -- field untouched -- answered 404, and the rendered "Example Value" pane described a record the mock had never heard of. Add a fixture record reproducing that published example verbatim, sorted first by lastModifiedAt so the list -> detail round-trip is the obvious next click. Validate filter bound *values*, not just operators: a bound that isn't a date, or isn't a Money object, now returns 400 instead of being silently dropped -- dropping it answered a filtered request with unfiltered results. Report unapplied customFilters through filterInfo.errors, the spec's channel for non-fatal filtering errors. Reject page/pageSize below the spec's `minimum: 1` rather than clamping, and drop the MAX_PAGE_SIZE cap -- 100 is the declared default, not a maximum. Derive totalPages the same way on list and search, so an empty result set reports zero pages on both. Break sort ties on `id` so `desc` is the exact reverse of `asc`; without it, records sharing a sort value kept their incoming order under a stable sort. Surface the reserved error inputs on the playground page itself, built from the exported id constants so the docs can't drift from the handlers. Also: evict a failed resolver's cache key instead of replaying the rejection forever; derive Version from SUPPORTED_VERSIONS and guard the dropdown value so an unsupported version degrades visibly rather than being cast through; move personal AI-tooling ignores out of the shared .gitignore. Files changed: - website/src/lib/mock/opportunities/fixtures.ts - website/src/lib/mock/opportunities/handlers.ts - website/src/lib/mock/spec-handlers.ts - website/src/components/MockPlayground.tsx - website/src/pages/protocol/mock-playground.astro - website/__tests__/lib/mock/*.spec.ts - .gitignore --- .gitignore | 5 - .../lib/mock/opportunities-fixtures.spec.ts | 49 +++ .../lib/mock/opportunities-handlers.spec.ts | 249 +++++++++++++-- .../__tests__/lib/mock/spec-handlers.spec.ts | 61 +++- website/src/components/MockPlayground.tsx | 16 +- .../src/lib/mock/opportunities/fixtures.ts | 180 ++++++++++- .../src/lib/mock/opportunities/handlers.ts | 292 ++++++++++++------ website/src/lib/mock/spec-handlers.ts | 17 +- .../src/pages/protocol/mock-playground.astro | 114 +++++++ 9 files changed, 845 insertions(+), 138 deletions(-) diff --git a/.gitignore b/.gitignore index ba1335175..5c01ef660 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,6 @@ node_modules/ # AI .cursor -.claude -CLAUDE.md -PLAN.md # Environment variables .env @@ -19,5 +16,3 @@ PLAN.md # Local tooling configuration .tool-versions - -mock-playground-adr.md diff --git a/website/__tests__/lib/mock/opportunities-fixtures.spec.ts b/website/__tests__/lib/mock/opportunities-fixtures.spec.ts index 5a22b0390..2fad0d469 100644 --- a/website/__tests__/lib/mock/opportunities-fixtures.spec.ts +++ b/website/__tests__/lib/mock/opportunities-fixtures.spec.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from "vitest"; import { + CANONICAL_OPPORTUNITY_ID, OPPORTUNITY_FIXTURES, + RESERVED_MISSING_OPPORTUNITY_ID, + SUPPORTED_VERSIONS, + isSupportedVersion, shapeOpportunityForVersion, getById, allForVersion, @@ -15,6 +19,41 @@ describe("OPPORTUNITY_FIXTURES", () => { expect(OPPORTUNITY_FIXTURES.length).toBeLessThanOrEqual(12); }); + // Swagger UI pre-fills the `oppId` box with the specs' `Types.uuid` example. + // If no fixture carries that id, the first Execute a visitor runs — with the + // field untouched — answers 404 instead of the documented record. + it("carries the id the specs publish as their uuid example", () => { + const canonical = getById(CANONICAL_OPPORTUNITY_ID); + + expect(canonical).toBeDefined(); + expect(canonical!.title).toBe("Small business grant program"); + expect(canonical!.description).toBe( + "This program provides funding to small businesses to help them grow and create jobs", + ); + expect(canonical!.status).toEqual({ + value: "open", + description: "The opportunity is currently accepting applications", + }); + expect(canonical!.funding?.totalAmountAvailable).toEqual({ + amount: "1000000.00", + currency: "USD", + }); + }); + + it("sorts the documented example first under the list endpoint's default ordering", () => { + const newestFirst = [...OPPORTUNITY_FIXTURES].sort( + (a, b) => + new Date(b.lastModifiedAt).getTime() - + new Date(a.lastModifiedAt).getTime(), + ); + + expect(newestFirst[0].id).toBe(CANONICAL_OPPORTUNITY_ID); + }); + + it("leaves the reserved 404 id absent from the fixture set", () => { + expect(getById(RESERVED_MISSING_OPPORTUNITY_ID)).toBeUndefined(); + }); + it("gives every record the OpportunityBase-emitted shape", () => { for (const opp of OPPORTUNITY_FIXTURES) { expect(typeof opp.id).toBe("string"); @@ -115,3 +154,13 @@ describe("getById", () => { expect(getById("does-not-exist")).toBeUndefined(); }); }); + +describe("isSupportedVersion", () => { + it.each(SUPPORTED_VERSIONS)("accepts the supported version %s", (version) => { + expect(isSupportedVersion(version)).toBe(true); + }); + + it("rejects a version the fixture cannot shape", () => { + expect(isSupportedVersion("0.4.0")).toBe(false); + }); +}); diff --git a/website/__tests__/lib/mock/opportunities-handlers.spec.ts b/website/__tests__/lib/mock/opportunities-handlers.spec.ts index 75145c557..67afeb514 100644 --- a/website/__tests__/lib/mock/opportunities-handlers.spec.ts +++ b/website/__tests__/lib/mock/opportunities-handlers.spec.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import type { HttpHandler } from "msw"; import { buildOpportunityHandlers } from "@/lib/mock/opportunities/handlers"; import { + CANONICAL_OPPORTUNITY_ID, OPPORTUNITY_FIXTURES, type Version, } from "@/lib/mock/opportunities/fixtures"; @@ -57,7 +58,7 @@ describe("buildOpportunityHandlers", () => { expect(body.paginationInfo).toEqual({ page: 1, pageSize: 100, - totalItems: 10, + totalItems: OPPORTUNITY_FIXTURES.length, totalPages: 1, }); }); @@ -95,12 +96,12 @@ describe("buildOpportunityHandlers", () => { expect(body.paginationInfo).toEqual({ page: 999, pageSize: 100, - totalItems: 10, + totalItems: OPPORTUNITY_FIXTURES.length, totalPages: 1, }); }); - it("clamps pageSize to 100 when a larger value is requested", async () => { + it("honors a pageSize above the default, since the spec declares no maximum", async () => { const handlers = buildOpportunityHandlers("0.3.0"); const handler = handlers.find( @@ -110,7 +111,7 @@ describe("buildOpportunityHandlers", () => { const result = await handler!.run({ request: new Request(`${OPPORTUNITIES_URL}?pageSize=9999`), - requestId: "test-list-pagesize-too-large", + requestId: "test-list-pagesize-above-default", resolutionContext: { baseUrl: "http://localhost/" }, }); @@ -118,15 +119,20 @@ describe("buildOpportunityHandlers", () => { expect(result!.response?.status).toBe(200); const body = (await result!.response!.json()) as { - paginationInfo: { pageSize: number }; + paginationInfo: { pageSize: number; totalPages: number }; }; - expect(body.paginationInfo.pageSize).toBe(100); + expect(body.paginationInfo.pageSize).toBe(9999); + expect(body.paginationInfo.totalPages).toBe(1); }); - it.each([0, -5])( - "clamps pageSize to at least 1 when pageSize=%i is requested", - async (pageSizeValue) => { + it.each([ + ["pageSize", "0"], + ["pageSize", "-5"], + ["page", "0"], + ])( + "returns 400 with the protocol Error shape when %s=%s falls below the spec minimum", + async (param, value) => { const handlers = buildOpportunityHandlers("0.3.0"); const handler = handlers.find( @@ -135,25 +141,29 @@ describe("buildOpportunityHandlers", () => { expect(handler).toBeDefined(); const result = await handler!.run({ - request: new Request( - `${OPPORTUNITIES_URL}?pageSize=${pageSizeValue}`, - ), - requestId: `test-list-pagesize-${pageSizeValue}`, + request: new Request(`${OPPORTUNITIES_URL}?${param}=${value}`), + requestId: `test-list-${param}-${value}`, resolutionContext: { baseUrl: "http://localhost/" }, }); expect(result).not.toBeNull(); - expect(result!.response?.status).toBe(200); + expect(result!.response?.status).toBe(400); const body = (await result!.response!.json()) as { - paginationInfo: { pageSize: number }; + status: number; + message: string; + errors: Array<{ field: string; message: string }>; }; - expect(body.paginationInfo.pageSize).toBeGreaterThanOrEqual(1); + expect(body.status).toBe(400); + expect(typeof body.message).toBe("string"); + expect(body.errors).toEqual([ + { field: param, message: "Must be at least 1" }, + ]); }, ); - it("falls back to the default pageSize when a non-numeric value is requested", async () => { + it("returns 400 when a pagination param is present but not an integer", async () => { const handlers = buildOpportunityHandlers("0.3.0"); const handler = handlers.find( @@ -168,12 +178,38 @@ describe("buildOpportunityHandlers", () => { }); expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(400); + + const body = (await result!.response!.json()) as { + errors: Array<{ field: string; message: string }>; + }; + + expect(body.errors).toHaveLength(1); + expect(body.errors[0].field).toBe("pageSize"); + expect(body.errors[0].message).toContain("integer"); + }); + + it("applies both spec defaults when the pagination params are omitted entirely", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const handler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities", + ); + expect(handler).toBeDefined(); + + const result = await handler!.run({ + request: new Request(OPPORTUNITIES_URL), + requestId: "test-list-pagination-defaults", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + expect(result!.response?.status).toBe(200); const body = (await result!.response!.json()) as { - paginationInfo: { pageSize: number }; + paginationInfo: { page: number; pageSize: number }; }; + expect(body.paginationInfo.page).toBe(1); expect(body.paginationInfo.pageSize).toBe(100); }); }); @@ -185,6 +221,39 @@ describe("buildOpportunityHandlers", () => { // two shapes happen to coincide. const STEM_ID = "573525f2-8e15-4405-83fb-e6523511d893"; + // Swagger UI pre-fills the `oppId` box with the specs' `Types.uuid` + // example, so this is the request a visitor sends when they click "Try it + // out" and then "Execute" without touching anything. It must not 404. + it.each(VERSIONS)( + "answers 200 for the id Swagger UI pre-fills, for v%s", + async (version) => { + const handlers = buildOpportunityHandlers(version); + + const detailHandler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities/:oppId", + ); + expect(detailHandler).toBeDefined(); + + const result = await detailHandler!.run({ + request: new Request( + `http://localhost/common-grants/opportunities/${CANONICAL_OPPORTUNITY_ID}`, + ), + requestId: `test-detail-canonical-${version}`, + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(result!.response?.status).toBe(200); + + const body = (await result!.response!.json()) as { + data: { id: string; title: string }; + }; + + expect(body.data.id).toBe(CANONICAL_OPPORTUNITY_ID); + // The values the spec's own "Example Value" pane advertises. + expect(body.data.title).toBe("Small business grant program"); + }, + ); + it("echoes the requested oppId and matches the list item's shared fields, while adding the detail-only competitions field", async () => { const handlers = buildOpportunityHandlers("0.3.0"); @@ -665,9 +734,9 @@ describe("buildOpportunityHandlers", () => { const handlers = buildOpportunityHandlers("0.3.0"); // Bound chosen against the real fixture data (see fixtures.ts): splits - // the 10 records' `funding.maxAwardAmount` values (25k, 30k, 50k, 60k, - // 75k, 100k, 120k, 250k, 500k, 2M) into 6 inside [50000, 250000] and 4 - // outside it, so neither half is degenerate. + // the records' `funding.maxAwardAmount` values (25k, 30k, 50k, 50k, 60k, + // 75k, 100k, 120k, 250k, 500k, 2M) across [50000, 250000], so neither + // half is degenerate. const bound = { min: { amount: "50000.00", currency: "USD" }, max: { amount: "250000.00", currency: "USD" }, @@ -733,10 +802,9 @@ describe("buildOpportunityHandlers", () => { page: 1, pageSize: 100, totalItems: 0, - // The handler falls back to `totalPages: 1` (via `|| 1`) rather than - // 0 when there are no matching items, even though - // Math.ceil(0 / pageSize) is 0. - totalPages: 1, + // An empty result set reports zero pages, matching how the list + // endpoint derives `totalPages` (both go through `paginationInfo()`). + totalPages: 0, }); expect(body.sortInfo).toBeTypeOf("object"); @@ -781,7 +849,7 @@ describe("buildOpportunityHandlers", () => { // The currency mismatch excludes the record unconditionally, before the // operator is even considered, so `outside` also returns zero items - // rather than inverting to all 10. + // rather than inverting to the whole fixture set. expect(outsideBody.items).toHaveLength(0); }); @@ -896,10 +964,10 @@ describe("buildOpportunityHandlers", () => { it("filters using only the given bound when a maxAwardAmountRange filter omits max", async () => { const handlers = buildOpportunityHandlers("0.3.0"); - // Bound chosen against the real fixture data (see fixtures.ts): the 10 - // records' `funding.maxAwardAmount` values (25k, 30k, 50k, 60k, 75k, - // 100k, 120k, 250k, 500k, 2M) split into 8 at-or-above 50000 and 2 - // below it, so this is a proper, non-degenerate subset. + // Bound chosen against the real fixture data (see fixtures.ts): the + // records' `funding.maxAwardAmount` values (25k, 30k, 50k, 50k, 60k, + // 75k, 100k, 120k, 250k, 500k, 2M) split into those at-or-above 50000 + // and two below it, so this is a proper, non-degenerate subset. const body = await runSearch(handlers, { filters: { maxAwardAmountRange: { @@ -930,5 +998,126 @@ describe("buildOpportunityHandlers", () => { ).toBeGreaterThanOrEqual(50000); } }); + + // A malformed bound used to be dropped silently, which answered a + // filtered request with the entire unfiltered set — the response + // contradicting the filter the caller sent. + it.each([ + [ + "closeDateRange", + { operator: "between", value: { min: "not-a-date" } }, + "filters.closeDateRange.value.min", + ], + [ + "maxAwardAmountRange", + { + operator: "between", + value: { max: { amount: "lots", currency: "USD" } }, + }, + "filters.maxAwardAmountRange.value.max", + ], + [ + "totalFundingAvailableRange", + { operator: "between", value: { min: "50000.00" } }, + "filters.totalFundingAvailableRange.value.min", + ], + ])( + "returns 400 rather than silently dropping a malformed %s bound", + async (field, filter, expectedErrorField) => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const handler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities/search", + ); + expect(handler).toBeDefined(); + + const result = await handler!.run({ + request: new Request(SEARCH_URL, { + method: "POST", + body: JSON.stringify({ filters: { [field]: filter } }), + headers: { "Content-Type": "application/json" }, + }), + requestId: `test-search-bad-bound-${field}`, + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(result!.response?.status).toBe(400); + + const body = (await result!.response!.json()) as { + status: number; + message: string; + errors: Array<{ field: string; message: string }>; + }; + + expect(body.status).toBe(400); + expect(body.errors.map((error) => error.field)).toContain( + expectedErrorField, + ); + }, + ); + + it("reports unapplied customFilters via filterInfo.errors", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const body = await runSearch(handlers, { + filters: { + customFilters: { + agency: { operator: "in", value: ["NSF"] }, + region: { operator: "in", value: ["Northeast"] }, + }, + }, + }); + + // Echoed, but explicitly not applied — so the echo can't be read as + // "these narrowed the results". + expect(body.items).toHaveLength(OPPORTUNITY_FIXTURES.length); + expect(body.filterInfo.filters.customFilters).toEqual({ + agency: { operator: "in", value: ["NSF"] }, + region: { operator: "in", value: ["Northeast"] }, + }); + expect(body.filterInfo.errors).toHaveLength(1); + expect(body.filterInfo.errors![0]).toContain("agency"); + expect(body.filterInfo.errors![0]).toContain("region"); + }); + + it("omits filterInfo.errors when every filter sent was applied", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const body = await runSearch(handlers, { + filters: { status: { operator: "in", value: ["open"] } }, + }); + + expect(body.filterInfo.errors).toBeUndefined(); + }); + + it("returns 400 for a body pagination value below the spec minimum", async () => { + const handlers = buildOpportunityHandlers("0.3.0"); + + const handler = handlers.find( + (h) => String(h.info.path) === "/common-grants/opportunities/search", + ); + expect(handler).toBeDefined(); + + const result = await handler!.run({ + request: new Request(SEARCH_URL, { + method: "POST", + body: JSON.stringify({ pagination: { page: 0, pageSize: -1 } }), + headers: { "Content-Type": "application/json" }, + }), + requestId: "test-search-bad-pagination", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(result!.response?.status).toBe(400); + + const body = (await result!.response!.json()) as { + errors: Array<{ field: string; message: string }>; + }; + + expect(body.errors.map((error) => error.field)).toEqual([ + "pagination.page", + "pagination.pageSize", + ]); + }); }); }); diff --git a/website/__tests__/lib/mock/spec-handlers.spec.ts b/website/__tests__/lib/mock/spec-handlers.spec.ts index 64af59532..d3edfed5e 100644 --- a/website/__tests__/lib/mock/spec-handlers.spec.ts +++ b/website/__tests__/lib/mock/spec-handlers.spec.ts @@ -2,8 +2,8 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "fs"; import { resolve } from "path"; import yaml from "js-yaml"; -import type { HttpHandler } from "msw"; -import { buildHandlersFromSpec } from "@/lib/mock/spec-handlers"; +import { http, HttpResponse, type HttpHandler } from "msw"; +import { buildHandlersFromSpec, memoize } from "@/lib/mock/spec-handlers"; import { buildOpportunityHandlers } from "@/lib/mock/opportunities/handlers"; import { OPPORTUNITY_FIXTURES } from "@/lib/mock/opportunities/fixtures"; @@ -133,3 +133,60 @@ describe("buildHandlersFromSpec", () => { expect(specHandlers).not.toContain(winner); }); }); + +describe("memoize", () => { + const resolutionContext = { baseUrl: "http://localhost/" }; + + it("evicts a failed request instead of replaying the failure forever", async () => { + let calls = 0; + const [handler] = memoize([ + http.get("/flaky", () => { + calls += 1; + if (calls === 1) throw new Error("resolver blew up"); + return HttpResponse.json({ calls }); + }), + ]); + + // The failure propagates, as it would without memoization... + await expect( + handler.run({ + request: new Request("http://localhost/flaky"), + requestId: "test-memoize-failure-1", + resolutionContext, + }), + ).rejects.toThrow("resolver blew up"); + + // ...but it isn't cached, so the same request can still succeed later. + const retry = await handler.run({ + request: new Request("http://localhost/flaky"), + requestId: "test-memoize-failure-2", + resolutionContext, + }); + + expect(retry!.response?.status).toBe(200); + expect(await retry!.response!.json()).toEqual({ calls: 2 }); + }); + + it("keys the cache on the request body, not just the URL", async () => { + let calls = 0; + const [handler] = memoize([ + http.post("/echo", () => HttpResponse.json({ calls: ++calls })), + ]); + + const post = (body: string, requestId: string) => + handler.run({ + request: new Request("http://localhost/echo", { method: "POST", body }), + requestId, + resolutionContext, + }); + + const first = await post('{"a":1}', "test-memoize-body-1"); + const repeat = await post('{"a":1}', "test-memoize-body-2"); + const different = await post('{"a":2}', "test-memoize-body-3"); + + expect(await repeat!.response!.json()).toEqual( + await first!.response!.json(), + ); + expect(await different!.response!.json()).toEqual({ calls: 2 }); + }); +}); diff --git a/website/src/components/MockPlayground.tsx b/website/src/components/MockPlayground.tsx index 1a5e6ea29..ebfabcd90 100644 --- a/website/src/components/MockPlayground.tsx +++ b/website/src/components/MockPlayground.tsx @@ -7,7 +7,7 @@ import { type OpenApiSpec, } from "@/lib/mock/spec-handlers"; import { buildOpportunityHandlers } from "@/lib/mock/opportunities/handlers"; -import type { Version } from "@/lib/mock/opportunities/fixtures"; +import { isSupportedVersion } from "@/lib/mock/opportunities/fixtures"; /** * Throwaway MSW playground (#1034-T3): starts a Mock Service Worker and answers @@ -70,14 +70,24 @@ export default function MockPlayground() { schema: yaml.CORE_SCHEMA, }) as OpenApiSpec; const specHandlers = await buildHandlersFromSpec(spec); - const opportunityHandlers = buildOpportunityHandlers(version as Version); + // A version in the dropdown that the fixture doesn't know how to shape + // would otherwise be cast through silently and served with the newest + // version's shape. Fall back to the generated handlers and say so, rather + // than quietly regressing to randomly-generated opportunity data. + const opportunityHandlers = isSupportedVersion(version) + ? buildOpportunityHandlers(version) + : []; // Skip if unmounted or superseded by a newer version toggle. if (cancelledRef.current || token !== versionTokenRef.current) return; // Opportunity handlers first: MSW resolves first-match-wins, so they // override the generated `fromOpenApi` handlers for the same paths. worker.resetHandlers(...opportunityHandlers, ...specHandlers); - setError(null); + setError( + isSupportedVersion(version) + ? null + : `No opportunity fixture for v${version} — opportunity endpoints fall back to generated (non-deterministic) responses.`, + ); } catch (err) { if (cancelledRef.current || token !== versionTokenRef.current) return; console.warn( diff --git a/website/src/lib/mock/opportunities/fixtures.ts b/website/src/lib/mock/opportunities/fixtures.ts index ba26c08c3..aa0a091ba 100644 --- a/website/src/lib/mock/opportunities/fixtures.ts +++ b/website/src/lib/mock/opportunities/fixtures.ts @@ -5,8 +5,10 @@ * (#1034-T5/T6) draw from, so the list, detail, and search endpoints stay * mutually consistent and return the same body on every call. Values are * assembled from the TypeSpec `@example` decorators under - * `lib/core/lib/core/models/opportunity/` (semantic, not faker noise), and the - * first three records are carried over verbatim (id + title) from + * `lib/core/lib/core/models/opportunity/` (semantic, not faker noise). The + * first record reproduces the spec's own published example (see + * `CANONICAL_OPPORTUNITY_ID`) so the docs pane and the live response agree, and + * the next three are carried over verbatim (id + title) from * `lib/ts-sdk/examples/mock-api-server.ts` so cross-repo examples stay * recognizable. * @@ -18,8 +20,21 @@ * for v0.1 and from any `list`-variant projection. */ -/** Supported protocol versions, matching the OpenApiDocs dropdown. */ -export type Version = "0.1.0" | "0.2.0" | "0.3.0"; +/** + * Protocol versions this fixture knows how to shape, matching the OpenApiDocs + * dropdown. Adding a version to that dropdown without adding it here is caught + * by `isSupportedVersion`, which keeps the playground from silently falling + * back to randomly-generated opportunity data. + */ +export const SUPPORTED_VERSIONS = ["0.1.0", "0.2.0", "0.3.0"] as const; + +/** A protocol version the opportunity handlers can shape responses for. */ +export type Version = (typeof SUPPORTED_VERSIONS)[number]; + +/** Narrows an arbitrary version string to a `Version`. */ +export function isSupportedVersion(value: string): value is Version { + return (SUPPORTED_VERSIONS as readonly string[]).includes(value); +} /** Which endpoint shape to project: the list returns `OpportunityBase`, the * single-item read returns `OpportunityDetails` (v0.2+). */ @@ -38,7 +53,7 @@ export interface OppStatus { description?: string; } -/** A single-date event (the only `Event` variant the fixture uses). */ +/** A single-date event (mirrors the `singleDate` variant of `Fields.Event`). */ export interface SingleDateEvent { name: string; eventType: "singleDate"; @@ -47,11 +62,41 @@ export interface SingleDateEvent { description?: string; } -/** Key dates for an opportunity (mirrors `Models.OppTimeline`). */ +/** A date-range event (mirrors the `dateRange` variant of `Fields.Event`). */ +export interface DateRangeEvent { + name: string; + eventType: "dateRange"; + startDate: string; + startTime?: string; + endDate: string; + endTime?: string; + description?: string; +} + +/** A free-form event (mirrors the `other` variant of `Fields.Event`). */ +export interface OtherEvent { + name: string; + eventType: "other"; + details?: string; + description?: string; +} + +/** + * Any `Fields.Event` variant. Named `TimelineEvent` rather than `Event` so it + * doesn't shadow the DOM global in files that use both. + */ +export type TimelineEvent = SingleDateEvent | DateRangeEvent | OtherEvent; + +/** + * Key dates for an opportunity (mirrors `Models.OppTimeline`). `postDate` and + * `closeDate` are narrowed to `SingleDateEvent` — the protocol allows any + * `Event` variant, but the close-date filter and sort read `.date`, so the + * fixture only ever uses single dates for those two. + */ export interface OppTimeline { postDate?: SingleDateEvent; closeDate?: SingleDateEvent; - otherDates?: Record; + otherDates?: Record; } /** Funding details for an opportunity (mirrors `Models.OppFunding`). */ @@ -95,7 +140,7 @@ export interface CompetitionStatus { export interface CompetitionTimeline { openDate?: SingleDateEvent; closeDate?: SingleDateEvent; - otherDates?: Record; + otherDates?: Record; } /** @@ -171,12 +216,129 @@ const programCode = (value: string): CustomField => ({ }); /** - * The fixture set: 10 opportunities spanning all four statuses, a range of + * The id the specs publish as the `example` on `CommonGrants.Types.uuid`, which + * Swagger UI pre-fills into the `oppId` box when a visitor clicks "Try it out" + * on `GET /common-grants/opportunities/{oppId}`. A fixture record MUST carry + * this id, or the very first Execute a visitor runs — with the field untouched + * — answers 404. It is also the id used throughout the rendered "Example Value" + * panes, so the record carrying it mirrors those documented values field for + * field. + */ +export const CANONICAL_OPPORTUNITY_ID = "30a12e5e-5940-4c08-921c-17a8960fcf4b"; + +/** + * A well-formed UUID deliberately absent from the fixture, reserved as the + * playground's 404 demo. Documented on the mock-playground page. + */ +export const RESERVED_MISSING_OPPORTUNITY_ID = + "00000000-0000-0000-0000-000000000000"; + +/** + * The fixture set: 11 opportunities spanning all four statuses, a range of * funding amounts, and varied close dates so filtering and sorting visibly * change results. Sorted newest-first by `lastModifiedAt` to match the list * endpoint's default ordering. */ export const OPPORTUNITY_FIXTURES: readonly Opportunity[] = Object.freeze([ + // ---- The spec's own documented example (see CANONICAL_OPPORTUNITY_ID) ---- + { + // Field values here are copied verbatim from the `@example` decorators the + // spec renders in Swagger UI's "Example Value" pane for this endpoint + // (`Types.uuid`, `OpportunityBase.title`/`.description`, `OppStatus`, + // `OppFunding`, `OppTimeline`), so the documented example and the mock's + // live response agree field for field. Do not "fix" the 2024 dates against + // the `open` status — matching the published example is the point. + id: CANONICAL_OPPORTUNITY_ID, + title: "Small business grant program", + status: { + value: "open", + description: "The opportunity is currently accepting applications", + }, + description: + "This program provides funding to small businesses to help them grow and create jobs", + funding: { + totalAmountAvailable: usd("1000000.00"), + minAwardAmount: usd("10000.00"), + maxAwardAmount: usd("50000.00"), + minAwardCount: 5, + maxAwardCount: 20, + estimatedAwardCount: 10, + }, + keyDates: { + postDate: { + name: "Opportunity posted date", + eventType: "singleDate", + date: "2024-01-15", + description: "Opportunity is posted publicly", + }, + closeDate: { + name: "Opportunity close date", + eventType: "singleDate", + date: "2024-12-31", + time: "17:00:00", + description: "Opportunity closes for all applications", + }, + otherDates: { + anticipatedAward: { + name: "Anticipated award date", + eventType: "singleDate", + date: "2025-03-15", + description: + "When we expect to announce awards for this opportunity.", + }, + applicationPeriod: { + name: "Application period", + eventType: "dateRange", + startDate: "2024-01-01", + endDate: "2024-01-31", + endTime: "17:00:00", + description: "Primary application period for the grant opportunity", + }, + performancePeriod: { + name: "Period of Performance", + eventType: "dateRange", + startDate: "2024-01-01", + endDate: "2024-12-31", + description: "Period of performance for the grant", + }, + infoSessions: { + name: "Info sessions", + eventType: "other", + details: "Every other Tuesday", + description: "Info sessions for the opportunity", + }, + }, + }, + acceptedApplicantTypes: [ + { + value: "for_profit_small_business", + description: "For-profit small businesses", + }, + ], + source: "https://grants.example.gov/opportunities/small-business", + customFields: { + legacyId: legacyId(12344), + programCode: programCode("SMALL-BIZ"), + }, + competitions: [ + { + id: "c0a1b2c3-d4e5-4f60-8a1b-2c3d4e5f6a60", + opportunityId: CANONICAL_OPPORTUNITY_ID, + title: "Small business grant program — 2024 Cycle", + description: "The primary application cycle for the 2024 program year.", + status: { value: "open", description: "Accepting applications" }, + keyDates: { + closeDate: closeOn("2024-12-31"), + }, + }, + ], + // No `@example` exists for the readOnly audit timestamps; these are chosen + // so this record sorts first under the list endpoint's default + // `lastModifiedAt desc` ordering, putting the documented example at the top + // of the list response. + createdAt: "2024-01-15T00:00:00Z", + lastModifiedAt: "2025-06-01T00:00:00Z", + }, // ---- Carried over from lib/ts-sdk/examples/mock-api-server.ts (id + title) ---- { id: "573525f2-8e15-4405-83fb-e6523511d893", diff --git a/website/src/lib/mock/opportunities/handlers.ts b/website/src/lib/mock/opportunities/handlers.ts index c8ce6e5c2..4c2b540c2 100644 --- a/website/src/lib/mock/opportunities/handlers.ts +++ b/website/src/lib/mock/opportunities/handlers.ts @@ -1,9 +1,9 @@ import { http, HttpResponse, type HttpHandler } from "msw"; import { - OPPORTUNITY_FIXTURES, allForVersion, getById, shapeOpportunityForVersion, + type Money, type Opportunity, type Version, } from "./fixtures"; @@ -12,9 +12,11 @@ const LIST_PATH = "/common-grants/opportunities"; const DETAIL_PATH = "/common-grants/opportunities/:oppId"; const SEARCH_PATH = "/common-grants/opportunities/search"; +/** Spec defaults for `Pagination.PaginatedQueryParams` / `PaginatedBodyParams`. */ const DEFAULT_PAGE = 1; const DEFAULT_PAGE_SIZE = 100; -const MAX_PAGE_SIZE = 100; +/** Both params declare `minimum: 1` and no maximum. */ +const MIN_PAGE_VALUE = 1; /** Wire values of `Models.OppSortBy` (`lib/core/lib/core/models/opportunity/search.tsp`). */ const VALID_SORT_BY = new Set([ @@ -34,25 +36,40 @@ const VALID_SORT_ORDER = new Set(["asc", "desc"]); const VALID_ARRAY_OPERATORS = new Set(["in", "notIn"]); const VALID_RANGE_OPERATORS = new Set(["between", "outside"]); +/** + * The filter/sort/pagination shapes below describe what a *well-formed* request + * body looks like. Bodies arrive as untrusted JSON, so the search handler + * validates every field — operator, bound presence, and bound value — and + * answers 400 before any of these types are relied on. + */ interface StringArrayFilter { operator: string; value: string[]; } -interface RangeFilter { +interface DateRangeFilter { operator: string; - value: { - min: string | { amount: string; currency: string }; - max: string | { amount: string; currency: string }; - }; + value: { min?: string; max?: string }; +} + +interface MoneyRangeFilter { + operator: string; + value: { min?: Money; max?: Money }; } +/** The money-range filter fields of `Models.OppFilters`. */ +const MONEY_RANGE_FIELDS = [ + "totalFundingAvailableRange", + "minAwardAmountRange", + "maxAwardAmountRange", +] as const; + interface OppFilters { status?: StringArrayFilter; - closeDateRange?: RangeFilter; - totalFundingAvailableRange?: RangeFilter; - minAwardAmountRange?: RangeFilter; - maxAwardAmountRange?: RangeFilter; + closeDateRange?: DateRangeFilter; + totalFundingAvailableRange?: MoneyRangeFilter; + minAwardAmountRange?: MoneyRangeFilter; + maxAwardAmountRange?: MoneyRangeFilter; customFilters?: Record; } @@ -77,7 +94,8 @@ interface FieldError { function moneyAmount(value: unknown): number | undefined { if (value && typeof value === "object" && "amount" in value) { - return Number((value as { amount: string }).amount); + const amount = Number((value as { amount: string }).amount); + return Number.isFinite(amount) ? amount : undefined; } return undefined; } @@ -88,6 +106,16 @@ function dateTime(value: unknown): number | undefined { return Number.isNaN(ms) ? undefined : ms; } +/** True if `value` is a `Money` object whose `amount` parses as a number. */ +function isMoney(value: unknown): value is Money { + return ( + typeof value === "object" && + value !== null && + typeof (value as Money).currency === "string" && + moneyAmount(value) !== undefined + ); +} + /** Applies a `StringArrayFilter` (`in`/`notIn`) over the `status.value` field. */ function applyStatusFilter( items: Opportunity[], @@ -106,17 +134,11 @@ function applyStatusFilter( */ function applyDateRangeFilter( items: Opportunity[], - filter: RangeFilter, + filter: DateRangeFilter, getValue: (opp: Opportunity) => number | undefined, ): Opportunity[] { - const min = - filter.value.min !== undefined - ? dateTime(filter.value.min as string) - : undefined; - const max = - filter.value.max !== undefined - ? dateTime(filter.value.max as string) - : undefined; + const min = dateTime(filter.value.min); + const max = dateTime(filter.value.max); return items.filter((opp) => { const value = getValue(opp); @@ -138,20 +160,13 @@ function applyDateRangeFilter( */ function applyMoneyRangeFilter( items: Opportunity[], - filter: RangeFilter, - getMoney: ( - opp: Opportunity, - ) => { amount: string; currency: string } | undefined, + filter: MoneyRangeFilter, + getMoney: (opp: Opportunity) => Money | undefined, ): Opportunity[] { - const minBound = filter.value.min as - | { amount: string; currency: string } - | undefined; - const maxBound = filter.value.max as - | { amount: string; currency: string } - | undefined; + const { min: minBound, max: maxBound } = filter.value; const currency = minBound?.currency ?? maxBound?.currency; - const min = minBound ? Number(minBound.amount) : undefined; - const max = maxBound ? Number(maxBound.amount) : undefined; + const min = moneyAmount(minBound); + const max = moneyAmount(maxBound); return items.filter((opp) => { const money = getMoney(opp); @@ -200,26 +215,89 @@ function compare(a: string | number, b: string | number): number { } /** - * Resolves `page`/`pageSize` per the spec's defaults (page=1, pageSize=100) - * and bounds (both >= 1, pageSize <= 100). A missing or non-numeric raw value - * falls back to its default; a present, well-formed value outside the valid - * range is clamped to the nearest bound rather than replaced by the default - * (e.g. a requested `pageSize=0` becomes 1, not 100). + * Orders items by `sortBy`, breaking ties on `id`. The tiebreaker makes the + * ordering a total order: without it, records sharing a sort value (two + * opportunities with the same `funding.maxAwardAmount`, say) keep their + * incoming order under a stable sort, so `desc` would not be the exact reverse + * of `asc`. Negating the composed comparator — tiebreaker included — is what + * makes the two directions mirror each other exactly. */ +function orderBy( + items: Opportunity[], + sortBy: string, + sortOrder: string, +): Opportunity[] { + return [...items].sort((a, b) => { + const result = + compare(sortKey(a, sortBy), sortKey(b, sortBy)) || compare(a.id, b.id); + return sortOrder === "asc" ? result : -result; + }); +} + +/** A resolved pagination pair, or the validation errors that blocked it. */ +type PaginationResult = + | { ok: true; page: number; pageSize: number } + | { ok: false; errors: FieldError[] }; + +/** + * Resolves one pagination param against the spec + * (`Pagination.PaginatedQueryParams` / `PaginatedBodyParams`): an optional + * `integer` with `minimum: 1`, defaulting to 1 (`page`) / 100 (`pageSize`), and + * **no** declared maximum. + * + * An absent value takes the default. A present value that isn't an integer, or + * that falls below the minimum, is a validation error rather than something to + * silently clamp — clamping would answer a request the caller didn't make, and + * surfacing it as a 400 gives the playground another error case to demonstrate. + * + * @param raw - The value as received: a query string, a JSON body value, or + * `undefined`/`""` when the caller omitted it. + * @param field - Dotted path used in the `{field, message}` error entry. + * @param fallback - The spec default for this param. + */ +function resolvePaginationParam( + raw: string | number | undefined, + field: string, + fallback: number, + errors: FieldError[], +): number { + if (raw === undefined || raw === "") return fallback; + + const value = typeof raw === "number" ? raw : Number(raw); + if (!Number.isInteger(value)) { + errors.push({ field, message: `Must be an integer, received: ${raw}` }); + return fallback; + } + if (value < MIN_PAGE_VALUE) { + errors.push({ field, message: `Must be at least ${MIN_PAGE_VALUE}` }); + return fallback; + } + return value; +} + +/** Resolves both pagination params, collecting every validation error at once. */ function resolvePagination( - rawPage: number, - rawPageSize: number, -): { page: number; pageSize: number } { - return { - page: Math.max(1, Number.isFinite(rawPage) ? rawPage : DEFAULT_PAGE), - pageSize: Math.max( - 1, - Math.min( - MAX_PAGE_SIZE, - Number.isFinite(rawPageSize) ? rawPageSize : DEFAULT_PAGE_SIZE, - ), - ), - }; + rawPage: string | number | undefined, + rawPageSize: string | number | undefined, + fieldPrefix = "", +): PaginationResult { + const errors: FieldError[] = []; + const page = resolvePaginationParam( + rawPage, + `${fieldPrefix}page`, + DEFAULT_PAGE, + errors, + ); + const pageSize = resolvePaginationParam( + rawPageSize, + `${fieldPrefix}pageSize`, + DEFAULT_PAGE_SIZE, + errors, + ); + + return errors.length > 0 + ? { ok: false, errors } + : { ok: true, page, pageSize }; } /** @@ -244,6 +322,20 @@ function successResponse(body: Record): Response { return HttpResponse.json({ status: 200, message: "Success", ...body }); } +/** + * Builds `Pagination.PaginatedResultsInfo`. Shared by the list and search + * endpoints so `totalPages` is derived the same way on both — an empty result + * set reports zero pages, not one. + */ +function paginationInfo(page: number, pageSize: number, totalItems: number) { + return { + page, + pageSize, + totalItems, + totalPages: Math.ceil(totalItems / pageSize), + }; +} + /** * Deterministic, fixture-backed MSW handlers for the CommonGrants opportunity * list and detail endpoints (#1034-T5). Bodies are static projections of @@ -258,26 +350,26 @@ export function buildOpportunityHandlers(version: Version): HttpHandler[] { return [ http.get(LIST_PATH, ({ request }) => { const url = new URL(request.url); - const { page, pageSize } = resolvePagination( - parseInt(url.searchParams.get("page") ?? "", 10), - parseInt(url.searchParams.get("pageSize") ?? "", 10), + const pagination = resolvePagination( + url.searchParams.get("page") ?? undefined, + url.searchParams.get("pageSize") ?? undefined, ); + if (!pagination.ok) { + return errorResponse( + 400, + "Invalid pagination parameters", + pagination.errors, + ); + } + const { page, pageSize } = pagination; - const sorted = allForVersion(version).sort( - (a, b) => - -compare(sortKey(a, "lastModifiedAt"), sortKey(b, "lastModifiedAt")), - ); + const sorted = orderBy(allForVersion(version), "lastModifiedAt", "desc"); const start = (page - 1) * pageSize; const items = sorted.slice(start, start + pageSize); return successResponse({ items, - paginationInfo: { - page, - pageSize, - totalItems: OPPORTUNITY_FIXTURES.length, - totalPages: Math.ceil(OPPORTUNITY_FIXTURES.length / pageSize), - }, + paginationInfo: paginationInfo(page, pageSize, sorted.length), }); }), @@ -346,12 +438,7 @@ export function buildOpportunityHandlers(version: Version): HttpHandler[] { }); } } - for (const field of [ - "closeDateRange", - "totalFundingAvailableRange", - "minAwardAmountRange", - "maxAwardAmountRange", - ] as const) { + for (const field of ["closeDateRange", ...MONEY_RANGE_FIELDS] as const) { const filter = filters[field]; if (!filter) continue; if (!VALID_RANGE_OPERATORS.has(filter.operator)) { @@ -359,7 +446,9 @@ export function buildOpportunityHandlers(version: Version): HttpHandler[] { field: `filters.${field}.operator`, message: `Unknown range operator: ${String(filter.operator)}`, }); - } else if ( + continue; + } + if ( !filter.value || typeof filter.value !== "object" || (filter.value.min === undefined && filter.value.max === undefined) @@ -368,9 +457,40 @@ export function buildOpportunityHandlers(version: Version): HttpHandler[] { field: `filters.${field}.value`, message: "Must include at least one of min or max", }); + continue; + } + // Validate the bounds themselves, not just their presence. An + // malformed bound used to be silently dropped, which returned a + // result set that contradicted the filter the caller asked for. + const isDateRange = field === "closeDateRange"; + for (const bound of ["min", "max"] as const) { + const value = filter.value[bound]; + if (value === undefined) continue; + if (isDateRange ? dateTime(value) === undefined : !isMoney(value)) { + errors.push({ + field: `filters.${field}.value.${bound}`, + message: isDateRange + ? `Must be a valid ISO 8601 date, received: ${JSON.stringify(value)}` + : `Must be a Money object with a numeric amount and a currency, received: ${JSON.stringify(value)}`, + }); + } } } + const pagination = resolvePagination( + body.pagination?.page, + body.pagination?.pageSize, + "pagination.", + ); + if (!pagination.ok) { + errors.push(...pagination.errors); + } + // The defaults here are unreachable: an invalid pagination pair pushed + // errors above, and the guard below returns before they are used. + const { page, pageSize } = pagination.ok + ? pagination + : { page: DEFAULT_PAGE, pageSize: DEFAULT_PAGE_SIZE }; + if (errors.length > 0) { return errorResponse(400, "Invalid search request", errors); } @@ -425,28 +545,27 @@ export function buildOpportunityHandlers(version: Version): HttpHandler[] { : "Custom sort requested without customSortBy; results are unsorted.", ); } else { - items = [...items].sort((a, b) => { - const result = compare(sortKey(a, sortBy), sortKey(b, sortBy)); - return sortOrder === "asc" ? result : -result; - }); + items = orderBy(items, sortBy, sortOrder); + } + + // `filterInfo.errors` is the spec's channel for "non-fatal errors that + // occurred during filtering". `customFilters` are implementation-defined, + // so this mock echoes them but can't apply them — say so rather than + // letting the echo imply they narrowed the results. + const filterErrors: string[] = []; + const customFilterNames = Object.keys(filters.customFilters ?? {}); + if (customFilterNames.length > 0) { + filterErrors.push( + `Custom filters are not supported by this mock and were not applied: ${customFilterNames.join(", ")}.`, + ); } - const { page, pageSize } = resolvePagination( - body.pagination?.page ?? NaN, - body.pagination?.pageSize ?? NaN, - ); const start = (page - 1) * pageSize; - const totalItems = items.length; const pageItems = items.slice(start, start + pageSize); return successResponse({ items: pageItems, - paginationInfo: { - page, - pageSize, - totalItems, - totalPages: Math.ceil(totalItems / pageSize) || 1, - }, + paginationInfo: paginationInfo(page, pageSize, items.length), sortInfo: { sortBy, ...(sorting?.customSortBy !== undefined @@ -457,6 +576,7 @@ export function buildOpportunityHandlers(version: Version): HttpHandler[] { }, filterInfo: { filters, + ...(filterErrors.length > 0 ? { errors: filterErrors } : {}), }, }); }), diff --git a/website/src/lib/mock/spec-handlers.ts b/website/src/lib/mock/spec-handlers.ts index ba6098129..f7d50da24 100644 --- a/website/src/lib/mock/spec-handlers.ts +++ b/website/src/lib/mock/spec-handlers.ts @@ -69,8 +69,11 @@ function replayResponse(cached: CachedResponse): Response { * accessible off the public `HttpHandler` type, hence the cast) and returns the * same array. The cache lives in this closure, so a fresh call to * `buildHandlersFromSpec` (e.g. on a version swap) starts a fresh cache. + * + * Exported for tests — production code reaches it through + * `buildHandlersFromSpec`. */ -function memoize(handlers: HttpHandler[]): HttpHandler[] { +export function memoize(handlers: HttpHandler[]): HttpHandler[] { const cache = new Map>(); for (const handler of handlers) { @@ -81,8 +84,16 @@ function memoize(handlers: HttpHandler[]): HttpHandler[] { const key = await requestCacheKey(info.request); let cached = cache.get(key); if (!cached) { - cached = Promise.resolve(original(info)).then((result) => - result instanceof Response ? snapshotResponse(result) : undefined, + cached = Promise.resolve(original(info)).then( + (result) => + result instanceof Response ? snapshotResponse(result) : undefined, + (error: unknown) => { + // Don't let a one-off resolver failure poison the key: evict it so + // the next request retries, and rethrow so MSW reports it as it + // would without memoization. + cache.delete(key); + throw error; + }, ); cache.set(key, cached); } diff --git a/website/src/pages/protocol/mock-playground.astro b/website/src/pages/protocol/mock-playground.astro index efe79b86d..9df3fdb37 100644 --- a/website/src/pages/protocol/mock-playground.astro +++ b/website/src/pages/protocol/mock-playground.astro @@ -5,6 +5,37 @@ // page (not in the nav), so starlight-links-validator needs no exclude entry. import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro"; import MockPlayground from "@/components/MockPlayground"; +import { + CANONICAL_OPPORTUNITY_ID, + RESERVED_MISSING_OPPORTUNITY_ID, +} from "@/lib/mock/opportunities/fixtures"; + +// Reserved inputs that produce a non-2xx response, surfaced on the page so the +// error cases are discoverable rather than something you stumble into. +const errorCases = [ + { + request: `GET /common-grants/opportunities/${RESERVED_MISSING_OPPORTUNITY_ID}`, + result: "404 — a well-formed UUID that matches no opportunity", + }, + { + request: "GET /common-grants/opportunities/not-a-uuid", + result: "400 — path parameter fails the uuid format", + }, + { + request: "GET /common-grants/opportunities?pageSize=0", + result: "400 — page and pageSize both declare minimum: 1", + }, + { + request: + 'POST /common-grants/opportunities/search — sorting.sortBy: "nope"', + result: "400 — value outside the OppSortBy enum", + }, + { + request: + 'POST /common-grants/opportunities/search — filters.closeDateRange.value.min: "not-a-date"', + result: "400 — filter bound is not a valid date", + }, +]; ---