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..887fa4229 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": [], @@ -46,6 +47,7 @@ "tspconfig", "typespec", "underrepresents", + "underserved", "USCDI", "rescope", "rescoped", 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/__tests__/lib/mock/opportunities-fixtures.spec.ts b/website/__tests__/lib/mock/opportunities-fixtures.spec.ts new file mode 100644 index 000000000..2fad0d469 --- /dev/null +++ b/website/__tests__/lib/mock/opportunities-fixtures.spec.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from "vitest"; +import { + CANONICAL_OPPORTUNITY_ID, + OPPORTUNITY_FIXTURES, + RESERVED_MISSING_OPPORTUNITY_ID, + SUPPORTED_VERSIONS, + isSupportedVersion, + 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); + }); + + // 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"); + 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(); + }); +}); + +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 new file mode 100644 index 000000000..67afeb514 --- /dev/null +++ b/website/__tests__/lib/mock/opportunities-handlers.spec.ts @@ -0,0 +1,1123 @@ +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"; + +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)", () => { + 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: OPPORTUNITY_FIXTURES.length, + 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: OPPORTUNITY_FIXTURES.length, + totalPages: 1, + }); + }); + + it("honors a pageSize above the default, since the spec declares no maximum", 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-above-default", + resolutionContext: { baseUrl: "http://localhost/" }, + }); + + expect(result).not.toBeNull(); + expect(result!.response?.status).toBe(200); + + const body = (await result!.response!.json()) as { + paginationInfo: { pageSize: number; totalPages: number }; + }; + + expect(body.paginationInfo.pageSize).toBe(9999); + expect(body.paginationInfo.totalPages).toBe(1); + }); + + 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( + (h) => String(h.info.path) === "/common-grants/opportunities", + ); + expect(handler).toBeDefined(); + + const result = await handler!.run({ + 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(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.errors).toEqual([ + { field: param, message: "Must be at least 1" }, + ]); + }, + ); + + it("returns 400 when a pagination param is present but not an integer", 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(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: { page: number; pageSize: number }; + }; + + expect(body.paginationInfo.page).toBe(1); + expect(body.paginationInfo.pageSize).toBe(100); + }); + }); + + 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"; + + // 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"); + + 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.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 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.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 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.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 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.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 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); + }, + ); + + 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", () => { + 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.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 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.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" }, + }); + + 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.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 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 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" }, + }; + + 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, + // 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"); + 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 the whole fixture set. + 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 + // 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: { + 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); + } + }); + + // 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 new file mode 100644 index 000000000..d3edfed5e --- /dev/null +++ b/website/__tests__/lib/mock/spec-handlers.spec.ts @@ -0,0 +1,192 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "fs"; +import { resolve } from "path"; +import yaml from "js-yaml"; +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"; + +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"); + }, + ); + + 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); + }); +}); + +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/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, + } +} diff --git a/website/src/components/MockPlayground.tsx b/website/src/components/MockPlayground.tsx new file mode 100644 index 000000000..ebfabcd90 --- /dev/null +++ b/website/src/components/MockPlayground.tsx @@ -0,0 +1,112 @@ +import React, { useCallback, useEffect, useRef, useState } from "react"; +import yaml from "js-yaml"; +import { setupWorker, type SetupWorker } from "msw/browser"; +import OpenApiDocs from "./OpenApiDocs"; +import { + buildHandlersFromSpec, + type OpenApiSpec, +} from "@/lib/mock/spec-handlers"; +import { buildOpportunityHandlers } from "@/lib/mock/opportunities/handlers"; +import { isSupportedVersion } from "@/lib/mock/opportunities/fixtures"; + +/** + * 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 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(() => { + cancelledRef.current = false; + const worker = setupWorker(); + workerRef.current = worker; + + const startup = worker.start({ + onUnhandledRequest: "bypass", + serviceWorker: { url: "/mockServiceWorker.js" }, + }); + startup.then(() => { + if (!cancelledRef.current) setReady(true); + }); + + return () => { + 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 specHandlers = await buildHandlersFromSpec(spec); + // 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( + 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( + `[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 ( + <> + {error &&

{error}

} + + + ); +} diff --git a/website/src/components/OpenApiDocs.tsx b/website/src/components/OpenApiDocs.tsx index b2a29477a..bbbe46785 100644 --- a/website/src/components/OpenApiDocs.tsx +++ b/website/src/components/OpenApiDocs.tsx @@ -9,11 +9,24 @@ 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; + /** + * 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; } // ######################################################### @@ -71,7 +84,11 @@ const styles = { }, }; -export default function OpenApiDocs({ className }: OpenApiDocsProps) { +export default function OpenApiDocs({ + className, + enableTryItOut = false, + onVersionChange, +}: OpenApiDocsProps) { // ######################################################### // Set up state management // ######################################################### @@ -92,6 +109,9 @@ export default function OpenApiDocs({ className }: OpenApiDocsProps) { 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 // ######################################################### @@ -102,6 +122,7 @@ export default function OpenApiDocs({ className }: OpenApiDocsProps) { setSelectedVersion(newVersion); setKey((prev) => prev + 1); // Force SwaggerUI to re-render updateUrlParams(newVersion); + onVersionChange?.(newVersion); }; return ( @@ -128,7 +149,7 @@ export default function OpenApiDocs({ className }: OpenApiDocsProps) { )} diff --git a/website/src/lib/mock/opportunities/fixtures.ts b/website/src/lib/mock/opportunities/fixtures.ts new file mode 100644 index 000000000..aa0a091ba --- /dev/null +++ b/website/src/lib/mock/opportunities/fixtures.ts @@ -0,0 +1,699 @@ +/** + * 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). 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. + * + * 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. + */ + +/** + * 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+). */ +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 (mirrors the `singleDate` variant of `Fields.Event`). */ +export interface SingleDateEvent { + name: string; + eventType: "singleDate"; + date: string; + time?: string; + description?: string; +} + +/** 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; +} + +/** 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 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", + 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"), + ); +} diff --git a/website/src/lib/mock/opportunities/handlers.ts b/website/src/lib/mock/opportunities/handlers.ts new file mode 100644 index 000000000..4c2b540c2 --- /dev/null +++ b/website/src/lib/mock/opportunities/handlers.ts @@ -0,0 +1,584 @@ +import { http, HttpResponse, type HttpHandler } from "msw"; +import { + allForVersion, + getById, + shapeOpportunityForVersion, + type Money, + 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"; + +/** Spec defaults for `Pagination.PaginatedQueryParams` / `PaginatedBodyParams`. */ +const DEFAULT_PAGE = 1; +const DEFAULT_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([ + "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"]); + +/** + * 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 DateRangeFilter { + operator: 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?: DateRangeFilter; + totalFundingAvailableRange?: MoneyRangeFilter; + minAwardAmountRange?: MoneyRangeFilter; + maxAwardAmountRange?: MoneyRangeFilter; + 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) { + const amount = Number((value as { amount: string }).amount); + return Number.isFinite(amount) ? amount : undefined; + } + 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; +} + +/** 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[], + 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: DateRangeFilter, + getValue: (opp: Opportunity) => number | undefined, +): Opportunity[] { + const min = dateTime(filter.value.min); + const max = dateTime(filter.value.max); + + 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: MoneyRangeFilter, + getMoney: (opp: Opportunity) => Money | undefined, +): Opportunity[] { + const { min: minBound, max: maxBound } = filter.value; + const currency = minBound?.currency ?? maxBound?.currency; + const min = moneyAmount(minBound); + const max = moneyAmount(maxBound); + + 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; +} + +/** + * 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: 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 }; +} + +/** + * 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; + +/** Builds the protocol `Error` envelope (`status`, `message`, `errors`). */ +function errorResponse( + status: number, + message: string, + errors: unknown[], +): Response { + 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 }); +} + +/** + * 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 + * `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 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 = orderBy(allForVersion(version), "lastModifiedAt", "desc"); + const start = (page - 1) * pageSize; + const items = sorted.slice(start, start + pageSize); + + return successResponse({ + items, + paginationInfo: paginationInfo(page, pageSize, sorted.length), + }); + }), + + 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 successResponse({ + 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", ...MONEY_RANGE_FIELDS] 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)}`, + }); + continue; + } + 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", + }); + 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); + } + + 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 = 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 start = (page - 1) * pageSize; + const pageItems = items.slice(start, start + pageSize); + + return successResponse({ + items: pageItems, + paginationInfo: paginationInfo(page, pageSize, items.length), + sortInfo: { + sortBy, + ...(sorting?.customSortBy !== undefined + ? { customSortBy: sorting.customSortBy } + : {}), + sortOrder, + ...(sortErrors.length > 0 ? { errors: sortErrors } : {}), + }, + 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 new file mode 100644 index 000000000..f7d50da24 --- /dev/null +++ b/website/src/lib/mock/spec-handlers.ts @@ -0,0 +1,130 @@ +import { fromOpenApi } from "@mswjs/source/open-api"; +import type { HttpHandler, ResponseResolver } 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; +} + +/** 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. + * + * Exported for tests — production code reaches it through + * `buildHandlersFromSpec`. + */ +export 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, + (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); + } + 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 + * 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, memoized MSW handlers. + */ +export async function buildHandlersFromSpec( + spec: OpenApiSpec, +): Promise { + const handlers = await fromOpenApi(spec as Parameters[0]); + return memoize(handlers); +} diff --git a/website/src/pages/protocol/mock-playground.astro b/website/src/pages/protocol/mock-playground.astro new file mode 100644 index 000000000..9df3fdb37 --- /dev/null +++ b/website/src/pages/protocol/mock-playground.astro @@ -0,0 +1,443 @@ +--- +// 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"; +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", + }, +]; +--- + + + + +
+

+ Every endpoint below answers from a mock running in your browser — no + server involved. Hit Try it out, then Execute. +

+ +

What to expect

+
    +
  • + The three opportunity endpoints are backed by a fixed set of 11 records, + so repeat calls return the same body, and an id from + GET /common-grants/opportunities resolves to that same record + through GET /common-grants/opportunities/{"{oppId}"}. The + pre-filled oppId ({CANONICAL_OPPORTUNITY_ID}) + is the record the spec publishes as its own example. +
  • +
  • + Search applies the status, close-date, and funding-range + filters and the OppSortBy sort orders for real — results never + contradict what you asked for. +
  • +
  • + Every other endpoint is generated from the response schema. Those bodies + are placeholder data, held stable for the rest of this page visit but + reshuffled on reload. +
  • +
+ +

Error responses to try

+

+ Each returns the protocol's {"{ status, message, errors[] }"} error + envelope. Swagger UI labels the 400s "Undocumented" because the specs declare + only 200 and 404 for these routes. +

+ + + + + + + + + { + errorCases.map((errorCase) => ( + + + + + )) + } + +
RequestResponse
+ {errorCase.request} + {errorCase.result}
+
+ + +