From f3e4aef9969ffb6798539c0c25c94349cd2ed6ec Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 16 Aug 2026 18:28:39 +0200 Subject: [PATCH 01/17] Bootstrap testing harness --- jest.config.ts | 195 +--------------- jest.integration.config.ts | 18 ++ package.json | 10 +- pnpm-lock.yaml | 413 ++++++++++++++++++--------------- src/tests/setup/component.ts | 1 + src/tests/setup/integration.ts | 1 + 6 files changed, 260 insertions(+), 378 deletions(-) create mode 100644 jest.integration.config.ts create mode 100644 src/tests/setup/component.ts create mode 100644 src/tests/setup/integration.ts diff --git a/jest.config.ts b/jest.config.ts index f45658bc4..5d937f800 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,214 +1,25 @@ -/** - * 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 + setupFilesAfterEnv: ['/src/tests/setup/component.ts'], testEnvironment: 'jsdom', - - // Options that will be passed to the testEnvironment - // testEnvironmentOptions: {}, - - // Adds a location field to test results - // testLocationInResults: false, - - // The glob patterns Jest uses to detect test files - // testMatch: [ - // "**/__tests__/**/*.[jt]s?(x)", - // "**/?(*.)+(spec|test).[tj]s?(x)" - // ], - - // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped - // testPathIgnorePatterns: [ - // "\\\\node_modules\\\\" - // ], - - // The regexp pattern or array of patterns that Jest uses to detect test files - // testRegex: [], - - // This option allows the use of a custom results processor - // testResultsProcessor: undefined, - - // This option allows use of a custom test runner - // testRunner: "jest-circus/runner", - - // A map from regular expressions to paths to transformers - // transform: undefined, - - // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation - // transformIgnorePatterns: [ - // "\\\\node_modules\\\\", - // "\\.pnp\\.[^\\\\]+$" - // ], - - // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them - // unmockedModulePathPatterns: undefined, - - // Indicates whether each individual test should be reported during the run - // verbose: undefined, - - // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode - // watchPathIgnorePatterns: [], - - // Whether to use watchman for file crawling - // watchman: true, + testMatch: ['/src/**/*.{test,spec}.{ts,tsx}'], + testPathIgnorePatterns: ['/src/tests/integration/'], }; export default createJestConfig(config); diff --git a/jest.integration.config.ts b/jest.integration.config.ts new file mode 100644 index 000000000..4745972a6 --- /dev/null +++ b/jest.integration.config.ts @@ -0,0 +1,18 @@ +import type { Config } from 'jest'; +import nextJest from 'next/jest.js'; + +const createJestConfig = nextJest({ + dir: './', +}); + +const config: Config = { + coverageProvider: 'v8', + moduleNameMapper: { + '^~/(.*)$': '/src/$1', + }, + setupFilesAfterEnv: ['/src/tests/setup/integration.ts'], + testEnvironment: 'node', + testMatch: ['/src/tests/integration/**/*.{test,spec}.{ts,tsx}'], +}; + +export default createJestConfig(config); diff --git a/package.json b/package.json index ef4d4f0ac..69f2be72a 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,10 @@ "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:integration": "jest --config jest.integration.config.ts", + "test:integration:watch": "jest --config jest.integration.config.ts --watch", "prepare": "husky" }, "dependencies": { @@ -81,9 +83,13 @@ }, "devDependencies": { "@faker-js/faker": "10.1.0", + "@playwright/test": "^1.55.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@tailwindcss/postcss": "^4.1.10", "@types/formidable": "^3.4.6", - "@types/jest": "^29.5.14", + "@types/jest": "^30.0.0", "@types/node": "^22.15.21", "@types/nodemailer": "^8.0.0", "@types/react": "19.2.1", diff --git a/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/src/tests/setup/component.ts b/src/tests/setup/component.ts new file mode 100644 index 000000000..7b0828bfa --- /dev/null +++ b/src/tests/setup/component.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/src/tests/setup/integration.ts b/src/tests/setup/integration.ts new file mode 100644 index 000000000..27c713348 --- /dev/null +++ b/src/tests/setup/integration.ts @@ -0,0 +1 @@ +jest.setTimeout(30_000); From 058070ca7117c3e551674e8d038f41aa160b66f6 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 16 Aug 2026 18:34:04 +0200 Subject: [PATCH 02/17] test: add browser testing harness --- .env.example | 5 +++ .gitignore | 2 ++ playwright.config.ts | 63 +++++++++++++++++++++++++++++++++ src/env.ts | 2 ++ src/instrumentation.ts | 8 +++-- tests/e2e/auth.setup.ts | 25 +++++++++++++ tests/e2e/authorization.spec.ts | 15 ++++++++ tests/e2e/fixtures.ts | 21 +++++++++++ tests/e2e/group-expense.spec.ts | 16 +++++++++ tests/e2e/settlement.spec.ts | 7 ++++ 10 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 playwright.config.ts create mode 100644 tests/e2e/auth.setup.ts create mode 100644 tests/e2e/authorization.spec.ts create mode 100644 tests/e2e/fixtures.ts create mode 100644 tests/e2e/group-expense.spec.ts create mode 100644 tests/e2e/settlement.spec.ts diff --git a/.env.example b/.env.example index 100d27125..f6b6b0008 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,11 @@ DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@splitpro-db:5432 # https://next-auth.js.org/configuration/options#secret NEXTAUTH_SECRET="secret" NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_URL_INTERNAL="http://localhost:3000" + +# Playwright uses only this disposable database and port. +E2E_DATABASE_URL="postgresql://postgres:strong-password@localhost:5432/splitpro_test" +E2E_BASE_URL="http://127.0.0.1:3176" # The default /home page is a blog page that may not be suitable for your use case. # You can change it to /balances or any other URL you want. diff --git a/.gitignore b/.gitignore index 662b9383d..6cbe568ab 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ # testing /coverage +/test-results +/playwright/.auth # database /prisma/db.sqlite diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..f624662eb --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,63 @@ +import { defineConfig, devices } from '@playwright/test'; + +const port = 3176; +const databaseUrl = process.env.E2E_DATABASE_URL ?? 'postgresql://postgres:strong-password@localhost:5432/splitpro_test'; + +if (!databaseUrl.includes('_test')) { + throw new Error('E2E_DATABASE_URL must point at a disposable *_test database'); +} + +process.env.DATABASE_URL = databaseUrl; +process.env.NODE_ENV = 'test'; +process.env.TEST_MODE = '1'; +process.env.NEXTAUTH_SECRET ??= 'playwright-test-secret'; +process.env.NEXTAUTH_URL = `http://127.0.0.1:${port}`; +process.env.NEXTAUTH_URL_INTERNAL = `http://127.0.0.1:${port}`; +process.env.SKIP_ENV_VALIDATION = '1'; +process.env.ENABLE_SENDING_INVITES = '0'; +process.env.DISABLE_EMAIL_SIGNUP = '0'; +process.env.INVITE_ONLY = '0'; + +export default defineConfig({ + testDir: './tests/e2e', + outputDir: 'test-results/e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? 'line' : 'list', + use: { + ...devices['Desktop Chrome'], + baseURL: `http://127.0.0.1:${port}`, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'on-first-retry', + }, + projects: [ + { name: 'setup', testMatch: /.*\.setup\.ts/ }, + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' }, + dependencies: ['setup'], + }, + ], + webServer: { + command: `pnpm dev --port ${port}`, + url: `http://127.0.0.1:${port}`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + ...process.env, + DATABASE_URL: databaseUrl, + NODE_ENV: 'test', + TEST_MODE: '1', + NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET ?? 'playwright-test-secret', + NEXTAUTH_URL: `http://127.0.0.1:${port}`, + NEXTAUTH_URL_INTERNAL: `http://127.0.0.1:${port}`, + SKIP_ENV_VALIDATION: '1', + ENABLE_SENDING_INVITES: '0', + DISABLE_EMAIL_SIGNUP: '0', + INVITE_ONLY: '0', + }, + }, +}); diff --git a/src/env.ts b/src/env.ts index 11c46a0e0..84ab8a7ce 100644 --- a/src/env.ts +++ b/src/env.ts @@ -17,6 +17,7 @@ export const env = createEnv({ 'You forgot to change the default URL', ), NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + TEST_MODE: z.boolean().default(false), DOCKER_OUTPUT: z.boolean().default(false), NEXTAUTH_SECRET: 'production' === process.env.NODE_ENV ? z.string() : z.string().optional(), NEXTAUTH_URL: z.preprocess( @@ -96,6 +97,7 @@ export const env = createEnv({ process.env.DATABASE_URL ?? `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOST}:${process.env.POSTGRES_PORT}`, NODE_ENV: process.env.NODE_ENV, + TEST_MODE: parseEnvBoolean(process.env.TEST_MODE), DOCKER_OUTPUT: parseEnvBoolean(process.env.DOCKER_OUTPUT), NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, NEXTAUTH_URL: process.env.NEXTAUTH_URL, diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 17672f6a9..490cfb972 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -18,8 +18,10 @@ export async function register() { const { checkRecurrenceNotifications } = await import('./server/api/services/notificationService'); - console.log('Starting recurrent expense notification checking...'); - setTimeout(checkRecurrenceNotifications, 1000 * 10); // Start after 10 seconds + if (!env.TEST_MODE) { + console.log('Starting recurrent expense notification checking...'); + setTimeout(checkRecurrenceNotifications, 1000 * 10); // Start after 10 seconds + } } if (process.env.NEXT_RUNTIME !== 'nodejs') { @@ -27,7 +29,7 @@ export async function register() { return; } - if (env.CLEAR_CACHE_CRON_RULE && env.CACHE_RETENTION_INTERVAL) { + if (!env.TEST_MODE && env.CLEAR_CACHE_CRON_RULE && env.CACHE_RETENTION_INTERVAL) { // Create cron jobs console.log('Setting up cron jobs...'); diff --git a/tests/e2e/auth.setup.ts b/tests/e2e/auth.setup.ts new file mode 100644 index 000000000..46bf4dfbb --- /dev/null +++ b/tests/e2e/auth.setup.ts @@ -0,0 +1,25 @@ +import { randomBytes } from 'node:crypto'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { test as setup } from '@playwright/test'; +import { db } from '~/server/db'; + +const authDir = join(process.cwd(), 'playwright', '.auth'); +const email = `playwright-${Date.now()}-${randomBytes(4).toString('hex')}@example.test`; + +setup('create database-backed session', async () => { + const user = await db.user.create({ data: { email, name: 'Playwright Owner', preferredLanguage: 'en' } }); + const sessionToken = randomBytes(32).toString('hex'); + await db.session.create({ + data: { sessionToken, userId: user.id, expires: new Date(Date.now() + 86_400_000) }, + }); + await mkdir(authDir, { recursive: true }); + await writeFile(join(authDir, 'user-meta.json'), JSON.stringify({ userId: user.id, email })); + await writeFile( + join(authDir, 'user.json'), + JSON.stringify({ + cookies: [{ name: 'next-auth.session-token', value: sessionToken, domain: '127.0.0.1', path: '/', httpOnly: true, sameSite: 'Lax' }], + origins: [], + }), + ); +}); diff --git a/tests/e2e/authorization.spec.ts b/tests/e2e/authorization.spec.ts new file mode 100644 index 000000000..562f97a58 --- /dev/null +++ b/tests/e2e/authorization.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from './fixtures'; + +test('redirects unauthenticated visitors to sign in', async ({ browser }) => { + const context = await browser.newContext(); + const page = await context.newPage(); + await page.goto('/groups'); + await expect(page).toHaveURL(/\/auth\/signin/); + await context.close(); +}); + +test('keeps authenticated users on protected pages', async ({ page }) => { + await page.goto('/groups'); + await expect(page).not.toHaveURL(/\/auth\/signin/); + await expect(page.getByRole('main')).toBeVisible(); +}); diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 000000000..f4862aade --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -0,0 +1,21 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { test as base, expect } from '@playwright/test'; +import { db } from '~/server/db'; + +type Fixtures = { userId: number; uniqueName: string }; +type AuthMeta = { userId: number }; + +const metaPath = join(process.cwd(), 'playwright', '.auth', 'user-meta.json'); + +export const test = base.extend({ + userId: async ({}, use) => { + const meta = JSON.parse(await readFile(metaPath, 'utf8')) as AuthMeta; + await use(meta.userId); + }, + uniqueName: async ({}, use, testInfo) => { + await use(`E2E ${testInfo.project.name} ${testInfo.workerIndex} ${testInfo.testId}`); + }, +}); + +export { db, expect }; diff --git a/tests/e2e/group-expense.spec.ts b/tests/e2e/group-expense.spec.ts new file mode 100644 index 000000000..fbdcc7370 --- /dev/null +++ b/tests/e2e/group-expense.spec.ts @@ -0,0 +1,16 @@ +import { test, expect } from './fixtures'; + +test('creates an isolated group and records an expense', async ({ page, uniqueName }) => { + await page.goto('/groups'); + await page.getByRole('button', { name: /create group/i }).click(); + await page.getByPlaceholder(/group name/i).fill(uniqueName); + await page.getByRole('button', { name: /submit/i }).click(); + await expect(page.getByText(uniqueName)).toBeVisible(); + + await page.getByRole('link', { name: /add expense/i }).click(); + await page.getByPlaceholder(/description/i).fill(`${uniqueName} expense`); + await page.getByPlaceholder(/amount/i).fill('12.34'); + await page.getByRole('button', { name: /^save$/i }).last().click(); + await expect(page).toHaveURL(/\/groups\/\d+\/expenses\//); + await expect(page.getByText(`${uniqueName} expense`)).toBeVisible(); +}); diff --git a/tests/e2e/settlement.spec.ts b/tests/e2e/settlement.spec.ts new file mode 100644 index 000000000..c61cc6574 --- /dev/null +++ b/tests/e2e/settlement.spec.ts @@ -0,0 +1,7 @@ +import { test, expect } from './fixtures'; + +test('shows the group balance and settlement controls', async ({ page }) => { + await page.goto('/balances'); + await expect(page).toHaveURL(/\/balances/); + await expect(page.getByRole('main')).toBeVisible(); +}); From bb21b579f72b1060ef93975320435e778f046cab Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 16 Aug 2026 18:38:02 +0200 Subject: [PATCH 03/17] test: add UI component harness --- src/components/Friend/Settleup.test.tsx | 81 +++++++++++++++++++++++ src/components/Layout/MainLayout.test.tsx | 42 ++++++++++++ src/components/group/CreateGroup.test.tsx | 81 +++++++++++++++++++++++ src/tests/helpers/render.tsx | 67 +++++++++++++++++++ src/tests/helpers/resetStores.ts | 14 ++++ src/tests/helpers/router.ts | 36 ++++++++++ src/tests/helpers/session.ts | 23 +++++++ 7 files changed, 344 insertions(+) create mode 100644 src/components/Friend/Settleup.test.tsx create mode 100644 src/components/Layout/MainLayout.test.tsx create mode 100644 src/components/group/CreateGroup.test.tsx create mode 100644 src/tests/helpers/render.tsx create mode 100644 src/tests/helpers/resetStores.ts create mode 100644 src/tests/helpers/router.ts create mode 100644 src/tests/helpers/session.ts diff --git a/src/components/Friend/Settleup.test.tsx b/src/components/Friend/Settleup.test.tsx new file mode 100644 index 000000000..c9e0b4284 --- /dev/null +++ b/src/components/Friend/Settleup.test.tsx @@ -0,0 +1,81 @@ +import { SplitType } from '@prisma/client'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithProviders } from '~/tests/helpers/render'; +import { resetStores } from '~/tests/helpers/resetStores'; +import type { MinimalBalance } from '~/types/balance.types'; + +const mutate = jest.fn(); +const invalidate = jest.fn().mockResolvedValue(undefined); + +jest.mock('~/utils/api', () => ({ + api: { + expense: { addOrEditExpense: { useMutation: () => ({ mutate }) } }, + useUtils: () => ({ user: { invalidate }, expense: { invalidate } }), + }, +})); +jest.mock('next-auth/react', () => ({ + SessionProvider: ({ children }: React.PropsWithChildren) => children, + useSession: () => ({ + data: { user: { id: 1, name: 'Alex Example', email: 'alex@example.com', image: null } }, + status: 'authenticated', + }), +})); +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => + ({ + 'actions.save': 'Save', + 'actions.settle_up': 'Settle up', + 'ui.settle_up_name': 'Settlement', + 'ui.select_balance': 'Select balance', + 'actors.you': 'You', + 'ui.expense.you.pay': 'pay', + 'ui.expense.user.pay': 'pays', + })[key] ?? key, + i18n: { language: 'en' }, + }), +})); + +const { SettleUp } = require('./Settleup') as typeof import('./Settleup'); + +const friend = { id: 2, name: 'Sam Friend', email: 'sam@example.com', image: null } as never; +const balance = { + currency: 'USD', + amount: -1250n, + friendId: 2, + groupId: 7, + groupName: 'Trip', +} satisfies MinimalBalance; + +describe('SettleUp', () => { + afterEach(resetStores); + + beforeEach(() => mutate.mockClear()); + + it('submits a settlement with the correct BigInt direction', async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Settle up' })); + await user.click(await screen.findByRole('button', { name: 'Save' })); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(mutate.mock.calls[0]?.[0]).toMatchObject({ + amount: 1250n, + splitType: SplitType.SETTLEMENT, + paidBy: 1, + groupId: 7, + participants: [ + { userId: 1, amount: 1250n }, + { userId: 2, amount: -1250n }, + ], + }); + }); +}); diff --git a/src/components/Layout/MainLayout.test.tsx b/src/components/Layout/MainLayout.test.tsx new file mode 100644 index 000000000..d09e9c97d --- /dev/null +++ b/src/components/Layout/MainLayout.test.tsx @@ -0,0 +1,42 @@ +import { screen } from '@testing-library/react'; +import React from 'react'; + +import { renderWithProviders } from '~/tests/helpers/render'; +import { createMockRouter } from '~/tests/helpers/router'; +import { resetStores } from '~/tests/helpers/resetStores'; + +const mockRouter = createMockRouter({ pathname: '/groups/7' }); +jest.mock('next/router', () => ({ useRouter: () => mockRouter })); +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => + ({ + 'meta.application_name': 'SplitPro', + 'navigation.balances': 'Balances', + 'navigation.groups': 'Groups', + 'navigation.add_expense': 'Add Expense', + 'navigation.add': 'Add', + 'navigation.activity': 'Activity', + 'navigation.account': 'Account', + })[key] ?? key, + ready: true, + i18n: { language: 'en' }, + }), +})); + +const { default: MainLayout } = require('./MainLayout') as typeof import('./MainLayout'); + +it('renders navigation links and marks the active section', () => { + renderWithProviders( + +

Content

+
, + ); + + expect(screen.getAllByRole('link', { name: 'Groups' }).length).toBeGreaterThan(0); + expect(screen.getAllByRole('link', { name: 'Balances' }).length).toBeGreaterThan(0); + expect(screen.getByText('Content')).toBeInTheDocument(); + expect(screen.getAllByRole('link', { name: 'Groups' })[0]).toHaveAttribute('href', '/groups'); +}); + +afterEach(resetStores); diff --git a/src/components/group/CreateGroup.test.tsx b/src/components/group/CreateGroup.test.tsx new file mode 100644 index 000000000..e357c4cc1 --- /dev/null +++ b/src/components/group/CreateGroup.test.tsx @@ -0,0 +1,81 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithProviders } from '~/tests/helpers/render'; +import { resetStores } from '~/tests/helpers/resetStores'; + +const mutateAsync = jest.fn(); +const refetch = jest.fn().mockResolvedValue(undefined); +const push = jest.fn().mockResolvedValue(true); + +jest.mock('next/router', () => ({ useRouter: () => ({ push }) })); +jest.mock('~/utils/api', () => ({ + api: { + group: { create: { useMutation: () => ({ mutateAsync }) } }, + useUtils: () => ({ group: { getAllGroupsWithBalances: { refetch } } }), + }, +})); +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => + ({ + 'actions.cancel': 'Cancel', + 'actions.submit': 'Submit', + 'errors.name_required': 'Name is required', + 'group_details.create_group.title': 'Create group', + 'group_details.create_group.group_name_placeholder': 'Group name', + })[key] ?? key, + ready: true, + i18n: { language: 'en' }, + }), +})); + +const { CreateGroup } = require('./CreateGroup') as typeof import('./CreateGroup'); + +describe('CreateGroup', () => { + afterEach(resetStores); + + beforeEach(() => { + mutateAsync.mockReset().mockImplementation(async (_input, options) => { + options?.onSuccess?.({ id: 42 }); + return { id: 42 }; + }); + refetch.mockClear(); + push.mockClear().mockResolvedValue(true); + }); + + it('validates an empty name and submits the accessible form', async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'New group' })); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Submit' })); + expect(await screen.findByText('Name is required')).toBeInTheDocument(); + expect(mutateAsync).not.toHaveBeenCalled(); + }); + + it('creates the group, refreshes groups, and navigates to it', async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'New group' })); + await user.type(await screen.findByPlaceholderText('Group name'), 'Trip'); + await user.click(screen.getByRole('button', { name: 'Submit' })); + + await waitFor(() => + expect(mutateAsync).toHaveBeenCalledWith({ name: 'Trip' }, expect.anything()), + ); + expect(refetch).toHaveBeenCalled(); + expect(push).toHaveBeenCalledWith('/groups/42'); + }); +}); diff --git a/src/tests/helpers/render.tsx b/src/tests/helpers/render.tsx new file mode 100644 index 000000000..71b8af61c --- /dev/null +++ b/src/tests/helpers/render.tsx @@ -0,0 +1,67 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { type RenderOptions, render } from '@testing-library/react'; +import { SessionProvider } from 'next-auth/react'; +import { ThemeProvider } from 'next-themes'; +import React from 'react'; + +import { CurrencyHelpersProvider } from '~/contexts/CurrencyHelpersContext'; +import { createTestSession } from './session'; + +export const englishTranslations: Record = { + 'actions.back': 'Back', + 'actions.cancel': 'Cancel', + 'actions.save': 'Save', + 'actions.submit': 'Submit', + 'actors.you': 'You', + 'errors.name_required': 'Name is required', + 'errors.saving_expense': 'Error while saving expense', + 'group_details.create_group.title': 'Create group', + 'group_details.create_group.group_name_placeholder': 'Group name', + 'navigation.account': 'Account', + 'navigation.activity': 'Activity', + 'navigation.add': 'Add', + 'navigation.add_expense': 'Add Expense', + 'navigation.balances': 'Balances', + 'navigation.groups': 'Groups', + 'ui.expense.user.pay': 'pays', + 'ui.expense.you.pay': 'pay', + 'ui.settle_up_name': 'Settlement', + 'ui.select_balance': 'Select balance', + 'meta.application_name': 'SplitPro', +}; + +const translate = (key: string) => englishTranslations[key] ?? key; + +jest.mock('next-i18next', () => ({ + useTranslation: () => ({ + t: translate, + ready: true, + i18n: { language: 'en' }, + }), +})); + +export interface AppRenderOptions extends Omit { + session?: ReturnType | null; + queryClient?: QueryClient; +} + +export const renderWithProviders = (ui: React.ReactElement, options: AppRenderOptions = {}) => { + const { + session = createTestSession(), + queryClient = new QueryClient(), + ...renderOptions + } = options; + const Wrapper = ({ children }: React.PropsWithChildren) => ( + + + + + {children} + + + + + ); + + return render(ui, { wrapper: Wrapper, ...renderOptions }); +}; diff --git a/src/tests/helpers/resetStores.ts b/src/tests/helpers/resetStores.ts new file mode 100644 index 000000000..27cc3dd8a --- /dev/null +++ b/src/tests/helpers/resetStores.ts @@ -0,0 +1,14 @@ +import { useAddExpenseStore } from '~/store/addStore'; +import { useAppStore } from '~/store/appStore'; +import { useCurrencyPreferenceStore } from '~/store/currencyPreferenceStore'; + +const initialAddExpenseState = useAddExpenseStore.getState(); +const initialAppState = useAppStore.getState(); +const initialCurrencyPreferenceState = useCurrencyPreferenceStore.getState(); + +export const resetStores = () => { + useAddExpenseStore.setState(initialAddExpenseState, true); + useAppStore.setState(initialAppState, true); + useCurrencyPreferenceStore.setState(initialCurrencyPreferenceState, true); + window.sessionStorage.clear(); +}; diff --git a/src/tests/helpers/router.ts b/src/tests/helpers/router.ts new file mode 100644 index 000000000..283f52e01 --- /dev/null +++ b/src/tests/helpers/router.ts @@ -0,0 +1,36 @@ +import type { NextRouter } from 'next/router'; + +export interface MockRouterOptions { + pathname?: string; + asPath?: string; + query?: NextRouter['query']; + locale?: string; +} + +export const createMockRouter = (options: MockRouterOptions = {}): NextRouter => { + const router = { + pathname: options.pathname ?? '/', + route: options.pathname ?? '/', + asPath: options.asPath ?? options.pathname ?? '/', + query: options.query ?? {}, + locale: options.locale ?? 'en', + locales: ['en'], + defaultLocale: 'en', + domainLocales: undefined, + isReady: true, + isFallback: false, + isPreview: false, + isLocaleDomain: false, + basePath: '', + push: jest.fn().mockResolvedValue(true), + replace: jest.fn().mockResolvedValue(true), + reload: jest.fn(), + back: jest.fn(), + forward: jest.fn(), + prefetch: jest.fn().mockResolvedValue(undefined), + beforePopState: jest.fn(), + events: { on: jest.fn(), off: jest.fn(), emit: jest.fn() }, + } satisfies NextRouter; + + return router; +}; diff --git a/src/tests/helpers/session.ts b/src/tests/helpers/session.ts new file mode 100644 index 000000000..59ae14bb1 --- /dev/null +++ b/src/tests/helpers/session.ts @@ -0,0 +1,23 @@ +import type { Session } from 'next-auth'; + +export const testUser = { + id: 1, + name: 'Alex Example', + email: 'alex@example.com', + image: null, + currency: 'USD', + defaultCurrency: null, + preferredLanguage: 'en', + hiddenFriendIds: [], +}; + +export const createTestSession = (overrides: Partial = {}): Session => ({ + user: { ...testUser, ...overrides }, + expires: '2099-01-01T00:00:00.000Z', +}); + +export const createMockSession = (session: Session | null = createTestSession()) => ({ + data: session, + status: session ? ('authenticated' as const) : ('unauthenticated' as const), + update: jest.fn().mockResolvedValue(session), +}); From d0c9cf2ede12270addca7d2fe21897d29749c751 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 16 Aug 2026 18:42:13 +0200 Subject: [PATCH 04/17] Add database integration harness --- docker/test/compose.yml | 22 ++++++++ src/server/api/trpc.ts | 12 +++-- .../authorization.integration.test.ts | 29 +++++++++++ .../integration/balances.integration.test.ts | 26 ++++++++++ src/tests/integration/database.ts | 47 +++++++++++++++++ .../integration/expense.integration.test.ts | 51 +++++++++++++++++++ src/tests/integration/factories.ts | 49 ++++++++++++++++++ .../recurrence.integration.test.ts | 45 ++++++++++++++++ src/tests/integration/trpc.ts | 20 ++++++++ 9 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 docker/test/compose.yml create mode 100644 src/tests/integration/authorization.integration.test.ts create mode 100644 src/tests/integration/balances.integration.test.ts create mode 100644 src/tests/integration/database.ts create mode 100644 src/tests/integration/expense.integration.test.ts create mode 100644 src/tests/integration/factories.ts create mode 100644 src/tests/integration/recurrence.integration.test.ts create mode 100644 src/tests/integration/trpc.ts diff --git a/docker/test/compose.yml b/docker/test/compose.yml new file mode 100644 index 000000000..040e63e7b --- /dev/null +++ b/docker/test/compose.yml @@ -0,0 +1,22 @@ +name: split-pro-test + +services: + postgres: + image: ossapps/postgres:18.3-trixie + container_name: splitpro-testing-harness-db + environment: + POSTGRES_USER: splitpro + POSTGRES_PASSWORD: test-password + POSTGRES_DB: splitpro_harness_test + command: >- + postgres + -c shared_preload_libraries=pg_cron + -c cron.database_name=splitpro_harness_test + -c cron.timezone=UTC + ports: + - '55439:5432' + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U splitpro -d splitpro_harness_test'] + interval: 2s + timeout: 5s + retries: 15 diff --git a/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/src/tests/integration/authorization.integration.test.ts b/src/tests/integration/authorization.integration.test.ts new file mode 100644 index 000000000..ae9041b34 --- /dev/null +++ b/src/tests/integration/authorization.integration.test.ts @@ -0,0 +1,29 @@ +import { TRPCError } from '@trpc/server'; + +jest.mock('~/server/auth', () => ({ getServerAuthSession: jest.fn() })); +jest.mock('nanoid', () => ({ nanoid: () => 'integration-public-id' })); +jest.mock('superjson', () => ({ + default: { serialize: (value: unknown) => value, deserialize: (value: unknown) => value }, +})); +jest.mock('~/server/db', () => { + const { PrismaClient } = require('@prisma/client') as typeof import('@prisma/client'); + return { db: new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }) }; +}); + +import { resetDatabase } from './database'; +import { testGroup, testUser } from './factories'; +import { callerFor } from './trpc'; + +describe('authorization integration', () => { + beforeEach(() => resetDatabase()); + + it('rejects a non-member from group procedures', async () => { + const owner = await testUser('Owner'); + const outsider = await testUser('Outsider'); + const group = await testGroup(owner.id); + + await expect( + callerFor(outsider.id).group.getGroupDetails({ groupId: group.id }), + ).rejects.toMatchObject(new TRPCError({ code: 'FORBIDDEN', message: 'Not a group member' })); + }); +}); diff --git a/src/tests/integration/balances.integration.test.ts b/src/tests/integration/balances.integration.test.ts new file mode 100644 index 000000000..9e8a500c2 --- /dev/null +++ b/src/tests/integration/balances.integration.test.ts @@ -0,0 +1,26 @@ +jest.mock('~/server/db', () => { + const { PrismaClient } = require('@prisma/client') as typeof import('@prisma/client'); + return { db: new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }) }; +}); + +import { db } from '~/server/db'; +import { resetDatabase } from './database'; +import { testExpense, testUser } from './factories'; + +describe('balance view integration', () => { + beforeEach(() => resetDatabase()); + + it('calculates both sides of a double-entry balance', async () => { + const payer = await testUser('Payer'); + const participant = await testUser('Participant'); + await testExpense({ paidBy: payer.id, participantId: participant.id }); + + const balances = await db.balanceView.findMany({ orderBy: { userId: 'asc' } }); + expect(balances).toEqual( + expect.arrayContaining([ + expect.objectContaining({ userId: payer.id, friendId: participant.id, amount: 1_000n }), + expect.objectContaining({ userId: participant.id, friendId: payer.id, amount: -1_000n }), + ]), + ); + }); +}); diff --git a/src/tests/integration/database.ts b/src/tests/integration/database.ts new file mode 100644 index 000000000..5ea9bf596 --- /dev/null +++ b/src/tests/integration/database.ts @@ -0,0 +1,47 @@ +import { PrismaClient } from '@prisma/client'; + +import { db } from '~/server/db'; + +const TEST_DATABASE_PATTERN = /_test(?:[/?]|$)/i; + +export const assertTestDatabase = (url = process.env.DATABASE_URL) => { + if (!url) { + throw new Error('Integration tests require DATABASE_URL'); + } + + const parsed = new URL(url); + if (!['localhost', '127.0.0.1', '::1'].includes(parsed.hostname)) { + throw new Error(`Refusing integration tests against non-local database: ${parsed.hostname}`); + } + if (!TEST_DATABASE_PATTERN.test(parsed.pathname)) { + throw new Error( + `Refusing integration tests against database without _test suffix: ${parsed.pathname}`, + ); + } +}; + +assertTestDatabase(); + +export const resetDatabase = async () => { + assertTestDatabase(); + await db.$transaction([ + db.expenseParticipant.deleteMany(), + db.expenseNote.deleteMany(), + db.expense.deleteMany(), + db.expenseRecurrence.deleteMany(), + db.groupDefaultSplit.deleteMany(), + db.groupUser.deleteMany(), + db.group.deleteMany(), + db.friendDefaultSplit.deleteMany(), + db.pushNotification.deleteMany(), + db.cachedBankData.deleteMany(), + db.cachedCurrencyRate.deleteMany(), + db.session.deleteMany(), + db.account.deleteMany(), + db.user.deleteMany(), + ]); +}; + +export const closeDatabase = async () => db.$disconnect(); + +export const createTestClient = () => new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }); diff --git a/src/tests/integration/expense.integration.test.ts b/src/tests/integration/expense.integration.test.ts new file mode 100644 index 000000000..b5640a33e --- /dev/null +++ b/src/tests/integration/expense.integration.test.ts @@ -0,0 +1,51 @@ +jest.mock('~/server/api/services/notificationService', () => ({ + sendExpensePushNotification: jest.fn().mockResolvedValue(undefined), + sendGroupSimplifyDebtsToggleNotification: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('~/server/auth', () => ({ getServerAuthSession: jest.fn() })); +jest.mock('nanoid', () => ({ nanoid: () => 'integration-public-id' })); +jest.mock('superjson', () => ({ + default: { serialize: (value: unknown) => value, deserialize: (value: unknown) => value }, +})); +jest.mock('~/server/db', () => { + const { PrismaClient } = require('@prisma/client') as typeof import('@prisma/client'); + return { db: new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }) }; +}); + +import { SplitType } from '@prisma/client'; + +import { db } from '~/server/db'; +import { resetDatabase } from './database'; +import { testUser } from './factories'; +import { callerFor } from './trpc'; + +describe('expense integration', () => { + beforeEach(() => resetDatabase()); + + it('persists an expense transaction and exposes its balance view', async () => { + const payer = await testUser('Payer'); + const participant = await testUser('Participant'); + const caller = callerFor(payer.id); + + const [expense] = await caller.expense.addOrEditExpense({ + paidBy: payer.id, + name: 'Dinner', + category: 'Food', + amount: 2_500n, + groupId: null, + splitType: SplitType.EQUAL, + currency: 'USD', + participants: [ + { userId: payer.id, amount: 2_500n }, + { userId: participant.id, amount: -2_500n }, + ], + }); + + expect(expense?.id).toBeDefined(); + await expect(db.balanceView.findMany({ where: { groupId: null } })).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ userId: payer.id, friendId: participant.id, amount: 2_500n }), + ]), + ); + }); +}); diff --git a/src/tests/integration/factories.ts b/src/tests/integration/factories.ts new file mode 100644 index 000000000..9adf5534a --- /dev/null +++ b/src/tests/integration/factories.ts @@ -0,0 +1,49 @@ +import { type Prisma, SplitType } from '@prisma/client'; + +import { db } from '~/server/db'; + +let sequence = 0; + +export const testUser = async (name = `Test User ${sequence}`) => { + const id = sequence++; + return db.user.create({ + data: { name, email: `integration-${id}@splitpro.test`, currency: 'USD' }, + }); +}; + +export const testGroup = async (userId: number, name = `Test Group ${sequence++}`) => + db.group.create({ + data: { + name, + publicId: `integration-group-${sequence++}`, + userId, + groupUsers: { create: { userId } }, + }, + }); + +export const testExpense = async (input: { + paidBy: number; + participantId: number; + groupId?: number | null; + amount?: bigint; + name?: string; +}) => { + const amount = input.amount ?? 1_000n; + const data: Prisma.ExpenseCreateInput = { + name: input.name ?? `Test Expense ${sequence++}`, + category: 'Other', + amount, + currency: 'USD', + splitType: SplitType.EQUAL, + addedByUser: { connect: { id: input.paidBy } }, + paidByUser: { connect: { id: input.paidBy } }, + ...(input.groupId ? { group: { connect: { id: input.groupId } } } : {}), + expenseParticipants: { + create: [ + { userId: input.paidBy, amount }, + { userId: input.participantId, amount: -amount }, + ], + }, + }; + return db.expense.create({ data }); +}; diff --git a/src/tests/integration/recurrence.integration.test.ts b/src/tests/integration/recurrence.integration.test.ts new file mode 100644 index 000000000..c23fd5cd5 --- /dev/null +++ b/src/tests/integration/recurrence.integration.test.ts @@ -0,0 +1,45 @@ +jest.mock('~/server/api/services/notificationService', () => ({ + sendExpensePushNotification: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('~/server/auth', () => ({ getServerAuthSession: jest.fn() })); +jest.mock('nanoid', () => ({ nanoid: () => 'integration-public-id' })); +jest.mock('superjson', () => ({ + default: { serialize: (value: unknown) => value, deserialize: (value: unknown) => value }, +})); +jest.mock('~/server/db', () => { + const { PrismaClient } = require('@prisma/client') as typeof import('@prisma/client'); + return { db: new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }) }; +}); + +import { SplitType } from '@prisma/client'; + +import { db } from '~/server/db'; +import { resetDatabase } from './database'; +import { testUser } from './factories'; +import { callerFor } from './trpc'; + +describe('recurrence integration', () => { + beforeEach(() => resetDatabase()); + + it('creates a pg_cron-backed recurrence with an expense', async () => { + const user = await testUser(); + const caller = callerFor(user.id); + const [expense] = await caller.expense.addOrEditExpense({ + paidBy: user.id, + name: 'Recurring expense', + category: 'Other', + amount: 500n, + groupId: null, + splitType: SplitType.EQUAL, + currency: 'USD', + participants: [{ userId: user.id, amount: 500n }], + cronExpression: '0 0 * * *', + }); + + const recurrence = await db.expenseRecurrence.findFirst({ + include: { job: true, expense: true }, + }); + expect(recurrence?.expense[0]?.id).toBe(expense?.id); + expect(recurrence?.job.schedule).toBe('0 0 * * *'); + }); +}); diff --git a/src/tests/integration/trpc.ts b/src/tests/integration/trpc.ts new file mode 100644 index 000000000..fc9a50a31 --- /dev/null +++ b/src/tests/integration/trpc.ts @@ -0,0 +1,20 @@ +import { type Session } from 'next-auth'; + +import { appRouter } from '~/server/api/root'; +import { createInnerTRPCContext } from '~/server/api/trpc'; +import { db } from '~/server/db'; + +export const sessionFor = (userId: number): Session => ({ + user: { + id: userId, + name: `Integration User ${userId}`, + email: null, + currency: 'USD', + preferredLanguage: '', + hiddenFriendIds: [], + }, + expires: '2099-01-01T00:00:00.000Z', +}); + +export const callerFor = (userId: number) => + appRouter.createCaller(createInnerTRPCContext({ session: sessionFor(userId), db })); From 17c5a5d85f17ffd6b4f5c984013c7ab7813eb9e8 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 16 Aug 2026 18:43:55 +0200 Subject: [PATCH 05/17] test: fix harness formatting and type check --- playwright.config.ts | 5 +++-- tests/e2e/auth.setup.ts | 15 +++++++++++++-- tests/e2e/group-expense.spec.ts | 7 +++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index f624662eb..4097ef234 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,14 +1,15 @@ import { defineConfig, devices } from '@playwright/test'; const port = 3176; -const databaseUrl = process.env.E2E_DATABASE_URL ?? 'postgresql://postgres:strong-password@localhost:5432/splitpro_test'; +const databaseUrl = + process.env.E2E_DATABASE_URL ?? + 'postgresql://postgres:strong-password@localhost:5432/splitpro_test'; if (!databaseUrl.includes('_test')) { throw new Error('E2E_DATABASE_URL must point at a disposable *_test database'); } process.env.DATABASE_URL = databaseUrl; -process.env.NODE_ENV = 'test'; process.env.TEST_MODE = '1'; process.env.NEXTAUTH_SECRET ??= 'playwright-test-secret'; process.env.NEXTAUTH_URL = `http://127.0.0.1:${port}`; diff --git a/tests/e2e/auth.setup.ts b/tests/e2e/auth.setup.ts index 46bf4dfbb..d7c327757 100644 --- a/tests/e2e/auth.setup.ts +++ b/tests/e2e/auth.setup.ts @@ -8,7 +8,9 @@ const authDir = join(process.cwd(), 'playwright', '.auth'); const email = `playwright-${Date.now()}-${randomBytes(4).toString('hex')}@example.test`; setup('create database-backed session', async () => { - const user = await db.user.create({ data: { email, name: 'Playwright Owner', preferredLanguage: 'en' } }); + const user = await db.user.create({ + data: { email, name: 'Playwright Owner', preferredLanguage: 'en' }, + }); const sessionToken = randomBytes(32).toString('hex'); await db.session.create({ data: { sessionToken, userId: user.id, expires: new Date(Date.now() + 86_400_000) }, @@ -18,7 +20,16 @@ setup('create database-backed session', async () => { await writeFile( join(authDir, 'user.json'), JSON.stringify({ - cookies: [{ name: 'next-auth.session-token', value: sessionToken, domain: '127.0.0.1', path: '/', httpOnly: true, sameSite: 'Lax' }], + cookies: [ + { + name: 'next-auth.session-token', + value: sessionToken, + domain: '127.0.0.1', + path: '/', + httpOnly: true, + sameSite: 'Lax', + }, + ], origins: [], }), ); diff --git a/tests/e2e/group-expense.spec.ts b/tests/e2e/group-expense.spec.ts index fbdcc7370..c56c875a4 100644 --- a/tests/e2e/group-expense.spec.ts +++ b/tests/e2e/group-expense.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from './fixtures'; +import { expect, test } from './fixtures'; test('creates an isolated group and records an expense', async ({ page, uniqueName }) => { await page.goto('/groups'); @@ -10,7 +10,10 @@ test('creates an isolated group and records an expense', async ({ page, uniqueNa await page.getByRole('link', { name: /add expense/i }).click(); 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 page + .getByRole('button', { name: /^save$/i }) + .last() + .click(); await expect(page).toHaveURL(/\/groups\/\d+\/expenses\//); await expect(page.getByText(`${uniqueName} expense`)).toBeVisible(); }); From d30917e234786d97185df41df293b7f8de2af1e3 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 16 Aug 2026 18:48:32 +0200 Subject: [PATCH 06/17] docs: document testing harness CI --- .github/copilot-instructions.md | 11 ++++ .github/workflows/test-e2e.yml | 69 ++++++++++++++++++++++++++ .github/workflows/test-integration.yml | 55 ++++++++++++++++++++ AGENTS.md | 27 ++++++++++ docs/testing-strategy.md | 51 +++++++++++++++++++ 5 files changed, 213 insertions(+) create mode 100644 .github/workflows/test-e2e.yml create mode 100644 .github/workflows/test-integration.yml create mode 100644 docs/testing-strategy.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1cad5e837..8bf4cb6c8 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -258,3 +258,14 @@ library/API documentation. This means you should automatically use the Context7 tools to resolve library id and get library docs without me having to explicitly ask. Do not generate documentation or tests if not explicitly requested. + +## Testing Harness Rules + +- Keep fast checks independent from PostgreSQL integration and Chromium E2E jobs. +- Select unit/component tests with `pnpm test`; select database tests with + `pnpm test:integration`; select browser tests with `pnpm exec playwright test --project=chromium`. +- Integration and E2E commands may only use a disposable local database whose name ends in + `_test`. Never run reset, push, or destructive test setup against shared or production data. +- Read `docs/testing-strategy.md` before changing test selection, CI workflows, or database setup. +- When browser tests fail, inspect `test-results/e2e` and `playwright-report` artifacts before + changing application code. diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml new file mode 100644 index 000000000..ee815bc63 --- /dev/null +++ b/.github/workflows/test-e2e.yml @@ -0,0 +1,69 @@ +name: E2E Tests + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + e2e: + name: Chromium E2E tests + runs-on: ubuntu-latest + env: + DATABASE_URL: postgresql://splitpro:test-password@localhost:5432/splitpro_harness_test + E2E_DATABASE_URL: postgresql://splitpro:test-password@localhost:5432/splitpro_harness_test + SKIP_ENV_VALIDATION: '1' + services: + postgres: + image: ossapps/postgres:18.3-trixie + env: + POSTGRES_USER: splitpro + POSTGRES_PASSWORD: test-password + POSTGRES_DB: splitpro_harness_test + options: >- + --health-cmd "pg_isready -U splitpro -d splitpro_harness_test" + --health-interval 2s + --health-timeout 5s + --health-retries 15 + ports: + - 5432:5432 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v5 + with: + run_install: false + + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 22.16.0 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Prepare disposable database + run: pnpm exec prisma db push --accept-data-loss + + - name: Install Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Run Chromium E2E tests + run: pnpm exec playwright test --project=chromium + + - name: Upload Playwright artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-artifacts + path: | + test-results/e2e + playwright-report + if-no-files-found: ignore diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml new file mode 100644 index 000000000..0afb25265 --- /dev/null +++ b/.github/workflows/test-integration.yml @@ -0,0 +1,55 @@ +name: Integration Tests + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + integration: + name: PostgreSQL integration tests + runs-on: ubuntu-latest + env: + DATABASE_URL: postgresql://splitpro:test-password@localhost:5432/splitpro_harness_test + SKIP_ENV_VALIDATION: '1' + services: + postgres: + image: ossapps/postgres:18.3-trixie + env: + POSTGRES_USER: splitpro + POSTGRES_PASSWORD: test-password + POSTGRES_DB: splitpro_harness_test + options: >- + --health-cmd "pg_isready -U splitpro -d splitpro_harness_test" + --health-interval 2s + --health-timeout 5s + --health-retries 15 + ports: + - 5432:5432 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v5 + with: + run_install: false + + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 22.16.0 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Prepare disposable database + run: pnpm exec prisma db push --accept-data-loss + + - name: Run integration tests + run: pnpm test:integration diff --git a/AGENTS.md b/AGENTS.md index b1d556d9c..43339e35d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -323,6 +323,33 @@ Husky runs on commit: Override with `git commit --no-verify` if needed. +## Testing Harness Workflow + +Keep the fast checks (`pnpm prettier --check .`, `pnpm lint`, `pnpm tsgo --noEmit`, +`pnpm test`, and `pnpm build --no-lint`) independent from the disposable database and +browser jobs. CI runs PostgreSQL integration tests with `pnpm test:integration` and Chromium +E2E tests with `pnpm exec playwright test --project=chromium` in separate jobs. + +### Test Selection + +- `pnpm test` selects `src/**/*.{test,spec}.{ts,tsx}` and excludes `src/tests/integration/`. +- `pnpm test:integration` selects only `src/tests/integration/**/*.{test,spec}.{ts,tsx}`. +- `pnpm exec playwright test --project=chromium` selects `tests/e2e/` through + `playwright.config.ts`; setup runs before the Chromium project. +- Run one Jest file with `pnpm test src/tests/simplify.test.ts`, one integration file with + `pnpm test:integration src/tests/integration/expense.integration.test.ts`, or one browser + file with `pnpm exec playwright test tests/e2e/group-expense.spec.ts`. + +### Database Safety + +Integration and E2E databases must be local/disposable and end in `_test`. The integration +harness refuses non-local or non-test URLs; never point these commands at development, +staging, or production data. CI creates a fresh PostgreSQL service and may use +`prisma db push --accept-data-loss` because that database is disposable. Local worktrees +must use a separate PostgreSQL container, database name, and host port. + +See `docs/testing-strategy.md` for the complete command matrix and agent workflow. + Use the `ctx7` CLI to fetch current documentation whenever the user asks about a library, framework, SDK, API, CLI tool, or cloud service -- even well-known ones like React, Next.js, Prisma, Express, Tailwind, Django, or Spring Boot. This includes API syntax, configuration, version migration, library-specific debugging, setup instructions, and CLI tool usage. Use even when you think you know the answer -- your training data may not reflect recent changes. Prefer this over web search for library docs. diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md new file mode 100644 index 000000000..0f9f84b10 --- /dev/null +++ b/docs/testing-strategy.md @@ -0,0 +1,51 @@ +# Testing strategy + +SplitPro has three deliberately independent test surfaces. Fast checks fail quickly without +requiring PostgreSQL; database and browser checks each provision their own disposable service. + +## Command matrix + +| Purpose | Exact command | Selection | +| --- | --- | --- | +| Formatting | `pnpm prettier --check .` | All supported files | +| Lint | `pnpm lint` | Oxlint project sources | +| Types | `pnpm tsgo --noEmit` | TypeScript project | +| Unit/component tests | `pnpm test` | `src/**/*.{test,spec}.{ts,tsx}`, excluding `src/tests/integration/` | +| One unit file | `pnpm test src/tests/simplify.test.ts` | The named file | +| Integration tests | `pnpm test:integration` | `src/tests/integration/**/*.{test,spec}.{ts,tsx}` | +| One integration file | `pnpm test:integration src/tests/integration/expense.integration.test.ts` | The named file | +| Chromium E2E | `pnpm exec playwright test --project=chromium` | `tests/e2e/`, including setup dependency | +| One E2E file | `pnpm exec playwright test tests/e2e/group-expense.spec.ts` | The named file | +| Production build | `pnpm build --no-lint` | Next.js production build | + +The pull-request `Check` workflow runs formatting, lint, types, unit/component tests, and the +build. `Integration Tests` and `E2E Tests` are separate jobs and do not depend on `Check` or on +each other. E2E failures upload `test-results/e2e` and `playwright-report`. + +## Database safety + +Integration and E2E tests are destructive by design: they create, update, and delete records. +Use only a local disposable PostgreSQL database whose database name ends in `_test`. The +integration harness also requires a local host (`localhost`, `127.0.0.1`, or `::1`) and refuses +other URLs before tests run. CI creates a new `splitpro_harness_test` service for each job and +prepares it with: + +```bash +pnpm exec prisma db push --accept-data-loss +``` + +For local work, copy `.env.example`, use a dedicated container/database/port per worktree, and +set `DATABASE_URL` (and `E2E_DATABASE_URL` for Playwright) to that `_test` database. Do not use +`pnpm db:push`, reset, seed, or these test commands against development, staging, or production. +When the disposable container is no longer needed, stop it with the project’s test-container +workflow; never clean it by deleting data from a shared server. + +## Agent workflow + +1. Read this document and the existing harness/configuration before changing tests. +2. Make the smallest change in the owning packet; keep application, Prisma, Jest, manifest, and + lockfile changes out of CI/documentation work. +3. Run the narrowest affected command first, then `pnpm prettier --check .` and any available + fast checks. Run integration/E2E only with a disposable `_test` database. +4. For E2E failures, preserve and inspect Playwright traces, screenshots, videos, and reports. +5. Report exact commands and failures; do not weaken selection or database guards to make CI pass. From 9023721153ada6136ac4729f660fe8888e7635f1 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 16 Aug 2026 18:55:07 +0200 Subject: [PATCH 07/17] test: harden harness isolation and safety --- jest.integration.config.ts | 3 ++ playwright.config.ts | 29 +++++++++++++------ src/env.ts | 7 ++++- src/tests/integration/database.ts | 2 ++ .../integration/expense.integration.test.ts | 6 ++-- src/tests/setup/integration.ts | 6 ++++ 6 files changed, 41 insertions(+), 12 deletions(-) diff --git a/jest.integration.config.ts b/jest.integration.config.ts index 4745972a6..3af1260fa 100644 --- a/jest.integration.config.ts +++ b/jest.integration.config.ts @@ -7,12 +7,15 @@ const createJestConfig = nextJest({ const config: Config = { coverageProvider: 'v8', + cacheDirectory: '/tmp/splitpro-jest-cache', + cache: false, moduleNameMapper: { '^~/(.*)$': '/src/$1', }, setupFilesAfterEnv: ['/src/tests/setup/integration.ts'], testEnvironment: 'node', testMatch: ['/src/tests/integration/**/*.{test,spec}.{ts,tsx}'], + maxWorkers: 1, }; export default createJestConfig(config); diff --git a/playwright.config.ts b/playwright.config.ts index 4097ef234..9f1dc1ba1 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,19 +1,30 @@ import { defineConfig, devices } from '@playwright/test'; -const port = 3176; +const defaultPort = 3176; const databaseUrl = process.env.E2E_DATABASE_URL ?? 'postgresql://postgres:strong-password@localhost:5432/splitpro_test'; +const database = new URL(databaseUrl); +const baseUrl = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${defaultPort}`; +const baseUrlDetails = new URL(baseUrl); -if (!databaseUrl.includes('_test')) { - throw new Error('E2E_DATABASE_URL must point at a disposable *_test database'); +if ( + !['localhost', '127.0.0.1', '::1'].includes(database.hostname) || + !database.pathname.endsWith('_test') +) { + throw new Error('E2E_DATABASE_URL must point at a local disposable *_test database'); } +if (!['localhost', '127.0.0.1', '::1'].includes(baseUrlDetails.hostname)) { + throw new Error('E2E_BASE_URL must point at a local test server'); +} + +const port = Number(baseUrlDetails.port) || 80; process.env.DATABASE_URL = databaseUrl; process.env.TEST_MODE = '1'; process.env.NEXTAUTH_SECRET ??= 'playwright-test-secret'; -process.env.NEXTAUTH_URL = `http://127.0.0.1:${port}`; -process.env.NEXTAUTH_URL_INTERNAL = `http://127.0.0.1:${port}`; +process.env.NEXTAUTH_URL = baseUrl; +process.env.NEXTAUTH_URL_INTERNAL = baseUrl; process.env.SKIP_ENV_VALIDATION = '1'; process.env.ENABLE_SENDING_INVITES = '0'; process.env.DISABLE_EMAIL_SIGNUP = '0'; @@ -29,7 +40,7 @@ export default defineConfig({ reporter: process.env.CI ? 'line' : 'list', use: { ...devices['Desktop Chrome'], - baseURL: `http://127.0.0.1:${port}`, + baseURL: baseUrl, trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'on-first-retry', @@ -44,7 +55,7 @@ export default defineConfig({ ], webServer: { command: `pnpm dev --port ${port}`, - url: `http://127.0.0.1:${port}`, + url: baseUrl, reuseExistingServer: !process.env.CI, timeout: 120_000, env: { @@ -53,8 +64,8 @@ export default defineConfig({ NODE_ENV: 'test', TEST_MODE: '1', NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET ?? 'playwright-test-secret', - NEXTAUTH_URL: `http://127.0.0.1:${port}`, - NEXTAUTH_URL_INTERNAL: `http://127.0.0.1:${port}`, + NEXTAUTH_URL: baseUrl, + NEXTAUTH_URL_INTERNAL: baseUrl, SKIP_ENV_VALIDATION: '1', ENABLE_SENDING_INVITES: '0', DISABLE_EMAIL_SIGNUP: '0', diff --git a/src/env.ts b/src/env.ts index 84ab8a7ce..40c73db8e 100644 --- a/src/env.ts +++ b/src/env.ts @@ -17,7 +17,12 @@ export const env = createEnv({ 'You forgot to change the default URL', ), NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), - TEST_MODE: z.boolean().default(false), + TEST_MODE: z + .boolean() + .default(false) + .refine((testMode) => !testMode || 'production' !== process.env.NODE_ENV, { + message: 'TEST_MODE cannot be enabled in production', + }), DOCKER_OUTPUT: z.boolean().default(false), NEXTAUTH_SECRET: 'production' === process.env.NODE_ENV ? z.string() : z.string().optional(), NEXTAUTH_URL: z.preprocess( diff --git a/src/tests/integration/database.ts b/src/tests/integration/database.ts index 5ea9bf596..c1bfe088d 100644 --- a/src/tests/integration/database.ts +++ b/src/tests/integration/database.ts @@ -40,6 +40,8 @@ export const resetDatabase = async () => { db.account.deleteMany(), db.user.deleteMany(), ]); + await db.$executeRawUnsafe('DELETE FROM cron.job_run_details'); + await db.$executeRawUnsafe('DELETE FROM cron.job'); }; export const closeDatabase = async () => db.$disconnect(); diff --git a/src/tests/integration/expense.integration.test.ts b/src/tests/integration/expense.integration.test.ts index b5640a33e..dafaaa0a0 100644 --- a/src/tests/integration/expense.integration.test.ts +++ b/src/tests/integration/expense.integration.test.ts @@ -3,7 +3,6 @@ jest.mock('~/server/api/services/notificationService', () => ({ sendGroupSimplifyDebtsToggleNotification: jest.fn().mockResolvedValue(undefined), })); jest.mock('~/server/auth', () => ({ getServerAuthSession: jest.fn() })); -jest.mock('nanoid', () => ({ nanoid: () => 'integration-public-id' })); jest.mock('superjson', () => ({ default: { serialize: (value: unknown) => value, deserialize: (value: unknown) => value }, })); @@ -42,10 +41,13 @@ describe('expense integration', () => { }); expect(expense?.id).toBeDefined(); - await expect(db.balanceView.findMany({ where: { groupId: null } })).resolves.toEqual( + const balances = await db.balanceView.findMany({ where: { groupId: null } }); + expect(balances).toEqual( expect.arrayContaining([ expect.objectContaining({ userId: payer.id, friendId: participant.id, amount: 2_500n }), + expect.objectContaining({ userId: participant.id, friendId: payer.id, amount: -2_500n }), ]), ); + expect(balances.reduce((total, balance) => total + balance.amount, 0n)).toBe(0n); }); }); diff --git a/src/tests/setup/integration.ts b/src/tests/setup/integration.ts index 27c713348..88f275fb1 100644 --- a/src/tests/setup/integration.ts +++ b/src/tests/setup/integration.ts @@ -1 +1,7 @@ +import { closeDatabase } from '~/tests/integration/database'; + jest.setTimeout(30_000); + +afterAll(async () => { + await closeDatabase(); +}); From 13123a826f550e09c401e73bf2f1c1b0f9c21e94 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 16 Aug 2026 18:55:29 +0200 Subject: [PATCH 08/17] docs: format testing strategy --- docs/testing-strategy.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index 0f9f84b10..3933c3cdb 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -5,18 +5,18 @@ requiring PostgreSQL; database and browser checks each provision their own dispo ## Command matrix -| Purpose | Exact command | Selection | -| --- | --- | --- | -| Formatting | `pnpm prettier --check .` | All supported files | -| Lint | `pnpm lint` | Oxlint project sources | -| Types | `pnpm tsgo --noEmit` | TypeScript project | -| Unit/component tests | `pnpm test` | `src/**/*.{test,spec}.{ts,tsx}`, excluding `src/tests/integration/` | -| One unit file | `pnpm test src/tests/simplify.test.ts` | The named file | -| Integration tests | `pnpm test:integration` | `src/tests/integration/**/*.{test,spec}.{ts,tsx}` | -| One integration file | `pnpm test:integration src/tests/integration/expense.integration.test.ts` | The named file | -| Chromium E2E | `pnpm exec playwright test --project=chromium` | `tests/e2e/`, including setup dependency | -| One E2E file | `pnpm exec playwright test tests/e2e/group-expense.spec.ts` | The named file | -| Production build | `pnpm build --no-lint` | Next.js production build | +| Purpose | Exact command | Selection | +| -------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| Formatting | `pnpm prettier --check .` | All supported files | +| Lint | `pnpm lint` | Oxlint project sources | +| Types | `pnpm tsgo --noEmit` | TypeScript project | +| Unit/component tests | `pnpm test` | `src/**/*.{test,spec}.{ts,tsx}`, excluding `src/tests/integration/` | +| One unit file | `pnpm test src/tests/simplify.test.ts` | The named file | +| Integration tests | `pnpm test:integration` | `src/tests/integration/**/*.{test,spec}.{ts,tsx}` | +| One integration file | `pnpm test:integration src/tests/integration/expense.integration.test.ts` | The named file | +| Chromium E2E | `pnpm exec playwright test --project=chromium` | `tests/e2e/`, including setup dependency | +| One E2E file | `pnpm exec playwright test tests/e2e/group-expense.spec.ts` | The named file | +| Production build | `pnpm build --no-lint` | Next.js production build | The pull-request `Check` workflow runs formatting, lint, types, unit/component tests, and the build. `Integration Tests` and `E2E Tests` are separate jobs and do not depend on `Check` or on From cd698dc80a33dd17d80173cd9ff954aa0deb12f7 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 16 Aug 2026 19:19:03 +0200 Subject: [PATCH 09/17] test: finalize automated harness --- .github/workflows/test-e2e.yml | 4 +-- .github/workflows/test-integration.yml | 2 +- docs/testing-strategy.md | 6 ++--- jest.config.ts | 1 + jest.integration.config.ts | 3 +-- package.json | 1 + src/tests/integration/database.ts | 4 +-- .../integration/expense.integration.test.ts | 2 ++ src/tests/setup/component.ts | 22 ++++++++++++++++ tests/e2e/authorization.spec.ts | 6 ++--- tests/e2e/fixtures.ts | 25 +++++++++++++++---- tests/e2e/group-expense.spec.ts | 6 +++-- tests/e2e/settlement.spec.ts | 2 +- 13 files changed, 63 insertions(+), 21 deletions(-) diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index ee815bc63..cbf143558 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -50,13 +50,13 @@ jobs: run: pnpm install --frozen-lockfile - name: Prepare disposable database - run: pnpm exec prisma db push --accept-data-loss + run: pnpm exec prisma migrate deploy - name: Install Chromium run: pnpm exec playwright install --with-deps chromium - name: Run Chromium E2E tests - run: pnpm exec playwright test --project=chromium + run: pnpm test:e2e -- --project=chromium - name: Upload Playwright artifacts on failure if: failure() diff --git a/.github/workflows/test-integration.yml b/.github/workflows/test-integration.yml index 0afb25265..de7ec2897 100644 --- a/.github/workflows/test-integration.yml +++ b/.github/workflows/test-integration.yml @@ -49,7 +49,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Prepare disposable database - run: pnpm exec prisma db push --accept-data-loss + run: pnpm exec prisma migrate deploy - name: Run integration tests run: pnpm test:integration diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index 3933c3cdb..02f5db55d 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -14,8 +14,8 @@ requiring PostgreSQL; database and browser checks each provision their own dispo | One unit file | `pnpm test src/tests/simplify.test.ts` | The named file | | Integration tests | `pnpm test:integration` | `src/tests/integration/**/*.{test,spec}.{ts,tsx}` | | One integration file | `pnpm test:integration src/tests/integration/expense.integration.test.ts` | The named file | -| Chromium E2E | `pnpm exec playwright test --project=chromium` | `tests/e2e/`, including setup dependency | -| One E2E file | `pnpm exec playwright test tests/e2e/group-expense.spec.ts` | The named file | +| Chromium E2E | `pnpm test:e2e -- --project=chromium` | `tests/e2e/`, including setup dependency | +| One E2E file | `pnpm test:e2e -- tests/e2e/group-expense.spec.ts` | The named file | | Production build | `pnpm build --no-lint` | Next.js production build | The pull-request `Check` workflow runs formatting, lint, types, unit/component tests, and the @@ -31,7 +31,7 @@ other URLs before tests run. CI creates a new `splitpro_harness_test` service fo prepares it with: ```bash -pnpm exec prisma db push --accept-data-loss +pnpm exec prisma migrate deploy ``` For local work, copy `.env.example`, use a dedicated container/database/port per worktree, and diff --git a/jest.config.ts b/jest.config.ts index 5d937f800..d553ddef2 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -13,6 +13,7 @@ const createJestConfig = nextJest({ const config: Config = { coverageProvider: 'v8', + cacheDirectory: '/node_modules/.cache/jest', moduleNameMapper: { '^~/(.*)$': '/src/$1', }, diff --git a/jest.integration.config.ts b/jest.integration.config.ts index 3af1260fa..77f73212f 100644 --- a/jest.integration.config.ts +++ b/jest.integration.config.ts @@ -7,8 +7,7 @@ const createJestConfig = nextJest({ const config: Config = { coverageProvider: 'v8', - cacheDirectory: '/tmp/splitpro-jest-cache', - cache: false, + cacheDirectory: '/node_modules/.cache/jest', moduleNameMapper: { '^~/(.*)$': '/src/$1', }, diff --git a/package.json b/package.json index 69f2be72a..ae7c27215 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test:watch": "jest --watch", "test:integration": "jest --config jest.integration.config.ts", "test:integration:watch": "jest --config jest.integration.config.ts --watch", + "test:e2e": "PWTEST_CACHE_DIR=node_modules/.cache/playwright playwright test", "prepare": "husky" }, "dependencies": { diff --git a/src/tests/integration/database.ts b/src/tests/integration/database.ts index c1bfe088d..1318a62c9 100644 --- a/src/tests/integration/database.ts +++ b/src/tests/integration/database.ts @@ -1,7 +1,5 @@ import { PrismaClient } from '@prisma/client'; -import { db } from '~/server/db'; - const TEST_DATABASE_PATTERN = /_test(?:[/?]|$)/i; export const assertTestDatabase = (url = process.env.DATABASE_URL) => { @@ -22,6 +20,8 @@ export const assertTestDatabase = (url = process.env.DATABASE_URL) => { assertTestDatabase(); +const db = new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }); + export const resetDatabase = async () => { assertTestDatabase(); await db.$transaction([ diff --git a/src/tests/integration/expense.integration.test.ts b/src/tests/integration/expense.integration.test.ts index dafaaa0a0..bc12acc10 100644 --- a/src/tests/integration/expense.integration.test.ts +++ b/src/tests/integration/expense.integration.test.ts @@ -3,6 +3,8 @@ jest.mock('~/server/api/services/notificationService', () => ({ sendGroupSimplifyDebtsToggleNotification: jest.fn().mockResolvedValue(undefined), })); jest.mock('~/server/auth', () => ({ getServerAuthSession: jest.fn() })); +let mockNanoidSequence = 0; +jest.mock('nanoid', () => ({ nanoid: () => `integration-public-id-${mockNanoidSequence++}` })); jest.mock('superjson', () => ({ default: { serialize: (value: unknown) => value, deserialize: (value: unknown) => value }, })); diff --git a/src/tests/setup/component.ts b/src/tests/setup/component.ts index 7b0828bfa..902f5a618 100644 --- a/src/tests/setup/component.ts +++ b/src/tests/setup/component.ts @@ -1 +1,23 @@ import '@testing-library/jest-dom'; + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + }), +}); + +Object.defineProperty(CSSStyleDeclaration.prototype, 'transform', { + configurable: true, + value: 'none', +}); + +HTMLElement.prototype.setPointerCapture = () => undefined; +HTMLElement.prototype.releasePointerCapture = () => undefined; diff --git a/tests/e2e/authorization.spec.ts b/tests/e2e/authorization.spec.ts index 562f97a58..a16298821 100644 --- a/tests/e2e/authorization.spec.ts +++ b/tests/e2e/authorization.spec.ts @@ -1,9 +1,9 @@ -import { test, expect } from './fixtures'; +import { expect, test } from './fixtures'; test('redirects unauthenticated visitors to sign in', async ({ browser }) => { - const context = await browser.newContext(); + const context = await browser.newContext({ storageState: { cookies: [], origins: [] } }); const page = await context.newPage(); - await page.goto('/groups'); + await page.goto('/balances'); await expect(page).toHaveURL(/\/auth\/signin/); await context.close(); }); diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts index f4862aade..9fb126dc5 100644 --- a/tests/e2e/fixtures.ts +++ b/tests/e2e/fixtures.ts @@ -3,17 +3,32 @@ import { join } from 'node:path'; import { test as base, expect } from '@playwright/test'; import { db } from '~/server/db'; -type Fixtures = { userId: number; uniqueName: string }; -type AuthMeta = { userId: number }; +interface Fixtures { + userId: number; + uniqueName: string; +} + +interface AuthMeta { + userId: number; +} const metaPath = join(process.cwd(), 'playwright', '.auth', 'user-meta.json'); export const test = base.extend({ - userId: async ({}, use) => { - const meta = JSON.parse(await readFile(metaPath, 'utf8')) as AuthMeta; + userId: async ({ page: _page }, use) => { + const parsed: unknown = JSON.parse(await readFile(metaPath, 'utf8')); + if ( + !parsed || + 'object' !== typeof parsed || + !('userId' in parsed) || + 'number' !== typeof parsed.userId + ) { + throw new Error('Playwright authentication metadata has no numeric userId'); + } + const meta: AuthMeta = { userId: parsed.userId }; await use(meta.userId); }, - uniqueName: async ({}, use, testInfo) => { + uniqueName: async ({ page: _page }, use, testInfo) => { await use(`E2E ${testInfo.project.name} ${testInfo.workerIndex} ${testInfo.testId}`); }, }); diff --git a/tests/e2e/group-expense.spec.ts b/tests/e2e/group-expense.spec.ts index c56c875a4..de5055f6e 100644 --- a/tests/e2e/group-expense.spec.ts +++ b/tests/e2e/group-expense.spec.ts @@ -2,12 +2,14 @@ import { expect, test } from './fixtures'; test('creates an isolated group and records an expense', async ({ page, uniqueName }) => { await page.goto('/groups'); - await page.getByRole('button', { name: /create group/i }).click(); + await page.getByRole('button', { name: /^create$/i }).click(); await page.getByPlaceholder(/group name/i).fill(uniqueName); await page.getByRole('button', { name: /submit/i }).click(); await expect(page.getByText(uniqueName)).toBeVisible(); - await page.getByRole('link', { name: /add expense/i }).click(); + await expect(page).toHaveURL(/\/groups\/\d+/); + const groupId = page.url().match(/\/groups\/(\d+)/)?.[1]; + await page.goto(`/add?groupId=${groupId}`); await page.getByPlaceholder(/description/i).fill(`${uniqueName} expense`); await page.getByPlaceholder(/amount/i).fill('12.34'); await page diff --git a/tests/e2e/settlement.spec.ts b/tests/e2e/settlement.spec.ts index c61cc6574..518ffef3f 100644 --- a/tests/e2e/settlement.spec.ts +++ b/tests/e2e/settlement.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from './fixtures'; +import { expect, test } from './fixtures'; test('shows the group balance and settlement controls', async ({ page }) => { await page.goto('/balances'); From 74d6686fd8f38886f6e24fdccceee8095ce50368 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 4 Oct 2026 23:48:45 +0200 Subject: [PATCH 10/17] fix: enforce expense access and clean up failed recurrences --- src/server/api/routers/expense.ts | 92 ++++++++++++++++--------- src/server/api/services/splitService.ts | 30 ++++---- 2 files changed, 76 insertions(+), 46 deletions(-) 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..30ea9ebff 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,7 +330,15 @@ export async function getCompleteFriendsDetails(userId: number) { }, }); - const friends = viewBalances.reduce( + const friends = viewBalances.reduce< Record< + number, + { + id: number; + email?: string | null; + name?: string | null; + balances: { currency: string; amount: bigint }[]; + } + >>( (acc, balance) => { const { friendId } = balance; acc[friendId] ??= { @@ -349,15 +357,7 @@ export async function getCompleteFriendsDetails(userId: number) { return acc; }, - {} as Record< - number, - { - id: number; - email?: string | null; - name?: string | null; - balances: { currency: string; amount: bigint }[]; - } - >, + {}, ); return friends; @@ -389,7 +389,7 @@ export async function importUserBalanceFromSplitWise( const users = await createUsersFromSplitwise(splitWiseUsers); - const userMap = users.reduce( + const userMap = users.reduce< Record>( (acc, user) => { if (user.email) { acc[user.email] = user; @@ -397,7 +397,7 @@ export async function importUserBalanceFromSplitWise( return acc; }, - {} as Record, + {}, ); const currencyHelperCache: Record['toSafeBigInt']> = @@ -503,7 +503,7 @@ export async function importGroupFromSplitwise( const users = await createUsersFromSplitwise(Object.values(splitwiseUserMap)); - const userMap = users.reduce( + const userMap = users.reduce< Record>( (acc, user) => { if (user.email) { acc[user.email] = user; @@ -511,7 +511,7 @@ export async function importGroupFromSplitwise( return acc; }, - {} as Record, + {}, ); const missingGroups = await Promise.all( From b533fca29f9c469e2238c17e854a74ba366deabd Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 4 Oct 2026 23:48:55 +0200 Subject: [PATCH 11/17] fix: make group creation controls accessible --- src/components/group/CreateGroup.tsx | 1 + src/components/ui/drawer.tsx | 5 +++-- src/pages/groups.tsx | 6 ++++-- 3 files changed, 8 insertions(+), 4 deletions(-) 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 ( From a5fc06389cc3185f8f36f790d670a1b8751b2c47 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 4 Oct 2026 23:49:30 +0200 Subject: [PATCH 12/17] test: consolidate fixtures and provision isolated test databases --- .env.example | 3 +- .github/workflows/test-e2e.yml | 27 +-- .github/workflows/test-integration.yml | 24 +-- .gitignore | 1 + docker/test/compose.yml | 15 +- flake.nix | 26 +++ jest.config.ts | 35 +--- jest.integration.config.ts | 31 ++- jest.shared.ts | 22 +++ package.json | 6 +- playwright.config.ts | 83 +++------ scripts/test-db.ts | 123 ++++++++++++ src/components/Friend/Settleup.test.tsx | 112 ++++++----- src/components/Layout/MainLayout.test.tsx | 43 ++--- src/components/group/CreateGroup.test.tsx | 61 +++--- src/tests/currencyPreferenceStore.test.ts | 2 + src/tests/helpers/databaseFactories.ts | 69 +++++++ src/tests/helpers/environment.ts | 8 + src/tests/helpers/i18n.ts | 23 +++ src/tests/helpers/queryClient.ts | 19 ++ src/tests/helpers/render.tsx | 76 +++----- src/tests/helpers/resetStores.ts | 11 +- src/tests/helpers/session.ts | 31 ++- src/tests/helpers/testDatabase.ts | 34 ++++ src/tests/helpers/user.ts | 16 ++ .../authorization.integration.test.ts | 138 +++++++++++--- .../integration/balances.integration.test.ts | 72 +++++-- src/tests/integration/database.ts | 69 +++---- .../integration/expense.integration.test.ts | 176 +++++++++++++----- src/tests/integration/factories.ts | 76 +++----- .../recurrence.integration.test.ts | 84 +++++---- src/tests/integration/trpc.ts | 6 +- src/tests/setup/component.ts | 16 +- src/tests/setup/integration.ts | 16 +- src/tests/setup/unit.ts | 3 + src/tests/testDatabase.test.ts | 21 +++ tests/e2e/auth.setup.ts | 36 ---- tests/e2e/authorization.spec.ts | 27 ++- tests/e2e/fixtures.ts | 105 ++++++++--- tests/e2e/group-expense.spec.ts | 46 ++++- tests/e2e/settlement.spec.ts | 57 +++++- tests/e2e/transport.spec.ts | 42 +++++ tsconfig.json | 3 +- 43 files changed, 1253 insertions(+), 641 deletions(-) create mode 100644 jest.shared.ts create mode 100644 scripts/test-db.ts create mode 100644 src/tests/helpers/databaseFactories.ts create mode 100644 src/tests/helpers/environment.ts create mode 100644 src/tests/helpers/i18n.ts create mode 100644 src/tests/helpers/queryClient.ts create mode 100644 src/tests/helpers/testDatabase.ts create mode 100644 src/tests/helpers/user.ts create mode 100644 src/tests/setup/unit.ts create mode 100644 src/tests/testDatabase.test.ts delete mode 100644 tests/e2e/auth.setup.ts create mode 100644 tests/e2e/transport.spec.ts diff --git a/.env.example b/.env.example index f6b6b0008..37833fdb4 100644 --- a/.env.example +++ b/.env.example @@ -22,7 +22,8 @@ NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_URL_INTERNAL="http://localhost:3000" # Playwright uses only this disposable database and port. -E2E_DATABASE_URL="postgresql://postgres:strong-password@localhost:5432/splitpro_test" +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. diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index cbf143558..50a30d7b3 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -12,25 +12,6 @@ jobs: e2e: name: Chromium E2E tests runs-on: ubuntu-latest - env: - DATABASE_URL: postgresql://splitpro:test-password@localhost:5432/splitpro_harness_test - E2E_DATABASE_URL: postgresql://splitpro:test-password@localhost:5432/splitpro_harness_test - SKIP_ENV_VALIDATION: '1' - services: - postgres: - image: ossapps/postgres:18.3-trixie - env: - POSTGRES_USER: splitpro - POSTGRES_PASSWORD: test-password - POSTGRES_DB: splitpro_harness_test - options: >- - --health-cmd "pg_isready -U splitpro -d splitpro_harness_test" - --health-interval 2s - --health-timeout 5s - --health-retries 15 - ports: - - 5432:5432 - steps: - name: Checkout uses: actions/checkout@v6 @@ -50,13 +31,13 @@ jobs: run: pnpm install --frozen-lockfile - name: Prepare disposable database - run: pnpm exec prisma migrate deploy + 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 + run: pnpm test:e2e --project=chromium - name: Upload Playwright artifacts on failure if: failure() @@ -67,3 +48,7 @@ jobs: 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 index de7ec2897..f73fc3802 100644 --- a/.github/workflows/test-integration.yml +++ b/.github/workflows/test-integration.yml @@ -12,24 +12,6 @@ jobs: integration: name: PostgreSQL integration tests runs-on: ubuntu-latest - env: - DATABASE_URL: postgresql://splitpro:test-password@localhost:5432/splitpro_harness_test - SKIP_ENV_VALIDATION: '1' - services: - postgres: - image: ossapps/postgres:18.3-trixie - env: - POSTGRES_USER: splitpro - POSTGRES_PASSWORD: test-password - POSTGRES_DB: splitpro_harness_test - options: >- - --health-cmd "pg_isready -U splitpro -d splitpro_harness_test" - --health-interval 2s - --health-timeout 5s - --health-retries 15 - ports: - - 5432:5432 - steps: - name: Checkout uses: actions/checkout@v6 @@ -49,7 +31,11 @@ jobs: run: pnpm install --frozen-lockfile - name: Prepare disposable database - run: pnpm exec prisma migrate deploy + 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 6cbe568ab..9121487df 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ # testing /coverage /test-results +/playwright-report/ /playwright/.auth # database diff --git a/docker/test/compose.yml b/docker/test/compose.yml index 040e63e7b..adf312a74 100644 --- a/docker/test/compose.yml +++ b/docker/test/compose.yml @@ -1,22 +1,19 @@ -name: split-pro-test - services: postgres: image: ossapps/postgres:18.3-trixie - container_name: splitpro-testing-harness-db environment: - POSTGRES_USER: splitpro - POSTGRES_PASSWORD: test-password - POSTGRES_DB: splitpro_harness_test + 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=splitpro_harness_test + -c cron.database_name=${TEST_POSTGRES_DB:-splitpro_harness_test} -c cron.timezone=UTC ports: - - '55439:5432' + - '127.0.0.1:${TEST_POSTGRES_PORT:-55439}:5432' healthcheck: - test: ['CMD-SHELL', 'pg_isready -U splitpro -d splitpro_harness_test'] + test: ['CMD-SHELL', 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"'] interval: 2s timeout: 5s retries: 15 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 d553ddef2..2b482a51a 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,26 +1,9 @@ -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() { - return this.toString(); -}; - -const createJestConfig = nextJest({ - dir: './', -}); - -const config: Config = { - coverageProvider: 'v8', - cacheDirectory: '/node_modules/.cache/jest', - moduleNameMapper: { - '^~/(.*)$': '/src/$1', - }, - setupFilesAfterEnv: ['/src/tests/setup/component.ts'], - testEnvironment: 'jsdom', - testMatch: ['/src/**/*.{test,spec}.{ts,tsx}'], - testPathIgnorePatterns: ['/src/tests/integration/'], -}; - -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 index 77f73212f..dcc0bc039 100644 --- a/jest.integration.config.ts +++ b/jest.integration.config.ts @@ -1,20 +1,15 @@ -import type { Config } from 'jest'; -import nextJest from 'next/jest.js'; +import { configureJest } from './jest.shared.ts'; +import { getTestDatabaseUrl } from './src/tests/helpers/testDatabase.ts'; +import { loadTestEnvironment } from './src/tests/helpers/environment.ts'; -const createJestConfig = nextJest({ - dir: './', -}); +loadTestEnvironment(); +process.env.DATABASE_URL = getTestDatabaseUrl(); +process.env.SKIP_ENV_VALIDATION = '1'; -const config: Config = { - coverageProvider: 'v8', - cacheDirectory: '/node_modules/.cache/jest', - moduleNameMapper: { - '^~/(.*)$': '/src/$1', - }, - setupFilesAfterEnv: ['/src/tests/setup/integration.ts'], - testEnvironment: 'node', - testMatch: ['/src/tests/integration/**/*.{test,spec}.{ts,tsx}'], - maxWorkers: 1, -}; - -export default createJestConfig(config); +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 ae7c27215..b33b7b175 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,10 @@ "dx:down": "bash scripts/dev-db.sh down", "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", @@ -85,10 +89,10 @@ "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", - "@tailwindcss/postcss": "^4.1.10", "@types/formidable": "^3.4.6", "@types/jest": "^30.0.0", "@types/node": "^22.15.21", diff --git a/playwright.config.ts b/playwright.config.ts index 9f1dc1ba1..06e8ac486 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,34 +1,28 @@ import { defineConfig, devices } from '@playwright/test'; -const defaultPort = 3176; -const databaseUrl = - process.env.E2E_DATABASE_URL ?? - 'postgresql://postgres:strong-password@localhost:5432/splitpro_test'; -const database = new URL(databaseUrl); -const baseUrl = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${defaultPort}`; -const baseUrlDetails = new URL(baseUrl); +import { getTestDatabaseUrl } from './src/tests/helpers/testDatabase'; +import { loadTestEnvironment } from './src/tests/helpers/environment'; -if ( - !['localhost', '127.0.0.1', '::1'].includes(database.hostname) || - !database.pathname.endsWith('_test') -) { - throw new Error('E2E_DATABASE_URL must point at a local disposable *_test database'); +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'); } -if (!['localhost', '127.0.0.1', '::1'].includes(baseUrlDetails.hostname)) { - throw new Error('E2E_BASE_URL must point at a local test server'); -} - -const port = Number(baseUrlDetails.port) || 80; -process.env.DATABASE_URL = databaseUrl; -process.env.TEST_MODE = '1'; -process.env.NEXTAUTH_SECRET ??= 'playwright-test-secret'; -process.env.NEXTAUTH_URL = baseUrl; -process.env.NEXTAUTH_URL_INTERNAL = baseUrl; -process.env.SKIP_ENV_VALIDATION = '1'; -process.env.ENABLE_SENDING_INVITES = '0'; -process.env.DISABLE_EMAIL_SIGNUP = '0'; -process.env.INVITE_ONLY = '0'; +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', @@ -37,39 +31,20 @@ export default defineConfig({ forbidOnly: Boolean(process.env.CI), retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, - reporter: process.env.CI ? 'line' : 'list', + reporter: [[process.env.CI ? 'line' : 'list'], ['html', { open: 'never' }]], use: { ...devices['Desktop Chrome'], - baseURL: baseUrl, - trace: 'on-first-retry', + baseURL, + trace: 'retain-on-failure', screenshot: 'only-on-failure', - video: 'on-first-retry', + video: 'retain-on-failure', }, - projects: [ - { name: 'setup', testMatch: /.*\.setup\.ts/ }, - { - name: 'chromium', - use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' }, - dependencies: ['setup'], - }, - ], + projects: [{ name: 'chromium' }], webServer: { - command: `pnpm dev --port ${port}`, - url: baseUrl, - reuseExistingServer: !process.env.CI, + command: `pnpm dev --hostname ${url.hostname.replaceAll('[', '').replaceAll(']', '')} --port ${Number(url.port) || 80}`, + url: baseURL, + reuseExistingServer: false, timeout: 120_000, - env: { - ...process.env, - DATABASE_URL: databaseUrl, - NODE_ENV: 'test', - TEST_MODE: '1', - NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET ?? 'playwright-test-secret', - NEXTAUTH_URL: baseUrl, - NEXTAUTH_URL_INTERNAL: baseUrl, - SKIP_ENV_VALIDATION: '1', - ENABLE_SENDING_INVITES: '0', - DISABLE_EMAIL_SIGNUP: '0', - INVITE_ONLY: '0', - }, + env: testEnvironment, }, }); 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 index c9e0b4284..66925852e 100644 --- a/src/components/Friend/Settleup.test.tsx +++ b/src/components/Friend/Settleup.test.tsx @@ -1,81 +1,95 @@ +/** @jest-environment jsdom */ + import { SplitType } from '@prisma/client'; -import { screen, waitFor } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; import { renderWithProviders } from '~/tests/helpers/render'; -import { resetStores } from '~/tests/helpers/resetStores'; +import { 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 } }), }, })); -jest.mock('next-auth/react', () => ({ - SessionProvider: ({ children }: React.PropsWithChildren) => children, - useSession: () => ({ - data: { user: { id: 1, name: 'Alex Example', email: 'alex@example.com', image: null } }, - status: 'authenticated', - }), -})); -jest.mock('next-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => - ({ - 'actions.save': 'Save', - 'actions.settle_up': 'Settle up', - 'ui.settle_up_name': 'Settlement', - 'ui.select_balance': 'Select balance', - 'actors.you': 'You', - 'ui.expense.you.pay': 'pay', - 'ui.expense.user.pay': 'pays', - })[key] ?? key, - i18n: { language: 'en' }, - }), -})); -const { SettleUp } = require('./Settleup') as typeof import('./Settleup'); +import { SettleUp } from './Settleup'; -const friend = { id: 2, name: 'Sam Friend', email: 'sam@example.com', image: null } as never; -const balance = { +const friend = createTestUser({ id: 2, name: 'Sam Friend', email: 'sam@example.test' }); +const makeBalance = (amount: bigint): MinimalBalance => ({ currency: 'USD', - amount: -1250n, - friendId: 2, + amount, + friendId: friend.id, groupId: 7, groupName: 'Trip', -} satisfies MinimalBalance; +}); describe('SettleUp', () => { - afterEach(resetStores); - - beforeEach(() => mutate.mockClear()); - - it('submits a settlement with the correct BigInt direction', async () => { + 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) }), + ); + }); - await waitFor(() => expect(mutate).toHaveBeenCalled()); - expect(mutate.mock.calls[0]?.[0]).toMatchObject({ - amount: 1250n, - splitType: SplitType.SETTLEMENT, - paidBy: 1, - groupId: 7, - participants: [ - { userId: 1, amount: 1250n }, - { userId: 2, amount: -1250n }, - ], - }); + 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 index d09e9c97d..abfa953e3 100644 --- a/src/components/Layout/MainLayout.test.tsx +++ b/src/components/Layout/MainLayout.test.tsx @@ -1,42 +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 { resetStores } from '~/tests/helpers/resetStores'; - -const mockRouter = createMockRouter({ pathname: '/groups/7' }); -jest.mock('next/router', () => ({ useRouter: () => mockRouter })); -jest.mock('next-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => - ({ - 'meta.application_name': 'SplitPro', - 'navigation.balances': 'Balances', - 'navigation.groups': 'Groups', - 'navigation.add_expense': 'Add Expense', - 'navigation.add': 'Add', - 'navigation.activity': 'Activity', - 'navigation.account': 'Account', - })[key] ?? key, - ready: true, - i18n: { language: 'en' }, - }), -})); - -const { default: MainLayout } = require('./MainLayout') as typeof import('./MainLayout'); +import MainLayout from './MainLayout'; -it('renders navigation links and marks the active section', () => { +it('marks the active navigation section for nested routes', () => { renderWithProviders(

Content

, + { + router: createMockRouter({ pathname: '/groups/[groupId]', asPath: '/groups/7' }), + }, ); - - expect(screen.getAllByRole('link', { name: 'Groups' }).length).toBeGreaterThan(0); - expect(screen.getAllByRole('link', { name: 'Balances' }).length).toBeGreaterThan(0); expect(screen.getByText('Content')).toBeInTheDocument(); - expect(screen.getAllByRole('link', { name: 'Groups' })[0]).toHaveAttribute('href', '/groups'); + 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'); + }); }); - -afterEach(resetStores); diff --git a/src/components/group/CreateGroup.test.tsx b/src/components/group/CreateGroup.test.tsx index e357c4cc1..5033d7fed 100644 --- a/src/components/group/CreateGroup.test.tsx +++ b/src/components/group/CreateGroup.test.tsx @@ -1,81 +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'; -import { resetStores } from '~/tests/helpers/resetStores'; const mutateAsync = jest.fn(); const refetch = jest.fn().mockResolvedValue(undefined); -const push = jest.fn().mockResolvedValue(true); - -jest.mock('next/router', () => ({ useRouter: () => ({ push }) })); jest.mock('~/utils/api', () => ({ api: { group: { create: { useMutation: () => ({ mutateAsync }) } }, useUtils: () => ({ group: { getAllGroupsWithBalances: { refetch } } }), }, })); -jest.mock('next-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => - ({ - 'actions.cancel': 'Cancel', - 'actions.submit': 'Submit', - 'errors.name_required': 'Name is required', - 'group_details.create_group.title': 'Create group', - 'group_details.create_group.group_name_placeholder': 'Group name', - })[key] ?? key, - ready: true, - i18n: { language: 'en' }, - }), -})); - -const { CreateGroup } = require('./CreateGroup') as typeof import('./CreateGroup'); +import { CreateGroup } from './CreateGroup'; describe('CreateGroup', () => { - afterEach(resetStores); - beforeEach(() => { mutateAsync.mockReset().mockImplementation(async (_input, options) => { options?.onSuccess?.({ id: 42 }); return { id: 42 }; }); - refetch.mockClear(); - push.mockClear().mockResolvedValue(true); }); - it('validates an empty name and submits the accessible form', async () => { + it('validates an empty name without creating a group', async () => { const user = userEvent.setup(); renderWithProviders( - + , ); - await user.click(screen.getByRole('button', { name: 'New group' })); - expect(await screen.findByRole('dialog')).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Submit' })); + 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, and navigates to it', async () => { + it('creates the group, refreshes groups, navigates and closes the drawer', async () => { const user = userEvent.setup(); - renderWithProviders( + 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()); + }); - await waitFor(() => - expect(mutateAsync).toHaveBeenCalledWith({ name: 'Trip' }, expect.anything()), + it('cancels without submitting', async () => { + const user = userEvent.setup(); + renderWithProviders( + + + , ); - expect(refetch).toHaveBeenCalled(); - expect(push).toHaveBeenCalledWith('/groups/42'); + 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/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/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 index 71b8af61c..2b3af942d 100644 --- a/src/tests/helpers/render.tsx +++ b/src/tests/helpers/render.tsx @@ -1,67 +1,43 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { QueryClientProvider } from '@tanstack/react-query'; import { type RenderOptions, render } from '@testing-library/react'; -import { SessionProvider } from 'next-auth/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 { createTestSession } from './session'; - -export const englishTranslations: Record = { - 'actions.back': 'Back', - 'actions.cancel': 'Cancel', - 'actions.save': 'Save', - 'actions.submit': 'Submit', - 'actors.you': 'You', - 'errors.name_required': 'Name is required', - 'errors.saving_expense': 'Error while saving expense', - 'group_details.create_group.title': 'Create group', - 'group_details.create_group.group_name_placeholder': 'Group name', - 'navigation.account': 'Account', - 'navigation.activity': 'Activity', - 'navigation.add': 'Add', - 'navigation.add_expense': 'Add Expense', - 'navigation.balances': 'Balances', - 'navigation.groups': 'Groups', - 'ui.expense.user.pay': 'pays', - 'ui.expense.you.pay': 'pay', - 'ui.settle_up_name': 'Settlement', - 'ui.select_balance': 'Select balance', - 'meta.application_name': 'SplitPro', -}; - -const translate = (key: string) => englishTranslations[key] ?? key; - -jest.mock('next-i18next', () => ({ - useTranslation: () => ({ - t: translate, - ready: true, - i18n: { language: 'en' }, - }), -})); +import { createTestQueryClient } from './queryClient'; +import { createMockRouter } from './router'; +import { createMockSession, createTestSession } from './session'; export interface AppRenderOptions extends Omit { - session?: ReturnType | null; - queryClient?: QueryClient; + session?: Session | null; + sessionLoading?: boolean; + router?: NextRouter; } export const renderWithProviders = (ui: React.ReactElement, options: AppRenderOptions = {}) => { const { session = createTestSession(), - queryClient = new QueryClient(), + sessionLoading = false, + router = createMockRouter(), ...renderOptions } = options; + const queryClient = createTestQueryClient(); const Wrapper = ({ children }: React.PropsWithChildren) => ( - - - - - {children} - - - - + + + + + + {children} + + + + + ); - - return render(ui, { wrapper: Wrapper, ...renderOptions }); + return { ...render(ui, { wrapper: Wrapper, ...renderOptions }), router, queryClient }; }; diff --git a/src/tests/helpers/resetStores.ts b/src/tests/helpers/resetStores.ts index 27cc3dd8a..c884adf09 100644 --- a/src/tests/helpers/resetStores.ts +++ b/src/tests/helpers/resetStores.ts @@ -2,13 +2,10 @@ import { useAddExpenseStore } from '~/store/addStore'; import { useAppStore } from '~/store/appStore'; import { useCurrencyPreferenceStore } from '~/store/currencyPreferenceStore'; -const initialAddExpenseState = useAddExpenseStore.getState(); -const initialAppState = useAppStore.getState(); -const initialCurrencyPreferenceState = useCurrencyPreferenceStore.getState(); - export const resetStores = () => { - useAddExpenseStore.setState(initialAddExpenseState, true); - useAppStore.setState(initialAppState, true); - useCurrencyPreferenceStore.setState(initialCurrencyPreferenceState, true); + 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/session.ts b/src/tests/helpers/session.ts index 59ae14bb1..73589dde5 100644 --- a/src/tests/helpers/session.ts +++ b/src/tests/helpers/session.ts @@ -1,23 +1,22 @@ import type { Session } from 'next-auth'; +import type { SessionContextValue } from 'next-auth/react'; -export const testUser = { - id: 1, - name: 'Alex Example', - email: 'alex@example.com', - image: null, - currency: 'USD', - defaultCurrency: null, - preferredLanguage: 'en', - hiddenFriendIds: [], -}; +import { createTestUser } from './user'; export const createTestSession = (overrides: Partial = {}): Session => ({ - user: { ...testUser, ...overrides }, + user: { ...createTestUser(), bankingId: undefined, obapiProviderId: undefined, ...overrides }, expires: '2099-01-01T00:00:00.000Z', }); -export const createMockSession = (session: Session | null = createTestSession()) => ({ - data: session, - status: session ? ('authenticated' as const) : ('unauthenticated' as const), - update: jest.fn().mockResolvedValue(session), -}); +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 index ae9041b34..622b0a87c 100644 --- a/src/tests/integration/authorization.integration.test.ts +++ b/src/tests/integration/authorization.integration.test.ts @@ -1,29 +1,125 @@ -import { TRPCError } from '@trpc/server'; +import { db } from './database'; +import { accountingSnapshot, testGroup, testScenario } from './factories'; +import { callerFor } from './trpc'; -jest.mock('~/server/auth', () => ({ getServerAuthSession: jest.fn() })); -jest.mock('nanoid', () => ({ nanoid: () => 'integration-public-id' })); -jest.mock('superjson', () => ({ - default: { serialize: (value: unknown) => value, deserialize: (value: unknown) => value }, -})); -jest.mock('~/server/db', () => { - const { PrismaClient } = require('@prisma/client') as typeof import('@prisma/client'); - return { db: new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }) }; -}); +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); + }); -import { resetDatabase } from './database'; -import { testGroup, testUser } from './factories'; -import { callerFor } from './trpc'; + 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, + }); + }); -describe('authorization integration', () => { - beforeEach(() => resetDatabase()); + 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 a non-member from group procedures', async () => { - const owner = await testUser('Owner'); - const outsider = await testUser('Outsider'); - const group = await testGroup(owner.id); + 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(outsider.id).group.getGroupDetails({ groupId: group.id }), - ).rejects.toMatchObject(new TRPCError({ code: 'FORBIDDEN', message: 'Not a group member' })); + 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 index 9e8a500c2..2e2708f9e 100644 --- a/src/tests/integration/balances.integration.test.ts +++ b/src/tests/integration/balances.integration.test.ts @@ -1,26 +1,60 @@ -jest.mock('~/server/db', () => { - const { PrismaClient } = require('@prisma/client') as typeof import('@prisma/client'); - return { db: new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }) }; -}); - -import { db } from '~/server/db'; -import { resetDatabase } from './database'; -import { testExpense, testUser } from './factories'; - -describe('balance view integration', () => { - beforeEach(() => resetDatabase()); +import { db } from './database'; +import { expenseInput, testGroup, testScenario } from './factories'; +import { callerFor } from './trpc'; - it('calculates both sides of a double-entry balance', async () => { - const payer = await testUser('Payer'); - const participant = await testUser('Participant'); - await testExpense({ paidBy: payer.id, participantId: participant.id }); +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']); + }); - const balances = await db.balanceView.findMany({ orderBy: { userId: 'asc' } }); - expect(balances).toEqual( + 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: payer.id, friendId: participant.id, amount: 1_000n }), - expect.objectContaining({ userId: participant.id, friendId: payer.id, amount: -1_000n }), + 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 index 1318a62c9..2d494f452 100644 --- a/src/tests/integration/database.ts +++ b/src/tests/integration/database.ts @@ -1,49 +1,34 @@ import { PrismaClient } from '@prisma/client'; -const TEST_DATABASE_PATTERN = /_test(?:[/?]|$)/i; - -export const assertTestDatabase = (url = process.env.DATABASE_URL) => { - if (!url) { - throw new Error('Integration tests require DATABASE_URL'); - } - - const parsed = new URL(url); - if (!['localhost', '127.0.0.1', '::1'].includes(parsed.hostname)) { - throw new Error(`Refusing integration tests against non-local database: ${parsed.hostname}`); - } - if (!TEST_DATABASE_PATTERN.test(parsed.pathname)) { - throw new Error( - `Refusing integration tests against database without _test suffix: ${parsed.pathname}`, - ); +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'); } }; -assertTestDatabase(); - -const db = new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }); - export const resetDatabase = async () => { - assertTestDatabase(); - await db.$transaction([ - db.expenseParticipant.deleteMany(), - db.expenseNote.deleteMany(), - db.expense.deleteMany(), - db.expenseRecurrence.deleteMany(), - db.groupDefaultSplit.deleteMany(), - db.groupUser.deleteMany(), - db.group.deleteMany(), - db.friendDefaultSplit.deleteMany(), - db.pushNotification.deleteMany(), - db.cachedBankData.deleteMany(), - db.cachedCurrencyRate.deleteMany(), - db.session.deleteMany(), - db.account.deleteMany(), - db.user.deleteMany(), - ]); - await db.$executeRawUnsafe('DELETE FROM cron.job_run_details'); - await db.$executeRawUnsafe('DELETE FROM cron.job'); + 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`; }; - -export const closeDatabase = async () => db.$disconnect(); - -export const createTestClient = () => new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }); diff --git a/src/tests/integration/expense.integration.test.ts b/src/tests/integration/expense.integration.test.ts index bc12acc10..fd92d9d24 100644 --- a/src/tests/integration/expense.integration.test.ts +++ b/src/tests/integration/expense.integration.test.ts @@ -1,55 +1,141 @@ -jest.mock('~/server/api/services/notificationService', () => ({ - sendExpensePushNotification: jest.fn().mockResolvedValue(undefined), - sendGroupSimplifyDebtsToggleNotification: jest.fn().mockResolvedValue(undefined), -})); -jest.mock('~/server/auth', () => ({ getServerAuthSession: jest.fn() })); -let mockNanoidSequence = 0; -jest.mock('nanoid', () => ({ nanoid: () => `integration-public-id-${mockNanoidSequence++}` })); -jest.mock('superjson', () => ({ - default: { serialize: (value: unknown) => value, deserialize: (value: unknown) => value }, -})); -jest.mock('~/server/db', () => { - const { PrismaClient } = require('@prisma/client') as typeof import('@prisma/client'); - return { db: new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }) }; -}); - import { SplitType } from '@prisma/client'; -import { db } from '~/server/db'; -import { resetDatabase } from './database'; -import { testUser } from './factories'; +import { db } from './database'; +import { accountingSnapshot, testScenario } from './factories'; import { callerFor } from './trpc'; -describe('expense integration', () => { - beforeEach(() => resetDatabase()); - - it('persists an expense transaction and exposes its balance view', async () => { - const payer = await testUser('Payer'); - const participant = await testUser('Participant'); - const caller = callerFor(payer.id); - - const [expense] = await caller.expense.addOrEditExpense({ - paidBy: payer.id, - name: 'Dinner', - category: 'Food', - amount: 2_500n, - groupId: null, - splitType: SplitType.EQUAL, - currency: 'USD', +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: payer.id, amount: 2_500n }, - { userId: participant.id, amount: -2_500n }, + { 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(); + } + }, + ); - expect(expense?.id).toBeDefined(); - const balances = await db.balanceView.findMany({ where: { groupId: null } }); - expect(balances).toEqual( - expect.arrayContaining([ - expect.objectContaining({ userId: payer.id, friendId: participant.id, amount: 2_500n }), - expect.objectContaining({ userId: participant.id, friendId: payer.id, amount: -2_500n }), - ]), - ); - expect(balances.reduce((total, balance) => total + balance.amount, 0n)).toBe(0n); + 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 index 9adf5534a..f2d1bc131 100644 --- a/src/tests/integration/factories.ts +++ b/src/tests/integration/factories.ts @@ -1,49 +1,35 @@ -import { type Prisma, SplitType } from '@prisma/client'; +import { + createDatabaseGroup, + createDatabaseUser, + expenseInput, +} from '../helpers/databaseFactories'; +import { db } from './database'; -import { db } from '~/server/db'; +export { expenseInput }; +export const testUser = (name = 'Test User') => createDatabaseUser(db, { name }); +export const testGroup = (ownerId: number, memberIds: number[] = []) => + createDatabaseGroup(db, ownerId, memberIds); -let sequence = 0; - -export const testUser = async (name = `Test User ${sequence}`) => { - const id = sequence++; - return db.user.create({ - data: { name, email: `integration-${id}@splitpro.test`, currency: 'USD' }, - }); -}; - -export const testGroup = async (userId: number, name = `Test Group ${sequence++}`) => - db.group.create({ - data: { - name, - publicId: `integration-group-${sequence++}`, - userId, - groupUsers: { create: { userId } }, - }, - }); - -export const testExpense = async (input: { - paidBy: number; - participantId: number; - groupId?: number | null; - amount?: bigint; - name?: string; -}) => { - const amount = input.amount ?? 1_000n; - const data: Prisma.ExpenseCreateInput = { - name: input.name ?? `Test Expense ${sequence++}`, - category: 'Other', - amount, - currency: 'USD', - splitType: SplitType.EQUAL, - addedByUser: { connect: { id: input.paidBy } }, - paidByUser: { connect: { id: input.paidBy } }, - ...(input.groupId ? { group: { connect: { id: input.groupId } } } : {}), - expenseParticipants: { - create: [ - { userId: input.paidBy, amount }, - { userId: input.participantId, amount: -amount }, - ], - }, +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 }), }; - return db.expense.create({ data }); }; + +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 index c23fd5cd5..9906c1b88 100644 --- a/src/tests/integration/recurrence.integration.test.ts +++ b/src/tests/integration/recurrence.integration.test.ts @@ -1,45 +1,51 @@ -jest.mock('~/server/api/services/notificationService', () => ({ - sendExpensePushNotification: jest.fn().mockResolvedValue(undefined), -})); -jest.mock('~/server/auth', () => ({ getServerAuthSession: jest.fn() })); -jest.mock('nanoid', () => ({ nanoid: () => 'integration-public-id' })); -jest.mock('superjson', () => ({ - default: { serialize: (value: unknown) => value, deserialize: (value: unknown) => value }, -})); -jest.mock('~/server/db', () => { - const { PrismaClient } = require('@prisma/client') as typeof import('@prisma/client'); - return { db: new PrismaClient({ datasourceUrl: process.env.DATABASE_URL }) }; -}); - -import { SplitType } from '@prisma/client'; - -import { db } from '~/server/db'; -import { resetDatabase } from './database'; -import { testUser } from './factories'; +import { db } from './database'; +import { testScenario } from './factories'; import { callerFor } from './trpc'; -describe('recurrence integration', () => { - beforeEach(() => resetDatabase()); - - it('creates a pg_cron-backed recurrence with an expense', async () => { - const user = await testUser(); - const caller = callerFor(user.id); - const [expense] = await caller.expense.addOrEditExpense({ - paidBy: user.id, - name: 'Recurring expense', - category: 'Other', - amount: 500n, - groupId: null, - splitType: SplitType.EQUAL, - currency: 'USD', - participants: [{ userId: user.id, amount: 500n }], - cronExpression: '0 0 * * *', +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.findFirst({ - include: { job: true, expense: true }, + 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 *', }); - expect(recurrence?.expense[0]?.id).toBe(expense?.id); - expect(recurrence?.job.schedule).toBe('0 0 * * *'); + 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 index fc9a50a31..1ab394b20 100644 --- a/src/tests/integration/trpc.ts +++ b/src/tests/integration/trpc.ts @@ -16,5 +16,7 @@ export const sessionFor = (userId: number): Session => ({ expires: '2099-01-01T00:00:00.000Z', }); -export const callerFor = (userId: number) => - appRouter.createCaller(createInnerTRPCContext({ session: sessionFor(userId), db })); +export const callerFor = (userId: number | null) => + appRouter.createCaller( + createInnerTRPCContext({ session: null === userId ? null : sessionFor(userId), db }), + ); diff --git a/src/tests/setup/component.ts b/src/tests/setup/component.ts index 902f5a618..06a8f1b02 100644 --- a/src/tests/setup/component.ts +++ b/src/tests/setup/component.ts @@ -1,9 +1,23 @@ 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: false, + matches: query === '(min-width: 768px)', media: query, onchange: null, addListener: () => undefined, diff --git a/src/tests/setup/integration.ts b/src/tests/setup/integration.ts index 88f275fb1..38e67a415 100644 --- a/src/tests/setup/integration.ts +++ b/src/tests/setup/integration.ts @@ -1,7 +1,13 @@ -import { closeDatabase } from '~/tests/integration/database'; +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), +})); -jest.setTimeout(30_000); +import { acquireDatabaseLock, db, resetDatabase } from '~/tests/integration/database'; -afterAll(async () => { - await closeDatabase(); -}); +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/auth.setup.ts b/tests/e2e/auth.setup.ts deleted file mode 100644 index d7c327757..000000000 --- a/tests/e2e/auth.setup.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { randomBytes } from 'node:crypto'; -import { mkdir, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { test as setup } from '@playwright/test'; -import { db } from '~/server/db'; - -const authDir = join(process.cwd(), 'playwright', '.auth'); -const email = `playwright-${Date.now()}-${randomBytes(4).toString('hex')}@example.test`; - -setup('create database-backed session', async () => { - const user = await db.user.create({ - data: { email, name: 'Playwright Owner', preferredLanguage: 'en' }, - }); - const sessionToken = randomBytes(32).toString('hex'); - await db.session.create({ - data: { sessionToken, userId: user.id, expires: new Date(Date.now() + 86_400_000) }, - }); - await mkdir(authDir, { recursive: true }); - await writeFile(join(authDir, 'user-meta.json'), JSON.stringify({ userId: user.id, email })); - await writeFile( - join(authDir, 'user.json'), - JSON.stringify({ - cookies: [ - { - name: 'next-auth.session-token', - value: sessionToken, - domain: '127.0.0.1', - path: '/', - httpOnly: true, - sameSite: 'Lax', - }, - ], - origins: [], - }), - ); -}); diff --git a/tests/e2e/authorization.spec.ts b/tests/e2e/authorization.spec.ts index a16298821..7c98f3c01 100644 --- a/tests/e2e/authorization.spec.ts +++ b/tests/e2e/authorization.spec.ts @@ -1,15 +1,22 @@ import { expect, test } from './fixtures'; -test('redirects unauthenticated visitors to sign in', async ({ browser }) => { - const context = await browser.newContext({ storageState: { cookies: [], origins: [] } }); - const page = await context.newPage(); - await page.goto('/balances'); - await expect(page).toHaveURL(/\/auth\/signin/); - await context.close(); +test('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('keeps authenticated users on protected pages', async ({ page }) => { - await page.goto('/groups'); - await expect(page).not.toHaveURL(/\/auth\/signin/); - await expect(page.getByRole('main')).toBeVisible(); +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 index 9fb126dc5..4a5460dc8 100644 --- a/tests/e2e/fixtures.ts +++ b/tests/e2e/fixtures.ts @@ -1,36 +1,93 @@ -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { type Group, PrismaClient, type User } from '@prisma/client'; import { test as base, expect } from '@playwright/test'; -import { db } from '~/server/db'; +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 { - userId: number; + scenario: Scenario; uniqueName: string; } - -interface AuthMeta { - userId: number; +interface WorkerFixtures { + db: PrismaClient; } -const metaPath = join(process.cwd(), 'playwright', '.auth', 'user-meta.json'); - -export const test = base.extend({ - userId: async ({ page: _page }, use) => { - const parsed: unknown = JSON.parse(await readFile(metaPath, 'utf8')); - if ( - !parsed || - 'object' !== typeof parsed || - !('userId' in parsed) || - 'number' !== typeof parsed.userId - ) { - throw new Error('Playwright authentication metadata has no numeric userId'); +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 } } }), + ]); } - const meta: AuthMeta = { userId: parsed.userId }; - await use(meta.userId); }, - uniqueName: async ({ page: _page }, use, testInfo) => { - await use(`E2E ${testInfo.project.name} ${testInfo.workerIndex} ${testInfo.testId}`); + 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 { db, expect }; +export { createDatabaseExpense, expenseInput, expect }; diff --git a/tests/e2e/group-expense.spec.ts b/tests/e2e/group-expense.spec.ts index de5055f6e..e499ce8b1 100644 --- a/tests/e2e/group-expense.spec.ts +++ b/tests/e2e/group-expense.spec.ts @@ -1,15 +1,26 @@ import { expect, test } from './fixtures'; -test('creates an isolated group and records an expense', async ({ page, uniqueName }) => { - await page.goto('/groups'); - await page.getByRole('button', { name: /^create$/i }).click(); - await page.getByPlaceholder(/group name/i).fill(uniqueName); +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.getByText(uniqueName)).toBeVisible(); + 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 }); +}); - await expect(page).toHaveURL(/\/groups\/\d+/); - const groupId = page.url().match(/\/groups\/(\d+)/)?.[1]; - await page.goto(`/add?groupId=${groupId}`); +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 @@ -17,5 +28,22 @@ test('creates an isolated group and records an expense', async ({ page, uniqueNa .last() .click(); await expect(page).toHaveURL(/\/groups\/\d+\/expenses\//); - await expect(page.getByText(`${uniqueName} expense`)).toBeVisible(); + 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 index 518ffef3f..68b8564d6 100644 --- a/tests/e2e/settlement.spec.ts +++ b/tests/e2e/settlement.spec.ts @@ -1,7 +1,54 @@ -import { expect, test } from './fixtures'; +import { SplitType } from '@prisma/client'; +import { createDatabaseExpense, expect, expenseInput, test } from './fixtures'; -test('shows the group balance and settlement controls', async ({ page }) => { - await page.goto('/balances'); - await expect(page).toHaveURL(/\/balances/); - await expect(page.getByRole('main')).toBeVisible(); +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"] } From 14de1364e56644fba879a66bd9761cfb3ec49036 Mon Sep 17 00:00:00 2001 From: krokosik Date: Sun, 4 Oct 2026 23:49:42 +0200 Subject: [PATCH 13/17] docs: document isolated test harness workflow --- .github/copilot-instructions.md | 2 +- AGENTS.md | 10 ++- docs/testing-strategy.md | 129 ++++++++++++++++++++------------ 3 files changed, 90 insertions(+), 51 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8bf4cb6c8..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): diff --git a/AGENTS.md b/AGENTS.md index 43339e35d..487b5075a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -335,7 +335,7 @@ E2E tests with `pnpm exec playwright test --project=chromium` in separate jobs. - `pnpm test` selects `src/**/*.{test,spec}.{ts,tsx}` and excludes `src/tests/integration/`. - `pnpm test:integration` selects only `src/tests/integration/**/*.{test,spec}.{ts,tsx}`. - `pnpm exec playwright test --project=chromium` selects `tests/e2e/` through - `playwright.config.ts`; setup runs before the Chromium project. + `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`. @@ -344,9 +344,11 @@ E2E tests with `pnpm exec playwright test --project=chromium` in separate jobs. Integration and E2E databases must be local/disposable and end in `_test`. The integration harness refuses non-local or non-test URLs; never point these commands at development, -staging, or production data. CI creates a fresh PostgreSQL service and may use -`prisma db push --accept-data-loss` because that database is disposable. Local worktrees -must use a separate PostgreSQL container, database name, and host port. +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. diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index 02f5db55d..90d4d7852 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -1,51 +1,88 @@ # Testing strategy -SplitPro has three deliberately independent test surfaces. Fast checks fail quickly without -requiring PostgreSQL; database and browser checks each provision their own disposable service. - -## Command matrix - -| Purpose | Exact command | Selection | -| -------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------- | -| Formatting | `pnpm prettier --check .` | All supported files | -| Lint | `pnpm lint` | Oxlint project sources | -| Types | `pnpm tsgo --noEmit` | TypeScript project | -| Unit/component tests | `pnpm test` | `src/**/*.{test,spec}.{ts,tsx}`, excluding `src/tests/integration/` | -| One unit file | `pnpm test src/tests/simplify.test.ts` | The named file | -| Integration tests | `pnpm test:integration` | `src/tests/integration/**/*.{test,spec}.{ts,tsx}` | -| One integration file | `pnpm test:integration src/tests/integration/expense.integration.test.ts` | The named file | -| Chromium E2E | `pnpm test:e2e -- --project=chromium` | `tests/e2e/`, including setup dependency | -| One E2E file | `pnpm test:e2e -- tests/e2e/group-expense.spec.ts` | The named file | -| Production build | `pnpm build --no-lint` | Next.js production build | - -The pull-request `Check` workflow runs formatting, lint, types, unit/component tests, and the -build. `Integration Tests` and `E2E Tests` are separate jobs and do not depend on `Check` or on -each other. E2E failures upload `test-results/e2e` and `playwright-report`. - -## Database safety - -Integration and E2E tests are destructive by design: they create, update, and delete records. -Use only a local disposable PostgreSQL database whose database name ends in `_test`. The -integration harness also requires a local host (`localhost`, `127.0.0.1`, or `::1`) and refuses -other URLs before tests run. CI creates a new `splitpro_harness_test` service for each job and -prepares it with: - -```bash -pnpm exec prisma migrate deploy -``` - -For local work, copy `.env.example`, use a dedicated container/database/port per worktree, and -set `DATABASE_URL` (and `E2E_DATABASE_URL` for Playwright) to that `_test` database. Do not use -`pnpm db:push`, reset, seed, or these test commands against development, staging, or production. -When the disposable container is no longer needed, stop it with the project’s test-container -workflow; never clean it by deleting data from a shared server. +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 this document and the existing harness/configuration before changing tests. -2. Make the smallest change in the owning packet; keep application, Prisma, Jest, manifest, and - lockfile changes out of CI/documentation work. -3. Run the narrowest affected command first, then `pnpm prettier --check .` and any available - fast checks. Run integration/E2E only with a disposable `_test` database. -4. For E2E failures, preserve and inspect Playwright traces, screenshots, videos, and reports. -5. Report exact commands and failures; do not weaken selection or database guards to make CI pass. +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. From 4d5ce6f853188d90996db16a5ca8b76ab43da610 Mon Sep 17 00:00:00 2001 From: krokosik Date: Mon, 5 Oct 2026 19:28:13 +0200 Subject: [PATCH 14/17] Fix formatting --- src/server/api/services/splitService.ts | 71 +++++++++++-------------- 1 file changed, 32 insertions(+), 39 deletions(-) diff --git a/src/server/api/services/splitService.ts b/src/server/api/services/splitService.ts index 30ea9ebff..9eb06948e 100644 --- a/src/server/api/services/splitService.ts +++ b/src/server/api/services/splitService.ts @@ -330,7 +330,8 @@ export async function getCompleteFriendsDetails(userId: number) { }, }); - const friends = viewBalances.reduce< Record< + const friends = viewBalances.reduce< + Record< number, { id: number; @@ -338,27 +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, - }); - } + > + >((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 acc; + }, {}); return friends; } @@ -389,16 +388,13 @@ export async function importUserBalanceFromSplitWise( const users = await createUsersFromSplitwise(splitWiseUsers); - const userMap = users.reduce< Record>( - (acc, user) => { - if (user.email) { - acc[user.email] = user; - } + const userMap = users.reduce>((acc, user) => { + if (user.email) { + acc[user.email] = user; + } - return acc; - }, - {}, - ); + 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< Record>( - (acc, user) => { - if (user.email) { - acc[user.email] = user; - } + const userMap = users.reduce>((acc, user) => { + if (user.email) { + acc[user.email] = user; + } - return acc; - }, - {}, - ); + return acc; + }, {}); const missingGroups = await Promise.all( splitWiseGroups.map(async (group) => { From 311e8de8a71624fa4348b2d8f533f4abea43e3be Mon Sep 17 00:00:00 2001 From: krokosik Date: Wed, 14 Oct 2026 20:05:10 +0200 Subject: [PATCH 15/17] Fix linter issue --- tests/mailer.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/mailer.test.ts b/tests/mailer.test.ts index 17913b0ff..ce1dcab47 100644 --- a/tests/mailer.test.ts +++ b/tests/mailer.test.ts @@ -31,9 +31,7 @@ jest.mock('~/server/service-notification', () => ({ })); const mockCreateTransport = jest.mocked(nodemailer.createTransport); -const mockSendMail = jest.mocked( - mockCreateTransport({} as Parameters[0]).sendMail, -); +const mockSendMail = jest.mocked(mockCreateTransport.mock.results[0]?.value.sendMail); const mockSendToDiscord = jest.mocked(sendToDiscord); const mockSentMessageInfo = { From 92c661fe76bd10053c649f5fb9333975ea859484 Mon Sep 17 00:00:00 2001 From: krokosik Date: Wed, 14 Oct 2026 20:32:38 +0200 Subject: [PATCH 16/17] Fix missing jsdom test --- src/tests/editRecurrenceDialog.test.tsx | 2 ++ 1 file changed, 2 insertions(+) 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'; From effc73c874cd8ee9e482f08a77bf3be56b998a58 Mon Sep 17 00:00:00 2001 From: krokosik Date: Wed, 14 Oct 2026 20:38:21 +0200 Subject: [PATCH 17/17] Put all test files in src/tests --- {tests => src/tests}/appError.test.ts | 0 {tests => src/tests}/inviteCooldown.test.ts | 0 {tests => src/tests}/inviteErrors.test.ts | 0 {tests => src/tests}/mailer.test.ts | 7 +++++-- 4 files changed, 5 insertions(+), 2 deletions(-) rename {tests => src/tests}/appError.test.ts (100%) rename {tests => src/tests}/inviteCooldown.test.ts (100%) rename {tests => src/tests}/inviteErrors.test.ts (100%) rename {tests => src/tests}/mailer.test.ts (95%) 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/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 ce1dcab47..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,7 +32,9 @@ jest.mock('~/server/service-notification', () => ({ })); const mockCreateTransport = jest.mocked(nodemailer.createTransport); -const mockSendMail = jest.mocked(mockCreateTransport.mock.results[0]?.value.sendMail); +const { mockSendMail } = jest.requireMock<{ + mockSendMail: jest.MockedFunction; +}>('nodemailer'); const mockSendToDiscord = jest.mocked(sendToDiscord); const mockSentMessageInfo = {