diff --git a/.env.example b/.env.example index 100d27125..f6b6b0008 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,11 @@ DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@splitpro-db:5432 # https://next-auth.js.org/configuration/options#secret NEXTAUTH_SECRET="secret" NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_URL_INTERNAL="http://localhost:3000" + +# Playwright uses only this disposable database and port. +E2E_DATABASE_URL="postgresql://postgres:strong-password@localhost:5432/splitpro_test" +E2E_BASE_URL="http://127.0.0.1:3176" # The default /home page is a blog page that may not be suitable for your use case. # You can change it to /balances or any other URL you want. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1cad5e837..8bf4cb6c8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -258,3 +258,14 @@ library/API documentation. This means you should automatically use the Context7 tools to resolve library id and get library docs without me having to explicitly ask. Do not generate documentation or tests if not explicitly requested. + +## Testing Harness Rules + +- Keep fast checks independent from PostgreSQL integration and Chromium E2E jobs. +- Select unit/component tests with `pnpm test`; select database tests with + `pnpm test:integration`; select browser tests with `pnpm exec playwright test --project=chromium`. +- Integration and E2E commands may only use a disposable local database whose name ends in + `_test`. Never run reset, push, or destructive test setup against shared or production data. +- Read `docs/testing-strategy.md` before changing test selection, CI workflows, or database setup. +- When browser tests fail, inspect `test-results/e2e` and `playwright-report` artifacts before + changing application code. diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml new file mode 100644 index 000000000..cbf143558 --- /dev/null +++ b/.github/workflows/test-e2e.yml @@ -0,0 +1,69 @@ +name: E2E Tests + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + e2e: + name: Chromium E2E tests + runs-on: ubuntu-latest + env: + DATABASE_URL: postgresql://splitpro:test-password@localhost:5432/splitpro_harness_test + E2E_DATABASE_URL: postgresql://splitpro:test-password@localhost:5432/splitpro_harness_test + SKIP_ENV_VALIDATION: '1' + services: + postgres: + image: ossapps/postgres:18.3-trixie + env: + POSTGRES_USER: splitpro + POSTGRES_PASSWORD: test-password + POSTGRES_DB: splitpro_harness_test + options: >- + --health-cmd "pg_isready -U splitpro -d splitpro_harness_test" + --health-interval 2s + --health-timeout 5s + --health-retries 15 + ports: + - 5432:5432 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v5 + with: + run_install: false + + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 22.16.0 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Prepare disposable database + run: pnpm exec prisma migrate deploy + + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Run Chromium E2E tests + run: pnpm test:e2e -- --project=chromium + + - name: Upload Playwright artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-artifacts + path: | + test-results/e2e + playwright-report + if-no-files-found: ignore diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml new file mode 100644 index 000000000..de7ec2897 --- /dev/null +++ b/.github/workflows/test-integration.yml @@ -0,0 +1,55 @@ +name: Integration Tests + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + integration: + name: PostgreSQL integration tests + runs-on: ubuntu-latest + env: + DATABASE_URL: postgresql://splitpro:test-password@localhost:5432/splitpro_harness_test + SKIP_ENV_VALIDATION: '1' + services: + postgres: + image: ossapps/postgres:18.3-trixie + env: + POSTGRES_USER: splitpro + POSTGRES_PASSWORD: test-password + POSTGRES_DB: splitpro_harness_test + options: >- + --health-cmd "pg_isready -U splitpro -d splitpro_harness_test" + --health-interval 2s + --health-timeout 5s + --health-retries 15 + ports: + - 5432:5432 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v5 + with: + run_install: false + + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 22.16.0 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Prepare disposable database + run: pnpm exec prisma migrate deploy + + - name: Run integration tests + run: pnpm test:integration diff --git a/.gitignore b/.gitignore index e2d67e58d..53dc7c661 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ # testing /coverage +/test-results +/playwright/.auth # database /prisma/db.sqlite @@ -60,4 +62,4 @@ SEED_STATISTICS.md # Agents .worktrees/ -docs/superpowers/ \ No newline at end of file +docs/superpowers/ diff --git a/AGENTS.md b/AGENTS.md index b1d556d9c..43339e35d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -323,6 +323,33 @@ Husky runs on commit: Override with `git commit --no-verify` if needed. +## Testing Harness Workflow + +Keep the fast checks (`pnpm prettier --check .`, `pnpm lint`, `pnpm tsgo --noEmit`, +`pnpm test`, and `pnpm build --no-lint`) independent from the disposable database and +browser jobs. CI runs PostgreSQL integration tests with `pnpm test:integration` and Chromium +E2E tests with `pnpm exec playwright test --project=chromium` in separate jobs. + +### Test Selection + +- `pnpm test` selects `src/**/*.{test,spec}.{ts,tsx}` and excludes `src/tests/integration/`. +- `pnpm test:integration` selects only `src/tests/integration/**/*.{test,spec}.{ts,tsx}`. +- `pnpm exec playwright test --project=chromium` selects `tests/e2e/` through + `playwright.config.ts`; setup runs before the Chromium project. +- Run one Jest file with `pnpm test src/tests/simplify.test.ts`, one integration file with + `pnpm test:integration src/tests/integration/expense.integration.test.ts`, or one browser + file with `pnpm exec playwright test tests/e2e/group-expense.spec.ts`. + +### Database Safety + +Integration and E2E databases must be local/disposable and end in `_test`. The integration +harness refuses non-local or non-test URLs; never point these commands at development, +staging, or production data. CI creates a fresh PostgreSQL service and may use +`prisma db push --accept-data-loss` because that database is disposable. Local worktrees +must use a separate PostgreSQL container, database name, and host port. + +See `docs/testing-strategy.md` for the complete command matrix and agent workflow. + Use the `ctx7` CLI to fetch current documentation whenever the user asks about a library, framework, SDK, API, CLI tool, or cloud service -- even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot. This includes API syntax, configuration, version migration, library-specific debugging, setup instructions, and CLI tool usage. Use even when you think you know the answer -- your training data may not reflect recent changes. Prefer this over web search for library docs. diff --git a/docker/test/compose.yml b/docker/test/compose.yml new file mode 100644 index 000000000..040e63e7b --- /dev/null +++ b/docker/test/compose.yml @@ -0,0 +1,22 @@ +name: split-pro-test + +services: + postgres: + image: ossapps/postgres:18.3-trixie + container_name: splitpro-testing-harness-db + environment: + POSTGRES_USER: splitpro + POSTGRES_PASSWORD: test-password + POSTGRES_DB: splitpro_harness_test + command: >- + postgres + -c shared_preload_libraries=pg_cron + -c cron.database_name=splitpro_harness_test + -c cron.timezone=UTC + ports: + - '55439:5432' + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U splitpro -d splitpro_harness_test'] + interval: 2s + timeout: 5s + retries: 15 diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md new file mode 100644 index 000000000..02f5db55d --- /dev/null +++ b/docs/testing-strategy.md @@ -0,0 +1,51 @@ +# Testing strategy + +SplitPro has three deliberately independent test surfaces. Fast checks fail quickly without +requiring PostgreSQL; database and browser checks each provision their own disposable service. + +## Command matrix + +| Purpose | Exact command | Selection | +| -------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| Formatting | `pnpm prettier --check .` | All supported files | +| Lint | `pnpm lint` | Oxlint project sources | +| Types | `pnpm tsgo --noEmit` | TypeScript project | +| Unit/component tests | `pnpm test` | `src/**/*.{test,spec}.{ts,tsx}`, excluding `src/tests/integration/` | +| One unit file | `pnpm test src/tests/simplify.test.ts` | The named file | +| Integration tests | `pnpm test:integration` | `src/tests/integration/**/*.{test,spec}.{ts,tsx}` | +| One integration file | `pnpm test:integration src/tests/integration/expense.integration.test.ts` | The named file | +| Chromium E2E | `pnpm test:e2e -- --project=chromium` | `tests/e2e/`, including setup dependency | +| One E2E file | `pnpm test:e2e -- tests/e2e/group-expense.spec.ts` | The named file | +| Production build | `pnpm build --no-lint` | Next.js production build | + +The pull-request `Check` workflow runs formatting, lint, types, unit/component tests, and the +build. `Integration Tests` and `E2E Tests` are separate jobs and do not depend on `Check` or on +each other. E2E failures upload `test-results/e2e` and `playwright-report`. + +## Database safety + +Integration and E2E tests are destructive by design: they create, update, and delete records. +Use only a local disposable PostgreSQL database whose database name ends in `_test`. The +integration harness also requires a local host (`localhost`, `127.0.0.1`, or `::1`) and refuses +other URLs before tests run. CI creates a new `splitpro_harness_test` service for each job and +prepares it with: + +```bash +pnpm exec prisma migrate deploy +``` + +For local work, copy `.env.example`, use a dedicated container/database/port per worktree, and +set `DATABASE_URL` (and `E2E_DATABASE_URL` for Playwright) to that `_test` database. Do not use +`pnpm db:push`, reset, seed, or these test commands against development, staging, or production. +When the disposable container is no longer needed, stop it with the project’s test-container +workflow; never clean it by deleting data from a shared server. + +## Agent workflow + +1. Read this document and the existing harness/configuration before changing tests. +2. Make the smallest change in the owning packet; keep application, Prisma, Jest, manifest, and + lockfile changes out of CI/documentation work. +3. Run the narrowest affected command first, then `pnpm prettier --check .` and any available + fast checks. Run integration/E2E only with a disposable `_test` database. +4. For E2E failures, preserve and inspect Playwright traces, screenshots, videos, and reports. +5. Report exact commands and failures; do not weaken selection or database guards to make CI pass. diff --git a/jest.config.ts b/jest.config.ts index f45658bc4..d553ddef2 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,214 +1,26 @@ -/** - * For a detailed explanation regarding each configuration property, visit: - * https://jestjs.io/docs/configuration - */ - import type { Config } from 'jest'; import nextJest from 'next/jest.js'; // @ts-expect-error we are extending BigInt prototype for JSON serialization // oxlint-disable-next-line no-extend-native BigInt.prototype.toJSON = function toJSON() { - // Custom JSON serialization for BigInt to avoid errors in Jest return this.toString(); }; const createJestConfig = nextJest({ - // Provide the path to your Next.js app to load next.config.js and .env files in your test environment dir: './', }); const config: Config = { - // All imported modules in your tests should be mocked automatically - // automock: false, - - // Stop running tests after `n` failures - // bail: 0, - - // The directory where Jest should store its cached dependency information - // cacheDirectory: "C:\\Users\\Wiktor\\AppData\\Local\\Temp\\jest", - - // Automatically clear mock calls, instances, contexts and results before every test - // clearMocks: false, - - // Indicates whether the coverage information should be collected while executing the test - // collectCoverage: false, - - // An array of glob patterns indicating a set of files for which coverage information should be collected - // collectCoverageFrom: undefined, - - // The directory where Jest should output its coverage files - // coverageDirectory: undefined, - - // An array of regexp pattern strings used to skip coverage collection - // coveragePathIgnorePatterns: [ - // "\\\\node_modules\\\\" - // ], - - // Indicates which provider should be used to instrument code for coverage coverageProvider: 'v8', - - // A list of reporter names that Jest uses when writing coverage reports - // coverageReporters: [ - // "json", - // "text", - // "lcov", - // "clover" - // ], - - // An object that configures minimum threshold enforcement for coverage results - // coverageThreshold: undefined, - - // A path to a custom dependency extractor - // dependencyExtractor: undefined, - - // Make calling deprecated APIs throw helpful error messages - // errorOnDeprecated: false, - - // The default configuration for fake timers - // fakeTimers: { - // "enableGlobally": false - // }, - - // Force coverage collection from ignored files using an array of glob patterns - // forceCoverageMatch: [], - - // A path to a module which exports an async function that is triggered once before all test suites - // globalSetup: undefined, - - // A path to a module which exports an async function that is triggered once after all test suites - // globalTeardown: undefined, - - // A set of global variables that need to be available in all test environments - // globals: {}, - - // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. - // maxWorkers: "50%", - - // An array of directory names to be searched recursively up from the requiring module's location - // moduleDirectories: [ - // "node_modules" - // ], - - // An array of file extensions your modules use - // moduleFileExtensions: [ - // "js", - // "mjs", - // "cjs", - // "jsx", - // "ts", - // "tsx", - // "json", - // "node" - // ], - - // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module + cacheDirectory: '/node_modules/.cache/jest', moduleNameMapper: { '^~/(.*)$': '/src/$1', }, - - // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader - // modulePathIgnorePatterns: [], - - // Activates notifications for test results - // notify: false, - - // An enum that specifies notification mode. Requires { notify: true } - // notifyMode: "failure-change", - - // A preset that is used as a base for Jest's configuration - // preset: undefined, - - // Run tests from one or more projects - // projects: undefined, - - // Use this configuration option to add custom reporters to Jest - // reporters: undefined, - - // Automatically reset mock state before every test - // resetMocks: false, - - // Reset the module registry before running each individual test - // resetModules: false, - - // A path to a custom resolver - // resolver: undefined, - - // Automatically restore mock state and implementation before every test - // restoreMocks: false, - - // The root directory that Jest should scan for tests and modules within - // rootDir: undefined, - - // A list of paths to directories that Jest should use to search for files in - // roots: [ - // "" - // ], - - // Allows you to use a custom runner instead of Jest's default test runner - // runner: "jest-runner", - - // The paths to modules that run some code to configure or set up the testing environment before each test - // setupFiles: [], - - // A list of paths to modules that run some code to configure or set up the testing framework before each test - // setupFilesAfterEnv: [], - - // The number of seconds after which a test is considered as slow and reported as such in the results. - // slowTestThreshold: 5, - - // A list of paths to snapshot serializer modules Jest should use for snapshot testing - // snapshotSerializers: [], - - // The test environment that will be used for testing + setupFilesAfterEnv: ['/src/tests/setup/component.ts'], testEnvironment: 'jsdom', - - // Options that will be passed to the testEnvironment - // testEnvironmentOptions: {}, - - // Adds a location field to test results - // testLocationInResults: false, - - // The glob patterns Jest uses to detect test files - // testMatch: [ - // "**/__tests__/**/*.[jt]s?(x)", - // "**/?(*.)+(spec|test).[tj]s?(x)" - // ], - - // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped - // testPathIgnorePatterns: [ - // "\\\\node_modules\\\\" - // ], - - // The regexp pattern or array of patterns that Jest uses to detect test files - // testRegex: [], - - // This option allows the use of a custom results processor - // testResultsProcessor: undefined, - - // This option allows use of a custom test runner - // testRunner: "jest-circus/runner", - - // A map from regular expressions to paths to transformers - // transform: undefined, - - // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation - // transformIgnorePatterns: [ - // "\\\\node_modules\\\\", - // "\\.pnp\\.[^\\\\]+$" - // ], - - // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them - // unmockedModulePathPatterns: undefined, - - // Indicates whether each individual test should be reported during the run - // verbose: undefined, - - // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode - // watchPathIgnorePatterns: [], - - // Whether to use watchman for file crawling - // watchman: true, + testMatch: ['/src/**/*.{test,spec}.{ts,tsx}'], + testPathIgnorePatterns: ['/src/tests/integration/'], }; export default createJestConfig(config); diff --git a/jest.integration.config.ts b/jest.integration.config.ts new file mode 100644 index 000000000..77f73212f --- /dev/null +++ b/jest.integration.config.ts @@ -0,0 +1,20 @@ +import type { Config } from 'jest'; +import nextJest from 'next/jest.js'; + +const createJestConfig = nextJest({ + dir: './', +}); + +const config: Config = { + coverageProvider: 'v8', + cacheDirectory: '/node_modules/.cache/jest', + moduleNameMapper: { + '^~/(.*)$': '/src/$1', + }, + setupFilesAfterEnv: ['/src/tests/setup/integration.ts'], + testEnvironment: 'node', + testMatch: ['/src/tests/integration/**/*.{test,spec}.{ts,tsx}'], + maxWorkers: 1, +}; + +export default createJestConfig(config); diff --git a/package.json b/package.json index 8ef2a1e14..4c995b3e9 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,11 @@ "dx": "pnpm i && pnpm dx:up && pnpm db:dev", "dx:up": "docker compose -f docker/dev/compose.yml --env-file .env up -d", "dx:down": "docker compose -f docker/dev/compose.yml down", - "test": "jest", + "test": "jest --config jest.config.ts", "test:watch": "jest --watch", + "test:integration": "jest --config jest.integration.config.ts", + "test:integration:watch": "jest --config jest.integration.config.ts --watch", + "test:e2e": "PWTEST_CACHE_DIR=node_modules/.cache/playwright playwright test", "prepare": "husky" }, "dependencies": { @@ -79,9 +82,13 @@ }, "devDependencies": { "@faker-js/faker": "10.1.0", + "@playwright/test": "^1.55.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@tailwindcss/postcss": "^4.1.10", "@types/formidable": "^3.4.6", - "@types/jest": "^29.5.14", + "@types/jest": "^30.0.0", "@types/node": "^22.15.21", "@types/nodemailer": "^8.0.0", "@types/react": "19.2.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..9f1dc1ba1 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,75 @@ +import { defineConfig, devices } from '@playwright/test'; + +const defaultPort = 3176; +const databaseUrl = + process.env.E2E_DATABASE_URL ?? + 'postgresql://postgres:strong-password@localhost:5432/splitpro_test'; +const database = new URL(databaseUrl); +const baseUrl = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${defaultPort}`; +const baseUrlDetails = new URL(baseUrl); + +if ( + !['localhost', '127.0.0.1', '::1'].includes(database.hostname) || + !database.pathname.endsWith('_test') +) { + throw new Error('E2E_DATABASE_URL must point at a local disposable *_test database'); +} +if (!['localhost', '127.0.0.1', '::1'].includes(baseUrlDetails.hostname)) { + throw new Error('E2E_BASE_URL must point at a local test server'); +} + +const port = Number(baseUrlDetails.port) || 80; + +process.env.DATABASE_URL = databaseUrl; +process.env.TEST_MODE = '1'; +process.env.NEXTAUTH_SECRET ??= 'playwright-test-secret'; +process.env.NEXTAUTH_URL = baseUrl; +process.env.NEXTAUTH_URL_INTERNAL = baseUrl; +process.env.SKIP_ENV_VALIDATION = '1'; +process.env.ENABLE_SENDING_INVITES = '0'; +process.env.DISABLE_EMAIL_SIGNUP = '0'; +process.env.INVITE_ONLY = '0'; + +export default defineConfig({ + testDir: './tests/e2e', + outputDir: 'test-results/e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? 'line' : 'list', + use: { + ...devices['Desktop Chrome'], + baseURL: baseUrl, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'on-first-retry', + }, + projects: [ + { name: 'setup', testMatch: /.*\.setup\.ts/ }, + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' }, + dependencies: ['setup'], + }, + ], + webServer: { + command: `pnpm dev --port ${port}`, + url: baseUrl, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + ...process.env, + DATABASE_URL: databaseUrl, + NODE_ENV: 'test', + TEST_MODE: '1', + NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET ?? 'playwright-test-secret', + NEXTAUTH_URL: baseUrl, + NEXTAUTH_URL_INTERNAL: baseUrl, + SKIP_ENV_VALIDATION: '1', + ENABLE_SENDING_INVITES: '0', + DISABLE_EMAIL_SIGNUP: '0', + INVITE_ONLY: '0', + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b86928770..e97bff27d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,13 +36,13 @@ importers: version: 13.8.0(react@19.2.1) '@next-auth/prisma-adapter': specifier: ^1.0.7 - version: 1.0.7(@prisma/client@6.19.1(prisma@6.19.3(typescript@5.7.3))(typescript@5.7.3))(next-auth@4.24.14(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)) + version: 1.0.7(@prisma/client@6.19.1(prisma@6.19.3(typescript@5.7.3))(typescript@5.7.3))(next-auth@4.24.14(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)) '@prisma/client': specifier: ^6.16.2 version: 6.19.1(prisma@6.19.3(typescript@5.7.3))(typescript@5.7.3) '@serwist/next': specifier: ^9.2.1 - version: 9.2.3(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.7.3) + version: 9.2.3(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.7.3) '@t3-oss/env-nextjs': specifier: ^0.13.8 version: 0.13.10(typescript@5.7.3)(zod@3.25.76) @@ -54,7 +54,7 @@ importers: version: 11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3) '@trpc/next': specifier: ^11.2.0 - version: 11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/react-query@11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) + version: 11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/react-query@11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) '@trpc/react-query': specifier: ^11.2.0 version: 11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) @@ -108,13 +108,13 @@ importers: version: 5.1.6 next: specifier: 15.5.18 - version: 15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) next-auth: specifier: ^4.24.14 - version: 4.24.14(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 4.24.14(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) next-i18next: specifier: ^15.4.3 - version: 15.4.3(i18next@25.10.10(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-i18next@15.7.4(i18next@25.10.10(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(react@19.2.1) + version: 15.4.3(i18next@25.10.10(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-i18next@15.7.4(i18next@25.10.10(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(react@19.2.1) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -176,15 +176,27 @@ importers: '@faker-js/faker': specifier: 10.1.0 version: 10.1.0 + '@playwright/test': + specifier: ^1.55.0 + version: 1.62.1 '@tailwindcss/postcss': specifier: ^4.1.10 version: 4.1.18 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.10.0(@testing-library/dom@10.4.1) + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.4(@testing-library/dom@10.4.1) '@types/formidable': specifier: ^3.4.6 version: 3.4.6 '@types/jest': - specifier: ^29.5.14 - version: 29.5.14 + specifier: ^30.0.0 + version: 30.0.0 '@types/node': specifier: ^22.15.21 version: 22.19.3 @@ -257,6 +269,9 @@ importers: packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -859,10 +874,6 @@ packages: resolution: {integrity: sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/expect-utils@29.7.0': - resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jest/expect-utils@30.3.0': resolution: {integrity: sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -896,10 +907,6 @@ packages: node-notifier: optional: true - '@jest/schemas@29.6.3': - resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jest/schemas@30.0.5': resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -924,10 +931,6 @@ packages: resolution: {integrity: sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - '@jest/types@29.6.3': - resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - '@jest/types@30.3.0': resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1176,6 +1179,11 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@prisma/client@6.19.1': resolution: {integrity: sha512-4SXj4Oo6HyQkLUWT8Ke5R0PTAfVOKip5Roo+6+b2EDTkFg5be0FnBWiuRJc0BC0sRQIWGMLKW1XguhVfW/z3/A==} engines: {node: '>=18.18'} @@ -1986,9 +1994,6 @@ packages: typescript: optional: true - '@sinclair/typebox@0.27.8': - resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} - '@sinclair/typebox@0.34.40': resolution: {integrity: sha512-gwBNIP8ZAYev/ORDWW0QvxdwPXwxBtLsdsJgSc7eDIRt8ubP+rxUBzPsrwnu16fgEF8Bx4lh/+mvQvJzcTM6Kw==} @@ -2134,6 +2139,38 @@ packages: peerDependencies: react: ^18 || ^19 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.10.0': + resolution: {integrity: sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + deprecated: Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0. + peerDependencies: + '@testing-library/dom': '>=10 <11' + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.4': + resolution: {integrity: sha512-QCGwP6QrjypBLwyj5cuyfVamkaIEy/XGY+1VDehbtbQqOggYmTFpFOdWR5mPz14vX8vXLMVjDHlRNBcClyO9ew==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@trpc/client@11.8.0': resolution: {integrity: sha512-imJQeESX1hAapDaC4JB91yvXg41AZfBuTh/scnEiN/hAubZa5s/ikp0n+w29q2GCf+hREkr3WptUFKFJoDAIug==} peerDependencies: @@ -2187,6 +2224,9 @@ packages: '@tybys/wasm-util@0.10.0': resolution: {integrity: sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2214,8 +2254,8 @@ packages: '@types/istanbul-reports@3.0.4': resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} - '@types/jest@29.5.14': - resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + '@types/jest@30.0.0': + resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} '@types/jsdom@21.1.7': resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} @@ -2444,6 +2484,13 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} @@ -2497,10 +2544,6 @@ packages: brace-expansion@2.0.3: resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - browser-image-compression@2.0.2: resolution: {integrity: sha512-pBLlQyUf6yB8SmmngrcOw3EoS4RpQ1BcylI3T9Yqn7+4nrQTXJD4sJDe5ODnJdrvNMaio5OicFo75rDyJD2Ucw==} @@ -2561,10 +2604,6 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - ci-info@3.9.0: - resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} - engines: {node: '>=8'} - ci-info@4.3.0: resolution: {integrity: sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==} engines: {node: '>=8'} @@ -2671,6 +2710,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssstyle@4.6.0: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} @@ -2726,6 +2768,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} @@ -2743,14 +2789,16 @@ packages: dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - diff@4.0.4: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dotenv@10.0.0: resolution: {integrity: sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==} engines: {node: '>=10'} @@ -2852,10 +2900,6 @@ packages: resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} engines: {node: '>= 0.8.0'} - expect@29.7.0: - resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - expect@30.3.0: resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2873,10 +2917,6 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -2905,6 +2945,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3050,6 +3095,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -3078,10 +3127,6 @@ packages: resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} engines: {node: '>=6'} - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -3152,10 +3197,6 @@ packages: ts-node: optional: true - jest-diff@29.7.0: - resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-diff@30.3.0: resolution: {integrity: sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -3181,10 +3222,6 @@ packages: resolution: {integrity: sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-get-type@29.6.3: - resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-haste-map@30.3.0: resolution: {integrity: sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -3193,18 +3230,10 @@ packages: resolution: {integrity: sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-matcher-utils@29.7.0: - resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-matcher-utils@30.3.0: resolution: {integrity: sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-message-util@29.7.0: - resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-message-util@30.3.0: resolution: {integrity: sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -3246,10 +3275,6 @@ packages: resolution: {integrity: sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jest-util@29.7.0: - resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - jest-util@30.3.0: resolution: {integrity: sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -3438,6 +3463,10 @@ packages: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} engines: {node: '>=12'} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3458,10 +3487,6 @@ packages: merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -3478,6 +3503,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -3723,6 +3752,16 @@ packages: resolution: {integrity: sha512-5CxHxiTEJUEohvxOhfNclWIrIKJYN6pODBoF7X6Iwr9nYYUsk2XsYzOMUMBybAFRTcanU2C2F5STPmsNxgT0PQ==} engines: {node: '>=10.0.0'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} @@ -3809,9 +3848,9 @@ packages: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} - pretty-format@29.7.0: - resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} pretty-format@3.8.0: resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} @@ -3905,6 +3944,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -3962,6 +4004,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -4113,6 +4159,10 @@ packages: resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} engines: {node: '>=6'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -4177,10 +4227,6 @@ packages: tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - tough-cookie@5.1.2: resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} engines: {node: '>=16'} @@ -4430,6 +4476,8 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + '@alloc/quick-lru@5.2.0': {} '@ampproject/remapping@2.3.0': @@ -4970,10 +5018,6 @@ snapshots: '@types/node': 22.19.3 jest-mock: 30.3.0 - '@jest/expect-utils@29.7.0': - dependencies: - jest-get-type: 29.6.3 - '@jest/expect-utils@30.3.0': dependencies: '@jest/get-type': 30.1.0 @@ -5038,10 +5082,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@jest/schemas@29.6.3': - dependencies: - '@sinclair/typebox': 0.27.8 - '@jest/schemas@30.0.5': dependencies: '@sinclair/typebox': 0.34.40 @@ -5092,15 +5132,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@jest/types@29.6.3': - dependencies: - '@jest/schemas': 29.6.3 - '@types/istanbul-lib-coverage': 2.0.6 - '@types/istanbul-reports': 3.0.4 - '@types/node': 22.19.3 - '@types/yargs': 17.0.33 - chalk: 4.1.2 - '@jest/types@30.3.0': dependencies: '@jest/pattern': 30.0.1 @@ -5144,10 +5175,10 @@ snapshots: '@tybys/wasm-util': 0.10.0 optional: true - '@next-auth/prisma-adapter@1.0.7(@prisma/client@6.19.1(prisma@6.19.3(typescript@5.7.3))(typescript@5.7.3))(next-auth@4.24.14(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))': + '@next-auth/prisma-adapter@1.0.7(@prisma/client@6.19.1(prisma@6.19.3(typescript@5.7.3))(typescript@5.7.3))(next-auth@4.24.14(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))': dependencies: '@prisma/client': 6.19.1(prisma@6.19.3(typescript@5.7.3))(typescript@5.7.3) - next-auth: 4.24.14(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next-auth: 4.24.14(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@next/env@15.5.18': {} @@ -5263,6 +5294,10 @@ snapshots: '@pkgr/core@0.2.9': {} + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@prisma/client@6.19.1(prisma@6.19.3(typescript@5.7.3))(typescript@5.7.3)': optionalDependencies: prisma: 6.19.3(typescript@5.7.3) @@ -6108,14 +6143,14 @@ snapshots: optionalDependencies: typescript: 5.7.3 - '@serwist/next@9.2.3(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.7.3)': + '@serwist/next@9.2.3(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(typescript@5.7.3)': dependencies: '@serwist/build': 9.2.3(typescript@5.7.3) '@serwist/webpack-plugin': 9.2.3(typescript@5.7.3) '@serwist/window': 9.2.3(typescript@5.7.3) chalk: 5.6.2 glob: 10.5.0 - next: 15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) serwist: 9.2.3(typescript@5.7.3) zod: 4.1.12 optionalDependencies: @@ -6138,8 +6173,6 @@ snapshots: optionalDependencies: typescript: 5.7.3 - '@sinclair/typebox@0.27.8': {} - '@sinclair/typebox@0.34.40': {} '@sinonjs/commons@3.0.1': @@ -6244,16 +6277,51 @@ snapshots: '@tanstack/query-core': 5.90.12 react: 19.2.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.10.0(@testing-library/dom@10.4.1)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.1 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + optionalDependencies: + '@types/react': 19.2.1 + '@types/react-dom': 19.2.1(@types/react@19.2.1) + + '@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3)': dependencies: '@trpc/server': 11.8.0(typescript@5.7.3) typescript: 5.7.3 - '@trpc/next@11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/react-query@11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3)': + '@trpc/next@11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/react-query@11.8.0(@tanstack/react-query@5.90.12(react@19.2.1))(@trpc/client@11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(@trpc/server@11.8.0(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3)': dependencies: '@trpc/client': 11.8.0(@trpc/server@11.8.0(typescript@5.7.3))(typescript@5.7.3) '@trpc/server': 11.8.0(typescript@5.7.3) - next: 15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 react-dom: 19.2.1(react@19.2.1) typescript: 5.7.3 @@ -6287,6 +6355,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.3 @@ -6327,10 +6397,10 @@ snapshots: dependencies: '@types/istanbul-lib-report': 3.0.3 - '@types/jest@29.5.14': + '@types/jest@30.0.0': dependencies: - expect: 29.7.0 - pretty-format: 29.7.0 + expect: 30.3.0 + pretty-format: 30.3.0 '@types/jsdom@21.1.7': dependencies: @@ -6505,6 +6575,12 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + asap@2.0.6: {} asn1.js@5.4.1: @@ -6588,10 +6664,6 @@ snapshots: dependencies: balanced-match: 1.0.2 - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - browser-image-compression@2.0.2: dependencies: uzip: 0.20201231.0 @@ -6653,8 +6725,6 @@ snapshots: dependencies: readdirp: 4.1.2 - ci-info@3.9.0: {} - ci-info@4.3.0: {} citty@0.1.6: @@ -6752,6 +6822,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css.escape@1.5.1: {} + cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -6786,6 +6858,8 @@ snapshots: delayed-stream@1.0.0: {} + dequal@2.0.3: {} + destr@2.0.5: {} detect-libc@2.1.2: {} @@ -6799,10 +6873,12 @@ snapshots: asap: 2.0.6 wrappy: 1.0.2 - diff-sequences@29.6.3: {} - diff@4.0.4: {} + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dotenv@10.0.0: {} dotenv@16.6.1: {} @@ -6915,14 +6991,6 @@ snapshots: exit-x@0.2.2: {} - expect@29.7.0: - dependencies: - '@jest/expect-utils': 29.7.0 - jest-get-type: 29.6.3 - jest-matcher-utils: 29.7.0 - jest-message-util: 29.7.0 - jest-util: 29.7.0 - expect@30.3.0: dependencies: '@jest/expect-utils': 30.3.0 @@ -6944,10 +7012,6 @@ snapshots: dependencies: bser: 2.1.1 - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - find-up@4.1.0: dependencies: locate-path: 5.0.0 @@ -6976,6 +7040,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -7123,6 +7190,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -7145,8 +7214,6 @@ snapshots: is-generator-fn@2.1.0: {} - is-number@7.0.0: {} - is-potential-custom-element-name@1.0.1: {} is-stream@2.0.1: {} @@ -7275,13 +7342,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-diff@29.7.0: - dependencies: - chalk: 4.1.2 - diff-sequences: 29.6.3 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - jest-diff@30.3.0: dependencies: '@jest/diff-sequences': 30.3.0 @@ -7321,8 +7381,6 @@ snapshots: jest-util: 30.3.0 jest-validate: 30.3.0 - jest-get-type@29.6.3: {} - jest-haste-map@30.3.0: dependencies: '@jest/types': 30.3.0 @@ -7343,13 +7401,6 @@ snapshots: '@jest/get-type': 30.1.0 pretty-format: 30.3.0 - jest-matcher-utils@29.7.0: - dependencies: - chalk: 4.1.2 - jest-diff: 29.7.0 - jest-get-type: 29.6.3 - pretty-format: 29.7.0 - jest-matcher-utils@30.3.0: dependencies: '@jest/get-type': 30.1.0 @@ -7357,18 +7408,6 @@ snapshots: jest-diff: 30.3.0 pretty-format: 30.3.0 - jest-message-util@29.7.0: - dependencies: - '@babel/code-frame': 7.27.1 - '@jest/types': 29.6.3 - '@types/stack-utils': 2.0.3 - chalk: 4.1.2 - graceful-fs: 4.2.11 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - stack-utils: 2.0.6 - jest-message-util@30.3.0: dependencies: '@babel/code-frame': 7.27.1 @@ -7491,15 +7530,6 @@ snapshots: transitivePeerDependencies: - supports-color - jest-util@29.7.0: - dependencies: - '@jest/types': 29.6.3 - '@types/node': 22.19.3 - chalk: 4.1.2 - ci-info: 3.9.0 - graceful-fs: 4.2.11 - picomatch: 4.0.4 - jest-util@30.3.0: dependencies: '@jest/types': 30.3.0 @@ -7710,6 +7740,8 @@ snapshots: luxon@3.7.2: {} + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -7728,11 +7760,6 @@ snapshots: merge-stream@2.0.0: {} - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 4.0.4 - mime-db@1.52.0: {} mime-types@2.1.35: @@ -7743,6 +7770,8 @@ snapshots: mimic-function@5.0.1: {} + min-indent@1.0.1: {} + minimalistic-assert@1.0.1: {} minimatch@9.0.9: @@ -7763,13 +7792,13 @@ snapshots: natural-compare@1.4.0: {} - next-auth@4.24.14(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + next-auth@4.24.14(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(nodemailer@9.0.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: '@babel/runtime': 7.29.2 '@panva/hkdf': 1.2.1 cookie: 0.7.2 jose: 4.15.9 - next: 15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) oauth: 0.9.15 openid-client: 5.7.1 preact: 10.28.3 @@ -7780,7 +7809,7 @@ snapshots: optionalDependencies: nodemailer: 9.0.1 - next-i18next@15.4.3(i18next@25.10.10(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-i18next@15.7.4(i18next@25.10.10(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(react@19.2.1): + next-i18next@15.4.3(i18next@25.10.10(typescript@5.7.3))(next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-i18next@15.7.4(i18next@25.10.10(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3))(react@19.2.1): dependencies: '@babel/runtime': 7.28.4 '@types/hoist-non-react-statics': 3.3.6 @@ -7788,7 +7817,7 @@ snapshots: hoist-non-react-statics: 3.3.2 i18next: 25.10.10(typescript@5.7.3) i18next-fs-backend: 2.6.0 - next: 15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + next: 15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 react-i18next: 15.7.4(i18next@25.10.10(typescript@5.7.3))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.7.3) @@ -7797,7 +7826,7 @@ snapshots: react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - next@15.5.18(@babel/core@7.28.3)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + next@15.5.18(@babel/core@7.28.3)(@playwright/test@1.62.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: '@next/env': 15.5.18 '@swc/helpers': 0.5.15 @@ -7815,6 +7844,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.18 '@next/swc-win32-arm64-msvc': 15.5.18 '@next/swc-win32-x64-msvc': 15.5.18 + '@playwright/test': 1.62.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -7982,6 +8012,14 @@ snapshots: transitivePeerDependencies: - debug + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.4.31: dependencies: nanoid: 3.3.11 @@ -8009,11 +8047,11 @@ snapshots: pretty-bytes@6.1.1: {} - pretty-format@29.7.0: + pretty-format@27.5.1: dependencies: - '@jest/schemas': 29.6.3 + ansi-regex: 5.0.1 ansi-styles: 5.2.0 - react-is: 18.3.1 + react-is: 17.0.2 pretty-format@3.8.0: {} @@ -8149,6 +8187,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.3.1: {} react-plaid-link@4.1.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1): @@ -8199,6 +8239,11 @@ snapshots: readdirp@4.1.2: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + require-directory@2.1.1: {} resolve-cwd@3.0.0: @@ -8352,6 +8397,10 @@ snapshots: strip-final-newline@2.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} styled-jsx@5.1.6(@babel/core@7.28.3)(react@19.2.1): @@ -8401,10 +8450,6 @@ snapshots: tmpl@1.0.5: {} - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - tough-cookie@5.1.2: dependencies: tldts: 6.1.86 diff --git a/src/components/Friend/Settleup.test.tsx b/src/components/Friend/Settleup.test.tsx new file mode 100644 index 000000000..c9e0b4284 --- /dev/null +++ b/src/components/Friend/Settleup.test.tsx @@ -0,0 +1,81 @@ +import { SplitType } from '@prisma/client'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithProviders } from '~/tests/helpers/render'; +import { resetStores } from '~/tests/helpers/resetStores'; +import type { MinimalBalance } from '~/types/balance.types'; + +const mutate = jest.fn(); +const invalidate = jest.fn().mockResolvedValue(undefined); + +jest.mock('~/utils/api', () => ({ + api: { + expense: { addOrEditExpense: { useMutation: () => ({ mutate }) } }, + useUtils: () => ({ user: { invalidate }, expense: { invalidate } }), + }, +})); +jest.mock('next-auth/react', () => ({ + SessionProvider: ({ children }: React.PropsWithChildren) => children, + useSession: () => ({ + data: { user: { id: 1, name: 'Alex Example', email: 'alex@example.com', image: null } }, + status: 'authenticated', + }), +})); +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => + ({ + 'actions.save': 'Save', + 'actions.settle_up': 'Settle up', + 'ui.settle_up_name': 'Settlement', + 'ui.select_balance': 'Select balance', + 'actors.you': 'You', + 'ui.expense.you.pay': 'pay', + 'ui.expense.user.pay': 'pays', + })[key] ?? key, + i18n: { language: 'en' }, + }), +})); + +const { SettleUp } = require('./Settleup') as typeof import('./Settleup'); + +const friend = { id: 2, name: 'Sam Friend', email: 'sam@example.com', image: null } as never; +const balance = { + currency: 'USD', + amount: -1250n, + friendId: 2, + groupId: 7, + groupName: 'Trip', +} satisfies MinimalBalance; + +describe('SettleUp', () => { + afterEach(resetStores); + + beforeEach(() => mutate.mockClear()); + + it('submits a settlement with the correct BigInt direction', async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Settle up' })); + await user.click(await screen.findByRole('button', { name: 'Save' })); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(mutate.mock.calls[0]?.[0]).toMatchObject({ + amount: 1250n, + splitType: SplitType.SETTLEMENT, + paidBy: 1, + groupId: 7, + participants: [ + { userId: 1, amount: 1250n }, + { userId: 2, amount: -1250n }, + ], + }); + }); +}); diff --git a/src/components/Layout/MainLayout.test.tsx b/src/components/Layout/MainLayout.test.tsx new file mode 100644 index 000000000..d09e9c97d --- /dev/null +++ b/src/components/Layout/MainLayout.test.tsx @@ -0,0 +1,42 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; + +import { renderWithProviders } from '~/tests/helpers/render'; +import { createMockRouter } from '~/tests/helpers/router'; +import { resetStores } from '~/tests/helpers/resetStores'; + +const mockRouter = createMockRouter({ pathname: '/groups/7' }); +jest.mock('next/router', () => ({ useRouter: () => mockRouter })); +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => + ({ + 'meta.application_name': 'SplitPro', + 'navigation.balances': 'Balances', + 'navigation.groups': 'Groups', + 'navigation.add_expense': 'Add Expense', + 'navigation.add': 'Add', + 'navigation.activity': 'Activity', + 'navigation.account': 'Account', + })[key] ?? key, + ready: true, + i18n: { language: 'en' }, + }), +})); + +const { default: MainLayout } = require('./MainLayout') as typeof import('./MainLayout'); + +it('renders navigation links and marks the active section', () => { + renderWithProviders( + +

Content

+
, + ); + + expect(screen.getAllByRole('link', { name: 'Groups' }).length).toBeGreaterThan(0); + expect(screen.getAllByRole('link', { name: 'Balances' }).length).toBeGreaterThan(0); + expect(screen.getByText('Content')).toBeInTheDocument(); + expect(screen.getAllByRole('link', { name: 'Groups' })[0]).toHaveAttribute('href', '/groups'); +}); + +afterEach(resetStores); diff --git a/src/components/group/CreateGroup.test.tsx b/src/components/group/CreateGroup.test.tsx new file mode 100644 index 000000000..e357c4cc1 --- /dev/null +++ b/src/components/group/CreateGroup.test.tsx @@ -0,0 +1,81 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithProviders } from '~/tests/helpers/render'; +import { resetStores } from '~/tests/helpers/resetStores'; + +const mutateAsync = jest.fn(); +const refetch = jest.fn().mockResolvedValue(undefined); +const push = jest.fn().mockResolvedValue(true); + +jest.mock('next/router', () => ({ useRouter: () => ({ push }) })); +jest.mock('~/utils/api', () => ({ + api: { + group: { create: { useMutation: () => ({ mutateAsync }) } }, + useUtils: () => ({ group: { getAllGroupsWithBalances: { refetch } } }), + }, +})); +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => + ({ + 'actions.cancel': 'Cancel', + 'actions.submit': 'Submit', + 'errors.name_required': 'Name is required', + 'group_details.create_group.title': 'Create group', + 'group_details.create_group.group_name_placeholder': 'Group name', + })[key] ?? key, + ready: true, + i18n: { language: 'en' }, + }), +})); + +const { CreateGroup } = require('./CreateGroup') as typeof import('./CreateGroup'); + +describe('CreateGroup', () => { + afterEach(resetStores); + + beforeEach(() => { + mutateAsync.mockReset().mockImplementation(async (_input, options) => { + options?.onSuccess?.({ id: 42 }); + return { id: 42 }; + }); + refetch.mockClear(); + push.mockClear().mockResolvedValue(true); + }); + + it('validates an empty name and submits the accessible form', async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'New group' })); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(await screen.findByText('Name is required')).toBeInTheDocument(); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it('creates the group, refreshes groups, and navigates to it', async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'New group' })); + await user.type(await screen.findByPlaceholderText('Group name'), 'Trip'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => + expect(mutateAsync).toHaveBeenCalledWith({ name: 'Trip' }, expect.anything()), + ); + expect(refetch).toHaveBeenCalled(); + expect(push).toHaveBeenCalledWith('/groups/42'); + }); +}); diff --git a/src/env.ts b/src/env.ts index 11c46a0e0..40c73db8e 100644 --- a/src/env.ts +++ b/src/env.ts @@ -17,6 +17,12 @@ export const env = createEnv({ 'You forgot to change the default URL', ), NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + TEST_MODE: z + .boolean() + .default(false) + .refine((testMode) => !testMode || 'production' !== process.env.NODE_ENV, { + message: 'TEST_MODE cannot be enabled in production', + }), DOCKER_OUTPUT: z.boolean().default(false), NEXTAUTH_SECRET: 'production' === process.env.NODE_ENV ? z.string() : z.string().optional(), NEXTAUTH_URL: z.preprocess( @@ -96,6 +102,7 @@ export const env = createEnv({ process.env.DATABASE_URL ?? `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOST}:${process.env.POSTGRES_PORT}`, NODE_ENV: process.env.NODE_ENV, + TEST_MODE: parseEnvBoolean(process.env.TEST_MODE), DOCKER_OUTPUT: parseEnvBoolean(process.env.DOCKER_OUTPUT), NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXTAUTH_URL: process.env.NEXTAUTH_URL, diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 17672f6a9..490cfb972 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -18,8 +18,10 @@ export async function register() { const { checkRecurrenceNotifications } = await import('./server/api/services/notificationService'); - console.log('Starting recurrent expense notification checking...'); - setTimeout(checkRecurrenceNotifications, 1000 * 10); // Start after 10 seconds + if (!env.TEST_MODE) { + console.log('Starting recurrent expense notification checking...'); + setTimeout(checkRecurrenceNotifications, 1000 * 10); // Start after 10 seconds + } } if (process.env.NEXT_RUNTIME !== 'nodejs') { @@ -27,7 +29,7 @@ export async function register() { return; } - if (env.CLEAR_CACHE_CRON_RULE && env.CACHE_RETENTION_INTERVAL) { + if (!env.TEST_MODE && env.CLEAR_CACHE_CRON_RULE && env.CACHE_RETENTION_INTERVAL) { // Create cron jobs console.log('Setting up cron jobs...'); diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index fa6fdbc1a..0a5f478ab 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -9,6 +9,7 @@ import { TRPCError, initTRPC } from '@trpc/server'; import { type CreateNextContextOptions } from '@trpc/server/adapters/next'; +import { type PrismaClient } from '@prisma/client'; import { type Session } from 'next-auth'; import superjson from 'superjson'; import { ZodError, z } from 'zod'; @@ -24,8 +25,9 @@ import { db } from '~/server/db'; * These allow you to access things when processing a request, like the database, the session, etc. */ -interface CreateContextOptions { +export interface CreateContextOptions { session: Session | null; + db?: PrismaClient; } /** @@ -38,9 +40,9 @@ interface CreateContextOptions { * * @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts */ -const createInnerTRPCContext = (opts: CreateContextOptions) => ({ +export const createInnerTRPCContext = (opts: CreateContextOptions) => ({ session: opts.session, - db, + db: opts.db ?? db, }); /** @@ -60,6 +62,8 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => { }); }; +export type InnerTRPCContext = ReturnType; + /** * 2. INITIALIZATION * @@ -68,7 +72,7 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => { * errors on the backend. */ -const t = initTRPC.context().create({ +const t = initTRPC.context().create({ transformer: superjson, errorFormatter({ shape, error }) { return { @@ -119,7 +123,7 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => { return next({ ctx: { - // infers the `session` as non-nullable + // Infers the `session` as non-nullable session: { ...ctx.session, user: ctx.session.user }, }, }); diff --git a/src/tests/helpers/render.tsx b/src/tests/helpers/render.tsx new file mode 100644 index 000000000..71b8af61c --- /dev/null +++ b/src/tests/helpers/render.tsx @@ -0,0 +1,67 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { type RenderOptions, render } from '@testing-library/react'; +import { SessionProvider } from 'next-auth/react'; +import { ThemeProvider } from 'next-themes'; +import React from 'react'; + +import { CurrencyHelpersProvider } from '~/contexts/CurrencyHelpersContext'; +import { createTestSession } from './session'; + +export const englishTranslations: Record = { + 'actions.back': 'Back', + 'actions.cancel': 'Cancel', + 'actions.save': 'Save', + 'actions.submit': 'Submit', + 'actors.you': 'You', + 'errors.name_required': 'Name is required', + 'errors.saving_expense': 'Error while saving expense', + 'group_details.create_group.title': 'Create group', + 'group_details.create_group.group_name_placeholder': 'Group name', + 'navigation.account': 'Account', + 'navigation.activity': 'Activity', + 'navigation.add': 'Add', + 'navigation.add_expense': 'Add Expense', + 'navigation.balances': 'Balances', + 'navigation.groups': 'Groups', + 'ui.expense.user.pay': 'pays', + 'ui.expense.you.pay': 'pay', + 'ui.settle_up_name': 'Settlement', + 'ui.select_balance': 'Select balance', + 'meta.application_name': 'SplitPro', +}; + +const translate = (key: string) => englishTranslations[key] ?? key; + +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: translate, + ready: true, + i18n: { language: 'en' }, + }), +})); + +export interface AppRenderOptions extends Omit { + session?: ReturnType | null; + queryClient?: QueryClient; +} + +export const renderWithProviders = (ui: React.ReactElement, options: AppRenderOptions = {}) => { + const { + session = createTestSession(), + queryClient = new QueryClient(), + ...renderOptions + } = options; + const Wrapper = ({ children }: React.PropsWithChildren) => ( + + + + + {children} + + + + + ); + + return render(ui, { wrapper: Wrapper, ...renderOptions }); +}; diff --git a/src/tests/helpers/resetStores.ts b/src/tests/helpers/resetStores.ts new file mode 100644 index 000000000..27cc3dd8a --- /dev/null +++ b/src/tests/helpers/resetStores.ts @@ -0,0 +1,14 @@ +import { useAddExpenseStore } from '~/store/addStore'; +import { useAppStore } from '~/store/appStore'; +import { useCurrencyPreferenceStore } from '~/store/currencyPreferenceStore'; + +const initialAddExpenseState = useAddExpenseStore.getState(); +const initialAppState = useAppStore.getState(); +const initialCurrencyPreferenceState = useCurrencyPreferenceStore.getState(); + +export const resetStores = () => { + useAddExpenseStore.setState(initialAddExpenseState, true); + useAppStore.setState(initialAppState, true); + useCurrencyPreferenceStore.setState(initialCurrencyPreferenceState, true); + window.sessionStorage.clear(); +}; diff --git a/src/tests/helpers/router.ts b/src/tests/helpers/router.ts new file mode 100644 index 000000000..283f52e01 --- /dev/null +++ b/src/tests/helpers/router.ts @@ -0,0 +1,36 @@ +import type { NextRouter } from 'next/router'; + +export interface MockRouterOptions { + pathname?: string; + asPath?: string; + query?: NextRouter['query']; + locale?: string; +} + +export const createMockRouter = (options: MockRouterOptions = {}): NextRouter => { + const router = { + pathname: options.pathname ?? '/', + route: options.pathname ?? '/', + asPath: options.asPath ?? options.pathname ?? '/', + query: options.query ?? {}, + locale: options.locale ?? 'en', + locales: ['en'], + defaultLocale: 'en', + domainLocales: undefined, + isReady: true, + isFallback: false, + isPreview: false, + isLocaleDomain: false, + basePath: '', + push: jest.fn().mockResolvedValue(true), + replace: jest.fn().mockResolvedValue(true), + reload: jest.fn(), + back: jest.fn(), + forward: jest.fn(), + prefetch: jest.fn().mockResolvedValue(undefined), + beforePopState: jest.fn(), + events: { on: jest.fn(), off: jest.fn(), emit: jest.fn() }, + } satisfies NextRouter; + + return router; +}; diff --git a/src/tests/helpers/session.ts b/src/tests/helpers/session.ts new file mode 100644 index 000000000..59ae14bb1 --- /dev/null +++ b/src/tests/helpers/session.ts @@ -0,0 +1,23 @@ +import type { Session } from 'next-auth'; + +export const testUser = { + id: 1, + name: 'Alex Example', + email: 'alex@example.com', + image: null, + currency: 'USD', + defaultCurrency: null, + preferredLanguage: 'en', + hiddenFriendIds: [], +}; + +export const createTestSession = (overrides: Partial = {}): Session => ({ + user: { ...testUser, ...overrides }, + expires: '2099-01-01T00:00:00.000Z', +}); + +export const createMockSession = (session: Session | null = createTestSession()) => ({ + data: session, + status: session ? ('authenticated' as const) : ('unauthenticated' as const), + update: jest.fn().mockResolvedValue(session), +}); diff --git a/src/tests/integration/authorization.integration.test.ts b/src/tests/integration/authorization.integration.test.ts new file mode 100644 index 000000000..ae9041b34 --- /dev/null +++ b/src/tests/integration/authorization.integration.test.ts @@ -0,0 +1,29 @@ +import { TRPCError } from '@trpc/server'; + +jest.mock('~/server/auth', () => ({ getServerAuthSession: jest.fn() })); +jest.mock('nanoid', () => ({ nanoid: () => 'integration-public-id' })); +jest.mock('superjson', () => ({ + default: { serialize: (value: unknown) => value, deserialize: (value: unknown) => value }, +})); +jest.mock('~/server/db', () => { + const { PrismaClient } = require('@prisma/client') as typeof import('@prisma/client'); + return { db: new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }) }; +}); + +import { resetDatabase } from './database'; +import { testGroup, testUser } from './factories'; +import { callerFor } from './trpc'; + +describe('authorization integration', () => { + beforeEach(() => resetDatabase()); + + it('rejects a non-member from group procedures', async () => { + const owner = await testUser('Owner'); + const outsider = await testUser('Outsider'); + const group = await testGroup(owner.id); + + await expect( + callerFor(outsider.id).group.getGroupDetails({ groupId: group.id }), + ).rejects.toMatchObject(new TRPCError({ code: 'FORBIDDEN', message: 'Not a group member' })); + }); +}); diff --git a/src/tests/integration/balances.integration.test.ts b/src/tests/integration/balances.integration.test.ts new file mode 100644 index 000000000..9e8a500c2 --- /dev/null +++ b/src/tests/integration/balances.integration.test.ts @@ -0,0 +1,26 @@ +jest.mock('~/server/db', () => { + const { PrismaClient } = require('@prisma/client') as typeof import('@prisma/client'); + return { db: new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }) }; +}); + +import { db } from '~/server/db'; +import { resetDatabase } from './database'; +import { testExpense, testUser } from './factories'; + +describe('balance view integration', () => { + beforeEach(() => resetDatabase()); + + it('calculates both sides of a double-entry balance', async () => { + const payer = await testUser('Payer'); + const participant = await testUser('Participant'); + await testExpense({ paidBy: payer.id, participantId: participant.id }); + + const balances = await db.balanceView.findMany({ orderBy: { userId: 'asc' } }); + expect(balances).toEqual( + expect.arrayContaining([ + expect.objectContaining({ userId: payer.id, friendId: participant.id, amount: 1_000n }), + expect.objectContaining({ userId: participant.id, friendId: payer.id, amount: -1_000n }), + ]), + ); + }); +}); diff --git a/src/tests/integration/database.ts b/src/tests/integration/database.ts new file mode 100644 index 000000000..1318a62c9 --- /dev/null +++ b/src/tests/integration/database.ts @@ -0,0 +1,49 @@ +import { PrismaClient } from '@prisma/client'; + +const TEST_DATABASE_PATTERN = /_test(?:[/?]|$)/i; + +export const assertTestDatabase = (url = process.env.DATABASE_URL) => { + if (!url) { + throw new Error('Integration tests require DATABASE_URL'); + } + + const parsed = new URL(url); + if (!['localhost', '127.0.0.1', '::1'].includes(parsed.hostname)) { + throw new Error(`Refusing integration tests against non-local database: ${parsed.hostname}`); + } + if (!TEST_DATABASE_PATTERN.test(parsed.pathname)) { + throw new Error( + `Refusing integration tests against database without _test suffix: ${parsed.pathname}`, + ); + } +}; + +assertTestDatabase(); + +const db = new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }); + +export const resetDatabase = async () => { + assertTestDatabase(); + await db.$transaction([ + db.expenseParticipant.deleteMany(), + db.expenseNote.deleteMany(), + db.expense.deleteMany(), + db.expenseRecurrence.deleteMany(), + db.groupDefaultSplit.deleteMany(), + db.groupUser.deleteMany(), + db.group.deleteMany(), + db.friendDefaultSplit.deleteMany(), + db.pushNotification.deleteMany(), + db.cachedBankData.deleteMany(), + db.cachedCurrencyRate.deleteMany(), + db.session.deleteMany(), + db.account.deleteMany(), + db.user.deleteMany(), + ]); + await db.$executeRawUnsafe('DELETE FROM cron.job_run_details'); + await db.$executeRawUnsafe('DELETE FROM cron.job'); +}; + +export const closeDatabase = async () => db.$disconnect(); + +export const createTestClient = () => new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }); diff --git a/src/tests/integration/expense.integration.test.ts b/src/tests/integration/expense.integration.test.ts new file mode 100644 index 000000000..bc12acc10 --- /dev/null +++ b/src/tests/integration/expense.integration.test.ts @@ -0,0 +1,55 @@ +jest.mock('~/server/api/services/notificationService', () => ({ + sendExpensePushNotification: jest.fn().mockResolvedValue(undefined), + sendGroupSimplifyDebtsToggleNotification: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('~/server/auth', () => ({ getServerAuthSession: jest.fn() })); +let mockNanoidSequence = 0; +jest.mock('nanoid', () => ({ nanoid: () => `integration-public-id-${mockNanoidSequence++}` })); +jest.mock('superjson', () => ({ + default: { serialize: (value: unknown) => value, deserialize: (value: unknown) => value }, +})); +jest.mock('~/server/db', () => { + const { PrismaClient } = require('@prisma/client') as typeof import('@prisma/client'); + return { db: new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }) }; +}); + +import { SplitType } from '@prisma/client'; + +import { db } from '~/server/db'; +import { resetDatabase } from './database'; +import { testUser } from './factories'; +import { callerFor } from './trpc'; + +describe('expense integration', () => { + beforeEach(() => resetDatabase()); + + it('persists an expense transaction and exposes its balance view', async () => { + const payer = await testUser('Payer'); + const participant = await testUser('Participant'); + const caller = callerFor(payer.id); + + const [expense] = await caller.expense.addOrEditExpense({ + paidBy: payer.id, + name: 'Dinner', + category: 'Food', + amount: 2_500n, + groupId: null, + splitType: SplitType.EQUAL, + currency: 'USD', + participants: [ + { userId: payer.id, amount: 2_500n }, + { userId: participant.id, amount: -2_500n }, + ], + }); + + expect(expense?.id).toBeDefined(); + const balances = await db.balanceView.findMany({ where: { groupId: null } }); + expect(balances).toEqual( + expect.arrayContaining([ + expect.objectContaining({ userId: payer.id, friendId: participant.id, amount: 2_500n }), + expect.objectContaining({ userId: participant.id, friendId: payer.id, amount: -2_500n }), + ]), + ); + expect(balances.reduce((total, balance) => total + balance.amount, 0n)).toBe(0n); + }); +}); diff --git a/src/tests/integration/factories.ts b/src/tests/integration/factories.ts new file mode 100644 index 000000000..9adf5534a --- /dev/null +++ b/src/tests/integration/factories.ts @@ -0,0 +1,49 @@ +import { type Prisma, SplitType } from '@prisma/client'; + +import { db } from '~/server/db'; + +let sequence = 0; + +export const testUser = async (name = `Test User ${sequence}`) => { + const id = sequence++; + return db.user.create({ + data: { name, email: `integration-${id}@splitpro.test`, currency: 'USD' }, + }); +}; + +export const testGroup = async (userId: number, name = `Test Group ${sequence++}`) => + db.group.create({ + data: { + name, + publicId: `integration-group-${sequence++}`, + userId, + groupUsers: { create: { userId } }, + }, + }); + +export const testExpense = async (input: { + paidBy: number; + participantId: number; + groupId?: number | null; + amount?: bigint; + name?: string; +}) => { + const amount = input.amount ?? 1_000n; + const data: Prisma.ExpenseCreateInput = { + name: input.name ?? `Test Expense ${sequence++}`, + category: 'Other', + amount, + currency: 'USD', + splitType: SplitType.EQUAL, + addedByUser: { connect: { id: input.paidBy } }, + paidByUser: { connect: { id: input.paidBy } }, + ...(input.groupId ? { group: { connect: { id: input.groupId } } } : {}), + expenseParticipants: { + create: [ + { userId: input.paidBy, amount }, + { userId: input.participantId, amount: -amount }, + ], + }, + }; + return db.expense.create({ data }); +}; diff --git a/src/tests/integration/recurrence.integration.test.ts b/src/tests/integration/recurrence.integration.test.ts new file mode 100644 index 000000000..c23fd5cd5 --- /dev/null +++ b/src/tests/integration/recurrence.integration.test.ts @@ -0,0 +1,45 @@ +jest.mock('~/server/api/services/notificationService', () => ({ + sendExpensePushNotification: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('~/server/auth', () => ({ getServerAuthSession: jest.fn() })); +jest.mock('nanoid', () => ({ nanoid: () => 'integration-public-id' })); +jest.mock('superjson', () => ({ + default: { serialize: (value: unknown) => value, deserialize: (value: unknown) => value }, +})); +jest.mock('~/server/db', () => { + const { PrismaClient } = require('@prisma/client') as typeof import('@prisma/client'); + return { db: new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }) }; +}); + +import { SplitType } from '@prisma/client'; + +import { db } from '~/server/db'; +import { resetDatabase } from './database'; +import { testUser } from './factories'; +import { callerFor } from './trpc'; + +describe('recurrence integration', () => { + beforeEach(() => resetDatabase()); + + it('creates a pg_cron-backed recurrence with an expense', async () => { + const user = await testUser(); + const caller = callerFor(user.id); + const [expense] = await caller.expense.addOrEditExpense({ + paidBy: user.id, + name: 'Recurring expense', + category: 'Other', + amount: 500n, + groupId: null, + splitType: SplitType.EQUAL, + currency: 'USD', + participants: [{ userId: user.id, amount: 500n }], + cronExpression: '0 0 * * *', + }); + + const recurrence = await db.expenseRecurrence.findFirst({ + include: { job: true, expense: true }, + }); + expect(recurrence?.expense[0]?.id).toBe(expense?.id); + expect(recurrence?.job.schedule).toBe('0 0 * * *'); + }); +}); diff --git a/src/tests/integration/trpc.ts b/src/tests/integration/trpc.ts new file mode 100644 index 000000000..fc9a50a31 --- /dev/null +++ b/src/tests/integration/trpc.ts @@ -0,0 +1,20 @@ +import { type Session } from 'next-auth'; + +import { appRouter } from '~/server/api/root'; +import { createInnerTRPCContext } from '~/server/api/trpc'; +import { db } from '~/server/db'; + +export const sessionFor = (userId: number): Session => ({ + user: { + id: userId, + name: `Integration User ${userId}`, + email: null, + currency: 'USD', + preferredLanguage: '', + hiddenFriendIds: [], + }, + expires: '2099-01-01T00:00:00.000Z', +}); + +export const callerFor = (userId: number) => + appRouter.createCaller(createInnerTRPCContext({ session: sessionFor(userId), db })); diff --git a/src/tests/setup/component.ts b/src/tests/setup/component.ts new file mode 100644 index 000000000..902f5a618 --- /dev/null +++ b/src/tests/setup/component.ts @@ -0,0 +1,23 @@ +import '@testing-library/jest-dom'; + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + }), +}); + +Object.defineProperty(CSSStyleDeclaration.prototype, 'transform', { + configurable: true, + value: 'none', +}); + +HTMLElement.prototype.setPointerCapture = () => undefined; +HTMLElement.prototype.releasePointerCapture = () => undefined; diff --git a/src/tests/setup/integration.ts b/src/tests/setup/integration.ts new file mode 100644 index 000000000..88f275fb1 --- /dev/null +++ b/src/tests/setup/integration.ts @@ -0,0 +1,7 @@ +import { closeDatabase } from '~/tests/integration/database'; + +jest.setTimeout(30_000); + +afterAll(async () => { + await closeDatabase(); +}); diff --git a/tests/e2e/auth.setup.ts b/tests/e2e/auth.setup.ts new file mode 100644 index 000000000..d7c327757 --- /dev/null +++ b/tests/e2e/auth.setup.ts @@ -0,0 +1,36 @@ +import { randomBytes } from 'node:crypto'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { test as setup } from '@playwright/test'; +import { db } from '~/server/db'; + +const authDir = join(process.cwd(), 'playwright', '.auth'); +const email = `playwright-${Date.now()}-${randomBytes(4).toString('hex')}@example.test`; + +setup('create database-backed session', async () => { + const user = await db.user.create({ + data: { email, name: 'Playwright Owner', preferredLanguage: 'en' }, + }); + const sessionToken = randomBytes(32).toString('hex'); + await db.session.create({ + data: { sessionToken, userId: user.id, expires: new Date(Date.now() + 86_400_000) }, + }); + await mkdir(authDir, { recursive: true }); + await writeFile(join(authDir, 'user-meta.json'), JSON.stringify({ userId: user.id, email })); + await writeFile( + join(authDir, 'user.json'), + JSON.stringify({ + cookies: [ + { + name: 'next-auth.session-token', + value: sessionToken, + domain: '127.0.0.1', + path: '/', + httpOnly: true, + sameSite: 'Lax', + }, + ], + origins: [], + }), + ); +}); diff --git a/tests/e2e/authorization.spec.ts b/tests/e2e/authorization.spec.ts new file mode 100644 index 000000000..a16298821 --- /dev/null +++ b/tests/e2e/authorization.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from './fixtures'; + +test('redirects unauthenticated visitors to sign in', async ({ browser }) => { + const context = await browser.newContext({ storageState: { cookies: [], origins: [] } }); + const page = await context.newPage(); + await page.goto('/balances'); + await expect(page).toHaveURL(/\/auth\/signin/); + await context.close(); +}); + +test('keeps authenticated users on protected pages', async ({ page }) => { + await page.goto('/groups'); + await expect(page).not.toHaveURL(/\/auth\/signin/); + await expect(page.getByRole('main')).toBeVisible(); +}); diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 000000000..9fb126dc5 --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -0,0 +1,36 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { test as base, expect } from '@playwright/test'; +import { db } from '~/server/db'; + +interface Fixtures { + userId: number; + uniqueName: string; +} + +interface AuthMeta { + userId: number; +} + +const metaPath = join(process.cwd(), 'playwright', '.auth', 'user-meta.json'); + +export const test = base.extend({ + userId: async ({ page: _page }, use) => { + const parsed: unknown = JSON.parse(await readFile(metaPath, 'utf8')); + if ( + !parsed || + 'object' !== typeof parsed || + !('userId' in parsed) || + 'number' !== typeof parsed.userId + ) { + throw new Error('Playwright authentication metadata has no numeric userId'); + } + const meta: AuthMeta = { userId: parsed.userId }; + await use(meta.userId); + }, + uniqueName: async ({ page: _page }, use, testInfo) => { + await use(`E2E ${testInfo.project.name} ${testInfo.workerIndex} ${testInfo.testId}`); + }, +}); + +export { db, expect }; diff --git a/tests/e2e/group-expense.spec.ts b/tests/e2e/group-expense.spec.ts new file mode 100644 index 000000000..de5055f6e --- /dev/null +++ b/tests/e2e/group-expense.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from './fixtures'; + +test('creates an isolated group and records an expense', async ({ page, uniqueName }) => { + await page.goto('/groups'); + await page.getByRole('button', { name: /^create$/i }).click(); + await page.getByPlaceholder(/group name/i).fill(uniqueName); + await page.getByRole('button', { name: /submit/i }).click(); + await expect(page.getByText(uniqueName)).toBeVisible(); + + await expect(page).toHaveURL(/\/groups\/\d+/); + const groupId = page.url().match(/\/groups\/(\d+)/)?.[1]; + await page.goto(`/add?groupId=${groupId}`); + await page.getByPlaceholder(/description/i).fill(`${uniqueName} expense`); + await page.getByPlaceholder(/amount/i).fill('12.34'); + await page + .getByRole('button', { name: /^save$/i }) + .last() + .click(); + await expect(page).toHaveURL(/\/groups\/\d+\/expenses\//); + await expect(page.getByText(`${uniqueName} expense`)).toBeVisible(); +}); diff --git a/tests/e2e/settlement.spec.ts b/tests/e2e/settlement.spec.ts new file mode 100644 index 000000000..518ffef3f --- /dev/null +++ b/tests/e2e/settlement.spec.ts @@ -0,0 +1,7 @@ +import { expect, test } from './fixtures'; + +test('shows the group balance and settlement controls', async ({ page }) => { + await page.goto('/balances'); + await expect(page).toHaveURL(/\/balances/); + await expect(page.getByRole('main')).toBeVisible(); +});