diff --git a/.env.example b/.env.example index 100d27125..37833fdb4 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,12 @@ 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. +TEST_DATABASE_URL="postgresql://splitpro:test-password@127.0.0.1:55439/splitpro_harness_test" +E2E_DATABASE_URL="postgresql://splitpro:test-password@127.0.0.1:55440/splitpro_e2e_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..4d0f4a9f4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -18,7 +18,7 @@ SplitPro is a **Next.js PWA** expense-splitting app (Splitwise alternative). Cor - `toSafeBigInt(input)` - Parse user input to BigInt (multiplies by 100) - Access these via `CurrencyHelpersContext` in React components - `BigMath` utility (`src/utils/numbers.ts`) provides safe arithmetic: `abs`, `sign`, `min`, `max`, `roundDiv` -- **Jest setup**: `BigInt.prototype.toJSON` is defined in `jest.config.ts` for JSON serialization +- **Jest setup**: `jest.shared.ts` configures real SuperJSON/ESM transforms; monetary values remain BigInts ### 2. **Double-Entry Balance Accounting** Every expense creates **two balance records** (bidirectional): @@ -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..50a30d7b3 --- /dev/null +++ b/.github/workflows/test-e2e.yml @@ -0,0 +1,54 @@ +name: E2E Tests + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + e2e: + name: Chromium E2E tests + runs-on: ubuntu-latest + 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 test:e2e:db:up + + - 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 + + - name: Stop disposable database + if: always() + run: pnpm test:e2e:db:down diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml new file mode 100644 index 000000000..f73fc3802 --- /dev/null +++ b/.github/workflows/test-integration.yml @@ -0,0 +1,41 @@ +name: Integration Tests + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + integration: + name: PostgreSQL integration tests + runs-on: ubuntu-latest + 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 test:db:up + + - name: Run integration tests + run: pnpm test:integration + + - name: Stop disposable database + if: always() + run: pnpm test:db:down diff --git a/.gitignore b/.gitignore index 662b9383d..9121487df 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ # testing /coverage +/test-results +/playwright-report/ +/playwright/.auth # database /prisma/db.sqlite diff --git a/AGENTS.md b/AGENTS.md index b1d556d9c..487b5075a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -323,6 +323,35 @@ 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`; each test receives an isolated authenticated scenario. +- 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. Prepare integration databases with `pnpm test:db:up` and browser +databases with `pnpm test:e2e:db:up`; the shared launcher deploys migrations and configures +pg_cron in native Nix PostgreSQL or Docker. Set `TEST_DATABASE_URL` and `E2E_DATABASE_URL` +to distinct ports/clusters per suite and worktree. Stop them with the corresponding `:down` +commands. Test database selection never falls back to application `DATABASE_URL`. + +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..adf312a74 --- /dev/null +++ b/docker/test/compose.yml @@ -0,0 +1,19 @@ +services: + postgres: + image: ossapps/postgres:18.3-trixie + environment: + POSTGRES_USER: ${TEST_POSTGRES_USER:-splitpro} + POSTGRES_PASSWORD: ${TEST_POSTGRES_PASSWORD:-test-password} + POSTGRES_DB: ${TEST_POSTGRES_DB:-splitpro_harness_test} + command: >- + postgres + -c shared_preload_libraries=pg_cron + -c cron.database_name=${TEST_POSTGRES_DB:-splitpro_harness_test} + -c cron.timezone=UTC + ports: + - '127.0.0.1:${TEST_POSTGRES_PORT:-55439}:5432' + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"'] + interval: 2s + timeout: 5s + retries: 15 diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md new file mode 100644 index 000000000..90d4d7852 --- /dev/null +++ b/docs/testing-strategy.md @@ -0,0 +1,88 @@ +# Testing strategy + +Fast checks, database integration tests, and browser tests are independent. CI runs each in +its own job. Fast checks require no running database; the other jobs provision disposable +PostgreSQL with the same launcher used locally. + +## Commands + +| Purpose | Command | +| ---------------------------- | ------------------------------------------------------------------------- | +| Formatting | `pnpm prettier --check .` | +| Lint | `pnpm lint` | +| Types | `pnpm tsgo --noEmit` | +| Unit/component tests | `pnpm test` | +| One unit file | `pnpm test src/tests/simplify.test.ts` | +| Prepare integration database | `pnpm test:db:up` | +| Integration tests | `pnpm test:integration` | +| One integration file | `pnpm test:integration src/tests/integration/expense.integration.test.ts` | +| Stop integration database | `pnpm test:db:down` | +| Prepare browser database | `pnpm test:e2e:db:up` | +| Install Chromium | `pnpm exec playwright install chromium` (CI adds `--with-deps`) | +| Chromium E2E | `pnpm test:e2e --project=chromium` | +| One browser file | `pnpm test:e2e tests/e2e/group-expense.spec.ts` | +| Stop browser database | `pnpm test:e2e:db:down` | +| Production build | `pnpm build` | + +Pass arguments directly to pnpm scripts: an extra `--` is forwarded to Playwright and stops +option parsing. `pnpm test:e2e --list` lists tests without starting a server. + +## Local databases and worktrees + +The launchers create separate PostgreSQL instances with `pg_cron` preloaded and its database +name configured, wait for startup, and deploy Prisma migrations. Docker is used outside Nix; +`nix develop` uses native PostgreSQL and keeps its state under `.direnv/test-postgres-`. +The Nix runtime supplies Chromium's Linux libraries; install the browser once with the command +above. Re-enter `nix develop` after changing the flake. + +| Suite | Environment variable | Default port | Default database | +| ----------- | -------------------- | ------------ | ----------------------- | +| Integration | `TEST_DATABASE_URL` | 55439 | `splitpro_harness_test` | +| Browser | `E2E_DATABASE_URL` | 55440 | `splitpro_e2e_test` | + +Both default URLs use `splitpro:test-password@127.0.0.1`. The launcher, Jest, and Playwright +load the root `.env` file before reading these variables; exported shell +variables take precedence. Application `DATABASE_URL` is not used to select test databases. +Use distinct test ports per worktree and per suite, and a database name ending in `_test`. +Native PostgreSQL supports IPv4/IPv6 loopback; Docker requires IPv4 loopback. A PostgreSQL +instance can host `pg_cron` in only one configured database, so suites cannot share a port. +`E2E_BASE_URL` defaults to `http://127.0.0.1:3176`; give each concurrent browser run its own +local HTTP port. Playwright starts its own app and refuses to reuse another running server. + +Tests only accept local disposable `_test` databases. Integration resets application tables +before each test. Browser fixtures clean only their own users, groups, sessions, and expenses. +An advisory lock rejects competing integration suites against the same database. Docker teardown +removes disposable volumes; native teardown stops the server and preserves state for reuse. + +## Reusable helpers and test selection + +- `jest.shared.ts` owns aliases, mock clearing, SWC configuration, and ESM-package handling. + Unit tests default to Node. DOM tests declare `/** @jest-environment jsdom */`; the unit + setup loads browser shims and cleanup only in that environment. +- `renderWithProviders` supplies a fresh query client, router, session context, theme, and + currency helpers. Pass `router`, `session`, or `sessionLoading` for individual scenarios. + Translations use the real English resources with interpolation and namespaces. Store state, + browser storage, mounted components, and managed query clients are automatically reset. +- `databaseFactories.ts` builds typed users/groups and expense inputs for integration and E2E. + API lifecycle tests use the real caller and accounting; direct database seeding is for + arranging scenarios, not validating writes. +- Integration setup owns one Prisma client per Jest suite and disconnects it after the suite. + Only auth-session lookup and notification delivery are mocked. Nano ID and SuperJSON are real. +- Playwright fixtures own a client per worker and a new authenticated scenario per test. There + are no shared authentication files or setup dependency. Cookies follow the configured host. + The transport test checks BigInt/Date serialization through the actual Next.js API endpoint. + +`pnpm test` selects `src/**/*.{test,spec}.{ts,tsx}` except `src/tests/integration/`. +`pnpm test:integration` selects that integration directory only. Playwright selects `tests/e2e/`. +Failures retain traces, screenshots, videos, and the HTML report even when local retries are +disabled. CI uploads `test-results/e2e` and `playwright-report` on failure and always stops its +database. Do not run integration resets against the browser database or a shared server. + +## Agent workflow + +1. Read the relevant helpers before adding fixtures or mocks; extend them rather than copying + test setup into individual suites. +2. Run the narrowest affected test first, then formatting, lint, types, and the unit suite. +3. Prepare disposable databases before integration/E2E; preserve failure artifacts for diagnosis. +4. Assert persisted accounting and permissions, including rejected mutations leaving state intact. +5. Report exact failures; do not weaken assertions, test selection, or database guards to pass CI. diff --git a/flake.nix b/flake.nix index 45ede4876..b92bffd2c 100644 --- a/flake.nix +++ b/flake.nix @@ -58,6 +58,31 @@ p.cacert p.stdenv.cc.cc.lib p.zlib + p.glib + p.nss + p.nspr + p.dbus + p.atk + p.at-spi2-atk + p.cups + p.libdrm + p.expat + p.libxkbcommon + p.xorg.libX11 + p.xorg.libXcomposite + p.xorg.libXdamage + p.xorg.libXext + p.xorg.libXfixes + p.xorg.libXrandr + p.xorg.libxcb + p.mesa + p.libgbm + p.udev + p.pango + p.cairo + p.alsa-lib + p.fontconfig + p.freetype p.bash p.coreutils p.findutils @@ -98,6 +123,7 @@ pkgs.git pkgs.openssl pkgs.nixfmt-rfc-style + pkgs.dejavu_fonts ]; SPLITPRO_NATIVE_POSTGRES = "1"; shellHook = '' diff --git a/jest.config.ts b/jest.config.ts index f45658bc4..2b482a51a 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,214 +1,9 @@ -/** - * 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 - 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 - 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, -}; - -export default createJestConfig(config); +import { configureJest } from './jest.shared.ts'; + +export default () => + configureJest({ + testEnvironment: 'node', + setupFilesAfterEnv: ['/src/tests/setup/unit.ts'], + testMatch: ['/src/**/*.{test,spec}.{ts,tsx}'], + testPathIgnorePatterns: ['/src/tests/integration/'], + }); diff --git a/jest.integration.config.ts b/jest.integration.config.ts new file mode 100644 index 000000000..dcc0bc039 --- /dev/null +++ b/jest.integration.config.ts @@ -0,0 +1,15 @@ +import { configureJest } from './jest.shared.ts'; +import { getTestDatabaseUrl } from './src/tests/helpers/testDatabase.ts'; +import { loadTestEnvironment } from './src/tests/helpers/environment.ts'; + +loadTestEnvironment(); +process.env.DATABASE_URL = getTestDatabaseUrl(); +process.env.SKIP_ENV_VALIDATION = '1'; + +export default () => + configureJest({ + setupFilesAfterEnv: ['/src/tests/setup/integration.ts'], + testEnvironment: 'node', + testMatch: ['/src/tests/integration/**/*.{test,spec}.{ts,tsx}'], + maxWorkers: 1, + }); diff --git a/jest.shared.ts b/jest.shared.ts new file mode 100644 index 000000000..cf53cde7f --- /dev/null +++ b/jest.shared.ts @@ -0,0 +1,22 @@ +import type { Config } from 'jest'; +import nextJest from 'next/jest.js'; + +export const configureJest = async (config: Config): Promise => { + const resolved = await nextJest({ dir: './' })({ + coverageProvider: 'v8', + cacheDirectory: '/node_modules/.cache/jest', + moduleNameMapper: { '^~/(.*)$': '/src/$1' }, + clearMocks: true, + ...config, + })(); + // Next/jest prepends its own node_modules exclusions. Replace them after resolution + // So the real ESM-only serializer and ID generator work with pnpm's nested layout. + const esmPackages = 'superjson|copy-anything|is-what|nanoid|@t3-oss[+/]env-(?:core|nextjs)'; + return { + ...resolved, + transformIgnorePatterns: [ + `/node_modules/(?!(\\.pnpm|${esmPackages})/)`, + `/node_modules/\\.pnpm/(?!(?:${esmPackages})@)`, + ], + }; +}; diff --git a/package.json b/package.json index ef4d4f0ac..b33b7b175 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,15 @@ "dx": "pnpm i && pnpm dx:up && pnpm db:dev", "dx:up": "bash scripts/dev-db.sh up", "dx:down": "bash scripts/dev-db.sh down", - "test": "jest", + "test": "jest --config jest.config.ts", "test:watch": "jest --watch", + "test:db:up": "tsx scripts/test-db.ts up", + "test:db:down": "tsx scripts/test-db.ts down", + "test:e2e:db:up": "tsx scripts/test-db.ts up e2e", + "test:e2e:db:down": "tsx scripts/test-db.ts down e2e", + "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": { @@ -81,9 +88,13 @@ }, "devDependencies": { "@faker-js/faker": "10.1.0", + "@playwright/test": "^1.55.0", "@tailwindcss/postcss": "^4.1.10", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@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..06e8ac486 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,50 @@ +import { defineConfig, devices } from '@playwright/test'; + +import { getTestDatabaseUrl } from './src/tests/helpers/testDatabase'; +import { loadTestEnvironment } from './src/tests/helpers/environment'; + +loadTestEnvironment(); +const baseURL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3176'; +const url = new URL(baseURL); +if ('http:' !== url.protocol || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) { + throw new Error('E2E_BASE_URL must point at a local HTTP test server'); +} + +const testEnvironment = { + DATABASE_URL: getTestDatabaseUrl('e2e'), + NODE_ENV: 'test', + TEST_MODE: '1', + 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', +}; +Object.assign(process.env, testEnvironment); + +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'], ['html', { open: 'never' }]], + use: { + ...devices['Desktop Chrome'], + baseURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [{ name: 'chromium' }], + webServer: { + command: `pnpm dev --hostname ${url.hostname.replaceAll('[', '').replaceAll(']', '')} --port ${Number(url.port) || 80}`, + url: baseURL, + reuseExistingServer: false, + timeout: 120_000, + env: testEnvironment, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54d0cbe49..712dc6ab3 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) @@ -114,13 +114,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) @@ -182,15 +182,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 @@ -263,6 +275,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'} @@ -880,10 +895,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} @@ -917,10 +928,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} @@ -945,10 +952,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} @@ -1197,6 +1200,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'} @@ -2007,9 +2015,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==} @@ -2155,6 +2160,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: @@ -2208,6 +2245,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==} @@ -2235,8 +2275,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==} @@ -2465,6 +2505,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==} @@ -2518,10 +2565,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==} @@ -2582,10 +2625,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'} @@ -2692,6 +2731,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'} @@ -2747,6 +2789,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==} @@ -2764,14 +2810,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'} @@ -2873,10 +2921,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} @@ -2894,10 +2938,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'} @@ -2926,6 +2966,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} @@ -3076,6 +3121,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. @@ -3107,10 +3156,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==} @@ -3181,10 +3226,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} @@ -3210,10 +3251,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} @@ -3222,18 +3259,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} @@ -3275,10 +3304,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} @@ -3467,6 +3492,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==} @@ -3487,10 +3516,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'} @@ -3507,6 +3532,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==} @@ -3752,6 +3781,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} @@ -3838,9 +3877,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==} @@ -3934,6 +3973,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==} @@ -3991,6 +4033,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'} @@ -4142,6 +4188,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'} @@ -4206,10 +4256,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'} @@ -4459,6 +4505,8 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + '@alloc/quick-lru@5.2.0': {} '@ampproject/remapping@2.3.0': @@ -5025,10 +5073,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 @@ -5093,10 +5137,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 @@ -5147,15 +5187,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 @@ -5199,10 +5230,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': {} @@ -5318,6 +5349,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) @@ -6163,14 +6198,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: @@ -6193,8 +6228,6 @@ snapshots: optionalDependencies: typescript: 5.7.3 - '@sinclair/typebox@0.27.8': {} - '@sinclair/typebox@0.34.40': {} '@sinonjs/commons@3.0.1': @@ -6299,16 +6332,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 @@ -6342,6 +6410,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 @@ -6382,10 +6452,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: @@ -6560,6 +6630,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: @@ -6643,10 +6719,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 @@ -6708,8 +6780,6 @@ snapshots: dependencies: readdirp: 4.1.2 - ci-info@3.9.0: {} - ci-info@4.3.0: {} citty@0.1.6: @@ -6807,6 +6877,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 @@ -6841,6 +6913,8 @@ snapshots: delayed-stream@1.0.0: {} + dequal@2.0.3: {} + destr@2.0.5: {} detect-libc@2.1.2: {} @@ -6854,10 +6928,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: {} @@ -6970,14 +7046,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 @@ -6999,10 +7067,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 @@ -7031,6 +7095,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -7182,6 +7249,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -7211,8 +7280,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: {} @@ -7341,13 +7408,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 @@ -7387,8 +7447,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 @@ -7409,13 +7467,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 @@ -7423,18 +7474,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 @@ -7557,15 +7596,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 @@ -7776,6 +7806,8 @@ snapshots: luxon@3.7.2: {} + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -7794,11 +7826,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: @@ -7809,6 +7836,8 @@ snapshots: mimic-function@5.0.1: {} + min-indent@1.0.1: {} + minimalistic-assert@1.0.1: {} minimatch@9.0.9: @@ -7829,13 +7858,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 @@ -7846,7 +7875,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 @@ -7854,7 +7883,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) @@ -7863,7 +7892,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 @@ -7881,6 +7910,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' @@ -8048,6 +8078,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 @@ -8075,11 +8113,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: {} @@ -8215,6 +8253,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): @@ -8265,6 +8305,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: @@ -8418,6 +8463,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): @@ -8467,10 +8516,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/scripts/test-db.ts b/scripts/test-db.ts new file mode 100644 index 000000000..ab77bdf98 --- /dev/null +++ b/scripts/test-db.ts @@ -0,0 +1,123 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { assertTestDatabaseUrl, getTestDatabaseUrl } from '../src/tests/helpers/testDatabase'; +import { loadTestEnvironment } from '../src/tests/helpers/environment'; + +const action = process.argv[2]; +if ('up' !== action && 'down' !== action) { + throw new Error('Usage: pnpm test:db:up | pnpm test:db:down'); +} +loadTestEnvironment(); +const suite = process.argv[3] ?? 'integration'; +if ('integration' !== suite && 'e2e' !== suite) { + throw new Error('Unknown test suite'); +} +const databaseUrl = getTestDatabaseUrl(suite); +const url = assertTestDatabaseUrl(databaseUrl); +const database = url.pathname.slice(1); +const port = url.port || '5432'; +const user = decodeURIComponent(url.username); +if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(user)) { + throw new Error('Test database user must be a simple PostgreSQL identifier'); +} +const run = (command: string, args: string[]) => { + const result = spawnSync(command, args, { + stdio: 'inherit', + env: { + ...process.env, + DATABASE_URL: databaseUrl, + TEST_POSTGRES_USER: user, + TEST_POSTGRES_PASSWORD: decodeURIComponent(url.password), + TEST_POSTGRES_DB: database, + TEST_POSTGRES_PORT: port, + }, + }); + if (result.error || 0 !== result.status) { + throw result.error ?? new Error(`${command} exited with ${result.status}`); + } +}; + +if ('1' !== process.env.SPLITPRO_NATIVE_POSTGRES) { + if ('[::1]' === url.hostname) { + throw new Error('Docker test databases require an IPv4 loopback URL'); + } + run('docker', [ + 'compose', + '-f', + 'docker/test/compose.yml', + '-p', + `splitpro-test-${port}`, + ...('up' === action ? ['up', '-d', '--wait'] : ['down', '--volumes']), + ]); +} else { + const state = resolve(`.direnv/test-postgres-${port}`); + const data = resolve(state, 'data'); + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + if ('down' === action) { + if (existsSync(resolve(data, 'postmaster.pid'))) { + run('pg_ctl', ['-D', data, '-m', 'fast', '-w', 'stop']); + } + } else { + mkdirSync(state, { recursive: true }); + if (!existsSync(resolve(data, 'PG_VERSION'))) { + const passwordFile = resolve(state, 'bootstrap-password'); + writeFileSync(passwordFile, `${decodeURIComponent(url.password)}\n`, { mode: 0o600 }); + try { + run('initdb', [ + '-D', + data, + '-U', + user, + '--encoding=UTF8', + '--locale=C', + '--auth-local=trust', + '--auth-host=scram-sha-256', + `--pwfile=${passwordFile}`, + ]); + } finally { + unlinkSync(passwordFile); + } + } + if (!existsSync(resolve(data, 'postmaster.pid'))) { + run('pg_ctl', [ + '-D', + data, + '-l', + resolve(state, 'postgres.log'), + '-w', + 'start', + '-o', + `-p ${port} -k ${quote(state)} -h ${quote(url.hostname.replaceAll('[', '').replaceAll(']', ''))} ` + + `-c shared_preload_libraries=pg_cron -c cron.database_name=${database} ` + + '-c cron.timezone=UTC -c cron.use_background_workers=on', + ]); + } + const result = spawnSync( + 'psql', + [ + '-h', + state, + '-p', + port, + '-U', + user, + '-d', + 'postgres', + '-Atc', + `SELECT 1 FROM pg_database WHERE datname = '${database}'`, + ], + { encoding: 'utf8' }, + ); + if (0 !== result.status) { + throw new Error(result.stderr); + } + if ('1' !== result.stdout.trim()) { + run('createdb', ['-h', state, '-p', port, '-U', user, database]); + } + } +} +if ('up' === action) { + run('pnpm', ['exec', 'prisma', 'migrate', 'deploy']); +} diff --git a/src/components/Friend/Settleup.test.tsx b/src/components/Friend/Settleup.test.tsx new file mode 100644 index 000000000..66925852e --- /dev/null +++ b/src/components/Friend/Settleup.test.tsx @@ -0,0 +1,95 @@ +/** @jest-environment jsdom */ + +import { SplitType } from '@prisma/client'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithProviders } from '~/tests/helpers/render'; +import { createTestUser } from '~/tests/helpers/user'; +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 } }), + }, +})); + +import { SettleUp } from './Settleup'; + +const friend = createTestUser({ id: 2, name: 'Sam Friend', email: 'sam@example.test' }); +const makeBalance = (amount: bigint): MinimalBalance => ({ + currency: 'USD', + amount, + friendId: friend.id, + groupId: 7, + groupName: 'Trip', +}); + +describe('SettleUp', () => { + it.each([ + { balance: -1250n, paidBy: 1, currentUserAmount: 1250n }, + { balance: 1250n, paidBy: 2, currentUserAmount: -1250n }, + ])('settles both directions: $balance', async ({ balance, paidBy, currentUserAmount }) => { + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + await user.click(screen.getByRole('button', { name: 'Settle up' })); + await user.click(await screen.findByRole('button', { name: 'Save' })); + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate).toHaveBeenCalledWith( + expect.objectContaining({ + amount: 1250n, + currency: 'USD', + splitType: SplitType.SETTLEMENT, + paidBy, + groupId: 7, + participants: [ + { userId: 1, amount: currentUserAmount }, + { userId: 2, amount: -currentUserAmount }, + ], + }), + expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }), + ); + }); + + it('uses the edited amount for a partial settlement', async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + await user.click(screen.getByRole('button', { name: 'Settle up' })); + const amount = await screen.findByRole('textbox'); + await user.clear(amount); + await user.type(amount, '5.00'); + await user.click(screen.getByRole('button', { name: 'Save' })); + expect(mutate).toHaveBeenCalledWith( + expect.objectContaining({ + amount: 500n, + participants: [ + { userId: 1, amount: 500n }, + { userId: 2, amount: -500n }, + ], + }), + expect.anything(), + ); + }); + + it('does not render an unauthenticated settlement action', () => { + renderWithProviders( + + + , + { session: null }, + ); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Layout/MainLayout.test.tsx b/src/components/Layout/MainLayout.test.tsx new file mode 100644 index 000000000..abfa953e3 --- /dev/null +++ b/src/components/Layout/MainLayout.test.tsx @@ -0,0 +1,27 @@ +/** @jest-environment jsdom */ + +import { screen } from '@testing-library/react'; +import React from 'react'; + +import { renderWithProviders } from '~/tests/helpers/render'; +import { createMockRouter } from '~/tests/helpers/router'; +import MainLayout from './MainLayout'; + +it('marks the active navigation section for nested routes', () => { + renderWithProviders( + +

Content

+
, + { + router: createMockRouter({ pathname: '/groups/[groupId]', asPath: '/groups/7' }), + }, + ); + expect(screen.getByText('Content')).toBeInTheDocument(); + screen.getAllByRole('link', { name: 'Groups' }).forEach((link) => { + expect(link).toHaveAttribute('href', '/groups'); + expect(link.querySelector('span')).toHaveClass('text-cyan-500'); + }); + screen.getAllByRole('link', { name: 'Balances' }).forEach((link) => { + expect(link.querySelector('span')).not.toHaveClass('text-cyan-500'); + }); +}); diff --git a/src/components/group/CreateGroup.test.tsx b/src/components/group/CreateGroup.test.tsx new file mode 100644 index 000000000..5033d7fed --- /dev/null +++ b/src/components/group/CreateGroup.test.tsx @@ -0,0 +1,68 @@ +/** @jest-environment jsdom */ + +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithProviders } from '~/tests/helpers/render'; + +const mutateAsync = jest.fn(); +const refetch = jest.fn().mockResolvedValue(undefined); +jest.mock('~/utils/api', () => ({ + api: { + group: { create: { useMutation: () => ({ mutateAsync }) } }, + useUtils: () => ({ group: { getAllGroupsWithBalances: { refetch } } }), + }, +})); +import { CreateGroup } from './CreateGroup'; + +describe('CreateGroup', () => { + beforeEach(() => { + mutateAsync.mockReset().mockImplementation(async (_input, options) => { + options?.onSuccess?.({ id: 42 }); + return { id: 42 }; + }); + }); + + it('validates an empty name without creating a group', async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + await user.click(screen.getByRole('button', { name: 'New group' })); + await user.click(await screen.findByRole('button', { name: 'Submit' })); + expect(await screen.findByText('Name is required')).toBeInTheDocument(); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it('creates the group, refreshes groups, navigates and closes the drawer', async () => { + const user = userEvent.setup(); + const { router } = 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(router.push).toHaveBeenCalledWith('/groups/42')); + expect(mutateAsync).toHaveBeenCalledWith({ name: 'Trip' }, expect.anything()); + expect(refetch).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('cancels without submitting', async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + await user.click(screen.getByRole('button', { name: 'New group' })); + await user.click(await screen.findByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(mutateAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/group/CreateGroup.tsx b/src/components/group/CreateGroup.tsx index bf286717f..efa777643 100644 --- a/src/components/group/CreateGroup.tsx +++ b/src/components/group/CreateGroup.tsx @@ -30,6 +30,7 @@ export const CreateGroup: React.FC<{ children: React.ReactNode }> = ({ children const groupForm = useForm({ resolver: zodResolver(groupSchema(t)), + defaultValues: { name: '' }, }); const router = useRouter(); diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx index 4f4c2fd73..13e089ac3 100644 --- a/src/components/ui/drawer.tsx +++ b/src/components/ui/drawer.tsx @@ -220,6 +220,7 @@ export const AppDrawer: React.FC = (props) => { {trigger} { if (false === dismissible) { e.preventDefault(); @@ -284,7 +285,7 @@ export const AppDrawer: React.FC = (props) => { > {trigger} - +
{leftAction ? ( @@ -303,7 +304,7 @@ export const AppDrawer: React.FC = (props) => { ) : (
)} -

{title}

+ {title} {actionTitle ? ( !shouldCloseOnAction ? ( ), - [], + [t], ); return ( diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index afc6f354a..9f90ba754 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -134,26 +134,11 @@ export const expenseRouter = createTRPCRouter({ .mutation(async ({ input: expenses, ctx }) => { const results = []; for (const input of expenses) { - if (input.expenseId) { - await validateEditExpensePermission(input.expenseId, ctx.session.user.id); - } + await validateExpenseWritePermission(input.expenseId, input.groupId, ctx.session.user.id); if (input.splitType === SplitType.CURRENCY_CONVERSION) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid split type' }); } - if (input.groupId !== null) { - const group = await db.group.findUnique({ - where: { id: input.groupId }, - select: { archivedAt: true }, - }); - if (!group) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group not found' }); - } - if (group.archivedAt) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group is archived' }); - } - } - try { const expense = input.expenseId ? await editExpense(input, ctx.session.user.id) @@ -176,6 +161,7 @@ export const expenseRouter = createTRPCRouter({ .input(createCurrencyConversionSchema) .mutation(async ({ input, ctx }) => { const { amount, rate, from, to, senderId, receiverId, groupId, expenseId } = input; + await validateExpenseWritePermission(expenseId, groupId, ctx.session.user.id); if (!isCurrencyCode(from) || !isCurrencyCode(to)) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid currency code' }); @@ -350,7 +336,7 @@ export const expenseRouter = createTRPCRouter({ getExpenseDetails: protectedProcedure .input(z.object({ expenseId: z.string() })) - .query(async ({ input }) => { + .query(async ({ input, ctx }) => { const expense = await db.expense.findUnique({ where: { id: input.expenseId, @@ -389,6 +375,26 @@ export const expenseRouter = createTRPCRouter({ }, }); + if ( + expense && + expense.addedBy !== ctx.session.user.id && + !expense.expenseParticipants.some( + (participant) => participant.userId === ctx.session.user.id, + ) + ) { + const membership = + null === expense.groupId + ? null + : await db.groupUser.findUnique({ + where: { + groupId_userId: { groupId: expense.groupId, userId: ctx.session.user.id }, + }, + }); + if (!membership) { + throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You cannot view this expense' }); + } + } + if (expense && expense.groupId !== null) { const missingGroupMembers = await db.group.findUnique({ where: { @@ -564,19 +570,7 @@ export const expenseRouter = createTRPCRouter({ if (!expense) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Expense not found' }); } - - if (expense.groupId !== null) { - const group = await db.group.findUnique({ - where: { id: expense.groupId }, - select: { archivedAt: true }, - }); - if (!group) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group not found' }); - } - if (group.archivedAt) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group is archived' }); - } - } + await validateExpenseWritePermission(input.expenseId, expense.groupId, ctx.session.user.id); await deleteExpense(input.expenseId, ctx.session.user.id); }), @@ -639,7 +633,7 @@ const validateEditExpensePermission = async (expenseId: string, userId: number): db.expense.findUnique({ where: { id: expenseId }, select: { addedBy: true } }), ]); - if (!expenseParticipant && !addedBy?.addedBy) { + if (!expenseParticipant && userId !== addedBy?.addedBy) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You are not the participant of the expense', @@ -647,4 +641,40 @@ const validateEditExpensePermission = async (expenseId: string, userId: number): } }; +const validateExpenseWritePermission = async ( + expenseId: string | undefined, + groupId: number | null, + userId: number, +): Promise => { + if (expenseId) { + await validateEditExpensePermission(expenseId, userId); + const expense = await db.expense.findUniqueOrThrow({ + where: { id: expenseId }, + select: { groupId: true }, + }); + if (expense.groupId !== groupId) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Cannot move an expense to another group', + }); + } + } + if (null === groupId) { + return; + } + const group = await db.group.findUnique({ + where: { id: groupId }, + select: { archivedAt: true, groupUsers: { where: { userId }, select: { userId: true } } }, + }); + if (!group) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group not found' }); + } + if (0 === group.groupUsers.length) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Not a group member' }); + } + if (group.archivedAt) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group is archived' }); + } +}; + export type ExpenseRouter = typeof expenseRouter; diff --git a/src/server/api/services/splitService.ts b/src/server/api/services/splitService.ts index 8c81a47ea..9eb06948e 100644 --- a/src/server/api/services/splitService.ts +++ b/src/server/api/services/splitService.ts @@ -137,7 +137,7 @@ export async function createExpense( } catch (error) { // If we created a cron job but transaction failed, clean up the cron job if (expenseId) { - await db.$executeRaw`SELECT cron.unschedule(${`expense_recurring_${expenseId}`})`; + await db.$executeRaw`SELECT cron.unschedule(${expenseId})`; } throw error; } @@ -330,26 +330,8 @@ export async function getCompleteFriendsDetails(userId: number) { }, }); - const friends = viewBalances.reduce( - (acc, balance) => { - const { friendId } = balance; - acc[friendId] ??= { - balances: [], - id: friendId, - email: balance.friend.email, - name: balance.friend.name, - }; - - if (0n !== balance.amount) { - acc[friendId]?.balances.push({ - currency: balance.currency, - amount: balance.amount, - }); - } - - return acc; - }, - {} as Record< + const friends = viewBalances.reduce< + Record< number, { id: number; @@ -357,8 +339,25 @@ export async function getCompleteFriendsDetails(userId: number) { name?: string | null; balances: { currency: string; amount: bigint }[]; } - >, - ); + > + >((acc, balance) => { + const { friendId } = balance; + acc[friendId] ??= { + balances: [], + id: friendId, + email: balance.friend.email, + name: balance.friend.name, + }; + + if (0n !== balance.amount) { + acc[friendId]?.balances.push({ + currency: balance.currency, + amount: balance.amount, + }); + } + + return acc; + }, {}); return friends; } @@ -389,16 +388,13 @@ export async function importUserBalanceFromSplitWise( const users = await createUsersFromSplitwise(splitWiseUsers); - const userMap = users.reduce( - (acc, user) => { - if (user.email) { - acc[user.email] = user; - } + const userMap = users.reduce>((acc, user) => { + if (user.email) { + acc[user.email] = user; + } - return acc; - }, - {} as Record, - ); + return acc; + }, {}); const currencyHelperCache: Record['toSafeBigInt']> = {}; @@ -503,16 +499,13 @@ export async function importGroupFromSplitwise( const users = await createUsersFromSplitwise(Object.values(splitwiseUserMap)); - const userMap = users.reduce( - (acc, user) => { - if (user.email) { - acc[user.email] = user; - } + const userMap = users.reduce>((acc, user) => { + if (user.email) { + acc[user.email] = user; + } - return acc; - }, - {} as Record, - ); + return acc; + }, {}); const missingGroups = await Promise.all( splitWiseGroups.map(async (group) => { diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 9ff2189ae..907849d7b 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'; @@ -25,8 +26,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; } /** @@ -39,9 +41,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, }); /** @@ -61,6 +63,8 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => { }); }; +export type InnerTRPCContext = ReturnType; + /** * 2. INITIALIZATION * @@ -69,7 +73,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 { diff --git a/tests/appError.test.ts b/src/tests/appError.test.ts similarity index 100% rename from tests/appError.test.ts rename to src/tests/appError.test.ts diff --git a/src/tests/currencyPreferenceStore.test.ts b/src/tests/currencyPreferenceStore.test.ts index 7213a13ae..24d0de32b 100644 --- a/src/tests/currencyPreferenceStore.test.ts +++ b/src/tests/currencyPreferenceStore.test.ts @@ -1,3 +1,5 @@ +/** @jest-environment jsdom */ + import { SHOW_ALL_VALUE, useCurrencyPreferenceStore } from '~/store/currencyPreferenceStore'; const resetStore = () => diff --git a/src/tests/editRecurrenceDialog.test.tsx b/src/tests/editRecurrenceDialog.test.tsx index b14a60c9a..4d987491d 100644 --- a/src/tests/editRecurrenceDialog.test.tsx +++ b/src/tests/editRecurrenceDialog.test.tsx @@ -1,3 +1,5 @@ +/** @jest-environment jsdom */ + import { act, createElement } from 'react'; import { createRoot } from 'react-dom/client'; diff --git a/src/tests/helpers/databaseFactories.ts b/src/tests/helpers/databaseFactories.ts new file mode 100644 index 000000000..a57af47a9 --- /dev/null +++ b/src/tests/helpers/databaseFactories.ts @@ -0,0 +1,69 @@ +import { randomUUID } from 'node:crypto'; +import { type Prisma, type PrismaClient, SplitType } from '@prisma/client'; + +import type { CreateExpense } from '~/types/expense.types'; + +export const createDatabaseUser = (db: PrismaClient, overrides: Prisma.UserCreateInput = {}) => + db.user.create({ + data: { + name: 'Test User', + email: `test-${randomUUID()}@example.test`, + currency: 'USD', + preferredLanguage: 'en', + ...overrides, + }, + }); + +export const createDatabaseGroup = ( + db: PrismaClient, + ownerId: number, + memberIds: number[] = [], + overrides: Pick & + Partial> = { name: 'Test Group' }, +) => + db.group.create({ + data: { + ...overrides, + publicId: randomUUID(), + userId: ownerId, + groupUsers: { create: [...new Set([ownerId, ...memberIds])].map((userId) => ({ userId })) }, + }, + }); + +export const expenseInput = ( + payerId: number, + participantId: number, + overrides: Partial = {}, +): CreateExpense => { + const amount = overrides.amount ?? 2000n; + const debt = amount / 2n; + return { + paidBy: payerId, + name: 'Dinner', + category: 'Food', + amount, + groupId: null, + splitType: SplitType.EQUAL, + currency: 'USD', + participants: [ + { userId: payerId, amount: debt }, + { userId: participantId, amount: -debt }, + ], + ...overrides, + }; +}; + +export const createDatabaseExpense = ( + db: PrismaClient, + input: CreateExpense, + addedBy = input.paidBy, +) => { + const { participants, expenseId: _expenseId, ...data } = input; + return db.expense.create({ + data: { + ...data, + addedBy, + expenseParticipants: { create: participants }, + }, + }); +}; diff --git a/src/tests/helpers/environment.ts b/src/tests/helpers/environment.ts new file mode 100644 index 000000000..5f275d87e --- /dev/null +++ b/src/tests/helpers/environment.ts @@ -0,0 +1,8 @@ +import { existsSync } from 'node:fs'; + +// Match the database launcher's .env contract. Node preserves shell overrides. +export const loadTestEnvironment = () => { + if (existsSync('.env')) { + process.loadEnvFile('.env'); + } +}; diff --git a/src/tests/helpers/i18n.ts b/src/tests/helpers/i18n.ts new file mode 100644 index 000000000..cfaeb3b20 --- /dev/null +++ b/src/tests/helpers/i18n.ts @@ -0,0 +1,23 @@ +import i18next from 'i18next'; + +import categories from '../../../public/locales/en/categories.json'; +import common from '../../../public/locales/en/common.json'; +import currencies from '../../../public/locales/en/currencies.json'; +import home from '../../../public/locales/en/home.json'; + +export const testI18n = i18next.createInstance(); +void testI18n.init({ + lng: 'en', + fallbackLng: 'en', + defaultNS: 'common', + initImmediate: false, + showSupportNotice: false, + interpolation: { escapeValue: false }, + resources: { en: { categories, common, currencies, home } }, +}); + +export const useTestTranslation = (namespace: string | string[] = 'common') => ({ + t: testI18n.getFixedT('en', namespace), + ready: true, + i18n: testI18n, +}); diff --git a/src/tests/helpers/queryClient.ts b/src/tests/helpers/queryClient.ts new file mode 100644 index 000000000..86026aa40 --- /dev/null +++ b/src/tests/helpers/queryClient.ts @@ -0,0 +1,19 @@ +import { QueryClient } from '@tanstack/react-query'; + +const clients = new Set(); + +export const createTestQueryClient = () => { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity }, + mutations: { retry: false }, + }, + }); + clients.add(client); + return client; +}; + +export const clearTestQueryClients = () => { + clients.forEach((client) => client.clear()); + clients.clear(); +}; diff --git a/src/tests/helpers/render.tsx b/src/tests/helpers/render.tsx new file mode 100644 index 000000000..2b3af942d --- /dev/null +++ b/src/tests/helpers/render.tsx @@ -0,0 +1,43 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { type RenderOptions, render } from '@testing-library/react'; +import type { Session } from 'next-auth'; +import { SessionContext } from 'next-auth/react'; +import { RouterContext } from 'next/dist/shared/lib/router-context.shared-runtime'; +import type { NextRouter } from 'next/router'; +import { ThemeProvider } from 'next-themes'; +import React from 'react'; + +import { CurrencyHelpersProvider } from '~/contexts/CurrencyHelpersContext'; +import { createTestQueryClient } from './queryClient'; +import { createMockRouter } from './router'; +import { createMockSession, createTestSession } from './session'; + +export interface AppRenderOptions extends Omit { + session?: Session | null; + sessionLoading?: boolean; + router?: NextRouter; +} + +export const renderWithProviders = (ui: React.ReactElement, options: AppRenderOptions = {}) => { + const { + session = createTestSession(), + sessionLoading = false, + router = createMockRouter(), + ...renderOptions + } = options; + const queryClient = createTestQueryClient(); + const Wrapper = ({ children }: React.PropsWithChildren) => ( + + + + + + {children} + + + + + + ); + return { ...render(ui, { wrapper: Wrapper, ...renderOptions }), router, queryClient }; +}; diff --git a/src/tests/helpers/resetStores.ts b/src/tests/helpers/resetStores.ts new file mode 100644 index 000000000..c884adf09 --- /dev/null +++ b/src/tests/helpers/resetStores.ts @@ -0,0 +1,11 @@ +import { useAddExpenseStore } from '~/store/addStore'; +import { useAppStore } from '~/store/appStore'; +import { useCurrencyPreferenceStore } from '~/store/currencyPreferenceStore'; + +export const resetStores = () => { + useAddExpenseStore.setState(useAddExpenseStore.getInitialState(), true); + useAppStore.setState(useAppStore.getInitialState(), true); + useCurrencyPreferenceStore.setState(useCurrencyPreferenceStore.getInitialState(), true); + window.sessionStorage.clear(); + window.localStorage.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..73589dde5 --- /dev/null +++ b/src/tests/helpers/session.ts @@ -0,0 +1,22 @@ +import type { Session } from 'next-auth'; +import type { SessionContextValue } from 'next-auth/react'; + +import { createTestUser } from './user'; + +export const createTestSession = (overrides: Partial = {}): Session => ({ + user: { ...createTestUser(), bankingId: undefined, obapiProviderId: undefined, ...overrides }, + expires: '2099-01-01T00:00:00.000Z', +}); + +export const createMockSession = ( + session: Session | null = createTestSession(), + loading = false, +): SessionContextValue => { + const update = jest.fn().mockResolvedValue(session); + if (loading) { + return { data: null, status: 'loading', update }; + } + return session + ? { data: session, status: 'authenticated', update } + : { data: null, status: 'unauthenticated', update }; +}; diff --git a/src/tests/helpers/testDatabase.ts b/src/tests/helpers/testDatabase.ts new file mode 100644 index 000000000..89c2d34ed --- /dev/null +++ b/src/tests/helpers/testDatabase.ts @@ -0,0 +1,34 @@ +export const DEFAULT_TEST_DATABASE_URL = + 'postgresql://splitpro:test-password@127.0.0.1:55439/splitpro_harness_test'; +export const DEFAULT_E2E_DATABASE_URL = + 'postgresql://splitpro:test-password@127.0.0.1:55440/splitpro_e2e_test'; + +export const assertTestDatabaseUrl = (value: string) => { + const url = new URL(value); + if ( + !['postgres:', 'postgresql:'].includes(url.protocol) || + !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || + !/^\/[a-zA-Z_][a-zA-Z0-9_]*_test$/.test(url.pathname) + ) { + throw new Error('Tests require a local PostgreSQL database whose name ends in _test'); + } + return url; +}; + +export const getTestDatabaseUrl = (suite: 'integration' | 'e2e' = 'integration') => { + const value = + 'e2e' === suite + ? (process.env.E2E_DATABASE_URL ?? DEFAULT_E2E_DATABASE_URL) + : (process.env.TEST_DATABASE_URL ?? DEFAULT_TEST_DATABASE_URL); + assertTestDatabaseUrl(value); + const otherValue = + 'e2e' === suite + ? (process.env.TEST_DATABASE_URL ?? DEFAULT_TEST_DATABASE_URL) + : (process.env.E2E_DATABASE_URL ?? DEFAULT_E2E_DATABASE_URL); + const url = new URL(value); + const other = new URL(otherValue); + if ((url.port || '5432') === (other.port || '5432')) { + throw new Error('Integration and E2E databases require separate PostgreSQL ports/clusters'); + } + return value; +}; diff --git a/src/tests/helpers/user.ts b/src/tests/helpers/user.ts new file mode 100644 index 000000000..508f5cf16 --- /dev/null +++ b/src/tests/helpers/user.ts @@ -0,0 +1,16 @@ +import type { User } from '@prisma/client'; + +export const createTestUser = (overrides: Partial = {}): User => ({ + id: 1, + name: 'Alex Example', + email: 'alex@example.test', + emailVerified: null, + image: null, + currency: 'USD', + defaultCurrency: null, + preferredLanguage: 'en', + bankingId: null, + obapiProviderId: null, + hiddenFriendIds: [], + ...overrides, +}); diff --git a/src/tests/integration/authorization.integration.test.ts b/src/tests/integration/authorization.integration.test.ts new file mode 100644 index 000000000..622b0a87c --- /dev/null +++ b/src/tests/integration/authorization.integration.test.ts @@ -0,0 +1,125 @@ +import { db } from './database'; +import { accountingSnapshot, testGroup, testScenario } from './factories'; +import { callerFor } from './trpc'; + +describe('authorization', () => { + it('rejects unauthenticated queries and mutations', async () => { + await expect(callerFor(null).group.getAllGroups()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + await expect(callerFor(null).group.create({ name: 'Forbidden' })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + expect(await db.group.count()).toBe(0); + }); + + it('rejects outsider group reads and membership mutations', async () => { + const { owner, outsider, group, input } = await testScenario(); + const caller = callerFor(outsider.id); + await expect(caller.group.getGroupDetails({ groupId: group.id })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect(caller.expense.getGroupExpenses({ groupId: group.id })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + await expect( + caller.group.addMembers({ groupId: group.id, userIds: [outsider.id] }), + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect(caller.expense.addOrEditExpense(input)).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + expect(await callerFor(owner.id).group.getGroupDetails({ groupId: group.id })).toMatchObject({ + id: group.id, + }); + }); + + it('rejects outsider expense reads, edits and deletes without changing accounting', async () => { + const { owner, outsider, input } = await testScenario(); + const [expense] = await callerFor(owner.id).expense.addOrEditExpense(input); + const before = await accountingSnapshot(); + const caller = callerFor(outsider.id); + await expect( + caller.expense.getExpenseDetails({ expenseId: expense!.id }), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + await expect( + caller.expense.addOrEditExpense({ ...input, expenseId: expense!.id, name: 'Stolen' }), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + await expect(caller.expense.deleteExpense({ expenseId: expense!.id })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + expect(await db.expense.findUnique({ where: { id: expense!.id } })).toMatchObject({ + name: input.name, + deletedAt: null, + }); + expect(await accountingSnapshot()).toEqual(before); + }); + + it('rejects writes to archived groups', async () => { + const { owner, group, input } = await testScenario(); + const caller = callerFor(owner.id); + const [expense] = await caller.expense.addOrEditExpense(input); + const otherGroup = await testGroup(owner.id); + await db.group.update({ where: { id: group.id }, data: { archivedAt: new Date() } }); + const before = await accountingSnapshot(); + await expect(caller.expense.addOrEditExpense(input)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + await expect( + caller.expense.addOrEditExpense({ ...input, expenseId: expense!.id }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect(caller.expense.deleteExpense({ expenseId: expense!.id })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + await expect( + caller.expense.addOrEditExpense({ ...input, expenseId: expense!.id, groupId: null }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect( + caller.expense.addOrEditExpense({ ...input, expenseId: expense!.id, groupId: otherGroup.id }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(await db.expense.count()).toBe(1); + expect(await accountingSnapshot()).toEqual(before); + }); + + it('applies the same membership, edit and archive guards to linked currency conversions', async () => { + const { owner, member, outsider, group } = await testScenario(); + const conversion = { + amount: 1000n, + rate: 0.9, + from: 'USD', + to: 'EUR', + senderId: owner.id, + receiverId: member.id, + groupId: group.id, + }; + await expect( + callerFor(outsider.id).expense.addOrEditCurrencyConversion(conversion), + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await callerFor(owner.id).expense.addOrEditCurrencyConversion(conversion); + const expense = await db.expense.findFirstOrThrow({ where: { conversionToId: { not: null } } }); + const before = await accountingSnapshot(); + await expect( + callerFor(outsider.id).expense.addOrEditCurrencyConversion({ + ...conversion, + expenseId: expense.id, + }), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + await db.group.update({ where: { id: group.id }, data: { archivedAt: new Date() } }); + await expect( + callerFor(owner.id).expense.addOrEditCurrencyConversion(conversion), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect( + callerFor(owner.id).expense.addOrEditCurrencyConversion({ + ...conversion, + expenseId: expense.id, + }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect( + callerFor(owner.id).expense.addOrEditCurrencyConversion({ + ...conversion, + expenseId: expense.id, + groupId: null, + }), + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(await accountingSnapshot()).toEqual(before); + }); +}); diff --git a/src/tests/integration/balances.integration.test.ts b/src/tests/integration/balances.integration.test.ts new file mode 100644 index 000000000..2e2708f9e --- /dev/null +++ b/src/tests/integration/balances.integration.test.ts @@ -0,0 +1,60 @@ +import { db } from './database'; +import { expenseInput, testGroup, testScenario } from './factories'; +import { callerFor } from './trpc'; + +describe('balance isolation and simplification', () => { + it('isolates groups and currencies and hides deleted expenses', async () => { + const { owner, member, group, input } = await testScenario(); + const otherGroup = await testGroup(owner.id, [member.id]); + const caller = callerFor(owner.id); + const [usd] = await caller.expense.addOrEditExpense(input); + await caller.expense.addOrEditExpense({ + ...input, + currency: 'EUR', + amount: 6000n, + participants: [ + { userId: owner.id, amount: 3000n }, + { userId: member.id, amount: -3000n }, + ], + }); + await caller.expense.addOrEditExpense({ ...input, groupId: otherGroup.id }); + expect(await db.balanceView.findMany({ where: { userId: owner.id } })).toHaveLength(3); + await caller.expense.deleteExpense({ expenseId: usd!.id }); + expect( + await db.balanceView.findFirst({ + where: { userId: owner.id, groupId: group.id, currency: 'EUR' }, + }), + ).toMatchObject({ amount: 3000n }); + expect( + await db.balanceView.findFirst({ where: { userId: owner.id, groupId: otherGroup.id } }), + ).toMatchObject({ amount: 1000n }); + expect( + (await caller.expense.getGroupExpenses({ groupId: group.id })).map( + (expense) => expense.currency, + ), + ).toEqual(['EUR']); + }); + + it('simplifies a three-member chain while preserving every member net balance', async () => { + const { owner, member, outsider, group } = await testScenario(); + await callerFor(owner.id).group.addMembers({ groupId: group.id, userIds: [outsider.id] }); + await callerFor(owner.id).expense.addOrEditExpense( + expenseInput(owner.id, member.id, { groupId: group.id }), + ); + await callerFor(member.id).expense.addOrEditExpense( + expenseInput(member.id, outsider.id, { groupId: group.id }), + ); + await callerFor(owner.id).group.toggleSimplifyDebts({ groupId: group.id }); + const simplified = await callerFor(owner.id).group.getGroupDetails({ groupId: group.id }); + expect(simplified?.groupBalances).toEqual( + expect.arrayContaining([ + expect.objectContaining({ userId: owner.id, friendId: outsider.id, amount: 1000n }), + expect.objectContaining({ userId: outsider.id, friendId: owner.id, amount: -1000n }), + ]), + ); + expect(simplified?.groupBalances.filter((balance) => 0n !== balance.amount)).toHaveLength(2); + expect( + (simplified?.groupBalances ?? []).reduce((total, balance) => total + balance.amount, 0n), + ).toBe(0n); + }); +}); diff --git a/src/tests/integration/database.ts b/src/tests/integration/database.ts new file mode 100644 index 000000000..2d494f452 --- /dev/null +++ b/src/tests/integration/database.ts @@ -0,0 +1,34 @@ +import { PrismaClient } from '@prisma/client'; + +import { assertTestDatabaseUrl, getTestDatabaseUrl } from '../helpers/testDatabase'; + +const databaseUrl = getTestDatabaseUrl(); +assertTestDatabaseUrl(databaseUrl); +const clientUrl = new URL(databaseUrl); +// Keep the suite's advisory lock and all operations on one connection. A second +// Integration process must fail rather than truncate another run's fixtures. +clientUrl.searchParams.set('connection_limit', '1'); +export const db = new PrismaClient({ datasourceUrl: clientUrl.toString() }); + +export const acquireDatabaseLock = async () => { + const [result] = await db.$queryRaw< + [{ locked: boolean }] + >`SELECT pg_try_advisory_lock(55439) AS locked`; + if (!result.locked) { + throw new Error('Another integration suite owns this database; use a separate test cluster'); + } +}; + +export const resetDatabase = async () => { + assertTestDatabaseUrl(databaseUrl); + // All application tables, including legacy balances and metadata. The migration + // Ledger and extension tables are deliberately outside this explicit list. + await db.$executeRawUnsafe(`TRUNCATE TABLE + "ExpenseParticipant", "ExpenseNote", "Expense", "ExpenseRecurrence", + "GroupDefaultSplit", "GroupUser", "Group", "FriendDefaultSplit", + "PushNotification", "CachedBankData", "CachedCurrencyRate", "Session", + "Account", "User", "VerificationToken", "Balance", "GroupBalance", "AppMetadata" + RESTART IDENTITY CASCADE`); + await db.$executeRaw`DELETE FROM cron.job_run_details`; + await db.$executeRaw`DELETE FROM cron.job`; +}; diff --git a/src/tests/integration/expense.integration.test.ts b/src/tests/integration/expense.integration.test.ts new file mode 100644 index 000000000..fd92d9d24 --- /dev/null +++ b/src/tests/integration/expense.integration.test.ts @@ -0,0 +1,141 @@ +import { SplitType } from '@prisma/client'; + +import { db } from './database'; +import { accountingSnapshot, testScenario } from './factories'; +import { callerFor } from './trpc'; + +describe('expense lifecycle', () => { + it('creates, edits and soft-deletes an expense with exact bidirectional balances', async () => { + const { owner, member, group, input } = await testScenario(); + const caller = callerFor(owner.id); + const [expense] = await caller.expense.addOrEditExpense(input); + expect(expense).toBeDefined(); + const balances = () => + db.balanceView.findMany({ where: { groupId: group.id }, orderBy: { userId: 'asc' } }); + expect(await balances()).toEqual([ + expect.objectContaining({ userId: owner.id, friendId: member.id, amount: 1000n }), + expect.objectContaining({ userId: member.id, friendId: owner.id, amount: -1000n }), + ]); + await caller.expense.addOrEditExpense({ + ...input, + expenseId: expense!.id, + amount: 6000n, + participants: [ + { userId: owner.id, amount: 3000n }, + { userId: member.id, amount: -3000n }, + ], + }); + expect((await balances()).map((balance) => balance.amount)).toEqual([3000n, -3000n]); + expect(await db.expenseParticipant.count({ where: { expenseId: expense!.id } })).toBe(2); + await caller.expense.deleteExpense({ expenseId: expense!.id }); + expect((await balances()).every((balance) => 0n === balance.amount)).toBe(true); + expect(await caller.expense.getGroupExpenses({ groupId: group.id })).toEqual([]); + expect(await db.expense.findUnique({ where: { id: expense!.id } })).toMatchObject({ + deletedBy: owner.id, + deletedAt: expect.any(Date), + }); + }); + + it.each([false, true])( + 'rolls back invalid participants, including cron jobs: recurring=$recurring', + async (recurring) => { + const { owner, input } = await testScenario(); + if (recurring) { + await callerFor(owner.id).expense.addOrEditExpense({ + ...input, + cronExpression: '0 0 1 1 *', + }); + } + const before = await accountingSnapshot(); + const jobsBefore = await db.job.findMany(); + // A missing user forces the nested write to fail after scheduling a recurring job. + const invalid = { + ...input, + participants: [{ userId: -1, amount: -1000n }], + ...(recurring ? { cronExpression: '0 0 1 1 *' } : {}), + }; + const log = jest.spyOn(console, 'error').mockImplementation(() => undefined); + try { + await expect(callerFor(owner.id).expense.addOrEditExpense(invalid)).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + }); + expect(await accountingSnapshot()).toEqual(before); + expect(await db.expenseRecurrence.count()).toBe(recurring ? 1 : 0); + expect(await db.job.findMany()).toEqual(jobsBefore); + } finally { + log.mockRestore(); + } + }, + ); + + it('preserves the original expense if an edit transaction fails', async () => { + const { owner, input } = await testScenario(); + const caller = callerFor(owner.id); + const [expense] = await caller.expense.addOrEditExpense(input); + const before = await accountingSnapshot(); + const log = jest.spyOn(console, 'error').mockImplementation(() => undefined); + try { + await expect( + caller.expense.addOrEditExpense({ + ...input, + expenseId: expense!.id, + amount: 999n, + participants: [{ userId: -1, amount: -999n }], + }), + ).rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR' }); + expect(await db.expense.findUnique({ where: { id: expense!.id } })).toMatchObject({ + amount: 2000n, + }); + expect(await db.expenseParticipant.count()).toBe(2); + expect(await accountingSnapshot()).toEqual(before); + } finally { + log.mockRestore(); + } + }); +}); + +describe('settlement accounting', () => { + it.each([false, true])( + 'partially then fully settles debt in either direction: reverse=%s', + async (reverse) => { + const { owner, member, input, group } = await testScenario(); + await callerFor(owner.id).expense.addOrEditExpense(input); + const payerId = reverse ? owner.id : member.id; + const receiverId = reverse ? member.id : owner.id; + if (reverse) { + await db.expense.deleteMany(); + await callerFor(member.id).expense.addOrEditExpense({ + ...input, + paidBy: member.id, + participants: [ + { userId: member.id, amount: 1000n }, + { userId: owner.id, amount: -1000n }, + ], + }); + } + const settle = (amount: bigint) => + callerFor(payerId).expense.addOrEditExpense({ + ...input, + name: 'Settlement', + splitType: SplitType.SETTLEMENT, + amount, + paidBy: payerId, + participants: [ + { userId: payerId, amount }, + { userId: receiverId, amount: -amount }, + ], + }); + await settle(400n); + expect( + await db.balanceView.findFirst({ where: { groupId: group.id, userId: payerId } }), + ).toMatchObject({ amount: -600n }); + await settle(600n); + expect((await db.balanceView.findMany()).every((balance) => 0n === balance.amount)).toBe( + true, + ); + expect(await callerFor(owner.id).group.getGroupTotals({ groupId: group.id })).toEqual([ + expect.objectContaining({ currency: 'USD', _sum: { amount: 2000n } }), + ]); + }, + ); +}); diff --git a/src/tests/integration/factories.ts b/src/tests/integration/factories.ts new file mode 100644 index 000000000..f2d1bc131 --- /dev/null +++ b/src/tests/integration/factories.ts @@ -0,0 +1,35 @@ +import { + createDatabaseGroup, + createDatabaseUser, + expenseInput, +} from '../helpers/databaseFactories'; +import { db } from './database'; + +export { expenseInput }; +export const testUser = (name = 'Test User') => createDatabaseUser(db, { name }); +export const testGroup = (ownerId: number, memberIds: number[] = []) => + createDatabaseGroup(db, ownerId, memberIds); + +export const testScenario = async () => { + const owner = await testUser('Owner'); + const member = await testUser('Member'); + const outsider = await testUser('Outsider'); + const group = await testGroup(owner.id, [member.id]); + return { + owner, + member, + outsider, + group, + input: expenseInput(owner.id, member.id, { groupId: group.id }), + }; +}; + +export const accountingSnapshot = async () => ({ + expenses: await db.expense.findMany({ orderBy: { id: 'asc' } }), + participants: await db.expenseParticipant.findMany({ + orderBy: [{ expenseId: 'asc' }, { userId: 'asc' }], + }), + balances: await db.balanceView.findMany({ + orderBy: [{ groupId: 'asc' }, { currency: 'asc' }, { userId: 'asc' }, { friendId: 'asc' }], + }), +}); diff --git a/src/tests/integration/recurrence.integration.test.ts b/src/tests/integration/recurrence.integration.test.ts new file mode 100644 index 000000000..9906c1b88 --- /dev/null +++ b/src/tests/integration/recurrence.integration.test.ts @@ -0,0 +1,51 @@ +import { db } from './database'; +import { testScenario } from './factories'; +import { callerFor } from './trpc'; + +describe('recurrence lifecycle', () => { + it('duplicates participants and balances, edits the schedule, and stops only when the template is deleted', async () => { + const { owner, member, input, group } = await testScenario(); + const caller = callerFor(owner.id); + const [template] = await caller.expense.addOrEditExpense({ + ...input, + cronExpression: '0 0 1 1 *', + }); + const recurrence = await db.expenseRecurrence.findFirstOrThrow({ include: { job: true } }); + expect(recurrence.job.schedule).toBe('0 0 1 1 *'); + const [duplicate] = await db.$queryRaw<[{ id: string }]>` + SELECT duplicate_expense_with_participants(${template!.id}::uuid)::text AS id + `; + expect( + await db.expense.findUnique({ + where: { id: duplicate.id }, + include: { expenseParticipants: true }, + }), + ).toMatchObject({ + amount: input.amount, + recurrenceId: recurrence.id, + expenseParticipants: expect.arrayContaining([ + expect.objectContaining({ userId: owner.id, amount: 1000n }), + expect.objectContaining({ userId: member.id, amount: -1000n }), + ]), + }); + expect( + await db.balanceView.findFirst({ where: { userId: owner.id, groupId: group.id } }), + ).toMatchObject({ amount: 2000n }); + await caller.expense.addOrEditExpense({ + ...input, + expenseId: template!.id, + cronExpression: '0 12 1 1 *', + }); + expect(await db.job.findUnique({ where: { jobid: recurrence.jobId } })).toMatchObject({ + schedule: '0 12 1 1 *', + }); + await caller.expense.deleteExpense({ expenseId: duplicate.id }); + expect(await db.job.count()).toBe(1); + expect( + await db.balanceView.findFirst({ where: { userId: owner.id, groupId: group.id } }), + ).toMatchObject({ amount: 1000n }); + await caller.expense.deleteExpense({ expenseId: template!.id }); + expect(await db.job.count()).toBe(0); + expect(await db.expenseRecurrence.count()).toBe(0); + }); +}); diff --git a/src/tests/integration/trpc.ts b/src/tests/integration/trpc.ts new file mode 100644 index 000000000..1ab394b20 --- /dev/null +++ b/src/tests/integration/trpc.ts @@ -0,0 +1,22 @@ +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 | null) => + appRouter.createCaller( + createInnerTRPCContext({ session: null === userId ? null : sessionFor(userId), db }), + ); diff --git a/tests/inviteCooldown.test.ts b/src/tests/inviteCooldown.test.ts similarity index 100% rename from tests/inviteCooldown.test.ts rename to src/tests/inviteCooldown.test.ts diff --git a/tests/inviteErrors.test.ts b/src/tests/inviteErrors.test.ts similarity index 100% rename from tests/inviteErrors.test.ts rename to src/tests/inviteErrors.test.ts diff --git a/tests/mailer.test.ts b/src/tests/mailer.test.ts similarity index 95% rename from tests/mailer.test.ts rename to src/tests/mailer.test.ts index 17913b0ff..4ca47533a 100644 --- a/tests/mailer.test.ts +++ b/src/tests/mailer.test.ts @@ -1,4 +1,4 @@ -import nodemailer from 'nodemailer'; +import nodemailer, { type Transporter } from 'nodemailer'; import { env } from '~/env'; import { sendToDiscord } from '~/server/service-notification'; @@ -22,6 +22,7 @@ jest.mock('nodemailer', () => { const sendMail = jest.fn(); return { __esModule: true, + mockSendMail: sendMail, default: { createTransport: jest.fn(() => ({ sendMail })) }, }; }); @@ -31,9 +32,9 @@ jest.mock('~/server/service-notification', () => ({ })); const mockCreateTransport = jest.mocked(nodemailer.createTransport); -const mockSendMail = jest.mocked( - mockCreateTransport({} as Parameters[0]).sendMail, -); +const { mockSendMail } = jest.requireMock<{ + mockSendMail: jest.MockedFunction; +}>('nodemailer'); const mockSendToDiscord = jest.mocked(sendToDiscord); const mockSentMessageInfo = { diff --git a/src/tests/setup/component.ts b/src/tests/setup/component.ts new file mode 100644 index 000000000..06a8f1b02 --- /dev/null +++ b/src/tests/setup/component.ts @@ -0,0 +1,37 @@ +import '@testing-library/jest-dom'; +import { act, cleanup } from '@testing-library/react'; + +import { clearTestQueryClients } from '../helpers/queryClient'; +import { resetStores } from '../helpers/resetStores'; + +jest.mock('next-i18next', () => ({ + useTranslation: require('~/tests/helpers/i18n').useTestTranslation, +})); + +afterEach(() => { + cleanup(); + clearTestQueryClients(); + act(resetStores); +}); + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: query === '(min-width: 768px)', + 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..38e67a415 --- /dev/null +++ b/src/tests/setup/integration.ts @@ -0,0 +1,13 @@ +jest.mock('~/server/db', () => ({ db: require('~/tests/integration/database').db })); +jest.mock('~/server/auth', () => ({ getServerAuthSession: jest.fn() })); +jest.mock('~/server/api/services/notificationService', () => ({ + sendExpensePushNotification: jest.fn().mockResolvedValue(undefined), + sendGroupSimplifyDebtsToggleNotification: jest.fn().mockResolvedValue(undefined), +})); + +import { acquireDatabaseLock, db, resetDatabase } from '~/tests/integration/database'; + +jest.setTimeout(30_000); +beforeAll(acquireDatabaseLock); +beforeEach(resetDatabase); +afterAll(() => db.$disconnect()); diff --git a/src/tests/setup/unit.ts b/src/tests/setup/unit.ts new file mode 100644 index 000000000..021a81ec8 --- /dev/null +++ b/src/tests/setup/unit.ts @@ -0,0 +1,3 @@ +if ('undefined' !== typeof window) { + require('./component'); +} diff --git a/src/tests/testDatabase.test.ts b/src/tests/testDatabase.test.ts new file mode 100644 index 000000000..fac3fa813 --- /dev/null +++ b/src/tests/testDatabase.test.ts @@ -0,0 +1,21 @@ +import { assertTestDatabaseUrl } from './helpers/testDatabase'; + +describe('disposable database guard', () => { + it.each([ + 'postgresql://user:password@localhost:55439/app_test', + 'postgresql://user:password@127.0.0.1:55439/app_test?schema=public', + 'postgresql://user:password@[::1]:55439/app_test', + ])('accepts a local disposable PostgreSQL URL: %s', (url) => { + expect(assertTestDatabaseUrl(url).pathname).toBe('/app_test'); + }); + it.each([ + 'postgresql://user:password@database.example/app_test', + 'postgresql://user:password@localhost/development', + 'postgresql://user:password@localhost/app_test_backup', + 'postgresql://user:password@localhost/app_test/production', + 'https://localhost/app_test', + 'not a URL', + ])('refuses unsafe or malformed database targets: %s', (url) => { + expect(() => assertTestDatabaseUrl(url)).toThrow(); + }); +}); diff --git a/tests/e2e/authorization.spec.ts b/tests/e2e/authorization.spec.ts new file mode 100644 index 000000000..7c98f3c01 --- /dev/null +++ b/tests/e2e/authorization.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from './fixtures'; + +test('redirects unauthenticated visitors to sign in', async ({ browser, baseURL }) => { + const context = await browser.newContext({ baseURL, storageState: { cookies: [], origins: [] } }); + try { + const page = await context.newPage(); + await page.goto('/en/balances'); + await expect(page).toHaveURL(/\/auth\/signin/); + } finally { + await context.close(); + } +}); + +test('renders authenticated protected content from the isolated scenario', async ({ + page, + scenario, +}) => { + await page.goto('/en/groups'); + await expect(page.getByText(scenario.group.name, { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Create a group', exact: true })).toBeVisible(); + await expect(page).toHaveURL(/\/groups$/); +}); diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 000000000..4a5460dc8 --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -0,0 +1,93 @@ +import { randomUUID } from 'node:crypto'; +import { type Group, PrismaClient, type User } from '@prisma/client'; +import { test as base, expect } from '@playwright/test'; + +import { + createDatabaseExpense, + createDatabaseGroup, + createDatabaseUser, + expenseInput, +} from '~/tests/helpers/databaseFactories'; +import { getTestDatabaseUrl } from '~/tests/helpers/testDatabase'; + +interface Scenario { + owner: User; + member: User; + group: Group; +} +interface Fixtures { + scenario: Scenario; + uniqueName: string; +} +interface WorkerFixtures { + db: PrismaClient; +} + +export const test = base.extend({ + db: [ + async ({ browserName: _browserName }, use) => { + const db = new PrismaClient({ datasourceUrl: getTestDatabaseUrl('e2e') }); + try { + await use(db); + } finally { + await db.$disconnect(); + } + }, + { scope: 'worker' }, + ], + uniqueName: async ({ browserName: _browserName }, use) => { + await use(`E2E ${randomUUID()}`); + }, + scenario: async ({ db, uniqueName }, use) => { + const users: User[] = []; + try { + // Sequential creation lets teardown clean even partially-created scenarios. + const owner = await createDatabaseUser(db, { name: 'E2E Owner' }); + users.push(owner); + const member = await createDatabaseUser(db, { name: 'E2E Member' }); + users.push(member); + const group = await createDatabaseGroup(db, owner.id, [member.id], { name: uniqueName }); + await use({ owner, member, group }); + } finally { + const userIds = users.map((user) => user.id); + await db.$transaction([ + db.expense.deleteMany({ + where: { + OR: [ + { addedBy: { in: userIds } }, + { paidBy: { in: userIds } }, + { expenseParticipants: { some: { userId: { in: userIds } } } }, + ], + }, + }), + db.group.deleteMany({ where: { userId: { in: userIds } } }), + db.user.deleteMany({ where: { id: { in: userIds } } }), + ]); + } + }, + storageState: async ({ db, scenario, baseURL }, use) => { + if (!baseURL) { + throw new Error('Missing E2E base URL'); + } + const token = randomUUID(); + const expires = new Date(Date.now() + 3_600_000); + await db.session.create({ data: { userId: scenario.owner.id, sessionToken: token, expires } }); + await use({ + cookies: [ + { + name: 'next-auth.session-token', + value: token, + domain: new URL(baseURL).hostname, + path: '/', + expires: expires.getTime() / 1000, + httpOnly: true, + secure: false, + sameSite: 'Lax', + }, + ], + origins: [], + }); + }, +}); + +export { createDatabaseExpense, expenseInput, expect }; diff --git a/tests/e2e/group-expense.spec.ts b/tests/e2e/group-expense.spec.ts new file mode 100644 index 000000000..e499ce8b1 --- /dev/null +++ b/tests/e2e/group-expense.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from './fixtures'; + +test('creates a group through the UI', async ({ page, uniqueName, scenario, db }) => { + await page.goto('/en/groups'); + await page.getByRole('button', { name: 'Create a group', exact: true }).click(); + await page.getByPlaceholder(/group name/i).fill(`${uniqueName} created`); + await page.getByRole('button', { name: /submit/i }).click(); + await expect(page).toHaveURL(/\/groups\/\d+$/); + await expect(page.getByText(`${uniqueName} created`, { exact: true })).toBeVisible(); + expect( + await db.group.findFirst({ + where: { userId: scenario.owner.id, name: `${uniqueName} created` }, + }), + ).toMatchObject({ userId: scenario.owner.id }); +}); + +test('records a two-member expense with exact persisted shares and debt', async ({ + page, + scenario, + uniqueName, + db, +}) => { + await page.goto(`/en/add?groupId=${scenario.group.id}`); + 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`, { exact: true })).toBeVisible(); + const expense = await db.expense.findFirstOrThrow({ + where: { groupId: scenario.group.id }, + include: { expenseParticipants: true }, + }); + expect(expense.amount).toBe(1234n); + expect(expense.expenseParticipants).toHaveLength(2); + expect(expense.expenseParticipants).toEqual( + expect.arrayContaining([ + expect.objectContaining({ userId: scenario.owner.id, amount: 617n }), + expect.objectContaining({ userId: scenario.member.id, amount: -617n }), + ]), + ); + expect( + await db.balanceView.findFirst({ + where: { groupId: scenario.group.id, userId: scenario.owner.id }, + }), + ).toMatchObject({ amount: 617n, currency: 'USD' }); +}); diff --git a/tests/e2e/settlement.spec.ts b/tests/e2e/settlement.spec.ts new file mode 100644 index 000000000..68b8564d6 --- /dev/null +++ b/tests/e2e/settlement.spec.ts @@ -0,0 +1,54 @@ +import { SplitType } from '@prisma/client'; +import { createDatabaseExpense, expect, expenseInput, test } from './fixtures'; + +test('partially then fully settles a real debt through the UI', async ({ page, scenario, db }) => { + await createDatabaseExpense( + db, + expenseInput(scenario.member.id, scenario.owner.id, { groupId: scenario.group.id }), + ); + await page.goto(`/en/balances/${scenario.member.id}`); + const settle = async (amount: string) => { + await page.getByRole('button', { name: 'Settle up', exact: true }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('textbox').fill(amount); + await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(dialog).not.toBeVisible(); + }; + await settle('4.00'); + await expect + .poll( + async () => + ( + await db.balanceView.findFirst({ + where: { + userId: scenario.owner.id, + groupId: scenario.group.id, + }, + }) + )?.amount, + ) + .toBe(-600n); + await page.reload(); + await expect(page.getByText('$6', { exact: true })).toBeVisible(); + await settle('6.00'); + await expect + .poll(async () => + ( + await db.balanceView.findMany({ + where: { + groupId: scenario.group.id, + }, + }) + ).every((balance) => 0n === balance.amount), + ) + .toBe(true); + await page.reload(); + await expect(page.getByRole('button', { name: 'Settle up', exact: true })).toBeDisabled(); + const settlements = await db.expense.findMany({ + where: { groupId: scenario.group.id, splitType: SplitType.SETTLEMENT }, + orderBy: { createdAt: 'asc' }, + }); + expect(settlements.map((expense) => expense.amount)).toEqual([400n, 600n]); + expect(settlements.every((expense) => expense.paidBy === scenario.owner.id)).toBe(true); +}); diff --git a/tests/e2e/transport.spec.ts b/tests/e2e/transport.spec.ts new file mode 100644 index 000000000..2988bb190 --- /dev/null +++ b/tests/e2e/transport.spec.ts @@ -0,0 +1,42 @@ +import { createTRPCClient, httpBatchLink } from '@trpc/client'; +import superjson from 'superjson'; + +import type { AppRouter } from '~/server/api/root'; +import { expect, expenseInput, test } from './fixtures'; + +test('round-trips BigInts and Dates through the authenticated Next.js tRPC endpoint', async ({ + page, + baseURL, + scenario, +}) => { + const client = createTRPCClient({ + links: [ + httpBatchLink({ + url: `${baseURL}/api/trpc`, + transformer: superjson, + fetch: async (url, options) => { + const requestUrl = + 'string' === typeof url ? url : url instanceof URL ? url.href : url.url; + const response = await page.request.fetch(requestUrl, { + method: options?.method, + headers: { 'content-type': 'application/json' }, + data: options?.body, + }); + return new Response(await response.text(), { + status: response.status(), + headers: { 'content-type': 'application/json' }, + }); + }, + }), + ], + }); + const expenseDate = new Date('2026-01-01T12:00:00Z'); + const [expense] = await client.expense.addOrEditExpense.mutate({ + ...expenseInput(scenario.owner.id, scenario.member.id, { groupId: scenario.group.id }), + expenseDate, + }); + expect(expense).toBeDefined(); + const details = await client.expense.getExpenseDetails.query({ expenseId: expense!.id }); + expect(details?.amount).toBe(2000n); + expect(details?.expenseDate).toEqual(expenseDate); +}); diff --git a/tsconfig.json b/tsconfig.json index f11f7096c..02c202013 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,7 @@ /* Bundled projects */ "lib": ["dom", "dom.iterable", "ES2023", "WebWorker"], "noEmit": true, + "allowImportingTsExtensions": true, "module": "ESNext", "moduleResolution": "Bundler", "jsx": "preserve", @@ -39,5 +40,5 @@ "**/*.js", ".next/types/**/*.ts" ], - "exclude": ["node_modules", "public"] + "exclude": ["node_modules", "public", "playwright-report", "test-results"] }