From 5e6135d660985bf2f30cddabace006cb3f1c4be3 Mon Sep 17 00:00:00 2001 From: Mathea Liv Date: Mon, 26 Oct 2026 14:51:37 +0100 Subject: [PATCH 1/4] [test] installerte pakker, la til tester, la til litt dokumentasjson --- .github/workflows/test.yml | 23 + README.md | 21 + backend/package-lock.json | 1116 +++++++++++++++++++++++++- backend/package.json | 6 +- backend/src/app.ts | 92 +++ backend/src/server.ts | 1 - backend/test/graphql.test.ts | 158 ++++ backend/test/testDb.ts | 79 ++ package-lock.json | 1053 +++++++++++++++++++++++- package.json | 12 +- src/App.test.tsx | 159 ++++ src/components/components.test.tsx | 82 ++ src/hooks/useDebounce.test.ts | 35 + src/hooks/useLoadMore.test.ts | 53 ++ src/lib/apollo.test.ts | 120 +++ src/lib/apollo.ts | 20 +- src/pages/CountryDetailPage.test.tsx | 58 ++ src/test/fixtures.ts | 25 + src/test/setup.ts | 5 + vite.config.ts | 7 +- 20 files changed, 3105 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 backend/src/app.ts create mode 100644 backend/test/graphql.test.ts create mode 100644 backend/test/testDb.ts create mode 100644 src/App.test.tsx create mode 100644 src/components/components.test.tsx create mode 100644 src/hooks/useDebounce.test.ts create mode 100644 src/hooks/useLoadMore.test.ts create mode 100644 src/lib/apollo.test.ts create mode 100644 src/pages/CountryDetailPage.test.tsx create mode 100644 src/test/fixtures.ts create mode 100644 src/test/setup.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1134daa --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: Tests + +on: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: | + package-lock.json + backend/package-lock.json + - run: npm ci + - run: npm ci --prefix backend + - name: Frontend tests (components and hooks) + run: npm test + - name: API tests (GraphQL against in-memory test database) + run: npm run test:api diff --git a/README.md b/README.md index 76f82bc..299399d 100644 --- a/README.md +++ b/README.md @@ -158,10 +158,31 @@ Unless otherwise specified, run these commands from the repository root. | `npm run format` | Format code with Prettier | | `npm run format:check` | Check formatting without changing files | | `npm run preview` | Preview the production build locally | +| `npm test` | Run frontend component and hook tests (Vitest) | +| `npm run test:api` | Run GraphQL API tests against the test database | | `npm run fetch-data` | Fetch and process country and city data into `data/` | | `npm run db:check` | Check the database connection (run from `backend/`) | | `npm run db:seed` | Populate the database with country and city data (run from `backend/`) | +## Testing + +Tests use [Vitest](https://vitest.dev/) and React Testing Library. They use fixed test data and require neither the production database nor the NTNU VPN. + +| Command | What it runs | +| ------------------ | -------------------------------------------------------------------------------------------------------- | +| `npm test` | Component, hook and Apollo cache tests (`src/**/*.test.ts(x)`). Use `npm run test:watch` for watch mode. | +| `npm run test:api` | GraphQL API tests (`backend/test/`). Same as `npm test` run inside `backend/`. | + +Before running the API tests for the first time, install the backend dependencies with `cd backend && npm install`. + +The API tests run the real resolvers against an in-memory PostgreSQL database ([PGlite](https://pglite.dev/)) that is created and filled with fixed data from `backend/test/testDb.ts` for each run. Nothing needs to be installed or started. + +The tests cover search (case-insensitive), filtering, pagination, `totalCount`, loading/error/empty states, debounce (300 ms), "Load more" (offset, counter, last page, retry) and Apollo cache behaviour (separate searches, merged pages without duplicates). + +When server-side sorting/filtering ([#27](https://git.ntnu.no/IT2810-H26/T28-Project-2/issues/27)) and search in both countries and cities ([#55](https://git.ntnu.no/IT2810-H26/T28-Project-2/issues/55)) are implemented, extend `backend/test/graphql.test.ts` and `src/lib/apollo.test.ts` with sorting tests on the full result set before pagination, and separate country/city results. + +The workflow `.github/workflows/test.yml` runs both test suites on every pull request. + ## Data The dataset is generated by `scripts/fetch-data.ts` and saved to `data/`: diff --git a/backend/package-lock.json b/backend/package-lock.json index b24ac44..b265816 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,7 +14,9 @@ "pg": "^8.23.1" }, "devDependencies": { - "@types/pg": "^8.23.1" + "@electric-sql/pglite": "^0.5.11", + "@types/pg": "^8.23.1", + "vitest": "^5.0.5" } }, "node_modules/@apollo/cache-control-types": { @@ -254,6 +256,13 @@ "node": ">=16" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.11.tgz", + "integrity": "sha512-SWuqsTjA4DDWqgZ05PC1RJaq8e00p5KACxbqaikDFQ8dIGA0SLRzuPniky7IW+q/c5RDNMdEXwv7mFBWIx9Hxw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@graphql-tools/merge": { "version": "9.2.6", "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-9.2.6.tgz", @@ -314,6 +323,45 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -377,6 +425,309 @@ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "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.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/long": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", @@ -405,6 +756,44 @@ "pg-types": "^2.2.0" } }, + "node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@whatwg-node/promise-helpers": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", @@ -417,6 +806,16 @@ "node": ">=22.15.0" } }, + "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/async-retry": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", @@ -534,6 +933,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", @@ -615,6 +1024,17 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -662,6 +1082,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -680,6 +1107,44 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "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/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "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/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -716,6 +1181,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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" + ], + "peer": true, + "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", @@ -909,6 +1390,279 @@ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -943,6 +1697,16 @@ "node": "20 || >=22" } }, + "node_modules/magic-string": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.4.tgz", + "integrity": "sha512-jQz9sVhrZuphH4NrAIHSgkDJCluQO7Ioi4KGRLa7aTUjAODQ13hENX0YIytqYE/rIkO485gQG2LeBxJaW53kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -996,6 +1760,26 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", @@ -1046,6 +1830,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1156,6 +1954,27 @@ "split2": "^4.1.0" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -1165,6 +1984,36 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -1244,6 +2093,41 @@ "node": ">= 4" } }, + "node_modules/rolldown": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.155.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1385,6 +2269,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -1403,6 +2298,50 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -1502,6 +2441,168 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", + "tinyglobby": "^0.2.17" + }, + "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.7.1 || ^0.8.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/vitest": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.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/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", @@ -1532,6 +2633,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index 03003f5..503229f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,7 +4,7 @@ "description": "", "private": true, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "test": "vitest run", "db:check": "node --env-file=.env src/checkDb.ts", "db:seed": "node --env-file=.env src/seed.ts", "dev": "node --env-file=.env --watch src/server.ts", @@ -20,6 +20,8 @@ "pg": "^8.23.1" }, "devDependencies": { - "@types/pg": "^8.23.1" + "@electric-sql/pglite": "^0.5.11", + "@types/pg": "^8.23.1", + "vitest": "^5.0.5" } } diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..1864908 --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,92 @@ +import { readFile } from 'node:fs/promises'; +import { ApolloServer } from '@apollo/server'; + +export interface Db { + query: (sql: string, params?: unknown[]) => Promise<{ rows: Record[] }>; +} + +const COUNTRY_COLUMNS = ` + code, name, capital, continent, languages, population, area, + flag_url AS "flagUrl" +`; +const CITY_COLUMNS = ` + id, name, latitude, longitude, population, country_code +`; + +export const typeDefs = await readFile(new URL('./schema.graphql', import.meta.url), 'utf-8'); + +export function createResolvers(pool: Db) { + return { + Query: { + country: async (_parent, args) => { + const { rows } = await pool.query( + `SELECT ${COUNTRY_COLUMNS} FROM country WHERE code = $1`, + [args.code] + ); + return rows[0] ?? null; + }, + + countries: async (_parent, args) => { + const filters = ` + WHERE ($1::text IS NULL OR name ILIKE '%' || $1 || '%') + AND ($2::text IS NULL OR continent = $2) + `; + const filterValues = [args.search ?? null, args.continent ?? null]; + + const itemsResult = await pool.query( + `SELECT ${COUNTRY_COLUMNS} FROM country ${filters} ORDER BY name LIMIT $3 OFFSET $4`, + [...filterValues, args.limit, args.offset] + ); + const countResult = await pool.query( + `SELECT count(*) FROM country ${filters}`, + filterValues + ); + + return { + items: itemsResult.rows, + totalCount: Number(countResult.rows[0].count), + }; + }, + + city: async (_parent, args) => { + const { rows } = await pool.query(`SELECT ${CITY_COLUMNS} FROM city WHERE id = $1`, [ + args.id, + ]); + return rows[0] ?? null; + }, + + cities: async (_parent, args) => { + const filters = ` + WHERE ($1::text IS NULL OR name ILIKE '%' || $1 || '%') + AND ($2::text IS NULL OR country_code = $2) + `; + const filterValues = [args.search ?? null, args.countryCode ?? null]; + + const itemsResult = await pool.query( + `SELECT ${CITY_COLUMNS} FROM city ${filters} ORDER BY population DESC LIMIT $3 OFFSET $4`, + [...filterValues, args.limit, args.offset] + ); + const countResult = await pool.query(`SELECT count(*) FROM city ${filters}`, filterValues); + + return { + items: itemsResult.rows, + totalCount: Number(countResult.rows[0].count), + }; + }, + }, + + City: { + country: async (parent) => { + const { rows } = await pool.query( + `SELECT ${COUNTRY_COLUMNS} FROM country WHERE code = $1`, + [parent.country_code] + ); + return rows[0]; + }, + }, + }; +} + +export function createServer(pool: Db) { + return new ApolloServer({ typeDefs, resolvers: createResolvers(pool) }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index e5a046c..259eff6 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -6,7 +6,6 @@ import { pool } from './db.ts'; const PORT = Number(process.env.PORT ?? 3001); const HOST = process.env.HOST ?? '0.0.0.0'; - const COUNTRY_COLUMNS = ` code, name, capital, continent, languages, population, area, flag_url AS "flagUrl" diff --git a/backend/test/graphql.test.ts b/backend/test/graphql.test.ts new file mode 100644 index 0000000..086b656 --- /dev/null +++ b/backend/test/graphql.test.ts @@ -0,0 +1,158 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createServer } from '../src/app.ts'; +import { countryFixtures, createTestDb } from './testDb.ts'; + +let db: Awaited>; +let server: ReturnType; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const data = (result: { data?: unknown }) => result.data as any; + +async function run(query: string, variables: Record = {}) { + const res = await server.executeOperation({ query, variables }); + if (res.body.kind !== 'single') throw new Error('Unexpected response kind'); + return res.body.singleResult; +} + +async function countries(variables: Record = {}) { + const result = await run( + `query($search: String, $continent: String, $limit: Int, $offset: Int) { + countries(search: $search, continent: $continent, limit: $limit, offset: $offset) { + items { code name } totalCount + } + }`, + variables + ); + expect(result.errors).toBeUndefined(); + return data(result).countries as { + items: { code: string; name: string }[]; + totalCount: number; + }; +} + +beforeAll(async () => { + db = await createTestDb(); + server = createServer(db); + await server.start(); +}); + +afterAll(async () => { + await server.stop(); + await db.close(); +}); + +describe('countries query', () => { + it('returns the first page with the total count of all rows', async () => { + const result = await countries({ limit: 10, offset: 0 }); + expect(result.items).toHaveLength(10); + expect(result.totalCount).toBe(countryFixtures.length); + }); + + it('pages without duplicates and covers every country', async () => { + const codes: string[] = []; + for (let offset = 0; offset < 30; offset += 10) { + codes.push(...(await countries({ limit: 10, offset })).items.map((c) => c.code)); + } + expect(codes).toHaveLength(countryFixtures.length); + expect(new Set(codes).size).toBe(codes.length); + }); + + it('returns a short last page and an empty page past the end', async () => { + expect((await countries({ limit: 10, offset: 20 })).items).toHaveLength(5); + const past = await countries({ limit: 10, offset: 100 }); + expect(past.items).toEqual([]); + expect(past.totalCount).toBe(countryFixtures.length); + }); + + it('orders by name', async () => { + const names = (await countries({ limit: 25, offset: 0 })).items.map((c) => c.name); + const sorted = [...names].sort(); + expect(names).toEqual(sorted); + }); + + it('search is case-insensitive', async () => { + const lower = await countries({ search: 'norway', limit: 20, offset: 0 }); + const upper = await countries({ search: 'NORWAY', limit: 20, offset: 0 }); + const mixed = await countries({ search: 'nOrWaY', limit: 20, offset: 0 }); + expect(lower.items.map((c) => c.name)).toEqual(['Norway']); + expect(upper).toEqual(lower); + expect(mixed).toEqual(lower); + }); + + it('search matches substrings and totalCount reflects the filter', async () => { + const result = await countries({ search: 'nor', limit: 1, offset: 0 }); + expect(result.totalCount).toBe(2); // Norway, Northland + expect(result.items).toHaveLength(1); + }); + + it('filters by continent on the whole set before paginating', async () => { + const expected = countryFixtures.filter((c) => c.continent === 'Europe'); + const page1 = await countries({ continent: 'Europe', limit: 3, offset: 0 }); + const page2 = await countries({ continent: 'Europe', limit: 3, offset: 3 }); + expect(page1.totalCount).toBe(expected.length); + const all = [...page1.items, ...page2.items].map((c) => c.code); + expect(new Set(all).size).toBe(all.length); + for (const code of all) { + expect(expected.map((c) => c.code)).toContain(code); + } + }); + + it('combines search and continent filters', async () => { + const result = await countries({ search: 'norway', continent: 'Asia', limit: 20, offset: 0 }); + expect(result).toEqual({ items: [], totalCount: 0 }); + }); + + it('returns nothing for a search without matches', async () => { + expect(await countries({ search: 'zzzz', limit: 20, offset: 0 })).toEqual({ + items: [], + totalCount: 0, + }); + }); +}); + +describe('country query', () => { + it('returns one country with nullable fields', async () => { + const result = await run( + `query($code: ID!) { country(code: $code) { code name population area capital languages } }`, + { code: 'XD' } + ); + expect(result.errors).toBeUndefined(); + expect(data(result).country).toMatchObject({ + code: 'XD', + population: null, + area: null, + }); + }); + + it('returns null for an unknown code', async () => { + const result = await run(`query { country(code: "ZZ") { code } }`); + expect(data(result).country).toBeNull(); + }); +}); + +describe('cities query', () => { + const CITIES = `query($search: String, $countryCode: String, $limit: Int, $offset: Int) { + cities(search: $search, countryCode: $countryCode, limit: $limit, offset: $offset) { + items { id name country { code } } totalCount + } + }`; + + it('orders by population, filters by country and resolves country', async () => { + const all = await run(CITIES, { limit: 10, offset: 0 }); + expect(data(all).cities.items.map((c: { name: string }) => c.name)).toEqual([ + 'Osaka', + 'Oslo', + 'Bergen', + ]); + + const filtered = await run(CITIES, { countryCode: 'XA', limit: 1, offset: 1 }); + const page = data(filtered).cities; + expect(page.totalCount).toBe(2); + expect(page.items).toEqual([{ id: '2', name: 'Bergen', country: { code: 'XA' } }]); + }); + + it('searches case-insensitively', async () => { + const result = await run(CITIES, { search: 'OSL', limit: 10, offset: 0 }); + expect(data(result).cities.items.map((c: { name: string }) => c.name)).toEqual(['Oslo']); + }); +}); diff --git a/backend/test/testDb.ts b/backend/test/testDb.ts new file mode 100644 index 0000000..3f3ab44 --- /dev/null +++ b/backend/test/testDb.ts @@ -0,0 +1,79 @@ +import { PGlite } from '@electric-sql/pglite'; +import type { Db } from '../src/app.ts'; + +// Fixed test data: 25 countries (so pagination spans several pages) and a few cities. +export const CONTINENTS = ['Europe', 'Asia', 'Africa', 'Oceania', 'Americas']; + +export const countryFixtures = Array.from({ length: 25 }, (_, i) => { + const n = String(i + 1).padStart(2, '0'); + return { + code: `X${String.fromCharCode(65 + i)}`, + name: `Testland ${n}`, + capital: [`Capital ${n}`], + continent: CONTINENTS[i % CONTINENTS.length], + languages: ['Testish'], + population: i === 3 ? null : (i + 1) * 1000, + area: i === 3 ? null : (i + 1) * 10.5, + flagUrl: `https://example.test/flags/${n}.png`, + }; +}); + +// Names that exercise case-insensitive search. +countryFixtures[0].name = 'Norway'; +countryFixtures[1].name = 'Northland'; +countryFixtures[2].name = 'Aland'; + +export const cityFixtures = [ + { id: 1, name: 'Oslo', latitude: 59.9, longitude: 10.7, country_code: 'XA', population: 700000 }, + { id: 2, name: 'Bergen', latitude: 60.4, longitude: 5.3, country_code: 'XA', population: 280000 }, + { + id: 3, + name: 'Osaka', + latitude: 34.7, + longitude: 135.5, + country_code: 'XB', + population: 2700000, + }, +]; + +export async function createTestDb(): Promise Promise }> { + const db = new PGlite(); + + await db.exec(` + CREATE TABLE country ( + code CHAR(2) PRIMARY KEY, + name TEXT NOT NULL, + capital TEXT[] NOT NULL DEFAULT '{}', + continent TEXT NOT NULL, + languages TEXT[] NOT NULL DEFAULT '{}', + population INTEGER, + area DOUBLE PRECISION, + flag_url TEXT NOT NULL + ); + CREATE TABLE city ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + latitude DOUBLE PRECISION NOT NULL, + longitude DOUBLE PRECISION NOT NULL, + country_code CHAR(2) NOT NULL REFERENCES country(code), + population INTEGER NOT NULL + ); + `); + + for (const c of countryFixtures) { + await db.query( + `INSERT INTO country (code, name, capital, continent, languages, population, area, flag_url) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [c.code, c.name, c.capital, c.continent, c.languages, c.population, c.area, c.flagUrl] + ); + } + for (const c of cityFixtures) { + await db.query( + `INSERT INTO city (id, name, latitude, longitude, country_code, population) + VALUES ($1, $2, $3, $4, $5, $6)`, + [c.id, c.name, c.latitude, c.longitude, c.country_code, c.population] + ); + } + + return { query: (sql, params) => db.query(sql, params), close: () => db.close() }; +} diff --git a/package-lock.json b/package-lock.json index 5d3f24a..36f5cf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.8", "@types/node": "^24.13.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", @@ -25,13 +28,22 @@ "eslint-plugin-react-refresh": "^0.5.6", "fflate": "^0.8.3", "globals": "^17.12.0", + "jsdom": "^29.1.1", "prettier": "^3.9.9", "tsx": "^4.23.15", "typescript": "~6.0.2", "typescript-eslint": "^8.69.0", - "vite": "^8.3.0" + "vite": "^8.3.0", + "vitest": "^5.0.5" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@apollo/client": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.3.3.tgz", @@ -69,6 +81,57 @@ } } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -261,6 +324,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -309,6 +382,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@cacheable/memory": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", @@ -333,6 +419,146 @@ "keyv": "^5.6.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "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.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "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.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "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.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "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-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "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" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -903,6 +1129,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "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/@graphql-typed-document-node/core": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", @@ -1324,6 +1568,131 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "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/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "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": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "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.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "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.8", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.8.tgz", + "integrity": "sha512-vgSSqnANMnkcYR/vUNDbwS7aM3OErRLNUG8vCVVlj4oc6cKffUrccX7UAS5AmJQQNZTSibi9z5GvvRZ0aYgUCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "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", + "peer": true + }, + "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/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1648,6 +2017,44 @@ } } }, + "node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@wry/caches": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", @@ -1736,6 +2143,51 @@ "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", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "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", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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/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/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1759,10 +2211,20 @@ "node": ">=6.0.0" } }, - "node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1841,6 +2303,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1863,6 +2335,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.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/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1870,6 +2363,20 @@ "dev": true, "license": "MIT" }, + "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/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1888,6 +2395,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", @@ -1895,6 +2409,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1905,6 +2429,14 @@ "node": ">=8" } }, + "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", + "peer": true + }, "node_modules/electron-to-chromium": { "version": "1.5.439", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.439.tgz", @@ -1912,6 +2444,26 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -2168,6 +2720,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", @@ -2178,6 +2740,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2382,6 +2954,19 @@ "dev": true, "license": "MIT" }, + "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/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2402,6 +2987,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/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2425,6 +3020,13 @@ "node": ">=0.10.0" } }, + "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/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2439,6 +3041,57 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2790,6 +3443,44 @@ "yallist": "^3.0.2" } }, + "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", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.4.tgz", + "integrity": "sha512-jQz9sVhrZuphH4NrAIHSgkDJCluQO7Ioi4KGRLa7aTUjAODQ13hENX0YIytqYE/rIkO485gQG2LeBxJaW53kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "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": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -2849,6 +3540,20 @@ "node": ">=18" } }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optimism": { "version": "0.18.1", "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz", @@ -2911,6 +3616,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.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", @@ -3006,6 +3724,22 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "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", + "peer": true, + "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/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3057,6 +3791,38 @@ "react": "^19.3.0" } }, + "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", + "peer": true + }, + "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/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/rolldown": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", @@ -3101,6 +3867,19 @@ "tslib": "^2.1.0" } }, + "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.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", @@ -3150,6 +3929,53 @@ "node": ">=0.10.0" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "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/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/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3167,6 +3993,52 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz", + "integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.19.tgz", + "integrity": "sha512-goNXV6Ho9TIOfpJZanTd6CKG5DyYgYAL+k+22T0SLh8l6+KHIfBUPFuePuSZv05UttIaaLmI9tb7TmGIdK5AkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/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.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -3256,6 +4128,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -3382,6 +4264,137 @@ } } }, + "node_modules/vitest": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.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/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", @@ -3398,6 +4411,19 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3408,6 +4434,23 @@ "node": ">=0.10.0" } }, + "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/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 790ecfd..9ee3056 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,10 @@ "preview": "vite preview", "format": "prettier --write .", "format:check": "prettier --check .", - "fetch-data": "tsx scripts/fetch-data.ts" + "fetch-data": "tsx scripts/fetch-data.ts", + "test": "vitest run", + "test:watch": "vitest", + "test:api": "npm --prefix backend test" }, "dependencies": { "@apollo/client": "^4.3.3", @@ -20,6 +23,9 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.8", "@types/node": "^24.13.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", @@ -30,10 +36,12 @@ "eslint-plugin-react-refresh": "^0.5.6", "fflate": "^0.8.3", "globals": "^17.12.0", + "jsdom": "^29.1.1", "prettier": "^3.9.9", "tsx": "^4.23.15", "typescript": "~6.0.2", "typescript-eslint": "^8.69.0", - "vite": "^8.3.0" + "vite": "^8.3.0", + "vitest": "^5.0.5" } } diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..9d8c5b3 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,159 @@ +import type { MockLink } from '@apollo/client/testing'; +import { MockedProvider } from '@apollo/client/testing/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import App from './App'; +import { GET_COUNTRIES, GET_COUNTRY } from './graphql/countries'; +import { createCache } from './lib/apollo'; +import { makeCountries, withTypename } from './test/fixtures'; + +const TOTAL = 25; + +function countriesResult(from: number, to: number, totalCount = TOTAL) { + return { + data: { + countries: { + __typename: 'CountryPage', + items: makeCountries(from, to).map(withTypename), + totalCount, + }, + }, + }; +} + +function countriesMock( + variables: Record, + from: number, + to: number, + totalCount = TOTAL +): MockLink.MockedResponse { + return { + request: { query: GET_COUNTRIES, variables }, + result: countriesResult(from, to, totalCount), + }; +} + +const firstPage = (search: string | null = null) => + countriesMock({ search, limit: 20, offset: 0 }, 1, 20); + +function renderApp(mocks: MockLink.MockedResponse[]) { + return render( + + + + ); +} + +describe('App', () => { + it('shows loading, then the first page with counter', async () => { + renderApp([firstPage()]); + expect(screen.getByText('Loading countries...')).toBeInTheDocument(); + + expect(await screen.findByText('Testland 01')).toBeInTheDocument(); + expect(screen.getAllByRole('listitem')).toHaveLength(20); + expect(screen.getByRole('status')).toHaveTextContent('Showing 20 of 25'); + }); + + it('shows an error message when the query fails', async () => { + renderApp([ + { + request: { query: GET_COUNTRIES, variables: { search: null, limit: 20, offset: 0 } }, + error: new Error('Network down'), + }, + ]); + expect(await screen.findByText(/Error loading countries: Network down/)).toBeInTheDocument(); + }); + + it('shows a message when nothing matches', async () => { + renderApp([countriesMock({ search: null, limit: 20, offset: 0 }, 1, 0, 0)]); + expect(await screen.findByText('No countries found.')).toBeInTheDocument(); + }); + + it('loads the next page with the right offset and hides the button on the last page', async () => { + const secondPage = countriesMock({ search: null, limit: 20, offset: 20 }, 21, 25); + const spy = vi.fn(() => countriesResult(21, 25)); + renderApp([firstPage(), { ...secondPage, result: spy }]); + + await userEvent.click(await screen.findByRole('button', { name: 'Load more' })); + + expect(await screen.findByText('Testland 25')).toBeInTheDocument(); + expect(spy).toHaveBeenCalledOnce(); + expect(screen.getAllByRole('listitem')).toHaveLength(25); + expect(screen.getByRole('status')).toHaveTextContent('Showing 25 of 25'); + expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument(); + // First page is still there: pages are merged, not replaced. + expect(screen.getByText('Testland 01')).toBeInTheDocument(); + }); + + it('shows an error when loading more fails and succeeds on retry', async () => { + const secondPage = countriesMock({ search: null, limit: 20, offset: 20 }, 21, 25); + renderApp([ + firstPage(), + { ...secondPage, result: undefined, error: new Error('Flaky') }, + secondPage, + ]); + + await userEvent.click(await screen.findByRole('button', { name: 'Load more' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Could not load more: Flaky'); + expect(screen.getAllByRole('listitem')).toHaveLength(20); + + await userEvent.click(screen.getByRole('button', { name: 'Load more' })); + expect(await screen.findByText('Testland 25')).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('searches with the last typed value after the 300 ms debounce', async () => { + const searchResult = vi.fn(() => countriesResult(1, 1, 1)); + renderApp([ + firstPage(), + { + request: { query: GET_COUNTRIES, variables: { search: 'norway', limit: 20, offset: 0 } }, + result: searchResult, + }, + ]); + await screen.findByText('Testland 01'); + + const input = screen.getByLabelText('Search for countries'); + for (const value of ['n', 'no', 'nor', 'norway']) { + fireEvent.change(input, { target: { value } }); + } + expect(searchResult).not.toHaveBeenCalled(); + + await waitFor(() => expect(searchResult).toHaveBeenCalledOnce(), { timeout: 2000 }); + await waitFor(() => expect(screen.getAllByRole('listitem')).toHaveLength(1)); + expect(screen.getByRole('status')).toHaveTextContent('Showing 1 of 1'); + }); + + it('trims the search text and treats an empty search as no filter', async () => { + const mocks = [firstPage(), countriesMock({ search: 'oslo', limit: 20, offset: 0 }, 1, 2, 2)]; + renderApp(mocks); + await screen.findByText('Testland 01'); + + fireEvent.change(screen.getByLabelText('Search for countries'), { + target: { value: ' oslo ' }, + }); + await waitFor(() => expect(screen.getAllByRole('listitem')).toHaveLength(2), { timeout: 2000 }); + }); + + it('opens the detail page and goes back', async () => { + const country = withTypename(makeCountries(1, 1)[0]); + renderApp([ + firstPage(), + { + request: { query: GET_COUNTRY, variables: { code: 'T01' } }, + result: { data: { country } }, + }, + ]); + + const card = (await screen.findByText('Testland 01')).closest('article')!; + await userEvent.click(within(card).getByRole('button', { name: 'View details' })); + + expect(await screen.findByRole('button', { name: 'Back' })).toBeInTheDocument(); + expect(screen.queryByLabelText('Search for countries')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Back' })); + expect(await screen.findByLabelText('Search for countries')).toBeInTheDocument(); + expect(screen.getAllByRole('listitem')).toHaveLength(20); + }); +}); diff --git a/src/components/components.test.tsx b/src/components/components.test.tsx new file mode 100644 index 0000000..8ab0b82 --- /dev/null +++ b/src/components/components.test.tsx @@ -0,0 +1,82 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { makeCountries, makeCountry } from '../test/fixtures'; +import { CountryCard } from './CountryCard'; +import { CountryList } from './CountryList'; +import { LoadMore } from './LoadMore'; +import { SearchBar } from './SearchBar'; + +describe('CountryCard', () => { + it('shows name, capital, continent and flag', () => { + render( {}} />); + expect(screen.getByRole('heading', { name: 'Testland 01' })).toBeInTheDocument(); + expect(screen.getByText('Capital: Capital 01')).toBeInTheDocument(); + expect(screen.getByText('Continent: Europe')).toBeInTheDocument(); + expect(screen.getByAltText('Flag of Testland 01')).toBeInTheDocument(); + }); + + it('shows "None" when there is no capital and joins several capitals', () => { + const { rerender } = render( + {}} /> + ); + expect(screen.getByText('Capital: None')).toBeInTheDocument(); + rerender( {}} />); + expect(screen.getByText('Capital: A, B')).toBeInTheDocument(); + }); + + it('calls onSelect when "View details" is clicked', async () => { + const onSelect = vi.fn(); + render(); + await userEvent.click(screen.getByRole('button', { name: 'View details' })); + expect(onSelect).toHaveBeenCalledOnce(); + }); +}); + +describe('CountryList', () => { + it('renders one item per country and passes the selected country', async () => { + const countries = makeCountries(1, 3); + const onSelect = vi.fn(); + render(); + + expect(screen.getAllByRole('listitem')).toHaveLength(3); + await userEvent.click(screen.getAllByRole('button', { name: 'View details' })[1]); + expect(onSelect).toHaveBeenCalledWith(countries[1]); + }); + + it('renders an empty list', () => { + render( {}} />); + expect(screen.queryAllByRole('listitem')).toHaveLength(0); + }); +}); + +describe('LoadMore', () => { + it('shows the counter and a button while more items exist', async () => { + const onLoadMore = vi.fn(); + render(); + expect(screen.getByRole('status')).toHaveTextContent('Showing 20 of 45'); + await userEvent.click(screen.getByRole('button', { name: 'Load more' })); + expect(onLoadMore).toHaveBeenCalledOnce(); + }); + + it('hides the button on the last page', () => { + render( {}} />); + expect(screen.getByRole('status')).toHaveTextContent('Showing 45 of 45'); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('disables the button while loading', () => { + render( {}} />); + expect(screen.getByRole('button', { name: 'Loading...' })).toBeDisabled(); + }); +}); + +describe('SearchBar', () => { + it('shows the value and reports changes', async () => { + const onChange = vi.fn(); + render(); + await userEvent.type(screen.getByLabelText('Search for countries'), 'no'); + expect(onChange).toHaveBeenNthCalledWith(1, 'n'); + expect(onChange).toHaveBeenNthCalledWith(2, 'o'); + }); +}); diff --git a/src/hooks/useDebounce.test.ts b/src/hooks/useDebounce.test.ts new file mode 100644 index 0000000..9f95e25 --- /dev/null +++ b/src/hooks/useDebounce.test.ts @@ -0,0 +1,35 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useDebounce } from './useDebounce'; + +describe('useDebounce', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('returns the initial value immediately', () => { + const { result } = renderHook(() => useDebounce('a', 300)); + expect(result.current).toBe('a'); + }); + + it('does not update before 300 ms have passed', () => { + const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), { + initialProps: { value: 'a' }, + }); + rerender({ value: 'b' }); + act(() => vi.advanceTimersByTime(299)); + expect(result.current).toBe('a'); + }); + + it('uses the last value after 300 ms when the value changes quickly', () => { + const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), { + initialProps: { value: '' }, + }); + for (const value of ['n', 'no', 'nor', 'norw']) { + rerender({ value }); + act(() => vi.advanceTimersByTime(100)); + } + expect(result.current).toBe(''); + act(() => vi.advanceTimersByTime(200)); + expect(result.current).toBe('norw'); + }); +}); diff --git a/src/hooks/useLoadMore.test.ts b/src/hooks/useLoadMore.test.ts new file mode 100644 index 0000000..62e896e --- /dev/null +++ b/src/hooks/useLoadMore.test.ts @@ -0,0 +1,53 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { useLoadMore } from './useLoadMore'; + +describe('useLoadMore', () => { + it('calls fetchPage with the number of loaded items as offset', async () => { + const fetchPage = vi.fn().mockResolvedValue(undefined); + const { result } = renderHook(() => useLoadMore(fetchPage, 40)); + + await act(() => result.current.loadMore()); + + expect(fetchPage).toHaveBeenCalledExactlyOnceWith(40); + expect(result.current.isLoadingMore).toBe(false); + expect(result.current.loadMoreError).toBeNull(); + }); + + it('is loading while the request is pending', async () => { + let resolve!: () => void; + const fetchPage = vi.fn(() => new Promise((r) => (resolve = r))); + const { result } = renderHook(() => useLoadMore(fetchPage, 20)); + + let promise!: Promise; + act(() => { + promise = result.current.loadMore(); + }); + expect(result.current.isLoadingMore).toBe(true); + + await act(async () => { + resolve(); + await promise; + }); + expect(result.current.isLoadingMore).toBe(false); + }); + + it('stores the error and clears it on a successful retry', async () => { + const fetchPage = vi.fn().mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce(null); + const { result } = renderHook(() => useLoadMore(fetchPage, 20)); + + await act(() => result.current.loadMore()); + expect(result.current.loadMoreError?.message).toBe('boom'); + expect(result.current.isLoadingMore).toBe(false); + + await act(() => result.current.loadMore()); + expect(result.current.loadMoreError).toBeNull(); + expect(fetchPage).toHaveBeenCalledTimes(2); + }); + + it('wraps non-Error rejections', async () => { + const { result } = renderHook(() => useLoadMore(() => Promise.reject('nope'), 0)); + await act(() => result.current.loadMore()); + expect(result.current.loadMoreError).toBeInstanceOf(Error); + }); +}); diff --git a/src/lib/apollo.test.ts b/src/lib/apollo.test.ts new file mode 100644 index 0000000..c3c0470 --- /dev/null +++ b/src/lib/apollo.test.ts @@ -0,0 +1,120 @@ +import { gql } from '@apollo/client'; +import { describe, expect, it } from 'vitest'; +import { GET_COUNTRIES } from '../graphql/countries'; +import { makeCountries, withTypename } from '../test/fixtures'; +import { createCache } from './apollo'; + +type Vars = { search?: string | null; continent?: string | null; limit: number; offset: number }; + +function write( + cache: ReturnType, + variables: Vars, + from: number, + to: number, + total: number +) { + cache.writeQuery({ + query: GET_COUNTRIES, + variables, + data: { + countries: { + __typename: 'CountryPage', + items: makeCountries(from, to).map(withTypename), + totalCount: total, + }, + }, + }); +} + +function read(cache: ReturnType, variables: Vars) { + return cache.readQuery<{ countries: { items: { code: string }[]; totalCount: number } }>({ + query: GET_COUNTRIES, + variables, + }); +} + +describe('Apollo cache for countries', () => { + it('merges pages into one list in order', () => { + const cache = createCache(); + write(cache, { search: null, limit: 20, offset: 0 }, 1, 20, 25); + write(cache, { search: null, limit: 20, offset: 20 }, 21, 25, 25); + + const result = read(cache, { search: null, limit: 20, offset: 0 }); + expect(result?.countries.items.map((c) => c.code)).toEqual( + makeCountries(1, 25).map((c) => c.code) + ); + expect(result?.countries.totalCount).toBe(25); + }); + + it('does not duplicate items when the same page is written twice or pages overlap', () => { + const cache = createCache(); + write(cache, { search: null, limit: 20, offset: 0 }, 1, 20, 25); + write(cache, { search: null, limit: 20, offset: 0 }, 1, 20, 25); + write(cache, { search: null, limit: 20, offset: 15 }, 16, 25, 25); + + const codes = read(cache, { search: null, limit: 20, offset: 0 })!.countries.items.map( + (c) => c.code + ); + expect(codes).toHaveLength(25); + expect(new Set(codes).size).toBe(25); + }); + + it('keeps different searches separate', () => { + const cache = createCache(); + write(cache, { search: null, limit: 20, offset: 0 }, 1, 20, 25); + write(cache, { search: 'land 0', limit: 20, offset: 0 }, 1, 3, 3); + + expect(read(cache, { search: null, limit: 20, offset: 0 })?.countries.items).toHaveLength(20); + const filtered = read(cache, { search: 'land 0', limit: 20, offset: 0 }); + expect(filtered?.countries.items).toHaveLength(3); + expect(filtered?.countries.totalCount).toBe(3); + expect(read(cache, { search: 'other', limit: 20, offset: 0 })).toBeNull(); + }); + + // GET_COUNTRIES has no continent argument yet (see issue #27), so use a local query. + it('keeps different continent filters separate', () => { + const cache = createCache(); + const query = gql` + query ($continent: String, $limit: Int!, $offset: Int!) { + countries(continent: $continent, limit: $limit, offset: $offset) { + items { + code + } + totalCount + } + } + `; + const put = (continent: string, from: number, to: number) => + cache.writeQuery({ + query, + variables: { continent, limit: 20, offset: 0 }, + data: { + countries: { + __typename: 'CountryPage', + items: makeCountries(from, to).map(withTypename), + totalCount: to - from + 1, + }, + }, + }); + put('Europe', 1, 5); + put('Asia', 6, 7); + + const get = (continent: string) => + cache.readQuery<{ countries: { items: unknown[]; totalCount: number } }>({ + query, + variables: { continent, limit: 20, offset: 0 }, + })?.countries; + expect(get('Europe')?.totalCount).toBe(5); + expect(get('Asia')?.items).toHaveLength(2); + }); + + it('does not mix search pages: a later page of one search leaves another untouched', () => { + const cache = createCache(); + write(cache, { search: 'a', limit: 20, offset: 0 }, 1, 20, 40); + write(cache, { search: 'b', limit: 20, offset: 0 }, 21, 40, 40); + write(cache, { search: 'a', limit: 20, offset: 20 }, 21, 40, 40); + + expect(read(cache, { search: 'b', limit: 20, offset: 0 })?.countries.items).toHaveLength(20); + expect(read(cache, { search: 'a', limit: 20, offset: 0 })?.countries.items).toHaveLength(40); + }); +}); diff --git a/src/lib/apollo.ts b/src/lib/apollo.ts index 25bf45a..d31175b 100644 --- a/src/lib/apollo.ts +++ b/src/lib/apollo.ts @@ -32,14 +32,8 @@ function offsetPagination(keyArgs: string[]) { }; } -export const apolloClient = new ApolloClient({ - link: new HttpLink({ - uri: import.meta.env.VITE_GRAPHQL_URL ?? 'http://localhost:3001/graphql', - headers: { - 'x-user-id': getUserId(), - }, - }), - cache: new InMemoryCache({ +export function createCache() { + return new InMemoryCache({ typePolicies: { Country: { keyFields: ['code'] }, Query: { @@ -57,5 +51,15 @@ export const apolloClient = new ApolloClient({ }, }, }, + }); +} + +export const apolloClient = new ApolloClient({ + link: new HttpLink({ + uri: import.meta.env.VITE_GRAPHQL_URL ?? 'http://localhost:3001/graphql', + headers: { + 'x-user-id': getUserId(), + }, }), + cache: createCache(), }); diff --git a/src/pages/CountryDetailPage.test.tsx b/src/pages/CountryDetailPage.test.tsx new file mode 100644 index 0000000..2ef81e0 --- /dev/null +++ b/src/pages/CountryDetailPage.test.tsx @@ -0,0 +1,58 @@ +import type { MockLink } from '@apollo/client/testing'; +import { MockedProvider } from '@apollo/client/testing/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { GraphQLError } from 'graphql'; +import { describe, expect, it, vi } from 'vitest'; +import { GET_COUNTRY } from '../graphql/countries'; +import { makeCountry, withTypename } from '../test/fixtures'; +import { CountryDetailPage } from './CountryDetailPage'; + +const listCountry = makeCountry(1); + +function renderPage(mocks: MockLink.MockedResponse[], onBack = () => {}) { + return render( + + + + ); +} + +const request = { query: GET_COUNTRY, variables: { code: listCountry.code } }; + +describe('CountryDetailPage', () => { + it('shows loading, then the details', async () => { + renderPage([{ request, result: { data: { country: withTypename(listCountry) } } }]); + expect(screen.getByText('Loading country details...')).toBeInTheDocument(); + expect(await screen.findByRole('heading', { name: 'Testland 01' })).toBeInTheDocument(); + expect(screen.getByText('1,000')).toBeInTheDocument(); + expect(screen.getByText('10 km²')).toBeInTheDocument(); + }); + + it('shows "Unknown" for missing population and area', async () => { + const country = withTypename(makeCountry(1, { population: null, area: null })); + renderPage([{ request, result: { data: { country } } }]); + await screen.findByRole('heading', { name: 'Testland 01' }); + expect(screen.getAllByText('Unknown')).toHaveLength(2); + }); + + it('shows "None" for empty capital and languages', async () => { + const country = withTypename(makeCountry(1, { capital: [], languages: [] })); + renderPage([{ request, result: { data: { country } } }]); + await screen.findByRole('heading', { name: 'Testland 01' }); + expect(screen.getAllByText('None')).toHaveLength(2); + }); + + it('shows a message when the country does not exist', async () => { + renderPage([{ request, result: { data: { country: null } } }]); + expect(await screen.findByText('Country not found.')).toBeInTheDocument(); + }); + + it('shows an error message and lets the user go back', async () => { + const onBack = vi.fn(); + renderPage([{ request, result: { errors: [new GraphQLError('Server down')] } }], onBack); + expect(await screen.findByText(/Error loading country: Server down/)).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Back' })); + expect(onBack).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts new file mode 100644 index 0000000..fb0b7e4 --- /dev/null +++ b/src/test/fixtures.ts @@ -0,0 +1,25 @@ +import type { Country } from '../types/country'; + +export function makeCountry(n: number, overrides: Partial = {}): Country { + const id = String(n).padStart(2, '0'); + return { + code: `T${id}`, + name: `Testland ${id}`, + capital: [`Capital ${id}`], + continent: 'Europe', + languages: ['Testish'], + population: n * 1000, + area: n * 10, + flagUrl: `https://example.test/flags/${id}.png`, + ...overrides, + }; +} + +export function makeCountries(from: number, to: number): Country[] { + return Array.from({ length: to - from + 1 }, (_, i) => makeCountry(from + i)); +} + +// Apollo needs __typename on every object to normalize the cache. +export function withTypename(country: Country) { + return { __typename: 'Country' as const, ...country }; +} diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..1d55db9 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,5 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +afterEach(cleanup); diff --git a/vite.config.ts b/vite.config.ts index a74ec84..590efda 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,12 @@ import react from '@vitejs/plugin-react'; -import { defineConfig } from 'vite'; +import { defineConfig } from 'vitest/config'; // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.test.{ts,tsx}'], + }, }); From 92ae7b47150ded27b1fbc8e5adc76e86a1dba700 Mon Sep 17 00:00:00 2001 From: Mathea Liv Date: Fri, 30 Oct 2026 13:42:14 +0100 Subject: [PATCH 2/4] =?UTF-8?q?[test]=20kj=C3=B8r=20tester=20p=C3=A5=20sel?= =?UTF-8?q?f-hosted=20runner=20p=C3=A5=20VM,=20oppdatert=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 2 +- README.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1134daa..e4c162f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,7 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: self-hosted steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/README.md b/README.md index 299399d..5fb7f52 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Users can mark countries as visited or want to visit. The data is stored in the - **Backend**: GraphQL (Node.js/TypeScript) - **Database**: PostgreSQL, installed directly on the group's VM - **Data**: countries and cities from open datasets (see [Data](#data)) -- **Testing**: Vitest (coming) +- **Testing**: Vitest and React Testing Library - **Linting/formatting**: ESLint and Prettier ## Architecture and Scalability @@ -183,6 +183,8 @@ When server-side sorting/filtering ([#27](https://git.ntnu.no/IT2810-H26/T28-Pro The workflow `.github/workflows/test.yml` runs both test suites on every pull request. +git.ntnu.no has no GitHub-hosted runners, so the workflow runs on a self-hosted runner installed as a service on the group's NTNU VM (`it2810-28.idi.ntnu.no`). The runner is registered under *Settings → Actions → Runners*. If jobs are stuck in "Queued", check that the runner is online there, or run `sudo ./svc.sh status` in the runner folder on the VM. + ## Data The dataset is generated by `scripts/fetch-data.ts` and saved to `data/`: From d7a3fbba5174d7e16b79d63c71630580569c76cf Mon Sep 17 00:00:00 2001 From: Mathea Liv Date: Fri, 30 Oct 2026 14:13:26 +0100 Subject: [PATCH 3/4] [fix] endret README med Prettier --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5fb7f52..dca2b37 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ When server-side sorting/filtering ([#27](https://git.ntnu.no/IT2810-H26/T28-Pro The workflow `.github/workflows/test.yml` runs both test suites on every pull request. -git.ntnu.no has no GitHub-hosted runners, so the workflow runs on a self-hosted runner installed as a service on the group's NTNU VM (`it2810-28.idi.ntnu.no`). The runner is registered under *Settings → Actions → Runners*. If jobs are stuck in "Queued", check that the runner is online there, or run `sudo ./svc.sh status` in the runner folder on the VM. +git.ntnu.no has no GitHub-hosted runners, so the workflow runs on a self-hosted runner installed as a service on the group's NTNU VM (`it2810-28.idi.ntnu.no`). The runner is registered under _Settings → Actions → Runners_. If jobs are stuck in "Queued", check that the runner is online there, or run `sudo ./svc.sh status` in the runner folder on the VM. ## Data From 6991785284cb5cf78eeea9d0b796f8ed4271db86 Mon Sep 17 00:00:00 2001 From: Mathea Liv Date: Fri, 30 Oct 2026 15:38:22 +0100 Subject: [PATCH 4/4] [test] oppdater tester for ny UI etter rebase --- src/App.test.tsx | 77 +++++++++++++++++++++++++----- src/components/components.test.tsx | 33 ++++++++++--- src/test/fixtures.ts | 1 + 3 files changed, 92 insertions(+), 19 deletions(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index 9d8c5b3..365964a 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -4,12 +4,20 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import App from './App'; -import { GET_COUNTRIES, GET_COUNTRY } from './graphql/countries'; +import { GET_CITIES, GET_COUNTRIES, GET_COUNTRY } from './graphql/countries'; import { createCache } from './lib/apollo'; import { makeCountries, withTypename } from './test/fixtures'; const TOTAL = 25; +const DEFAULT_COUNTRY_VARIABLES = { + continent: null, + language: null, + sortBy: 'NAME', + sortOrder: 'ASC', + status: null, +}; + function countriesResult(from: number, to: number, totalCount = TOTAL) { return { data: { @@ -29,7 +37,10 @@ function countriesMock( totalCount = TOTAL ): MockLink.MockedResponse { return { - request: { query: GET_COUNTRIES, variables }, + request: { + query: GET_COUNTRIES, + variables: { ...DEFAULT_COUNTRY_VARIABLES, ...variables }, + }, result: countriesResult(from, to, totalCount), }; } @@ -37,6 +48,24 @@ function countriesMock( const firstPage = (search: string | null = null) => countriesMock({ search, limit: 20, offset: 0 }, 1, 20); +function emptyCitiesMock(search: string): MockLink.MockedResponse { + return { + request: { + query: GET_CITIES, + variables: { search, continent: null, limit: 20, offset: 0 }, + }, + result: { + data: { + cities: { + __typename: 'CityPage', + items: [], + totalCount: 0, + }, + }, + }, + }; +} + function renderApp(mocks: MockLink.MockedResponse[]) { return render( @@ -48,7 +77,7 @@ function renderApp(mocks: MockLink.MockedResponse[]) { describe('App', () => { it('shows loading, then the first page with counter', async () => { renderApp([firstPage()]); - expect(screen.getByText('Loading countries...')).toBeInTheDocument(); + expect(screen.getByText('Loading countries…')).toBeInTheDocument(); expect(await screen.findByText('Testland 01')).toBeInTheDocument(); expect(screen.getAllByRole('listitem')).toHaveLength(20); @@ -58,11 +87,21 @@ describe('App', () => { it('shows an error message when the query fails', async () => { renderApp([ { - request: { query: GET_COUNTRIES, variables: { search: null, limit: 20, offset: 0 } }, + request: { + query: GET_COUNTRIES, + variables: { + ...DEFAULT_COUNTRY_VARIABLES, + search: null, + limit: 20, + offset: 0, + }, + }, error: new Error('Network down'), }, ]); - expect(await screen.findByText(/Error loading countries: Network down/)).toBeInTheDocument(); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Could not load countries: Network down' + ); }); it('shows a message when nothing matches', async () => { @@ -95,7 +134,9 @@ describe('App', () => { ]); await userEvent.click(await screen.findByRole('button', { name: 'Load more' })); - expect(await screen.findByRole('alert')).toHaveTextContent('Could not load more: Flaky'); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'Could not load more countries: Flaky' + ); expect(screen.getAllByRole('listitem')).toHaveLength(20); await userEvent.click(screen.getByRole('button', { name: 'Load more' })); @@ -108,13 +149,22 @@ describe('App', () => { renderApp([ firstPage(), { - request: { query: GET_COUNTRIES, variables: { search: 'norway', limit: 20, offset: 0 } }, + request: { + query: GET_COUNTRIES, + variables: { + ...DEFAULT_COUNTRY_VARIABLES, + search: 'norway', + limit: 20, + offset: 0, + }, + }, result: searchResult, }, + emptyCitiesMock('norway'), ]); await screen.findByText('Testland 01'); - const input = screen.getByLabelText('Search for countries'); + const input = screen.getByLabelText('Search for a country or city'); for (const value of ['n', 'no', 'nor', 'norway']) { fireEvent.change(input, { target: { value } }); } @@ -126,11 +176,15 @@ describe('App', () => { }); it('trims the search text and treats an empty search as no filter', async () => { - const mocks = [firstPage(), countriesMock({ search: 'oslo', limit: 20, offset: 0 }, 1, 2, 2)]; + const mocks = [ + firstPage(), + countriesMock({ search: 'oslo', limit: 20, offset: 0 }, 1, 2, 2), + emptyCitiesMock('oslo'), + ]; renderApp(mocks); await screen.findByText('Testland 01'); - fireEvent.change(screen.getByLabelText('Search for countries'), { + fireEvent.change(screen.getByLabelText('Search for a country or city'), { target: { value: ' oslo ' }, }); await waitFor(() => expect(screen.getAllByRole('listitem')).toHaveLength(2), { timeout: 2000 }); @@ -150,10 +204,9 @@ describe('App', () => { await userEvent.click(within(card).getByRole('button', { name: 'View details' })); expect(await screen.findByRole('button', { name: 'Back' })).toBeInTheDocument(); - expect(screen.queryByLabelText('Search for countries')).not.toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: 'Back' })); - expect(await screen.findByLabelText('Search for countries')).toBeInTheDocument(); + expect(await screen.findByLabelText('Search for a country or city')).toBeInTheDocument(); expect(screen.getAllByRole('listitem')).toHaveLength(20); }); }); diff --git a/src/components/components.test.tsx b/src/components/components.test.tsx index 8ab0b82..1b26034 100644 --- a/src/components/components.test.tsx +++ b/src/components/components.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { MockedProvider } from '@apollo/client/testing/react'; import { describe, expect, it, vi } from 'vitest'; import { makeCountries, makeCountry } from '../test/fixtures'; import { CountryCard } from './CountryCard'; @@ -9,7 +10,11 @@ import { SearchBar } from './SearchBar'; describe('CountryCard', () => { it('shows name, capital, continent and flag', () => { - render( {}} />); + render( + + {}} /> + + ); expect(screen.getByRole('heading', { name: 'Testland 01' })).toBeInTheDocument(); expect(screen.getByText('Capital: Capital 01')).toBeInTheDocument(); expect(screen.getByText('Continent: Europe')).toBeInTheDocument(); @@ -18,16 +23,26 @@ describe('CountryCard', () => { it('shows "None" when there is no capital and joins several capitals', () => { const { rerender } = render( - {}} /> + + {}} /> + ); expect(screen.getByText('Capital: None')).toBeInTheDocument(); - rerender( {}} />); + rerender( + + {}} /> + + ); expect(screen.getByText('Capital: A, B')).toBeInTheDocument(); }); it('calls onSelect when "View details" is clicked', async () => { const onSelect = vi.fn(); - render(); + render( + + + + ); await userEvent.click(screen.getByRole('button', { name: 'View details' })); expect(onSelect).toHaveBeenCalledOnce(); }); @@ -37,7 +52,11 @@ describe('CountryList', () => { it('renders one item per country and passes the selected country', async () => { const countries = makeCountries(1, 3); const onSelect = vi.fn(); - render(); + render( + + + + ); expect(screen.getAllByRole('listitem')).toHaveLength(3); await userEvent.click(screen.getAllByRole('button', { name: 'View details' })[1]); @@ -74,8 +93,8 @@ describe('LoadMore', () => { describe('SearchBar', () => { it('shows the value and reports changes', async () => { const onChange = vi.fn(); - render(); - await userEvent.type(screen.getByLabelText('Search for countries'), 'no'); + render( {}} />); + await userEvent.type(screen.getByLabelText('Search for a country or city'), 'no'); expect(onChange).toHaveBeenNthCalledWith(1, 'n'); expect(onChange).toHaveBeenNthCalledWith(2, 'o'); }); diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index fb0b7e4..c7ce79c 100644 --- a/src/test/fixtures.ts +++ b/src/test/fixtures.ts @@ -11,6 +11,7 @@ export function makeCountry(n: number, overrides: Partial = {}): Countr population: n * 1000, area: n * 10, flagUrl: `https://example.test/flags/${id}.png`, + status: null, ...overrides, }; }