diff --git a/.env.example b/.env.example index 88b5a63..55337fa 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,12 @@ GOOGLE_OAUTH_CLIENT_ID="your-google-client-id" GOOGLE_OAUTH_CLIENT_SECRET="your-google-client-secret" DROPBOX_OAUTH_CLIENT_ID="your-dropbox-client-id" DROPBOX_OAUTH_CLIENT_SECRET="your-dropbox-client-secret" + +# Optional endpoint overrides for deterministic local provider tests. +GOOGLE_OAUTH_AUTHORIZE_URL="" +GOOGLE_OAUTH_TOKEN_URL="" +GOOGLE_DRIVE_API_URL="" +GOOGLE_DRIVE_UPLOAD_URL="" +DROPBOX_OAUTH_AUTHORIZE_URL="" +DROPBOX_OAUTH_TOKEN_URL="" +DROPBOX_UPLOAD_URL="" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..b9372f1 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,85 @@ +name: Tests + +on: + pull_request: + push: + branches: [master] + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run check + - run: npm run lint + - run: npm run test:fast + - run: npm run test:coverage + - uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage + path: coverage/ + if-no-files-found: ignore + + integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npx supabase start + - run: npx supabase db reset + - run: npm run test:integration + - if: always() + run: npx supabase stop + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run test:build + + bdd: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + - run: npx playwright install --with-deps chromium + - run: npx supabase start + - run: npx supabase db reset + - run: npm run test:bdd + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-failures + path: | + test-results/ + playwright-report/ + if-no-files-found: ignore + - if: always() + run: npx supabase stop diff --git a/.gitignore b/.gitignore index b855d60..8027dc1 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* /static/output.css .netlify +.features-gen +coverage +playwright-report +test-results diff --git a/README.md b/README.md index 80aa8c1..e1cfe3a 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,50 @@ To create a production version: npm run build ``` +## Testing + +The test suite is split by responsibility so a failure points to the correct layer: + +- `tests/unit` contains fast, isolated tests for utilities, repositories, and services. +- `tests/data` validates the tracked Pokémon, game, region, dex, and sprite reference files. +- `tests/integration` checks the migrated Supabase schema, views, constraints, RLS, and repositories. +- `tests/bdd/features` is the executable Gherkin specification for user-visible behaviour. Step + definitions and browser fixtures live beside it under `tests/bdd`. +- `tests/build` verifies generated service-worker and manifest artifacts after each supported build. + +Run the offline suites while developing: + +```bash +npm run test:fast +npm run test:coverage +``` + +Database and BDD tests require Docker and the local Supabase stack. The wrappers read local keys from +`supabase status`; no credentials are written to disk or committed: + +```bash +npm run supabase:start +npm run supabase:reset +npm run test:integration +npm run test:bdd +``` + +`npm test` runs the complete CI-equivalent sequence and fails with setup instructions when Supabase is +not available. Individual layers are available as `test:unit`, `test:data`, `test:integration`, +`test:build`, and `test:bdd`. + +Gherkin describes outcomes in domain language. Keep selectors, API calls, test-user provisioning, and +provider mocks in step definitions or support fixtures. `@product-review` marks a rule that should be +reviewed with product stakeholders, but does not skip it. Missing or ambiguous steps fail generation. + +Google Drive and Dropbox scenarios use a local provider server and private endpoint overrides. They do +not contact real provider accounts. Chromium is the only configured browser project. Playwright traces +and screenshots are retained on failure under `test-results`. + +The current National Dex maximum is deliberately asserted as 1025. When adding a new generation, +update that expectation together with Pokémon data, the corresponding game/dex files, database seed, +and sprites. Data tests print the exact conflicting identities or broken references. + You can preview the production build with `npm run preview`. ## Sprites diff --git a/client-test/offline.spec.ts b/client-test/offline.spec.ts deleted file mode 100644 index 349d3ae..0000000 --- a/client-test/offline.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { test, expect } from '@playwright/test'; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore - -test('Test offline and trailing slashes', async ({ browser }) => { - // test offline + trailing slashes routes - const context = await browser.newContext(); - const offlinePage = await context.newPage(); - await offlinePage.goto('/'); - const offlineSwURL = await offlinePage.evaluate(async () => { - const registration = await Promise.race([ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - navigator.serviceWorker.ready, - new Promise((_, reject) => - setTimeout(() => reject(new Error('Service worker registration failed: time out')), 10000) - ) - ]); - // @ts-expect-error registration is of type unknown - return registration.active?.scriptURL; - }); - const offlineSwName = 'sw.js'; - expect(offlineSwURL).toBe(`http://localhost:4173/${offlineSwName}`); - await context.setOffline(true); - const aboutAnchor = offlinePage.getByRole('link', { name: 'About' }); - expect(await aboutAnchor.getAttribute('href')).toBe('/about'); - await aboutAnchor.click({ noWaitAfter: false }); - const url = await offlinePage.evaluate(async () => { - await new Promise((resolve) => setTimeout(resolve, 3000)); - return location.href; - }); - expect(url).toBe('http://localhost:4173/about'); - expect(offlinePage.locator('li[aria-current="page"] a').getByText('About')).toBeTruthy(); - await offlinePage.reload({ waitUntil: 'load' }); - expect(offlinePage.url()).toBe('http://localhost:4173/about'); - expect(offlinePage.locator('li[aria-current="page"] a').getByText('About')).toBeTruthy(); - // Dispose context once it's no longer needed. - await context.close(); -}); diff --git a/client-test/sw.spec.ts b/client-test/sw.spec.ts deleted file mode 100644 index 8e3f038..0000000 --- a/client-test/sw.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { test, expect } from '@playwright/test'; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore - -test('The service worker is registered and cache storage is present', async ({ page }) => { - await page.goto('/'); - - const swURL = await page.evaluate(async () => { - const registration = await Promise.race([ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - navigator.serviceWorker.ready, - new Promise((_, reject) => - setTimeout(() => reject(new Error('Service worker registration failed: time out')), 10000) - ) - ]); - // @ts-expect-error registration is of type unknown - return registration.active?.scriptURL; - }); - const swName = 'sw.js'; - expect(swURL).toBe(`http://localhost:4173/${swName}`); - - const cacheContents = await page.evaluate(async () => { - const cacheState: Record> = {}; - for (const cacheName of await caches.keys()) { - const cache = await caches.open(cacheName); - cacheState[cacheName] = (await cache.keys()).map((req) => req.url); - } - return cacheState; - }); - - expect(Object.keys(cacheContents).length).toEqual(1); - - const key = 'workbox-precache-v2-http://localhost:4173/'; - - expect(Object.keys(cacheContents)[0]).toEqual(key); - - const urls = cacheContents[key].map((url) => url.slice('http://localhost:4173/'.length)); - - /* - 'http://localhost:4173/about?__WB_REVISION__=38251751d310c9b683a1426c22c135a2', - 'http://localhost:4173/?__WB_REVISION__=073370aa3804305a787b01180cd6b8aa', - 'http://localhost:4173/manifest.webmanifest?__WB_REVISION__=27df2fa4f35d014b42361148a2207da3' - */ - expect(urls.some((url) => url.startsWith('manifest.webmanifest?__WB_REVISION__='))).toEqual(true); - expect(urls.some((url) => url.startsWith('?__WB_REVISION__='))).toEqual(true); - expect(urls.some((url) => url.startsWith('about?__WB_REVISION__='))).toEqual(true); - // dontCacheBustURLsMatching: any asset in _app/immutable folder shouldn't have a revision (?__WB_REVISION__=) - expect(urls.some((url) => url.startsWith('_app/immutable/') && url.endsWith('.css'))).toEqual( - true - ); - expect(urls.some((url) => url.startsWith('_app/immutable/') && url.endsWith('.js'))).toEqual( - true - ); - expect(urls.some((url) => url.includes('_app/version.json?__WB_REVISION__='))).toEqual(true); -}); diff --git a/data/csvs/native-game-dexes/x-y-central-kalos.csv b/data/csvs/native-game-dexes/x-y-central-kalos.csv index a65636a..0f910eb 100644 --- a/data/csvs/native-game-dexes/x-y-central-kalos.csv +++ b/data/csvs/native-game-dexes/x-y-central-kalos.csv @@ -59,7 +59,7 @@ dexNumber,pokemon,form,notes 58,Pyroar,, 59,Psyduck,, 60,Golduck,, -61,Farfetch'd,, +61,Farfetch’d,, 62,Riolu,, 63,Lucario,, 64,Ralts,, diff --git a/package-lock.json b/package-lock.json index 569cb87..ff78751 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "nanoid": "^5.0.4" }, "devDependencies": { - "@playwright/test": "^1.37.1", + "@playwright/test": "1.55.1", "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-netlify": "^4.1.0", "@sveltejs/adapter-node": "^2.0.0", @@ -26,11 +26,13 @@ "@typescript-eslint/parser": "^7.0.0", "@vite-pwa/assets-generator": "^0.2.4", "@vite-pwa/sveltekit": "^0.4.0", + "@vitest/coverage-v8": "^1.6.1", "autoprefixer": "^10.4.19", "daisyui": "^4.10.1", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.35.1", + "playwright-bdd": "^8.5.1", "postcss": "^8.4.38", "prettier": "^3.1.1", "prettier-plugin-svelte": "^3.1.2", @@ -509,7 +511,6 @@ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -520,7 +521,6 @@ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -571,7 +571,6 @@ "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.28.5" }, @@ -1938,7 +1937,6 @@ "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" @@ -1947,12 +1945,175 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, "node_modules/@canvas/image-data": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@canvas/image-data/-/image-data-1.0.0.tgz", "integrity": "sha512-BxOqI5LgsIQP1odU5KMwV9yoijleOPzHL18/YvNqF9KFSGF2K/DLlYAbDQsWqd/1nbaFuSkYD/191dpMtNh4vw==", "dev": true }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cucumber/cucumber-expressions": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/cucumber-expressions/-/cucumber-expressions-18.0.1.tgz", + "integrity": "sha512-NSid6bI+7UlgMywl5octojY5NXnxR9uq+JisjOrO52VbFsQM6gTWuQFE8syI10KnIBEdPzuEUSVEeZ0VFzRnZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-match-indices": "1.0.2" + } + }, + "node_modules/@cucumber/gherkin": { + "version": "32.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-32.2.0.tgz", + "integrity": "sha512-X8xuVhSIqlUjxSRifRJ7t0TycVWyX58fygJH3wDNmHINLg9sYEkvQT0SO2G5YlRZnYc11TIFr4YPenscvdlBIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cucumber/messages": ">=19.1.4 <28" + } + }, + "node_modules/@cucumber/gherkin-utils": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin-utils/-/gherkin-utils-9.2.0.tgz", + "integrity": "sha512-3nmRbG1bUAZP3fAaUBNmqWO0z0OSkykZZotfLjyhc8KWwDSOrOmMJlBTd474lpA8EWh4JFLAX3iXgynBqBvKzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cucumber/gherkin": "^31.0.0", + "@cucumber/messages": "^27.0.0", + "@teppeis/multimaps": "3.0.0", + "commander": "13.1.0", + "source-map-support": "^0.5.21" + }, + "bin": { + "gherkin-utils": "bin/gherkin-utils" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin": { + "version": "31.0.0", + "resolved": "https://registry.npmjs.org/@cucumber/gherkin/-/gherkin-31.0.0.tgz", + "integrity": "sha512-wlZfdPif7JpBWJdqvHk1Mkr21L5vl4EfxVUOS4JinWGf3FLRV6IKUekBv5bb5VX79fkDcfDvESzcQ8WQc07Wgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cucumber/messages": ">=19.1.4 <=26" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/@cucumber/gherkin/node_modules/@cucumber/messages": { + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-26.0.1.tgz", + "integrity": "sha512-DIxSg+ZGariumO+Lq6bn4kOUIUET83A4umrnWmidjGFl8XxkBieUZtsmNbLYgH/gnsmP07EfxxdTr0hOchV1Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/uuid": "10.0.0", + "class-transformer": "0.5.1", + "reflect-metadata": "0.2.2", + "uuid": "10.0.0" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@cucumber/gherkin-utils/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@cucumber/html-formatter": { + "version": "21.15.1", + "resolved": "https://registry.npmjs.org/@cucumber/html-formatter/-/html-formatter-21.15.1.tgz", + "integrity": "sha512-tjxEpP161sQ7xc3VREc94v1ymwIckR3ySViy7lTvfi1jUpyqy2Hd/p4oE3YT1kQ9fFDvUflPwu5ugK5mA7BQLA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@cucumber/messages": ">=18" + } + }, + "node_modules/@cucumber/junit-xml-formatter": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@cucumber/junit-xml-formatter/-/junit-xml-formatter-0.7.1.tgz", + "integrity": "sha512-AzhX+xFE/3zfoYeqkT7DNq68wAQfBcx4Dk9qS/ocXM2v5tBv6eFQ+w8zaSfsktCjYzu4oYRH/jh4USD1CYHfaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cucumber/query": "^13.0.2", + "@teppeis/multimaps": "^3.0.0", + "luxon": "^3.5.0", + "xmlbuilder": "^15.1.1" + }, + "peerDependencies": { + "@cucumber/messages": "*" + } + }, + "node_modules/@cucumber/messages": { + "version": "27.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/messages/-/messages-27.2.0.tgz", + "integrity": "sha512-f2o/HqKHgsqzFLdq6fAhfG1FNOQPdBdyMGpKwhb7hZqg0yZtx9BVqkTyuoNk83Fcvk3wjMVfouFXXHNEk4nddA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/uuid": "10.0.0", + "class-transformer": "0.5.1", + "reflect-metadata": "0.2.2", + "uuid": "11.0.5" + } + }, + "node_modules/@cucumber/query": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/@cucumber/query/-/query-13.6.0.tgz", + "integrity": "sha512-tiDneuD5MoWsJ9VKPBmQok31mSX9Ybl+U4wqDoXeZgsXHDURqzM3rnpWVV3bC34y9W6vuFxrlwF/m7HdOxwqRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@teppeis/multimaps": "3.0.0", + "lodash.sortby": "^4.7.0" + }, + "peerDependencies": { + "@cucumber/messages": "*" + } + }, + "node_modules/@cucumber/tag-expressions": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/@cucumber/tag-expressions/-/tag-expressions-6.2.0.tgz", + "integrity": "sha512-KIF0eLcafHbWOuSDWFw0lMmgJOLdDRWjEL1kfXEWrqHmx2119HxVAr35WuEd9z542d3Yyg+XNqSr+81rIKqEdg==", + "dev": true, + "license": "MIT" + }, "node_modules/@emnapi/runtime": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", @@ -2970,6 +3131,16 @@ "node": ">=18.0.0" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -3089,13 +3260,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", - "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.1.tgz", + "integrity": "sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.57.0" + "playwright": "1.55.1" }, "bin": { "playwright": "cli.js" @@ -3822,6 +3993,16 @@ "vite": "^5.0.0" } }, + "node_modules/@teppeis/multimaps": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@teppeis/multimaps/-/multimaps-3.0.0.tgz", + "integrity": "sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -3894,6 +4075,13 @@ "dev": true, "peer": true }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -4171,6 +4359,34 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz", + "integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.4", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.4", + "istanbul-reports": "^3.1.6", + "magic-string": "^0.30.5", + "magicast": "^0.3.3", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "test-exclude": "^6.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "1.6.1" + } + }, "node_modules/@vitest/expect": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", @@ -4855,8 +5071,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/builtin-modules": { "version": "3.3.0", @@ -5028,6 +5243,51 @@ "dev": true, "license": "ISC" }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-table3/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-table3/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cmd-shim": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz", @@ -6094,16 +6354,17 @@ "license": "MIT" }, "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", - "micromatch": "^4.0.4" + "micromatch": "^4.0.8" }, "engines": { "node": ">=8.6.0" @@ -6704,6 +6965,13 @@ "node": ">= 0.4" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -7223,6 +7491,60 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -7537,8 +7859,7 @@ "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", - "dev": true, - "peer": true + "dev": true }, "node_modules/loupe": { "version": "2.3.7", @@ -7550,6 +7871,16 @@ "get-func-name": "^2.0.1" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.8", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", @@ -7562,6 +7893,34 @@ "node": ">=12" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", @@ -7597,6 +7956,33 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", @@ -8186,13 +8572,13 @@ } }, "node_modules/playwright": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", - "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.1.tgz", + "integrity": "sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.57.0" + "playwright-core": "1.55.1" }, "bin": { "playwright": "cli.js" @@ -8204,10 +8590,53 @@ "fsevents": "2.3.2" } }, + "node_modules/playwright-bdd": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/playwright-bdd/-/playwright-bdd-8.5.1.tgz", + "integrity": "sha512-lDNaDzW8RvbvsKuR8cZaP9LBnRbG9juCOE3tgwm3pr1O0W1ooGPz7X8xH7zdUbqGgHbdOQ+5XpUTlOJrvpY6Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cucumber/cucumber-expressions": "18.0.1", + "@cucumber/gherkin": "^32.1.2", + "@cucumber/gherkin-utils": "^9.2.0", + "@cucumber/html-formatter": "^21.11.0", + "@cucumber/junit-xml-formatter": "^0.7.1", + "@cucumber/messages": "^27.2.0", + "@cucumber/tag-expressions": "^6.2.0", + "cli-table3": "0.6.5", + "commander": "^13.1.0", + "fast-glob": "^3.3.3", + "mime-types": "^3.0.2", + "xmlbuilder": "15.1.1" + }, + "bin": { + "bddgen": "dist/cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/vitalets" + }, + "peerDependencies": { + "@playwright/test": ">=1.44" + } + }, + "node_modules/playwright-bdd/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/playwright-core": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", - "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "version": "1.55.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.1.tgz", + "integrity": "sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==", "dev": true, "license": "Apache-2.0", "bin": { @@ -8715,6 +9144,13 @@ "node": ">=8.10.0" } }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -8745,6 +9181,26 @@ "@babel/runtime": "^7.8.4" } }, + "node_modules/regexp-match-indices": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regexp-match-indices/-/regexp-match-indices-1.0.2.tgz", + "integrity": "sha512-DwZuAkt8NF5mKwGGER1EGh2PRqyvhRhhLviH+R8y8dIuaQROlUfXjt4s9ZTXstIsSkptf06BSvwcEmmfheJJWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "regexp-tree": "^0.1.11" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", @@ -9319,7 +9775,6 @@ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -9330,7 +9785,6 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10141,6 +10595,45 @@ "dev": true, "peer": true }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/text-decoder": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", @@ -10569,6 +11062,20 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "node_modules/uuid": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -11811,6 +12318,16 @@ } } }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/yaml": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", diff --git a/package.json b/package.json index f253696..f067c68 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build-generate-sw": "GENERATE_SW=true vite build", "build-generate-sw-node": "NODE_ADAPTER=true GENERATE_SW=true vite build", "build": "npx tailwindcss -i ./static/input.css -o ./static/output.css && vite build", + "build-inject-manifest": "vite build", "build-inject-manifest-node": "NODE_ADAPTER=true vite build", "build-self-destroying": "SELF_DESTROYING_SW=true vite build", "preview": "vite preview --port=4173", @@ -20,11 +21,21 @@ "lint-fix": "npm run lint --fix", "format": "prettier --write .", "tailwind": "npx tailwindcss -i ./static/input.css -o ./static/output.css", - "test-generate-sw": "npm run build-generate-sw && GENERATE_SW=true vitest run && GENERATE_SW=true playwright test", - "test-generate-sw-node": "npm run build-generate-sw-node && NODE_ADAPTER=true GENERATE_SW=true vitest run && NODE_ADAPTER=true GENERATE_SW=true playwright test", - "test-inject-manifest": "npm run build-inject-manifest && vitest run && playwright test", - "test-inject-manifest-node": "npm run build-inject-manifest-node && NODE_ADAPTER=true vitest run && NODE_ADAPTER=true playwright test", - "test": "npm run test-generate-sw && npm run test-generate-sw-node && npm run test-inject-manifest && npm run test-inject-manifest-node", + "test:unit": "vitest run tests/unit", + "test:data": "vitest run tests/data", + "test:fast": "npm run test:unit && npm run test:data", + "test:coverage": "vitest run tests/unit --coverage", + "test:integration": "node scripts/run-with-local-supabase.mjs vitest run --config vitest.integration.config.mts", + "test:bdd:generate": "bddgen", + "test:bdd:inner": "npm run build-inject-manifest && npm run test:bdd:generate && playwright test --project=chromium", + "test:bdd": "GOOGLE_OAUTH_CLIENT_ID=mock-google GOOGLE_OAUTH_CLIENT_SECRET=mock-google-secret DROPBOX_OAUTH_CLIENT_ID=mock-dropbox DROPBOX_OAUTH_CLIENT_SECRET=mock-dropbox-secret GOOGLE_OAUTH_AUTHORIZE_URL=http://127.0.0.1:4199/google/authorize GOOGLE_OAUTH_TOKEN_URL=http://127.0.0.1:4199/google/token GOOGLE_DRIVE_API_URL=http://127.0.0.1:4199/google/drive GOOGLE_DRIVE_UPLOAD_URL=http://127.0.0.1:4199/google/upload DROPBOX_OAUTH_AUTHORIZE_URL=http://127.0.0.1:4199/dropbox/authorize DROPBOX_OAUTH_TOKEN_URL=http://127.0.0.1:4199/dropbox/token DROPBOX_UPLOAD_URL=http://127.0.0.1:4199/dropbox/files/upload node scripts/run-with-local-supabase.mjs npm run test:bdd:inner", + "test:build:generate-static": "npm run build-generate-sw && GENERATE_SW=true vitest run --config vitest.build.config.mts", + "test:build:generate-node": "npm run build-generate-sw-node && NODE_ADAPTER=true GENERATE_SW=true vitest run --config vitest.build.config.mts", + "test:build:inject-static": "npm run build-inject-manifest && vitest run --config vitest.build.config.mts", + "test:build:inject-node": "npm run build-inject-manifest-node && NODE_ADAPTER=true vitest run --config vitest.build.config.mts", + "test:build": "npm run test:build:generate-static && npm run test:build:generate-node && npm run test:build:inject-static && npm run test:build:inject-node", + "test:ci": "npm run test:fast && npm run test:coverage && npm run test:integration && npm run test:build && npm run test:bdd", + "test": "npm run test:ci", "supabase:start": "supabase start", "supabase:stop": "supabase stop", "supabase:reset": "supabase db reset", @@ -34,7 +45,7 @@ "dev:supabase": "supabase start && npm run dev" }, "devDependencies": { - "@playwright/test": "^1.37.1", + "@playwright/test": "1.55.1", "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-netlify": "^4.1.0", "@sveltejs/adapter-node": "^2.0.0", @@ -47,11 +58,13 @@ "@typescript-eslint/parser": "^7.0.0", "@vite-pwa/assets-generator": "^0.2.4", "@vite-pwa/sveltekit": "^0.4.0", + "@vitest/coverage-v8": "^1.6.1", "autoprefixer": "^10.4.19", "daisyui": "^4.10.1", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.35.1", + "playwright-bdd": "^8.5.1", "postcss": "^8.4.38", "prettier": "^3.1.1", "prettier-plugin-svelte": "^3.1.2", diff --git a/playwright.config.ts b/playwright.config.ts index 8b5ca1c..23499eb 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,10 +1,18 @@ -import { defineConfig, devices } from '@playwright/test' +import { defineConfig, devices } from '@playwright/test'; +import { defineBddConfig } from 'playwright-bdd'; -const url = 'http://localhost:4173' +const url = 'http://localhost:4173'; + +const testDir = defineBddConfig({ + features: 'tests/bdd/features/**/*.feature', + steps: ['tests/bdd/steps/**/*.ts', 'tests/bdd/fixtures.ts'], + outputDir: '.features-gen', + missingSteps: 'fail-on-gen' +}); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore -import { nodeAdapter } from './adapter.mjs' +import { nodeAdapter } from './adapter.mjs'; /** * Read environment variables from file. @@ -16,81 +24,89 @@ import { nodeAdapter } from './adapter.mjs' * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - testDir: './client-test', - /* Folder for test artifacts such as screenshots, videos, traces, etc. */ - outputDir: 'test-results/', - timeout: 5 * 1000, - expect: { - /** - * Maximum time expect() should wait for the condition to be met. - * For example in `await expect(locator).toHaveText();` - */ - timeout: 1000, - }, - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ - forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'line', - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ - use: { - /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ - actionTimeout: 0, - /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: url, - //offline: true, + testDir, + /* Folder for test artifacts such as screenshots, videos, traces, etc. */ + outputDir: 'test-results/', + timeout: 90 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 10 * 1000 + }, + /* Run tests in files in parallel */ + fullyParallel: false, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: 0, + /* Opt out of parallel tests on CI. */ + workers: 1, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [['line'], ['./tests/support/no-skips-reporter.ts']], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: url, + //offline: true, - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', - }, + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'retain-on-failure', + screenshot: 'only-on-failure' + }, - /* Configure projects for major browsers */ - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + } - // { - // name: 'firefox', - // use: { ...devices['Desktop Firefox'] }, - // }, + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, - // { - // name: 'webkit', - // use: { ...devices['Desktop Safari'] }, - // }, + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ..devices['Desktop Chrome'], channel: 'chrome' }, - // }, - ], + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ..devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], - /* Run your local dev server before starting the tests */ - webServer: { - command: nodeAdapter ? 'pnpm run preview-node' : 'pnpm run preview', - url, - reuseExistingServer: !process.env.CI, - }, + /* Run your local dev server before starting the tests */ + webServer: [ + { + command: 'node scripts/mock-provider-server.mjs', + url: 'http://127.0.0.1:4199/__mock/state', + reuseExistingServer: !process.env.CI + }, + { + command: nodeAdapter ? 'npm run preview-node' : 'npm run preview', + url, + reuseExistingServer: !process.env.CI + } + ] }); diff --git a/scripts/mock-provider-server.mjs b/scripts/mock-provider-server.mjs new file mode 100644 index 0000000..b1784c8 --- /dev/null +++ b/scripts/mock-provider-server.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node + +import { createServer } from 'node:http'; + +const port = Number(process.env.MOCK_PROVIDER_PORT ?? 4199); +const state = { requests: [], failUploads: false, refreshes: 0 }; + +function send(response, status, body, headers = {}) { + response.writeHead(status, { 'Content-Type': 'application/json', ...headers }); + response.end(typeof body === 'string' ? body : JSON.stringify(body)); +} + +const server = createServer(async (request, response) => { + const url = new URL(request.url ?? '/', `http://127.0.0.1:${port}`); + let body = ''; + for await (const chunk of request) body += chunk; + state.requests.push({ method: request.method, path: url.pathname, query: url.search, body }); + + if (url.pathname === '/__mock/state') return send(response, 200, state); + if (url.pathname === '/__mock/reset') { + state.requests = []; + state.failUploads = false; + state.refreshes = 0; + return send(response, 200, { ok: true }); + } + if (url.pathname === '/__mock/fail-uploads') { + state.failUploads = true; + return send(response, 200, { ok: true }); + } + + if (url.pathname.endsWith('/authorize')) { + const redirectUri = url.searchParams.get('redirect_uri'); + const oauthState = url.searchParams.get('state'); + if (!redirectUri || !oauthState) return send(response, 400, { error: 'missing redirect data' }); + const callback = new URL(redirectUri); + callback.searchParams.set('code', 'mock-authorization-code'); + callback.searchParams.set('state', oauthState); + response.writeHead(302, { Location: callback.toString() }); + return response.end(); + } + + if (url.pathname.endsWith('/token')) { + if (body.includes('grant_type=refresh_token')) state.refreshes++; + return send(response, 200, { + access_token: 'mock-access-token', + refresh_token: 'mock-refresh-token', + expires_in: 3600, + scope: 'files.content.write drive.file' + }); + } + + if (url.pathname.includes('/upload') || url.pathname.endsWith('/files/upload')) { + if (state.failUploads) return send(response, 503, { error: 'mock upload failure' }); + return send(response, 200, { id: 'mock-file-id', name: 'pokedex.csv' }); + } + + if (url.pathname.endsWith('/drive/files')) { + if (request.method === 'GET') return send(response, 200, { files: [{ id: 'mock-folder-id' }] }); + return send(response, 200, { id: 'mock-folder-id' }); + } + + return send(response, 404, { error: `No mock route for ${url.pathname}` }); +}); + +server.listen(port, '127.0.0.1', () => { + console.log(`Mock provider server listening on http://127.0.0.1:${port}`); +}); + +for (const signal of ['SIGTERM', 'SIGINT']) { + process.on(signal, () => server.close(() => process.exit(0))); +} diff --git a/scripts/run-with-local-supabase.mjs b/scripts/run-with-local-supabase.mjs new file mode 100644 index 0000000..ec95195 --- /dev/null +++ b/scripts/run-with-local-supabase.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; + +const [, , command, ...args] = process.argv; +if (!command) { + console.error('Usage: node scripts/run-with-local-supabase.mjs [...args]'); + process.exit(2); +} + +const status = spawnSync('npx', ['supabase', 'status', '--output', 'json'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] +}); + +if (status.status !== 0) { + console.error('Local Supabase is required but is not running.'); + console.error('Run "npm run supabase:start" followed by "npm run supabase:reset".'); + if (status.stderr.trim()) console.error(status.stderr.trim()); + process.exit(status.status ?? 1); +} + +let values; +try { + values = JSON.parse(status.stdout); +} catch (error) { + console.error('Unable to parse "supabase status --output json".'); + console.error(error); + process.exit(1); +} + +const apiUrl = values.API_URL ?? values.api_url ?? 'http://127.0.0.1:54321'; +const anonKey = values.ANON_KEY ?? values.PUBLISHABLE_KEY ?? values.anon_key; +const serviceRoleKey = values.SERVICE_ROLE_KEY ?? values.SECRET_KEY ?? values.service_role_key; + +if (!anonKey || !serviceRoleKey) { + console.error('Supabase status did not return an anonymous and service-role key.'); + process.exit(1); +} + +const child = spawnSync(command, args, { + stdio: 'inherit', + env: { + ...process.env, + PUBLIC_SUPABASE_URL: process.env.PUBLIC_SUPABASE_URL ?? apiUrl, + PUBLIC_SUPABASE_ANON_KEY: process.env.PUBLIC_SUPABASE_ANON_KEY ?? anonKey, + SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY ?? serviceRoleKey, + TEST_SUPABASE_URL: process.env.TEST_SUPABASE_URL ?? apiUrl, + TEST_SUPABASE_ANON_KEY: process.env.TEST_SUPABASE_ANON_KEY ?? anonKey, + E2E_SERVICE_ROLE_KEY: process.env.E2E_SERVICE_ROLE_KEY ?? serviceRoleKey + } +}); + +process.exit(child.status ?? 1); diff --git a/src/lib/services/PokedexExportFormatting.ts b/src/lib/services/PokedexExportFormatting.ts new file mode 100644 index 0000000..3933c4f --- /dev/null +++ b/src/lib/services/PokedexExportFormatting.ts @@ -0,0 +1,65 @@ +import type { CombinedData } from '$lib/models/CombinedData'; +import type { Pokedex } from '$lib/models/Pokedex'; + +export function csvEscape(value: unknown): string { + if (value === null || value === undefined) return ''; + const str = String(value); + if (/[",\n\r]/.test(str)) return `"${str.replace(/"/g, '""')}"`; + return str; +} + +export function sanitizeFileName(name: string, fallback: string): string { + const trimmed = name.trim(); + const safe = trimmed.replace(/[\\/:*?"<>|]+/g, '-'); + if (!safe) return fallback; + return safe.endsWith('.csv') ? safe : `${safe}.csv`; +} + +export function buildCsv(pokedex: Pokedex, combinedData: CombinedData[]): string { + void pokedex; + const headers = [ + 'pokemonId', + 'pokedexNumber', + 'pokemon', + 'form', + 'caught', + 'haveToEvolve', + 'inHome', + 'personalNotes' + ]; + const lines = [headers.map(csvEscape).join(',')]; + + for (const row of combinedData) { + const entry = row.pokedexEntry; + const catchRecord = row.catchRecord ?? { + caught: false, + haveToEvolve: false, + inHome: false, + hasGigantamaxed: false, + personalNotes: '' + }; + lines.push( + [ + entry._id, + entry.pokedexNumber, + entry.pokemon, + entry.form || '', + catchRecord.caught, + catchRecord.haveToEvolve, + catchRecord.inHome, + catchRecord.personalNotes || '' + ] + .map(csvEscape) + .join(',') + ); + } + + return lines.join('\r\n'); +} + +export function shouldRefreshToken(expiresAt: string | null): boolean { + if (!expiresAt) return false; + const expiry = new Date(expiresAt).getTime(); + if (!Number.isFinite(expiry)) return false; + return expiry - Date.now() < 60_000; +} diff --git a/src/lib/services/PokedexExportService.ts b/src/lib/services/PokedexExportService.ts index 85b0678..6d9dcd2 100644 --- a/src/lib/services/PokedexExportService.ts +++ b/src/lib/services/PokedexExportService.ts @@ -1,13 +1,21 @@ import { randomUUID } from 'node:crypto'; import type { SupabaseClient } from '@supabase/supabase-js'; -import type { CombinedData } from '$lib/models/CombinedData'; import type { Pokedex } from '$lib/models/Pokedex'; -import type { ExportProvider, PokedexExportIntegration } from '$lib/models/PokedexExportIntegration'; +import type { + ExportProvider, + PokedexExportIntegration +} from '$lib/models/PokedexExportIntegration'; import PokedexRepository from '$lib/repositories/PokedexRepository'; import CombinedDataRepository from '$lib/repositories/CombinedDataRepository'; import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository'; import { resolveDexScopes } from '$lib/services/PokedexDexScopeService'; import { getEnv } from '$lib/utils/env'; +import { getProviderEndpoints } from '$lib/services/providerEndpoints'; +import { + buildCsv, + sanitizeFileName, + shouldRefreshToken +} from '$lib/services/PokedexExportFormatting'; type ExportFailure = { integrationId: string; @@ -21,71 +29,6 @@ export type PokedexExportResult = { failed: ExportFailure[]; }; -function csvEscape(value: unknown): string { - if (value === null || value === undefined) return ''; - const str = String(value); - if (/[",\n\r]/.test(str)) { - return `"${str.replace(/"/g, '""')}"`; - } - return str; -} - -function sanitizeFileName(name: string, fallback: string): string { - const trimmed = name.trim(); - const safe = trimmed.replace(/[\\/:*?"<>|]+/g, '-'); - if (!safe) return fallback; - return safe.endsWith('.csv') ? safe : `${safe}.csv`; -} - -function buildCsv(pokedex: Pokedex, combinedData: CombinedData[]): string { - const headers = [ - 'pokemonId', - 'pokedexNumber', - 'pokemon', - 'form', - 'caught', - 'haveToEvolve', - 'inHome', - 'personalNotes' - ]; - - const lines = [headers.map(csvEscape).join(',')]; - - for (const row of combinedData) { - const entry = row.pokedexEntry; - const catchRecord = row.catchRecord ?? { - caught: false, - haveToEvolve: false, - inHome: false, - hasGigantamaxed: false, - personalNotes: '' - }; - - const values = [ - entry._id, - entry.pokedexNumber, - entry.pokemon, - entry.form || '', - catchRecord.caught, - catchRecord.haveToEvolve, - catchRecord.inHome, - catchRecord.personalNotes || '' - ]; - - lines.push(values.map(csvEscape).join(',')); - } - - return lines.join('\r\n'); -} - -function shouldRefreshToken(expiresAt: string | null): boolean { - if (!expiresAt) return false; - const expiry = new Date(expiresAt).getTime(); - if (!Number.isFinite(expiry)) return false; - // Refresh if within 60 seconds of expiry. - return expiry - Date.now() < 60_000; -} - async function refreshGoogleToken( integration: PokedexExportIntegration, repo: PokedexExportIntegrationRepository @@ -108,7 +51,7 @@ async function refreshGoogleToken( grant_type: 'refresh_token' }); - const response = await fetch('https://oauth2.googleapis.com/token', { + const response = await fetch(getProviderEndpoints().google.token, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() @@ -162,7 +105,7 @@ async function refreshDropboxToken( grant_type: 'refresh_token' }); - const response = await fetch('https://api.dropbox.com/oauth2/token', { + const response = await fetch(getProviderEndpoints().dropbox.token, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() @@ -217,7 +160,10 @@ type GoogleDriveMetadata = { files?: Record; }; -function getGoogleFileId(metadata: Record | null, pokedexId: string): string | null { +function getGoogleFileId( + metadata: Record | null, + pokedexId: string +): string | null { const data = metadata as GoogleDriveMetadata | null; const fileId = data?.files?.[pokedexId]; return typeof fileId === 'string' && fileId ? fileId : null; @@ -262,7 +208,7 @@ async function uploadToGoogleDrive( if (!folderId) { try { const folderResponse = await fetch( - 'https://www.googleapis.com/drive/v3/files?' + + `${getProviderEndpoints().google.driveApi}/files?` + new URLSearchParams({ q: "name='Living Dex Tracker' and mimeType='application/vnd.google-apps.folder' and trashed=false", fields: 'files(id,name)', @@ -286,7 +232,7 @@ async function uploadToGoogleDrive( if (!folderId) { try { - const createResponse = await fetch('https://www.googleapis.com/drive/v3/files', { + const createResponse = await fetch(`${getProviderEndpoints().google.driveApi}/files`, { method: 'POST', headers: { Authorization: `Bearer ${refreshed.accessToken}`, @@ -332,12 +278,11 @@ async function uploadToGoogleDrive( ].join('\r\n'); const url = currentFileId - ? `https://www.googleapis.com/upload/drive/v3/files/${currentFileId}?uploadType=multipart` - : 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart'; + ? `${getProviderEndpoints().google.driveUpload}/files/${currentFileId}?uploadType=multipart` + : `${getProviderEndpoints().google.driveUpload}/files?uploadType=multipart`; const method = currentFileId ? 'PATCH' : 'POST'; - const uploadUrl = currentFileId && folderId - ? `${url}&addParents=${encodeURIComponent(folderId)}` - : url; + const uploadUrl = + currentFileId && folderId ? `${url}&addParents=${encodeURIComponent(folderId)}` : url; const response = await fetch(uploadUrl, { method, @@ -392,7 +337,7 @@ async function uploadToDropbox( targetPath = `${targetPath}/${fileName}`; } - const response = await fetch('https://content.dropboxapi.com/2/files/upload', { + const response = await fetch(getProviderEndpoints().dropbox.upload, { method: 'POST', headers: { Authorization: `Bearer ${refreshed.accessToken}`, diff --git a/src/lib/services/providerEndpoints.ts b/src/lib/services/providerEndpoints.ts new file mode 100644 index 0000000..28211d5 --- /dev/null +++ b/src/lib/services/providerEndpoints.ts @@ -0,0 +1,18 @@ +import { getEnv } from '$lib/utils/env'; + +export function getProviderEndpoints() { + const env = getEnv(); + return { + google: { + authorize: env.GOOGLE_OAUTH_AUTHORIZE_URL || 'https://accounts.google.com/o/oauth2/v2/auth', + token: env.GOOGLE_OAUTH_TOKEN_URL || 'https://oauth2.googleapis.com/token', + driveApi: env.GOOGLE_DRIVE_API_URL || 'https://www.googleapis.com/drive/v3', + driveUpload: env.GOOGLE_DRIVE_UPLOAD_URL || 'https://www.googleapis.com/upload/drive/v3' + }, + dropbox: { + authorize: env.DROPBOX_OAUTH_AUTHORIZE_URL || 'https://www.dropbox.com/oauth2/authorize', + token: env.DROPBOX_OAUTH_TOKEN_URL || 'https://api.dropbox.com/oauth2/token', + upload: env.DROPBOX_UPLOAD_URL || 'https://content.dropboxapi.com/2/files/upload' + } + }; +} diff --git a/src/routes/api/integrations/dropbox/callback/+server.ts b/src/routes/api/integrations/dropbox/callback/+server.ts index 0b46fc9..42e8d45 100644 --- a/src/routes/api/integrations/dropbox/callback/+server.ts +++ b/src/routes/api/integrations/dropbox/callback/+server.ts @@ -4,6 +4,7 @@ import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportI import { requireAuth } from '$lib/utils/auth'; import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState'; import { getEnv } from '$lib/utils/env'; +import { getProviderEndpoints } from '$lib/services/providerEndpoints'; export const GET = async (event: RequestEvent) => { try { @@ -56,7 +57,7 @@ export const GET = async (event: RequestEvent) => { grant_type: 'authorization_code' }); - const tokenResponse = await fetch('https://api.dropbox.com/oauth2/token', { + const tokenResponse = await fetch(getProviderEndpoints().dropbox.token, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: tokenParams.toString() diff --git a/src/routes/api/integrations/dropbox/connect/+server.ts b/src/routes/api/integrations/dropbox/connect/+server.ts index 2b0f7e8..a5e0376 100644 --- a/src/routes/api/integrations/dropbox/connect/+server.ts +++ b/src/routes/api/integrations/dropbox/connect/+server.ts @@ -4,6 +4,7 @@ import PokedexRepository from '$lib/repositories/PokedexRepository'; import { requireAuth } from '$lib/utils/auth'; import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState'; import { getEnv } from '$lib/utils/env'; +import { getProviderEndpoints } from '$lib/services/providerEndpoints'; export const GET = async (event: RequestEvent) => { const userId = await requireAuth(event); @@ -60,5 +61,5 @@ export const GET = async (event: RequestEvent) => { scope: 'files.content.write' }); - throw redirect(302, `https://www.dropbox.com/oauth2/authorize?${params.toString()}`); + throw redirect(302, `${getProviderEndpoints().dropbox.authorize}?${params.toString()}`); }; diff --git a/src/routes/api/integrations/google-drive/callback/+server.ts b/src/routes/api/integrations/google-drive/callback/+server.ts index d22dba0..0c4d426 100644 --- a/src/routes/api/integrations/google-drive/callback/+server.ts +++ b/src/routes/api/integrations/google-drive/callback/+server.ts @@ -4,6 +4,7 @@ import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportI import { requireAuth } from '$lib/utils/auth'; import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState'; import { getEnv } from '$lib/utils/env'; +import { getProviderEndpoints } from '$lib/services/providerEndpoints'; export const GET = async (event: RequestEvent) => { try { @@ -57,7 +58,7 @@ export const GET = async (event: RequestEvent) => { grant_type: 'authorization_code' }); - const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { + const tokenResponse = await fetch(getProviderEndpoints().google.token, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: tokenParams.toString() diff --git a/src/routes/api/integrations/google-drive/connect/+server.ts b/src/routes/api/integrations/google-drive/connect/+server.ts index c9e79f6..973f3a5 100644 --- a/src/routes/api/integrations/google-drive/connect/+server.ts +++ b/src/routes/api/integrations/google-drive/connect/+server.ts @@ -4,6 +4,7 @@ import PokedexRepository from '$lib/repositories/PokedexRepository'; import { requireAuth } from '$lib/utils/auth'; import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState'; import { getEnv } from '$lib/utils/env'; +import { getProviderEndpoints } from '$lib/services/providerEndpoints'; export const GET = async (event: RequestEvent) => { const userId = await requireAuth(event); @@ -65,5 +66,5 @@ export const GET = async (event: RequestEvent) => { state }); - throw redirect(302, `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`); + throw redirect(302, `${getProviderEndpoints().google.authorize}?${params.toString()}`); }; diff --git a/test/build.test.ts b/test/build.test.ts deleted file mode 100644 index 89a68cb..0000000 --- a/test/build.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { existsSync, readFileSync } from 'node:fs' -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import { generateSW } from '../pwa.mjs' -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import { nodeAdapter } from '../adapter.mjs' - -describe(`test-build: ${nodeAdapter ? 'node' : 'static'} adapter`, () => { - it(`service worker is generated: ${generateSW ? 'sw.js' : 'prompt-sw.js'}`, () => { - const swName = `./build/${nodeAdapter ? 'client/': ''}${generateSW ? 'sw.js' : 'prompt-sw.js'}` - expect(existsSync(swName), `${swName} doesn't exist`).toBeTruthy() - const webManifest = `./build/${nodeAdapter ? 'client/': ''}manifest.webmanifest` - expect(existsSync(webManifest), `${webManifest} doesn't exist`).toBeTruthy() - const swContent = readFileSync(swName, 'utf-8') - let match: RegExpMatchArray | null - if (generateSW) { - match = swContent.match(/define\(\['\.\/(workbox-\w+)'/) - expect(match && match.length === 2, `workbox-***.js entry not found in ${swName}`).toBeTruthy() - const workboxName = `./build/${nodeAdapter ? 'client/': ''}${match?.[1]}.js` - expect(existsSync(workboxName),`${workboxName} doesn't exist`).toBeTruthy() - } - match = swContent.match(/"url":\s*"manifest\.webmanifest"/) - expect(match && match.length === 1, 'missing manifest.webmanifest in sw precache manifest').toBeTruthy() - match = swContent.match(/"url":\s*"\/"/) - expect(match && match.length === 1, 'missing entry point route (/) in sw precache manifest').toBeTruthy() - match = swContent.match(/"url":\s*"about"/) - expect(match && match.length === 1,'missing about route (/about) in sw precache manifest').toBeTruthy() - if (nodeAdapter) { - match = swContent.match(/"url":\s*"server\//) - expect(match === null, 'found server/ entries in sw precache manifest').toBeTruthy() - } - }) -}) diff --git a/test/catchRecordWriteQueue.test.ts b/test/catchRecordWriteQueue.test.ts deleted file mode 100644 index 74aa0ad..0000000 --- a/test/catchRecordWriteQueue.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { createCatchRecordWriteQueue } from '../src/lib/utils/catchRecordWriteQueue'; -import type { CatchRecord } from '../src/lib/models/CatchRecord'; - -function mkRecord(overrides: Partial = {}): CatchRecord { - return { - _id: '', - userId: 'u1', - pokedexId: 'p1', - pokemonId: '25', - haveToEvolve: false, - caught: false, - inHome: false, - hasGigantamaxed: false, - personalNotes: '', - ...overrides - }; -} - -describe('createCatchRecordWriteQueue()', () => { - beforeEach(() => { - vi.useFakeTimers(); - vi.restoreAllMocks(); - }); - - it('coalesces multiple updates for the same key and flushes only the latest state', async () => { - const fetchFn = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { - return new Response(init?.body as string, { status: 200 }); - }); - - const queue = createCatchRecordWriteQueue({ - endpointUrl: '/api/pokedexes/p1/catch-records', - fetchFn, - batchSize: 50, - concurrency: 1 - }); - - queue.enqueue(mkRecord({ caught: true })); - queue.enqueue(mkRecord({ caught: false, haveToEvolve: true })); - - await queue.flushNow(); - - expect(fetchFn).toHaveBeenCalledTimes(1); - const body = JSON.parse(String(fetchFn.mock.calls[0]?.[1]?.body)); - expect(body).toHaveLength(1); - expect(body[0].haveToEvolve).toBe(true); - expect(body[0].caught).toBe(false); - }); - - it('debounces notes updates before flushing', async () => { - const fetchFn = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { - return new Response(init?.body as string, { status: 200 }); - }); - - const queue = createCatchRecordWriteQueue({ - endpointUrl: '/api/pokedexes/p1/catch-records', - fetchFn, - batchSize: 50, - concurrency: 1 - }); - - queue.enqueue(mkRecord({ personalNotes: 'a' }), { debounceMs: 500 }); - await queue.flushNow(); - expect(fetchFn).toHaveBeenCalledTimes(0); - - vi.advanceTimersByTime(499); - await queue.flushNow(); - expect(fetchFn).toHaveBeenCalledTimes(0); - - vi.advanceTimersByTime(1); - await queue.flushNow(); - expect(fetchFn).toHaveBeenCalledTimes(1); - }); -}); diff --git a/tests/bdd/features/account-access.feature b/tests/bdd/features/account-access.feature new file mode 100644 index 0000000..1b84abd --- /dev/null +++ b/tests/bdd/features/account-access.feature @@ -0,0 +1,47 @@ +Feature: Account access + As a trainer + I want secure access to my account + So that only I can update my collection + + Scenario: Register a new account + Given I am a new visitor + When I register with valid account details + Then I am told to confirm my email + And a confirmation email is captured locally + + Scenario: Sign in with valid credentials + Given I have a confirmed account + When I sign in with my credentials + Then I arrive at my Pokédex list + + Scenario: Reject invalid credentials + Given I have a confirmed account + When I sign in with an incorrect password + Then I see a sign-in error + + Scenario: Redirect an authenticated visitor + Given I am signed in + When I visit the public home page + Then I arrive at my Pokédex list + + Scenario: Sign out + Given I am signed in + When I sign out + Then I return to the public home page + + Scenario: Request a password reset + Given I have a confirmed account + When I request a password reset + Then a password reset email is captured locally + + @product-review + Scenario: Reject mismatched replacement passwords + Given I am on the password reset page with a recovery session + When I enter two different replacement passwords + Then I am told that the passwords do not match + + Scenario: Complete a password reset + Given I am on the password reset page with a recovery session + When I enter a valid replacement password + Then I am told that my password was updated + diff --git a/tests/bdd/features/backup-export.feature b/tests/bdd/features/backup-export.feature new file mode 100644 index 0000000..a4488d3 --- /dev/null +++ b/tests/bdd/features/backup-export.feature @@ -0,0 +1,43 @@ +Feature: Backup and export + As a trainer + I want changes exported to my connected storage + So that I retain a portable backup + + Background: + Given I am signed in + + Scenario: Show disconnected backup providers + When I visit backup settings + Then Google Drive and Dropbox are shown as not connected + + Scenario Outline: Connect a backup provider + When I connect the mocked "" provider + Then "" is shown as connected + + Examples: + | provider | + | Google Drive | + | Dropbox | + + Scenario: Reject an invalid OAuth state + When a mocked OAuth callback has an invalid state + Then the backup connection is rejected + + Scenario: Export escaped catch data without losing the update + Given Google Drive is connected to the mocked provider + And I have a Living Dex named "Quoted, Dex" + When I save a catch note containing a comma and quote + Then the mocked provider receives a valid escaped CSV + And the catch update remains saved + + Scenario: Refresh an expired provider token + Given Dropbox is connected with an expired token + When an export is requested + Then the token is refreshed before the mocked upload + + Scenario: Record provider failure without losing progress + Given Google Drive is connected to a failing mocked provider + When I update collection progress + Then the catch update remains saved + And the provider failure is shown in backup settings + diff --git a/tests/bdd/features/dex-composition.feature b/tests/bdd/features/dex-composition.feature new file mode 100644 index 0000000..8ce780b --- /dev/null +++ b/tests/bdd/features/dex-composition.feature @@ -0,0 +1,32 @@ +Feature: Pokédex composition + As a trainer + I want the correct Pokémon in each configured dex + So that completion totals are trustworthy + + Background: + Given I am signed in + + Scenario: Build a national Living Dex from canonical forms + Given I have a Living Dex named "National" + When I inspect its entries without forms + Then it contains 1025 unique species + And named default forms are represented once + + Scenario: Include all supported forms + Given I have a Form Dex named "Forms" + When I inspect its entries with forms + Then every entry identity is unique + And Basculin has 3 forms + And Alcremie has 63 forms + And Unown has 28 forms + + Scenario: Render shiny artwork + Given I have a Shiny Dex named "Shinies" + When I view the Pokédex + Then its Pokémon use shiny sprites + + Scenario: Respect game and dex scope + Given I have a Form Dex named "Black Forms" scoped to game "Black" and dex "Unova" + When I inspect its entries with forms + Then Rotom includes its named default form without duplicate forms + diff --git a/tests/bdd/features/pokedex-lifecycle.feature b/tests/bdd/features/pokedex-lifecycle.feature new file mode 100644 index 0000000..6620c0a --- /dev/null +++ b/tests/bdd/features/pokedex-lifecycle.feature @@ -0,0 +1,57 @@ +Feature: Pokédex lifecycle + As a trainer + I want to configure and manage Pokédexes + So that each collection matches my goal + + Background: + Given I am signed in + + Scenario: See the empty state + Given I have no Pokédexes + When I visit my Pokédex list + Then I see the empty Pokédex message + + Scenario: Validate a new Pokédex + When I open the new Pokédex form + Then I cannot create a Pokédex without a name and type + + Scenario Outline: Create each supported Pokédex type + When I create a Pokédex named "" of type "" + Then the Pokédex "" is available to view + + Examples: + | name | type | + | Living | Living Dex | + | Shiny | Shiny Dex | + | Origin | Origin Dex | + | Every Form | Form Dex | + + Scenario: Create a game and dex scoped Pokédex + When I create a Living Dex named "Black Regional" scoped to game "Black" and dex "Unova" + Then the Pokédex "Black Regional" is available to view + + Scenario: Reject a duplicate name + Given I have a Living Dex named "My Collection" + When I try to create another Living Dex named "My Collection" + Then I am told that the Pokédex name is already used + + Scenario: Edit a Pokédex + Given I have a Living Dex named "Before Editing" + When I rename it to "After Editing" and enable forms + Then the Pokédex "After Editing" is available to view + + Scenario: Cancel deleting a Pokédex + Given I have a Living Dex named "Keep Me" + When I cancel deleting "Keep Me" + Then the Pokédex "Keep Me" is available to view + + Scenario: Delete a Pokédex + Given I have a Living Dex named "Delete Me" + When I confirm deleting "Delete Me" + Then the Pokédex "Delete Me" is no longer listed + + Scenario: Keep another user's Pokédex private + Given another trainer has a Pokédex + When I request the other trainer's Pokédex + Then the Pokédex is not disclosed + diff --git a/tests/bdd/features/progress-tracking.feature b/tests/bdd/features/progress-tracking.feature new file mode 100644 index 0000000..294c263 --- /dev/null +++ b/tests/bdd/features/progress-tracking.feature @@ -0,0 +1,38 @@ +Feature: Progress tracking + As a trainer + I want to record collection state + So that my Pokédex shows what remains + + Background: + Given I am signed in + And I have a Living Dex named "Progress" + And I view the Pokédex + + Scenario: Mark a Pokémon caught + When I mark the first Pokémon as caught + Then the first Pokémon is shown as caught after reloading + + Scenario: Keep caught and needs-to-evolve mutually exclusive + When I mark the first Pokémon as caught + And I mark the first Pokémon as needing evolution + Then the first Pokémon needs evolution and is not marked caught + + Scenario: Record HOME state and notes + When I mark the first Pokémon as in HOME + And I add the note "Caught, traded, and checked" to the first Pokémon + Then its HOME state and note persist after reloading + + Scenario: Update an entire box + When I mark box 1 as caught + Then box 1 contains 30 caught Pokémon + + Scenario: Filter collection progress + When I mark the first Pokémon as caught + And I filter to Pokémon that are not caught + Then the caught Pokémon is filtered out + + Scenario: Remember box layout density + When I select the "Compact" box layout + And I reload the Pokédex + Then the "Compact" box layout remains selected + diff --git a/tests/bdd/features/pwa-offline.feature b/tests/bdd/features/pwa-offline.feature new file mode 100644 index 0000000..4ffe63a --- /dev/null +++ b/tests/bdd/features/pwa-offline.feature @@ -0,0 +1,19 @@ +Feature: Offline-friendly application + As a trainer + I want the installed site to survive network loss + So that I can consult my collection anywhere + + Scenario: Register the service worker + When I open the built application + Then a service worker controls the page + And the application cache is present + + Scenario: Navigate while offline + Given I have opened the built application online + When I go offline and revisit the home page with a trailing slash + Then the application remains available + + Scenario: Restore network access + Given I have opened the built application online + When I go offline and then return online + Then the application remains available diff --git a/tests/bdd/fixtures.ts b/tests/bdd/fixtures.ts new file mode 100644 index 0000000..06c25ea --- /dev/null +++ b/tests/bdd/fixtures.ts @@ -0,0 +1,39 @@ +import { test as base } from 'playwright-bdd'; + +export type ScenarioState = { + email: string; + password: string; + replacementPassword: string; + userId: string | null; + pokedexId: string | null; + pokedexName: string | null; + entries: Array<{ pokemon: string; form: string | null; num: number; id: string }>; + lastResponseStatus: number | null; + lastMessage: string | null; +}; + +type Fixtures = { state: ScenarioState }; + +export const test = base.extend({ + // Playwright fixture callbacks require the dependency object even when this fixture has none. + // eslint-disable-next-line no-empty-pattern + state: async ({}, use, testInfo) => { + const slug = testInfo.testId + .replace(/[^a-z0-9]/gi, '') + .slice(-18) + .toLowerCase(); + await use({ + email: `bdd-${slug}-${Date.now()}@example.test`, + password: 'BddPassword123!', + replacementPassword: 'BddReplacement456!', + userId: null, + pokedexId: null, + pokedexName: null, + entries: [], + lastResponseStatus: null, + lastMessage: null + }); + } +}); + +export { expect } from '@playwright/test'; diff --git a/tests/bdd/steps/account.steps.ts b/tests/bdd/steps/account.steps.ts new file mode 100644 index 0000000..a03d8bd --- /dev/null +++ b/tests/bdd/steps/account.steps.ts @@ -0,0 +1,111 @@ +import { createBdd } from 'playwright-bdd'; +import { test, expect } from '../fixtures'; +import { createConfirmedUser, signIn } from '../support/app'; + +const { Given, When, Then } = createBdd(test); +const MAILPIT_URL = process.env.TEST_MAILPIT_URL ?? 'http://127.0.0.1:54324'; + +async function mailCountFor(email: string, subject: string): Promise { + const response = await fetch( + `${MAILPIT_URL}/api/v1/search?query=${encodeURIComponent(`to:${email} subject:${subject}`)}` + ); + if (!response.ok) throw new Error(`MailPit is unavailable: ${response.status}`); + const body = (await response.json()) as { total?: number; messages?: unknown[] }; + return body.total ?? body.messages?.length ?? 0; +} + +Given('I am a new visitor', async ({ page }) => { + await page.goto('/'); +}); + +Given('I have a confirmed account', async ({ state }) => { + await createConfirmedUser(state); +}); + +Given('I am signed in', async ({ page, state }) => { + await createConfirmedUser(state); + await signIn(page, state); + await expect(page).toHaveURL(/\/my-pokedexes$/); +}); + +Given('I am on the password reset page with a recovery session', async ({ page, state }) => { + await createConfirmedUser(state); + await signIn(page, state); + await expect(page).toHaveURL(/\/my-pokedexes$/); + await page.goto('/reset-password'); +}); + +When('I register with valid account details', async ({ page, state }) => { + await page.getByLabel('Email').fill(state.email); + await page.getByLabel('Password').fill(state.password); + await page.getByRole('button', { name: 'Sign Up', exact: true }).first().click(); +}); + +When('I sign in with my credentials', async ({ page, state }) => { + await signIn(page, state); +}); + +When('I sign in with an incorrect password', async ({ page, state }) => { + await signIn(page, state, `${state.password}-incorrect`); +}); + +When('I visit the public home page', async ({ page }) => { + await page.goto('/'); +}); + +When('I sign out', async ({ page }) => { + await page.getByRole('button', { name: 'usericon' }).click(); + await page.getByRole('button', { name: 'Sign Out', exact: true }).click(); +}); + +When('I request a password reset', async ({ page, state }) => { + await page.goto('/forgot-password'); + await page.getByLabel('Email').fill(state.email); + await page.getByRole('button', { name: 'Send Reset Link' }).click(); +}); + +When('I enter two different replacement passwords', async ({ page, state }) => { + await page.getByLabel('New Password').fill(state.replacementPassword); + await page.getByLabel('Confirm Password').fill(`${state.replacementPassword}-different`); + await page.getByRole('button', { name: 'Update Password' }).click(); +}); + +When('I enter a valid replacement password', async ({ page, state }) => { + await page.getByLabel('New Password').fill(state.replacementPassword); + await page.getByLabel('Confirm Password').fill(state.replacementPassword); + await page.getByRole('button', { name: 'Update Password' }).click(); +}); + +Then('I am told to confirm my email', async ({ page }) => { + await expect(page).toHaveURL(/\/welcome$/); + await expect(page.getByText('Check Your Email', { exact: true })).toBeVisible(); +}); + +Then('a confirmation email is captured locally', async ({ state }) => { + await expect.poll(() => mailCountFor(state.email, 'Confirm')).toBeGreaterThan(0); +}); + +Then('I arrive at my Pokédex list', async ({ page }) => { + await expect(page).toHaveURL(/\/my-pokedexes$/); +}); + +Then('I see a sign-in error', async ({ page }) => { + await expect(page.locator('.alert-error')).toBeVisible(); +}); + +Then('I return to the public home page', async ({ page }) => { + await expect(page).toHaveURL(/\/$/); +}); + +Then('a password reset email is captured locally', async ({ page, state }) => { + await expect(page.getByText('Check your email for the password reset link')).toBeVisible(); + await expect.poll(() => mailCountFor(state.email, 'Reset')).toBeGreaterThan(0); +}); + +Then('I am told that the passwords do not match', async ({ page }) => { + await expect(page.getByText('Passwords do not match')).toBeVisible(); +}); + +Then('I am told that my password was updated', async ({ page }) => { + await expect(page.getByText(/Password updated successfully/)).toBeVisible(); +}); diff --git a/tests/bdd/steps/backup.steps.ts b/tests/bdd/steps/backup.steps.ts new file mode 100644 index 0000000..fd399ab --- /dev/null +++ b/tests/bdd/steps/backup.steps.ts @@ -0,0 +1,156 @@ +import { createBdd } from 'playwright-bdd'; +import { test, expect } from '../fixtures'; +import { createDexThroughUi, firstPokemon, openFirstPokemon } from '../support/app'; + +const { Given, When, Then } = createBdd(test); +const MOCK_URL = process.env.MOCK_PROVIDER_URL ?? 'http://127.0.0.1:4199'; +const SUPABASE_URL = process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321'; + +type Provider = 'google_drive' | 'dropbox'; + +async function seedIntegration( + state: import('../fixtures').ScenarioState, + provider: Provider, + overrides: Record = {} +) { + const key = process.env.E2E_SERVICE_ROLE_KEY; + if (!key || !state.userId) + throw new Error('A confirmed user and E2E_SERVICE_ROLE_KEY are required'); + const response = await fetch(`${SUPABASE_URL}/rest/v1/pokedex_export_integrations`, { + method: 'POST', + headers: { + apikey: key, + Authorization: `Bearer ${key}`, + 'Content-Type': 'application/json', + Prefer: 'resolution=merge-duplicates,return=representation' + }, + body: JSON.stringify({ + userId: state.userId, + pokedexId: null, + provider, + enabled: true, + accessToken: 'mock-access-token', + refreshToken: 'mock-refresh-token', + accessTokenExpiresAt: new Date(Date.now() + 3_600_000).toISOString(), + ...overrides + }) + }); + if (!response.ok) throw new Error(await response.text()); +} + +async function ensureExportDex( + page: import('@playwright/test').Page, + state: import('../fixtures').ScenarioState +) { + if (!state.pokedexId) { + await createDexThroughUi(page, state, { + name: state.pokedexName ?? `Export ${Date.now()}`, + type: 'Living Dex' + }); + } +} + +Given('Google Drive is connected to the mocked provider', async ({ state }) => { + await seedIntegration(state, 'google_drive'); +}); + +Given('Dropbox is connected with an expired token', async ({ page, state }) => { + await seedIntegration(state, 'dropbox', { + accessTokenExpiresAt: new Date(Date.now() - 60_000).toISOString() + }); + await ensureExportDex(page, state); +}); + +Given('Google Drive is connected to a failing mocked provider', async ({ page, state }) => { + await seedIntegration(state, 'google_drive'); + await fetch(`${MOCK_URL}/__mock/fail-uploads`); + await ensureExportDex(page, state); +}); + +When('I visit backup settings', async ({ page }) => { + await page.goto('/backup-settings'); +}); + +When('I connect the mocked {string} provider', async ({ page }, provider: string) => { + await fetch(`${MOCK_URL}/__mock/reset`); + await page.goto('/backup-settings'); + const card = page.locator('.card, .border').filter({ hasText: provider }).last(); + await card.getByRole('button', { name: 'Connect' }).click(); + await page.waitForURL(/\/backup-settings\?export=.*-connected/); +}); + +When('a mocked OAuth callback has an invalid state', async ({ page, state }) => { + const response = await page.request.get( + '/api/integrations/google-drive/callback?code=mock&state=invalid-state' + ); + state.lastResponseStatus = response.status(); +}); + +When('I save a catch note containing a comma and quote', async ({ page, state }) => { + await ensureExportDex(page, state); + await page.goto(`/pokedex/${state.pokedexId}`); + await openFirstPokemon(page); + await page.getByRole('dialog').getByLabel('Notes:').fill('A comma, and a "quote"'); + await page.getByRole('dialog').getByLabel('Notes:').blur(); + await expect(page.getByText(/Saving…/)).toHaveCount(0, { timeout: 15_000 }); +}); + +When('an export is requested', async ({ page, state }) => { + const response = await page.request.post(`/api/pokedexes/${state.pokedexId}/export`); + state.lastResponseStatus = response.status(); +}); + +When('I update collection progress', async ({ page, state }) => { + await page.goto(`/pokedex/${state.pokedexId}`); + await openFirstPokemon(page); + await page + .getByRole('dialog') + .getByText('Caught:', { exact: true }) + .locator('..') + .getByRole('checkbox') + .check(); + await expect(page.getByText(/Saving…/)).toHaveCount(0, { timeout: 15_000 }); +}); + +Then('Google Drive and Dropbox are shown as not connected', async ({ page }) => { + await expect(page.getByText('Not Connected', { exact: true })).toHaveCount(2); +}); + +Then('{string} is shown as connected', async ({ page }, provider: string) => { + const card = page.locator('.border').filter({ hasText: provider }); + await expect(card.getByText('Connected', { exact: true })).toBeVisible(); +}); + +Then('the backup connection is rejected', async ({ state }) => { + expect(state.lastResponseStatus).toBe(400); +}); + +Then('the mocked provider receives a valid escaped CSV', async () => { + const response = await fetch(`${MOCK_URL}/__mock/state`); + const mock = (await response.json()) as { requests: Array<{ path: string; body: string }> }; + const upload = mock.requests.find((request) => request.path.includes('upload')); + expect(upload?.body).toContain('"A comma, and a ""quote"""'); +}); + +Then('the catch update remains saved', async ({ page, state }) => { + await page.goto(`/pokedex/${state.pokedexId}`); + await expect(firstPokemon(page)).toBeVisible(); + await openFirstPokemon(page); + const dialog = page.getByRole('dialog'); + const caught = dialog.getByText('Caught:', { exact: true }).locator('..').getByRole('checkbox'); + const notes = dialog.getByLabel('Notes:'); + expect((await caught.isChecked()) || (await notes.inputValue()).includes('comma')).toBe(true); +}); + +Then('the token is refreshed before the mocked upload', async ({ state }) => { + expect(state.lastResponseStatus).toBe(200); + const response = await fetch(`${MOCK_URL}/__mock/state`); + const mock = (await response.json()) as { refreshes: number; requests: Array<{ path: string }> }; + expect(mock.refreshes).toBeGreaterThan(0); + expect(mock.requests.some((request) => request.path.includes('upload'))).toBe(true); +}); + +Then('the provider failure is shown in backup settings', async ({ page }) => { + await page.goto('/backup-settings'); + await expect(page.getByText(/mock upload failure/)).toBeVisible(); +}); diff --git a/tests/bdd/steps/dex-composition.steps.ts b/tests/bdd/steps/dex-composition.steps.ts new file mode 100644 index 0000000..62f2e81 --- /dev/null +++ b/tests/bdd/steps/dex-composition.steps.ts @@ -0,0 +1,65 @@ +import { createBdd } from 'playwright-bdd'; +import { test, expect } from '../fixtures'; +import { loadEntries } from '../support/app'; + +const { When, Then } = createBdd(test); + +When('I inspect its entries without forms', async ({ page, state }) => { + await loadEntries(page, state, false); +}); + +When('I inspect its entries with forms', async ({ page, state }) => { + await loadEntries(page, state, true); +}); + +When('I view the Pokédex', async ({ page, state }) => { + if (!state.pokedexId) throw new Error('A Pokédex must exist before it can be viewed'); + await page.goto(`/pokedex/${state.pokedexId}`); + await expect(page.getByRole('button', { name: /^View details for / }).first()).toBeVisible(); +}); + +Then('it contains {int} unique species', async ({ state }, count: number) => { + expect(state.entries).toHaveLength(count); + expect(new Set(state.entries.map((entry) => entry.pokemon)).size).toBe(count); +}); + +Then('named default forms are represented once', async ({ state }) => { + for (const [pokemon, form] of Object.entries({ + Basculin: 'Red-striped', + Tornadus: 'Incarnate Form', + Oricorio: 'Baile (Red)', + Zygarde: '50%', + Gimmighoul: 'Box Form', + Rotom: 'Lightbulb', + Unown: 'A' + })) { + expect(state.entries.filter((entry) => entry.pokemon === pokemon)).toEqual([ + expect.objectContaining({ form }) + ]); + } +}); + +Then('every entry identity is unique', async ({ state }) => { + const identities = state.entries.map((entry) => `${entry.pokemon}|${entry.form ?? ''}`); + expect(new Set(identities).size).toBe(identities.length); +}); + +Then('{word} has {int} forms', async ({ state }, pokemon: string, count: number) => { + expect(state.entries.filter((entry) => entry.pokemon === pokemon)).toHaveLength(count); +}); + +Then('its Pokémon use shiny sprites', async ({ page }) => { + const first = page.locator('img[src*="/shiny/"]').first(); + for (let attempts = 0; attempts < 10 && (await first.count()) === 0; attempts++) { + await page.mouse.wheel(0, 2500); + } + await expect(first).toBeVisible(); +}); + +Then('Rotom includes its named default form without duplicate forms', async ({ state }) => { + const forms = state.entries + .filter((entry) => entry.pokemon === 'Rotom') + .map((entry) => entry.form ?? ''); + expect(forms).toContain('Lightbulb'); + expect(new Set(forms).size).toBe(forms.length); +}); diff --git a/tests/bdd/steps/pokedex-lifecycle.steps.ts b/tests/bdd/steps/pokedex-lifecycle.steps.ts new file mode 100644 index 0000000..aeb90e1 --- /dev/null +++ b/tests/bdd/steps/pokedex-lifecycle.steps.ts @@ -0,0 +1,167 @@ +import { createBdd } from 'playwright-bdd'; +import { test, expect } from '../fixtures'; +import { createDexThroughUi } from '../support/app'; + +const { Given, When, Then } = createBdd(test); + +async function dexCard(page: Parameters[0], name: string) { + await page.goto('/my-pokedexes'); + const card = page.locator('.card').filter({ hasText: name }).first(); + await expect(card).toBeVisible(); + return card; +} + +Given('I have no Pokédexes', async ({ page }) => { + await page.goto('/my-pokedexes'); + await expect( + page.locator('.card').filter({ has: page.getByRole('button', { name: 'View' }) }) + ).toHaveCount(0); +}); + +Given('I have a Living Dex named {string}', async ({ page, state }, name: string) => { + await createDexThroughUi(page, state, { name, type: 'Living Dex' }); +}); + +Given('I have a Form Dex named {string}', async ({ page, state }, name: string) => { + await createDexThroughUi(page, state, { name, type: 'Form Dex' }); +}); + +Given( + 'I have a Form Dex named {string} scoped to game {string} and dex {string}', + async ({ page, state }, name: string, game: string, dex: string) => { + await createDexThroughUi(page, state, { name, type: 'Form Dex', game, dex }); + } +); + +Given('I have a Shiny Dex named {string}', async ({ page, state }, name: string) => { + await createDexThroughUi(page, state, { name, type: 'Shiny Dex' }); +}); + +Given('another trainer has a Pokédex', async ({ state }) => { + const url = process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321'; + const key = process.env.E2E_SERVICE_ROLE_KEY; + if (!key) throw new Error('E2E_SERVICE_ROLE_KEY is required'); + const headers = { + apikey: key, + Authorization: `Bearer ${key}`, + 'Content-Type': 'application/json' + }; + const userResponse = await fetch(`${url}/auth/v1/admin/users`, { + method: 'POST', + headers, + body: JSON.stringify({ + email: `other-${Date.now()}@example.test`, + password: 'OtherPassword123!', + email_confirm: true + }) + }); + const other = (await userResponse.json()) as { id: string }; + const dexResponse = await fetch(`${url}/rest/v1/pokedexes`, { + method: 'POST', + headers: { ...headers, Prefer: 'return=representation' }, + body: JSON.stringify({ userId: other.id, name: 'Private', isLivingDex: true }) + }); + if (!dexResponse.ok) throw new Error(await dexResponse.text()); + const [dex] = (await dexResponse.json()) as Array<{ id: string }>; + state.pokedexId = dex.id; +}); + +When('I visit my Pokédex list', async ({ page }) => { + await page.goto('/my-pokedexes'); +}); + +When('I open the new Pokédex form', async ({ page }) => { + await page.goto('/my-pokedexes'); + await page + .getByRole('button', { name: /Create (New|Your First) Pokédex/ }) + .first() + .click(); +}); + +When('I create a Pokédex named {string} of type {string}', async ({ page, state }, name, type) => { + await createDexThroughUi(page, state, { name, type }); +}); + +When( + 'I create a Living Dex named {string} scoped to game {string} and dex {string}', + async ({ page, state }, name: string, game: string, dex: string) => { + await createDexThroughUi(page, state, { name, type: 'Living Dex', game, dex }); + } +); + +When('I try to create another Living Dex named {string}', async ({ page, state }, name: string) => { + await page.goto('/my-pokedexes'); + await page.getByRole('button', { name: 'Create New Pokédex', exact: true }).click(); + const modal = page.locator('.modal-open'); + await modal.getByLabel('Name').fill(name); + await modal.getByText('Living Dex', { exact: false }).locator('..').getByRole('checkbox').check(); + page.once('dialog', async (dialog) => { + state.lastMessage = dialog.message(); + await dialog.accept(); + }); + await modal.getByRole('button', { name: 'Create', exact: true }).click(); + await expect.poll(() => state.lastMessage).not.toBeNull(); +}); + +When('I rename it to {string} and enable forms', async ({ page }, name: string) => { + const card = await dexCard(page, 'Before Editing'); + await card.getByRole('button', { name: 'Edit' }).click(); + const modal = page.locator('.modal-open'); + await modal.getByLabel('Name').fill(name); + await modal.getByText('Form Dex', { exact: false }).locator('..').getByRole('checkbox').check(); + const response = page.waitForResponse( + (candidate) => + candidate.url().includes('/api/pokedexes/') && candidate.request().method() === 'PUT' + ); + await modal.getByRole('button', { name: 'Save', exact: true }).click(); + expect((await response).ok()).toBe(true); + await expect(modal).toHaveCount(0); +}); + +When('I cancel deleting {string}', async ({ page }, name: string) => { + const card = await dexCard(page, name); + page.once('dialog', (dialog) => dialog.dismiss()); + await card.getByRole('button', { name: 'Delete' }).click(); +}); + +When('I confirm deleting {string}', async ({ page }, name: string) => { + const card = await dexCard(page, name); + page.once('dialog', (dialog) => dialog.accept()); + const response = page.waitForResponse( + (candidate) => + candidate.url().includes('/api/pokedexes/') && candidate.request().method() === 'DELETE' + ); + await card.getByRole('button', { name: 'Delete' }).click(); + expect((await response).ok()).toBe(true); +}); + +When("I request the other trainer's Pokédex", async ({ page, state }) => { + const response = await page.request.get(`/api/pokedexes/${state.pokedexId}`); + state.lastResponseStatus = response.status(); +}); + +Then('I see the empty Pokédex message', async ({ page }) => { + await expect(page.getByText("You haven't created any pokédexes yet!")).toBeVisible(); +}); + +Then('I cannot create a Pokédex without a name and type', async ({ page }) => { + await expect(page.getByRole('button', { name: 'Create', exact: true })).toBeDisabled(); + await expect(page.getByText('At least one type required')).toBeVisible(); +}); + +Then('the Pokédex {string} is available to view', async ({ page }, name: string) => { + await expect((await dexCard(page, name)).getByRole('button', { name: 'View' })).toBeVisible(); +}); + +Then('I am told that the Pokédex name is already used', async ({ state }) => { + expect(state.lastMessage).toMatch(/already have a Pokédex named/i); +}); + +Then('the Pokédex {string} is no longer listed', async ({ page }, name: string) => { + await page.goto('/my-pokedexes'); + await expect(page.locator('.card').filter({ hasText: name })).toHaveCount(0); +}); + +Then('the Pokédex is not disclosed', async ({ state }) => { + expect(state.lastResponseStatus).toBe(404); +}); diff --git a/tests/bdd/steps/progress.steps.ts b/tests/bdd/steps/progress.steps.ts new file mode 100644 index 0000000..793f628 --- /dev/null +++ b/tests/bdd/steps/progress.steps.ts @@ -0,0 +1,113 @@ +import { createBdd } from 'playwright-bdd'; +import { test, expect } from '../fixtures'; +import { firstPokemon, openFirstPokemon } from '../support/app'; + +const { When, Then } = createBdd(test); + +async function ensurePokemonModal(page: Parameters[0]) { + if ((await page.getByRole('dialog').count()) === 0) await openFirstPokemon(page); +} + +async function settleAndReload(page: Parameters[0]) { + await expect(page.getByText(/Saving…/)).toHaveCount(0, { timeout: 15_000 }); + await page.reload({ waitUntil: 'networkidle' }); + await expect(firstPokemon(page)).toBeVisible(); +} + +When('I mark the first Pokémon as caught', async ({ page }) => { + await ensurePokemonModal(page); + const checkbox = page + .getByRole('dialog') + .getByText('Caught:', { exact: true }) + .locator('..') + .getByRole('checkbox'); + await checkbox.check(); +}); + +When('I mark the first Pokémon as needing evolution', async ({ page }) => { + await ensurePokemonModal(page); + const checkbox = page + .getByRole('dialog') + .getByText('Needs to evolve:', { exact: true }) + .locator('..') + .getByRole('checkbox'); + await checkbox.check(); +}); + +When('I mark the first Pokémon as in HOME', async ({ page }) => { + await ensurePokemonModal(page); + await page + .getByRole('dialog') + .getByText('In Home:', { exact: true }) + .locator('..') + .getByRole('checkbox') + .check(); +}); + +When('I add the note {string} to the first Pokémon', async ({ page }, note: string) => { + await ensurePokemonModal(page); + await page.getByRole('dialog').getByLabel('Notes:').fill(note); + await page.getByRole('dialog').getByLabel('Notes:').blur(); +}); + +When('I mark box {int} as caught', async ({ page }, box: number) => { + const heading = page.getByRole('heading', { name: `Box ${box}`, exact: true }); + const container = heading.locator('..').locator('..'); + await container.getByRole('button', { name: 'Open bulk actions menu' }).click(); + await container.getByRole('button', { name: 'Mark box as Caught' }).click(); +}); + +When('I filter to Pokémon that are not caught', async ({ page }) => { + if ((await page.getByRole('dialog').count()) > 0) { + await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click(); + } + await page.getByText('Not caught', { exact: true }).locator('..').getByRole('checkbox').check(); +}); + +When('I select the {string} box layout', async ({ page }, layout: string) => { + await page.getByLabel('Choose box view layout density').selectOption(layout.toLowerCase()); +}); + +When('I reload the Pokédex', async ({ page }) => { + await settleAndReload(page); +}); + +Then('the first Pokémon is shown as caught after reloading', async ({ page }) => { + await settleAndReload(page); + await expect(firstPokemon(page)).toHaveAttribute('aria-label', /Status: Caught/); +}); + +Then('the first Pokémon needs evolution and is not marked caught', async ({ page }) => { + await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click(); + await settleAndReload(page); + await expect(firstPokemon(page)).toHaveAttribute('aria-label', /Needs to evolve/); + await expect(firstPokemon(page)).not.toHaveAttribute('aria-label', /Status: Caught(?:,|$)/); +}); + +Then('its HOME state and note persist after reloading', async ({ page }) => { + await page.getByRole('dialog').getByRole('button', { name: 'Close', exact: true }).click(); + await settleAndReload(page); + await expect(firstPokemon(page)).toHaveAttribute('aria-label', /In HOME/); + await openFirstPokemon(page); + await expect(page.getByRole('dialog').getByLabel('Notes:')).toHaveValue( + 'Caught, traded, and checked' + ); +}); + +Then('box {int} contains {int} caught Pokémon', async ({ page }, _box: number, count: number) => { + await settleAndReload(page); + const firstBoxEntries = page + .getByRole('button', { name: /^View details for / }) + .filter({ hasNot: page.locator('[disabled]') }); + for (let index = 0; index < count; index++) { + await expect(firstBoxEntries.nth(index)).toHaveAttribute('aria-label', /Status: Caught/); + } +}); + +Then('the caught Pokémon is filtered out', async ({ page }) => { + await expect(firstPokemon(page)).toHaveAttribute('aria-disabled', 'true'); +}); + +Then('the {string} box layout remains selected', async ({ page }, layout: string) => { + await expect(page.getByLabel('Choose box view layout density')).toHaveValue(layout.toLowerCase()); +}); diff --git a/tests/bdd/steps/pwa.steps.ts b/tests/bdd/steps/pwa.steps.ts new file mode 100644 index 0000000..9d9fbda --- /dev/null +++ b/tests/bdd/steps/pwa.steps.ts @@ -0,0 +1,55 @@ +import { createBdd } from 'playwright-bdd'; +import type { Page } from '@playwright/test'; +import { test, expect } from '../fixtures'; + +const { Given, When, Then } = createBdd(test); + +async function waitForServiceWorker(page: Page) { + return page.evaluate(async () => { + if (!('serviceWorker' in navigator)) throw new Error('Service workers are not supported'); + const registration = await Promise.race([ + navigator.serviceWorker.ready, + new Promise((_, reject) => + setTimeout(() => reject(new Error('Service worker registration timed out')), 15_000) + ) + ]); + return registration.active?.scriptURL ?? null; + }); +} + +When('I open the built application', async ({ page }) => { + await page.goto('/'); + await waitForServiceWorker(page); +}); + +Given('I have opened the built application online', async ({ page }) => { + await page.context().setOffline(false); + await page.goto('/'); + await waitForServiceWorker(page); + await page.reload(); +}); + +When('I go offline and revisit the home page with a trailing slash', async ({ page }) => { + await page.context().setOffline(true); + await page.goto('/', { waitUntil: 'domcontentloaded' }); +}); + +When('I go offline and then return online', async ({ page }) => { + await page.context().setOffline(true); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.context().setOffline(false); + await page.reload({ waitUntil: 'domcontentloaded' }); +}); + +Then('a service worker controls the page', async ({ page }) => { + expect(await waitForServiceWorker(page)).toMatch(/\/(?:sw|prompt-sw)\.js$/); +}); + +Then('the application cache is present', async ({ page }) => { + const cacheNames = await page.evaluate(() => caches.keys()); + expect(cacheNames.length).toBeGreaterThan(0); +}); + +Then('the application remains available', async ({ page }) => { + await expect(page.getByRole('heading', { name: /Start Your Pokédex Journey/ })).toBeVisible(); +}); diff --git a/tests/bdd/support/app.ts b/tests/bdd/support/app.ts new file mode 100644 index 0000000..b197c20 --- /dev/null +++ b/tests/bdd/support/app.ts @@ -0,0 +1,116 @@ +import type { Page } from '@playwright/test'; +import type { ScenarioState } from '../fixtures'; + +const SUPABASE_URL = process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321'; +const SERVICE_ROLE_KEY = process.env.E2E_SERVICE_ROLE_KEY; + +function requireServiceRoleKey(): string { + if (!SERVICE_ROLE_KEY) { + throw new Error('E2E_SERVICE_ROLE_KEY is required. Run BDD through "npm run test:bdd".'); + } + return SERVICE_ROLE_KEY; +} + +export async function createConfirmedUser(state: ScenarioState): Promise { + if (state.userId) return; + const key = requireServiceRoleKey(); + const response = await fetch(`${SUPABASE_URL}/auth/v1/admin/users`, { + method: 'POST', + headers: { + apikey: key, + Authorization: `Bearer ${key}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email: state.email, + password: state.password, + email_confirm: true + }) + }); + if (!response.ok) + throw new Error(`Unable to create BDD user: ${response.status} ${await response.text()}`); + const body = (await response.json()) as { id: string }; + state.userId = body.id; +} + +export async function signIn(page: Page, state: ScenarioState, password = state.password) { + await page.goto('/signin'); + await page.getByLabel('Email').fill(state.email); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: 'Sign In' }).click(); +} + +export async function createDexThroughUi( + page: Page, + state: ScenarioState, + options: { name: string; type: string; game?: string; dex?: string } +) { + await page.goto('/my-pokedexes'); + await page + .getByRole('button', { name: /Create (New|Your First) Pokédex/ }) + .first() + .click(); + await page.getByLabel('Name').fill(options.name); + await page.getByText(options.type, { exact: false }).locator('..').getByRole('checkbox').check(); + if (options.game) { + await page.getByLabel('Game Scope').selectOption({ label: options.game }); + if (options.dex) { + const checkbox = page + .getByText(options.dex, { exact: false }) + .locator('..') + .getByRole('checkbox'); + if (!(await checkbox.isChecked())) await checkbox.check(); + } + } + await page.locator('.modal-open').getByRole('button', { name: 'Create', exact: true }).click(); + await page.waitForLoadState('networkidle'); + state.pokedexName = options.name; + if (page.url().includes('/pokedex/')) { + state.pokedexId = page.url().split('/pokedex/')[1].split(/[?#]/)[0]; + } else { + const card = page.locator('.card').filter({ hasText: options.name }).first(); + await card.getByRole('button', { name: 'View', exact: true }).click(); + await page.waitForURL('**/pokedex/**'); + state.pokedexId = page.url().split('/pokedex/')[1].split(/[?#]/)[0]; + } +} + +export async function loadEntries(page: Page, state: ScenarioState, forms: boolean) { + if (!state.pokedexId) throw new Error('A Pokédex must be created before loading entries'); + const result = await page.evaluate( + async ([id, enableForms]) => { + const response = await fetch( + `/api/pokedexes/${id}/combined-data?page=1&limit=9999&enableForms=${enableForms}` + ); + if (!response.ok) throw new Error(await response.text()); + const body = await response.json(); + return body.combinedData.map( + (row: { + pokedexEntry: { + _id: string; + pokemon: string; + form: string | null; + pokedexNumber: number; + }; + }) => ({ + id: row.pokedexEntry._id, + pokemon: row.pokedexEntry.pokemon, + form: row.pokedexEntry.form, + num: row.pokedexEntry.pokedexNumber + }) + ); + }, + [state.pokedexId, String(forms)] + ); + state.entries = result; +} + +export function firstPokemon(page: Page) { + return page.getByRole('button', { name: /^View details for / }).first(); +} + +export async function openFirstPokemon(page: Page) { + const pokemon = firstPokemon(page); + await pokemon.click(); + await page.locator('.modal-open, [role="dialog"]').first().waitFor({ state: 'visible' }); +} diff --git a/tests/build/build.test.ts b/tests/build/build.test.ts new file mode 100644 index 0000000..2734e70 --- /dev/null +++ b/tests/build/build.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { generateSW } from '../../pwa.mjs'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import { nodeAdapter } from '../../adapter.mjs'; + +describe(`test-build: ${nodeAdapter ? 'node' : 'static'} adapter`, () => { + it(`service worker is generated: ${generateSW ? 'sw.js' : 'prompt-sw.js'}`, () => { + const swName = `./build/${nodeAdapter ? 'client/' : ''}${generateSW ? 'sw.js' : 'prompt-sw.js'}`; + expect(existsSync(swName), `${swName} doesn't exist`).toBeTruthy(); + const webManifest = `./build/${nodeAdapter ? 'client/' : ''}manifest.webmanifest`; + expect(existsSync(webManifest), `${webManifest} doesn't exist`).toBeTruthy(); + const swContent = readFileSync(swName, 'utf-8'); + let match: RegExpMatchArray | null; + if (generateSW) { + match = swContent.match(/define\(\['\.\/(workbox-\w+)'/); + expect( + match && match.length === 2, + `workbox-***.js entry not found in ${swName}` + ).toBeTruthy(); + const workboxName = `./build/${nodeAdapter ? 'client/' : ''}${match?.[1]}.js`; + expect(existsSync(workboxName), `${workboxName} doesn't exist`).toBeTruthy(); + } + match = swContent.match(/"url":\s*"manifest\.webmanifest"/); + expect( + match && match.length === 1, + 'missing manifest.webmanifest in sw precache manifest' + ).toBeTruthy(); + match = swContent.match(/"url":\s*"(?:\/|index\.html)"/); + expect( + match && match.length === 1, + 'missing offline entry point in sw precache manifest' + ).toBeTruthy(); + if (nodeAdapter) { + match = swContent.match(/"url":\s*"server\//); + expect(match === null, 'found server/ entries in sw precache manifest').toBeTruthy(); + } + }); +}); diff --git a/tests/data/pokemonData.test.ts b/tests/data/pokemonData.test.ts new file mode 100644 index 0000000..8aa26fa --- /dev/null +++ b/tests/data/pokemonData.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect } from 'vitest'; +import { normalizedIdentity, readRepoCsv } from '../support/csv'; + +/** + * Correctness checks for the Pokemon reference data itself - not the code that queries it. + * + * Everything here is offline and unconditional: it cross-checks `data/csvs/pokemon.csv` + * against `static/sprites/pokeapi-pokemon.csv`, a tracked export of PokeAPI's `pokemon` + * table (`id` matches this project's spriteKey convention, `species_id` is the national dex + * number, `is_default` is PokeAPI's canonical default-variety flag). + * + * `defaultForm.integration.test.ts` asserts the CSV still mirrors the database, so these + * checks cannot quietly drift away from what the app actually serves. The canonical named + * forms are test input rather than being parsed from the fix migration, which lets this + * suite load on the pre-fix commit and fail only on observable data differences. + */ +type ApiRow = { id: string; identifier: string; species_id: string; is_default: string }; + +const EXPECTED_NATIONAL_DEX_MAX = 1025; +const pokemon = readRepoCsv('data/csvs/pokemon.csv'); +const apiRows = readRepoCsv('static/sprites/pokeapi-pokemon.csv') as unknown as ApiRow[]; +const api = new Map(apiRows.map((r) => [r.id, r])); + +const canonicalNamedDefaults = { + Alcremie: 'Vanilla Strawberry', + Basculin: 'Red-striped', + Beautifly: 'male', + Burmy: 'Leaf Cloak', + Deerling: 'Spring', + Dudunsparce: '2-Segment', + Enamorus: 'Incarnate Form', + Flabébé: 'Red', + Floette: 'Red', + Florges: 'Red', + Gastrodon: 'West Sea', + Gimmighoul: 'Box Form', + Gourgeist: 'Medium', + Gulpin: 'male', + Hoopa: 'Confined', + Landorus: 'Incarnate Form', + Lycanroc: 'Midday', + Maushold: 'Family of 4', + Minior: 'Red', + Oricorio: 'Baile (Red)', + Poltchageist: 'Phony', + Polteageist: 'Phony', + Pumpkaboo: 'Medium', + Sawsbuck: 'Spring', + Shaymin: 'Normal Form', + Shellos: 'West Sea', + Sinistcha: 'Phony', + Sinistea: 'Phony', + Squawkabilly: 'Green', + Swalot: 'male', + Tatsugiri: 'Curly', + Thundurus: 'Incarnate Form', + Tornadus: 'Incarnate Form', + Toxtricity: 'Amped', + Unown: 'A', + Urshifu: 'Single', + Vivillon: 'Meadow (France-Alsace) [Not Ultra Sun compatible]', + Wormadam: 'Leaf Cloak', + Zygarde: '50%', + Rotom: 'Lightbulb' +} as const; + +const declaredDefaults = Object.entries(canonicalNamedDefaults).map(([pokemon, form]) => ({ + pokemon, + form +})); + +/** + * PokeAPI's default variety for Minior is `minior-red-meteor` (the shielded Meteor Form), + * which this dataset doesn't track because it isn't separately catchable - a caught Minior + * always resolves to a core colour. Red is the conventional stand-in. + */ +const DEFAULT_FORM_EXCEPTIONS = new Set(['Minior']); + +describe('pokemon.csv structure', () => { + it('covers every national dex number from 1 through the declared maximum', () => { + const numbers = [...new Set(pokemon.map((row) => Number(row.pokedexNumber)))].sort( + (a, b) => a - b + ); + expect(numbers).toEqual( + Array.from({ length: EXPECTED_NATIONAL_DEX_MAX }, (_, index) => index + 1) + ); + }); + + it('maps each number to one species and each species to one number', () => { + const speciesByNumber = new Map>(); + const numbersBySpecies = new Map>(); + for (const row of pokemon) { + const number = Number(row.pokedexNumber); + if (!speciesByNumber.has(number)) speciesByNumber.set(number, new Set()); + if (!numbersBySpecies.has(row.pokemon)) numbersBySpecies.set(row.pokemon, new Set()); + speciesByNumber.get(number)?.add(row.pokemon); + numbersBySpecies.get(row.pokemon)?.add(number); + } + + expect([...speciesByNumber].filter(([, species]) => species.size !== 1)).toEqual([]); + expect([...numbersBySpecies].filter(([, numbers]) => numbers.size !== 1)).toEqual([]); + }); + + it('has no duplicate species/form rows', () => { + const seen = new Map(); + for (const r of pokemon) { + const key = normalizedIdentity(r.pokemon, r.form); + seen.set(key, (seen.get(key) ?? 0) + 1); + } + expect([...seen.entries()].filter(([, n]) => n > 1)).toEqual([]); + }); + + it('has no duplicate national-number/form rows', () => { + const seen = new Map(); + for (const row of pokemon) { + const key = normalizedIdentity(row.pokedexNumber, row.form); + seen.set(key, (seen.get(key) ?? 0) + 1); + } + expect([...seen.entries()].filter(([, count]) => count > 1)).toEqual([]); + }); + + it('has valid required fields on every row', () => { + const invalid = pokemon.filter( + (row) => + !Number.isInteger(Number(row.pokedexNumber)) || + Number(row.pokedexNumber) <= 0 || + !row.pokemon || + !row.originRegionToCatchIn || + !row.originGamesToCatchIn + ); + expect(invalid).toEqual([]); + }); + + it('gives every row a sprite key', () => { + expect(pokemon.filter((r) => !r.spriteKey)).toEqual([]); + }); +}); + +describe('pokemon.csv agrees with the PokeAPI reference export', () => { + it('matches PokeAPI on the national dex number behind every numeric sprite key', () => { + // Wyrdeer was seeded at 999 with sprite 999 - Gimmighoul's - so it sorted into the + // wrong dex slot and rendered the wrong artwork. This is the check that catches that. + const mismatches = pokemon + .filter((r) => /^\d+$/.test(r.spriteKey) && api.has(r.spriteKey)) + .filter((r) => api.get(r.spriteKey)!.species_id !== r.pokedexNumber) + .map((r) => `${r.pokemon} ${r.form} sprite=${r.spriteKey} dex=${r.pokedexNumber}`); + + expect(mismatches).toEqual([]); + }); + + it('resolves every numeric sprite key to a real PokeAPI entry', () => { + const unresolved = pokemon + .filter((r) => /^\d+$/.test(r.spriteKey) && !api.has(r.spriteKey)) + .map((r) => `${r.pokemon} ${r.form} -> ${r.spriteKey}`); + + expect(unresolved).toEqual([]); + }); +}); + +describe('declared default forms', () => { + const bySpeciesForm = new Map(pokemon.map((r) => [`${r.pokemon}|${r.form}`, r])); + + it('names a row that actually exists', () => { + // Mirrors the migration's own assertion, but fails in CI rather than only on deploy. + const missing = declaredDefaults + .filter((d) => !bySpeciesForm.has(`${d.pokemon}|${d.form}`)) + .map((d) => `${d.pokemon} '${d.form}'`); + + expect(missing).toEqual([]); + }); + + it('picks the form PokeAPI marks as the default variety', () => { + // Independently validates the judgement calls - Zygarde 50% over 10%, Basculin + // Red-striped, Toxtricity Amped, Urshifu Single Strike and the rest. + const disagreements = declaredDefaults + .filter((d) => !DEFAULT_FORM_EXCEPTIONS.has(d.pokemon)) + .map((d) => ({ d, row: bySpeciesForm.get(`${d.pokemon}|${d.form}`) })) + .filter(({ row }) => row && /^\d+$/.test(row.spriteKey) && api.has(row.spriteKey)) + .filter(({ row }) => api.get(row!.spriteKey)!.is_default !== '1') + .map( + ({ d, row }) => + `${d.pokemon} '${d.form}' -> ${api.get(row!.spriteKey)!.identifier} (is_default=0)` + ); + + expect(disagreements).toEqual([]); + }); + + it('checks a meaningful number of picks rather than silently resolving none', () => { + // Guards the test above: form-suffixed sprite keys (666-meadow, 201-a, ...) have no + // PokeAPI equivalent, so if the join broke entirely this would still pass vacuously. + const verifiable = declaredDefaults + .filter((d) => !DEFAULT_FORM_EXCEPTIONS.has(d.pokemon)) + .map((d) => bySpeciesForm.get(`${d.pokemon}|${d.form}`)) + .filter((row) => row && /^\d+$/.test(row.spriteKey) && api.has(row.spriteKey)); + + expect(verifiable.length).toBeGreaterThanOrEqual(28); + }); +}); + +describe('default-form coverage', () => { + it('declares a default for every species whose forms are all named', () => { + // The original bug: Basculin's forms are all named, so nothing matched `form IS NULL` + // and it disappeared from every non-form dex. Any future species added the same way + // must be given an explicit default here. + const bySpecies = new Map[]>(); + for (const r of pokemon) { + bySpecies.set(r.pokemon, [...(bySpecies.get(r.pokemon) ?? []), r]); + } + const declared = new Set(declaredDefaults.map((d) => d.pokemon)); + + const undeclared = [...bySpecies.entries()] + .filter(([species, rows]) => rows.every((r) => r.form !== '') && !declared.has(species)) + .map(([species, rows]) => `${species} (forms: ${rows.map((r) => r.form).join(', ')})`); + + expect(undeclared).toEqual([]); + }); + + it('declares at most one default per species', () => { + const counts = new Map(); + for (const d of declaredDefaults) counts.set(d.pokemon, (counts.get(d.pokemon) ?? 0) + 1); + expect([...counts.entries()].filter(([, n]) => n > 1)).toEqual([]); + }); +}); diff --git a/tests/data/referenceData.test.ts b/tests/data/referenceData.test.ts new file mode 100644 index 0000000..199dcf6 --- /dev/null +++ b/tests/data/referenceData.test.ts @@ -0,0 +1,89 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { normalizedIdentity, readRepoCsv } from '../support/csv'; + +const pokemon = readRepoCsv('data/csvs/pokemon.csv'); +const games = readRepoCsv('data/csvs/games.csv'); +const regions = readRepoCsv('data/csvs/regions.csv'); +const dexes = readRepoCsv('data/csvs/game-dexes.csv'); + +function duplicates(values: string[]) { + const counts = new Map(); + for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1); + return [...counts].filter(([, count]) => count > 1); +} + +describe('reference-data relationships', () => { + it('uses unique, valid region and game identities', () => { + expect(duplicates(regions.map((row) => normalizedIdentity(row.region)))).toEqual([]); + expect(duplicates(games.map((row) => normalizedIdentity(row.game)))).toEqual([]); + const knownRegions = new Set(regions.map((row) => row.region)); + expect(games.filter((row) => !knownRegions.has(row.region))).toEqual([]); + expect( + games.filter( + (row) => !Number.isInteger(Number(row.releaseYear)) || Number(row.releaseYear) < 1996 + ) + ).toEqual([]); + }); + + it('uses unique dex ids and references known games and tracked files', () => { + expect(duplicates(dexes.map((row) => normalizedIdentity(row.dexId)))).toEqual([]); + const knownGames = new Set(games.map((row) => row.game)); + expect(dexes.filter((row) => !knownGames.has(row.gameDisplayName))).toEqual([]); + expect(dexes.filter((row) => !existsSync(resolve('data/csvs', row.file)))).toEqual([]); + const knownDexes = new Set(dexes.map((row) => row.dexId)); + expect(dexes.filter((row) => row.parentDexId && !knownDexes.has(row.parentDexId))).toEqual([]); + }); + + it('keeps Pokémon origin references valid', () => { + const knownRegions = new Set(regions.map((row) => row.region)); + const knownGames = new Set(games.map((row) => row.game)); + expect(pokemon.filter((row) => !knownRegions.has(row.originRegionToCatchIn))).toEqual([]); + const unknownGames = pokemon.flatMap((row) => + row.originGamesToCatchIn + .split('/') + .map((game) => game.trim()) + .filter((game) => game && !knownGames.has(game)) + .map((game) => `${row.pokemon}: ${game}`) + ); + expect(unknownGames).toEqual([]); + }); + + it('resolves every native-dex member to a unique Pokémon identity', () => { + const identities = new Set(pokemon.map((row) => normalizedIdentity(row.pokemon, row.form))); + const species = new Set(pokemon.map((row) => normalizedIdentity(row.pokemon))); + const failures: string[] = []; + for (const file of new Set(dexes.map((row) => row.file))) { + const rows = readRepoCsv(`data/csvs/${file}`); + const memberIdentities = rows.map((row) => normalizedIdentity(row.pokemon, row.form)); + for (const duplicate of duplicates(memberIdentities)) + failures.push(`${file}: duplicate ${duplicate[0]}`); + for (const row of rows) { + const resolves = row.form + ? identities.has(normalizedIdentity(row.pokemon, row.form)) + : species.has(normalizedIdentity(row.pokemon)); + if (!resolves) { + failures.push(`${file}: unknown ${row.pokemon}|${row.form}`); + } + if ( + row.dexNumber && + (!Number.isInteger(Number(row.dexNumber)) || Number(row.dexNumber) < 0) + ) { + failures.push(`${file}: invalid dex number ${row.dexNumber}`); + } + } + } + expect(failures).toEqual([]); + }); + + it('has a tracked normal sprite for every declared sprite key', () => { + const missing = pokemon + .filter((row) => { + const folder = /^female\b/i.test(row.form) ? 'female' : ''; + return !existsSync(resolve('static/sprites-small/home', folder, `${row.spriteKey}.webp`)); + }) + .map((row) => `${row.pokemon}|${row.form} -> ${row.spriteKey}`); + expect(missing).toEqual([]); + }); +}); diff --git a/tests/integration/databaseIntegrity.integration.test.ts b/tests/integration/databaseIntegrity.integration.test.ts new file mode 100644 index 0000000..1b4ded1 --- /dev/null +++ b/tests/integration/databaseIntegrity.integration.test.ts @@ -0,0 +1,114 @@ +import { createClient } from '@supabase/supabase-js'; +import { beforeAll, describe, expect, it } from 'vitest'; + +const url = process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321'; +const anonKey = process.env.TEST_SUPABASE_ANON_KEY; +const serviceKey = process.env.E2E_SERVICE_ROLE_KEY ?? process.env.SUPABASE_SERVICE_ROLE_KEY; + +describe('database integrity and ownership', () => { + beforeAll(() => { + if (!anonKey || !serviceKey) { + throw new Error('Integration tests require TEST_SUPABASE_ANON_KEY and E2E_SERVICE_ROLE_KEY'); + } + }); + + it('enforces catch-record uniqueness and cascades records when a Pokédex is deleted', async () => { + const admin = createClient(url, serviceKey!); + const email = `integration-cascade-${Date.now()}@example.test`; + const { data: created, error: userError } = await admin.auth.admin.createUser({ + email, + password: 'Integration123!', + email_confirm: true + }); + expect(userError).toBeNull(); + const userId = created.user!.id; + const { data: dex, error: dexError } = await admin + .from('pokedexes') + .insert({ userId, name: 'Cascade', isLivingDex: true }) + .select('id') + .single(); + expect(dexError).toBeNull(); + const { data: pokemon } = await admin + .from('pokemon') + .select('id') + .order('id') + .limit(1) + .single(); + const record = { userId, pokedexId: dex!.id, pokemonId: pokemon!.id, caught: true }; + expect((await admin.from('catch_records').insert(record)).error).toBeNull(); + expect((await admin.from('catch_records').insert(record)).error?.code).toBe('23505'); + + expect((await admin.from('pokedexes').delete().eq('id', dex!.id)).error).toBeNull(); + const { count } = await admin + .from('catch_records') + .select('*', { count: 'exact', head: true }) + .eq('pokedexId', dex!.id); + expect(count).toBe(0); + }); + + it("does not disclose another user's Pokédex through row-level security", async () => { + const admin = createClient(url, serviceKey!); + const stamp = Date.now(); + const password = 'Integration123!'; + const firstEmail = `integration-owner-${stamp}@example.test`; + const secondEmail = `integration-other-${stamp}@example.test`; + const first = await admin.auth.admin.createUser({ + email: firstEmail, + password, + email_confirm: true + }); + const second = await admin.auth.admin.createUser({ + email: secondEmail, + password, + email_confirm: true + }); + expect(first.error).toBeNull(); + expect(second.error).toBeNull(); + const { data: dex } = await admin + .from('pokedexes') + .insert({ userId: first.data.user!.id, name: 'Owner only', isLivingDex: true }) + .select('id') + .single(); + + const other = createClient(url, anonKey!); + expect( + (await other.auth.signInWithPassword({ email: secondEmail, password })).error + ).toBeNull(); + const { data, error } = await other.from('pokedexes').select('id').eq('id', dex!.id); + expect(error).toBeNull(); + expect(data).toEqual([]); + }); + + it('keeps mapping membership unique for each Pokédex', async () => { + const admin = createClient(url, serviceKey!); + const email = `integration-mapping-${Date.now()}@example.test`; + const created = await admin.auth.admin.createUser({ + email, + password: 'Integration123!', + email_confirm: true + }); + const { data: dex } = await admin + .from('pokedexes') + .insert({ userId: created.data.user!.id, name: 'Unique mapping', isLivingDex: true }) + .select('id') + .single(); + const { data: pokemon } = await admin + .from('pokemon') + .select('id') + .order('id') + .limit(1) + .single(); + const mapping = { pokedexId: dex!.id, pokemonId: pokemon!.id }; + expect((await admin.from('pokedex_pokemon_mapping').insert(mapping)).error).toBeNull(); + expect((await admin.from('pokedex_pokemon_mapping').insert(mapping)).error?.code).toBe('23505'); + }); + + it('rejects mapping rows that reference missing records', async () => { + const admin = createClient(url, serviceKey!); + const { error } = await admin.from('pokedex_pokemon_mapping').insert({ + pokedexId: '00000000-0000-0000-0000-000000000000', + pokemonId: -1 + }); + expect(error?.code).toBe('23503'); + }); +}); diff --git a/tests/integration/defaultForm.integration.test.ts b/tests/integration/defaultForm.integration.test.ts new file mode 100644 index 0000000..6c1033c --- /dev/null +++ b/tests/integration/defaultForm.integration.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { createClient, type SupabaseClient } from '@supabase/supabase-js'; +import CombinedDataRepository from '../../src/lib/repositories/CombinedDataRepository'; +import { readRepoCsv } from '../support/csv'; + +/** + * Black-box data and repository regressions. These tests deliberately use only the schema + * available before the fix: on master they load normally and fail on behavior, while the + * fix branch makes the same assertions pass without test-only schema knowledge. + */ +const SUPABASE_URL = process.env.TEST_SUPABASE_URL ?? 'http://127.0.0.1:54321'; +const SUPABASE_KEY = + process.env.TEST_SUPABASE_ANON_KEY ?? + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0'; + +async function requireSupabase() { + try { + const res = await fetch(`${SUPABASE_URL}/rest/v1/pokedex_entries?select=id&limit=1`, { + headers: { apikey: SUPABASE_KEY, Authorization: `Bearer ${SUPABASE_KEY}` }, + signal: AbortSignal.timeout(2000) + }); + if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`); + } catch (error) { + throw new Error( + `Local Supabase is required for integration tests at ${SUPABASE_URL}. Run "npm run supabase:start" and "npm run supabase:reset" first. ${String(error)}` + ); + } +} + +type Row = { + id: number; + pokedexNumber: number; + pokemon: string; + form: string | null; + spriteKey: string | null; + isDefaultForm: boolean; + notes: string | null; +}; + +describe('pokedex behavior regressions', () => { + let supabase: SupabaseClient; + let rows: Row[]; + + beforeAll(async () => { + await requireSupabase(); + supabase = createClient(SUPABASE_URL, SUPABASE_KEY); + const all: Row[] = []; + for (let from = 0; ; from += 1000) { + const { data, error } = await supabase + .from('pokedex_entries') + .select('id, pokedexNumber, pokemon, form, spriteKey, isDefaultForm, notes') + .order('id', { ascending: true }) + .range(from, from + 999); + if (error) throw new Error(error.message); + if (!data?.length) break; + all.push(...(data as Row[])); + if (data.length < 1000) break; + } + rows = all; + }); + + it('returns one canonical representative for species whose forms are all named', async () => { + const repo = new CombinedDataRepository(supabase, null, null); + const entries = (await repo.findAllCombinedData('', false)).map((item) => item.pokedexEntry); + + for (const [species, form] of Object.entries({ + Basculin: 'Red-striped', + Tornadus: 'Incarnate Form', + Oricorio: 'Baile (Red)', + Zygarde: '50%', + Gimmighoul: 'Box Form', + Rotom: 'Lightbulb' + })) { + const found = entries.filter((entry) => entry.pokemon === species); + expect(found, `${species} should appear exactly once`).toHaveLength(1); + expect(found[0].form).toBe(form); + } + + expect(entries.filter((entry) => entry.pokemon === 'Beautifly').map((e) => e.form)).toEqual([ + 'male' + ]); + expect(entries.filter((entry) => entry.pokemon === 'Unown').map((e) => e.form)).toEqual(['A']); + }); + + it('returns every alternate form exactly once when forms are enabled', async () => { + const repo = new CombinedDataRepository(supabase, null, null); + const entries = (await repo.findAllCombinedData('', true)).map((item) => item.pokedexEntry); + + expect( + entries + .filter((entry) => entry.pokemon === 'Basculin') + .map((entry) => entry.form) + .sort() + ).toEqual(['Blue-striped', 'Red-striped', 'White-striped']); + expect(entries.filter((entry) => entry.pokemon === 'Alcremie')).toHaveLength(63); + expect(entries.filter((entry) => entry.pokemon === 'Unown')).toHaveLength(28); + expect(new Set(entries.map((entry) => entry._id)).size).toBe(entries.length); + }); + + it('keeps named default forms in a game-scoped form dex without duplicates', async () => { + const repo = new CombinedDataRepository(supabase, null, null); + const rotom = (await repo.findAllCombinedData('', true, '', 'Black', ['black-unova'])) + .map((item) => item.pokedexEntry) + .filter((entry) => entry.pokemon === 'Rotom'); + + expect(rotom.map((entry) => entry.form)).toContain('Lightbulb'); + expect(rotom).toHaveLength(6); + expect(new Set(rotom.map((entry) => entry._id)).size).toBe(rotom.length); + }); + + it('uses the correct national dex numbers and sprite for corrected rows', () => { + const wyrdeer = rows.find((row) => row.pokemon === 'Wyrdeer'); + expect(wyrdeer).toMatchObject({ pokedexNumber: 899, spriteKey: '899' }); + expect(rows.find((row) => row.pokemon === 'Gimmighoul')?.pokedexNumber).toBe(999); + expect( + rows.find((row) => row.pokemon === 'Ursaluna' && row.form === 'Bloodmoon')?.pokedexNumber + ).toBe(901); + }); + + it('returns every expected entry despite the PostgREST row cap', async () => { + const { calculateExpectedEntries } = await import( + '../../src/lib/services/PokedexMappingService' + ); + const baseDex = { + id: 'test', + name: 'test', + isLivingDex: true, + isShinyDex: false, + isOriginDex: false, + isFormDex: false, + gameScope: null, + dexScopes: [] + }; + + const baseIds = await calculateExpectedEntries(supabase, baseDex as never); + const formIds = await calculateExpectedEntries(supabase, { + ...baseDex, + isFormDex: true + } as never); + + expect(baseIds).toHaveLength(1025); + expect(new Set(baseIds).size).toBe(baseIds.length); + expect(formIds).toHaveLength(rows.length); + expect(new Set(formIds).size).toBe(formIds.length); + }); + + it('keeps the tracked CSV synchronized with the database', () => { + const fromCsv = new Map( + readRepoCsv('data/csvs/pokemon.csv').map((row) => [ + `${row.pokemon}|${row.form}`, + row.pokedexNumber + ]) + ); + const fromDb = new Map( + rows.map((row) => [`${row.pokemon}|${row.form ?? ''}`, String(row.pokedexNumber)]) + ); + + expect({ + onlyInCsv: [...fromCsv.keys()].filter((key) => !fromDb.has(key)), + onlyInDb: [...fromDb.keys()].filter((key) => !fromCsv.has(key)), + differing: [...fromCsv.entries()] + .filter(([key, value]) => fromDb.has(key) && fromDb.get(key) !== value) + .map(([key, value]) => `${key}: csv ${value} vs db ${fromDb.get(key)}`) + }).toEqual({ onlyInCsv: [], onlyInDb: [], differing: [] }); + }); + + it('projects exactly one default form per species through the public view', () => { + const bySpecies = new Map(); + for (const row of rows) + bySpecies.set(row.pokemon, [...(bySpecies.get(row.pokemon) ?? []), row]); + const invalid = [...bySpecies] + .filter(([, entries]) => entries.filter((entry) => entry.isDefaultForm).length !== 1) + .map(([species, entries]) => ({ + species, + defaults: entries.filter((entry) => entry.isDefaultForm).map((entry) => entry.form) + })); + expect(invalid).toEqual([]); + expect(rows[0]).toHaveProperty('notes'); + }); +}); diff --git a/tests/support/csv.ts b/tests/support/csv.ts new file mode 100644 index 0000000..8eab419 --- /dev/null +++ b/tests/support/csv.ts @@ -0,0 +1,61 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +export type CsvRow = Record; + +export function parseCsv(text: string): CsvRow[] { + const rows: string[][] = []; + let row: string[] = []; + let cell = ''; + let quoted = false; + + for (let index = 0; index < text.length; index++) { + const char = text[index]; + if (char === '"') { + if (quoted && text[index + 1] === '"') { + cell += '"'; + index++; + } else { + quoted = !quoted; + } + } else if (char === ',' && !quoted) { + row.push(cell); + cell = ''; + } else if ((char === '\n' || char === '\r') && !quoted) { + if (char === '\r' && text[index + 1] === '\n') index++; + row.push(cell); + if (row.some((value) => value.length > 0)) rows.push(row); + row = []; + cell = ''; + } else { + cell += char; + } + } + + if (quoted) throw new Error('Malformed CSV: unclosed quoted field'); + if (cell.length > 0 || row.length > 0) { + row.push(cell); + if (row.some((value) => value.length > 0)) rows.push(row); + } + + const [headers, ...records] = rows; + if (!headers) return []; + return records.map((record, rowIndex) => { + if (record.length !== headers.length) { + throw new Error( + `Malformed CSV row ${rowIndex + 2}: expected ${headers.length} columns, received ${record.length}` + ); + } + return Object.fromEntries( + headers.map((header, index) => [header.trim(), record[index].trim()]) + ); + }); +} + +export function readRepoCsv(relativePath: string): CsvRow[] { + return parseCsv(readFileSync(resolve(process.cwd(), relativePath), 'utf8')); +} + +export function normalizedIdentity(...parts: Array): string { + return parts.map((part) => (part ?? '').trim().toLocaleLowerCase('en-GB')).join('|'); +} diff --git a/tests/support/no-skips-reporter.ts b/tests/support/no-skips-reporter.ts new file mode 100644 index 0000000..ec21cad --- /dev/null +++ b/tests/support/no-skips-reporter.ts @@ -0,0 +1,16 @@ +import type { FullResult, Reporter, TestCase, TestResult } from '@playwright/test/reporter'; + +export default class NoSkipsReporter implements Reporter { + private skipped: string[] = []; + + onTestEnd(test: TestCase, result: TestResult) { + if (result.status === 'skipped') this.skipped.push(test.titlePath().join(' › ')); + } + + onEnd(result: FullResult) { + if (this.skipped.length === 0) return; + for (const title of this.skipped) + process.stderr.write(`Unexpected skipped BDD scenario: ${title}\n`); + return { status: 'failed' as const, startTime: result.startTime, duration: result.duration }; + } +} diff --git a/tests/unit/auth.test.ts b/tests/unit/auth.test.ts new file mode 100644 index 0000000..085b39f --- /dev/null +++ b/tests/unit/auth.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getOptionalUserId, requireAuth } from '../../src/lib/utils/auth'; + +function eventReturning(value: unknown) { + return { locals: { safeGetSession: vi.fn(async () => value) } } as never; +} + +describe('authentication guards', () => { + it('returns the authenticated user id', async () => { + await expect( + requireAuth(eventReturning({ session: {}, user: { id: 'user-1' } })) + ).resolves.toBe('user-1'); + }); + + it.each([ + { session: null, user: null }, + { session: {}, user: null }, + { session: null, user: { id: 'user-1' } } + ])('rejects an incomplete authenticated session', async (value) => { + await expect(requireAuth(eventReturning(value))).rejects.toMatchObject({ status: 401 }); + }); + + it('optionally returns a user id or null', async () => { + await expect( + getOptionalUserId(eventReturning({ session: {}, user: { id: 'user-2' } })) + ).resolves.toBe('user-2'); + await expect( + getOptionalUserId(eventReturning({ session: null, user: null })) + ).resolves.toBeNull(); + const throwing = { + locals: { safeGetSession: vi.fn(async () => Promise.reject(new Error('unavailable'))) } + } as never; + await expect(getOptionalUserId(throwing)).resolves.toBeNull(); + }); +}); diff --git a/tests/unit/boxPlacement.test.ts b/tests/unit/boxPlacement.test.ts new file mode 100644 index 0000000..c026c42 --- /dev/null +++ b/tests/unit/boxPlacement.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { calculateBoxNumbers, calculateBoxPlacement } from '../../src/lib/utils/boxPlacement'; + +describe('box placement', () => { + it.each([ + [0, { box: 1, row: 1, column: 1 }], + [5, { box: 1, row: 1, column: 6 }], + [6, { box: 1, row: 2, column: 1 }], + [29, { box: 1, row: 5, column: 6 }], + [30, { box: 2, row: 1, column: 1 }] + ])('places zero-based entry %i in its box grid', (index, expected) => { + expect(calculateBoxPlacement(index)).toEqual(expected); + }); + + it.each([ + [0, []], + [1, [1]], + [30, [1]], + [31, [1, 2]], + [1025, Array.from({ length: 35 }, (_, index) => index + 1)] + ])('calculates box numbers for %i entries', (count, expected) => { + expect(calculateBoxNumbers(count)).toEqual(expected); + }); +}); diff --git a/tests/unit/catchRecordWriteQueue.test.ts b/tests/unit/catchRecordWriteQueue.test.ts new file mode 100644 index 0000000..c9fabaa --- /dev/null +++ b/tests/unit/catchRecordWriteQueue.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createCatchRecordWriteQueue } from '../../src/lib/utils/catchRecordWriteQueue'; +import type { CatchRecord } from '../../src/lib/models/CatchRecord'; + +function mkRecord(overrides: Partial = {}): CatchRecord { + return { + _id: '', + userId: 'u1', + pokedexId: 'p1', + pokemonId: '25', + haveToEvolve: false, + caught: false, + inHome: false, + hasGigantamaxed: false, + personalNotes: '', + ...overrides + }; +} + +describe('createCatchRecordWriteQueue()', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('coalesces multiple updates for the same key and flushes only the latest state', async () => { + const fetchFn = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + return new Response(init?.body as string, { status: 200 }); + }); + + const queue = createCatchRecordWriteQueue({ + endpointUrl: '/api/pokedexes/p1/catch-records', + fetchFn, + batchSize: 50, + concurrency: 1 + }); + + queue.enqueue(mkRecord({ caught: true })); + queue.enqueue(mkRecord({ caught: false, haveToEvolve: true })); + + await queue.flushNow(); + + expect(fetchFn).toHaveBeenCalledTimes(1); + const body = JSON.parse(String(fetchFn.mock.calls[0]?.[1]?.body)); + expect(body).toHaveLength(1); + expect(body[0].haveToEvolve).toBe(true); + expect(body[0].caught).toBe(false); + }); + + it('debounces notes updates before flushing', async () => { + const fetchFn = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + return new Response(init?.body as string, { status: 200 }); + }); + + const queue = createCatchRecordWriteQueue({ + endpointUrl: '/api/pokedexes/p1/catch-records', + fetchFn, + batchSize: 50, + concurrency: 1 + }); + + queue.enqueue(mkRecord({ personalNotes: 'a' }), { debounceMs: 500 }); + await queue.flushNow(); + expect(fetchFn).toHaveBeenCalledTimes(0); + + vi.advanceTimersByTime(499); + await queue.flushNow(); + expect(fetchFn).toHaveBeenCalledTimes(0); + + vi.advanceTimersByTime(1); + await queue.flushNow(); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it('reports failures, clears errors, and retries after exponential backoff', async () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + const fetchFn = vi + .fn() + .mockResolvedValueOnce(new Response('temporarily unavailable', { status: 503 })) + .mockResolvedValueOnce(new Response('[]', { status: 200 })); + const queue = createCatchRecordWriteQueue({ + endpointUrl: '/catch-records', + fetchFn + }); + let status = null as null | { + pending: number; + lastError: string | null; + lastSuccessfulFlushAt: number | null; + }; + const unsubscribe = queue.getStatus.subscribe((value) => (status = value)); + + queue.enqueue(mkRecord(), { flushSoon: false }); + expect(queue.getPendingCount()).toBe(1); + await queue.flushNow(); + expect(status?.lastError).toContain('503 temporarily unavailable'); + expect(queue.getPendingCount()).toBe(1); + + queue.clearError(); + expect(status?.lastError).toBeNull(); + await vi.advanceTimersByTimeAsync(250); + await queue.flushNow(); + expect(fetchFn).toHaveBeenCalledTimes(2); + expect(queue.getPendingCount()).toBe(0); + expect(status?.lastSuccessfulFlushAt).not.toBeNull(); + unsubscribe(); + }); + + it('honours batch limits and keepalive while draining eligible records', async () => { + const fetchFn = vi.fn(async () => new Response('[]', { status: 200 })); + const queue = createCatchRecordWriteQueue({ + endpointUrl: '/catch-records', + fetchFn, + batchSize: 50 + }); + queue.enqueue(mkRecord({ pokemonId: '1' }), { flushSoon: false }); + queue.enqueue(mkRecord({ pokemonId: '2' }), { flushSoon: false }); + queue.enqueue(mkRecord({ pokemonId: '3' }), { flushSoon: false }); + + await queue.flushNow({ limit: 2, keepalive: true }); + + expect(fetchFn).toHaveBeenCalledTimes(2); + expect(JSON.parse(String(fetchFn.mock.calls[0][1]?.body))).toHaveLength(2); + expect(JSON.parse(String(fetchFn.mock.calls[1][1]?.body))).toHaveLength(1); + expect(fetchFn.mock.calls.every(([, init]) => init?.keepalive === true)).toBe(true); + }); + + it('retains work while the browser is offline', async () => { + vi.stubGlobal('navigator', { onLine: false }); + const fetchFn = vi.fn(); + const queue = createCatchRecordWriteQueue({ endpointUrl: '/catch-records', fetchFn }); + queue.enqueue(mkRecord(), { flushSoon: false }); + + await queue.flushNow(); + + expect(fetchFn).not.toHaveBeenCalled(); + expect(queue.getPendingCount()).toBe(1); + }); + + it('does not discard a newer version enqueued during an in-flight request', async () => { + let resolveFirst: ((response: Response) => void) | undefined; + const firstResponse = new Promise((resolve) => (resolveFirst = resolve)); + const fetchFn = vi + .fn() + .mockReturnValueOnce(firstResponse) + .mockResolvedValueOnce(new Response('[]', { status: 200 })); + const queue = createCatchRecordWriteQueue({ endpointUrl: '/catch-records', fetchFn }); + queue.enqueue(mkRecord({ personalNotes: 'old' }), { flushSoon: false }); + + const flushing = queue.flushNow(); + await vi.waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(1)); + queue.enqueue(mkRecord({ personalNotes: 'new' }), { flushSoon: false }); + resolveFirst?.(new Response('[]', { status: 200 })); + await flushing; + + expect(fetchFn).toHaveBeenCalledTimes(2); + const latest = JSON.parse(String(fetchFn.mock.calls[1][1]?.body)); + expect(latest[0].personalNotes).toBe('new'); + expect(queue.getPendingCount()).toBe(0); + }); + + it('stores non-Error failures as readable status text', async () => { + const fetchFn = vi.fn(async () => { + throw 'network down'; + }); + const queue = createCatchRecordWriteQueue({ endpointUrl: '/catch-records', fetchFn }); + let lastError: string | null = null; + queue.getStatus.subscribe((status) => (lastError = status.lastError)); + queue.enqueue(mkRecord(), { flushSoon: false }); + await queue.flushNow(); + expect(lastError).toBe('network down'); + }); +}); diff --git a/tests/unit/combinedDataRepository.test.ts b/tests/unit/combinedDataRepository.test.ts new file mode 100644 index 0000000..c0d1a4f --- /dev/null +++ b/tests/unit/combinedDataRepository.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from 'vitest'; +import CombinedDataRepository from '../../src/lib/repositories/CombinedDataRepository'; + +type Call = { method: string; args: unknown[] }; +type TableQuery = { table: string; calls: Call[] }; + +/** + * Minimal recording stand-in for a Supabase query builder. + * + * Every chained call is recorded and returns the builder, and the builder is thenable so + * `await query` / `await query.range(...)` resolve like a real PostgREST response. Returning + * an empty data set keeps the repository's paging loops to a single iteration. + */ +function createSupabaseStub() { + const queries: TableQuery[] = []; + + const from = (table: string) => { + const record: TableQuery = { table, calls: [] }; + queries.push(record); + + const builder: Record = new Proxy( + {}, + { + get(_target, prop: string) { + if (prop === 'then') { + return (resolve: (value: unknown) => unknown) => + resolve({ data: [], error: null, count: 0 }); + } + return (...args: unknown[]) => { + record.calls.push({ method: prop, args }); + return builder; + }; + } + } + ); + + return builder; + }; + + return { supabase: { from } as never, queries }; +} + +const queryFor = (queries: TableQuery[], table: string) => queries.filter((q) => q.table === table); + +const hasCall = (q: TableQuery, method: string, args: unknown[]) => + q.calls.some((c) => c.method === method && JSON.stringify(c.args) === JSON.stringify(args)); + +const mentionsIsDefaultForm = (q: TableQuery) => + q.calls.some((c) => JSON.stringify(c.args).includes('isDefaultForm')); + +describe('CombinedDataRepository base-form filtering', () => { + it('filters to default forms only when the form dex toggle is off', async () => { + const { supabase, queries } = createSupabaseStub(); + const repo = new CombinedDataRepository(supabase, 'user-1', null); + + await repo.findAllCombinedData('user-1', false, '', '', []); + + const [entries] = queryFor(queries, 'pokedex_entries'); + expect(entries).toBeDefined(); + expect(hasCall(entries, 'eq', ['isDefaultForm', true])).toBe(true); + }); + + it('applies no form filter at all when the form dex toggle is on', async () => { + const { supabase, queries } = createSupabaseStub(); + const repo = new CombinedDataRepository(supabase, 'user-1', null); + + await repo.findAllCombinedData('user-1', true, '', '', []); + + const [entries] = queryFor(queries, 'pokedex_entries'); + expect(mentionsIsDefaultForm(entries)).toBe(false); + }); + + it('filters dex-scoped queries to default forms when the form dex toggle is off', async () => { + const { supabase, queries } = createSupabaseStub(); + const repo = new CombinedDataRepository(supabase, 'user-1', null); + + await repo.findAllCombinedData('user-1', false, '', '', ['black-unova']); + + const [dexEntries] = queryFor(queries, 'game_pokedex_entry_details'); + expect(dexEntries).toBeDefined(); + expect(hasCall(dexEntries, 'eq', ['isDefaultForm', true])).toBe(true); + }); + + it('counts with the same default-form filter the listing uses', async () => { + const { supabase, queries } = createSupabaseStub(); + const repo = new CombinedDataRepository(supabase, 'user-1', null); + + await repo.countCombinedData(false, '', '', []); + + const [entries] = queryFor(queries, 'pokedex_entries'); + expect(hasCall(entries, 'eq', ['isDefaultForm', true])).toBe(true); + }); + + /** + * Regression guard. game_pokedex_entries is seeded from `form IS NULL` rows, so a default + * form that has a NAME (e.g. Rotom "Lightbulb", Basculin "Red-striped") is absent from the + * game dex tables and can only reach a game-scoped form dex through this supplement query. + * + * Switching this filter to `isDefaultForm` looks like a tidy-up, but it silently drops + * those rows: base Rotom disappeared from the Black form dex while its five appliance + * forms remained. Keep it keyed on `form` - excludeIds already dedupes whatever the dex + * table does list. + */ + it('supplements game forms by form name, never by isDefaultForm', async () => { + const { supabase, queries } = createSupabaseStub(); + const repo = new CombinedDataRepository(supabase, 'user-1', null); + + await repo.findAllCombinedData('user-1', true, '', 'Black', ['black-unova']); + + const supplements = queryFor(queries, 'pokedex_entries'); + expect(supplements.length).toBeGreaterThan(0); + + const supplement = supplements[0]; + expect(hasCall(supplement, 'not', ['form', 'is', null])).toBe(true); + expect(mentionsIsDefaultForm(supplement)).toBe(false); + }); +}); diff --git a/tests/unit/oauthState.test.ts b/tests/unit/oauthState.test.ts new file mode 100644 index 0000000..e722d02 --- /dev/null +++ b/tests/unit/oauthState.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + clearOAuthStateCookie, + createOAuthState, + readOAuthStateCookie, + setOAuthStateCookie, + type OAuthStatePayload +} from '../../src/lib/utils/oauthState'; + +function eventWithCookie(raw?: string) { + return { + url: new URL('https://example.test/callback'), + cookies: { + get: vi.fn(() => raw), + set: vi.fn(), + delete: vi.fn() + } + } as never; +} + +describe('OAuth state cookies', () => { + it('creates opaque unique state values', () => { + const first = createOAuthState(); + const second = createOAuthState(); + expect(first).toMatch(/^[0-9a-f-]{36}$/); + expect(second).not.toBe(first); + }); + + it('writes a secure, short-lived, provider-scoped cookie', () => { + const event = eventWithCookie(); + const payload: OAuthStatePayload = { + state: 'state-1', + userId: 'user-1', + provider: 'google_drive', + returnTo: '/backup-settings' + }; + setOAuthStateCookie(event, 'google_drive', payload); + expect(event.cookies.set).toHaveBeenCalledWith( + 'oauth_state_google_drive', + JSON.stringify(payload), + expect.objectContaining({ httpOnly: true, sameSite: 'lax', secure: true, maxAge: 600 }) + ); + }); + + it('reads only structurally valid state', () => { + expect(readOAuthStateCookie(eventWithCookie('{bad json'), 'dropbox')).toBeNull(); + expect(readOAuthStateCookie(eventWithCookie('{}'), 'dropbox')).toBeNull(); + expect(readOAuthStateCookie(eventWithCookie(), 'dropbox')).toBeNull(); + expect( + readOAuthStateCookie( + eventWithCookie(JSON.stringify({ state: 's', userId: 'u', provider: 'dropbox' })), + 'dropbox' + ) + ).toMatchObject({ state: 's', userId: 'u' }); + }); + + it('clears the provider cookie at the shared path', () => { + const event = eventWithCookie(); + clearOAuthStateCookie(event, 'dropbox'); + expect(event.cookies.delete).toHaveBeenCalledWith('oauth_state_dropbox', { path: '/' }); + }); +}); diff --git a/tests/unit/pokedexExportService.test.ts b/tests/unit/pokedexExportService.test.ts new file mode 100644 index 0000000..27c54c5 --- /dev/null +++ b/tests/unit/pokedexExportService.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + buildCsv, + csvEscape, + sanitizeFileName, + shouldRefreshToken +} from '../../src/lib/services/PokedexExportFormatting'; + +describe('Pokédex export formatting', () => { + it.each([ + [null, ''], + [undefined, ''], + ['plain', 'plain'], + ['comma,value', '"comma,value"'], + ['a "quote"', '"a ""quote"""'], + ['two\nlines', '"two\nlines"'] + ])('escapes CSV value %j', (value, expected) => { + expect(csvEscape(value)).toBe(expected); + }); + + it('sanitizes provider filenames while preserving a CSV suffix', () => { + expect(sanitizeFileName(' My: Dex? ', 'fallback')).toBe('My- Dex-.csv'); + expect(sanitizeFileName('already.csv', 'fallback')).toBe('already.csv'); + expect(sanitizeFileName('***', 'fallback')).toBe('-.csv'); + expect(sanitizeFileName(' ', 'fallback')).toBe('fallback'); + }); + + it('builds a stable, escaped CSV with defaults for missing catch records', () => { + const csv = buildCsv( + { _id: 'dex-1', name: 'Test' } as never, + [ + { + pokedexEntry: { + _id: '25', + pokedexNumber: 25, + pokemon: 'Pikachu', + form: null + }, + catchRecord: { + caught: true, + haveToEvolve: false, + inHome: true, + hasGigantamaxed: false, + personalNotes: 'Comma, and "quote"' + } + }, + { + pokedexEntry: { + _id: '26', + pokedexNumber: 26, + pokemon: 'Raichu', + form: 'Alolan' + }, + catchRecord: null + } + ] as never + ); + expect(csv.split('\r\n')).toEqual([ + 'pokemonId,pokedexNumber,pokemon,form,caught,haveToEvolve,inHome,personalNotes', + '25,25,Pikachu,,true,false,true,"Comma, and ""quote"""', + '26,26,Raichu,Alolan,false,false,false,' + ]); + }); + + it('refreshes only finite expiries within the next minute', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-09-13T12:00:00Z')); + expect(shouldRefreshToken(null)).toBe(false); + expect(shouldRefreshToken('not-a-date')).toBe(false); + expect(shouldRefreshToken('2026-09-13T12:02:00Z')).toBe(false); + expect(shouldRefreshToken('2026-09-13T12:00:30Z')).toBe(true); + expect(shouldRefreshToken('2026-09-13T11:59:00Z')).toBe(true); + vi.useRealTimers(); + }); +}); diff --git a/tests/unit/regionalDexMapping.test.ts b/tests/unit/regionalDexMapping.test.ts new file mode 100644 index 0000000..be27c75 --- /dev/null +++ b/tests/unit/regionalDexMapping.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { + getRegionalDexColumnName, + getRegionalDexFieldName, + getRegionalDexKey, + hasRegionalDex +} from '../../src/lib/utils/regionalDexMapping'; + +describe('regional dex mapping', () => { + it.each([ + ['Red', 'kanto', 'kanto_dex_number', 'kantoDexNumber'], + ['Black2', 'unova_b2w2', 'unova_b2w2_dex_number', 'unovaB2w2DexNumber'], + ['UltraMoon', 'alola_usum', 'alola_usum_dex_number', 'alolaUsumDexNumber'], + ['Scarlet', 'paldea', 'paldea_dex_number', 'paldeaDexNumber'] + ])('maps %s consistently', (game, key, column, field) => { + expect(hasRegionalDex(game)).toBe(true); + expect(getRegionalDexKey(game)).toBe(key); + expect(getRegionalDexColumnName(game)).toBe(column); + expect(getRegionalDexFieldName(game)).toBe(field); + }); + + it('returns no mapping for unknown games', () => { + expect(hasRegionalDex('Legends Z-A')).toBe(false); + expect(getRegionalDexKey('Legends Z-A')).toBeUndefined(); + expect(getRegionalDexColumnName('Legends Z-A')).toBeUndefined(); + expect(getRegionalDexFieldName('Legends Z-A')).toBeUndefined(); + }); +}); diff --git a/vitest.build.config.mts b/vitest.build.config.mts new file mode 100644 index 0000000..926e205 --- /dev/null +++ b/vitest.build.config.mts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + allowOnly: false, + include: ['tests/build/**/*.test.ts'] + } +}); diff --git a/vitest.config.mts b/vitest.config.mts index 11fe712..9ab640e 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,7 +1,33 @@ -import { defineConfig } from 'vitest/config' +import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; export default defineConfig({ - test: { - include: ['test/*.test.ts'] - } -}) + resolve: { + alias: { + $lib: fileURLToPath(new URL('./src/lib', import.meta.url)) + } + }, + test: { + allowOnly: false, + include: ['tests/unit/**/*.test.ts', 'tests/data/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json-summary', 'html'], + reportsDirectory: 'coverage', + include: [ + 'src/lib/utils/boxPlacement.ts', + 'src/lib/utils/catchRecordWriteQueue.ts', + 'src/lib/utils/oauthState.ts', + 'src/lib/utils/regionalDexMapping.ts', + 'src/lib/services/PokedexExportFormatting.ts' + ], + thresholds: { + perFile: true, + statements: 90, + functions: 90, + lines: 90, + branches: 80 + } + } + } +}); diff --git a/vitest.integration.config.mts b/vitest.integration.config.mts new file mode 100644 index 0000000..2126b10 --- /dev/null +++ b/vitest.integration.config.mts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; + +export default defineConfig({ + resolve: { + alias: { + $lib: fileURLToPath(new URL('./src/lib', import.meta.url)) + } + }, + test: { + allowOnly: false, + include: ['tests/integration/**/*.test.ts'], + testTimeout: 30_000, + hookTimeout: 30_000, + sequence: { concurrent: false } + } +});