diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d36369f..e68df07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,31 @@ jobs: - name: Lint run: npm run lint + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run tests with coverage + run: npx vitest run --coverage + + - name: Upload coverage to Codecov + if: github.event_name == 'push' + uses: codecov/codecov-action@v5 + with: + fail_ci_if_error: false + build: name: Build runs-on: ubuntu-latest @@ -74,7 +99,7 @@ jobs: docker: name: Docker Build runs-on: ubuntu-latest - needs: [lint, build] + needs: [lint, test, build] if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - name: Checkout code @@ -96,12 +121,13 @@ jobs: summary: name: CI Summary runs-on: ubuntu-latest - needs: [lint, build] + needs: [lint, test, build] if: always() steps: - name: Check results run: | if [ "${{ needs.lint.result }}" != "success" ] || \ + [ "${{ needs.test.result }}" != "success" ] || \ [ "${{ needs.build.result }}" != "success" ]; then echo "CI failed" exit 1 diff --git a/README.md b/README.md index 4eea2a2..279e3b0 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Docker Pulls listed on awesome-europe ArtifactHub + codecov


diff --git a/package-lock.json b/package-lock.json index f3daa06..ce9c94e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,12 +26,14 @@ "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@vitest/coverage-v8": "^4.1.4", "eslint": "^9.28.0", "eslint-config-next": "^16.2.3", "postcss": "^8.5.9", "tailwindcss": "^4.2.1", "tsx": "^4.21.0", - "typescript": "^6.0.2" + "typescript": "^6.0.2", + "vitest": "^4.1.4" } }, "node_modules/@alloc/quick-lru": { @@ -203,7 +205,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -213,7 +215,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -247,7 +249,7 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -297,7 +299,7 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -307,6 +309,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@clack/core": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.5.0.tgz", @@ -356,21 +368,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "license": "MIT", "optional": true, "dependencies": { @@ -378,9 +390,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -1870,6 +1882,16 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@prisma/adapter-pg": { "version": "7.7.0", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.7.0.tgz", @@ -2229,6 +2251,307 @@ } } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2597,6 +2920,24 @@ "tslib": "^2.4.0" } }, + "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", @@ -3045,11 +3386,174 @@ "gl-style-validate": "dist/gl-style-validate.mjs" } }, - "node_modules/@vis.gl/react-maplibre/node_modules/json-stringify-pretty-compact": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz", - "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==", - "license": "MIT" + "node_modules/@vis.gl/react-maplibre/node_modules/json-stringify-pretty-compact": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz", + "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==", + "license": "MIT" + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.4.tgz", + "integrity": "sha512-x7FptB5oDruxNPDNY2+S8tCh0pcq7ymCe1gTHcsp733jYjrJl8V1gMUlVysuCD9Kz46Xz9t1akkv08dPcYDs1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.4", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.4", + "vitest": "4.1.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "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 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } }, "node_modules/acorn": { "version": "8.16.0", @@ -3293,6 +3797,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/assign-symbols": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", @@ -3309,6 +3823,25 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -3597,6 +4130,16 @@ ], "license": "CC-BY-4.0" }, + "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", @@ -4103,6 +4646,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "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", @@ -4901,6 +5451,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", @@ -4911,6 +5471,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", @@ -5510,6 +6080,13 @@ "node": ">=16.9.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-status-codes": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", @@ -6035,6 +6612,45 @@ "node": ">=0.10.0" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -6538,6 +7154,22 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/maplibre-gl": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.22.0.tgz", @@ -6986,6 +7618,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", @@ -7686,6 +8329,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8007,6 +8684,13 @@ "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", @@ -8131,6 +8815,13 @@ "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/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -8366,6 +9057,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.1.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", @@ -8429,6 +9127,16 @@ "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", "license": "ISC" }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8757,6 +9465,207 @@ } } }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "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", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.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 + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "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/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.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.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "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/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8861,6 +9770,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", diff --git a/package.json b/package.json index 028733b..661cdb4 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "build": "next build", "start": "next start", "lint": "eslint .", + "test": "vitest run", + "test:coverage": "vitest run --coverage", "scraper:run": "npx tsx src/scrapers/cli.ts" }, "repository": { @@ -37,12 +39,14 @@ "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", + "@vitest/coverage-v8": "^4.1.4", "eslint": "^9.28.0", "eslint-config-next": "^16.2.3", "postcss": "^8.5.9", "tailwindcss": "^4.2.1", "tsx": "^4.21.0", - "typescript": "^6.0.2" + "typescript": "^6.0.2", + "vitest": "^4.1.4" }, "overrides": { "hono": "^4.12.12", diff --git a/src/app/api/exchange-rates/route.test.ts b/src/app/api/exchange-rates/route.test.ts new file mode 100644 index 0000000..5a04dad --- /dev/null +++ b/src/app/api/exchange-rates/route.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock next/server +vi.mock("next/server", () => ({ + NextResponse: { + json: (data: unknown, init?: { headers?: Record; status?: number }) => ({ + data, + status: init?.status ?? 200, + headers: init?.headers ?? {}, + }), + }, +})); + +describe("exchange-rates API", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("fetches and parses ECB XML rates", async () => { + const mockXml = ` + + + + + + + + + +`; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: async () => mockXml, + } as Response); + + const { GET } = await import("./route"); + const response = await GET(); + + expect(response.data.base).toBe("EUR"); + expect(response.data.rates.EUR).toBe(1); + expect(response.data.rates.USD).toBeCloseTo(1.0934, 4); + expect(response.data.rates.GBP).toBeCloseTo(0.8561, 4); + expect(response.data.rates.CHF).toBeCloseTo(0.9584, 4); + expect(response.data.date).toBe("2026-04-10"); + }); + + it("adds approximate rates for non-ECB currencies", async () => { + const mockXml = ` + + `; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + text: async () => mockXml, + } as Response); + + const { GET } = await import("./route"); + const response = await GET(); + + // Should include fixed/approximate rates + expect(response.data.rates.BAM).toBeCloseTo(1.95583, 4); + expect(response.data.rates.MKD).toBeDefined(); + expect(response.data.rates.RSD).toBeDefined(); + expect(response.data.rates.ARS).toBeDefined(); + expect(response.data.rates.MDL).toBeDefined(); + }); + + it("returns 502 when ECB fetch fails and no cache", async () => { + vi.mocked(fetch).mockRejectedValue(new Error("Network error")); + + const { GET } = await import("./route"); + const response = await GET(); + + expect(response.status).toBe(502); + expect(response.data.error).toBeDefined(); + }); +}); diff --git a/src/lib/brand.test.ts b/src/lib/brand.test.ts new file mode 100644 index 0000000..0aa622e --- /dev/null +++ b/src/lib/brand.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { + BOLT_PATH, + BOLT_VIEWBOX, + BOLT_FILL, + BRAND_GRADIENT_START, + BRAND_GRADIENT_END, +} from "./brand"; + +describe("brand constants", () => { + it("BOLT_PATH is a non-empty SVG path", () => { + expect(BOLT_PATH).toBeTruthy(); + expect(BOLT_PATH).toContain("M"); + }); + + it("BOLT_VIEWBOX is a valid viewBox string", () => { + expect(BOLT_VIEWBOX).toBe("0 0 32 32"); + }); + + it("BOLT_FILL is a valid hex color", () => { + expect(BOLT_FILL).toMatch(/^#[0-9a-f]{6}$/); + }); + + it("gradient colors are valid hex colors", () => { + expect(BRAND_GRADIENT_START).toMatch(/^#[0-9a-f]{6}$/); + expect(BRAND_GRADIENT_END).toMatch(/^#[0-9a-f]{6}$/); + }); +}); diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts new file mode 100644 index 0000000..2d7e359 --- /dev/null +++ b/src/lib/config.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { getConfig, COUNTRIES, type CountryConfig } from "./config"; + +describe("COUNTRIES", () => { + it("contains expected country codes", () => { + expect(COUNTRIES.ES).toBeDefined(); + expect(COUNTRIES.FR).toBeDefined(); + expect(COUNTRIES.DE).toBeDefined(); + expect(COUNTRIES.GB).toBeDefined(); + expect(COUNTRIES.AU).toBeDefined(); + expect(COUNTRIES.AR).toBeDefined(); + expect(COUNTRIES.MX).toBeDefined(); + }); + + it("all entries have required fields", () => { + for (const [code, config] of Object.entries(COUNTRIES)) { + expect(config.code).toBe(code); + expect(config.name).toBeTruthy(); + expect(config.center).toHaveLength(2); + expect(config.center[0]).toBeGreaterThanOrEqual(-180); + expect(config.center[0]).toBeLessThanOrEqual(180); + expect(config.center[1]).toBeGreaterThanOrEqual(-90); + expect(config.center[1]).toBeLessThanOrEqual(90); + expect(config.zoom).toBeGreaterThan(0); + expect(config.defaultFuel).toBeTruthy(); + } + }); + + it("each country code is a 2-letter uppercase string", () => { + for (const code of Object.keys(COUNTRIES)) { + expect(code).toMatch(/^[A-Z]{2}$/); + } + }); +}); + +describe("getConfig", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + delete process.env.PUMPERLY_DEFAULT_COUNTRY; + delete process.env.PUMPERLY_ENABLED_COUNTRIES; + delete process.env.PUMPERLY_DEFAULT_FUEL; + delete process.env.PUMPERLY_CLUSTER_STATIONS; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("returns ES defaults when no env vars are set", () => { + const config = getConfig(); + expect(config.defaultCountry).toBe("ES"); + expect(config.defaultFuel).toBe("B7"); + expect(config.center).toEqual([-3.7, 40.4]); + expect(config.zoom).toBe(6); + expect(config.clusterStations).toBe(true); + expect(config.enabledCountries).toEqual(Object.keys(COUNTRIES)); + }); + + it("respects PUMPERLY_DEFAULT_COUNTRY", () => { + process.env.PUMPERLY_DEFAULT_COUNTRY = "FR"; + const config = getConfig(); + expect(config.defaultCountry).toBe("FR"); + expect(config.defaultFuel).toBe("E10"); + expect(config.center).toEqual([2.35, 46.85]); + }); + + it("falls back to ES for unknown country", () => { + process.env.PUMPERLY_DEFAULT_COUNTRY = "XX"; + const config = getConfig(); + expect(config.center).toEqual(COUNTRIES.ES.center); + expect(config.zoom).toBe(COUNTRIES.ES.zoom); + }); + + it("respects PUMPERLY_ENABLED_COUNTRIES", () => { + process.env.PUMPERLY_ENABLED_COUNTRIES = "ES, FR, DE"; + const config = getConfig(); + expect(config.enabledCountries).toEqual(["ES", "FR", "DE"]); + }); + + it("filters out invalid country codes from PUMPERLY_ENABLED_COUNTRIES", () => { + process.env.PUMPERLY_ENABLED_COUNTRIES = "ES, XX, FR"; + const config = getConfig(); + expect(config.enabledCountries).toEqual(["ES", "FR"]); + }); + + it("handles case-insensitive country codes", () => { + process.env.PUMPERLY_ENABLED_COUNTRIES = "es, fr"; + const config = getConfig(); + expect(config.enabledCountries).toEqual(["ES", "FR"]); + }); + + it("respects PUMPERLY_DEFAULT_FUEL override", () => { + process.env.PUMPERLY_DEFAULT_FUEL = "E5"; + const config = getConfig(); + expect(config.defaultFuel).toBe("E5"); + }); + + it("respects PUMPERLY_CLUSTER_STATIONS=false", () => { + process.env.PUMPERLY_CLUSTER_STATIONS = "false"; + const config = getConfig(); + expect(config.clusterStations).toBe(false); + }); + + it("defaults PUMPERLY_CLUSTER_STATIONS to true", () => { + const config = getConfig(); + expect(config.clusterStations).toBe(true); + }); +}); diff --git a/src/lib/og-translations.test.ts b/src/lib/og-translations.test.ts new file mode 100644 index 0000000..e6eb450 --- /dev/null +++ b/src/lib/og-translations.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { OG_TRANSLATIONS, SUPPORTED_LOCALES, DEFAULT_LOCALE } from "./og-translations"; + +describe("og-translations", () => { + it("DEFAULT_LOCALE is es", () => { + expect(DEFAULT_LOCALE).toBe("es"); + }); + + it("SUPPORTED_LOCALES matches OG_TRANSLATIONS keys", () => { + expect(SUPPORTED_LOCALES).toEqual(Object.keys(OG_TRANSLATIONS)); + }); + + it("all locales have required OG fields", () => { + for (const locale of SUPPORTED_LOCALES) { + const og = OG_TRANSLATIONS[locale]; + expect(og.title, `${locale}.title`).toBeTruthy(); + expect(og.description, `${locale}.description`).toBeTruthy(); + expect(og.imageSubtitle, `${locale}.imageSubtitle`).toBeTruthy(); + expect(og.ogLocale, `${locale}.ogLocale`).toMatch(/^[a-z]{2}_[A-Z]{2}$/); + } + }); + + it("all titles contain Pumperly", () => { + for (const locale of SUPPORTED_LOCALES) { + expect(OG_TRANSLATIONS[locale].title).toContain("Pumperly"); + } + }); + + it("includes at least 10 locales", () => { + expect(SUPPORTED_LOCALES.length).toBeGreaterThanOrEqual(10); + }); +}); diff --git a/src/lib/photon.test.ts b/src/lib/photon.test.ts new file mode 100644 index 0000000..a877639 --- /dev/null +++ b/src/lib/photon.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const MOCK_PHOTON = "http://photon.test"; + +describe("photon geocode", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env.PHOTON_URL = MOCK_PHOTON; + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it("returns empty array when PHOTON_URL is not set", async () => { + delete process.env.PHOTON_URL; + const { geocode } = await import("./photon"); + const results = await geocode("Madrid"); + expect(results).toEqual([]); + }); + + it("returns parsed results from Photon API", async () => { + const { geocode } = await import("./photon"); + + const mockResponse = { + type: "FeatureCollection", + features: [ + { + type: "Feature", + geometry: { type: "Point", coordinates: [-3.7038, 40.4168] }, + properties: { + name: "Madrid", + city: "Madrid", + state: "Community of Madrid", + country: "Spain", + }, + }, + { + type: "Feature", + geometry: { type: "Point", coordinates: [-3.6, 40.5] }, + properties: { + name: "Madrid Barajas", + country: "Spain", + }, + }, + ], + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + const results = await geocode("Madrid", 40.4, -3.7); + + expect(results).toHaveLength(2); + expect(results[0]).toEqual({ + name: "Madrid", + city: "Madrid", + state: "Community of Madrid", + country: "Spain", + coordinates: [-3.7038, 40.4168], + }); + expect(results[1].city).toBeNull(); + expect(results[1].state).toBeNull(); + + // Verify fetch was called with correct params + const call = vi.mocked(fetch).mock.calls[0]; + const url = call[0] as string; + expect(url).toContain(`${MOCK_PHOTON}/api?`); + expect(url).toContain("q=Madrid"); + expect(url).toContain("lat=40.4"); + expect(url).toContain("lon=-3.7"); + expect(url).toContain("limit=5"); + }); + + it("returns empty array on non-ok response", async () => { + const { geocode } = await import("./photon"); + + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 500, + } as Response); + + const results = await geocode("test"); + expect(results).toEqual([]); + }); + + it("uses query as fallback name when property name is missing", async () => { + const { geocode } = await import("./photon"); + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + type: "FeatureCollection", + features: [ + { + type: "Feature", + geometry: { type: "Point", coordinates: [0, 0] }, + properties: {}, + }, + ], + }), + } as Response); + + const results = await geocode("Unknown Place"); + expect(results[0].name).toBe("Unknown Place"); + }); + + it("does not send lat/lon params when not provided", async () => { + const { geocode } = await import("./photon"); + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ type: "FeatureCollection", features: [] }), + } as Response); + + await geocode("Berlin"); + + const call = vi.mocked(fetch).mock.calls[0]; + const url = call[0] as string; + expect(url).not.toContain("lat="); + expect(url).not.toContain("lon="); + }); +}); diff --git a/src/lib/valhalla.test.ts b/src/lib/valhalla.test.ts new file mode 100644 index 0000000..666f511 --- /dev/null +++ b/src/lib/valhalla.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// We test the internal functions by importing the module, but the exported +// functions (getRoute, getRoutes, getRouteDuration) depend on fetch + env. +// We mock fetch globally and set VALHALLA_URL. + +const MOCK_VALHALLA = "http://valhalla.test"; + +// Simple encoded polyline for testing decodePolyline: +// The Valhalla polyline uses precision 6. We encode two points: +// (40.0, -3.0) and (40.1, -2.9) +// lat1=40.0 => 40000000, lon1=-3.0 => -3000000 +// lat2=40.1 => 40100000 (delta +100000), lon2=-2.9 => -2900000 (delta +100000) + +function encodeValue(value: number): string { + let v = value < 0 ? ~(value << 1) : value << 1; + let result = ""; + while (v >= 0x20) { + result += String.fromCharCode((v & 0x1f) | 0x20 + 63); + v >>= 5; + } + // Actually let's use a known encoded polyline from Valhalla docs + // For simplicity, test via the round-trip through the exported API + return result + String.fromCharCode(v + 63); +} + +describe("valhalla module", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env.VALHALLA_URL = MOCK_VALHALLA; + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + vi.restoreAllMocks(); + // Clear module cache so VALHALLA_URL is re-read + vi.resetModules(); + }); + + describe("getRoute", () => { + it("returns null when VALHALLA_URL is not set", async () => { + delete process.env.VALHALLA_URL; + // Must re-import to pick up env change + const { getRoute } = await import("./valhalla"); + const result = await getRoute([ + { lat: 40.0, lon: -3.0 }, + { lat: 41.0, lon: -2.0 }, + ]); + expect(result).toBeNull(); + }); + + it("returns null when fetch fails", async () => { + const { getRoute } = await import("./valhalla"); + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 500, + } as Response); + + const result = await getRoute([ + { lat: 40.0, lon: -3.0 }, + { lat: 41.0, lon: -2.0 }, + ]); + expect(result).toBeNull(); + }); + + it("calls Valhalla with correct body and returns parsed route", async () => { + const { getRoute } = await import("./valhalla"); + + // Minimal Valhalla trip response with a simple encoded polyline + // Encode (40.0, -3.0) -> just use a tiny shape + const mockTrip = { + legs: [ + { + shape: "_c}|gAz~fjC_seK_seK", // approximate encoding + summary: { length: 150.5, time: 5400 }, + maneuvers: [], + }, + ], + summary: { length: 150.5, time: 5400 }, + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ trip: mockTrip }), + } as Response); + + const result = await getRoute([ + { lat: 40.0, lon: -3.0 }, + { lat: 41.0, lon: -2.0 }, + ]); + + expect(fetch).toHaveBeenCalledOnce(); + const call = vi.mocked(fetch).mock.calls[0]; + expect(call[0]).toBe(`${MOCK_VALHALLA}/route`); + const body = JSON.parse((call[1] as RequestInit).body as string); + expect(body.locations).toHaveLength(2); + expect(body.costing).toBe("auto"); + + expect(result).not.toBeNull(); + expect(result!.distance).toBe(150.5); + expect(result!.duration).toBe(5400); + expect(result!.geometry.type).toBe("LineString"); + expect(result!.bbox).toHaveLength(4); + }); + }); + + describe("getRoutes", () => { + it("returns empty array when VALHALLA_URL is not set", async () => { + delete process.env.VALHALLA_URL; + const { getRoutes } = await import("./valhalla"); + const result = await getRoutes([ + { lat: 40.0, lon: -3.0 }, + { lat: 41.0, lon: -2.0 }, + ]); + expect(result).toEqual([]); + }); + + it("returns multiple routes including alternates", async () => { + const { getRoutes } = await import("./valhalla"); + + const makeLeg = (length: number, time: number) => ({ + shape: "_c}|gAz~fjC_seK_seK", + summary: { length, time }, + maneuvers: [], + }); + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + trip: { + legs: [makeLeg(100, 3600)], + summary: { length: 100, time: 3600 }, + }, + alternates: [ + { + trip: { + legs: [makeLeg(120, 4000)], + summary: { length: 120, time: 4000 }, + }, + }, + ], + }), + } as Response); + + const routes = await getRoutes( + [{ lat: 40.0, lon: -3.0 }, { lat: 41.0, lon: -2.0 }], + 2, + ); + + expect(routes).toHaveLength(2); + expect(routes[0].distance).toBe(100); + expect(routes[1].distance).toBe(120); + }); + }); + + describe("getRouteDuration", () => { + it("returns null when VALHALLA_URL is not set", async () => { + delete process.env.VALHALLA_URL; + const { getRouteDuration } = await import("./valhalla"); + const result = await getRouteDuration([ + { lat: 40.0, lon: -3.0 }, + { lat: 41.0, lon: -2.0 }, + ]); + expect(result).toBeNull(); + }); + + it("returns duration from trip summary", async () => { + const { getRouteDuration } = await import("./valhalla"); + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + trip: { + legs: [], + summary: { length: 50, time: 1800 }, + }, + }), + } as Response); + + const result = await getRouteDuration([ + { lat: 40.0, lon: -3.0 }, + { lat: 41.0, lon: -2.0 }, + ]); + expect(result).toBe(1800); + }); + }); +}); diff --git a/src/middleware.test.ts b/src/middleware.test.ts new file mode 100644 index 0000000..5659c72 --- /dev/null +++ b/src/middleware.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, vi } from "vitest"; + +// We need to mock next/server since middleware uses NextRequest/NextResponse +vi.mock("next/server", () => { + class MockHeaders extends Map { + constructor(init?: Record | [string, string][]) { + super(); + if (init && !Array.isArray(init)) { + for (const [k, v] of Object.entries(init)) { + this.set(k.toLowerCase(), v); + } + } + } + // next/server Headers uses .get() which Map already provides + } + + class MockNextRequest { + nextUrl: { pathname: string; clone: () => MockNextRequest["nextUrl"] }; + headers: MockHeaders; + cookies: { get: (name: string) => { value: string } | undefined }; + + constructor( + url: string, + options?: { + headers?: Record; + cookies?: Record; + }, + ) { + const parsed = new URL(url, "http://localhost"); + const self = this; + this.nextUrl = { + pathname: parsed.pathname, + clone() { + return { ...self.nextUrl, pathname: self.nextUrl.pathname }; + }, + }; + this.headers = new MockHeaders(options?.headers); + const cookieStore = options?.cookies ?? {}; + this.cookies = { + get: (name: string) => + name in cookieStore ? { value: cookieStore[name] } : undefined, + }; + } + } + + const responses: Array<{ type: string; args: unknown[] }> = []; + + class MockNextResponse { + static _responses = responses; + + cookies = { + set: vi.fn(), + }; + + static next(opts?: { request?: { headers?: MockHeaders } }) { + const res = new MockNextResponse(); + responses.push({ type: "next", args: [opts] }); + return res; + } + + static rewrite(url: unknown, opts?: unknown) { + const res = new MockNextResponse(); + responses.push({ type: "rewrite", args: [url, opts] }); + return res; + } + + static json(data: unknown, init?: unknown) { + return { data, init }; + } + } + + return { + NextRequest: MockNextRequest, + NextResponse: MockNextResponse, + }; +}); + +// Import after mock +import { NextRequest, NextResponse } from "next/server"; +import { middleware, config } from "./middleware"; + +function makeRequest( + pathname: string, + opts?: { headers?: Record; cookies?: Record }, +) { + return new (NextRequest as any)(`http://localhost${pathname}`, opts); +} + +describe("middleware", () => { + it("skips API routes", () => { + const req = makeRequest("/api/stations"); + const res = middleware(req); + // Should call NextResponse.next() without locale rewrite + expect(res).toBeDefined(); + }); + + it("skips Next.js internals", () => { + const req = makeRequest("/_next/static/chunk.js"); + const res = middleware(req); + expect(res).toBeDefined(); + }); + + it("skips paths with file extensions", () => { + const req = makeRequest("/icon.svg"); + const res = middleware(req); + expect(res).toBeDefined(); + }); + + it("passes through when locale is already in path", () => { + const req = makeRequest("/en"); + const res = middleware(req); + expect(res).toBeDefined(); + // Should set cookie for the detected locale + expect(res.cookies.set).toHaveBeenCalledWith( + "pumperly-locale", + "en", + expect.objectContaining({ path: "/" }), + ); + }); + + it("detects locale from cookie", () => { + const req = makeRequest("/", { cookies: { "pumperly-locale": "fr" } }); + const res = middleware(req); + expect(res).toBeDefined(); + expect(res.cookies.set).toHaveBeenCalledWith( + "pumperly-locale", + "fr", + expect.objectContaining({ path: "/" }), + ); + }); + + it("detects locale from accept-language header", () => { + const req = makeRequest("/", { + headers: { "accept-language": "de-DE,de;q=0.9,en;q=0.8" }, + }); + const res = middleware(req); + expect(res).toBeDefined(); + expect(res.cookies.set).toHaveBeenCalledWith( + "pumperly-locale", + "de", + expect.objectContaining({ path: "/" }), + ); + }); + + it("falls back to es when no locale detected", () => { + const req = makeRequest("/", { + headers: { "accept-language": "zh-CN" }, + }); + const res = middleware(req); + expect(res).toBeDefined(); + expect(res.cookies.set).toHaveBeenCalledWith( + "pumperly-locale", + "es", + expect.objectContaining({ path: "/" }), + ); + }); +}); + +describe("middleware config", () => { + it("has matcher pattern", () => { + expect(config.matcher).toBeDefined(); + expect(config.matcher).toHaveLength(1); + }); +}); diff --git a/src/scrapers/spain.test.ts b/src/scrapers/spain.test.ts new file mode 100644 index 0000000..c1ec396 --- /dev/null +++ b/src/scrapers/spain.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock the base class DB dependencies so we only test fetch() +vi.mock("@prisma/adapter-pg", () => ({ + PrismaPg: vi.fn(), +})); + +vi.mock("../generated/prisma/client", () => ({ + PrismaClient: vi.fn(), +})); + +describe("SpainScraper", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it("has correct country and source", async () => { + const { SpainScraper } = await import("./spain"); + const scraper = new SpainScraper(); + expect(scraper.country).toBe("ES"); + expect(scraper.source).toBe("miteco"); + }); + + it("parses MITECO API response into stations and prices", async () => { + const { SpainScraper } = await import("./spain"); + const scraper = new SpainScraper(); + + const mockMitecoResponse = { + Fecha: "01/04/2026 08:00:00", + ResultadoConsulta: "OK", + Nota: "", + ListaEESSPrecio: [ + { + IDEESS: "1234", + "Rótulo": "REPSOL", + "Dirección": "CALLE MAYOR 1", + Municipio: "MADRID", + Provincia: "MADRID", + "C.P.": "28001", + Latitud: "40,416775", + "Longitud (WGS84)": "-3,703790", + "Tipo Venta": "D", + "Precio Gasoleo A": "1,459", + "Precio Gasolina 95 E5": "1,599", + "Precio Gasolina 98 E5": "1,789", + "Precio Gases licuados del petróleo": "", + "Precio Gasoleo Premium": "1,529", + "Precio Gasoleo B": "", + "Precio Diésel Renovable": "", + "Precio Gasolina 95 E5 Premium": "", + "Precio Gasolina 95 E10": "", + "Precio Gasolina 98 E10": "", + "Precio Gas Natural Comprimido": "", + "Precio Gas Natural Licuado": "", + "Precio Hidrogeno": "", + "Precio Adblue": "", + }, + { + IDEESS: "5678", + "Rótulo": "CEPSA", + "Dirección": "AV LIBERTAD 5", + Municipio: "MURCIA", + Provincia: "MURCIA", + "C.P.": "30001", + Latitud: "37,983810", + "Longitud (WGS84)": "-1,129812", + "Tipo Venta": "R", + "Precio Gasoleo A": "1,439", + "Precio Gasolina 95 E5": "1,579", + "Precio Gasolina 98 E5": "", + "Precio Gases licuados del petróleo": "0,699", + "Precio Gasoleo Premium": "", + "Precio Gasoleo B": "", + "Precio Diésel Renovable": "", + "Precio Gasolina 95 E5 Premium": "", + "Precio Gasolina 95 E10": "", + "Precio Gasolina 98 E10": "", + "Precio Gas Natural Comprimido": "", + "Precio Gas Natural Licuado": "", + "Precio Hidrogeno": "", + "Precio Adblue": "", + }, + ], + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockMitecoResponse, + } as Response); + + const { stations, prices } = await scraper.fetch(); + + // Should parse both stations + expect(stations).toHaveLength(2); + + // First station + expect(stations[0].externalId).toBe("1234"); + expect(stations[0].brand).toBe("Repsol"); + expect(stations[0].city).toBe("MADRID"); + expect(stations[0].latitude).toBeCloseTo(40.416775, 4); + expect(stations[0].longitude).toBeCloseTo(-3.70379, 4); + expect(stations[0].stationType).toBe("fuel"); + + // Second station + expect(stations[1].externalId).toBe("5678"); + expect(stations[1].brand).toBe("Cepsa"); + + // Prices: station 1 has B7, E5, E5_98, B7_PREMIUM = 4 prices + // Station 2 has B7, E5, LPG = 3 prices + expect(prices).toHaveLength(7); + + const station1Prices = prices.filter((p) => p.stationExternalId === "1234"); + expect(station1Prices).toHaveLength(4); + + const dieselPrice = station1Prices.find((p) => p.fuelType === "B7"); + expect(dieselPrice).toBeDefined(); + expect(dieselPrice!.price).toBeCloseTo(1.459, 3); + expect(dieselPrice!.currency).toBe("EUR"); + + const lpgPrice = prices.find((p) => p.fuelType === "LPG"); + expect(lpgPrice).toBeDefined(); + expect(lpgPrice!.price).toBeCloseTo(0.699, 3); + }); + + it("skips stations with invalid coordinates", async () => { + const { SpainScraper } = await import("./spain"); + const scraper = new SpainScraper(); + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + Fecha: "01/04/2026", + ResultadoConsulta: "OK", + Nota: "", + ListaEESSPrecio: [ + { + IDEESS: "9999", + "Rótulo": "TEST", + "Dirección": "TEST", + Municipio: "TEST", + Provincia: "TEST", + "C.P.": "00000", + Latitud: "", // empty + "Longitud (WGS84)": "-3,5", + "Tipo Venta": "D", + "Precio Gasoleo A": "1,500", + }, + { + IDEESS: "8888", + "Rótulo": "TEST2", + "Dirección": "TEST2", + Municipio: "TEST2", + Provincia: "TEST2", + "C.P.": "00001", + Latitud: "90,000", // out of Spain bounds + "Longitud (WGS84)": "0,000", + "Tipo Venta": "D", + "Precio Gasoleo A": "1,500", + }, + ], + }), + } as Response); + + const { stations } = await scraper.fetch(); + expect(stations).toHaveLength(0); + }); + + it("throws on non-OK API response", async () => { + const { SpainScraper } = await import("./spain"); + const scraper = new SpainScraper(); + + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + } as Response); + + await expect(scraper.fetch()).rejects.toThrow("HTTP 500"); + }); + + it("throws on ResultadoConsulta != OK", async () => { + const { SpainScraper } = await import("./spain"); + const scraper = new SpainScraper(); + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + Fecha: "01/04/2026", + ResultadoConsulta: "ERROR", + Nota: "", + ListaEESSPrecio: [], + }), + } as Response); + + await expect(scraper.fetch()).rejects.toThrow("ResultadoConsulta"); + }); + + it("throws on empty ListaEESSPrecio", async () => { + const { SpainScraper } = await import("./spain"); + const scraper = new SpainScraper(); + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + Fecha: "01/04/2026", + ResultadoConsulta: "OK", + Nota: "", + ListaEESSPrecio: [], + }), + } as Response); + + await expect(scraper.fetch()).rejects.toThrow("empty"); + }); + + it("ignores prices with zero or missing values", async () => { + const { SpainScraper } = await import("./spain"); + const scraper = new SpainScraper(); + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + Fecha: "01/04/2026", + ResultadoConsulta: "OK", + Nota: "", + ListaEESSPrecio: [ + { + IDEESS: "1111", + "Rótulo": "TEST", + "Dirección": "TEST", + Municipio: "TEST", + Provincia: "TEST", + "C.P.": "28001", + Latitud: "40,416", + "Longitud (WGS84)": "-3,703", + "Tipo Venta": "D", + "Precio Gasoleo A": "0,000", // zero price + "Precio Gasolina 95 E5": "1,599", + }, + ], + }), + } as Response); + + const { prices } = await scraper.fetch(); + // Zero price should be filtered out, only E5 remains + expect(prices).toHaveLength(1); + expect(prices[0].fuelType).toBe("E5"); + }); +}); diff --git a/src/types/fuel.test.ts b/src/types/fuel.test.ts new file mode 100644 index 0000000..a9bfbc3 --- /dev/null +++ b/src/types/fuel.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { FUEL_TYPES, FUEL_TYPE_MAP, FUEL_CATEGORIES } from "./fuel"; + +describe("FUEL_TYPES", () => { + it("contains expected fuel types", () => { + const codes = FUEL_TYPES.map((f) => f.code); + expect(codes).toContain("B7"); + expect(codes).toContain("E5"); + expect(codes).toContain("E10"); + expect(codes).toContain("LPG"); + expect(codes).toContain("EV"); + expect(codes).toContain("H2"); + expect(codes).toContain("ADBLUE"); + }); + + it("all entries have label and valid category", () => { + const validCategories = ["gasoline", "diesel", "gas", "hydrogen", "electric", "other"]; + for (const ft of FUEL_TYPES) { + expect(ft.label).toBeTruthy(); + expect(validCategories).toContain(ft.category); + } + }); + + it("has no duplicate codes", () => { + const codes = FUEL_TYPES.map((f) => f.code); + expect(new Set(codes).size).toBe(codes.length); + }); +}); + +describe("FUEL_TYPE_MAP", () => { + it("maps all fuel type codes", () => { + for (const ft of FUEL_TYPES) { + expect(FUEL_TYPE_MAP.get(ft.code)).toBe(ft); + } + }); + + it("returns undefined for unknown codes", () => { + expect(FUEL_TYPE_MAP.get("UNKNOWN" as any)).toBeUndefined(); + }); +}); + +describe("FUEL_CATEGORIES", () => { + it("covers all categories used in FUEL_TYPES", () => { + const usedCategories = new Set(FUEL_TYPES.map((f) => f.category)); + const definedCategories = new Set(FUEL_CATEGORIES.map((c) => c.key)); + for (const cat of usedCategories) { + expect(definedCategories.has(cat), `category "${cat}" missing from FUEL_CATEGORIES`).toBe(true); + } + }); + + it("each category has a label", () => { + for (const cat of FUEL_CATEGORIES) { + expect(cat.label).toBeTruthy(); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..cb5ba0e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "src"), + }, + }, + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "lcov"], + include: ["src/lib/**", "src/scrapers/**", "src/middleware.ts", "src/app/api/**"], + exclude: ["src/generated/**", "src/**/*.test.ts", "src/scrapers/cli.ts"], + }, + }, +});