diff --git a/docs/uat-runbook.md b/docs/uat-runbook.md new file mode 100644 index 0000000..787c5b1 --- /dev/null +++ b/docs/uat-runbook.md @@ -0,0 +1,151 @@ +# CartSnitch UAT Runbook v1 + +**Version:** 1.0 +**Author:** Savannah Savings, CTO +**Date:** 2026-03-30 +**Effective:** Immediately upon Phase 1 completion + +--- + +## 1. Defect Severity Classification + +Every defect discovered during UAT **must** be classified by severity and priority before triage. + +### Severity Levels + +| Severity | Definition | Examples | +|----------|-----------|----------| +| **S1 — Critical** | Blocks all users from completing a core journey. System is down, data is lost, or security is breached. | Login page crashes for all users; purchase data deleted; auth tokens exposed in response | +| **S2 — High** | Blocks a major user flow for a significant portion of users. Core feature is broken but workarounds may exist. | Registration fails for email addresses with `+` character; price alerts never trigger; store comparison shows wrong prices | +| **S3 — Medium** | Feature is degraded but usable. User can complete the journey with friction. | Date formatting shows raw ISO string instead of friendly date; slow page load (>5s) on product detail; search results not sorted correctly | +| **S4 — Low** | Cosmetic issue, minor UI inconsistency, or edge case with minimal user impact. | Button text truncated on narrow screens; extra whitespace in footer; tooltip shows on hover but not on focus | + +### Priority Levels + +Priority determines **when** the defect must be fixed. Priority is set by the CTO based on severity, business impact, and sprint capacity. + +| Priority | SLA | When to Use | +|----------|-----|------------| +| **P0 — Fix Now** | Triage within 1 hour, fix deployed within 4 hours | S1 defects, any security vulnerability, data integrity issues | +| **P1 — Fix This Sprint** | Triage within 4 hours, fix in current sprint | S2 defects blocking upcoming release, S1 defects with viable workaround | +| **P2 — Fix Next Sprint** | Triage within 24 hours, scheduled for next sprint | S3 defects, S2 defects with easy workarounds | +| **P3 — Backlog** | Triage within 48 hours, prioritized against backlog | S4 defects, minor improvements, nice-to-haves | + +### Defect Report Template + +Every defect filed during UAT must include: + +``` +**Title:** [Short description] +**Severity:** S1/S2/S3/S4 +**Priority:** P0/P1/P2/P3 (set by CTO at triage) +**Journey:** [Which user journey — J1 through J10] +**Environment:** [Dev / Prod, deployed image tag] +**Steps to Reproduce:** +1. Navigate to ... +2. Click ... +3. Enter ... +**Expected Result:** ... +**Actual Result:** ... +**Screenshots/Logs:** [Attach or link] +**Browser/Device:** [e.g., Chromium 124, mobile viewport 390x844] +``` + +--- + +## 2. UAT Entry Criteria + +UAT **must not begin** until ALL of the following are satisfied. Checkout Charlie verifies these before opening the UAT gate. + +| # | Criterion | Verified By | +|---|-----------|------------| +| E1 | CI pipeline passes on the merged commit (lint, type-check, unit tests, build) | GitHub Actions (automated) | +| E2 | Docker image is built and pushed to GHCR with a CalVer tag | GitHub Actions (automated) | +| E3 | Dev environment is deployed and accessible at `cartsnitch.dev.farh.net` | Flux reconciliation + health check | +| E4 | All Playwright E2E tests pass in CI | GitHub Actions (automated) | +| E5 | No open S1/S2 defects from previous UAT cycle | Checkout Charlie (manual check) | +| E6 | PR has been reviewed and approved by QA (Checkout Charlie) and CTO (Savannah Savings) | GitHub PR approvals | +| E7 | PR has been merged to main by CEO (Coupon Carl) | GitHub merge event | +| E8 | Acceptance criteria for the feature/change are documented in the Paperclip issue | Checkout Charlie (manual check) | + +**If any entry criterion is not met**, UAT is blocked. Checkout Charlie must comment on the Paperclip issue specifying which criteria failed and assign back to the responsible party. + +--- + +## 3. UAT Exit Criteria + +UAT is **complete** only when ALL of the following are satisfied. Rollback Rhonda verifies these before signing off. + +| # | Criterion | Verified By | +|---|-----------|------------| +| X1 | All 10 critical user journeys (J1-J10) have been executed | Rollback Rhonda (full regression) | +| X2 | Zero open S1 (Critical) defects | Defect tracker | +| X3 | Zero open S2 (High) defects, OR CTO has granted a documented exception | Defect tracker + CTO sign-off | +| X4 | All S3/S4 defects are logged and triaged (not necessarily fixed) | Defect tracker | +| X5 | 100% test execution rate -- every test case was run, none skipped | Rollback Rhonda's UAT report | +| X6 | Accessibility scan (axe-core) reports zero critical violations | Automated in E2E suite | +| X7 | Lighthouse performance score >= 50, accessibility score >= 90 | Lighthouse CI | +| X8 | Written sign-off from Rollback Rhonda confirming all criteria met | Paperclip comment on issue | + +**If any exit criterion is not met**, the release is blocked. Rollback Rhonda must: +1. File defects for all failures using the Defect Report Template above. +2. Comment on the Paperclip issue specifying which exit criteria failed. +3. Assign back to CTO for triage and redistribution. + +--- + +## 4. UAT Execution Procedure + +### 4.1 Pre-UAT (Checkout Charlie) + +1. Verify all entry criteria (E1-E8) are met. +2. Comment on the Paperclip issue: "UAT gate open -- all entry criteria verified." +3. Assign to Rollback Rhonda with status todo. + +### 4.2 UAT Execution (Rollback Rhonda) + +1. **Full regression run** -- execute ALL 10 user journeys against cartsnitch.dev.farh.net. No partial runs. No exceptions. +2. For each journey, verify: + - All interactive elements respond correctly (buttons, forms, links, toggles) + - State transitions are correct (auth state, data mutations, navigation) + - Error states are handled gracefully (invalid input, network failures) + - Accessibility scan passes (axe-core integrated in Playwright) +3. Log results for each journey: PASS / FAIL with details. +4. File defects immediately for any failures. +5. Complete the UAT report with execution results. + +### 4.3 Post-UAT Sign-Off + +1. If all exit criteria (X1-X8) are met: + - Rollback Rhonda posts sign-off comment: "UAT PASSED -- all exit criteria met." + - Production promotion is automated via Flux on UAT pass. +2. If any exit criterion fails: + - Rollback Rhonda posts failure comment with specific failures. + - CTO triages defects and redistributes to engineers. + - After fixes are merged, UAT restarts from 4.1 (full cycle). + +--- + +## 5. Critical User Journeys Reference + +| ID | Journey | Key Interactions | +|----|---------|-----------------| +| J1 | Registration -> Login -> Dashboard | Form submission, auth state, redirect | +| J2 | Login -> Browse Products -> View Detail -> Price Chart | Search, navigation, data visualization | +| J3 | Login -> Purchases -> Purchase Detail -> Product Link | List navigation, detail view, cross-linking | +| J4 | Login -> Connect Store Account -> Verify Connection | OAuth flow, external integration | +| J5 | Login -> Create Price Alert -> View -> Delete Alert | CRUD operations, confirmation dialogs | +| J6 | Login -> Browse Coupons -> Copy Code | Clipboard interaction, toast feedback | +| J7 | Login -> Settings -> Toggle Preferences -> Sign Out | Checkbox toggles, theme switch, session termination | +| J8 | Login -> Store Comparison -> Compare Prices | Data comparison, sorting, price display | +| J9 | Forgot Password Flow | Email input, validation, redirect | +| J10 | Unauth Access -> Redirect to Login | Route protection, redirect behavior | + +--- + +## 6. Revision History + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | 2026-03-30 | Savannah Savings | Initial runbook -- defect taxonomy, entry/exit criteria, execution procedure | + diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..07e12e6 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,12 @@ +import { test as base, expect } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; + +export const test = base.extend<{ axeCheck: void }>({ + axeCheck: [async ({ page }, use) => { + await use(); + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toEqual([]); + }, { auto: true }], +}); + +export { expect } from "@playwright/test"; diff --git a/e2e/journeys/j1-registration-login.spec.ts b/e2e/journeys/j1-registration-login.spec.ts new file mode 100644 index 0000000..ec116ab --- /dev/null +++ b/e2e/journeys/j1-registration-login.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from '@playwright/test'; + +const uniqueEmail = () => `betty+e2e-${Date.now()}@cartsnitch.test`; + +test.describe('J1: Registration and Login', () => { + test('can register a new account and lands on dashboard', async ({ page }) => { + await page.goto('/register'); + await page.fill('[placeholder="Full Name"]', 'Betty Tester'); + await page.fill('[placeholder="Email"]', uniqueEmail()); + await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!'); + await page.click('button[type="submit"]'); + + // With VITE_MOCK_AUTH=true the app navigates to "/" on success + await expect(page).toHaveURL('http://localhost:5173/'); + await expect(page.getByRole('heading', { name: /cart/i })).toBeVisible(); + }); + + test('shows validation error when registration fields are empty', async ({ page }) => { + await page.goto('/register'); + await page.click('button[type="submit"]'); + + await expect(page.locator('.bg-red-50')).toContainText('Please fill in all fields'); + }); + + test('can navigate from register to login', async ({ page }) => { + await page.goto('/register'); + await page.getByRole('link', { name: /sign in/i }).click(); + + await expect(page).toHaveURL(/\/login/); + await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible(); + }); + + test('can sign in with credentials and land on dashboard', async ({ page }) => { + // Register first so we have a real account + const email = uniqueEmail(); + await page.goto('/register'); + await page.fill('[placeholder="Full Name"]', 'Login Betty'); + await page.fill('[placeholder="Email"]', email); + await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!'); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL('http://localhost:5173/'); + + // Sign out by clearing the mock session (reload with no session) + await page.goto('/'); + await page.reload(); + + // Now sign in + await page.goto('/login'); + await page.fill('[placeholder="Email"]', email); + await page.fill('[placeholder="Password"]', 'TestPass123!'); + await page.click('button[type="submit"]'); + + await expect(page).toHaveURL('http://localhost:5173/'); + }); + +}); diff --git a/e2e/journeys/j8-unauth-access.spec.ts b/e2e/journeys/j8-unauth-access.spec.ts new file mode 100644 index 0000000..9ed40da --- /dev/null +++ b/e2e/journeys/j8-unauth-access.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from '@playwright/test'; + +test.describe('J8: Unauthenticated Access', () => { + test('redirects /dashboard (/) to /login when not authenticated', async ({ page }) => { + // No session cookie — start fresh + await page.context().clearCookies(); + await page.goto('/'); + + await expect(page).toHaveURL(/\/login/); + await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible(); + }); + + test('redirects /purchases to /login when not authenticated', async ({ page }) => { + await page.context().clearCookies(); + await page.goto('/purchases'); + + await expect(page).toHaveURL(/\/login/); + await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible(); + }); + + test('redirects /products to /login when not authenticated', async ({ page }) => { + await page.context().clearCookies(); + await page.goto('/products'); + + await expect(page).toHaveURL(/\/login/); + await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible(); + }); + + test('redirects /coupons to /login when not authenticated', async ({ page }) => { + await page.context().clearCookies(); + await page.goto('/coupons'); + + await expect(page).toHaveURL(/\/login/); + await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible(); + }); + + test('shows loading spinner while auth session is pending', async ({ page }) => { + // Intercept but don't respond — session stays pending + await page.context().clearCookies(); + await page.request.fetch('/api/auth/session', { + method: 'GET', + }); + + // Just navigate to a protected route — ProtectedRoute will show spinner while session is pending + await page.goto('/purchases'); + // Spinner is visible briefly; once resolved, should redirect to login + await expect(page).toHaveURL(/\/login/, { timeout: 10_000 }); + }); +}); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..2819d15 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,8 @@ +import { test, expect } from './fixtures'; + +test('app loads', async ({ page }) => { + await page.goto('/'); + // Unauthenticated users are redirected to /login + await expect(page).toHaveURL(/\/login/); + await expect(page.getByRole('heading', { name: /CartSnitch/i })).toBeVisible(); +}); diff --git a/lighthouserc.json b/lighthouserc.json new file mode 100644 index 0000000..f85a377 --- /dev/null +++ b/lighthouserc.json @@ -0,0 +1,24 @@ +{ + "ci": { + "collect": { + "staticDistDir": "./dist", + "url": ["http://localhost:4173/"], + "numberOfRuns": 1, + "settings": { + "chromeFlags": ["--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"], + "skipAudits": ["bf-cache"], + "disableFullPageScreenshot": true + } + }, + "assert": { + "assertions": { + "categories:performance": ["warn", { "minScore": 0.7 }], + "categories:accessibility": ["error", { "minScore": 0.9 }], + "categories:best-practices": ["warn", { "minScore": 0.8 }] + } + }, + "upload": { + "target": "temporary-public-storage" + } + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8200b00..04163c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tanstack/react-query": "^5.0.0", "better-auth": "^1.2.0", + "picomatch": "4.0.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.0.0", @@ -17,19 +18,23 @@ "zustand": "^5.0.0" }, "devDependencies": { + "@axe-core/playwright": "^4.10.0", "@eslint/js": "^9.39.4", + "@playwright/test": "^1.58.2", "@tailwindcss/vite": "^4.0.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.2", "@types/node": "^24.12.0", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", - "@vitejs/plugin-react": "^4.5.2", + "@vitejs/plugin-react": "^4.7.0", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", "jsdom": "^25.0.1", + "msw": "^2.12.14", + "playwright": "^1.58.2", "tailwindcss": "^4.0.0", "typescript": "^5.7.3", "typescript-eslint": "^8.56.1", @@ -66,6 +71,19 @@ "devOptional": true, "license": "ISC" }, + "node_modules/@axe-core/playwright": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz", + "integrity": "sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -2474,6 +2492,94 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -2545,6 +2651,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", + "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@noble/ciphers": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", @@ -2569,6 +2693,31 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@opentelemetry/api": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", @@ -2588,6 +2737,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -3292,6 +3457,70 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", @@ -3636,6 +3865,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4134,9 +4370,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -4145,7 +4380,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4273,6 +4508,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axe-core": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.16", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.16.tgz", @@ -4669,6 +4914,49 @@ "node": ">= 16" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4682,7 +4970,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4695,7 +4983,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -5194,6 +5482,13 @@ "dev": true, "license": "ISC" }, + "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==", + "devOptional": true, + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.20.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", @@ -5420,7 +5715,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -5783,9 +6078,9 @@ } }, "node_modules/flatted": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -5930,6 +6225,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -6121,6 +6426,16 @@ "dev": true, "license": "ISC" }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -6215,6 +6530,13 @@ "node": ">= 0.4" } }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -6535,6 +6857,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -6601,6 +6933,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/is-number-object": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", @@ -7467,6 +7806,110 @@ "devOptional": true, "license": "MIT" }, + "node_modules/msw": { + "version": "2.12.14", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.14.tgz", + "integrity": "sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.41.2", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.0.2", + "graphql": "^16.12.0", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.10.1", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^5.2.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/tldts": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.27" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/msw/node_modules/tldts-core": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/msw/node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/msw/node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "devOptional": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -7584,6 +8027,13 @@ "node": ">= 0.8.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -7721,6 +8171,13 @@ "node": "20 || >=22" } }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -7746,10 +8203,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "devOptional": true, + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -7758,6 +8214,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -7860,16 +8363,6 @@ "node": ">=6" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -8134,6 +8627,16 @@ "regjsparser": "bin/parser" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -8181,6 +8684,13 @@ "node": ">=4" } }, + "node_modules/rettime": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", + "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -8259,27 +8769,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -8355,13 +8844,13 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/set-cookie-parser": { @@ -8529,7 +9018,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -8637,6 +9126,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -8658,6 +9157,28 @@ "node": ">= 0.4" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -8760,6 +9281,19 @@ "node": ">=4" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", @@ -8849,6 +9383,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwindcss": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", @@ -9152,7 +9699,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -9279,6 +9826,16 @@ "node": ">= 10.0.0" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/upath": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", @@ -9883,31 +10440,6 @@ "rollup": "^1.20.0 || ^2.0.0" } }, - "node_modules/workbox-build/node_modules/@rollup/pluginutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", - "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "0.0.39", - "estree-walker": "^1.0.1", - "picomatch": "^2.2.2" - }, - "engines": { - "node": ">= 8.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0" - } - }, - "node_modules/workbox-build/node_modules/@types/estree": { - "version": "0.0.39", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", - "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", - "dev": true, - "license": "MIT" - }, "node_modules/workbox-build/node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -9925,13 +10457,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/workbox-build/node_modules/estree-walker": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", - "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", - "dev": true, - "license": "MIT" - }, "node_modules/workbox-build/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -9949,19 +10474,6 @@ "sourcemap-codec": "^1.4.8" } }, - "node_modules/workbox-build/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/workbox-build/node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -10128,6 +10640,21 @@ "workbox-core": "7.4.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", @@ -10167,6 +10694,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -10174,6 +10711,35 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -10187,6 +10753,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index 67e3891..6f11531 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,13 @@ "lint": "eslint .", "preview": "vite preview", "test": "NODE_ENV=test vitest run", - "test:watch": "NODE_ENV=test vitest" + "test:watch": "NODE_ENV=test vitest", + "test:e2e": "npx playwright test" }, "dependencies": { "@tanstack/react-query": "^5.0.0", "better-auth": "^1.2.0", + "picomatch": "4.0.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.0.0", @@ -21,24 +23,33 @@ "zustand": "^5.0.0" }, "devDependencies": { + "@axe-core/playwright": "^4.10.0", "@eslint/js": "^9.39.4", + "@playwright/test": "^1.58.2", "@tailwindcss/vite": "^4.0.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.2", "@types/node": "^24.12.0", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", - "@vitejs/plugin-react": "^4.5.2", + "@vitejs/plugin-react": "^4.7.0", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", "jsdom": "^25.0.1", + "msw": "^2.12.14", + "playwright": "^1.58.2", "tailwindcss": "^4.0.0", "typescript": "^5.7.3", "typescript-eslint": "^8.56.1", "vite": "^6.3.5", "vite-plugin-pwa": "^0.21.2", "vitest": "^3.2.4" + }, + "overrides": { + "@rollup/pluginutils": "5.3.0", + "flatted": "^3.4.2", + "serialize-javascript": "7.0.5" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..b22d74a --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,19 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'VITE_MOCK_AUTH=true npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, + use: { + baseURL: 'http://localhost:5173', + }, +}); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..f1384ca --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://cartsnitch.com/sitemap.xml diff --git a/scripts/seed-dev-job.yaml b/scripts/seed-dev-job.yaml new file mode 100644 index 0000000..2d5cc86 --- /dev/null +++ b/scripts/seed-dev-job.yaml @@ -0,0 +1,61 @@ +# seed-dev-job.yaml +# K8s Job to run the CartSnitch seed runner against the dev database. +# +# Usage: +# kubectl apply -f seed-dev-job.yaml -n cartsnitch-dev +# +# To view logs: +# kubectl logs -n cartsnitch-dev job/seed-dev -f +# +# To re-run after fixing issues: +# kubectl delete -f seed-dev-job.yaml -n cartsnitch-dev && kubectl apply -f seed-dev-job.yaml -n cartsnitch-dev +# +apiVersion: batch/v1 +kind: Job +metadata: + name: seed-dev + namespace: cartsnitch-dev + labels: + app: cartsnitch + component: seed + environment: dev + annotations: + description: "Runs cartsnitch-common seed runner to populate dev database with realistic test data." +spec: + # Prevent retries — a failed seed run should be investigated, not auto-repeated. + backoffLimit: 0 + # Do not run concurrently; sequential runs are safer for truncate+reseed. + concurrencyPolicy: Forbid + template: + metadata: + labels: + app: cartsnitch + component: seed + environment: dev + spec: + restartPolicy: Never + containers: + - name: seed + # Use slim Python image with the cartsnitch-common package installed from git. + # The common repo is public; no additional secret is needed for the pip install. + image: python:3.12-slim + command: + - sh + - -c + - | + pip install --no-cache-dir "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@main" && \ + python -m cartsnitch_common.seed --database-url "$${DATABASE_URL}" + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: cartsnitch-secrets + key: database-url-pg + optional: false + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi diff --git a/scripts/seed-dev.sh b/scripts/seed-dev.sh new file mode 100755 index 0000000..a478015 --- /dev/null +++ b/scripts/seed-dev.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# ============================================================================= +# seed-dev.sh — Run the CartSnitch seed runner against the dev database. +# +# Usage: +# ./seed-dev.sh Run full seed against dev +# ./seed-dev.sh --dry-run Show planned record counts without writing +# ./seed-dev.sh --help Show this help +# +# Prerequisites: +# - kubectl configured for the cartsnitch-dev cluster +# - Namespace cartsnitch-dev exists (CNPG Postgres must be running) +# +# What it does: +# 1. Starts a background port-forward to cartsnitch-pg-rw:5432 +# 2. Waits for the tunnel to be ready +# 3. Runs python -m cartsnitch_common.seed with --database-url pointing +# to localhost:/cartsnitch +# 4. Cleans up the port-forward on exit (normal, interrupt, or error) +# ============================================================================= + +set -euo pipefail + +# --- Config ------------------------------------------------------------------- +readonly NAMESPACE="cartsnitch-dev" +readonly SVC_NAME="cartsnitch-pg-rw" +readonly LOCAL_PORT="5433" # use a non-privileged port to avoid conflicts +readonly DB_NAME="cartsnitch" +readonly PG_USER="cartsnitch" +# Retrieve password from the CNPG credentials secret +readonly PG_PASSWORD="$( + kubectl get secret cartsnitch-pg-credentials \ + -n "$NAMESPACE" \ + -o jsonpath='{.data.password}' \ + | base64 -d +)" +readonly DB_URL="postgresql://${PG_USER}:${PG_PASSWORD}@localhost:${LOCAL_PORT}/${DB_NAME}" + +# --- Helpers ------------------------------------------------------------------ +log() { echo "[seed-dev] $*"; } +fail() { log "ERROR: $*" >&2; exit 1; } + +# Cleanup port-forward and exit. +cleanup() { + if [[ -n "${PF_PID:-}" ]]; then + log "Stopping port-forward (PID $PF_PID)..." + kill "$PF_PID" 2>/dev/null || true + wait "$PF_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +# --- Args --------------------------------------------------------------------- +DRY_RUN="" +HELP_FLAG="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN="--dry-run"; shift ;; + --help) HELP_FLAG="1"; shift ;; + *) fail "Unknown argument: $1";; + esac +done + +if [[ -n "$HELP_FLAG" ]]; then + sed -n '3,/^# ---/p' "$0" | head -n -1 | sed 's/^# //' + echo "" + echo "Additional arguments are passed through to the seed runner." + echo "Common seed-runner options:" + echo " --dry-run Show planned record counts without writing" + echo " --seed N Set random seed (default: 42)" + exit 0 +fi + +# --- Prerequisites ------------------------------------------------------------ +if ! command -v kubectl &>/dev/null; then + fail "kubectl not found — must be installed and configured." +fi + +# --- Port-forward ------------------------------------------------------------- +log "Starting port-forward ${SVC_NAME}:5432 -> localhost:${LOCAL_PORT} ..." +kubectl port-forward \ + -n "$NAMESPACE" \ + svc/"$SVC_NAME" \ + "${LOCAL_PORT}:5432" \ + &>/dev/null & +PF_PID=$! + +# Give the tunnel a moment to establish +sleep 2 + +# Verify the tunnel is up +if ! kill -0 "$PF_PID" 2>/dev/null; then + fail "Port-forward failed to start." +fi +log "Port-forward active (PID $PF_PID) on localhost:${LOCAL_PORT}" + +# --- Seed -------------------------------------------------------------------- +log "Running seed against dev database..." +set -x +python -m cartsnitch_common.seed --database-url "$DB_URL" $DRY_RUN +set +x + +log "Done." diff --git a/src/App.test.tsx b/src/App.test.tsx index 27e040d..4eeddd3 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,23 +1,17 @@ -import { render, screen } from '@testing-library/react' -import { describe, it, expect, vi } from 'vitest' -import App from './App.tsx' - -vi.mock('./lib/auth-client.ts', () => ({ - authClient: { - useSession: () => ({ data: null, isPending: false }), - }, -})) - -describe('App', () => { - it('renders the dashboard on the root route', () => { - render() - expect(screen.getByText('CartSnitch')).toBeInTheDocument() - }) - - it('renders the bottom navigation', () => { - render() - expect(screen.getByText('Home')).toBeInTheDocument() - expect(screen.getByText('Purchases')).toBeInTheDocument() - expect(screen.getByText('Products')).toBeInTheDocument() - }) -}) +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import App from './App.tsx' + +vi.mock('./lib/auth-client.ts', () => ({ + authClient: { + useSession: () => ({ data: null, isPending: false }), + }, +})) + +describe('App', () => { + it('redirects unauthenticated users to login', () => { + render() + expect(screen.getByText('CartSnitch')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument() + }) +}) diff --git a/src/App.tsx b/src/App.tsx index bfd515a..ee4c2dc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,8 +31,8 @@ export default function App() { }> - } /> }> + } /> } /> } /> } /> diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index 6c3df87..cf92831 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -4,12 +4,22 @@ import { authClient } from '../lib/auth-client.ts' import { useAuthStore } from '../stores/auth.ts' export function ProtectedRoute() { + const isMockAuth = import.meta.env.VITE_MOCK_AUTH === 'true' const { data: session, isPending } = authClient.useSession() + const isAuthenticated = useAuthStore((s) => s.isAuthenticated) const setAuthenticated = useAuthStore((s) => s.setAuthenticated) useEffect(() => { - setAuthenticated(!!session) - }, [session, setAuthenticated]) + if (!isMockAuth) { + setAuthenticated(!!session) + } + }, [session, setAuthenticated, isMockAuth]) + + // In mock auth mode, rely on Zustand store (set by Login/Register pages) + if (isMockAuth) { + if (!isAuthenticated) return + return + } if (isPending) { return ( diff --git a/src/hooks/__tests__/useApi.test.tsx b/src/hooks/__tests__/useApi.test.tsx new file mode 100644 index 0000000..6644d12 --- /dev/null +++ b/src/hooks/__tests__/useApi.test.tsx @@ -0,0 +1,45 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { usePurchases } from '../useApi' +import { http, HttpResponse } from 'msw' +import { server } from '../../test/mocks/server' + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) + } +} + +describe('useApi hooks', () => { + describe('usePurchases', () => { + it('fetches and returns purchases', async () => { + const { result } = renderHook(() => usePurchases(), { wrapper: createWrapper() }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data).toHaveLength(1) + expect(result.current.data![0]).toMatchObject({ + id: 'pur_1', + storeName: 'Kroger', + total: 42.5, + }) + }) + + it('returns an error when the endpoint fails', async () => { + server.use( + http.get('/api/v1/purchases', () => HttpResponse.error()), + ) + + const { result } = renderHook(() => usePurchases(), { wrapper: createWrapper() }) + + await waitFor(() => expect(result.current.isError).toBe(true)) + }) + }) +}) diff --git a/src/hooks/useApi.ts b/src/hooks/useApi.ts index 235b5f6..cccd77d 100644 --- a/src/hooks/useApi.ts +++ b/src/hooks/useApi.ts @@ -35,7 +35,7 @@ export function useProduct(id: string) { export function usePriceHistory(productId: string) { return useQuery({ queryKey: ['priceHistory', productId], - queryFn: () => api.get(`/products/${productId}/price-history`), + queryFn: () => api.get(`/products/${productId}/prices`), enabled: !!productId, }) } @@ -50,6 +50,6 @@ export function useCoupons() { export function usePriceAlerts() { return useQuery({ queryKey: ['priceAlerts'], - queryFn: () => api.get('/price-alerts'), + queryFn: () => api.get('/alerts'), }) } diff --git a/src/lib/api.ts b/src/lib/api.ts index 3907dde..1c2b0f8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -15,7 +15,7 @@ const mockRoutes: Record unknown> = { '/purchases': () => mockPurchases, '/products': () => mockProducts, '/coupons': () => mockCoupons, - '/price-alerts': () => mockAlerts, + '/alerts': () => mockAlerts, } function matchMockRoute(path: string): T | null { @@ -30,7 +30,7 @@ function matchMockRoute(path: string): T | null { } // /products/:id/price-history - const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/) + const priceHistoryMatch = path.match(/^\/products\/(.+)\/prices$/) if (priceHistoryMatch) { return getMockPriceHistory(priceHistoryMatch[1]) as T } diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index a6fe18f..70d7a61 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -1,8 +1,36 @@ import { createAuthClient } from "better-auth/react" +import type { BetterFetchPlugin } from "@better-fetch/fetch" + +/** + * Maps 'name' -> 'display_name' in register requests to match the API's RegisterRequest schema. + */ +const displayNameMapper: BetterFetchPlugin = { + id: "display-name-mapper", + name: "display-name-mapper", + hooks: { + onRequest: async (context) => { + const url = typeof context.url === "string" ? context.url : context.url.pathname + if ( + url.endsWith("/auth/register") && + context.method === "POST" && + context.body && + "name" in context.body + ) { + context.body = { + ...context.body, + display_name: context.body.name as string, + name: undefined, + } + } + return context + }, + }, +} export const authClient = createAuthClient({ - baseURL: import.meta.env.VITE_AUTH_URL ?? "http://localhost:3001", + baseURL: import.meta.env.VITE_AUTH_URL || "", basePath: "/auth", + fetchPlugins: [displayNameMapper], }) export const { useSession, signIn, signUp, signOut } = authClient diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index d1e885f..c3f428a 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,13 +1,8 @@ -import React, { Suspense } from 'react' import { Link } from 'react-router-dom' import { authClient } from '../lib/auth-client.ts' -import { usePurchases, usePriceAlerts, usePriceHistory } from '../hooks/useApi.ts' +import { usePurchases, usePriceAlerts } from '../hooks/useApi.ts' import { StoreIcon } from '../components/StoreIcon.tsx' -const LazySparklineCard = React.lazy(() => - import('../components/SparklineChart.tsx').then((mod) => ({ default: mod.SparklineCard })) -) - export function Dashboard() { const { data: session, isPending } = authClient.useSession() @@ -44,19 +39,11 @@ export function Dashboard() { function AuthenticatedDashboard({ userName }: { userName: string }) { const { data: purchases = [], isLoading: purchasesLoading } = usePurchases() const { data: alerts = [], isLoading: alertsLoading } = usePriceAlerts() - const { data: eggHistory = [] } = usePriceHistory('prod10') - const { data: milkHistory = [] } = usePriceHistory('prod1') const triggeredAlerts = alerts.filter((a) => a.triggered) const watchingAlerts = alerts.filter((a) => !a.triggered) const recentPurchases = purchases.slice(0, 3) - const sparklineData = eggHistory.filter((p) => p.storeId === 'meijer').slice(-8) - const milkSparkline = milkHistory.filter((p) => p.storeId === 'kroger').slice(-8) - - const eggCurrent = sparklineData.length > 0 ? `$${sparklineData[sparklineData.length - 1].price.toFixed(2)}` : '—' - const milkCurrent = milkSparkline.length > 0 ? `$${milkSparkline[milkSparkline.length - 1].price.toFixed(2)}` : '—' - if (purchasesLoading || alertsLoading) { return } @@ -106,11 +93,8 @@ function AuthenticatedDashboard({ userName }: { userName: string }) { {/* Price trend sparklines */}

Price Trends

-
- }> - - - +
+ Connect a store to see price trends
@@ -173,6 +157,7 @@ function AuthenticatedDashboard({ userName }: { userName: string }) { function DashboardSkeleton() { return (
+

Loading CartSnitch…

@@ -186,15 +171,3 @@ function DashboardSkeleton() {
) } - -function SparklinePlaceholder() { - return ( -
-
-
-
-
-
-
- ) -} diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 214dcd4..ae7fc0c 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -31,8 +31,14 @@ export function Login() { throw new Error(authError.message ?? 'Sign in failed') } - setAuthenticated(true) - navigate('/') + // After successful signIn, force a session fetch to confirm the cookie is set + // before navigating to the protected route + const sessionResult = await authClient.getSession() + if (sessionResult.data) { + navigate('/') + } else { + setError('Sign in failed. Please try again.') + } } catch { if (import.meta.env.VITE_MOCK_AUTH === 'true') { setAuthenticated(true) @@ -46,7 +52,7 @@ export function Login() { } return ( -
+

CartSnitch

Track prices. Save money.

@@ -88,10 +94,10 @@ export function Login() {

Don't have an account?{' '} - + Sign up

-
+ ) } diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index a65e7b6..c75e2d6 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -38,8 +38,15 @@ export function Register() { throw new Error(authError.message ?? 'Registration failed') } - setAuthenticated(true) - navigate('/') + // After successful signUp, force a session fetch to confirm the cookie is set + // before navigating to the protected route + const sessionResult = await authClient.getSession() + if (sessionResult.data) { + navigate('/') + } else { + // Session not established — show success message and link to login + setError('Account created! Please sign in.') + } } catch { if (import.meta.env.VITE_MOCK_AUTH === 'true') { setAuthenticated(true) diff --git a/src/test/mocks/handlers.ts b/src/test/mocks/handlers.ts new file mode 100644 index 0000000..4282004 --- /dev/null +++ b/src/test/mocks/handlers.ts @@ -0,0 +1,65 @@ +import { http, HttpResponse } from 'msw' +import type { Purchase, Product, Coupon, PriceAlert } from '../../types/api.ts' + +const mockPurchases: Purchase[] = [ + { + id: 'pur_1', + storeId: 'store_1', + storeName: 'Kroger', + date: '2024-01-15', + total: 42.5, + items: [ + { id: 'item_1', productId: 'prod_1', name: 'Milk', quantity: 1, price: 3.99, unitPrice: 3.99 }, + { id: 'item_2', productId: 'prod_2', name: 'Bread', quantity: 2, price: 5.98, unitPrice: 2.99 }, + ], + }, +] + +const mockProducts: Product[] = [ + { + id: 'prod_1', + name: 'Whole Milk', + brand: 'Kroger', + category: 'Dairy', + prices: [{ storeId: 'store_1', storeName: 'Kroger', price: 3.99, lastUpdated: '2024-01-15' }], + }, + { + id: 'prod_2', + name: 'Whole Wheat Bread', + brand: 'Nature\'s Own', + category: 'Bakery', + prices: [{ storeId: 'store_1', storeName: 'Kroger', price: 2.99, lastUpdated: '2024-01-15' }], + }, +] + +const mockCoupons: Coupon[] = [ + { + id: 'coupon_1', + productId: 'prod_1', + storeName: 'Kroger', + description: '$1 off milk', + discount: '$1.00', + expiresAt: '2024-12-31', + code: 'MILK1', + }, +] + +const mockAlerts: PriceAlert[] = [ + { + id: 'alert_1', + productId: 'prod_1', + productName: 'Whole Milk', + targetPrice: 2.99, + currentPrice: 3.99, + triggered: false, + }, +] + +export const handlers = [ + http.get('/api/v1/health', () => HttpResponse.json({ status: 'ok' })), + http.get('/api/v1/purchases', () => HttpResponse.json(mockPurchases)), + http.get('/api/v1/products', () => HttpResponse.json(mockProducts)), + http.get('/api/v1/products/prod_1', () => HttpResponse.json(mockProducts[0])), + http.get('/api/v1/coupons', () => HttpResponse.json(mockCoupons)), + http.get('/api/v1/alerts', () => HttpResponse.json(mockAlerts)), +] diff --git a/src/test/mocks/server.ts b/src/test/mocks/server.ts new file mode 100644 index 0000000..86f7d61 --- /dev/null +++ b/src/test/mocks/server.ts @@ -0,0 +1,4 @@ +import { setupServer } from 'msw/node' +import { handlers } from './handlers' + +export const server = setupServer(...handlers) diff --git a/src/test/setup.ts b/src/test/setup.ts index d0618e8..c6d0755 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1 +1,6 @@ import '@testing-library/jest-dom/vitest' +import { server } from './mocks/server' + +beforeAll(() => server.listen()) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) diff --git a/src/utils/__tests__/formatCurrency.test.ts b/src/utils/__tests__/formatCurrency.test.ts new file mode 100644 index 0000000..cb7cbfa --- /dev/null +++ b/src/utils/__tests__/formatCurrency.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { formatCurrency } from '../formatCurrency'; + +describe('formatCurrency', () => { + it('formats 0 cents as $0.00', () => { + expect(formatCurrency(0)).toBe('$0.00'); + }); + + it('formats 199 cents as $1.99', () => { + expect(formatCurrency(199)).toBe('$1.99'); + }); + + it('formats 10000 cents as $100.00', () => { + expect(formatCurrency(10000)).toBe('$100.00'); + }); + + it('handles negative values', () => { + expect(formatCurrency(-500)).toBe('-$5.00'); + }); + + it('handles large numbers', () => { + expect(formatCurrency(99999999)).toBe('$999,999.99'); + }); + + it('supports custom locale', () => { + expect(formatCurrency(1999, 'de-DE', 'EUR')).toContain('19,99'); + }); + + it('supports custom currency', () => { + const result = formatCurrency(1000, 'en-US', 'EUR'); + expect(result).toContain('10.00'); + }); +}); diff --git a/src/utils/__tests__/formatDate.test.ts b/src/utils/__tests__/formatDate.test.ts new file mode 100644 index 0000000..8e291d4 --- /dev/null +++ b/src/utils/__tests__/formatDate.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { formatDate } from '../formatDate'; + +describe('formatDate', () => { + describe('short style', () => { + it('formats an ISO date string', () => { + const result = formatDate('2024-03-15', 'short'); + expect(result).toMatch(/Mar 15, 2024/); + }); + + it('formats a Date object', () => { + const result = formatDate(new Date('2024-03-15'), 'short'); + expect(result).toMatch(/Mar 15, 2024/); + }); + }); + + describe('long style', () => { + it('formats with weekday and full month name', () => { + const result = formatDate('2024-03-15', 'long'); + expect(result).toMatch(/Friday/); + expect(result).toMatch(/March/); + }); + }); + + describe('relative style', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns "just now" for very recent dates', () => { + const now = new Date('2024-01-01T12:00:00Z'); + vi.setSystemTime(now); + const result = formatDate(new Date('2024-01-01T11:59:59Z'), 'relative'); + expect(result).toBe('just now'); + }); + + it('returns minutes ago', () => { + const now = new Date('2024-01-01T12:00:00Z'); + vi.setSystemTime(now); + const result = formatDate(new Date('2024-01-01T11:45:00Z'), 'relative'); + expect(result).toBe('15m ago'); + }); + + it('returns hours ago', () => { + const now = new Date('2024-01-01T12:00:00Z'); + vi.setSystemTime(now); + const result = formatDate(new Date('2024-01-01T09:00:00Z'), 'relative'); + expect(result).toBe('3h ago'); + }); + + it('returns days ago', () => { + const now = new Date('2024-01-05T12:00:00Z'); + vi.setSystemTime(now); + const result = formatDate(new Date('2024-01-01T12:00:00Z'), 'relative'); + expect(result).toBe('4d ago'); + }); + }); +}); diff --git a/src/utils/__tests__/storeSlugs.test.ts b/src/utils/__tests__/storeSlugs.test.ts new file mode 100644 index 0000000..9a06429 --- /dev/null +++ b/src/utils/__tests__/storeSlugs.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { getStore, getStoreName, STORE_SLUGS } from '../storeSlugs'; + +describe('storeSlugs', () => { + describe('STORE_SLUGS constant', () => { + it('contains meijer, kroger, and target', () => { + expect(STORE_SLUGS).toHaveProperty('meijer'); + expect(STORE_SLUGS).toHaveProperty('kroger'); + expect(STORE_SLUGS).toHaveProperty('target'); + }); + }); + + describe('getStore', () => { + it('returns store data for known slug', () => { + const store = getStore('meijer'); + expect(store).toEqual({ + name: 'Meijer', + color: '#e31837', + icon: '/icons/stores/meijer.svg', + }); + }); + + it('returns null for unknown slug', () => { + expect(getStore('unknown-store')).toBeNull(); + }); + + it('is case insensitive', () => { + expect(getStore('KROGER')).toBeTruthy(); + expect(getStore('Target')).toBeTruthy(); + }); + }); + + describe('getStoreName', () => { + it('returns store name for known slug', () => { + expect(getStoreName('kroger')).toBe('Kroger'); + }); + + it('returns raw slug for unknown store', () => { + expect(getStoreName('unknown-store')).toBe('unknown-store'); + }); + + it('is case insensitive', () => { + expect(getStoreName('TARGET')).toBe('Target'); + }); + }); +}); diff --git a/src/utils/formatCurrency.ts b/src/utils/formatCurrency.ts new file mode 100644 index 0000000..22d63db --- /dev/null +++ b/src/utils/formatCurrency.ts @@ -0,0 +1,10 @@ +export function formatCurrency( + cents: number, + locale = 'en-US', + currency = 'USD' +): string { + return new Intl.NumberFormat(locale, { + style: 'currency', + currency, + }).format(cents / 100); +} diff --git a/src/utils/formatDate.ts b/src/utils/formatDate.ts new file mode 100644 index 0000000..f726cee --- /dev/null +++ b/src/utils/formatDate.ts @@ -0,0 +1,34 @@ +export function formatDate( + date: string | Date, + style: 'short' | 'long' | 'relative' = 'short' +): string { + const d = typeof date === 'string' ? new Date(date) : date; + + if (style === 'short') { + return d.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } + + if (style === 'long') { + return d.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric', + }); + } + + // relative + const diff = Date.now() - d.getTime(); + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} diff --git a/src/utils/storeSlugs.ts b/src/utils/storeSlugs.ts new file mode 100644 index 0000000..4fef82f --- /dev/null +++ b/src/utils/storeSlugs.ts @@ -0,0 +1,13 @@ +export const STORE_SLUGS: Record = { + meijer: { name: 'Meijer', color: '#e31837', icon: '/icons/stores/meijer.svg' }, + kroger: { name: 'Kroger', color: '#0033a0', icon: '/icons/stores/kroger.svg' }, + target: { name: 'Target', color: '#cc0000', icon: '/icons/stores/target.svg' }, +}; + +export function getStore(slug: string) { + return STORE_SLUGS[slug.toLowerCase()] ?? null; +} + +export function getStoreName(slug: string): string { + return getStore(slug)?.name ?? slug; +} diff --git a/vitest.config.ts b/vitest.config.ts index 1dec76f..30c96d0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,5 +7,6 @@ export default defineConfig({ environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], + exclude: ['e2e/**', 'node_modules/**'], }, })