From 3ccd1262f941ccd5b94b4fb025689dab3dcc5818 Mon Sep 17 00:00:00 2001 From: kurihada Date: Sat, 28 Feb 2026 20:19:14 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=A5=97=E4=BB=B6=EF=BC=8852=20=E4=B8=AA?= =?UTF-8?q?=E6=96=87=E4=BB=B6=EF=BC=8C326=20=E4=B8=AA=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基于 Vitest 搭建测试基础设施,覆盖后端纯函数、API 路由、 前端 hooks、UI 组件和页面级集成测试。 --- package-lock.json | 2823 ++++++++++++++++- package.json | 15 +- src/__tests__/helpers/api-test-utils.ts | 29 + src/__tests__/helpers/fixtures.ts | 123 + src/__tests__/helpers/prisma-mock.ts | 13 + src/__tests__/helpers/setup.ts | 6 + src/app/api/auth/login/route.test.ts | 71 + src/app/api/auth/register/route.test.ts | 106 + src/app/api/blindbox/draw/route.test.ts | 99 + src/app/api/blindbox/plan/route.test.ts | 191 ++ .../api/blindbox/plan/stream/route.test.ts | 135 + .../api/blindbox/room/[code]/route.test.ts | 131 + src/app/api/blindbox/room/join/route.test.ts | 64 + src/app/api/blindbox/room/route.test.ts | 66 + src/app/api/blindbox/rooms/route.test.ts | 51 + src/app/api/blindbox/route.test.ts | 149 + src/app/api/blindbox/suggest/route.test.ts | 56 + src/app/api/location/regeo/route.test.ts | 71 + src/app/api/location/search/route.test.ts | 74 + src/app/api/location/suggest/route.test.ts | 81 + src/app/api/room/[id]/join/route.test.ts | 95 + src/app/api/room/[id]/manage/route.test.ts | 141 + src/app/api/room/[id]/reset/route.test.ts | 95 + src/app/api/room/[id]/route.test.ts | 57 + src/app/api/room/[id]/swipe/route.test.ts | 127 + src/app/api/room/[id]/undo/route.test.ts | 91 + src/app/api/room/create/route.test.ts | 137 + src/app/api/user/achievements/route.test.ts | 64 + src/app/api/user/favorite/route.test.ts | 142 + src/app/api/user/history/route.test.ts | 128 + src/app/api/user/route.test.ts | 174 + src/app/blindbox/[code]/page.test.tsx | 104 + src/app/blindbox/page.test.tsx | 58 + src/app/panic/page.test.tsx | 100 + src/app/profile/page.test.tsx | 98 + src/app/room/[id]/page.test.tsx | 129 + src/components/AuthModal.test.tsx | 204 ++ src/components/BlindboxDrawnHistory.test.tsx | 46 + src/components/BlindboxMyIdeas.test.tsx | 95 + src/components/BlindboxPlan.test.tsx | 156 + src/components/Button.test.tsx | 67 + src/components/EmptyState.test.tsx | 47 + src/components/Input.test.tsx | 47 + src/components/MatchResult.test.tsx | 155 + src/components/Modal.test.tsx | 70 + src/components/Skeleton.test.tsx | 65 + src/components/SwipeDeck.test.tsx | 100 + src/components/SwipeableCard.test.tsx | 76 + src/components/Toast.test.tsx | 27 + src/components/TopNav.test.tsx | 59 + src/components/WeekendTimeSelector.test.tsx | 63 + src/hooks/useGeolocation.test.ts | 102 + src/hooks/useRoomPolling.test.ts | 91 + src/hooks/useShare.test.ts | 124 + src/hooks/useToast.test.tsx | 26 + src/lib/api.test.ts | 104 + src/lib/buildRoomStatus.test.ts | 164 + src/lib/validation.test.ts | 127 + vitest.config.ts | 25 + 59 files changed, 8131 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/helpers/api-test-utils.ts create mode 100644 src/__tests__/helpers/fixtures.ts create mode 100644 src/__tests__/helpers/prisma-mock.ts create mode 100644 src/__tests__/helpers/setup.ts create mode 100644 src/app/api/auth/login/route.test.ts create mode 100644 src/app/api/auth/register/route.test.ts create mode 100644 src/app/api/blindbox/draw/route.test.ts create mode 100644 src/app/api/blindbox/plan/route.test.ts create mode 100644 src/app/api/blindbox/plan/stream/route.test.ts create mode 100644 src/app/api/blindbox/room/[code]/route.test.ts create mode 100644 src/app/api/blindbox/room/join/route.test.ts create mode 100644 src/app/api/blindbox/room/route.test.ts create mode 100644 src/app/api/blindbox/rooms/route.test.ts create mode 100644 src/app/api/blindbox/route.test.ts create mode 100644 src/app/api/blindbox/suggest/route.test.ts create mode 100644 src/app/api/location/regeo/route.test.ts create mode 100644 src/app/api/location/search/route.test.ts create mode 100644 src/app/api/location/suggest/route.test.ts create mode 100644 src/app/api/room/[id]/join/route.test.ts create mode 100644 src/app/api/room/[id]/manage/route.test.ts create mode 100644 src/app/api/room/[id]/reset/route.test.ts create mode 100644 src/app/api/room/[id]/route.test.ts create mode 100644 src/app/api/room/[id]/swipe/route.test.ts create mode 100644 src/app/api/room/[id]/undo/route.test.ts create mode 100644 src/app/api/room/create/route.test.ts create mode 100644 src/app/api/user/achievements/route.test.ts create mode 100644 src/app/api/user/favorite/route.test.ts create mode 100644 src/app/api/user/history/route.test.ts create mode 100644 src/app/api/user/route.test.ts create mode 100644 src/app/blindbox/[code]/page.test.tsx create mode 100644 src/app/blindbox/page.test.tsx create mode 100644 src/app/panic/page.test.tsx create mode 100644 src/app/profile/page.test.tsx create mode 100644 src/app/room/[id]/page.test.tsx create mode 100644 src/components/AuthModal.test.tsx create mode 100644 src/components/BlindboxDrawnHistory.test.tsx create mode 100644 src/components/BlindboxMyIdeas.test.tsx create mode 100644 src/components/BlindboxPlan.test.tsx create mode 100644 src/components/Button.test.tsx create mode 100644 src/components/EmptyState.test.tsx create mode 100644 src/components/Input.test.tsx create mode 100644 src/components/MatchResult.test.tsx create mode 100644 src/components/Modal.test.tsx create mode 100644 src/components/Skeleton.test.tsx create mode 100644 src/components/SwipeDeck.test.tsx create mode 100644 src/components/SwipeableCard.test.tsx create mode 100644 src/components/Toast.test.tsx create mode 100644 src/components/TopNav.test.tsx create mode 100644 src/components/WeekendTimeSelector.test.tsx create mode 100644 src/hooks/useGeolocation.test.ts create mode 100644 src/hooks/useRoomPolling.test.ts create mode 100644 src/hooks/useShare.test.ts create mode 100644 src/hooks/useToast.test.tsx create mode 100644 src/lib/api.test.ts create mode 100644 src/lib/buildRoomStatus.test.ts create mode 100644 src/lib/validation.test.ts create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 91c7b03..bc6effb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,17 +24,39 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/bcryptjs": "^2.4.6", "@types/canvas-confetti": "^1.9.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^5.1.4", "eslint": "^9", "eslint-config-next": "16.1.6", + "jsdom": "^28.1.0", + "msw": "^2.12.10", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.0.18", + "vitest-mock-extended": "^3.1.0" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -48,6 +70,64 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -181,6 +261,16 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -241,6 +331,48 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -289,6 +421,153 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.28", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.28.tgz", + "integrity": "sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -322,6 +601,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -466,6 +1187,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -984,6 +1723,94 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1034,6 +1861,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", + "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1239,6 +2084,31 @@ "node": ">=12.4.0" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@prisma/client": { "version": "6.19.2", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz", @@ -1318,6 +2188,363 @@ "@prisma/debug": "6.19.2" } }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1611,6 +2838,106 @@ "tailwindcss": "4.2.1" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1622,6 +2949,58 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, "node_modules/@types/bcryptjs": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", @@ -1636,6 +3015,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1663,6 +3060,7 @@ "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1684,10 +3082,18 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.56.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", @@ -2253,6 +3659,138 @@ "win32" ] }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2277,6 +3815,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -2294,6 +3842,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2487,6 +4045,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -2568,6 +4136,16 @@ "bcrypt": "bin/bcrypt" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2745,6 +4323,16 @@ "url": "https://www.paypal.me/kirilvatev" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2786,12 +4374,55 @@ "consola": "^3.2.3" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2841,6 +4472,20 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2856,6 +4501,53 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.1.0.tgz", + "integrity": "sha512-Ml4fP2UT2K3CUBQnVlbdV/8aFDdlY69E+YnwJM+3VUWl08S3J8c8aRuJqCkD9Py8DHZ7zNNvsfKl8psocHZEFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.0", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2870,6 +4562,20 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -2942,6 +4648,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3038,6 +4751,13 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -3112,6 +4832,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -3229,6 +4962,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3289,6 +5029,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3734,6 +5516,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3744,6 +5536,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -3940,6 +5742,21 @@ } } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4001,6 +5818,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -4151,6 +5978,16 @@ "dev": true, "license": "ISC" }, + "node_modules/graphql": { + "version": "16.13.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.0.tgz", + "integrity": "sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -4245,6 +6082,13 @@ "node": ">= 0.4" } }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -4262,12 +6106,53 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-to-image": { "version": "1.11.13", "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", "license": "MIT" }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4305,6 +6190,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4504,6 +6399,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -4563,6 +6468,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4590,6 +6502,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4796,6 +6715,48 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5219,6 +7180,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5239,6 +7210,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5263,6 +7241,16 @@ "node": ">=8.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz", @@ -5308,6 +7296,62 @@ "dev": true, "license": "MIT" }, + "node_modules/msw": { + "version": "2.12.10", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.10.tgz", + "integrity": "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.41.2", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.0.2", + "graphql": "^16.12.0", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.10.1", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^5.2.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -5608,6 +7652,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -5653,6 +7708,13 @@ "node": ">= 0.8.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -5716,6 +7778,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5743,6 +7818,13 @@ "dev": true, "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -5834,6 +7916,41 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/prisma": { "version": "6.19.2", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz", @@ -5968,6 +8085,16 @@ "dev": true, "license": "MIT" }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -5981,6 +8108,20 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6025,6 +8166,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -6066,6 +8227,13 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/rettime": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", + "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", + "dev": true, + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -6077,6 +8245,51 @@ "node": ">=0.10.0" } }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6156,6 +8369,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6378,6 +8604,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -6394,6 +8640,30 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6408,6 +8678,35 @@ "node": ">= 0.4" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -6521,6 +8820,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -6531,6 +8843,19 @@ "node": ">=4" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6606,6 +8931,26 @@ "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwindcss": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", @@ -6627,6 +8972,13 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -6685,6 +9037,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6698,6 +9080,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -6711,6 +9119,21 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-essentials": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.1.1.tgz", + "integrity": "sha512-4aTB7KLHKmUvkjNj8V+EdnmuVTiECzn3K+zIbRthumvHu+j44x3w63xpfs0JL3NGIzGXqoQ7AV591xHO+XrOTw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -6756,6 +9179,22 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz", + "integrity": "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -6892,6 +9331,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -6934,6 +9383,16 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -6984,6 +9443,267 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest-mock-extended": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vitest-mock-extended/-/vitest-mock-extended-3.1.0.tgz", + "integrity": "sha512-vCM0VkuocOUBwwqwV7JB7YStw07pqeKvEIrZnR8l3PtwYi6rAAJAyJACeC1UYNfbQWi85nz7EdiXWBFI5hll2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-essentials": ">=10.0.0" + }, + "peerDependencies": { + "typescript": "3.x || 4.x || 5.x", + "vitest": ">=3.0.0" + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7089,6 +9809,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7099,6 +9836,48 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -7106,6 +9885,35 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -7119,6 +9927,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index c4d0916..ed02c73 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@prisma/client": "^6.19.2", @@ -25,14 +28,22 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/bcryptjs": "^2.4.6", "@types/canvas-confetti": "^1.9.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^5.1.4", "eslint": "^9", "eslint-config-next": "16.1.6", + "jsdom": "^28.1.0", + "msw": "^2.12.10", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.0.18", + "vitest-mock-extended": "^3.1.0" } } diff --git a/src/__tests__/helpers/api-test-utils.ts b/src/__tests__/helpers/api-test-utils.ts new file mode 100644 index 0000000..948a085 --- /dev/null +++ b/src/__tests__/helpers/api-test-utils.ts @@ -0,0 +1,29 @@ +import { NextRequest } from "next/server"; + +export function createRequest( + url: string, + options: { + method?: string; + body?: unknown; + headers?: Record; + } = {}, +): NextRequest { + const { method = "GET", body, headers = {} } = options; + const init: RequestInit = { method, headers }; + if (body !== undefined) { + init.body = JSON.stringify(body); + (init.headers as Record)["content-type"] = "application/json"; + } + return new NextRequest(new URL(url, "http://localhost:3000"), init); +} + +export function createRouteContext( + params: Record, +): { params: Promise> } { + return { params: Promise.resolve(params) }; +} + +export async function parseJsonResponse(response: Response) { + const data = await response.json(); + return { status: response.status, data }; +} diff --git a/src/__tests__/helpers/fixtures.ts b/src/__tests__/helpers/fixtures.ts new file mode 100644 index 0000000..667cb59 --- /dev/null +++ b/src/__tests__/helpers/fixtures.ts @@ -0,0 +1,123 @@ +import type { Restaurant } from "@/types"; + +export const TEST_USER = { + id: "user-1", + username: "testuser", + avatar: "🐱", + email: null, + passwordHash: "$2a$10$hashedpassword", + preferences: "{}", + createdAt: new Date("2025-01-01"), +}; + +export const TEST_USER_2 = { + id: "user-2", + username: "testuser2", + avatar: "🐶", + email: null, + passwordHash: "$2a$10$hashedpassword2", + preferences: "{}", + createdAt: new Date("2025-01-02"), +}; + +export const TEST_RESTAURANT: Restaurant = { + id: "rest-1", + name: "测试餐厅", + rating: 4.5, + price: "¥80", + distance: "500m", + images: ["https://example.com/img.jpg"], + category: "中餐", + address: "测试地址", + openTime: "09:00-22:00", + tel: "021-12345678", + tag: "川菜", + location: "121.4,31.2", +}; + +export const TEST_RESTAURANT_2: Restaurant = { + id: "rest-2", + name: "测试餐厅2", + rating: 4.0, + price: "¥60", + distance: "800m", + images: ["https://example.com/img2.jpg"], + category: "日料", + address: "测试地址2", + openTime: "10:00-21:00", + tel: "021-87654321", + tag: "寿司", + location: "121.5,31.3", +}; + +export const TEST_RESTAURANT_3: Restaurant = { + id: "rest-3", + name: "测试餐厅3", + rating: 3.5, + price: "¥120", + distance: "1200m", + images: ["https://example.com/img3.jpg"], + category: "西餐", + address: "测试地址3", + openTime: "11:00-23:00", + tel: "021-11111111", + tag: "牛排", + location: "121.6,31.4", +}; + +export const TEST_ROOM_DATA = { + users: [TEST_USER.id, TEST_USER_2.id], + restaurants: [TEST_RESTAURANT, TEST_RESTAURANT_2, TEST_RESTAURANT_3], + likes: {} as Record, + swipeCounts: {} as Record, + match: null as string | null, + creatorId: TEST_USER.id, + locked: false, + kickedUsers: [] as string[], + scene: "eat" as const, +}; + +export const TEST_BLINDBOX_ROOM = { + id: "bb-room-1", + code: "ABC123", + name: "我们的周末", + creatorId: TEST_USER.id, + city: null, + lat: null, + lng: null, + createdAt: new Date("2025-01-01"), +}; + +export const TEST_BLINDBOX_IDEA = { + id: "idea-1", + roomId: "bb-room-1", + userId: TEST_USER.id, + content: "去公园野餐", + status: "in_pool", + category: "outdoor", + timeSlot: "morning", + estimatedMinutes: 120, + outdoor: true, + searchQuery: "公园", + searchType: "category", + drawnById: null, + createdAt: new Date("2025-01-01"), +}; + +export const TEST_WEEKEND_PLAN = { + id: "plan-1", + roomId: "bb-room-1", + userId: TEST_USER.id, + status: "active", + planData: JSON.stringify({ + days: [{ + date: "周六", + items: [ + { time: "10:00", activity: "去公园", poi: "某公园", address: "xx路", lat: 31.2, lng: 121.4, duration: 120, reason: "天气好" }, + ], + }], + summary: "愉快的一天", + }), + endTime: null, + createdAt: new Date("2025-01-01"), +}; diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts new file mode 100644 index 0000000..adf3902 --- /dev/null +++ b/src/__tests__/helpers/prisma-mock.ts @@ -0,0 +1,13 @@ +import { vi } from "vitest"; +import { mockDeep, mockReset, type DeepMockProxy } from "vitest-mock-extended"; +import type { PrismaClient } from "@prisma/client"; + +export const prismaMock = mockDeep(); + +vi.mock("@/lib/prisma", () => ({ + prisma: prismaMock, +})); + +export function resetPrismaMock() { + mockReset(prismaMock); +} diff --git a/src/__tests__/helpers/setup.ts b/src/__tests__/helpers/setup.ts new file mode 100644 index 0000000..f061ee2 --- /dev/null +++ b/src/__tests__/helpers/setup.ts @@ -0,0 +1,6 @@ +import "@testing-library/jest-dom/vitest"; +import { afterEach, vi } from "vitest"; + +afterEach(() => { + vi.restoreAllMocks(); +}); diff --git a/src/app/api/auth/login/route.test.ts b/src/app/api/auth/login/route.test.ts new file mode 100644 index 0000000..2a47e1c --- /dev/null +++ b/src/app/api/auth/login/route.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; +import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; +import { TEST_USER } from "@/__tests__/helpers/fixtures"; + +vi.mock("bcryptjs", () => ({ + default: { + compare: vi.fn(), + }, +})); + +import bcrypt from "bcryptjs"; +import { POST } from "./route"; + +const mockCompare = vi.mocked(bcrypt.compare); + +beforeEach(() => { + resetPrismaMock(); + mockCompare.mockReset(); +}); + +describe("POST /api/auth/login", () => { + it("logs in successfully with correct credentials", async () => { + prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never); + mockCompare.mockResolvedValue(true as never); + + const req = createRequest("/api/auth/login", { + method: "POST", + body: { username: "testuser", password: "password123" }, + }); + const res = await POST(req, { params: Promise.resolve({}) }); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.id).toBe(TEST_USER.id); + expect(data.username).toBe("testuser"); + expect(data.avatar).toBe("🐱"); + }); + + it("returns 401 when user not found", async () => { + prismaMock.user.findUnique.mockResolvedValue(null as never); + + const req = createRequest("/api/auth/login", { + method: "POST", + body: { username: "nonexistent", password: "password123" }, + }); + const res = await POST(req, { params: Promise.resolve({}) }); + expect(res.status).toBe(401); + }); + + it("returns 401 when password is wrong", async () => { + prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never); + mockCompare.mockResolvedValue(false as never); + + const req = createRequest("/api/auth/login", { + method: "POST", + body: { username: "testuser", password: "wrongpassword" }, + }); + const res = await POST(req, { params: Promise.resolve({}) }); + expect(res.status).toBe(401); + }); + + it("returns 400 when fields are missing", async () => { + const req = createRequest("/api/auth/login", { + method: "POST", + body: {}, + }); + const res = await POST(req, { params: Promise.resolve({}) }); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/auth/register/route.test.ts b/src/app/api/auth/register/route.test.ts new file mode 100644 index 0000000..81195d0 --- /dev/null +++ b/src/app/api/auth/register/route.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; +import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; +import { TEST_USER } from "@/__tests__/helpers/fixtures"; + +vi.mock("bcryptjs", () => ({ + default: { hash: vi.fn().mockResolvedValue("$2a$10$hashed") }, +})); + +import { POST } from "./route"; + +beforeEach(() => { + resetPrismaMock(); +}); + +describe("POST /api/auth/register", () => { + it("registers a new user successfully", async () => { + prismaMock.user.create.mockResolvedValue({ + ...TEST_USER, + id: "new-user", + username: "newuser", + avatar: "🐱", + } as never); + + const req = createRequest("/api/auth/register", { + method: "POST", + body: { username: "newuser", password: "password123" }, + }); + const res = await POST(req, { params: Promise.resolve({}) }); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.username).toBe("newuser"); + expect(data.id).toBe("new-user"); + expect(data.avatar).toBe("🐱"); + }); + + it("uses custom avatar if provided", async () => { + prismaMock.user.create.mockResolvedValue({ + ...TEST_USER, + avatar: "🦊", + } as never); + + const req = createRequest("/api/auth/register", { + method: "POST", + body: { username: "newuser", password: "password123", avatar: "🦊" }, + }); + const res = await POST(req, { params: Promise.resolve({}) }); + const { data } = await parseJsonResponse(res); + + expect(data.avatar).toBe("🦊"); + }); + + it("returns 400 when username is missing", async () => { + const req = createRequest("/api/auth/register", { + method: "POST", + body: { password: "password123" }, + }); + const res = await POST(req, { params: Promise.resolve({}) }); + expect(res.status).toBe(400); + }); + + it("returns 400 when password is missing", async () => { + const req = createRequest("/api/auth/register", { + method: "POST", + body: { username: "testuser" }, + }); + const res = await POST(req, { params: Promise.resolve({}) }); + expect(res.status).toBe(400); + }); + + it("returns 400 when username too short", async () => { + const req = createRequest("/api/auth/register", { + method: "POST", + body: { username: "a", password: "password123" }, + }); + const res = await POST(req, { params: Promise.resolve({}) }); + expect(res.status).toBe(400); + }); + + it("returns 400 when password too short", async () => { + const req = createRequest("/api/auth/register", { + method: "POST", + body: { username: "testuser", password: "12345" }, + }); + const res = await POST(req, { params: Promise.resolve({}) }); + expect(res.status).toBe(400); + }); + + it("returns 409 when username already exists", async () => { + const { Prisma } = await import("@prisma/client"); + prismaMock.user.create.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique constraint", { + code: "P2002", + clientVersion: "5.0.0", + }), + ); + + const req = createRequest("/api/auth/register", { + method: "POST", + body: { username: "existing", password: "password123" }, + }); + const res = await POST(req, { params: Promise.resolve({}) }); + expect(res.status).toBe(409); + }); +}); diff --git a/src/app/api/blindbox/draw/route.test.ts b/src/app/api/blindbox/draw/route.test.ts new file mode 100644 index 0000000..372f612 --- /dev/null +++ b/src/app/api/blindbox/draw/route.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; +import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; +import { TEST_USER } from "@/__tests__/helpers/fixtures"; + +vi.mock("@/lib/blindbox", () => ({ + requireMembership: vi.fn().mockResolvedValue({}), +})); + +import { POST } from "./route"; + +const mockCtx = { params: Promise.resolve({}) }; + +beforeEach(() => { + resetPrismaMock(); +}); + +describe("POST /api/blindbox/draw", () => { + it("draws a random idea", async () => { + prismaMock.$transaction.mockImplementation(async (fn: (tx: unknown) => Promise) => { + const tx = { + blindBoxIdea: { + findMany: vi.fn().mockResolvedValue([{ id: "idea-1" }]), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + findUnique: vi.fn().mockResolvedValue({ + id: "idea-1", + content: "去公园", + createdAt: new Date(), + user: { id: "user-2", username: "submitter", avatar: "🐶" }, + drawnBy: { id: "user-1", username: "drawer", avatar: "🐱" }, + }), + }, + }; + return fn(tx); + }); + + const req = createRequest("/api/blindbox/draw", { + method: "POST", + body: { roomId: "bb-room-1", userId: "user-1" }, + }); + const res = await POST(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.id).toBe("idea-1"); + expect(data.content).toBe("去公园"); + expect(data.submitter).toBeDefined(); + expect(data.drawnBy).toBeDefined(); + }); + + it("returns 404 when pool is empty", async () => { + prismaMock.$transaction.mockImplementation(async (fn: (tx: unknown) => Promise) => { + const tx = { + blindBoxIdea: { + findMany: vi.fn().mockResolvedValue([]), + updateMany: vi.fn(), + findUnique: vi.fn(), + }, + }; + return fn(tx); + }); + + const req = createRequest("/api/blindbox/draw", { + method: "POST", + body: { roomId: "bb-room-1", userId: "user-1" }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(404); + }); + + it("returns 409 on race condition (count=0)", async () => { + prismaMock.$transaction.mockImplementation(async (fn: (tx: unknown) => Promise) => { + const tx = { + blindBoxIdea: { + findMany: vi.fn().mockResolvedValue([{ id: "idea-1" }]), + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + findUnique: vi.fn(), + }, + }; + return fn(tx); + }); + + const req = createRequest("/api/blindbox/draw", { + method: "POST", + body: { roomId: "bb-room-1", userId: "user-1" }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(409); + }); + + it("returns 400 when roomId is missing", async () => { + const req = createRequest("/api/blindbox/draw", { + method: "POST", + body: { userId: "user-1" }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/blindbox/plan/route.test.ts b/src/app/api/blindbox/plan/route.test.ts new file mode 100644 index 0000000..faa04e9 --- /dev/null +++ b/src/app/api/blindbox/plan/route.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; +import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; +import { TEST_WEEKEND_PLAN } from "@/__tests__/helpers/fixtures"; + +vi.mock("@/lib/blindbox", () => ({ + requireMembership: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/blindboxPlanGen", () => ({ + runPlanGeneration: vi.fn().mockResolvedValue({ + id: "plan-1", + days: [{ date: "周六", items: [] }], + createdAt: new Date(), + }), +})); + +import { POST, PATCH, GET } from "./route"; + +const mockCtx = { params: Promise.resolve({}) }; + +beforeEach(() => { + resetPrismaMock(); +}); + +describe("POST /api/blindbox/plan", () => { + it("generates a weekend plan", async () => { + const req = createRequest("/api/blindbox/plan", { + method: "POST", + body: { + roomId: "bb-room-1", + userId: "user-1", + availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 }, + }, + }); + const res = await POST(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.id).toBe("plan-1"); + }); + + it("returns 400 for invalid available time", async () => { + const req = createRequest("/api/blindbox/plan", { + method: "POST", + body: { + roomId: "bb-room-1", + userId: "user-1", + availableTime: { date: "2025-03-01", startHour: 18, endHour: 9 }, + }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(400); + }); + + it("returns 400 when roomId is missing", async () => { + const req = createRequest("/api/blindbox/plan", { + method: "POST", + body: { + userId: "user-1", + availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 }, + }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(400); + }); +}); + +describe("PATCH /api/blindbox/plan", () => { + it("accepts a plan", async () => { + prismaMock.weekendPlan.findUnique.mockResolvedValue({ + ...TEST_WEEKEND_PLAN, + status: "active", + } as never); + prismaMock.weekendPlan.update.mockResolvedValue({} as never); + + const req = createRequest("/api/blindbox/plan", { + method: "PATCH", + body: { planId: "plan-1", userId: "user-1", action: "accept" }, + }); + const res = await PATCH(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.ok).toBe(true); + }); + + it("completes an accepted plan", async () => { + prismaMock.weekendPlan.findUnique.mockResolvedValue({ + ...TEST_WEEKEND_PLAN, + status: "accepted", + } as never); + prismaMock.weekendPlan.update.mockResolvedValue({} as never); + + const req = createRequest("/api/blindbox/plan", { + method: "PATCH", + body: { planId: "plan-1", userId: "user-1", action: "complete" }, + }); + const res = await PATCH(req, mockCtx); + expect(res.status).toBe(200); + }); + + it("expires an accepted plan", async () => { + prismaMock.weekendPlan.findUnique.mockResolvedValue({ + ...TEST_WEEKEND_PLAN, + status: "accepted", + } as never); + prismaMock.weekendPlan.update.mockResolvedValue({} as never); + + const req = createRequest("/api/blindbox/plan", { + method: "PATCH", + body: { planId: "plan-1", userId: "user-1", action: "expire" }, + }); + const res = await PATCH(req, mockCtx); + expect(res.status).toBe(200); + }); + + it("returns 400 when accepting non-active plan", async () => { + prismaMock.weekendPlan.findUnique.mockResolvedValue({ + ...TEST_WEEKEND_PLAN, + status: "accepted", + } as never); + + const req = createRequest("/api/blindbox/plan", { + method: "PATCH", + body: { planId: "plan-1", userId: "user-1", action: "accept" }, + }); + const res = await PATCH(req, mockCtx); + expect(res.status).toBe(400); + }); + + it("returns 403 when not plan owner", async () => { + prismaMock.weekendPlan.findUnique.mockResolvedValue({ + ...TEST_WEEKEND_PLAN, + userId: "other-user", + } as never); + + const req = createRequest("/api/blindbox/plan", { + method: "PATCH", + body: { planId: "plan-1", userId: "user-1", action: "accept" }, + }); + const res = await PATCH(req, mockCtx); + expect(res.status).toBe(403); + }); + + it("returns 400 for invalid action", async () => { + prismaMock.weekendPlan.findUnique.mockResolvedValue(TEST_WEEKEND_PLAN as never); + + const req = createRequest("/api/blindbox/plan", { + method: "PATCH", + body: { planId: "plan-1", userId: "user-1", action: "invalid" }, + }); + const res = await PATCH(req, mockCtx); + expect(res.status).toBe(400); + }); +}); + +describe("GET /api/blindbox/plan", () => { + it("returns latest accepted plan", async () => { + prismaMock.weekendPlan.findFirst.mockResolvedValue({ + id: "plan-1", + planData: JSON.stringify({ days: [{ date: "周六", items: [] }] }), + endTime: null, + createdAt: new Date(), + } as never); + + const req = createRequest("/api/blindbox/plan?mode=latest&userId=user-1&roomId=bb-room-1"); + const res = await GET(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.plan).toBeDefined(); + expect(data.plan.id).toBe("plan-1"); + }); + + it("returns null when no plan found", async () => { + prismaMock.weekendPlan.findFirst.mockResolvedValue(null as never); + + const req = createRequest("/api/blindbox/plan?mode=latest&userId=user-1&roomId=bb-room-1"); + const res = await GET(req, mockCtx); + const { data } = await parseJsonResponse(res); + + expect(data.plan).toBeNull(); + }); + + it("returns 400 for invalid mode", async () => { + const req = createRequest("/api/blindbox/plan?mode=invalid&userId=user-1"); + const res = await GET(req, mockCtx); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/blindbox/plan/stream/route.test.ts b/src/app/api/blindbox/plan/stream/route.test.ts new file mode 100644 index 0000000..62ca7d6 --- /dev/null +++ b/src/app/api/blindbox/plan/stream/route.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/prisma", () => ({ prisma: {} })); + +vi.mock("@/lib/blindbox", () => ({ + requireMembership: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/api", () => ({ + requireUserId: vi.fn((v) => { + if (!v || typeof v !== "string") throw new Error("请先登录"); + return v; + }), +})); + +vi.mock("@/lib/blindboxPlanGen", () => ({ + runPlanGeneration: vi.fn(), +})); + +import { POST } from "./route"; +import { runPlanGeneration } from "@/lib/blindboxPlanGen"; + +const mockRunPlan = vi.mocked(runPlanGeneration); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +async function readStream(response: Response): Promise { + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let text = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + return text; +} + +describe("POST /api/blindbox/plan/stream", () => { + it("streams plan generation with SSE events", async () => { + mockRunPlan.mockImplementation(async (_roomId, _userId, _at, onProgress) => { + onProgress?.("正在搜索周边..."); + onProgress?.("正在生成行程..."); + return { + id: "plan-1", + days: [{ date: "周六", items: [] }], + createdAt: new Date(), + }; + }); + + const req = new Request("http://localhost/api/blindbox/plan/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + roomId: "bb-room-1", + userId: "user-1", + availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 }, + }), + }); + + const res = await POST(req); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("text/event-stream"); + + const text = await readStream(res); + expect(text).toContain("event: status"); + expect(text).toContain("正在搜索周边..."); + expect(text).toContain("event: plan"); + expect(text).toContain("plan-1"); + }); + + it("streams error event on generation failure", async () => { + mockRunPlan.mockRejectedValue(new Error("AI 服务不可用")); + + const req = new Request("http://localhost/api/blindbox/plan/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + roomId: "bb-room-1", + userId: "user-1", + availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 }, + }), + }); + + const res = await POST(req); + const text = await readStream(res); + expect(text).toContain("event: error"); + expect(text).toContain("AI 服务不可用"); + }); + + it("returns 400 for missing roomId", async () => { + const req = new Request("http://localhost/api/blindbox/plan/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + userId: "user-1", + availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 }, + }), + }); + + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid available time", async () => { + const req = new Request("http://localhost/api/blindbox/plan/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + roomId: "bb-room-1", + userId: "user-1", + availableTime: { date: "2025-03-01", startHour: 18, endHour: 9 }, + }), + }); + + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("returns 400 when userId is missing", async () => { + const req = new Request("http://localhost/api/blindbox/plan/stream", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + roomId: "bb-room-1", + availableTime: { date: "2025-03-01", startHour: 9, endHour: 18 }, + }), + }); + + const res = await POST(req); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/blindbox/room/[code]/route.test.ts b/src/app/api/blindbox/room/[code]/route.test.ts new file mode 100644 index 0000000..b714ec0 --- /dev/null +++ b/src/app/api/blindbox/room/[code]/route.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; +import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; +import { TEST_BLINDBOX_ROOM, TEST_USER } from "@/__tests__/helpers/fixtures"; + +vi.mock("@/lib/blindbox", () => ({ + getRoomByCode: vi.fn(), + requireMembership: vi.fn().mockResolvedValue({}), +})); + +import { GET, PATCH, DELETE } from "./route"; +import { getRoomByCode } from "@/lib/blindbox"; + +const mockGetRoomByCode = vi.mocked(getRoomByCode); + +beforeEach(() => { + resetPrismaMock(); + vi.clearAllMocks(); +}); + +describe("GET /api/blindbox/room/[code]", () => { + it("returns room data", async () => { + mockGetRoomByCode.mockResolvedValue({ + ...TEST_BLINDBOX_ROOM, + _count: { ideas: 3 }, + members: [ + { user: { id: "user-1", username: "test", avatar: "🐱" }, joinedAt: new Date() }, + ], + } as never); + + const req = createRequest("/api/blindbox/room/ABC123"); + const ctx = createRouteContext({ code: "ABC123" }); + const res = await GET(req, ctx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.code).toBe("ABC123"); + expect(data.poolCount).toBe(3); + expect(data.members).toHaveLength(1); + }); + + it("returns 404 for nonexistent room", async () => { + mockGetRoomByCode.mockResolvedValue(null); + + const req = createRequest("/api/blindbox/room/BADCODE"); + const ctx = createRouteContext({ code: "BADCODE" }); + const res = await GET(req, ctx); + expect(res.status).toBe(404); + }); +}); + +describe("PATCH /api/blindbox/room/[code]", () => { + it("updates room location", async () => { + prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never); + prismaMock.blindBoxRoom.update.mockResolvedValue({ + ...TEST_BLINDBOX_ROOM, + city: "上海", + lat: 31.2, + lng: 121.4, + } as never); + + const req = createRequest("/api/blindbox/room/ABC123", { + method: "PATCH", + body: { userId: "user-1", city: "上海", lat: 31.2, lng: 121.4 }, + }); + const ctx = createRouteContext({ code: "ABC123" }); + const res = await PATCH(req, ctx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.city).toBe("上海"); + }); + + it("returns 400 for invalid coordinates", async () => { + prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never); + + const req = createRequest("/api/blindbox/room/ABC123", { + method: "PATCH", + body: { userId: "user-1", lat: 999, lng: 121.4 }, + }); + const ctx = createRouteContext({ code: "ABC123" }); + const res = await PATCH(req, ctx); + expect(res.status).toBe(400); + }); +}); + +describe("DELETE /api/blindbox/room/[code]", () => { + it("deletes room when creator", async () => { + prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never); + prismaMock.blindBoxRoom.delete.mockResolvedValue({} as never); + + const req = createRequest("/api/blindbox/room/ABC123", { + method: "DELETE", + body: { userId: "user-1" }, + }); + const ctx = createRouteContext({ code: "ABC123" }); + const res = await DELETE(req, ctx); + const { data } = await parseJsonResponse(res); + + expect(data.action).toBe("deleted"); + }); + + it("leaves room when not creator", async () => { + prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never); + prismaMock.blindBoxMember.findUnique.mockResolvedValue({ id: "member-2" } as never); + prismaMock.blindBoxMember.delete.mockResolvedValue({} as never); + + const req = createRequest("/api/blindbox/room/ABC123", { + method: "DELETE", + body: { userId: "user-2" }, + }); + const ctx = createRouteContext({ code: "ABC123" }); + const res = await DELETE(req, ctx); + const { data } = await parseJsonResponse(res); + + expect(data.action).toBe("left"); + }); + + it("returns 403 when not a member and not creator", async () => { + prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never); + prismaMock.blindBoxMember.findUnique.mockResolvedValue(null as never); + + const req = createRequest("/api/blindbox/room/ABC123", { + method: "DELETE", + body: { userId: "stranger" }, + }); + const ctx = createRouteContext({ code: "ABC123" }); + const res = await DELETE(req, ctx); + expect(res.status).toBe(403); + }); +}); diff --git a/src/app/api/blindbox/room/join/route.test.ts b/src/app/api/blindbox/room/join/route.test.ts new file mode 100644 index 0000000..06461cf --- /dev/null +++ b/src/app/api/blindbox/room/join/route.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; +import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; +import { TEST_BLINDBOX_ROOM } from "@/__tests__/helpers/fixtures"; + +import { POST } from "./route"; + +const mockCtx = { params: Promise.resolve({}) }; + +beforeEach(() => { + resetPrismaMock(); +}); + +describe("POST /api/blindbox/room/join", () => { + it("joins a room by code", async () => { + prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never); + prismaMock.blindBoxMember.findUnique.mockResolvedValue(null as never); + prismaMock.blindBoxMember.create.mockResolvedValue({} as never); + + const req = createRequest("/api/blindbox/room/join", { + method: "POST", + body: { userId: "user-2", code: "ABC123" }, + }); + const res = await POST(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(201); + expect(data.code).toBe("ABC123"); + }); + + it("returns alreadyMember if already joined", async () => { + prismaMock.blindBoxRoom.findUnique.mockResolvedValue(TEST_BLINDBOX_ROOM as never); + prismaMock.blindBoxMember.findUnique.mockResolvedValue({ id: "member-1" } as never); + + const req = createRequest("/api/blindbox/room/join", { + method: "POST", + body: { userId: "user-1", code: "ABC123" }, + }); + const res = await POST(req, mockCtx); + const { data } = await parseJsonResponse(res); + + expect(data.alreadyMember).toBe(true); + }); + + it("returns 404 when room code not found", async () => { + prismaMock.blindBoxRoom.findUnique.mockResolvedValue(null as never); + + const req = createRequest("/api/blindbox/room/join", { + method: "POST", + body: { userId: "user-1", code: "BADCODE" }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(404); + }); + + it("returns 400 when code is missing", async () => { + const req = createRequest("/api/blindbox/room/join", { + method: "POST", + body: { userId: "user-1" }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/blindbox/room/route.test.ts b/src/app/api/blindbox/room/route.test.ts new file mode 100644 index 0000000..116e5b4 --- /dev/null +++ b/src/app/api/blindbox/room/route.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; +import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; +import { TEST_USER, TEST_BLINDBOX_ROOM } from "@/__tests__/helpers/fixtures"; + +vi.mock("@/lib/blindbox", () => ({ + generateUniqueRoomCode: vi.fn().mockResolvedValue("XYZ789"), +})); + +import { POST } from "./route"; + +const mockCtx = { params: Promise.resolve({}) }; + +beforeEach(() => { + resetPrismaMock(); +}); + +describe("POST /api/blindbox/room", () => { + it("creates a blindbox room", async () => { + prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never); + prismaMock.blindBoxRoom.create.mockResolvedValue({ + ...TEST_BLINDBOX_ROOM, + code: "XYZ789", + } as never); + + const req = createRequest("/api/blindbox/room", { + method: "POST", + body: { userId: "user-1", name: "周末计划" }, + }); + const res = await POST(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(201); + expect(data.code).toBe("XYZ789"); + }); + + it("uses default room name when not provided", async () => { + prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never); + prismaMock.blindBoxRoom.create.mockResolvedValue(TEST_BLINDBOX_ROOM as never); + + const req = createRequest("/api/blindbox/room", { + method: "POST", + body: { userId: "user-1" }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(201); + }); + + it("returns 401 when no userId", async () => { + const req = createRequest("/api/blindbox/room", { + method: "POST", + body: { name: "test" }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(401); + }); + + it("returns 400 when room name too long", async () => { + const req = createRequest("/api/blindbox/room", { + method: "POST", + body: { userId: "user-1", name: "a".repeat(31) }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/blindbox/rooms/route.test.ts b/src/app/api/blindbox/rooms/route.test.ts new file mode 100644 index 0000000..500d10e --- /dev/null +++ b/src/app/api/blindbox/rooms/route.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; +import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; + +import { GET } from "./route"; + +const mockCtx = { params: Promise.resolve({}) }; + +beforeEach(() => { + resetPrismaMock(); +}); + +describe("GET /api/blindbox/rooms", () => { + it("returns user rooms list", async () => { + prismaMock.blindBoxMember.findMany.mockResolvedValue([ + { + room: { + id: "bb-room-1", + code: "ABC123", + name: "周末", + creatorId: "user-1", + _count: { members: 2, ideas: 5 }, + members: [ + { user: { id: "user-1", username: "test", avatar: "🐱" } }, + ], + ideas: [{ content: "去公园", createdAt: new Date() }], + }, + joinedAt: new Date(), + }, + ] as never); + + prismaMock.blindBoxIdea.groupBy.mockResolvedValue([ + { roomId: "bb-room-1", _count: 3 }, + ] as never); + + const req = createRequest("/api/blindbox/rooms?userId=user-1"); + const res = await GET(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.rooms).toHaveLength(1); + expect(data.rooms[0].code).toBe("ABC123"); + expect(data.rooms[0].poolCount).toBe(3); + }); + + it("returns 401 when no userId", async () => { + const req = createRequest("/api/blindbox/rooms"); + const res = await GET(req, mockCtx); + expect(res.status).toBe(401); + }); +}); diff --git a/src/app/api/blindbox/route.test.ts b/src/app/api/blindbox/route.test.ts new file mode 100644 index 0000000..6a9e39d --- /dev/null +++ b/src/app/api/blindbox/route.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; +import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; +import { TEST_BLINDBOX_IDEA } from "@/__tests__/helpers/fixtures"; + +vi.mock("@/lib/blindbox", () => ({ + requireMembership: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/ai", () => ({ + tagIdea: vi.fn().mockResolvedValue({ + category: "outdoor", + timeSlot: "morning", + estimatedMinutes: 120, + outdoor: true, + searchQuery: "公园", + searchType: "category", + }), +})); + +import { POST, GET, PUT, DELETE } from "./route"; + +const mockCtx = { params: Promise.resolve({}) }; + +beforeEach(() => { + resetPrismaMock(); +}); + +describe("POST /api/blindbox (create idea)", () => { + it("creates an idea successfully", async () => { + prismaMock.blindBoxIdea.create.mockResolvedValue(TEST_BLINDBOX_IDEA as never); + prismaMock.blindBoxIdea.update.mockResolvedValue(TEST_BLINDBOX_IDEA as never); + + const req = createRequest("/api/blindbox", { + method: "POST", + body: { roomId: "bb-room-1", userId: "user-1", content: "去公园野餐" }, + }); + const res = await POST(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(201); + expect(data.id).toBe("idea-1"); + expect(data.tags).toBeDefined(); + }); + + it("returns 401 when no userId", async () => { + const req = createRequest("/api/blindbox", { + method: "POST", + body: { roomId: "bb-room-1", content: "test" }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(401); + }); + + it("returns 400 when content is empty", async () => { + const req = createRequest("/api/blindbox", { + method: "POST", + body: { roomId: "bb-room-1", userId: "user-1", content: "" }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(400); + }); + + it("returns 400 when content over 200 chars", async () => { + const req = createRequest("/api/blindbox", { + method: "POST", + body: { roomId: "bb-room-1", userId: "user-1", content: "a".repeat(201) }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(400); + }); +}); + +describe("GET /api/blindbox (get pool data)", () => { + it("returns pool data for valid member", async () => { + prismaMock.blindBoxIdea.count.mockResolvedValue(5 as never); + prismaMock.blindBoxIdea.findMany + .mockResolvedValueOnce([TEST_BLINDBOX_IDEA] as never) + .mockResolvedValueOnce([] as never); + + const req = createRequest("/api/blindbox?userId=user-1&roomId=bb-room-1"); + const res = await GET(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.poolCount).toBe(5); + expect(data.myIdeas).toHaveLength(1); + expect(data.drawn).toHaveLength(0); + }); + + it("returns 401 when no userId", async () => { + const req = createRequest("/api/blindbox?roomId=bb-room-1"); + const res = await GET(req, mockCtx); + expect(res.status).toBe(401); + }); +}); + +describe("PUT /api/blindbox (edit idea)", () => { + it("edits an idea successfully", async () => { + prismaMock.blindBoxIdea.updateMany.mockResolvedValue({ count: 1 } as never); + + const req = createRequest("/api/blindbox", { + method: "PUT", + body: { ideaId: "idea-1", userId: "user-1", content: "去公园散步" }, + }); + const res = await PUT(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.content).toBe("去公园散步"); + }); + + it("returns 404 when idea not found or already drawn", async () => { + prismaMock.blindBoxIdea.updateMany.mockResolvedValue({ count: 0 } as never); + + const req = createRequest("/api/blindbox", { + method: "PUT", + body: { ideaId: "nonexistent", userId: "user-1", content: "test" }, + }); + const res = await PUT(req, mockCtx); + expect(res.status).toBe(404); + }); +}); + +describe("DELETE /api/blindbox (delete idea)", () => { + it("deletes an idea successfully", async () => { + prismaMock.blindBoxIdea.deleteMany.mockResolvedValue({ count: 1 } as never); + + const req = createRequest("/api/blindbox", { + method: "DELETE", + body: { ideaId: "idea-1", userId: "user-1" }, + }); + const res = await DELETE(req, mockCtx); + const { data } = await parseJsonResponse(res); + + expect(data.deleted).toBe(true); + }); + + it("returns 404 when idea not found or not owned", async () => { + prismaMock.blindBoxIdea.deleteMany.mockResolvedValue({ count: 0 } as never); + + const req = createRequest("/api/blindbox", { + method: "DELETE", + body: { ideaId: "nonexistent", userId: "user-1" }, + }); + const res = await DELETE(req, mockCtx); + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/blindbox/suggest/route.test.ts b/src/app/api/blindbox/suggest/route.test.ts new file mode 100644 index 0000000..a041782 --- /dev/null +++ b/src/app/api/blindbox/suggest/route.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; +import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; + +vi.mock("@/lib/blindbox", () => ({ + requireMembership: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/ai", () => ({ + suggestIdeas: vi.fn().mockResolvedValue(["去爬山", "骑自行车", "看日出", "野餐"]), +})); + +import { GET } from "./route"; + +const mockCtx = { params: Promise.resolve({}) }; + +beforeEach(() => { + resetPrismaMock(); +}); + +describe("GET /api/blindbox/suggest", () => { + it("returns AI suggestions when enough ideas exist", async () => { + prismaMock.blindBoxIdea.findMany.mockResolvedValue([ + { content: "去公园" }, + { content: "看电影" }, + { content: "吃火锅" }, + ] as never); + + const req = createRequest("/api/blindbox/suggest?roomId=bb-room-1&userId=user-1"); + const res = await GET(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.suggestions).toHaveLength(4); + expect(data.source).toBe("ai"); + }); + + it("returns empty when less than 2 ideas", async () => { + prismaMock.blindBoxIdea.findMany.mockResolvedValue([ + { content: "去公园" }, + ] as never); + + const req = createRequest("/api/blindbox/suggest?roomId=bb-room-1&userId=user-1"); + const res = await GET(req, mockCtx); + const { data } = await parseJsonResponse(res); + + expect(data.suggestions).toHaveLength(0); + expect(data.source).toBe("none"); + }); + + it("returns 400 when roomId missing", async () => { + const req = createRequest("/api/blindbox/suggest?userId=user-1"); + const res = await GET(req, mockCtx); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/location/regeo/route.test.ts b/src/app/api/location/regeo/route.test.ts new file mode 100644 index 0000000..0d1ef03 --- /dev/null +++ b/src/app/api/location/regeo/route.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; + +vi.mock("@/lib/prisma", () => ({ prisma: {} })); + +vi.mock("@/lib/amap", () => ({ + requireAmapApiKey: vi.fn().mockReturnValue("test-key"), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +import { GET } from "./route"; + +const mockCtx = { params: Promise.resolve({}) }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/location/regeo", () => { + it("returns reverse geocoded location", async () => { + mockFetch.mockResolvedValue({ + json: () => + Promise.resolve({ + status: "1", + regeocode: { + formatted_address: "上海市黄浦区人民大道", + addressComponent: { + district: "黄浦区", + township: "南京东路街道", + neighborhood: { name: "人民广场" }, + }, + }, + }), + }); + + const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47"); + const res = await GET(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.name).toContain("黄浦区"); + expect(data.formatted).toBe("上海市黄浦区人民大道"); + }); + + it("returns null name when API returns no result", async () => { + mockFetch.mockResolvedValue({ + json: () => Promise.resolve({ status: "0" }), + }); + + const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47"); + const res = await GET(req, mockCtx); + const { data } = await parseJsonResponse(res); + expect(data.name).toBeNull(); + }); + + it("returns 400 when coordinates missing", async () => { + const req = createRequest("/api/location/regeo"); + const res = await GET(req, mockCtx); + expect(res.status).toBe(400); + }); + + it("returns 503 when API unavailable", async () => { + mockFetch.mockRejectedValue(new Error("network")); + + const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47"); + const res = await GET(req, mockCtx); + expect(res.status).toBe(503); + }); +}); diff --git a/src/app/api/location/search/route.test.ts b/src/app/api/location/search/route.test.ts new file mode 100644 index 0000000..73dd897 --- /dev/null +++ b/src/app/api/location/search/route.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; + +vi.mock("@/lib/prisma", () => ({ prisma: {} })); + +vi.mock("@/lib/amap", () => ({ + requireAmapApiKey: vi.fn().mockReturnValue("test-key"), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +import { GET } from "./route"; + +const mockCtx = { params: Promise.resolve({}) }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/location/search", () => { + it("returns search results", async () => { + mockFetch.mockResolvedValue({ + json: () => + Promise.resolve({ + status: "1", + pois: [ + { + id: "poi-1", + name: "星巴克", + address: "南京路1号", + location: "121.4,31.2", + business: { rating: "4.5", cost: "40" }, + }, + ], + }), + }); + + const req = createRequest("/api/location/search?keywords=星巴克"); + const res = await GET(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data).toHaveLength(1); + expect(data[0].name).toBe("星巴克"); + expect(data[0].lat).toBe(31.2); + expect(data[0].lng).toBe(121.4); + }); + + it("returns empty when no results", async () => { + mockFetch.mockResolvedValue({ + json: () => Promise.resolve({ status: "1", pois: [] }), + }); + + const req = createRequest("/api/location/search?keywords=不存在的地方"); + const res = await GET(req, mockCtx); + const { data } = await parseJsonResponse(res); + expect(data).toEqual([]); + }); + + it("returns 400 when keywords missing", async () => { + const req = createRequest("/api/location/search"); + const res = await GET(req, mockCtx); + expect(res.status).toBe(400); + }); + + it("returns 503 when API unavailable", async () => { + mockFetch.mockRejectedValue(new Error("network error")); + + const req = createRequest("/api/location/search?keywords=test"); + const res = await GET(req, mockCtx); + expect(res.status).toBe(503); + }); +}); diff --git a/src/app/api/location/suggest/route.test.ts b/src/app/api/location/suggest/route.test.ts new file mode 100644 index 0000000..79957c9 --- /dev/null +++ b/src/app/api/location/suggest/route.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; + +vi.mock("@/lib/prisma", () => ({ prisma: {} })); + +vi.mock("@/lib/amap", () => ({ + requireAmapApiKey: vi.fn().mockReturnValue("test-key"), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +import { GET } from "./route"; + +const mockCtx = { params: Promise.resolve({}) }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/location/suggest", () => { + it("returns suggestions", async () => { + mockFetch.mockResolvedValue({ + json: () => + Promise.resolve({ + status: "1", + tips: [ + { + id: "tip-1", + name: "人民广场", + district: "黄浦区", + address: "人民大道", + location: "121.4737,31.2304", + }, + ], + }), + }); + + const req = createRequest("/api/location/suggest?keywords=人民广场"); + const res = await GET(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data).toHaveLength(1); + expect(data[0].name).toBe("人民广场"); + }); + + it("returns empty for no keywords", async () => { + const req = createRequest("/api/location/suggest"); + const res = await GET(req, mockCtx); + const { data } = await parseJsonResponse(res); + expect(data).toEqual([]); + }); + + it("filters tips without location", async () => { + mockFetch.mockResolvedValue({ + json: () => + Promise.resolve({ + status: "1", + tips: [ + { id: "tip-1", name: "有位置", location: "121.4,31.2" }, + { id: "tip-2", name: "无位置", location: "" }, + ], + }), + }); + + const req = createRequest("/api/location/suggest?keywords=test"); + const res = await GET(req, mockCtx); + const { data } = await parseJsonResponse(res); + expect(data).toHaveLength(1); + expect(data[0].name).toBe("有位置"); + }); + + it("returns 503 when API fails", async () => { + mockFetch.mockRejectedValue(new Error("network")); + + const req = createRequest("/api/location/suggest?keywords=test"); + const res = await GET(req, mockCtx); + expect(res.status).toBe(503); + }); +}); diff --git a/src/app/api/room/[id]/join/route.test.ts b/src/app/api/room/[id]/join/route.test.ts new file mode 100644 index 0000000..f98bbba --- /dev/null +++ b/src/app/api/room/[id]/join/route.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; +import { TEST_ROOM_DATA } from "@/__tests__/helpers/fixtures"; + +vi.mock("@/lib/prisma", () => ({ prisma: {} })); + +vi.mock("@/lib/store", () => ({ + atomicUpdateRoom: vi.fn(), +})); + +vi.mock("@/lib/roomEvents", () => ({ + notify: vi.fn(), +})); + +import { POST } from "./route"; +import { atomicUpdateRoom } from "@/lib/store"; + +const mockAtomicUpdate = vi.mocked(atomicUpdateRoom); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("POST /api/room/[id]/join", () => { + it("joins a room successfully", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = { ...TEST_ROOM_DATA, users: ["user-1"] }; + return updater(data); + }); + + const req = createRequest("/api/room/ROOM01/join", { + method: "POST", + body: { userId: "user-2" }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.roomId).toBe("ROOM01"); + expect(data.userCount).toBe(2); + }); + + it("returns 403 when kicked", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = { ...TEST_ROOM_DATA, kickedUsers: ["user-2"] }; + return updater(data); + }); + + const req = createRequest("/api/room/ROOM01/join", { + method: "POST", + body: { userId: "user-2" }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + expect(res.status).toBe(403); + }); + + it("returns 403 when room is locked", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = { ...TEST_ROOM_DATA, locked: true, users: ["user-1"] }; + return updater(data); + }); + + const req = createRequest("/api/room/ROOM01/join", { + method: "POST", + body: { userId: "user-2" }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + expect(res.status).toBe(403); + }); + + it("returns 404 when room not found", async () => { + mockAtomicUpdate.mockResolvedValue(null); + + const req = createRequest("/api/room/NONEXIST/join", { + method: "POST", + body: { userId: "user-1" }, + }); + const ctx = createRouteContext({ id: "NONEXIST" }); + const res = await POST(req, ctx); + expect(res.status).toBe(404); + }); + + it("returns 401 when no userId", async () => { + const req = createRequest("/api/room/ROOM01/join", { + method: "POST", + body: {}, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + expect(res.status).toBe(401); + }); +}); diff --git a/src/app/api/room/[id]/manage/route.test.ts b/src/app/api/room/[id]/manage/route.test.ts new file mode 100644 index 0000000..656d247 --- /dev/null +++ b/src/app/api/room/[id]/manage/route.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; +import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures"; + +vi.mock("@/lib/prisma", () => ({ prisma: {} })); + +vi.mock("@/lib/store", () => ({ + atomicUpdateRoom: vi.fn(), +})); + +vi.mock("@/lib/roomEvents", () => ({ + notify: vi.fn(), +})); + +import { POST } from "./route"; +import { atomicUpdateRoom } from "@/lib/store"; + +const mockAtomicUpdate = vi.mocked(atomicUpdateRoom); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("POST /api/room/[id]/manage", () => { + it("locks the room", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = structuredClone(TEST_ROOM_DATA); + const result = updater(data); + expect(result.locked).toBe(true); + return result; + }); + + const req = createRequest("/api/room/ROOM01/manage", { + method: "POST", + body: { userId: "user-1", action: "lock" }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + expect(res.status).toBe(200); + }); + + it("unlocks the room", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = structuredClone(TEST_ROOM_DATA); + data.locked = true; + const result = updater(data); + expect(result.locked).toBe(false); + return result; + }); + + const req = createRequest("/api/room/ROOM01/manage", { + method: "POST", + body: { userId: "user-1", action: "unlock" }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + expect(res.status).toBe(200); + }); + + it("kicks a user", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = structuredClone(TEST_ROOM_DATA); + const result = updater(data); + expect(result.users).not.toContain("user-2"); + expect(result.kickedUsers).toContain("user-2"); + return result; + }); + + const req = createRequest("/api/room/ROOM01/manage", { + method: "POST", + body: { userId: "user-1", action: "kick", targetUserId: "user-2" }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + expect(res.status).toBe(200); + }); + + it("prevents kicking yourself", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = structuredClone(TEST_ROOM_DATA); + return updater(data); + }); + + const req = createRequest("/api/room/ROOM01/manage", { + method: "POST", + body: { userId: "user-1", action: "kick", targetUserId: "user-1" }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + expect(res.status).toBe(400); + }); + + it("ends voting by setting all swipeCounts to total", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = structuredClone(TEST_ROOM_DATA); + const result = updater(data); + expect(result.swipeCounts["user-1"]).toBe(3); + expect(result.swipeCounts["user-2"]).toBe(3); + return result; + }); + + const req = createRequest("/api/room/ROOM01/manage", { + method: "POST", + body: { userId: "user-1", action: "end_voting" }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + expect(res.status).toBe(200); + }); + + it("returns 403 when not the creator", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = structuredClone(TEST_ROOM_DATA); + data.creatorId = "other-user"; + return updater(data); + }); + + const req = createRequest("/api/room/ROOM01/manage", { + method: "POST", + body: { userId: "user-1", action: "lock" }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + expect(res.status).toBe(403); + }); + + it("returns 400 for unknown action", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = structuredClone(TEST_ROOM_DATA); + return updater(data); + }); + + const req = createRequest("/api/room/ROOM01/manage", { + method: "POST", + body: { userId: "user-1", action: "unknown" }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/api/room/[id]/reset/route.test.ts b/src/app/api/room/[id]/reset/route.test.ts new file mode 100644 index 0000000..a4bdd1d --- /dev/null +++ b/src/app/api/room/[id]/reset/route.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; +import { TEST_ROOM_DATA, TEST_RESTAURANT, TEST_RESTAURANT_2 } from "@/__tests__/helpers/fixtures"; + +vi.mock("@/lib/prisma", () => ({ prisma: {} })); + +vi.mock("@/lib/store", () => ({ + atomicUpdateRoom: vi.fn(), +})); + +vi.mock("@/lib/roomEvents", () => ({ + notify: vi.fn(), +})); + +import { POST } from "./route"; +import { atomicUpdateRoom } from "@/lib/store"; + +const mockAtomicUpdate = vi.mocked(atomicUpdateRoom); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("POST /api/room/[id]/reset", () => { + it("resets the room (clears likes/swipeCounts/match)", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = structuredClone(TEST_ROOM_DATA); + data.likes = { "rest-1": ["user-1"] }; + data.swipeCounts = { "user-1": 3 }; + data.match = "rest-1"; + const result = updater(data); + expect(result.likes).toEqual({}); + expect(result.swipeCounts).toEqual({}); + expect(result.match).toBeNull(); + return result; + }); + + const req = createRequest("/api/room/ROOM01/reset", { + method: "POST", + body: { userId: "user-1" }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.ok).toBe(true); + }); + + it("filters restaurants when restaurantIds provided", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = structuredClone(TEST_ROOM_DATA); + const result = updater(data); + expect(result.restaurants).toHaveLength(1); + expect(result.restaurants[0].id).toBe(TEST_RESTAURANT.id); + return result; + }); + + const req = createRequest("/api/room/ROOM01/reset", { + method: "POST", + body: { userId: "user-1", restaurantIds: [TEST_RESTAURANT.id] }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + await POST(req, ctx); + }); + + it("returns 403 when not a member or creator", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = structuredClone(TEST_ROOM_DATA); + data.users = ["other-user"]; + data.creatorId = "other-user"; + return updater(data); + }); + + const req = createRequest("/api/room/ROOM01/reset", { + method: "POST", + body: { userId: "user-1" }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + expect(res.status).toBe(403); + }); + + it("returns 404 when room not found", async () => { + mockAtomicUpdate.mockResolvedValue(null); + + const req = createRequest("/api/room/NONEXIST/reset", { + method: "POST", + body: { userId: "user-1" }, + }); + const ctx = createRouteContext({ id: "NONEXIST" }); + const res = await POST(req, ctx); + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/room/[id]/route.test.ts b/src/app/api/room/[id]/route.test.ts new file mode 100644 index 0000000..ebb37ce --- /dev/null +++ b/src/app/api/room/[id]/route.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; + +vi.mock("@/lib/prisma", () => ({ + prisma: { user: { findMany: vi.fn().mockResolvedValue([]) } }, +})); + +vi.mock("@/lib/buildRoomStatus", () => ({ + buildRoomStatus: vi.fn(), +})); + +import { GET } from "./route"; +import { buildRoomStatus } from "@/lib/buildRoomStatus"; + +const mockBuildRoomStatus = vi.mocked(buildRoomStatus); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /api/room/[id]", () => { + it("returns room status", async () => { + mockBuildRoomStatus.mockResolvedValue({ + roomId: "ROOM01", + userCount: 2, + match: null, + matchType: null, + matchLikes: 0, + runnerUps: [], + likeCounts: {}, + swipeCounts: {}, + restaurants: [], + creatorId: "user-1", + locked: false, + users: ["user-1"], + userProfiles: {}, + scene: "eat", + }); + + const req = createRequest("/api/room/ROOM01"); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await GET(req, ctx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.roomId).toBe("ROOM01"); + }); + + it("returns 404 for nonexistent room", async () => { + mockBuildRoomStatus.mockResolvedValue(null); + + const req = createRequest("/api/room/NONEXIST"); + const ctx = createRouteContext({ id: "NONEXIST" }); + const res = await GET(req, ctx); + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/room/[id]/swipe/route.test.ts b/src/app/api/room/[id]/swipe/route.test.ts new file mode 100644 index 0000000..14face5 --- /dev/null +++ b/src/app/api/room/[id]/swipe/route.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; +import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures"; + +vi.mock("@/lib/prisma", () => ({ prisma: {} })); + +vi.mock("@/lib/store", () => ({ + atomicUpdateRoom: vi.fn(), +})); + +vi.mock("@/lib/roomEvents", () => ({ + notify: vi.fn(), +})); + +import { POST } from "./route"; +import { atomicUpdateRoom } from "@/lib/store"; + +const mockAtomicUpdate = vi.mocked(atomicUpdateRoom); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("POST /api/room/[id]/swipe", () => { + it("records a like action", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = structuredClone(TEST_ROOM_DATA); + return updater(data); + }); + + const req = createRequest("/api/room/ROOM01/swipe", { + method: "POST", + body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id, action: "like" }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.likeCount).toBe(1); + }); + + it("records a pass action", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = structuredClone(TEST_ROOM_DATA); + return updater(data); + }); + + const req = createRequest("/api/room/ROOM01/swipe", { + method: "POST", + body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id, action: "pass" }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.match).toBeNull(); + }); + + it("sets match when all users like same restaurant", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = structuredClone(TEST_ROOM_DATA); + data.likes[TEST_RESTAURANT.id] = ["user-2"]; + return updater(data); + }); + + const req = createRequest("/api/room/ROOM01/swipe", { + method: "POST", + body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id, action: "like" }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + const { data } = await parseJsonResponse(res); + + expect(data.match).toBe(TEST_RESTAURANT.id); + expect(data.likeCount).toBe(2); + }); + + it("returns 403 when user is not a member", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = structuredClone(TEST_ROOM_DATA); + data.users = ["other-user"]; + return updater(data); + }); + + const req = createRequest("/api/room/ROOM01/swipe", { + method: "POST", + body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id, action: "like" }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + expect(res.status).toBe(403); + }); + + it("returns 400 for invalid action", async () => { + const req = createRequest("/api/room/ROOM01/swipe", { + method: "POST", + body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id, action: "invalid" }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + expect(res.status).toBe(400); + }); + + it("returns 400 when missing restaurantId", async () => { + const req = createRequest("/api/room/ROOM01/swipe", { + method: "POST", + body: { userId: "user-1", action: "like" }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + expect(res.status).toBe(400); + }); + + it("returns 404 when room not found", async () => { + mockAtomicUpdate.mockResolvedValue(null); + + const req = createRequest("/api/room/ROOM01/swipe", { + method: "POST", + body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id, action: "like" }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/room/[id]/undo/route.test.ts b/src/app/api/room/[id]/undo/route.test.ts new file mode 100644 index 0000000..a06b5e6 --- /dev/null +++ b/src/app/api/room/[id]/undo/route.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createRequest, createRouteContext, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; +import { TEST_ROOM_DATA, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures"; + +vi.mock("@/lib/prisma", () => ({ prisma: {} })); + +vi.mock("@/lib/store", () => ({ + atomicUpdateRoom: vi.fn(), +})); + +vi.mock("@/lib/roomEvents", () => ({ + notify: vi.fn(), +})); + +import { POST } from "./route"; +import { atomicUpdateRoom } from "@/lib/store"; + +const mockAtomicUpdate = vi.mocked(atomicUpdateRoom); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("POST /api/room/[id]/undo", () => { + it("undoes a like and decrements swipe count", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = structuredClone(TEST_ROOM_DATA); + data.likes[TEST_RESTAURANT.id] = ["user-1"]; + data.swipeCounts["user-1"] = 1; + return updater(data); + }); + + const req = createRequest("/api/room/ROOM01/undo", { + method: "POST", + body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.ok).toBe(true); + }); + + it("clears match when undoing the matched restaurant", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = structuredClone(TEST_ROOM_DATA); + data.match = TEST_RESTAURANT.id; + data.likes[TEST_RESTAURANT.id] = ["user-1", "user-2"]; + data.swipeCounts["user-1"] = 1; + const result = updater(data); + expect(result.match).toBeNull(); + return result; + }); + + const req = createRequest("/api/room/ROOM01/undo", { + method: "POST", + body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + await POST(req, ctx); + }); + + it("returns 403 when user is not a member", async () => { + mockAtomicUpdate.mockImplementation(async (_id, updater) => { + const data = structuredClone(TEST_ROOM_DATA); + data.users = ["other-user"]; + return updater(data); + }); + + const req = createRequest("/api/room/ROOM01/undo", { + method: "POST", + body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id }, + }); + const ctx = createRouteContext({ id: "ROOM01" }); + const res = await POST(req, ctx); + expect(res.status).toBe(403); + }); + + it("returns 404 when room not found", async () => { + mockAtomicUpdate.mockResolvedValue(null); + + const req = createRequest("/api/room/NONEXIST/undo", { + method: "POST", + body: { userId: "user-1", restaurantId: TEST_RESTAURANT.id }, + }); + const ctx = createRouteContext({ id: "NONEXIST" }); + const res = await POST(req, ctx); + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/room/create/route.test.ts b/src/app/api/room/create/route.test.ts new file mode 100644 index 0000000..6484b2a --- /dev/null +++ b/src/app/api/room/create/route.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; + +vi.mock("@/lib/prisma", () => ({ + prisma: {}, +})); + +vi.mock("@/lib/store", () => ({ + createRoom: vi.fn().mockResolvedValue("ROOM01"), +})); + +vi.mock("@/lib/amap", () => ({ + requireAmapApiKey: vi.fn().mockReturnValue("test-key"), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +import { POST } from "./route"; +import { createRoom } from "@/lib/store"; + +const mockCtx = { params: Promise.resolve({}) }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("POST /api/room/create", () => { + it("creates a room with restaurants from Amap", async () => { + mockFetch.mockResolvedValue({ + json: () => + Promise.resolve({ + status: "1", + pois: [ + { + id: "poi-1", + name: "好吃餐厅", + distance: "300", + type: "餐饮服务;中餐厅;川菜", + address: "测试路1号", + location: "121.4,31.2", + business: { rating: "4.5", cost: "80" }, + photos: [{ url: "https://img.example.com/1.jpg" }], + }, + ], + }), + }); + + const req = createRequest("/api/room/create", { + method: "POST", + body: { lat: 31.2, lng: 121.4, userId: "user-1" }, + }); + const res = await POST(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.roomId).toBe("ROOM01"); + expect(data.restaurants).toHaveLength(1); + expect(data.restaurants[0].name).toBe("好吃餐厅"); + expect(createRoom).toHaveBeenCalled(); + }); + + it("returns 400 for invalid coordinates", async () => { + const req = createRequest("/api/room/create", { + method: "POST", + body: { lat: "invalid", lng: 121.4, userId: "user-1" }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(400); + }); + + it("returns 400 for out-of-range coordinates", async () => { + const req = createRequest("/api/room/create", { + method: "POST", + body: { lat: 100, lng: 121.4, userId: "user-1" }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(400); + }); + + it("returns 404 when no restaurants found", async () => { + mockFetch.mockResolvedValue({ + json: () => Promise.resolve({ status: "1", pois: [] }), + }); + + const req = createRequest("/api/room/create", { + method: "POST", + body: { lat: 31.2, lng: 121.4, userId: "user-1" }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(404); + }); + + it("returns 503 when Amap API fails", async () => { + mockFetch.mockRejectedValue(new Error("network error")); + + const req = createRequest("/api/room/create", { + method: "POST", + body: { lat: 31.2, lng: 121.4, userId: "user-1" }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(503); + }); + + it("filters restaurants by price range", async () => { + mockFetch.mockResolvedValue({ + json: () => + Promise.resolve({ + status: "1", + pois: [ + { + id: "poi-1", + name: "便宜店", + location: "121.4,31.2", + business: { cost: "30" }, + }, + { + id: "poi-2", + name: "贵店", + location: "121.4,31.2", + business: { cost: "150" }, + }, + ], + }), + }); + + const req = createRequest("/api/room/create", { + method: "POST", + body: { lat: 31.2, lng: 121.4, userId: "user-1", priceRange: "under50" }, + }); + const res = await POST(req, mockCtx); + const { data } = await parseJsonResponse(res); + + expect(data.restaurants).toHaveLength(1); + expect(data.restaurants[0].name).toBe("便宜店"); + }); +}); diff --git a/src/app/api/user/achievements/route.test.ts b/src/app/api/user/achievements/route.test.ts new file mode 100644 index 0000000..59173ab --- /dev/null +++ b/src/app/api/user/achievements/route.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; +import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; +import { TEST_RESTAURANT } from "@/__tests__/helpers/fixtures"; + +import { GET } from "./route"; + +const mockCtx = { params: Promise.resolve({}) }; + +beforeEach(() => { + resetPrismaMock(); +}); + +describe("GET /api/user/achievements", () => { + it("returns 401 when no userId", async () => { + const req = createRequest("/api/user/achievements"); + const res = await GET(req, mockCtx); + expect(res.status).toBe(401); + }); + + it("returns stats and records", async () => { + prismaMock.decision.findMany.mockResolvedValue([ + { + id: "dec-1", + userId: "user-1", + roomId: "room-1", + restaurantName: "测试餐厅", + restaurantData: JSON.stringify(TEST_RESTAURANT), + matchType: "unanimous", + participants: 2, + createdAt: new Date("2025-01-01"), + }, + ] as never); + + prismaMock.weekendPlan.findMany.mockResolvedValue([ + { + id: "plan-1", + planData: JSON.stringify({ + days: [{ date: "周六", items: [{ activity: "逛公园" }] }], + }), + status: "completed", + roomId: "bb-room-1", + createdAt: new Date("2025-01-01"), + }, + ] as never); + + prismaMock.blindBoxRoom.findMany.mockResolvedValue([ + { id: "bb-room-1", name: "周末", code: "ABC123" }, + ] as never); + + const req = createRequest("/api/user/achievements?userId=user-1"); + const res = await GET(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.stats.totalDecisions).toBe(1); + expect(data.stats.totalContracts).toBe(1); + expect(data.stats.completedContracts).toBe(1); + expect(data.stats.completionRate).toBe(100); + expect(data.decisions).toHaveLength(1); + expect(data.contracts).toHaveLength(1); + expect(data.contracts[0].roomName).toBe("周末"); + }); +}); diff --git a/src/app/api/user/favorite/route.test.ts b/src/app/api/user/favorite/route.test.ts new file mode 100644 index 0000000..34151b3 --- /dev/null +++ b/src/app/api/user/favorite/route.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; +import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; +import { TEST_USER, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures"; + +import { GET, POST, DELETE } from "./route"; + +const mockCtx = { params: Promise.resolve({}) }; + +beforeEach(() => { + resetPrismaMock(); +}); + +describe("GET /api/user/favorite", () => { + it("returns empty array when no userId", async () => { + const req = createRequest("/api/user/favorite"); + const res = await GET(req, mockCtx); + const { data } = await parseJsonResponse(res); + expect(data).toEqual([]); + }); + + it("returns favorites list", async () => { + prismaMock.favorite.findMany.mockResolvedValue([ + { + id: "fav-1", + userId: "user-1", + restaurantId: "rest-1", + restaurantData: JSON.stringify(TEST_RESTAURANT), + createdAt: new Date("2025-01-01"), + }, + ] as never); + + const req = createRequest("/api/user/favorite?userId=user-1"); + const res = await GET(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data).toHaveLength(1); + expect(data[0].id).toBe("fav-1"); + expect(data[0].restaurantData.name).toBe("测试餐厅"); + }); +}); + +describe("POST /api/user/favorite", () => { + it("adds a favorite", async () => { + prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never); + prismaMock.favorite.create.mockResolvedValue({ id: "fav-new" } as never); + + const req = createRequest("/api/user/favorite", { + method: "POST", + body: { userId: "user-1", restaurant: TEST_RESTAURANT }, + }); + const res = await POST(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.id).toBe("fav-new"); + }); + + it("returns alreadyExists on duplicate", async () => { + const { Prisma } = await import("@prisma/client"); + prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never); + prismaMock.favorite.create.mockRejectedValue( + new Prisma.PrismaClientKnownRequestError("Unique", { + code: "P2002", + clientVersion: "5.0.0", + }), + ); + prismaMock.favorite.findFirst.mockResolvedValue({ id: "fav-existing" } as never); + + const req = createRequest("/api/user/favorite", { + method: "POST", + body: { userId: "user-1", restaurant: TEST_RESTAURANT }, + }); + const res = await POST(req, mockCtx); + const { data } = await parseJsonResponse(res); + + expect(data.alreadyExists).toBe(true); + expect(data.id).toBe("fav-existing"); + }); + + it("returns 401 when no userId", async () => { + const req = createRequest("/api/user/favorite", { + method: "POST", + body: { restaurant: TEST_RESTAURANT }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(401); + }); + + it("returns 400 when no restaurant", async () => { + const req = createRequest("/api/user/favorite", { + method: "POST", + body: { userId: "user-1" }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(400); + }); +}); + +describe("DELETE /api/user/favorite", () => { + it("deletes a favorite", async () => { + prismaMock.favorite.findUnique.mockResolvedValue({ + id: "fav-1", + userId: "user-1", + } as never); + prismaMock.favorite.delete.mockResolvedValue({} as never); + + const req = createRequest("/api/user/favorite", { + method: "DELETE", + body: { userId: "user-1", favoriteId: "fav-1" }, + }); + const res = await DELETE(req, mockCtx); + const { data } = await parseJsonResponse(res); + expect(data.ok).toBe(true); + }); + + it("returns 404 when favorite not found", async () => { + prismaMock.favorite.findUnique.mockResolvedValue(null as never); + + const req = createRequest("/api/user/favorite", { + method: "DELETE", + body: { userId: "user-1", favoriteId: "nonexistent" }, + }); + const res = await DELETE(req, mockCtx); + expect(res.status).toBe(404); + }); + + it("returns 404 when favorite belongs to another user", async () => { + prismaMock.favorite.findUnique.mockResolvedValue({ + id: "fav-1", + userId: "other-user", + } as never); + + const req = createRequest("/api/user/favorite", { + method: "DELETE", + body: { userId: "user-1", favoriteId: "fav-1" }, + }); + const res = await DELETE(req, mockCtx); + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/user/history/route.test.ts b/src/app/api/user/history/route.test.ts new file mode 100644 index 0000000..f4746ab --- /dev/null +++ b/src/app/api/user/history/route.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; +import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; +import { TEST_USER, TEST_RESTAURANT } from "@/__tests__/helpers/fixtures"; + +import { GET, POST } from "./route"; + +const mockCtx = { params: Promise.resolve({}) }; + +beforeEach(() => { + resetPrismaMock(); +}); + +describe("GET /api/user/history", () => { + it("returns empty array when no userId", async () => { + const req = createRequest("/api/user/history"); + const res = await GET(req, mockCtx); + const { data } = await parseJsonResponse(res); + expect(data).toEqual([]); + }); + + it("returns decision history", async () => { + prismaMock.decision.findMany.mockResolvedValue([ + { + id: "dec-1", + userId: "user-1", + roomId: "room-1", + restaurantName: "测试餐厅", + restaurantData: JSON.stringify(TEST_RESTAURANT), + matchType: "unanimous", + participants: 2, + createdAt: new Date("2025-01-01"), + }, + ] as never); + + const req = createRequest("/api/user/history?userId=user-1"); + const res = await GET(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data).toHaveLength(1); + expect(data[0].restaurantName).toBe("测试餐厅"); + }); +}); + +describe("POST /api/user/history", () => { + it("saves a decision", async () => { + prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never); + prismaMock.decision.findFirst.mockResolvedValue(null as never); + prismaMock.decision.create.mockResolvedValue({ id: "dec-new" } as never); + prismaMock.decision.count.mockResolvedValue(1 as never); + + const req = createRequest("/api/user/history", { + method: "POST", + body: { + userId: "user-1", + roomId: "room-1", + restaurant: TEST_RESTAURANT, + matchType: "unanimous", + participants: 2, + }, + }); + const res = await POST(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.id).toBe("dec-new"); + }); + + it("returns alreadyExists for duplicate room", async () => { + prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never); + prismaMock.decision.findFirst.mockResolvedValue({ id: "existing" } as never); + + const req = createRequest("/api/user/history", { + method: "POST", + body: { + userId: "user-1", + roomId: "room-1", + restaurant: TEST_RESTAURANT, + matchType: "unanimous", + }, + }); + const res = await POST(req, mockCtx); + const { data } = await parseJsonResponse(res); + + expect(data.alreadyExists).toBe(true); + }); + + it("trims history to 50 records", async () => { + prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never); + prismaMock.decision.findFirst.mockResolvedValue(null as never); + prismaMock.decision.create.mockResolvedValue({ id: "dec-new" } as never); + prismaMock.decision.count.mockResolvedValue(51 as never); + prismaMock.decision.findMany.mockResolvedValue([{ id: "old-1" }] as never); + prismaMock.decision.deleteMany.mockResolvedValue({ count: 1 } as never); + + const req = createRequest("/api/user/history", { + method: "POST", + body: { + userId: "user-1", + roomId: "room-1", + restaurant: TEST_RESTAURANT, + matchType: "best", + }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(200); + expect(prismaMock.decision.deleteMany).toHaveBeenCalled(); + }); + + it("returns 400 when missing required fields", async () => { + const req = createRequest("/api/user/history", { + method: "POST", + body: { userId: "user-1" }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(400); + }); + + it("returns 401 when no userId", async () => { + const req = createRequest("/api/user/history", { + method: "POST", + body: { roomId: "room-1", restaurant: TEST_RESTAURANT, matchType: "best" }, + }); + const res = await POST(req, mockCtx); + expect(res.status).toBe(401); + }); +}); diff --git a/src/app/api/user/route.test.ts b/src/app/api/user/route.test.ts new file mode 100644 index 0000000..865db66 --- /dev/null +++ b/src/app/api/user/route.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock"; +import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils"; +import { TEST_USER } from "@/__tests__/helpers/fixtures"; + +vi.mock("bcryptjs", () => ({ + default: { + compare: vi.fn(), + hash: vi.fn().mockResolvedValue("$2a$10$newhash"), + }, +})); + +import bcrypt from "bcryptjs"; +import { GET, PUT } from "./route"; + +const mockCtx = { params: Promise.resolve({}) }; + +beforeEach(() => { + resetPrismaMock(); + vi.mocked(bcrypt.compare).mockReset(); +}); + +describe("GET /api/user", () => { + it("returns null when no userId provided", async () => { + const req = createRequest("/api/user"); + const res = await GET(req, mockCtx); + const { data } = await parseJsonResponse(res); + expect(data).toBeNull(); + }); + + it("returns null when user not found", async () => { + prismaMock.user.findUnique.mockResolvedValue(null as never); + + const req = createRequest("/api/user?id=nonexistent"); + const res = await GET(req, mockCtx); + const { data } = await parseJsonResponse(res); + expect(data).toBeNull(); + }); + + it("returns user info with decision count", async () => { + prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never); + prismaMock.decision.count.mockResolvedValue(5 as never); + + const req = createRequest("/api/user?id=user-1"); + const res = await GET(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.id).toBe("user-1"); + expect(data.username).toBe("testuser"); + expect(data.decisionCount).toBe(5); + }); +}); + +describe("PUT /api/user", () => { + it("updates username", async () => { + prismaMock.user.findUnique + .mockResolvedValueOnce(TEST_USER as never) + .mockResolvedValueOnce(null as never); + prismaMock.user.update.mockResolvedValue({ + ...TEST_USER, + username: "newname", + preferences: "{}", + } as never); + + const req = createRequest("/api/user", { + method: "PUT", + body: { userId: "user-1", username: "newname" }, + }); + const res = await PUT(req, mockCtx); + const { status, data } = await parseJsonResponse(res); + + expect(status).toBe(200); + expect(data.username).toBe("newname"); + }); + + it("returns 409 when new username is taken", async () => { + prismaMock.user.findUnique + .mockResolvedValueOnce(TEST_USER as never) + .mockResolvedValueOnce({ id: "other" } as never); + + const req = createRequest("/api/user", { + method: "PUT", + body: { userId: "user-1", username: "takenname" }, + }); + const res = await PUT(req, mockCtx); + expect(res.status).toBe(409); + }); + + it("updates password with correct current password", async () => { + prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never); + vi.mocked(bcrypt.compare).mockResolvedValue(true as never); + prismaMock.user.update.mockResolvedValue({ ...TEST_USER, preferences: "{}" } as never); + + const req = createRequest("/api/user", { + method: "PUT", + body: { userId: "user-1", currentPassword: "old", newPassword: "newpass123" }, + }); + const res = await PUT(req, mockCtx); + expect(res.status).toBe(200); + }); + + it("returns 403 when current password is wrong", async () => { + prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never); + vi.mocked(bcrypt.compare).mockResolvedValue(false as never); + + const req = createRequest("/api/user", { + method: "PUT", + body: { userId: "user-1", currentPassword: "wrong", newPassword: "newpass123" }, + }); + const res = await PUT(req, mockCtx); + expect(res.status).toBe(403); + }); + + it("returns 400 when no current password for password change", async () => { + prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never); + + const req = createRequest("/api/user", { + method: "PUT", + body: { userId: "user-1", newPassword: "newpass123" }, + }); + const res = await PUT(req, mockCtx); + expect(res.status).toBe(400); + }); + + it("returns 401 when no userId", async () => { + const req = createRequest("/api/user", { + method: "PUT", + body: { username: "test" }, + }); + const res = await PUT(req, mockCtx); + expect(res.status).toBe(401); + }); + + it("updates avatar", async () => { + prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never); + prismaMock.user.update.mockResolvedValue({ ...TEST_USER, avatar: "🦊", preferences: "{}" } as never); + + const req = createRequest("/api/user", { + method: "PUT", + body: { userId: "user-1", avatar: "🦊" }, + }); + const res = await PUT(req, mockCtx); + const { data } = await parseJsonResponse(res); + expect(data.avatar).toBe("🦊"); + }); + + it("updates email with validation", async () => { + prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never); + prismaMock.user.update.mockResolvedValue({ + ...TEST_USER, + email: "new@example.com", + preferences: "{}", + } as never); + + const req = createRequest("/api/user", { + method: "PUT", + body: { userId: "user-1", email: "new@example.com" }, + }); + const res = await PUT(req, mockCtx); + expect(res.status).toBe(200); + }); + + it("rejects invalid email", async () => { + prismaMock.user.findUnique.mockResolvedValue(TEST_USER as never); + + const req = createRequest("/api/user", { + method: "PUT", + body: { userId: "user-1", email: "notanemail" }, + }); + const res = await PUT(req, mockCtx); + expect(res.status).toBe(400); + }); +}); diff --git a/src/app/blindbox/[code]/page.test.tsx b/src/app/blindbox/[code]/page.test.tsx new file mode 100644 index 0000000..ef41368 --- /dev/null +++ b/src/app/blindbox/[code]/page.test.tsx @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { ToastContext, type ToastContextValue } from "@/hooks/useToast"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + back: vi.fn(), + }), + useParams: () => ({ code: "ABC123" }), +})); + +vi.mock("@/lib/userId", () => ({ + getCachedProfile: vi.fn().mockReturnValue({ id: "u1", username: "test", avatar: "🐱" }), + isRegistered: vi.fn().mockReturnValue(true), +})); + +vi.mock("@/hooks/useShare", () => ({ + useShare: () => ({ + copyToClipboard: vi.fn().mockResolvedValue(true), + share: vi.fn(), + }), +})); + +vi.mock("canvas-confetti", () => ({ + default: vi.fn(), +})); + +vi.mock("@/components/ShareCardModal", () => ({ + default: () => null, +})); + +vi.mock("@/components/ContractCompletionModal", () => ({ + default: () => null, +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +import BlindboxCodePage from "./page"; + +const toastCtx: ToastContextValue = { show: vi.fn() }; + +function renderPage() { + return render( + React.createElement( + ToastContext.Provider, + { value: toastCtx }, + React.createElement(BlindboxCodePage), + ), + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + room: { + id: "bb-1", + code: "ABC123", + name: "周末房间", + creatorId: "u1", + city: null, + lat: null, + lng: null, + ideaCount: 3, + memberCount: 2, + drawnCount: 0, + }, + ideas: [], + members: [ + { id: "u1", username: "test", avatar: "🐱" }, + ], + myIdeas: [], + drawnHistory: [], + pendingContracts: [], + }), + }); +}); + +describe("BlindboxCodePage", () => { + it("loads room data", async () => { + renderPage(); + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + const fetchCall = mockFetch.mock.calls.find( + (c: string[]) => typeof c[0] === "string" && c[0].includes("/api/blindbox/room/ABC123"), + ); + expect(fetchCall).toBeDefined(); + }); + + it("fetches room data and loads user ideas", async () => { + renderPage(); + await waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + const urls = mockFetch.mock.calls.map((c: string[]) => c[0]); + expect(urls.some((u: string) => u.includes("blindbox/room/ABC123"))).toBe(true); + }); +}); diff --git a/src/app/blindbox/page.test.tsx b/src/app/blindbox/page.test.tsx new file mode 100644 index 0000000..d457cb4 --- /dev/null +++ b/src/app/blindbox/page.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import React from "react"; +import { ToastContext, type ToastContextValue } from "@/hooks/useToast"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + back: vi.fn(), + }), +})); + +vi.mock("@/lib/userId", () => ({ + getCachedProfile: vi.fn().mockReturnValue({ id: "u1", username: "test", avatar: "🐱" }), + isRegistered: vi.fn().mockReturnValue(true), +})); + +const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ rooms: [] }), +}); +vi.stubGlobal("fetch", mockFetch); + +import BlindboxPage from "./page"; + +const toastCtx: ToastContextValue = { show: vi.fn() }; + +function renderPage() { + return render( + React.createElement( + ToastContext.Provider, + { value: toastCtx }, + React.createElement(BlindboxPage), + ), + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("BlindboxPage", () => { + it("renders page heading", () => { + renderPage(); + expect(screen.getByText("周末契约")).toBeInTheDocument(); + }); + + it("renders back button", () => { + renderPage(); + const backBtns = screen.getAllByRole("button"); + expect(backBtns.length).toBeGreaterThan(0); + }); + + it("renders subtitle text", () => { + renderPage(); + expect(screen.getByText("ADVENTURE ROULETTE")).toBeInTheDocument(); + }); +}); diff --git a/src/app/panic/page.test.tsx b/src/app/panic/page.test.tsx new file mode 100644 index 0000000..7daab98 --- /dev/null +++ b/src/app/panic/page.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import React from "react"; +import { ToastContext, type ToastContextValue } from "@/hooks/useToast"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + back: vi.fn(), + }), +})); + +vi.mock("@/lib/userId", () => ({ + getUserId: vi.fn().mockReturnValue("user-1"), + getCachedPreferences: vi.fn().mockReturnValue({}), +})); + +vi.mock("@/lib/sceneConfig", () => ({ + SCENES: ["eat", "drinks"], + getSceneConfig: vi.fn().mockReturnValue({ + key: "eat", + label: "餐厅", + emoji: "🍜", + verb: "吃", + poiTypes: "050000", + defaultImage: "", + hotTags: ["火锅", "日料", "烧烤"], + priceOptions: [{ label: "不限", value: "any" }], + tagLabel: "口味", + tagPlaceholder: "想吃什么?", + loadingText: "正在搜索...", + emptyError: "没找到", + subtitle: "一起选餐厅", + inviteText: "邀请你", + shareTitle: "分享", + shareText: "一起来", + qrSubtitle: "扫码加入", + }), +})); + +vi.mock("@/hooks/useGeolocation", () => ({ + useGeolocation: vi.fn().mockReturnValue({ + status: "success", + coords: { lat: 31.2, lng: 121.4 }, + locationName: "上海", + retry: vi.fn(), + }), +})); + +vi.mock("@/lib/room", () => ({ + joinRoom: vi.fn().mockResolvedValue(undefined), +})); + +const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ suggestions: [] }), +}); +vi.stubGlobal("fetch", mockFetch); + +import PanicPage from "./page"; + +const toastCtx: ToastContextValue = { show: vi.fn() }; + +function renderPage() { + return render( + React.createElement( + ToastContext.Provider, + { value: toastCtx }, + React.createElement(PanicPage), + ), + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("PanicPage", () => { + it("renders page title", () => { + renderPage(); + expect(screen.getByText("极速救场")).toBeInTheDocument(); + }); + + it("renders location search input", () => { + renderPage(); + expect(screen.getByPlaceholderText(/搜索位置/)).toBeInTheDocument(); + }); + + it("renders distance options", () => { + renderPage(); + expect(screen.getByText("1km")).toBeInTheDocument(); + expect(screen.getByText("3km")).toBeInTheDocument(); + expect(screen.getByText("5km")).toBeInTheDocument(); + }); + + it("renders scene selector with labels", () => { + renderPage(); + expect(screen.getAllByText("餐厅").length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/src/app/profile/page.test.tsx b/src/app/profile/page.test.tsx new file mode 100644 index 0000000..109afd3 --- /dev/null +++ b/src/app/profile/page.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { ToastContext, type ToastContextValue } from "@/hooks/useToast"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + back: vi.fn(), + }), +})); + +vi.mock("@/lib/userId", () => ({ + getUserId: vi.fn().mockReturnValue("user-1"), + getCachedProfile: vi.fn().mockReturnValue({ id: "user-1", username: "testuser", avatar: "🐱" }), + setCachedProfile: vi.fn(), + setCachedPreferences: vi.fn(), + logout: vi.fn(), +})); + +vi.mock("@/lib/avatars", () => ({ + getAvatarBg: vi.fn().mockReturnValue("bg-amber-100"), + AVATARS: [ + { emoji: "🐱", bg: "bg-amber-100" }, + { emoji: "🐶", bg: "bg-orange-100" }, + ], +})); + +vi.mock("@/components/ProfileFavoritesCard", () => ({ + default: () =>
, +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +import ProfilePage from "./page"; + +const toastCtx: ToastContextValue = { show: vi.fn() }; + +function renderPage() { + return render( + React.createElement( + ToastContext.Provider, + { value: toastCtx }, + React.createElement(ProfilePage), + ), + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + id: "user-1", + username: "testuser", + avatar: "🐱", + email: null, + achievements: { + totalDecisions: 5, + unanimousCount: 2, + roomsCreated: 3, + streak: 1, + }, + records: [], + favorites: [], + }), + }); +}); + +describe("ProfilePage", () => { + it("renders profile heading", () => { + renderPage(); + expect(screen.getByText("个人中心")).toBeInTheDocument(); + }); + + it("fetches user profile data with correct URL", async () => { + renderPage(); + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + "/api/user?id=user-1", + ); + }); + }); + + it("renders user info after data loads", async () => { + renderPage(); + await waitFor(() => { + expect(screen.getByText("testuser")).toBeInTheDocument(); + }); + }); + + it("renders navigation element", () => { + renderPage(); + expect(screen.getByRole("navigation")).toBeInTheDocument(); + }); +}); diff --git a/src/app/room/[id]/page.test.tsx b/src/app/room/[id]/page.test.tsx new file mode 100644 index 0000000..d2e94f1 --- /dev/null +++ b/src/app/room/[id]/page.test.tsx @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { ToastContext, type ToastContextValue } from "@/hooks/useToast"; +import { TEST_RESTAURANT, TEST_RESTAURANT_2 } from "@/__tests__/helpers/fixtures"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + back: vi.fn(), + }), + useParams: () => ({ id: "ROOM01" }), +})); + +vi.mock("@/lib/userId", () => ({ + getUserId: vi.fn().mockReturnValue("user-1"), +})); + +vi.mock("@/lib/room", () => ({ + joinRoom: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/sceneConfig", () => ({ + getSceneConfig: vi.fn().mockReturnValue({ + label: "吃什么", + icon: "🍔", + gradient: "from-orange-500 to-red-500", + bg: "bg-amber-50", + }), +})); + +vi.mock("@/hooks/useRoomPolling", () => ({ + useRoomPolling: vi.fn().mockReturnValue({ + userCount: 2, + match: null, + matchType: null, + matchLikes: 0, + runnerUps: [], + likeCounts: {}, + swipeCounts: {}, + restaurants: [TEST_RESTAURANT, TEST_RESTAURANT_2], + notFound: false, + mutate: vi.fn(), + creatorId: "user-1", + locked: false, + users: ["user-1", "user-2"], + userProfiles: {}, + scene: "eat", + }), +})); + +vi.mock("@/components/TopNav", () => ({ + default: ({ roomId }: { roomId: string }) => ( + + ), +})); + +vi.mock("@/components/SwipeDeck", () => ({ + default: () =>
, +})); + +vi.mock("@/components/Skeleton", () => ({ + SwipeDeckSkeleton: () =>
, +})); + +vi.mock("@/components/LeaveConfirmModal", () => ({ + default: () => null, +})); + +import RoomPage from "./page"; + +const toastCtx: ToastContextValue = { show: vi.fn() }; + +function renderPage() { + return render( + React.createElement( + ToastContext.Provider, + { value: toastCtx }, + React.createElement(RoomPage), + ), + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("RoomPage", () => { + it("renders TopNav with room ID", async () => { + renderPage(); + await waitFor(() => { + expect(screen.getByTestId("top-nav")).toBeInTheDocument(); + expect(screen.getByText("ROOM01")).toBeInTheDocument(); + }); + }); + + it("renders SwipeDeck when joined", async () => { + renderPage(); + await waitFor(() => { + expect(screen.getByTestId("swipe-deck")).toBeInTheDocument(); + }); + }); + + it("shows 404 message when room not found", async () => { + const { useRoomPolling } = await import("@/hooks/useRoomPolling"); + vi.mocked(useRoomPolling).mockReturnValue({ + userCount: 0, + match: null, + matchType: null, + matchLikes: 0, + runnerUps: [], + likeCounts: {}, + swipeCounts: {}, + restaurants: [], + notFound: true, + mutate: vi.fn(), + creatorId: null, + locked: false, + users: [], + userProfiles: {}, + scene: "eat", + }); + + renderPage(); + await waitFor(() => { + expect(screen.getByText(/不存在/)).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/AuthModal.test.tsx b/src/components/AuthModal.test.tsx new file mode 100644 index 0000000..02e02d4 --- /dev/null +++ b/src/components/AuthModal.test.tsx @@ -0,0 +1,204 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import AuthModal from "./AuthModal"; + +vi.mock("@/lib/userId", () => ({ + setCachedProfile: vi.fn(), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +const mockOnAuth = vi.fn(); +const mockOnClose = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + // Reset localStorage + Object.defineProperty(window, "localStorage", { + value: { setItem: vi.fn(), getItem: vi.fn(), removeItem: vi.fn() }, + writable: true, + }); +}); + +function renderModal(props = {}) { + return render( + , + ); +} + +describe("AuthModal", () => { + it("renders login tab by default", () => { + renderModal(); + expect(screen.getAllByText("登录").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("注册").length).toBeGreaterThanOrEqual(1); + expect(screen.getByPlaceholderText("请输入用户名")).toBeInTheDocument(); + }); + + it("renders register tab when specified", () => { + renderModal({ defaultTab: "register" }); + expect(screen.getByPlaceholderText("2-16 个字符")).toBeInTheDocument(); + expect(screen.getByText("确认密码")).toBeInTheDocument(); + }); + + it("switches between login and register tabs", async () => { + renderModal(); + expect(screen.getByPlaceholderText("请输入用户名")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("注册")); + expect(screen.getByPlaceholderText("2-16 个字符")).toBeInTheDocument(); + expect(screen.getByText("确认密码")).toBeInTheDocument(); + }); + + it("shows error when username is empty", async () => { + renderModal(); + const submitBtn = screen.getAllByText("登录").find( + (el) => el.closest("button[class*='bg-accent']"), + )!; + fireEvent.click(submitBtn); + await waitFor(() => { + expect(screen.getByText("请输入用户名")).toBeInTheDocument(); + }); + }); + + it("shows error when password is empty", async () => { + renderModal(); + const user = userEvent.setup(); + await user.type(screen.getByPlaceholderText("请输入用户名"), "testuser"); + const submitBtn = screen.getAllByText("登录").find( + (el) => el.closest("button[class*='bg-accent']"), + )!; + fireEvent.click(submitBtn); + await waitFor(() => { + expect(screen.getByText("请输入密码")).toBeInTheDocument(); + }); + }); + + it("shows register-specific validation errors", async () => { + renderModal({ defaultTab: "register" }); + const user = userEvent.setup(); + + await user.type(screen.getByPlaceholderText("2-16 个字符"), "a"); + await user.type(screen.getByPlaceholderText("至少 6 个字符"), "123456"); + await user.type(screen.getByPlaceholderText("再次输入密码"), "654321"); + + const submitBtn = screen.getAllByText("注册").find( + (el) => el.closest("button[class*='bg-accent']"), + )!; + fireEvent.click(submitBtn); + + await waitFor(() => { + expect(screen.getByText("用户名需要 2-16 个字符")).toBeInTheDocument(); + }); + }); + + it("shows password mismatch error during registration", async () => { + renderModal({ defaultTab: "register" }); + const user = userEvent.setup(); + + await user.type(screen.getByPlaceholderText("2-16 个字符"), "testuser"); + await user.type(screen.getByPlaceholderText("至少 6 个字符"), "123456"); + await user.type(screen.getByPlaceholderText("再次输入密码"), "654321"); + + const submitBtn = screen.getAllByText("注册").find( + (el) => el.closest("button[class*='bg-accent']"), + )!; + fireEvent.click(submitBtn); + + await waitFor(() => { + expect(screen.getByText("两次密码不一致")).toBeInTheDocument(); + }); + }); + + it("submits login successfully", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ id: "u1", username: "testuser", avatar: "🐱" }), + }); + + renderModal(); + const user = userEvent.setup(); + + await user.type(screen.getByPlaceholderText("请输入用户名"), "testuser"); + await user.type(screen.getByPlaceholderText("请输入密码"), "password123"); + + const submitBtn = screen.getAllByText("登录").find( + (el) => el.closest("button[class*='bg-accent']"), + )!; + fireEvent.click(submitBtn); + + await waitFor(() => { + expect(mockOnAuth).toHaveBeenCalledWith({ + id: "u1", + username: "testuser", + avatar: "🐱", + }); + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + + it("shows API error on failed login", async () => { + mockFetch.mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ error: "用户名或密码错误" }), + }); + + renderModal(); + const user = userEvent.setup(); + + await user.type(screen.getByPlaceholderText("请输入用户名"), "testuser"); + await user.type(screen.getByPlaceholderText("请输入密码"), "wrong"); + + const submitBtn = screen.getAllByText("登录").find( + (el) => el.closest("button[class*='bg-accent']"), + )!; + fireEvent.click(submitBtn); + + await waitFor(() => { + expect(screen.getByText("用户名或密码错误")).toBeInTheDocument(); + }); + }); + + it("shows network error on fetch failure", async () => { + mockFetch.mockRejectedValue(new Error("network")); + + renderModal(); + const user = userEvent.setup(); + + await user.type(screen.getByPlaceholderText("请输入用户名"), "testuser"); + await user.type(screen.getByPlaceholderText("请输入密码"), "password123"); + + const submitBtn = screen.getAllByText("登录").find( + (el) => el.closest("button[class*='bg-accent']"), + )!; + fireEvent.click(submitBtn); + + await waitFor(() => { + expect(screen.getByText("网络错误,请重试")).toBeInTheDocument(); + }); + }); + + it("toggles password visibility", () => { + renderModal(); + const toggle = screen.getByLabelText("显示密码"); + const passwordInput = screen.getByPlaceholderText("请输入密码"); + + expect(passwordInput).toHaveAttribute("type", "password"); + fireEvent.click(toggle); + expect(passwordInput).toHaveAttribute("type", "text"); + }); + + it("does not render when closed", () => { + render( + , + ); + expect(screen.queryByText("欢迎")).not.toBeInTheDocument(); + }); + + it("renders avatar selection in register mode", () => { + renderModal({ defaultTab: "register" }); + expect(screen.getByText("选择头像")).toBeInTheDocument(); + expect(screen.getByText("🐱")).toBeInTheDocument(); + }); +}); diff --git a/src/components/BlindboxDrawnHistory.test.tsx b/src/components/BlindboxDrawnHistory.test.tsx new file mode 100644 index 0000000..44c07de --- /dev/null +++ b/src/components/BlindboxDrawnHistory.test.tsx @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import BlindboxDrawnHistory, { type DrawnIdea } from "./BlindboxDrawnHistory"; + +const mockItems: DrawnIdea[] = [ + { + id: "drawn-1", + content: "去公园野餐", + createdAt: "2025-03-01T10:00:00Z", + user: { id: "u1", username: "小明", avatar: "🐱" }, + drawnBy: { id: "u2", username: "小红", avatar: "🐶" }, + }, + { + id: "drawn-2", + content: "看展览", + createdAt: "2025-03-02T10:00:00Z", + user: { id: "u2", username: "小红", avatar: "🐶" }, + drawnBy: null, + }, +]; + +describe("BlindboxDrawnHistory", () => { + it("renders nothing when items are empty", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); + + it("renders history title and items", () => { + render(); + expect(screen.getByText("履约记录")).toBeInTheDocument(); + expect(screen.getByText("去公园野餐")).toBeInTheDocument(); + expect(screen.getByText("看展览")).toBeInTheDocument(); + }); + + it("shows user who contributed and who drew", () => { + render(); + expect(screen.getByText(/小明 投入/)).toBeInTheDocument(); + expect(screen.getByText(/小红 抽中/)).toBeInTheDocument(); + }); + + it("renders formatted date", () => { + render(); + const dateElements = screen.getAllByText(/月/); + expect(dateElements.length).toBeGreaterThan(0); + }); +}); diff --git a/src/components/BlindboxMyIdeas.test.tsx b/src/components/BlindboxMyIdeas.test.tsx new file mode 100644 index 0000000..29f00f5 --- /dev/null +++ b/src/components/BlindboxMyIdeas.test.tsx @@ -0,0 +1,95 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import BlindboxMyIdeas, { type MyIdea, CategoryBadge, DurationLabel } from "./BlindboxMyIdeas"; + +const mockIdeas: MyIdea[] = [ + { + id: "idea-1", + content: "去爬山", + createdAt: new Date().toISOString(), + category: "outdoor", + estimatedMinutes: 120, + }, + { + id: "idea-2", + content: "看电影", + createdAt: new Date().toISOString(), + category: "entertainment", + estimatedMinutes: 150, + }, +]; + +const mockOnEdit = vi.fn().mockResolvedValue(undefined); +const mockOnDelete = vi.fn().mockResolvedValue(undefined); + +describe("BlindboxMyIdeas", () => { + it("renders idea list with count", () => { + render( + , + ); + expect(screen.getByText(/我投入的想法(2)/)).toBeInTheDocument(); + expect(screen.getByText("去爬山")).toBeInTheDocument(); + expect(screen.getByText("看电影")).toBeInTheDocument(); + }); + + it("shows duration labels", () => { + render( + , + ); + expect(screen.getByText("~2h")).toBeInTheDocument(); + expect(screen.getByText("~2.5h")).toBeInTheDocument(); + }); + + it("renders empty list", () => { + render( + , + ); + expect(screen.getByText(/我投入的想法(0)/)).toBeInTheDocument(); + }); + + it("enters edit mode on pencil button click", async () => { + render( + , + ); + const editButtons = screen.getAllByRole("button"); + const pencilBtn = editButtons.find((b) => b.querySelector("svg")); + fireEvent.click(pencilBtn!); + await waitFor(() => { + expect(screen.getByRole("textbox")).toBeInTheDocument(); + }); + }); +}); + +describe("CategoryBadge", () => { + it("renders icon for known category", () => { + const { container } = render(); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + + it("renders fallback for unknown category", () => { + const { container } = render(); + expect(container.textContent).toBe("💡"); + }); + + it("renders fallback for null category", () => { + const { container } = render(); + expect(container.textContent).toBe("💡"); + }); +}); + +describe("DurationLabel", () => { + it("renders minutes", () => { + render(); + expect(screen.getByText("~45min")).toBeInTheDocument(); + }); + + it("renders hours", () => { + render(); + expect(screen.getByText("~1h")).toBeInTheDocument(); + }); + + it("renders nothing for null", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); +}); diff --git a/src/components/BlindboxPlan.test.tsx b/src/components/BlindboxPlan.test.tsx new file mode 100644 index 0000000..5457406 --- /dev/null +++ b/src/components/BlindboxPlan.test.tsx @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import BlindboxPlan from "./BlindboxPlan"; +import type { WeekendPlanData } from "@/types"; + +beforeEach(() => { + Element.prototype.scrollTo = vi.fn(); +}); + +vi.mock("@/components/BlindboxMyIdeas", () => ({ + CategoryBadge: ({ category }: { category: string }) => ( + {category} + ), +})); + +const mockDays: WeekendPlanData[] = [ + { + date: "周六", + summary: "轻松的一天", + items: [ + { + time: "10:00", + activity: "去公园散步", + poi: "朝阳公园", + address: "朝阳区", + duration: 120, + lat: 39.9, + lng: 116.4, + reason: "空气好", + }, + { + time: "14:00", + activity: "午餐", + poi: "海底捞", + address: "朝阳区", + duration: 90, + lat: 39.9, + lng: 116.4, + }, + ], + }, + { + date: "周日", + summary: "文艺的一天", + items: [ + { + time: "10:00", + activity: "参观博物馆", + poi: "国家博物馆", + address: "东城区", + duration: 180, + lat: 39.9, + lng: 116.4, + }, + ], + }, +]; + +const mockOnAccept = vi.fn(); +const mockOnRegenerate = vi.fn(); +const mockOnShare = vi.fn(); +const mockOnBack = vi.fn(); + +function renderPlan(overrides = {}) { + return render( + , + ); +} + +describe("BlindboxPlan", () => { + it("renders day header with date", () => { + renderPlan(); + expect(screen.getByText(/周六 · 行程规划/)).toBeInTheDocument(); + }); + + it("renders summary text", () => { + renderPlan(); + expect(screen.getByText("轻松的一天")).toBeInTheDocument(); + }); + + it("renders timeline items", () => { + renderPlan(); + expect(screen.getByText("去公园散步")).toBeInTheDocument(); + expect(screen.getByText("朝阳公园")).toBeInTheDocument(); + expect(screen.getByText("午餐")).toBeInTheDocument(); + }); + + it("shows duration formatted", () => { + renderPlan(); + expect(screen.getByText("2h")).toBeInTheDocument(); + expect(screen.getByText("1h30min")).toBeInTheDocument(); + }); + + it("shows accept and regenerate buttons when not accepted", () => { + renderPlan(); + expect(screen.getByText("接受契约")).toBeInTheDocument(); + expect(screen.getByText("换一个方案")).toBeInTheDocument(); + }); + + it("shows share button when accepted", () => { + renderPlan({ accepted: true }); + expect(screen.getByText("分享计划")).toBeInTheDocument(); + expect(screen.queryByText("接受契约")).not.toBeInTheDocument(); + }); + + it("calls onAccept", () => { + renderPlan(); + fireEvent.click(screen.getByText("接受契约")); + expect(mockOnAccept).toHaveBeenCalled(); + }); + + it("calls onRegenerate", () => { + renderPlan(); + fireEvent.click(screen.getByText("换一个方案")); + expect(mockOnRegenerate).toHaveBeenCalled(); + }); + + it("calls onBack", () => { + renderPlan(); + fireEvent.click(screen.getByText("返回想法池")); + expect(mockOnBack).toHaveBeenCalled(); + }); + + it("navigates between days", async () => { + renderPlan(); + expect(screen.getByText(/周六 · 行程规划/)).toBeInTheDocument(); + + fireEvent.click(screen.getByText("周日")); + await waitFor(() => { + expect(screen.getByText(/周日 · 行程规划/)).toBeInTheDocument(); + }); + }); + + it("shows day indicators for multi-day plans", () => { + renderPlan(); + expect(screen.getByText("1 / 2")).toBeInTheDocument(); + }); + + it("shows navigation link for items with coordinates", () => { + renderPlan(); + const navLinks = screen.getAllByText("导航"); + expect(navLinks.length).toBeGreaterThan(0); + }); + + it("shows reason when available", () => { + renderPlan(); + expect(screen.getByText("空气好")).toBeInTheDocument(); + }); +}); diff --git a/src/components/Button.test.tsx b/src/components/Button.test.tsx new file mode 100644 index 0000000..2844d9d --- /dev/null +++ b/src/components/Button.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import Button from "./Button"; + +describe("Button", () => { + it("renders children text", () => { + render(); + expect(screen.getByText("Click me")).toBeInTheDocument(); + }); + + it("renders with primary variant by default", () => { + render(); + const button = screen.getByRole("button"); + expect(button.className).toContain("bg-accent"); + }); + + it("renders secondary variant", () => { + render(); + const button = screen.getByRole("button"); + expect(button.className).toContain("bg-surface"); + }); + + it("renders danger variant", () => { + render(); + const button = screen.getByRole("button"); + expect(button.className).toContain("bg-rose-600"); + }); + + it("shows loading spinner and disables button", () => { + render(); + const button = screen.getByRole("button"); + expect(button).toBeDisabled(); + expect(button.querySelector(".animate-spin")).toBeTruthy(); + }); + + it("shows loadingText when loading", () => { + render( + , + ); + expect(screen.getByText("请稍候...")).toBeInTheDocument(); + }); + + it("disables button when disabled prop is set", () => { + render(); + expect(screen.getByRole("button")).toBeDisabled(); + }); + + it("applies full width class", () => { + render(); + expect(screen.getByRole("button").className).toContain("w-full"); + }); + + it("applies pill shape", () => { + render(); + expect(screen.getByRole("button").className).toContain("rounded-full"); + }); + + it("applies size classes", () => { + const { rerender } = render(); + expect(screen.getByRole("button").className).toContain("h-8"); + + rerender(); + expect(screen.getByRole("button").className).toContain("h-11"); + }); +}); diff --git a/src/components/EmptyState.test.tsx b/src/components/EmptyState.test.tsx new file mode 100644 index 0000000..b263123 --- /dev/null +++ b/src/components/EmptyState.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { Package } from "lucide-react"; +import EmptyState from "./EmptyState"; + +describe("EmptyState", () => { + it("renders title", () => { + render(); + expect(screen.getByText("没有数据")).toBeInTheDocument(); + }); + + it("renders subtitle when provided", () => { + render( + , + ); + expect(screen.getByText("还没有任何内容")).toBeInTheDocument(); + }); + + it("does not render subtitle when not provided", () => { + const { container } = render(); + expect(container.querySelectorAll("p")).toHaveLength(1); + }); + + it("renders CTA button when ctaLabel and onCta provided", () => { + const onCta = vi.fn(); + render( + , + ); + const button = screen.getByText("添加"); + expect(button).toBeInTheDocument(); + + fireEvent.click(button); + expect(onCta).toHaveBeenCalled(); + }); + + it("does not render CTA without both ctaLabel and onCta", () => { + render(); + expect(screen.queryByText("添加")).not.toBeInTheDocument(); + }); + + it("renders image when provided", () => { + render( + , + ); + expect(screen.getByAltText("空")).toBeInTheDocument(); + }); +}); diff --git a/src/components/Input.test.tsx b/src/components/Input.test.tsx new file mode 100644 index 0000000..11ba6f5 --- /dev/null +++ b/src/components/Input.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import Input from "./Input"; + +describe("Input", () => { + it("renders an input element", () => { + render(); + expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument(); + }); + + it("accepts user input", async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByPlaceholderText("Type here"); + await user.type(input, "hello"); + expect(input).toHaveValue("hello"); + }); + + it("applies size variants", () => { + const { rerender } = render(); + expect(screen.getByTestId("input").className).toContain("h-8"); + + rerender(); + expect(screen.getByTestId("input").className).toContain("h-10"); + + rerender(); + expect(screen.getByTestId("input").className).toContain("h-11"); + }); + + it("applies purple variant", () => { + render(); + expect(screen.getByTestId("input").className).toContain("focus:ring-purple-600"); + }); + + it("forwards ref", () => { + const ref = { current: null as HTMLInputElement | null }; + render(); + expect(ref.current).toBeInstanceOf(HTMLInputElement); + }); + + it("supports disabled state", () => { + render(); + expect(screen.getByTestId("input")).toBeDisabled(); + }); +}); diff --git a/src/components/MatchResult.test.tsx b/src/components/MatchResult.test.tsx new file mode 100644 index 0000000..acd13c4 --- /dev/null +++ b/src/components/MatchResult.test.tsx @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { TEST_RESTAURANT, TEST_RESTAURANT_2 } from "@/__tests__/helpers/fixtures"; +import type { ReactNode } from "react"; +import React from "react"; +import { ToastContext, type ToastContextValue } from "@/hooks/useToast"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock("@/lib/celebrate", () => ({ + fireCelebration: vi.fn(), + playChime: vi.fn(), +})); + +vi.mock("@/lib/userId", () => ({ + isRegistered: vi.fn().mockReturnValue(true), +})); + +vi.mock("./ShareCardModal", () => ({ + default: () => null, +})); + +vi.mock("./RestaurantImage", () => ({ + default: ({ alt }: { alt: string }) => {alt}, +})); + +vi.mock("./AuthModal", () => ({ + default: () => null, +})); + +vi.mock("./NoMatchResult", () => ({ + default: ({ onReset }: { onReset: () => void }) => ( +
+ +
+ ), +})); + +vi.mock("./RunnerUpCard", () => ({ + default: ({ restaurant }: { restaurant: { name: string } }) => ( +
{restaurant.name}
+ ), +})); + +const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }); +vi.stubGlobal("fetch", mockFetch); + +import MatchResult from "./MatchResult"; + +const toastCtx: ToastContextValue = { show: vi.fn() }; + +function Wrapper({ children }: { children: ReactNode }) { + return React.createElement( + ToastContext.Provider, + { value: toastCtx }, + children, + ); +} + +const baseProps = { + restaurant: TEST_RESTAURANT, + matchLikes: 2, + runnerUps: [], + allRestaurants: [TEST_RESTAURANT, TEST_RESTAURANT_2], + userCount: 2, + roomId: "ROOM01", + userId: "user-1", + onReset: vi.fn().mockResolvedValue(undefined), + onNarrow: vi.fn().mockResolvedValue(undefined), + resetting: false, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("MatchResult", () => { + it("renders unanimous match display", () => { + render( + + + , + ); + expect(screen.getByText("就去这了")).toBeInTheDocument(); + expect(screen.getByText("大家一拍即合!")).toBeInTheDocument(); + expect(screen.getByText("测试餐厅")).toBeInTheDocument(); + }); + + it("renders best match display", () => { + render( + + + , + ); + expect(screen.getByText("就去这了")).toBeInTheDocument(); + expect(screen.getByText("2/2 人想去这家")).toBeInTheDocument(); + }); + + it("renders no_match by delegating to NoMatchResult", () => { + render( + + + , + ); + expect(screen.getByTestId("no-match")).toBeInTheDocument(); + }); + + it("shows navigation button", () => { + render( + + + , + ); + expect(screen.getByText("导航过去")).toBeInTheDocument(); + }); + + it("shows phone button when tel available", () => { + render( + + + , + ); + expect(screen.getByText("打电话订位")).toBeInTheDocument(); + }); + + it("shows share button", () => { + render( + + + , + ); + expect(screen.getByText("生成分享卡片")).toBeInTheDocument(); + }); + + it("shows solo message when userCount is 1", () => { + render( + + + , + ); + expect(screen.getByText("帮你选好了")).toBeInTheDocument(); + expect(screen.getByText("你的首选,别犹豫了")).toBeInTheDocument(); + }); + + it("shows reset button", () => { + render( + + + , + ); + expect(screen.getByText("再来一轮")).toBeInTheDocument(); + }); +}); diff --git a/src/components/Modal.test.tsx b/src/components/Modal.test.tsx new file mode 100644 index 0000000..b8996d4 --- /dev/null +++ b/src/components/Modal.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import Modal from "./Modal"; + +describe("Modal", () => { + it("renders children when open", () => { + render( + {}}> +

Modal content

+
, + ); + expect(screen.getByText("Modal content")).toBeInTheDocument(); + }); + + it("does not render when closed", () => { + render( + {}}> +

Hidden content

+
, + ); + expect(screen.queryByText("Hidden content")).not.toBeInTheDocument(); + }); + + it("calls onClose when clicking backdrop", () => { + const onClose = vi.fn(); + render( + +

Content

+
, + ); + + const backdrop = screen.getByText("Content").closest("[class*='fixed']"); + if (backdrop) { + fireEvent.click(backdrop); + expect(onClose).toHaveBeenCalled(); + } + }); + + it("does not close when clicking content", () => { + const onClose = vi.fn(); + render( + +

Content

+
, + ); + + fireEvent.click(screen.getByText("Content")); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("applies sheet variant by default", () => { + render( + {}}> +

Sheet

+
, + ); + const content = screen.getByText("Sheet").closest("div[class*='rounded']"); + expect(content?.className).toContain("rounded-t-3xl"); + }); + + it("applies dialog variant", () => { + render( + {}} variant="dialog"> +

Dialog

+
, + ); + const content = screen.getByText("Dialog").closest("div[class*='rounded']"); + expect(content?.className).toContain("rounded-2xl"); + }); +}); diff --git a/src/components/Skeleton.test.tsx b/src/components/Skeleton.test.tsx new file mode 100644 index 0000000..cc0c0f5 --- /dev/null +++ b/src/components/Skeleton.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { + Skeleton, + SkeletonCircle, + RoomCardSkeleton, + ProfileCardSkeleton, + RecordItemSkeleton, + SwipeDeckSkeleton, + BlindboxRoomSkeleton, + BlindboxListSkeleton, +} from "./Skeleton"; + +describe("Skeleton", () => { + it("renders with animate-pulse", () => { + const { container } = render(); + expect(container.firstChild).toHaveClass("animate-pulse"); + }); + + it("accepts custom className", () => { + const { container } = render(); + const el = container.firstChild as HTMLElement; + expect(el.className).toContain("h-4"); + expect(el.className).toContain("w-20"); + }); +}); + +describe("SkeletonCircle", () => { + it("renders rounded-full shape", () => { + const { container } = render(); + expect(container.firstChild).toHaveClass("rounded-full"); + }); +}); + +describe("Skeleton composites", () => { + it("renders RoomCardSkeleton", () => { + const { container } = render(); + expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0); + }); + + it("renders ProfileCardSkeleton", () => { + const { container } = render(); + expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0); + }); + + it("renders RecordItemSkeleton", () => { + const { container } = render(); + expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0); + }); + + it("renders SwipeDeckSkeleton", () => { + const { container } = render(); + expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0); + }); + + it("renders BlindboxRoomSkeleton", () => { + const { container } = render(); + expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0); + }); + + it("renders BlindboxListSkeleton", () => { + const { container } = render(); + expect(container.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0); + }); +}); diff --git a/src/components/SwipeDeck.test.tsx b/src/components/SwipeDeck.test.tsx new file mode 100644 index 0000000..00a6657 --- /dev/null +++ b/src/components/SwipeDeck.test.tsx @@ -0,0 +1,100 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import SwipeDeck from "./SwipeDeck"; +import { + TEST_RESTAURANT, + TEST_RESTAURANT_2, + TEST_RESTAURANT_3, +} from "@/__tests__/helpers/fixtures"; + +vi.mock("./SwipeableCard", () => ({ + default: ({ restaurant }: { restaurant: { name: string } }) => ( +
{restaurant.name}
+ ), +})); + +vi.mock("./ActionButtons", () => ({ + default: ({ disabled }: { disabled: boolean }) => ( +
+ ), +})); + +vi.mock("./MatchResult", () => ({ + default: ({ restaurant }: { restaurant: { name: string } }) => ( +
{restaurant.name}
+ ), +})); + +vi.mock("./SwipeGuide", () => ({ + default: () =>
, +})); + +vi.mock("./UserAvatar", () => ({ + default: () => , +})); + +const restaurants = [TEST_RESTAURANT, TEST_RESTAURANT_2, TEST_RESTAURANT_3]; + +const defaultProps = { + restaurants, + roomId: "ROOM01", + userId: "user-1", + initialIndex: 0, + matchedRestaurantId: null, + matchType: null as const, + matchLikes: 0, + runnerUps: [], + likeCounts: {}, + swipeCounts: {}, + userCount: 2, + userProfiles: {}, + onReset: vi.fn().mockResolvedValue(undefined), + onNarrow: vi.fn().mockResolvedValue(undefined), +}; + +describe("SwipeDeck", () => { + it("renders restaurant cards at initial index", () => { + render(); + const cards = screen.getAllByTestId("swipeable-card"); + expect(cards.length).toBeGreaterThan(0); + }); + + it("renders action buttons", () => { + render(); + expect(screen.getByTestId("action-buttons")).toBeInTheDocument(); + }); + + it("shows swipe guide at index 0", () => { + render(); + expect(screen.getByTestId("swipe-guide")).toBeInTheDocument(); + }); + + it("does not show swipe guide when initialIndex > 0", () => { + render(); + expect(screen.queryByTestId("swipe-guide")).not.toBeInTheDocument(); + }); + + it("shows match result when matchedRestaurantId is set", () => { + render( + , + ); + expect(screen.getByTestId("match-result")).toBeInTheDocument(); + }); + + it("disables action buttons when match exists", () => { + render( + , + ); + const actions = screen.getByTestId("action-buttons"); + expect(actions.getAttribute("data-disabled")).toBe("true"); + }); +}); diff --git a/src/components/SwipeableCard.test.tsx b/src/components/SwipeableCard.test.tsx new file mode 100644 index 0000000..2f43de9 --- /dev/null +++ b/src/components/SwipeableCard.test.tsx @@ -0,0 +1,76 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import SwipeableCard from "./SwipeableCard"; +import { TEST_RESTAURANT } from "@/__tests__/helpers/fixtures"; + +vi.mock("./RestaurantCard", () => ({ + default: ({ restaurant }: { restaurant: { name: string } }) => ( +
{restaurant.name}
+ ), +})); + +describe("SwipeableCard", () => { + it("renders restaurant card", () => { + render( + {}} + likeCount={0} + />, + ); + expect(screen.getByTestId("restaurant-card")).toBeInTheDocument(); + expect(screen.getByText("测试餐厅")).toBeInTheDocument(); + }); + + it("renders swipe overlays (LIKE and NOPE)", () => { + const { container } = render( + {}} + likeCount={0} + />, + ); + expect(container.textContent).toContain("LIKE"); + expect(container.textContent).toContain("NOPE"); + }); + + it("calls registerSwipe when provided and isTop", () => { + const registerSwipe = vi.fn(); + render( + {}} + registerSwipe={registerSwipe} + likeCount={0} + />, + ); + expect(registerSwipe).toHaveBeenCalledWith(expect.any(Function)); + }); + + it("does not pass registerSwipe for non-top cards", () => { + render( + {}} + likeCount={0} + />, + ); + expect(screen.getByTestId("restaurant-card")).toBeInTheDocument(); + }); + + it("displays like count", () => { + render( + {}} + likeCount={3} + />, + ); + expect(screen.getByTestId("restaurant-card")).toBeInTheDocument(); + }); +}); diff --git a/src/components/Toast.test.tsx b/src/components/Toast.test.tsx new file mode 100644 index 0000000..4f23b7d --- /dev/null +++ b/src/components/Toast.test.tsx @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import Toast from "./Toast"; + +describe("Toast", () => { + it("renders message when provided", () => { + render(); + expect(screen.getByText("操作成功")).toBeInTheDocument(); + }); + + it("does not render when message is empty", () => { + const { container } = render(); + expect(container.textContent).toBe(""); + }); + + it("applies top position class by default", () => { + render(); + const el = screen.getByText("test").closest("div[class*='fixed']"); + expect(el?.className).toContain("top-10"); + }); + + it("applies bottom position class", () => { + render(); + const el = screen.getByText("test").closest("div[class*='fixed']"); + expect(el?.className).toContain("bottom-8"); + }); +}); diff --git a/src/components/TopNav.test.tsx b/src/components/TopNav.test.tsx new file mode 100644 index 0000000..2ef44a5 --- /dev/null +++ b/src/components/TopNav.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import TopNav from "./TopNav"; + +vi.mock("./QrInviteModal", () => ({ + default: ({ open }: { open: boolean }) => + open ?
QR Modal
: null, +})); + +vi.mock("./RoomManageModal", () => ({ + default: ({ open }: { open: boolean }) => + open ?
Manage Modal
: null, +})); + +describe("TopNav", () => { + it("renders room ID in invite button", () => { + render(); + expect(screen.getByText(/ROOM01/)).toBeInTheDocument(); + }); + + it("renders exit button", () => { + const onExit = vi.fn(); + render(); + + const exitBtn = screen.getByLabelText("退出房间"); + fireEvent.click(exitBtn); + expect(onExit).toHaveBeenCalled(); + }); + + it("shows manage button only for creator", () => { + const { rerender } = render(); + expect(screen.queryByText("管理")).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByText("管理")).toBeInTheDocument(); + }); + + it("opens QR invite modal on button click", () => { + render(); + expect(screen.queryByTestId("qr-modal")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText(/ROOM01/)); + expect(screen.getByTestId("qr-modal")).toBeInTheDocument(); + }); + + it("opens manage modal on manage button click", () => { + render(); + expect(screen.queryByTestId("manage-modal")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText("管理")); + expect(screen.getByTestId("manage-modal")).toBeInTheDocument(); + }); + + it("renders brand title", () => { + render(); + expect(screen.getByText("NoWhatever")).toBeInTheDocument(); + expect(screen.getByText("别说随便")).toBeInTheDocument(); + }); +}); diff --git a/src/components/WeekendTimeSelector.test.tsx b/src/components/WeekendTimeSelector.test.tsx new file mode 100644 index 0000000..fc3da03 --- /dev/null +++ b/src/components/WeekendTimeSelector.test.tsx @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import WeekendTimeSelector from "./WeekendTimeSelector"; + +const mockOnConfirm = vi.fn(); +const mockOnClose = vi.fn(); + +function renderSelector(props = {}) { + return render( + , + ); +} + +describe("WeekendTimeSelector", () => { + it("renders time presets", () => { + renderSelector(); + expect(screen.getByText("周六全天")).toBeInTheDocument(); + expect(screen.getByText("周日全天")).toBeInTheDocument(); + expect(screen.getByText("整个周末")).toBeInTheDocument(); + }); + + it("renders hour selectors", () => { + renderSelector(); + const selects = screen.getAllByRole("combobox"); + expect(selects).toHaveLength(2); + }); + + it("shows confirm button", () => { + renderSelector(); + expect(screen.getByText("生成周末计划")).toBeInTheDocument(); + }); + + it("calls onConfirm with default config", () => { + renderSelector(); + fireEvent.click(screen.getByText("生成周末计划")); + expect(mockOnConfirm).toHaveBeenCalledWith({ + date: "周六", + startHour: 10, + endHour: 21, + }); + }); + + it("calls onClose when close button clicked", () => { + renderSelector(); + fireEvent.click(screen.getByLabelText("关闭")); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it("switches preset on click", () => { + renderSelector(); + fireEvent.click(screen.getByText("周日全天")); + fireEvent.click(screen.getByText("生成周末计划")); + expect(mockOnConfirm).toHaveBeenCalledWith({ + date: "周日", + startHour: 10, + endHour: 21, + }); + }); +}); diff --git a/src/hooks/useGeolocation.test.ts b/src/hooks/useGeolocation.test.ts new file mode 100644 index 0000000..5c55a2a --- /dev/null +++ b/src/hooks/useGeolocation.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; + +const mockGetCurrentPosition = vi.fn(); +const mockFetch = vi.fn(); + +vi.stubGlobal("navigator", { + geolocation: { + getCurrentPosition: mockGetCurrentPosition, + }, +}); +vi.stubGlobal("fetch", mockFetch); + +import { useGeolocation } from "./useGeolocation"; + +beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockResolvedValue({ + json: () => Promise.resolve({ name: "黄浦区", formatted: "上海市黄浦区" }), + }); +}); + +describe("useGeolocation", () => { + it("returns success with coords on successful geolocation", async () => { + mockGetCurrentPosition.mockImplementation((success) => { + success({ coords: { latitude: 31.23, longitude: 121.47 } }); + }); + + const { result } = renderHook(() => useGeolocation()); + + await waitFor(() => { + expect(result.current.status).toBe("success"); + }); + + expect(result.current.coords).toEqual({ lat: 31.23, lng: 121.47 }); + }); + + it("reverse geocodes after getting coords", async () => { + mockGetCurrentPosition.mockImplementation((success) => { + success({ coords: { latitude: 31.23, longitude: 121.47 } }); + }); + + const { result } = renderHook(() => useGeolocation()); + + await waitFor(() => { + expect(result.current.locationName).toBe("黄浦区"); + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/location/regeo"), + ); + }); + + it("sets denied status on permission denied", async () => { + mockGetCurrentPosition.mockImplementation((_success, error) => { + error({ code: 1, PERMISSION_DENIED: 1 }); + }); + + const { result } = renderHook(() => useGeolocation()); + + await waitFor(() => { + expect(result.current.status).toBe("denied"); + }); + + expect(result.current.coords).toBeNull(); + }); + + it("sets failed status on timeout", async () => { + mockGetCurrentPosition.mockImplementation((_success, error) => { + error({ code: 3, PERMISSION_DENIED: 1, TIMEOUT: 3 }); + }); + + const { result } = renderHook(() => useGeolocation()); + + await waitFor(() => { + expect(result.current.status).toBe("failed"); + }); + }); + + it("provides retry function", async () => { + mockGetCurrentPosition + .mockImplementationOnce((_success, error) => { + error({ code: 3, PERMISSION_DENIED: 1, TIMEOUT: 3 }); + }) + .mockImplementationOnce((success) => { + success({ coords: { latitude: 31.23, longitude: 121.47 } }); + }); + + const { result } = renderHook(() => useGeolocation()); + + await waitFor(() => { + expect(result.current.status).toBe("failed"); + }); + + await act(async () => { + await result.current.retry(); + }); + + expect(result.current.status).toBe("success"); + expect(result.current.coords).toEqual({ lat: 31.23, lng: 121.47 }); + }); +}); diff --git a/src/hooks/useRoomPolling.test.ts b/src/hooks/useRoomPolling.test.ts new file mode 100644 index 0000000..caed6d4 --- /dev/null +++ b/src/hooks/useRoomPolling.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; + +let esInstances: { onmessage?: (e: MessageEvent) => void; onerror?: () => void; onopen?: () => void; close: ReturnType }[] = []; + +class MockEventSource { + onmessage: ((e: MessageEvent) => void) | null = null; + onerror: (() => void) | null = null; + onopen: (() => void) | null = null; + close = vi.fn(); + + constructor(_url: string) { + esInstances.push(this); + } +} + +vi.stubGlobal("EventSource", MockEventSource); + +vi.mock("swr", () => ({ + default: vi.fn().mockReturnValue({ + data: { + roomId: "ROOM01", + userCount: 2, + match: null, + matchType: null, + matchLikes: 0, + runnerUps: [], + likeCounts: {}, + swipeCounts: {}, + restaurants: [], + creatorId: "user-1", + locked: false, + users: ["user-1", "user-2"], + userProfiles: {}, + scene: "eat", + }, + error: null, + isLoading: false, + mutate: vi.fn(), + }), +})); + +import { useRoomPolling } from "./useRoomPolling"; + +beforeEach(() => { + esInstances = []; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("useRoomPolling", () => { + it("returns room data from SWR", () => { + const { result } = renderHook(() => useRoomPolling("ROOM01")); + + expect(result.current.userCount).toBe(2); + expect(result.current.users).toEqual(["user-1", "user-2"]); + expect(result.current.scene).toBe("eat"); + }); + + it("creates EventSource connection", () => { + renderHook(() => useRoomPolling("ROOM01")); + expect(esInstances.length).toBeGreaterThan(0); + }); + + it("returns defaults when no roomId", () => { + const { result } = renderHook(() => useRoomPolling(undefined)); + expect(result.current.userCount).toBe(2); + }); + + it("returns notFound from error state", async () => { + const useSWR = vi.mocked((await import("swr")).default); + useSWR.mockReturnValue({ + data: undefined, + error: new Error("NOT_FOUND"), + isLoading: false, + mutate: vi.fn(), + } as never); + + const { result } = renderHook(() => useRoomPolling("ROOM01")); + expect(result.current.notFound).toBe(true); + }); + + it("cleans up EventSource on unmount", () => { + const { unmount } = renderHook(() => useRoomPolling("ROOM01")); + const es = esInstances[esInstances.length - 1]; + unmount(); + expect(es.close).toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useShare.test.ts b/src/hooks/useShare.test.ts new file mode 100644 index 0000000..a6df302 --- /dev/null +++ b/src/hooks/useShare.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { type ReactNode } from "react"; +import React from "react"; +import { ToastContext, type ToastContextValue } from "@/hooks/useToast"; +import { useShare } from "./useShare"; + +const mockShow = vi.fn(); +const ctxValue: ToastContextValue = { show: mockShow }; + +function wrapper({ children }: { children: ReactNode }) { + return React.createElement( + ToastContext.Provider, + { value: ctxValue }, + children, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("useShare", () => { + describe("copyToClipboard", () => { + it("copies text and shows success toast", async () => { + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + + const { result } = renderHook(() => useShare(), { wrapper }); + + await act(async () => { + await result.current.copyToClipboard("hello"); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith("hello"); + expect(mockShow).toHaveBeenCalledWith("已复制"); + }); + + it("uses custom success message", async () => { + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + + const { result } = renderHook(() => useShare(), { wrapper }); + + await act(async () => { + await result.current.copyToClipboard("hello", "复制成功"); + }); + + expect(mockShow).toHaveBeenCalledWith("复制成功"); + }); + + it("shows error toast on clipboard failure", async () => { + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockRejectedValue(new Error()) }, + }); + + const { result } = renderHook(() => useShare(), { wrapper }); + + await act(async () => { + await result.current.copyToClipboard("hello"); + }); + + expect(mockShow).toHaveBeenCalledWith("复制失败,请手动复制"); + }); + }); + + describe("share", () => { + it("uses native share when available", async () => { + const mockNativeShare = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { + share: mockNativeShare, + canShare: vi.fn().mockReturnValue(true), + }); + + const { result } = renderHook(() => useShare(), { wrapper }); + + let shared = false; + await act(async () => { + shared = await result.current.share({ title: "Test", text: "test" }); + }); + + expect(shared).toBe(true); + expect(mockNativeShare).toHaveBeenCalled(); + }); + + it("calls fallback when native share unavailable", async () => { + Object.assign(navigator, { + share: undefined, + canShare: undefined, + }); + + const fallback = vi.fn(); + const { result } = renderHook(() => useShare(), { wrapper }); + + let shared = false; + await act(async () => { + shared = await result.current.share({ title: "Test" }, fallback); + }); + + expect(shared).toBe(false); + expect(fallback).toHaveBeenCalled(); + }); + + it("handles AbortError gracefully", async () => { + const abortError = new Error("User aborted"); + abortError.name = "AbortError"; + Object.assign(navigator, { + share: vi.fn().mockRejectedValue(abortError), + canShare: vi.fn().mockReturnValue(true), + }); + + const { result } = renderHook(() => useShare(), { wrapper }); + + let shared = false; + await act(async () => { + shared = await result.current.share({ title: "Test" }); + }); + + expect(shared).toBe(true); + }); + }); +}); diff --git a/src/hooks/useToast.test.tsx b/src/hooks/useToast.test.tsx new file mode 100644 index 0000000..0070be0 --- /dev/null +++ b/src/hooks/useToast.test.tsx @@ -0,0 +1,26 @@ +import { describe, it, expect } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { type ReactNode } from "react"; +import { useToast, ToastContext, type ToastContextValue } from "./useToast"; + +describe("useToast", () => { + it("throws error when used outside provider", () => { + expect(() => { + renderHook(() => useToast()); + }).toThrow("useToast must be used within ToastProvider"); + }); + + it("returns context value when inside provider", () => { + const mockShow = () => {}; + const ctxValue: ToastContextValue = { show: mockShow }; + + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useToast(), { wrapper }); + expect(result.current.show).toBe(mockShow); + }); +}); diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts new file mode 100644 index 0000000..e45f187 --- /dev/null +++ b/src/lib/api.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { Prisma } from "@prisma/client"; +import { ApiError, requireUserId, apiHandler } from "@/lib/api"; + +vi.mock("@/lib/prisma", () => ({ + prisma: { user: { findUnique: vi.fn() } }, +})); + +describe("ApiError", () => { + it("creates with default 400 status", () => { + const err = new ApiError("bad request"); + expect(err.message).toBe("bad request"); + expect(err.status).toBe(400); + expect(err.name).toBe("ApiError"); + }); + + it("creates with custom status", () => { + const err = new ApiError("not found", 404); + expect(err.status).toBe(404); + }); + + it("is instance of Error", () => { + expect(new ApiError("test")).toBeInstanceOf(Error); + }); +}); + +describe("requireUserId", () => { + it("returns userId when valid string", () => { + expect(requireUserId("user-123")).toBe("user-123"); + }); + + it("throws 401 for empty string", () => { + expect(() => requireUserId("")).toThrow(ApiError); + try { + requireUserId(""); + } catch (e) { + expect((e as ApiError).status).toBe(401); + } + }); + + it("throws 401 for null/undefined", () => { + expect(() => requireUserId(null)).toThrow(ApiError); + expect(() => requireUserId(undefined)).toThrow(ApiError); + }); + + it("throws 401 for non-string", () => { + expect(() => requireUserId(123)).toThrow(ApiError); + }); +}); + +describe("apiHandler", () => { + const mockCtx = { params: Promise.resolve({}) }; + + it("passes through successful responses", async () => { + const handler = apiHandler(async () => + NextResponse.json({ ok: true }), + ); + const req = new NextRequest("http://localhost/api/test"); + const res = await handler(req, mockCtx); + const data = await res.json(); + expect(res.status).toBe(200); + expect(data.ok).toBe(true); + }); + + it("converts ApiError to JSON response", async () => { + const handler = apiHandler(async () => { + throw new ApiError("not found", 404); + }); + const req = new NextRequest("http://localhost/api/test"); + const res = await handler(req, mockCtx); + const data = await res.json(); + expect(res.status).toBe(404); + expect(data.error).toBe("not found"); + }); + + it("handles Prisma P2002 unique constraint error", async () => { + const handler = apiHandler(async () => { + const err = new Prisma.PrismaClientKnownRequestError("Unique constraint", { + code: "P2002", + clientVersion: "5.0.0", + }); + throw err; + }); + const req = new NextRequest("http://localhost/api/test"); + const res = await handler(req, mockCtx); + const data = await res.json(); + expect(res.status).toBe(409); + expect(data.error).toBe("该记录已存在或值已被占用"); + }); + + it("handles unknown errors as 500", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const handler = apiHandler(async () => { + throw new Error("unexpected"); + }); + const req = new NextRequest("http://localhost/api/test"); + const res = await handler(req, mockCtx); + const data = await res.json(); + expect(res.status).toBe(500); + expect(data.error).toBe("操作失败"); + consoleSpy.mockRestore(); + }); +}); diff --git a/src/lib/buildRoomStatus.test.ts b/src/lib/buildRoomStatus.test.ts new file mode 100644 index 0000000..db62dff --- /dev/null +++ b/src/lib/buildRoomStatus.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + TEST_USER, + TEST_USER_2, + TEST_RESTAURANT, + TEST_RESTAURANT_2, + TEST_RESTAURANT_3, + TEST_ROOM_DATA, +} from "@/__tests__/helpers/fixtures"; + +vi.mock("@/lib/store", () => ({ + getRoomData: vi.fn(), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + user: { + findMany: vi.fn(), + }, + }, +})); + +import { buildRoomStatus } from "@/lib/buildRoomStatus"; +import { getRoomData } from "@/lib/store"; +import { prisma } from "@/lib/prisma"; + +const mockGetRoomData = vi.mocked(getRoomData); +const mockFindMany = vi.mocked(prisma.user.findMany); + +beforeEach(() => { + vi.clearAllMocks(); + mockFindMany.mockResolvedValue([ + { id: TEST_USER.id, username: TEST_USER.username, avatar: TEST_USER.avatar } as never, + { id: TEST_USER_2.id, username: TEST_USER_2.username, avatar: TEST_USER_2.avatar } as never, + ]); +}); + +describe("buildRoomStatus", () => { + it("returns null when room not found", async () => { + mockGetRoomData.mockResolvedValue(null); + const result = await buildRoomStatus("nonexistent"); + expect(result).toBeNull(); + }); + + it("returns base status for room with no match", async () => { + mockGetRoomData.mockResolvedValue({ ...TEST_ROOM_DATA }); + const result = await buildRoomStatus("room-1"); + + expect(result).not.toBeNull(); + expect(result!.roomId).toBe("room-1"); + expect(result!.userCount).toBe(2); + expect(result!.match).toBeNull(); + expect(result!.matchType).toBeNull(); + expect(result!.restaurants).toHaveLength(3); + expect(result!.creatorId).toBe(TEST_USER.id); + }); + + it("returns unanimous match when data has match", async () => { + mockGetRoomData.mockResolvedValue({ + ...TEST_ROOM_DATA, + match: TEST_RESTAURANT.id, + }); + const result = await buildRoomStatus("room-1"); + + expect(result!.match).toBe(TEST_RESTAURANT.id); + expect(result!.matchType).toBe("unanimous"); + expect(result!.matchLikes).toBe(2); + }); + + it("returns best match when all finished and top has likes", async () => { + const data = { + ...TEST_ROOM_DATA, + swipeCounts: { + [TEST_USER.id]: 3, + [TEST_USER_2.id]: 3, + }, + likes: { + [TEST_RESTAURANT.id]: [TEST_USER.id], + [TEST_RESTAURANT_2.id]: [TEST_USER.id, TEST_USER_2.id], + }, + }; + mockGetRoomData.mockResolvedValue(data); + const result = await buildRoomStatus("room-1"); + + expect(result!.matchType).toBe("best"); + expect(result!.match).toBe(TEST_RESTAURANT_2.id); + expect(result!.matchLikes).toBe(2); + expect(result!.runnerUps).toEqual([{ id: TEST_RESTAURANT.id, likes: 1 }]); + }); + + it("returns no_match when all finished but no likes", async () => { + const data = { + ...TEST_ROOM_DATA, + swipeCounts: { + [TEST_USER.id]: 3, + [TEST_USER_2.id]: 3, + }, + likes: {}, + }; + mockGetRoomData.mockResolvedValue(data); + const result = await buildRoomStatus("room-1"); + + expect(result!.matchType).toBe("no_match"); + expect(result!.matchLikes).toBe(0); + }); + + it("ranks by likes descending then by rating", async () => { + const data = { + ...TEST_ROOM_DATA, + swipeCounts: { + [TEST_USER.id]: 3, + [TEST_USER_2.id]: 3, + }, + likes: { + [TEST_RESTAURANT.id]: [TEST_USER.id], + [TEST_RESTAURANT_2.id]: [TEST_USER.id], + }, + }; + mockGetRoomData.mockResolvedValue(data); + const result = await buildRoomStatus("room-1"); + + // Both have 1 like, TEST_RESTAURANT has higher rating (4.5 vs 4.0) + expect(result!.match).toBe(TEST_RESTAURANT.id); + expect(result!.runnerUps[0].id).toBe(TEST_RESTAURANT_2.id); + }); + + it("does not consider allFinished when no users", async () => { + const data = { + ...TEST_ROOM_DATA, + users: [], + swipeCounts: {}, + }; + mockGetRoomData.mockResolvedValue(data); + const result = await buildRoomStatus("room-1"); + + expect(result!.matchType).toBeNull(); + }); + + it("populates user profiles", async () => { + mockGetRoomData.mockResolvedValue({ ...TEST_ROOM_DATA }); + const result = await buildRoomStatus("room-1"); + + expect(result!.userProfiles[TEST_USER.id]).toEqual({ + id: TEST_USER.id, + username: TEST_USER.username, + avatar: TEST_USER.avatar, + }); + }); + + it("includes likeCounts only for restaurants with likes", async () => { + const data = { + ...TEST_ROOM_DATA, + likes: { + [TEST_RESTAURANT.id]: [TEST_USER.id, TEST_USER_2.id], + [TEST_RESTAURANT_2.id]: [], + }, + }; + mockGetRoomData.mockResolvedValue(data); + const result = await buildRoomStatus("room-1"); + + expect(result!.likeCounts[TEST_RESTAURANT.id]).toBe(2); + expect(result!.likeCounts[TEST_RESTAURANT_2.id]).toBeUndefined(); + }); +}); diff --git a/src/lib/validation.test.ts b/src/lib/validation.test.ts new file mode 100644 index 0000000..f0d7c9c --- /dev/null +++ b/src/lib/validation.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import { ApiError } from "@/lib/api"; +import { + validateUsername, + validatePassword, + validateEmail, + validateIdeaContent, + validateRoomName, + requireString, +} from "@/lib/validation"; + +describe("validateUsername", () => { + it("accepts 2-16 character names", () => { + expect(validateUsername("ab")).toBe("ab"); + expect(validateUsername("a".repeat(16))).toBe("a".repeat(16)); + expect(validateUsername("用户名")).toBe("用户名"); + }); + + it("trims whitespace", () => { + expect(validateUsername(" hello ")).toBe("hello"); + }); + + it("rejects names shorter than 2 chars", () => { + expect(() => validateUsername("a")).toThrow(ApiError); + expect(() => validateUsername("")).toThrow(ApiError); + expect(() => validateUsername(" ")).toThrow(ApiError); + }); + + it("rejects names longer than 16 chars", () => { + expect(() => validateUsername("a".repeat(17))).toThrow(ApiError); + }); +}); + +describe("validatePassword", () => { + it("accepts 6-128 character passwords", () => { + expect(() => validatePassword("123456")).not.toThrow(); + expect(() => validatePassword("a".repeat(128))).not.toThrow(); + }); + + it("rejects passwords shorter than 6 chars", () => { + expect(() => validatePassword("12345")).toThrow(ApiError); + expect(() => validatePassword("")).toThrow(ApiError); + }); + + it("rejects passwords longer than 128 chars", () => { + expect(() => validatePassword("a".repeat(129))).toThrow(ApiError); + }); + + it("uses custom label in error message", () => { + expect(() => validatePassword("12345", "新密码")).toThrow("新密码至少 6 个字符"); + }); +}); + +describe("validateEmail", () => { + it("accepts valid emails", () => { + expect(() => validateEmail("test@example.com")).not.toThrow(); + expect(() => validateEmail("user.name@domain.co")).not.toThrow(); + }); + + it("rejects invalid emails", () => { + expect(() => validateEmail("notanemail")).toThrow(ApiError); + expect(() => validateEmail("@domain.com")).toThrow(ApiError); + expect(() => validateEmail("user@")).toThrow(ApiError); + expect(() => validateEmail("user @domain.com")).toThrow(ApiError); + }); +}); + +describe("validateIdeaContent", () => { + it("accepts valid content and trims", () => { + expect(validateIdeaContent(" 去公园 ")).toBe("去公园"); + expect(validateIdeaContent("a")).toBe("a"); + }); + + it("rejects empty/falsy content", () => { + expect(() => validateIdeaContent("")).toThrow(ApiError); + expect(() => validateIdeaContent(null)).toThrow(ApiError); + expect(() => validateIdeaContent(undefined)).toThrow(ApiError); + expect(() => validateIdeaContent(" ")).toThrow(ApiError); + }); + + it("rejects non-string content", () => { + expect(() => validateIdeaContent(123)).toThrow(ApiError); + }); + + it("rejects content over 200 chars", () => { + expect(() => validateIdeaContent("a".repeat(201))).toThrow(ApiError); + expect(validateIdeaContent("a".repeat(200))).toBe("a".repeat(200)); + }); +}); + +describe("validateRoomName", () => { + it("accepts valid room name", () => { + expect(validateRoomName("我的房间")).toBe("我的房间"); + }); + + it("uses fallback for empty name", () => { + expect(validateRoomName("")).toBe("我们的周末"); + expect(validateRoomName(null)).toBe("我们的周末"); + expect(validateRoomName(undefined)).toBe("我们的周末"); + }); + + it("uses custom fallback", () => { + expect(validateRoomName("", "默认名")).toBe("默认名"); + }); + + it("rejects names over 30 chars", () => { + expect(() => validateRoomName("a".repeat(31))).toThrow(ApiError); + expect(validateRoomName("a".repeat(30))).toBe("a".repeat(30)); + }); +}); + +describe("requireString", () => { + it("returns string value when valid", () => { + expect(requireString("hello", "field")).toBe("hello"); + }); + + it("throws for empty/falsy values", () => { + expect(() => requireString("", "字段")).toThrow("字段不能为空"); + expect(() => requireString(null, "字段")).toThrow(ApiError); + expect(() => requireString(undefined, "字段")).toThrow(ApiError); + expect(() => requireString(" ", "字段")).toThrow(ApiError); + }); + + it("throws for non-string values", () => { + expect(() => requireString(123, "字段")).toThrow(ApiError); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..6094f2a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import path from "path"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: ["src/__tests__/helpers/setup.ts"], + environmentMatchGlobs: [ + ["src/app/api/**/*.test.ts", "node"], + ["src/lib/**/*.test.ts", "node"], + ], + env: { + AMAP_API_KEY: "test-amap-key", + DEEPSEEK_API_KEY: "test-deepseek-key", + }, + }, +});