diff --git a/.agent/architecture.md b/.agent/architecture.md new file mode 100644 index 000000000..2218ed2ca --- /dev/null +++ b/.agent/architecture.md @@ -0,0 +1,81 @@ +# Architecture + +## Data Model (Prisma) + +``` +Group (id, name, currency, currencyCode) + └── Participant (id, name) + └── Expense (id, title, amount, expenseDate, splitMode, isReimbursement) + ├── paidBy → Participant + ├── paidFor → ExpensePaidFor[] (participantId, shares) + ├── Category (id, grouping, name) + ├── ExpenseDocument[] (url, width, height) + └── RecurringExpenseLink (nextExpenseCreatedAt) + └── Activity (time, activityType, data) - audit log +``` + +### Split Modes + +- `EVENLY`: Divide equally, `shares` = 1 per participant +- `BY_SHARES`: Proportional, e.g., shares 2:1:1 = 50%:25%:25% +- `BY_PERCENTAGE`: Basis points (10000 = 100%), e.g., 2500 = 25% +- `BY_AMOUNT`: Direct cents, `shares` = exact amount owed + +### Calculations (src/lib/balances.ts) + +```typescript +// BY_PERCENTAGE: (expense.amount * shares) / 10000 +// BY_SHARES: (expense.amount * shares) / totalShares +// BY_AMOUNT: shares directly +// Rounding: Math.round() at the end +``` + +## Directory Details + +### src/app/ + +Next.js App Router. Pages, layouts, Server Actions. Group pages under `groups/[groupId]/`. + +### src/components/ + +Reusable components. shadcn/UI primitives in `ui/`. Feature components at root. + +### src/trpc/ + +- `init.ts` - tRPC config, SuperJSON transformer +- `routers/_app.ts` - Root router composition +- `routers/groups/` - Group domain (expenses, balances, stats, activities) +- `routers/categories/` - Category CRUD + +### src/lib/ + +- `api.ts` - Database operations (createExpense, updateExpense, etc.) +- `balances.ts` - Balance calculation logic +- `totals.ts` - Expense total calculations +- `schemas.ts` - Zod validation schemas +- `prisma.ts` - Prisma client singleton +- `featureFlags.ts` - Feature toggles (S3 docs, receipt scanning) + +## tRPC Router Hierarchy + +``` +appRouter +├── groups +│ ├── get, getDetails, list, create, update +│ ├── expenses (list, get, create, update, delete) +│ ├── balances (list) +│ ├── stats (get) +│ └── activities (list) +└── categories + └── list +``` + +API calls: `trpc.groups.expenses.create()`, `trpc.groups.balances.list()`, etc. + +## Feature Flags + +Env vars for optional features: + +- `NEXT_PUBLIC_ENABLE_EXPENSE_DOCUMENTS` - S3 image uploads +- `NEXT_PUBLIC_ENABLE_RECEIPT_EXTRACT` - GPT-4V receipt scanning +- `NEXT_PUBLIC_ENABLE_CATEGORY_EXTRACT` - AI category suggestions diff --git a/.agent/database.md b/.agent/database.md new file mode 100644 index 000000000..88eb3d2a7 --- /dev/null +++ b/.agent/database.md @@ -0,0 +1,119 @@ +# Database + +## Setup + +```bash +./scripts/start-local-db.sh # Start PostgreSQL container +npx prisma migrate dev # Run migrations +npx prisma studio # GUI for database +npx prisma generate # Regenerate client after schema changes +``` + +## Prisma Client Singleton + +```typescript +// src/lib/prisma.ts +import { PrismaClient } from '@prisma/client' + +const globalForPrisma = global as unknown as { prisma: PrismaClient } + +export const prisma = globalForPrisma.prisma || new PrismaClient() + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma +``` + +Dev mode uses global singleton to survive hot reload. + +## Schema Changes + +1. Edit `prisma/schema.prisma` +2. Run `npx prisma migrate dev --name descriptive_name` +3. Commit migration file + schema changes together + +## Query Patterns + +### Create with Relations + +```typescript +// src/lib/api.ts - createExpense +await prisma.expense.create({ + data: { + groupId, + title, + amount, + paidById: paidBy, + splitMode, + expenseDate, + paidFor: { + createMany: { + data: paidFor.map(({ participant, shares }) => ({ + participantId: participant, + shares, + })), + }, + }, + }, +}) +``` + +### Query with Includes + +```typescript +// Expenses with payer and split details +await prisma.expense.findMany({ + where: { groupId }, + include: { + paidBy: true, + paidFor: { include: { participant: true } }, + category: true, + }, + orderBy: [{ expenseDate: 'desc' }, { createdAt: 'desc' }], +}) +``` + +### Update with Nested Operations + +```typescript +// Update expense and replace paidFor entries +await prisma.expense.update({ + where: { id: expenseId }, + data: { + title, + amount, + paidFor: { + deleteMany: {}, // Remove all existing + createMany: { data: newPaidFor }, + }, + }, +}) +``` + +## Transactions + +Used for atomic operations: + +```typescript +// src/lib/api.ts - createRecurringExpenses +await prisma.$transaction(async (tx) => { + const expense = await tx.expense.create({ data: expenseData }) + await tx.recurringExpenseLink.update({ + where: { id: linkId }, + data: { nextExpenseCreatedAt: nextDate }, + }) + return expense +}) +``` + +## Amount Storage + +All monetary values stored as **integers in cents**: + +- `100` = $1.00 +- `15050` = $150.50 + +Split shares vary by mode: + +- `EVENLY`: 1 per participant +- `BY_SHARES`: Weight integers (1, 2, 3...) +- `BY_PERCENTAGE`: Basis points (2500 = 25%) +- `BY_AMOUNT`: Cents directly diff --git a/.agent/testing.md b/.agent/testing.md new file mode 100644 index 000000000..35d2a2096 --- /dev/null +++ b/.agent/testing.md @@ -0,0 +1,110 @@ +# Testing + +## Jest Unit Tests + +```bash +npm test # Run all tests +npm test -- --watch # Watch mode +npm test -- path/to/file.test.ts # Specific file +``` + +Tests in `src/**/*.test.ts` alongside implementation. + +### Test Data Factory Pattern + +```typescript +// src/lib/balances.test.ts +const makeExpense = (overrides: Partial): BalancesExpense => + ({ + id: 'e1', + expenseDate: new Date('2025-01-01T00:00:00.000Z'), + title: 'Dinner', + amount: 0, + isReimbursement: false, + splitMode: 'EVENLY', + paidBy: { id: 'p0', name: 'P0' }, + paidFor: [{ participant: { id: 'p0', name: 'P0' }, shares: 1 }], + ...overrides, + }) as BalancesExpense + +// Usage +const expenses = [ + makeExpense({ + amount: 100, + paidBy: { id: 'p0', name: 'P0' }, + paidFor: [ + { participant: { id: 'p0', name: 'P0' }, shares: 1 }, + { participant: { id: 'p1', name: 'P1' }, shares: 1 }, + ], + }), +] +``` + +### Focus Areas + +- `balances.test.ts` - Balance calculations, split modes, edge cases +- `totals.test.ts` - Expense totals, user shares +- `currency.test.ts` - Currency formatting + +## Playwright E2E Tests + +```bash +npm run test-e2e # Runs against local dev server +``` + +Tests in `tests/e2e/*.spec.ts` and `tests/*.spec.ts`. + +### Test Helpers (`tests/helpers/`) + +| Helper | Purpose | +| ----------------------------------------------- | ------------------------ | +| `createGroupViaAPI(page, name, participants)` | Fast group setup via API | +| `createExpense(page, { title, amount, payer })` | Fill expense form | +| `navigateToExpenseCreate(page, groupId)` | Go to expense creation | +| `fillParticipants(page, names)` | Add participants to form | +| `selectComboboxOption(page, label, value)` | Select dropdown value | + +### Stability Patterns + +```typescript +// Wait after navigation +await page.goto(`/groups/${groupId}`) +await page.waitForLoadState() + +// Wait for URL after form submission +await page.getByRole('button', { name: 'Create' }).click() +await page.waitForURL(/\/groups\/[^/]+\/expenses/) + +// Use API for fast setup +const groupId = await createGroupViaAPI(page, 'Test Group', ['Alice', 'Bob']) +``` + +### Example Test + +```typescript +import { createExpense } from '../helpers' +import { createGroupViaAPI } from '../helpers/batch-api' + +test('creates expense with correct values', async ({ page }) => { + const groupId = await createGroupViaAPI(page, `Test ${randomId(4)}`, [ + 'Alice', + 'Bob', + ]) + await page.goto(`/groups/${groupId}/expenses`) + + await createExpense(page, { + title: 'Dinner', + amount: '150.00', + payer: 'Alice', + }) + + await expect(page.getByText('Dinner')).toBeVisible() + await expect(page.getByText('$150.00')).toBeVisible() +}) +``` + +### Config Notes + +- `fullyParallel: false` in playwright.config.ts prevents DB conflicts +- Runs Chromium, Firefox, WebKit +- `json` reporter when `CLAUDE_CODE` env var detected diff --git a/.agent/trpc-procedures.md b/.agent/trpc-procedures.md new file mode 100644 index 000000000..ee753c964 --- /dev/null +++ b/.agent/trpc-procedures.md @@ -0,0 +1,131 @@ +# tRPC Procedures + +## Router Composition + +Routers compose hierarchically via `createTRPCRouter`: + +```typescript +// src/trpc/routers/_app.ts (root) +import { createTRPCRouter } from '../init' +import { categoriesRouter } from './categories' +import { groupsRouter } from './groups' + +export const appRouter = createTRPCRouter({ + groups: groupsRouter, + categories: categoriesRouter, +}) +``` + +```typescript +// src/trpc/routers/groups/index.ts (domain) +export const groupsRouter = createTRPCRouter({ + expenses: groupExpensesRouter, // sub-router + balances: groupBalancesRouter, + stats: groupStatsRouter, + activities: activitiesRouter, + get: getGroupProcedure, // procedures + create: createGroupProcedure, +}) +``` + +## Adding a New Procedure + +### 1. Create Procedure File + +```typescript +// src/trpc/routers/groups/expenses/archive.procedure.ts +import { prisma } from '@/lib/prisma' +import { baseProcedure } from '@/trpc/init' +import { z } from 'zod' + +export const archiveExpenseProcedure = baseProcedure + .input( + z.object({ + expenseId: z.string().min(1), + groupId: z.string().min(1), + }), + ) + .mutation(async ({ input: { expenseId, groupId } }) => { + const expense = await prisma.expense.update({ + where: { id: expenseId, groupId }, + data: { archived: true }, + }) + return { expenseId: expense.id } + }) +``` + +### 2. Export from Router Index + +```typescript +// src/trpc/routers/groups/expenses/index.ts +import { archiveExpenseProcedure } from './archive.procedure' + +export const groupExpensesRouter = createTRPCRouter({ + list: listGroupExpensesProcedure, + get: getGroupExpenseProcedure, + create: createGroupExpenseProcedure, + update: updateGroupExpenseProcedure, + delete: deleteGroupExpenseProcedure, + archive: archiveExpenseProcedure, // add here +}) +``` + +### 3. Use in Client + +```typescript +// Query +const { data } = trpc.groups.expenses.list.useQuery({ groupId }) + +// Mutation +const archiveMutation = trpc.groups.expenses.archive.useMutation() +await archiveMutation.mutateAsync({ expenseId, groupId }) +``` + +## Zod Validation + +Input validation via `.input()`: + +```typescript +// Inline schema +.input(z.object({ + groupId: z.string().min(1), + title: z.string().min(2), + amount: z.number().positive(), +})) + +// Shared schema (src/lib/schemas.ts) +import { expenseFormSchema } from '@/lib/schemas' + +.input(z.object({ + groupId: z.string(), + expenseFormValues: expenseFormSchema, +})) +``` + +## Query vs Mutation + +```typescript +// Query - fetching data +export const listProcedure = baseProcedure + .input(z.object({ groupId: z.string() })) + .query(async ({ input }) => { + return prisma.expense.findMany({ where: { groupId: input.groupId } }) + }) + +// Mutation - modifying data +export const createProcedure = baseProcedure + .input(z.object({ title: z.string() })) + .mutation(async ({ input }) => { + return prisma.expense.create({ data: input }) + }) +``` + +## SuperJSON Transformer + +Configured in `src/trpc/init.ts`. Automatically handles: + +- `Date` serialization +- `BigInt` serialization +- `Map`/`Set` serialization + +No manual conversion needed for these types. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b81bbbfc..816f457ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,23 +8,43 @@ on: jobs: checks: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04-arm + env: + POSTGRES_PRISMA_URL: postgresql://postgres:1234@localhost + POSTGRES_URL_NON_POOLING: postgresql://postgres:1234@localhost + NEXT_PUBLIC_DEFAULT_CURRENCY_CODE: USD + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: 1234 + POSTGRES_DB: test_db + ports: + - 5432:5432 + # Wait for postgres to be ready + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 steps: - name: Check out repository - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: - node-version: 18 + node-version: 24 cache: 'npm' - name: Install dependencies - run: npm ci --ignore-scripts + run: npm ci - name: Generate Prisma client - run: npx prisma generate + run: npm run prisma-generate - name: Check TypeScript types run: npm run check-types @@ -34,3 +54,12 @@ jobs: - name: Check Prettier formatting run: npm run check-formatting + + - name: Run Prisma migrations + run: npm run prisma-migrate + + - name: Run tests + run: npm run test + + - name: Build + run: npm run build \ No newline at end of file diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 000000000..c534e4eb8 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,67 @@ +name: Playwright Tests +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-24.04-arm + strategy: + fail-fast: false + matrix: + browser: [chromium, firefox, webkit] + env: + POSTGRES_PRISMA_URL: postgresql://postgres:1234@localhost + POSTGRES_URL_NON_POOLING: postgresql://postgres:1234@localhost + NEXT_PUBLIC_DEFAULT_CURRENCY_CODE: USD + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: 1234 + POSTGRES_DB: test_db + ports: + - 5432:5432 + # Wait for postgres to be ready + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Generate Prisma client + run: npm run prisma-generate + + - name: Run Prisma migrations + run: npm run prisma-migrate + + - name: Install Playwright Browsers + run: npx playwright install --with-deps ${{ matrix.browser }} + + - name: Run all Playwright tests + run: npx playwright test --project=${{ matrix.browser }} + if: github.event_name == 'pull_request' + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 9d718ff6a..4aea1ef40 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,11 @@ next-env.d.ts # db postgres-data + +# Playwright +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..d2cd42291 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,54 @@ +# AGENTS.md + +Spliit is an open-source expense-splitting app (Next.js + tRPC + Prisma + PostgreSQL). + +## Commands + +```bash +npm run dev # Dev server at localhost:3000 +npm run build # Production build +npm check-types # TypeScript check (not `npm run tsc`) +npm check-formatting # Prettier check +npm test # Jest unit tests +npm run test-e2e # Playwright e2e tests +``` + +## Directory Structure + +- `src/app/` - Next.js App Router pages, layouts, Server Actions +- `src/components/` - React components (shadcn/UI based) +- `src/trpc/routers/` - tRPC procedures organized by domain +- `src/lib/` - Utilities (balances, totals, currency, schemas) +- `prisma/schema.prisma` - Database schema + +## Key Patterns + +**Data** + +- Amounts stored as integers (cents). 100 = $1.00 +- `BY_PERCENTAGE` splits use basis points (2500 = 25%) + +**Frontend** + +- Next.js App Router, Server Components default +- shadcn/UI components in `src/components/ui/` +- Forms: React Hook Form + Zod + shadcn `
` +- tRPC hooks via `trpc.domain.procedure.useQuery/useMutation()` + +**Backend** + +- tRPC procedures in `src/trpc/routers/`, one file per operation +- Zod for input validation on all procedures +- Business logic in `src/lib/api.ts`, procedures are thin wrappers + +**Database** + +- Prisma ORM, schema at `prisma/schema.prisma` +- Queries use `include` for relations, not separate fetches + +## Detailed Docs + +- [Architecture](.agent/architecture.md) - Data model, tRPC structure, directory details +- [Database](.agent/database.md) - Prisma patterns, migrations, queries +- [Testing](.agent/testing.md) - Jest/Playwright patterns, helpers, factories +- [tRPC Procedures](.agent/trpc-procedures.md) - Adding new procedures, router composition diff --git a/README.md b/README.md index 91ee5d155..4c7c06ddc 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,11 @@ Here is the current state of translation: ## Run locally 1. Clone the repository (or fork it if you intend to contribute) -2. Start a PostgreSQL server. You can run `./scripts/start-local-db.sh` if you don’t have a server already. -3. Copy the file `.env.example` as `.env` -4. Run `npm install` to install dependencies. This will also apply database migrations and update Prisma Client. -5. Run `npm run dev` to start the development server +2. Run `npm install` to install dependencies. +3. Start a PostgreSQL server with `./scripts/start-local-db.sh`. +4. Copy the file `.env.example` as `.env` +5. Run prisma migrations and generate the client with `npm run prisma-migrate` and `npm run prisma-generate` +6. Run `npm run dev` to start the development server ## Run in a container diff --git a/jest.config.ts b/jest.config.ts index 844f75924..ab24c098a 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,18 +1,13 @@ import type { Config } from 'jest' import nextJest from 'next/jest.js' - + const createJestConfig = nextJest({ - // Provide the path to your Next.js app to load next.config.js and .env files in your test environment dir: './', }) - -// Add any custom config to be passed to Jest + const config: Config = { coverageProvider: 'v8', - testEnvironment: 'jsdom', - // Add more setup options before each test is run - // setupFilesAfterEnv: ['/jest.setup.ts'], + testEnvironment: 'node', } - -// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async -export default createJestConfig(config) \ No newline at end of file + +export default createJestConfig(config) diff --git a/package-lock.json b/package-lock.json index 17f239100..b5931f31a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "spliit2", "version": "0.1.0", - "hasInstallScript": true, "dependencies": { "@formatjs/intl-localematcher": "^0.5.4", "@hookform/resolvers": "^3.3.2", @@ -66,6 +65,7 @@ "zod": "^3.23.8" }, "devDependencies": { + "@playwright/test": "^1.57.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.3.0", @@ -85,6 +85,7 @@ "eslint-config-next": "^16.0.7", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "jest-mock-extended": "^4.0.0", "postcss": "^8", "prettier": "^3.0.3", "prettier-plugin-organize-imports": "^3.2.3", @@ -764,6 +765,7 @@ "version": "3.622.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.622.0.tgz", "integrity": "sha512-dwWDfN+S98npeY77Ugyv8VIHKRHN+n/70PWE4EgolcjaMrTINjvUh9a/SypFEs5JmBOAeCQt8S2QpM3Wvzp+pQ==", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -1424,6 +1426,7 @@ "version": "3.622.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.622.0.tgz", "integrity": "sha512-Yqtdf/wn3lcFVS42tR+zbz4HLyWxSmztjVW9L/yeMlvS7uza5nSkWqP/7ca+RxZnXLyrnA4jJtSHqykcErlhyg==", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -4007,6 +4010,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz", "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", @@ -5653,6 +5657,7 @@ "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", "dev": true, + "peer": true, "dependencies": { "@jest/environment": "^29.7.0", "@jest/expect": "^29.7.0", @@ -6079,6 +6084,23 @@ "node": ">=12.4.0" } }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@prisma/client": { "version": "6.18.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.18.0.tgz", @@ -9837,6 +9859,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.59.15.tgz", "integrity": "sha512-QbVlAkTI78wB4Mqgf2RDmgC0AOiJqer2c5k9STOOSXGv1S6ZkY37r/6UpE8DbQ2Du0ohsdoXgFNEyv+4eDoPEw==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/query-core": "5.59.13" }, @@ -9853,6 +9876,7 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -9958,6 +9982,7 @@ "https://trpc.io/sponsor" ], "license": "MIT", + "peer": true, "peerDependencies": { "@trpc/server": "11.0.0-rc.586+3388c9691" } @@ -9985,7 +10010,8 @@ "funding": [ "https://trpc.io/sponsor" ], - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@tsconfig/node10": { "version": "1.0.11", @@ -10193,6 +10219,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.8.9.tgz", "integrity": "sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -10291,6 +10318,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.48.tgz", "integrity": "sha512-qboRCl6Ie70DQQG9hhNREz81jqC1cs9EVNcjQ1AU+jH6NFfSAhVVbrrY/+nSF+Bsk4AOwm9Qa61InvMCyV+H3w==", "devOptional": true, + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -10302,6 +10330,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.18.tgz", "integrity": "sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==", "devOptional": true, + "peer": true, "dependencies": { "@types/react": "*" } @@ -10393,6 +10422,7 @@ "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", @@ -10923,6 +10953,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -11559,6 +11590,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001646", "electron-to-chromium": "^1.5.4", @@ -12575,7 +12607,8 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -12876,6 +12909,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -13100,6 +13134,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -13263,6 +13298,7 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -14945,6 +14981,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -15496,6 +15533,21 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-mock-extended": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jest-mock-extended/-/jest-mock-extended-4.0.0.tgz", + "integrity": "sha512-7BZpfuvLam+/HC+NxifIi9b+5VXj/utUDMPUqrDJehGWVuXPtLS9Jqlob2mJLrI/pg2k1S8DMfKDvEB88QNjaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-essentials": "^10.0.2" + }, + "peerDependencies": { + "@jest/globals": "^28.0.0 || ^29.0.0 || ^30.0.0", + "jest": "^24.0.0 || ^25.0.0 || ^26.0.0 || ^27.0.0 || ^28.0.0 || ^29.0.0 || ^30.0.0", + "typescript": "^3.0.0 || ^4.0.0 || ^5.0.0" + } + }, "node_modules/jest-pnp-resolver": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", @@ -15836,6 +15888,7 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.20.0.tgz", "integrity": "sha512-3TV69ZbrvV6U5DfQimop50jE9Dl6J8O1ja1dvBbMba/sZ3YBEQqJ2VZRoQPVnhlzjNtU1vaXRZVrVjU4qtm8yA==", "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -16380,6 +16433,7 @@ "resolved": "https://registry.npmjs.org/next/-/next-16.0.7.tgz", "integrity": "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "16.0.7", "@swc/helpers": "0.5.15", @@ -16501,17 +16555,6 @@ } } }, - "node_modules/next-intl/node_modules/@swc/helpers": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", - "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/next-intl/node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -17488,6 +17531,7 @@ "version": "8.11.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.11.3.tgz", "integrity": "sha512-+9iuvG8QfaaUrrph+kpF24cXkH1YOOUeArRNYIxq1viYHZagBxrTno7cecY1Fa44tJeZvaoG+Djpkc3JwehN5g==", + "peer": true, "dependencies": { "buffer-writer": "2.0.0", "packet-reader": "1.0.0", @@ -17686,6 +17730,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/po-parser": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-1.0.2.tgz", @@ -17721,6 +17812,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -17908,6 +18000,7 @@ "integrity": "sha512-L/4pUDMxcNa8R/EthV08Zt42WBO4h1rarVtK0K+QJG0X187OLo7l699jWw0GKuwzkPQ//jMFA/8Xm6Fh3J/DAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -17977,6 +18070,7 @@ "integrity": "sha512-bXWy3vTk8mnRmT+SLyZBQoC2vtV9Z8u7OHvEu+aULYxwiop/CPiFZ+F56KsNRNf35jw+8wcu8pmLsjxpBxAO9g==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "6.18.0", "@prisma/engines": "6.18.0" @@ -18092,6 +18186,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -18101,6 +18196,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -18113,6 +18209,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.68.0.tgz", "integrity": "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -19171,6 +19268,7 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.5.tgz", "integrity": "sha512-5SEZU4J7pxZgSkv7FP1zY8i2TIAOooNZ1e/OGtxIEv6GltpoiXUqWvLy89+a10qYTB1N5Ifkuw9lqQkN9sscvA==", "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -19306,6 +19404,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -19363,6 +19462,21 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-essentials": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/ts-essentials/-/ts-essentials-10.1.1.tgz", + "integrity": "sha512-4aTB7KLHKmUvkjNj8V+EdnmuVTiECzn3K+zIbRthumvHu+j44x3w63xpfs0JL3NGIzGXqoQ7AV591xHO+XrOTw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -19374,6 +19488,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -19548,6 +19663,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", "devOptional": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 0d96648ae..3eff094b1 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,12 @@ "check-types": "tsc --noEmit", "check-formatting": "prettier -c src", "prettier": "prettier -w src", - "postinstall": "prisma migrate deploy && prisma generate", + "prisma-migrate": "prisma migrate deploy", + "prisma-generate": "prisma generate", "build-image": "./scripts/build-image.sh", "start-container": "docker compose --env-file container.env up", - "test": "jest", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testEnvironment=node ./src", + "test-e2e": "playwright test", "generate-currency-data": "ts-node -T ./src/scripts/generateCurrencyData.ts" }, "dependencies": { @@ -74,6 +76,8 @@ "zod": "^3.23.8" }, "devDependencies": { + "@playwright/test": "^1.57.0", + "jest-mock-extended": "^4.0.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.3.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..235f61864 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,53 @@ +import { defineConfig, devices } from '@playwright/test' + +function isCodeAgent(): boolean { + return !!process.env.CLAUDE_CODE || !!process.env.OPENCODE +} +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : 4, + reporter: process.env.CI ? 'dot' : isCodeAgent() ? 'json' : 'html', + use: { + baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + // /* Test against mobile viewports. */ + // { + // name: 'mobile-chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'mobile-safari', + // use: { ...devices['iPhone 12'] }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run dev', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + }, +}) diff --git a/scripts/start-local-db.sh b/scripts/start-local-db.sh index 257b4b2af..1167204ce 100755 --- a/scripts/start-local-db.sh +++ b/scripts/start-local-db.sh @@ -1,11 +1,11 @@ result=$(docker ps | grep spliit-db) if [ $? -eq 0 ]; then - echo "postgres is already running, doing nothing" + echo "spliit-db is already running, doing nothing" else - echo "postgres is not running, starting it" - docker rm postgres --force + echo "spliit-db is not running, starting it" + docker rm spliit-db --force mkdir -p postgres-data docker run --name spliit-db -d -p 5432:5432 -e POSTGRES_PASSWORD=1234 -v "/$(pwd)/postgres-data:/var/lib/postgresql" postgres - sleep 5 # Wait for postgres to start + sleep 5 # Wait for spliit-db to start fi \ No newline at end of file diff --git a/src/app/groups/[groupId]/activity/activity-item.tsx b/src/app/groups/[groupId]/activity/activity-item.tsx index a3a38dba9..d56b8cb40 100644 --- a/src/app/groups/[groupId]/activity/activity-item.tsx +++ b/src/app/groups/[groupId]/activity/activity-item.tsx @@ -65,6 +65,7 @@ export function ActivityItem({ router.push(`/groups/${groupId}/expenses/${activity.expenseId}/edit`) } }} + data-testid={`activity-item-${activity.id}`} >
{dateStyle !== undefined && ( diff --git a/src/app/groups/[groupId]/activity/activity-list.tsx b/src/app/groups/[groupId]/activity/activity-list.tsx index 7dfe538ab..067e3302f 100644 --- a/src/app/groups/[groupId]/activity/activity-list.tsx +++ b/src/app/groups/[groupId]/activity/activity-list.tsx @@ -109,7 +109,7 @@ export function ActivityList() { const groupedActivitiesByDate = getGroupedActivitiesByDate(activities) return activities.length > 0 ? ( - <> +
{Object.values(DATE_GROUPS).map((dateGroup: string) => { let groupActivities = groupedActivitiesByDate[dateGroup] if (!groupActivities || groupActivities.length === 0) return null @@ -119,7 +119,7 @@ export function ActivityList() { : 'medium' return ( -
+
} - +
) : ( -

{t('noActivity')}

+

+ {t('noActivity')} +

) } diff --git a/src/app/groups/[groupId]/balances-list.tsx b/src/app/groups/[groupId]/balances-list.tsx index 445452d95..c63d9cbdd 100644 --- a/src/app/groups/[groupId]/balances-list.tsx +++ b/src/app/groups/[groupId]/balances-list.tsx @@ -17,7 +17,7 @@ export function BalancesList({ balances, participants, currency }: Props) { ) return ( -
+
{participants.map((participant) => { const balance = balances[participant.id]?.total ?? 0 const isLeft = balance >= 0 @@ -25,6 +25,7 @@ export function BalancesList({ balances, participants, currency }: Props) {
{participant.name} diff --git a/src/app/groups/[groupId]/expenses/create-from-receipt-button-actions.ts b/src/app/groups/[groupId]/expenses/create-from-receipt-button-actions.ts index b04dbab7c..0d3d2de21 100644 --- a/src/app/groups/[groupId]/expenses/create-from-receipt-button-actions.ts +++ b/src/app/groups/[groupId]/expenses/create-from-receipt-button-actions.ts @@ -5,10 +5,13 @@ import { formatCategoryForAIPrompt } from '@/lib/utils' import OpenAI from 'openai' import { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/index.mjs' -const openai = new OpenAI({ apiKey: env.OPENAI_API_KEY }) +let openai: OpenAI export async function extractExpenseInformationFromImage(imageUrl: string) { 'use server' + if (!openai) { + openai = new OpenAI({ apiKey: env.OPENAI_API_KEY }) + } const categories = await getCategories() const body: ChatCompletionCreateParamsNonStreaming = { diff --git a/src/app/groups/[groupId]/expenses/expense-card.tsx b/src/app/groups/[groupId]/expenses/expense-card.tsx index 52a3d1181..766af510f 100644 --- a/src/app/groups/[groupId]/expenses/expense-card.tsx +++ b/src/app/groups/[groupId]/expenses/expense-card.tsx @@ -63,6 +63,7 @@ export function ExpenseCard({ return (
-
+
{expense.title}
@@ -92,13 +96,17 @@ export function ExpenseCard({ 'tabular-nums whitespace-nowrap', expense.isReimbursement ? 'italic' : 'font-bold', )} + data-testid="expense-amount" > {formatCurrency(currency, expense.amount, locale)}
-
+
{formatDateOnly(expense.expenseDate, locale, { dateStyle: 'medium' })}
diff --git a/src/app/groups/[groupId]/reimbursement-list.tsx b/src/app/groups/[groupId]/reimbursement-list.tsx index a13163b55..c09c3161d 100644 --- a/src/app/groups/[groupId]/reimbursement-list.tsx +++ b/src/app/groups/[groupId]/reimbursement-list.tsx @@ -22,33 +22,45 @@ export function ReimbursementList({ const locale = useLocale() const t = useTranslations('Balances.Reimbursements') if (reimbursements.length === 0) { - return

{t('noImbursements')}

+ return ( +

+ {t('noImbursements')} +

+ ) } const getParticipant = (id: string) => participants.find((p) => p.id === id) return ( -
- {reimbursements.map((reimbursement, index) => ( -
-
-
- {t.rich('owes', { - from: getParticipant(reimbursement.from)?.name ?? '', - to: getParticipant(reimbursement.to)?.name ?? '', - strong: (chunks) => {chunks}, - })} +
+ {reimbursements.map((reimbursement) => { + const fromName = getParticipant(reimbursement.from)?.name ?? '' + const toName = getParticipant(reimbursement.to)?.name ?? '' + return ( +
+
+
+ {t.rich('owes', { + from: fromName, + to: toName, + strong: (chunks) => {chunks}, + })} +
+
- +
{formatCurrency(currency, reimbursement.amount, locale)}
-
{formatCurrency(currency, reimbursement.amount, locale)}
-
- ))} + ) + })}
) } diff --git a/src/app/groups/[groupId]/stats/totals-group-spending.tsx b/src/app/groups/[groupId]/stats/totals-group-spending.tsx index 3afad9cdb..a2ccb832a 100644 --- a/src/app/groups/[groupId]/stats/totals-group-spending.tsx +++ b/src/app/groups/[groupId]/stats/totals-group-spending.tsx @@ -12,7 +12,7 @@ export function TotalsGroupSpending({ totalGroupSpendings, currency }: Props) { const t = useTranslations('Stats.Totals') const balance = totalGroupSpendings < 0 ? 'groupEarnings' : 'groupSpendings' return ( -
+
{t(balance)}
{formatCurrency(currency, Math.abs(totalGroupSpendings), locale)} diff --git a/src/app/groups/[groupId]/stats/totals-your-share.tsx b/src/app/groups/[groupId]/stats/totals-your-share.tsx index c6390cfc9..83f5bec5c 100644 --- a/src/app/groups/[groupId]/stats/totals-your-share.tsx +++ b/src/app/groups/[groupId]/stats/totals-your-share.tsx @@ -14,7 +14,7 @@ export function TotalsYourShare({ const t = useTranslations('Stats.Totals') return ( -
+
{t('yourShare')}
+
{t(balance)}
- + ({ + nanoid: () => Math.random().toString(36).substring(2, 15), +})) + +const prisma = new PrismaClient() + +async function createRecurringExpenses() { + const localDate = new Date() + const utcDateFromLocal = new Date( + Date.UTC( + localDate.getUTCFullYear(), + localDate.getUTCMonth(), + localDate.getUTCDate(), + localDate.getUTCHours(), + localDate.getUTCMinutes(), + ), + ) + + const recurringExpenseLinksWithExpensesToCreate = + await prisma.recurringExpenseLink.findMany({ + where: { + nextExpenseCreatedAt: null, + nextExpenseDate: { + lte: utcDateFromLocal, + }, + }, + include: { + currentFrameExpense: { + include: { + paidBy: true, + paidFor: true, + category: true, + documents: true, + }, + }, + }, + }) + + for (const recurringExpenseLink of recurringExpenseLinksWithExpensesToCreate) { + let newExpenseDate = recurringExpenseLink.nextExpenseDate + + let currentExpenseRecord = recurringExpenseLink.currentFrameExpense + let currentReccuringExpenseLinkId = recurringExpenseLink.id + + while (newExpenseDate < utcDateFromLocal) { + const newExpenseId = Math.random().toString(36).substring(2, 15) + const newRecurringExpenseLinkId = Math.random() + .toString(36) + .substring(2, 15) + + const calculateNextDate = ( + recurrenceRule: RecurrenceRule, + priorDateToNextRecurrence: Date, + ) => { + const nextDate = new Date(priorDateToNextRecurrence) + switch (recurrenceRule) { + case RecurrenceRule.DAILY: + nextDate.setUTCDate(nextDate.getUTCDate() + 1) + break + case RecurrenceRule.WEEKLY: + nextDate.setUTCDate(nextDate.getUTCDate() + 7) + break + case RecurrenceRule.MONTHLY: { + const nextYear = nextDate.getUTCFullYear() + const nextMonth = nextDate.getUTCMonth() + 1 + let nextDay = nextDate.getUTCDate() + + const isDateInNextMonth = ( + utcYear: number, + utcMonth: number, + utcDate: number, + ) => { + const testDate = new Date(Date.UTC(utcYear, utcMonth, utcDate)) + return testDate.getUTCDate() === utcDate + } + + while (!isDateInNextMonth(nextYear, nextMonth, nextDay)) { + nextDay -= 1 + } + nextDate.setUTCMonth(nextMonth, nextDay) + break + } + } + return nextDate + } + + const newRecurringExpenseNextExpenseDate = calculateNextDate( + currentExpenseRecord.recurrenceRule as RecurrenceRule, + newExpenseDate, + ) + + const { + category, + paidBy, + paidFor, + documents, + ...destructeredCurrentExpenseRecord + } = currentExpenseRecord + + const newExpense = await prisma + .$transaction(async (transaction) => { + const newExpense = await transaction.expense.create({ + data: { + ...destructeredCurrentExpenseRecord, + categoryId: currentExpenseRecord.categoryId, + paidById: currentExpenseRecord.paidById, + paidFor: { + createMany: { + data: currentExpenseRecord.paidFor.map((paidFor) => ({ + participantId: paidFor.participantId, + shares: paidFor.shares, + })), + }, + }, + documents: { + connect: currentExpenseRecord.documents.map( + (documentRecord) => ({ + id: documentRecord.id, + }), + ), + }, + id: newExpenseId, + expenseDate: newExpenseDate, + recurringExpenseLink: { + create: { + groupId: currentExpenseRecord.groupId, + id: newRecurringExpenseLinkId, + nextExpenseDate: newRecurringExpenseNextExpenseDate, + }, + }, + }, + include: { + paidFor: true, + documents: true, + category: true, + paidBy: true, + }, + }) + + await transaction.recurringExpenseLink.update({ + where: { + id: currentReccuringExpenseLinkId, + nextExpenseCreatedAt: null, + }, + data: { + nextExpenseCreatedAt: newExpense.createdAt, + }, + }) + + return newExpense + }) + .catch(() => { + console.error( + 'Failed to created recurringExpense for expenseId: %s', + currentExpenseRecord.id, + ) + return null + }) + + if (newExpense === null) break + + currentExpenseRecord = newExpense + currentReccuringExpenseLinkId = newRecurringExpenseLinkId + newExpenseDate = newRecurringExpenseNextExpenseDate + } + } +} + +describe('Activity Logging', () => { + let groupId: string + let participantIds: string[] + let expenseId: string + + // Helper function to create activity - mimics logActivity from src/lib/api.ts + const createActivity = ( + gId: string, + activityType: ActivityType, + extra?: { participantId?: string; expenseId?: string; data?: string }, + ) => { + return prisma.activity.create({ + data: { + id: randomId(), + groupId: gId, + activityType, + ...extra, + }, + }) + } + + beforeEach(async () => { + groupId = randomId() + participantIds = [randomId(), randomId()] + expenseId = randomId() + await createTestGroup(groupId, participantIds) + }) + + afterEach(async () => { + await cleanupTestData(groupId, participantIds) + }) + + describe('CREATE_EXPENSE logging', () => { + it('logs CREATE_EXPENSE activity with correct data', async () => { + const expenseTitle = 'Test Expense' + const participantId = participantIds[0] + + const activity = await createActivity( + groupId, + ActivityType.CREATE_EXPENSE, + { + participantId, + expenseId, + data: expenseTitle, + }, + ) + + expect(activity).toBeDefined() + expect(activity.groupId).toBe(groupId) + expect(activity.activityType).toBe(ActivityType.CREATE_EXPENSE) + expect(activity.participantId).toBe(participantId) + expect(activity.expenseId).toBe(expenseId) + expect(activity.data).toBe(expenseTitle) + + // Verify it's stored in the database + const storedActivity = await prisma.activity.findUnique({ + where: { id: activity.id }, + }) + expect(storedActivity).toBeDefined() + expect(storedActivity!.activityType).toBe(ActivityType.CREATE_EXPENSE) + }) + + it('stores participant ID correctly in activity', async () => { + const participantId = participantIds[0] + const activity = await createActivity( + groupId, + ActivityType.CREATE_EXPENSE, + { + participantId, + expenseId, + data: 'Pizza', + }, + ) + + const stored = await prisma.activity.findUnique({ + where: { id: activity.id }, + }) + expect(stored!.participantId).toBe(participantId) + }) + + it('stores expense data (title) correctly in activity', async () => { + const expenseTitle = 'Restaurant Bill' + const activity = await createActivity( + groupId, + ActivityType.CREATE_EXPENSE, + { + participantId: participantIds[0], + expenseId, + data: expenseTitle, + }, + ) + + const stored = await prisma.activity.findUnique({ + where: { id: activity.id }, + }) + expect(stored!.data).toBe(expenseTitle) + }) + }) + + describe('UPDATE_EXPENSE logging', () => { + it('logs UPDATE_EXPENSE activity with correct data', async () => { + const newExpenseTitle = 'Updated Expense' + const participantId = participantIds[0] + + const activity = await createActivity( + groupId, + ActivityType.UPDATE_EXPENSE, + { + participantId, + expenseId, + data: newExpenseTitle, + }, + ) + + expect(activity).toBeDefined() + expect(activity.groupId).toBe(groupId) + expect(activity.activityType).toBe(ActivityType.UPDATE_EXPENSE) + expect(activity.participantId).toBe(participantId) + expect(activity.expenseId).toBe(expenseId) + expect(activity.data).toBe(newExpenseTitle) + + // Verify it's stored in the database + const storedActivity = await prisma.activity.findUnique({ + where: { id: activity.id }, + }) + expect(storedActivity).toBeDefined() + expect(storedActivity!.activityType).toBe(ActivityType.UPDATE_EXPENSE) + }) + + it('stores updated expense title in activity data', async () => { + const updatedTitle = 'Modified Dinner' + const activity = await createActivity( + groupId, + ActivityType.UPDATE_EXPENSE, + { + participantId: participantIds[1], + expenseId, + data: updatedTitle, + }, + ) + + const stored = await prisma.activity.findUnique({ + where: { id: activity.id }, + }) + expect(stored!.data).toBe(updatedTitle) + }) + }) + + describe('DELETE_EXPENSE logging', () => { + it('logs DELETE_EXPENSE activity with correct data', async () => { + const deletedExpenseTitle = 'Deleted Expense' + const participantId = participantIds[0] + + const activity = await createActivity( + groupId, + ActivityType.DELETE_EXPENSE, + { + participantId, + expenseId, + data: deletedExpenseTitle, + }, + ) + + expect(activity).toBeDefined() + expect(activity.groupId).toBe(groupId) + expect(activity.activityType).toBe(ActivityType.DELETE_EXPENSE) + expect(activity.participantId).toBe(participantId) + expect(activity.expenseId).toBe(expenseId) + expect(activity.data).toBe(deletedExpenseTitle) + + // Verify it's stored in the database + const storedActivity = await prisma.activity.findUnique({ + where: { id: activity.id }, + }) + expect(storedActivity).toBeDefined() + expect(storedActivity!.activityType).toBe(ActivityType.DELETE_EXPENSE) + }) + + it('stores deleted expense title in activity data', async () => { + const originalTitle = 'Groceries' + const activity = await createActivity( + groupId, + ActivityType.DELETE_EXPENSE, + { + participantId: participantIds[0], + expenseId, + data: originalTitle, + }, + ) + + const stored = await prisma.activity.findUnique({ + where: { id: activity.id }, + }) + expect(stored!.data).toBe(originalTitle) + }) + }) + + describe('UPDATE_GROUP logging', () => { + it('logs UPDATE_GROUP activity with correct data', async () => { + const participantId = participantIds[0] + + const activity = await createActivity( + groupId, + ActivityType.UPDATE_GROUP, + { + participantId, + }, + ) + + expect(activity).toBeDefined() + expect(activity.groupId).toBe(groupId) + expect(activity.activityType).toBe(ActivityType.UPDATE_GROUP) + expect(activity.participantId).toBe(participantId) + expect(activity.expenseId).toBeNull() + expect(activity.data).toBeNull() + + // Verify it's stored in the database + const storedActivity = await prisma.activity.findUnique({ + where: { id: activity.id }, + }) + expect(storedActivity).toBeDefined() + expect(storedActivity!.activityType).toBe(ActivityType.UPDATE_GROUP) + }) + + it('stores participant ID for group update activity', async () => { + const participantId = participantIds[1] + const activity = await createActivity( + groupId, + ActivityType.UPDATE_GROUP, + { + participantId, + }, + ) + + const stored = await prisma.activity.findUnique({ + where: { id: activity.id }, + }) + expect(stored!.participantId).toBe(participantId) + }) + }) + + describe('Activity retrieval', () => { + it('retrieves multiple activities for a group', async () => { + // Create multiple activities + const activity1 = await createActivity( + groupId, + ActivityType.CREATE_EXPENSE, + { + participantId: participantIds[0], + expenseId: randomId(), + data: 'Expense 1', + }, + ) + + const activity2 = await createActivity( + groupId, + ActivityType.UPDATE_EXPENSE, + { + participantId: participantIds[1], + expenseId: randomId(), + data: 'Expense 2', + }, + ) + + const activity3 = await createActivity( + groupId, + ActivityType.DELETE_EXPENSE, + { + participantId: participantIds[0], + expenseId: randomId(), + data: 'Expense 3', + }, + ) + + // Retrieve all activities for the group + const activities = await prisma.activity.findMany({ + where: { groupId }, + orderBy: { time: 'desc' }, + }) + + expect(activities).toHaveLength(3) + expect(activities.map((a) => a.id)).toContain(activity1.id) + expect(activities.map((a) => a.id)).toContain(activity2.id) + expect(activities.map((a) => a.id)).toContain(activity3.id) + }) + + it('activity records contain timestamp', async () => { + const activity = await createActivity( + groupId, + ActivityType.CREATE_EXPENSE, + { + participantId: participantIds[0], + expenseId, + data: 'Timestamped Expense', + }, + ) + + expect(activity.time).toBeDefined() + expect(activity.time).toBeInstanceOf(Date) + expect(activity.time.getTime()).toBeLessThanOrEqual(Date.now()) + }) + }) +}) + +function randomId() { + return Math.random().toString(36).substring(2, 15) +} + +async function createTestGroup(groupId: string, participantIds: string[]) { + await prisma.group.create({ + data: { + id: groupId, + name: 'Test Group', + currency: '$', + currencyCode: 'USD', + participants: { + createMany: { + data: [ + { id: participantIds[0], name: 'Alice' }, + { id: participantIds[1], name: 'Bob' }, + ], + }, + }, + }, + }) +} + +async function cleanupTestData(groupId: string, participantIds: string[]) { + await prisma.expense.deleteMany({ where: { groupId } }) + await prisma.recurringExpenseLink.deleteMany({ where: { groupId } }) + await prisma.activity.deleteMany({ where: { groupId } }) + await prisma.participant.deleteMany({ where: { id: { in: participantIds } } }) + await prisma.group.delete({ where: { id: groupId } }) +} + +describe('createPayloadForNewRecurringExpenseLink', () => { + describe('Daily recurrence', () => { + it('returns correct next date for daily interval', () => { + const priorDate = new Date(Date.UTC(2025, 0, 15, 10, 30, 0)) + const groupId = 'test-group-1' + + const payload = createPayloadForNewRecurringExpenseLink( + RecurrenceRule.DAILY, + priorDate, + groupId, + ) + + expect(payload).toBeDefined() + expect(payload.id).toBeDefined() + expect(payload.id).toBeTruthy() + expect(payload.groupId).toBe(groupId) + expect(payload.nextExpenseDate).toBeDefined() + + // Verify the next date is exactly 1 day later + const expectedDate = new Date(Date.UTC(2025, 0, 16, 10, 30, 0)) + expect(payload.nextExpenseDate.getUTCFullYear()).toBe( + expectedDate.getUTCFullYear(), + ) + expect(payload.nextExpenseDate.getUTCMonth()).toBe( + expectedDate.getUTCMonth(), + ) + expect(payload.nextExpenseDate.getUTCDate()).toBe( + expectedDate.getUTCDate(), + ) + }) + + it('handles year boundary for daily interval', () => { + const priorDate = new Date(Date.UTC(2024, 11, 31, 0, 0, 0)) + const groupId = 'test-group-1' + + const payload = createPayloadForNewRecurringExpenseLink( + RecurrenceRule.DAILY, + priorDate, + groupId, + ) + + // Should roll over to next year + expect(payload.nextExpenseDate.getUTCFullYear()).toBe(2025) + expect(payload.nextExpenseDate.getUTCMonth()).toBe(0) // January + expect(payload.nextExpenseDate.getUTCDate()).toBe(1) + }) + }) + + describe('Weekly recurrence', () => { + it('returns correct next date for weekly interval', () => { + const priorDate = new Date(Date.UTC(2025, 0, 13, 14, 45, 0)) // Monday + const groupId = 'test-group-2' + + const payload = createPayloadForNewRecurringExpenseLink( + RecurrenceRule.WEEKLY, + priorDate, + groupId, + ) + + expect(payload).toBeDefined() + expect(payload.id).toBeDefined() + expect(payload.id).toBeTruthy() + expect(payload.groupId).toBe(groupId) + expect(payload.nextExpenseDate).toBeDefined() + + // Verify the next date is exactly 7 days later + const expectedDate = new Date(Date.UTC(2025, 0, 20, 14, 45, 0)) + expect(payload.nextExpenseDate.getUTCFullYear()).toBe( + expectedDate.getUTCFullYear(), + ) + expect(payload.nextExpenseDate.getUTCMonth()).toBe( + expectedDate.getUTCMonth(), + ) + expect(payload.nextExpenseDate.getUTCDate()).toBe( + expectedDate.getUTCDate(), + ) + }) + + it('handles month boundary for weekly interval', () => { + const priorDate = new Date(Date.UTC(2025, 0, 28, 0, 0, 0)) + const groupId = 'test-group-2' + + const payload = createPayloadForNewRecurringExpenseLink( + RecurrenceRule.WEEKLY, + priorDate, + groupId, + ) + + // Should roll over to next month + expect(payload.nextExpenseDate.getUTCMonth()).toBe(1) // February + expect(payload.nextExpenseDate.getUTCDate()).toBe(4) + }) + }) + + describe('Monthly recurrence', () => { + it('returns correct next date for monthly interval', () => { + const priorDate = new Date(Date.UTC(2025, 0, 15, 9, 0, 0)) + const groupId = 'test-group-3' + + const payload = createPayloadForNewRecurringExpenseLink( + RecurrenceRule.MONTHLY, + priorDate, + groupId, + ) + + expect(payload).toBeDefined() + expect(payload.id).toBeDefined() + expect(payload.id).toBeTruthy() + expect(payload.groupId).toBe(groupId) + expect(payload.nextExpenseDate).toBeDefined() + + // Verify the next date is in the next month on the same day + expect(payload.nextExpenseDate.getUTCFullYear()).toBe(2025) + expect(payload.nextExpenseDate.getUTCMonth()).toBe(1) // February + expect(payload.nextExpenseDate.getUTCDate()).toBe(15) + }) + + it('handles month boundary for Jan 31 to Feb', () => { + const priorDate = new Date(Date.UTC(2025, 0, 31, 0, 0, 0)) + const groupId = 'test-group-3' + + const payload = createPayloadForNewRecurringExpenseLink( + RecurrenceRule.MONTHLY, + priorDate, + groupId, + ) + + // Should adjust to Feb 28 (non-leap year) + expect(payload.nextExpenseDate.getUTCFullYear()).toBe(2025) + expect(payload.nextExpenseDate.getUTCMonth()).toBe(1) // February + expect(payload.nextExpenseDate.getUTCDate()).toBe(28) + }) + + it('handles leap year Feb 29', () => { + const priorDate = new Date(Date.UTC(2024, 0, 31, 0, 0, 0)) + const groupId = 'test-group-3' + + const payload = createPayloadForNewRecurringExpenseLink( + RecurrenceRule.MONTHLY, + priorDate, + groupId, + ) + + // Should adjust to Feb 29 (leap year) + expect(payload.nextExpenseDate.getUTCFullYear()).toBe(2024) + expect(payload.nextExpenseDate.getUTCMonth()).toBe(1) // February + expect(payload.nextExpenseDate.getUTCDate()).toBe(29) + }) + + it('handles year boundary for monthly interval', () => { + const priorDate = new Date(Date.UTC(2024, 11, 15, 0, 0, 0)) // December + const groupId = 'test-group-3' + + const payload = createPayloadForNewRecurringExpenseLink( + RecurrenceRule.MONTHLY, + priorDate, + groupId, + ) + + // Should roll over to next year + expect(payload.nextExpenseDate.getUTCFullYear()).toBe(2025) + expect(payload.nextExpenseDate.getUTCMonth()).toBe(0) // January + expect(payload.nextExpenseDate.getUTCDate()).toBe(15) + }) + }) +}) + +describe('createRecurringExpenses', () => { + let groupId: string + let participantIds: string[] + + beforeEach(async () => { + groupId = randomId() + participantIds = [randomId(), randomId()] + await createTestGroup(groupId, participantIds) + }) + + afterEach(async () => { + await cleanupTestData(groupId, participantIds) + }) + + describe('MONTHLY recurrence', () => { + it('creates expense with correct date for monthly interval', async () => { + const initialDate = new Date(Date.UTC(2025, 0, 15, 0, 0, 0)) + const nextMonthDate = new Date(Date.UTC(2025, 1, 15, 0, 0, 0)) + + const expenseId = randomId() + await prisma.expense.create({ + data: { + id: expenseId, + groupId, + expenseDate: initialDate, + title: 'Monthly Rent', + amount: 1000, + paidById: participantIds[0], + splitMode: 'EVENLY', + recurrenceRule: RecurrenceRule.MONTHLY, + recurringExpenseLink: { + create: { + id: randomId(), + groupId, + nextExpenseDate: nextMonthDate, + }, + }, + paidFor: { + createMany: { + data: participantIds.map((pid) => ({ + participantId: pid, + shares: 1, + })), + }, + }, + }, + include: { recurringExpenseLink: true }, + }) + + const initialExpenseCount = await prisma.expense.count({ + where: { groupId }, + }) + expect(initialExpenseCount).toBe(1) + + await createRecurringExpenses() + + const newExpenseCount = await prisma.expense.count({ + where: { groupId }, + }) + expect(newExpenseCount).toBeGreaterThan(1) + + const newExpense = await prisma.expense.findFirst({ + where: { + groupId, + id: { not: expenseId }, + }, + orderBy: { createdAt: 'desc' }, + }) + + expect(newExpense).toBeDefined() + expect(newExpense!.expenseDate.getUTCFullYear()).toBe(2025) + expect(newExpense!.expenseDate.getUTCMonth()).toBe(1) + expect(newExpense!.expenseDate.getUTCDate()).toBe(15) + }) + + it('handles month boundary correctly for Jan 31 to Feb', async () => { + const january31 = new Date(Date.UTC(2025, 0, 31, 0, 0, 0)) + const february28 = new Date(Date.UTC(2025, 1, 28, 0, 0, 0)) + + const expenseId = randomId() + await prisma.expense.create({ + data: { + id: expenseId, + groupId, + expenseDate: january31, + title: 'Monthly Subscription', + amount: 1500, + paidById: participantIds[0], + splitMode: 'EVENLY', + recurrenceRule: RecurrenceRule.MONTHLY, + recurringExpenseLink: { + create: { + id: randomId(), + groupId, + nextExpenseDate: february28, + }, + }, + paidFor: { + createMany: { + data: participantIds.map((pid) => ({ + participantId: pid, + shares: 1, + })), + }, + }, + }, + }) + + await createRecurringExpenses() + + const newExpense = await prisma.expense.findFirst({ + where: { + groupId, + id: { not: expenseId }, + }, + orderBy: { createdAt: 'desc' }, + }) + + expect(newExpense).toBeDefined() + expect(newExpense!.expenseDate.getUTCFullYear()).toBe(2025) + expect(newExpense!.expenseDate.getUTCMonth()).toBe(1) + expect(newExpense!.expenseDate.getUTCDate()).toBe(28) + }) + + it('handles month boundary correctly for Nov 30 to Dec 30', async () => { + const november30 = new Date(Date.UTC(2025, 9, 30, 0, 0, 0)) + const december30 = new Date(Date.UTC(2025, 10, 30, 0, 0, 0)) + + const expenseId = randomId() + await prisma.expense.create({ + data: { + id: expenseId, + groupId, + expenseDate: november30, + title: 'Monthly Service', + amount: 5000, + paidById: participantIds[0], + splitMode: 'EVENLY', + recurrenceRule: RecurrenceRule.MONTHLY, + recurringExpenseLink: { + create: { + id: randomId(), + groupId, + nextExpenseDate: december30, + }, + }, + paidFor: { + createMany: { + data: participantIds.map((pid) => ({ + participantId: pid, + shares: 1, + })), + }, + }, + }, + }) + + await createRecurringExpenses() + + const newExpenseCount = await prisma.expense.count({ + where: { groupId }, + }) + expect(newExpenseCount).toBeGreaterThan(1) + + const newExpense = await prisma.expense.findFirst({ + where: { + groupId, + id: { not: expenseId }, + }, + orderBy: { createdAt: 'desc' }, + }) + + expect(newExpense).toBeDefined() + expect(newExpense!.expenseDate.getUTCFullYear()).toBe(2025) + expect(newExpense!.expenseDate.getUTCMonth()).toBe(10) + expect(newExpense!.expenseDate.getUTCDate()).toBe(30) + }) + + it('creates multiple instances when nextExpenseDate is far in the past', async () => { + const startDate = new Date(Date.UTC(2025, 0, 15, 0, 0, 0)) + const threeMonthsAgo = new Date(Date.UTC(2024, 10, 15, 0, 0, 0)) + + const expenseId = randomId() + await prisma.expense.create({ + data: { + id: expenseId, + groupId, + expenseDate: startDate, + title: 'Monthly Fee', + amount: 100, + paidById: participantIds[0], + splitMode: 'EVENLY', + recurrenceRule: RecurrenceRule.MONTHLY, + recurringExpenseLink: { + create: { + id: randomId(), + groupId, + nextExpenseDate: threeMonthsAgo, + }, + }, + paidFor: { + createMany: { + data: participantIds.map((pid) => ({ + participantId: pid, + shares: 1, + })), + }, + }, + }, + }) + + const initialCount = await prisma.expense.count({ where: { groupId } }) + expect(initialCount).toBe(1) + + await createRecurringExpenses() + + const finalCount = await prisma.expense.count({ where: { groupId } }) + expect(finalCount).toBeGreaterThan(1) + }) + + it('preserves expense metadata when creating recurring instance', async () => { + const initialDate = new Date(Date.UTC(2025, 2, 1, 0, 0, 0)) + const nextMonthDate = new Date(Date.UTC(2025, 3, 1, 0, 0, 0)) + + const expenseId = randomId() + await prisma.expense.create({ + data: { + id: expenseId, + groupId, + expenseDate: initialDate, + title: 'Office Supplies', + amount: 250, + paidById: participantIds[0], + splitMode: 'EVENLY', + recurrenceRule: RecurrenceRule.MONTHLY, + recurringExpenseLink: { + create: { + id: randomId(), + groupId, + nextExpenseDate: nextMonthDate, + }, + }, + paidFor: { + createMany: { + data: participantIds.map((pid) => ({ + participantId: pid, + shares: 1, + })), + }, + }, + }, + }) + + await createRecurringExpenses() + + const newExpense = await prisma.expense.findFirst({ + where: { + groupId, + id: { not: expenseId }, + }, + include: { paidFor: true }, + orderBy: { createdAt: 'desc' }, + }) + + expect(newExpense).toBeDefined() + expect(newExpense!.title).toBe('Office Supplies') + expect(newExpense!.amount).toBe(250) + expect(newExpense!.paidById).toBe(participantIds[0]) + expect(newExpense!.splitMode).toBe('EVENLY') + expect(newExpense!.paidFor).toHaveLength(2) + }) + }) + + describe('Transaction behavior', () => { + it('rolls back transaction on error and does not persist partial data', async () => { + const initialDate = new Date(Date.UTC(2025, 0, 15, 0, 0, 0)) + const nextMonthDate = new Date(Date.UTC(2025, 1, 15, 0, 0, 0)) + + const expenseId = randomId() + const recurringLinkId = randomId() + + // Create a recurring expense + await prisma.expense.create({ + data: { + id: expenseId, + groupId, + expenseDate: initialDate, + title: 'Monthly Service', + amount: 500, + paidById: participantIds[0], + splitMode: 'EVENLY', + recurrenceRule: RecurrenceRule.MONTHLY, + recurringExpenseLink: { + create: { + id: recurringLinkId, + groupId, + nextExpenseDate: nextMonthDate, + }, + }, + paidFor: { + createMany: { + data: participantIds.map((pid) => ({ + participantId: pid, + shares: 1, + })), + }, + }, + }, + include: { recurringExpenseLink: true }, + }) + + const initialExpenseCount = await prisma.expense.count({ + where: { groupId }, + }) + expect(initialExpenseCount).toBe(1) + + // Verify initial state of recurring link + const linkBefore = await prisma.recurringExpenseLink.findUnique({ + where: { id: recurringLinkId }, + }) + expect(linkBefore).toBeDefined() + expect(linkBefore!.nextExpenseCreatedAt).toBeNull() + + // Update the recurringExpenseLink to make the WHERE clause in the update fail + // The transaction expects nextExpenseCreatedAt to be null, but we set it to a date + // This will cause the update in the transaction to fail (record not found) + await prisma.recurringExpenseLink.update({ + where: { id: recurringLinkId }, + data: { nextExpenseCreatedAt: new Date() }, + }) + + // Attempt to create recurring expenses (should fail and rollback) + await createRecurringExpenses() + + // Verify no new expense was created (transaction rolled back) + const expenseCountAfter = await prisma.expense.count({ + where: { groupId }, + }) + expect(expenseCountAfter).toBe(initialExpenseCount) + + // Verify recurring link was NOT updated to null again (remains with the date we set) + const linkAfter = await prisma.recurringExpenseLink.findUnique({ + where: { id: recurringLinkId }, + }) + expect(linkAfter).toBeDefined() + expect(linkAfter!.nextExpenseCreatedAt).not.toBeNull() + + // Verify only the original expense exists + const expenses = await prisma.expense.findMany({ + where: { groupId }, + }) + expect(expenses).toHaveLength(1) + expect(expenses[0].id).toBe(expenseId) + }) + }) +}) diff --git a/src/lib/api.ts b/src/lib/api.ts index 43fa5d430..cd96e7196 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -7,9 +7,10 @@ import { RecurringExpenseLink, } from '@prisma/client' import { nanoid } from 'nanoid' +import { calculateNextDate } from './recurring-expenses' -export function randomId() { - return nanoid() +export function randomId(size?: number) { + return nanoid(size) } export async function createGroup(groupFormValues: GroupFormValues) { @@ -437,7 +438,7 @@ export async function logActivity( }) } -async function createRecurringExpenses() { +export async function createRecurringExpenses() { const localDate = new Date() // Current local date const utcDateFromLocal = new Date( Date.UTC( @@ -569,7 +570,7 @@ async function createRecurringExpenses() { } } -function createPayloadForNewRecurringExpenseLink( +export function createPayloadForNewRecurringExpenseLink( recurrenceRule: RecurrenceRule, priorDateToNextRecurrence: Date, groupId: String, @@ -588,52 +589,3 @@ function createPayloadForNewRecurringExpenseLink( return recurringExpenseLinkPayload as RecurringExpenseLink } - -// TODO: Modify this function to use a more comprehensive recurrence Rule library like rrule (https://github.com/jkbrzt/rrule) -// -// Current limitations: -// - If a date is intended to be repeated monthly on the 29th, 30th or 31st, it will change to repeating on the smallest -// date that the reccurence has encountered. Ex. If a recurrence is created for Jan 31st on 2025, the recurring expense -// will be created for Feb 28th, March 28, etc. until it is cancelled or fixed -function calculateNextDate( - recurrenceRule: RecurrenceRule, - priorDateToNextRecurrence: Date, -): Date { - const nextDate = new Date(priorDateToNextRecurrence) - switch (recurrenceRule) { - case RecurrenceRule.DAILY: - nextDate.setUTCDate(nextDate.getUTCDate() + 1) - break - case RecurrenceRule.WEEKLY: - nextDate.setUTCDate(nextDate.getUTCDate() + 7) - break - case RecurrenceRule.MONTHLY: - const nextYear = nextDate.getUTCFullYear() - const nextMonth = nextDate.getUTCMonth() + 1 - let nextDay = nextDate.getUTCDate() - - // Reduce the next day until it is within the direct next month - while (!isDateInNextMonth(nextYear, nextMonth, nextDay)) { - nextDay -= 1 - } - nextDate.setUTCMonth(nextMonth, nextDay) - break - } - - return nextDate -} - -function isDateInNextMonth( - utcYear: number, - utcMonth: number, - utcDate: number, -): Boolean { - const testDate = new Date(Date.UTC(utcYear, utcMonth, utcDate)) - - // We're not concerned if the year or month changes. We only want to make sure that the date is our target date - if (testDate.getUTCDate() !== utcDate) { - return false - } - - return true -} diff --git a/src/lib/balances.test.ts b/src/lib/balances.test.ts new file mode 100644 index 000000000..3518a3369 --- /dev/null +++ b/src/lib/balances.test.ts @@ -0,0 +1,527 @@ +import { getBalances, getSuggestedReimbursements } from './balances' + +type BalancesExpense = Parameters[0][number] + +const makeExpense = (overrides: Partial): BalancesExpense => + ({ + id: 'e1', + expenseDate: new Date('2025-01-01T00:00:00.000Z'), + title: 'Dinner', + amount: 0, + isReimbursement: false, + splitMode: 'EVENLY', + createdAt: new Date('2025-01-01T00:00:00.000Z'), + recurrenceRule: null, + category: null, + paidBy: { id: 'p0', name: 'P0' }, + paidFor: [ + { + participant: { id: 'p0', name: 'P0' }, + shares: 1, + }, + ], + _count: { documents: 0 }, + ...overrides, + }) as BalancesExpense + +describe('getBalances', () => { + it('avoids negative zeros', () => { + const expenses: BalancesExpense[] = [ + makeExpense({ + id: 'e1', + amount: 0, + paidBy: { id: 'p0', name: 'P0' }, + paidFor: [{ participant: { id: 'p0', name: 'P0' }, shares: 1 }], + }), + ] + + const balances = getBalances(expenses) + + expect(Object.is(balances.p0.paid, -0)).toBe(false) + expect(Object.is(balances.p0.paidFor, -0)).toBe(false) + expect(Object.is(balances.p0.total, -0)).toBe(false) + }) + + it('handles empty expense list', () => { + expect(getBalances([])).toEqual({}) + }) + + it('single expense, single participant', () => { + const expenses: BalancesExpense[] = [ + makeExpense({ + id: 'e1', + amount: 123, + paidBy: { id: 'p0', name: 'P0' }, + paidFor: [{ participant: { id: 'p0', name: 'P0' }, shares: 1 }], + }), + ] + + expect(getBalances(expenses)).toEqual({ + p0: { paid: 123, paidFor: 123, total: 0 }, + }) + }) + + it('evenly splits expenses', () => { + const expenses: BalancesExpense[] = [ + makeExpense({ + id: 'e1', + amount: 100, + paidBy: { id: 'p0', name: 'P0' }, + paidFor: [ + { participant: { id: 'p0', name: 'P0' }, shares: 1 }, + { participant: { id: 'p1', name: 'P1' }, shares: 1 }, + { participant: { id: 'p2', name: 'P2' }, shares: 1 }, + ], + }), + ] + + const balances = getBalances(expenses) + + expect(balances.p0).toEqual({ paid: 100, paidFor: 33, total: 67 }) + expect(balances.p1).toEqual({ paid: 0, paidFor: 33, total: -33 }) + expect(balances.p2).toEqual({ paid: 0, paidFor: 33, total: -33 }) + + const net = Object.values(balances).reduce((sum, b) => sum + b.total, 0) + expect(net).toBe(expenses[0].amount % expenses[0].paidFor.length) + }) + + it('splits BY_SHARES proportionally', () => { + const expenses: BalancesExpense[] = [ + makeExpense({ + id: 'e1', + amount: 600, + splitMode: 'BY_SHARES', + paidBy: { id: 'p0', name: 'P0' }, + paidFor: [ + { participant: { id: 'p0', name: 'P0' }, shares: 1 }, + { participant: { id: 'p1', name: 'P1' }, shares: 2 }, + { participant: { id: 'p2', name: 'P2' }, shares: 3 }, + ], + }), + ] + + const balances = getBalances(expenses) + + expect(balances.p0).toEqual({ paid: 600, paidFor: 100, total: 500 }) + expect(balances.p1).toEqual({ paid: 0, paidFor: 200, total: -200 }) + expect(balances.p2).toEqual({ paid: 0, paidFor: 300, total: -300 }) + }) + + it('splits BY_PERCENTAGE using basis points', () => { + const expenses: BalancesExpense[] = [ + makeExpense({ + id: 'e1', + amount: 250, + splitMode: 'BY_PERCENTAGE', + paidBy: { id: 'p0', name: 'P0' }, + paidFor: [ + { participant: { id: 'p0', name: 'P0' }, shares: 2000 }, + { participant: { id: 'p1', name: 'P1' }, shares: 3000 }, + { participant: { id: 'p2', name: 'P2' }, shares: 5000 }, + ], + }), + ] + + const balances = getBalances(expenses) + + expect(balances.p0).toEqual({ paid: 250, paidFor: 50, total: 200 }) + expect(balances.p1).toEqual({ paid: 0, paidFor: 75, total: -75 }) + expect(balances.p2).toEqual({ paid: 0, paidFor: 125, total: -125 }) + }) + + it('splits BY_AMOUNT and assigns remainder to last participant', () => { + const expenses: BalancesExpense[] = [ + makeExpense({ + id: 'e1', + amount: 101, + splitMode: 'BY_AMOUNT', + paidBy: { id: 'p0', name: 'P0' }, + paidFor: [ + { participant: { id: 'p0', name: 'P0' }, shares: 10 }, + { participant: { id: 'p1', name: 'P1' }, shares: 10 }, + { participant: { id: 'p2', name: 'P2' }, shares: 10 }, + ], + }), + ] + + const balances = getBalances(expenses) + + // Note: implementation treats `shares` as weights (not absolute amounts) + // and assigns the remainder to the last participant. + expect(balances.p0).toEqual({ paid: 101, paidFor: 34, total: 67 }) + expect(balances.p1).toEqual({ paid: 0, paidFor: 34, total: -34 }) + expect(balances.p2).toEqual({ paid: 0, paidFor: 34, total: -34 }) + }) + + it('handles rounding correctly', () => { + const expenses: BalancesExpense[] = [ + makeExpense({ + id: 'e1', + amount: 100, // 100 / 3 = 33.333... + splitMode: 'EVENLY', + paidBy: { id: 'p0', name: 'P0' }, + paidFor: [ + { participant: { id: 'p0', name: 'P0' }, shares: 1 }, + { participant: { id: 'p1', name: 'P1' }, shares: 1 }, + { participant: { id: 'p2', name: 'P2' }, shares: 1 }, + ], + }), + makeExpense({ + id: 'e2', + amount: 77, // 77 / 3 = 25.666... + splitMode: 'EVENLY', + paidBy: { id: 'p1', name: 'P1' }, + paidFor: [ + { participant: { id: 'p0', name: 'P0' }, shares: 1 }, + { participant: { id: 'p1', name: 'P1' }, shares: 1 }, + { participant: { id: 'p2', name: 'P2' }, shares: 1 }, + ], + }), + makeExpense({ + id: 'e3', + amount: 99, // 99 / 7 = 14.142857... + splitMode: 'BY_SHARES', + paidBy: { id: 'p2', name: 'P2' }, + paidFor: [ + { participant: { id: 'p0', name: 'P0' }, shares: 2 }, + { participant: { id: 'p1', name: 'P1' }, shares: 3 }, + { participant: { id: 'p2', name: 'P2' }, shares: 2 }, + ], + }), + ] + + const balances = getBalances(expenses) + + // Verify all values are integers (rounded) + expect(Number.isInteger(balances.p0.paid)).toBe(true) + expect(Number.isInteger(balances.p0.paidFor)).toBe(true) + expect(Number.isInteger(balances.p0.total)).toBe(true) + expect(Number.isInteger(balances.p1.paid)).toBe(true) + expect(Number.isInteger(balances.p1.paidFor)).toBe(true) + expect(Number.isInteger(balances.p1.total)).toBe(true) + expect(Number.isInteger(balances.p2.paid)).toBe(true) + expect(Number.isInteger(balances.p2.paidFor)).toBe(true) + expect(Number.isInteger(balances.p2.total)).toBe(true) + + // Verify totals balance (sum ~= 0, within rounding tolerance) + const netTotal = Object.values(balances).reduce( + (sum, b) => sum + b.total, + 0, + ) + expect(Math.abs(netTotal)).toBeLessThan(3) // Tolerance for rounding remainder + + // Verify no negative zeros + expect(Object.is(balances.p0.paid, -0)).toBe(false) + expect(Object.is(balances.p0.paidFor, -0)).toBe(false) + expect(Object.is(balances.p0.total, -0)).toBe(false) + }) + + it('handles multiple participants with mixed expenses', () => { + const expenses: BalancesExpense[] = [ + makeExpense({ + id: 'e1', + amount: 120, + splitMode: 'EVENLY', + paidBy: { id: 'p0', name: 'Alice' }, + paidFor: [ + { participant: { id: 'p0', name: 'Alice' }, shares: 1 }, + { participant: { id: 'p1', name: 'Bob' }, shares: 1 }, + { participant: { id: 'p2', name: 'Carol' }, shares: 1 }, + ], + }), + makeExpense({ + id: 'e2', + amount: 600, + splitMode: 'BY_SHARES', + paidBy: { id: 'p1', name: 'Bob' }, + paidFor: [ + { participant: { id: 'p0', name: 'Alice' }, shares: 1 }, + { participant: { id: 'p1', name: 'Bob' }, shares: 2 }, + { participant: { id: 'p2', name: 'Carol' }, shares: 3 }, + ], + }), + makeExpense({ + id: 'e3', + amount: 200, + splitMode: 'BY_PERCENTAGE', + paidBy: { id: 'p2', name: 'Carol' }, + paidFor: [ + { participant: { id: 'p0', name: 'Alice' }, shares: 5000 }, // 50% + { participant: { id: 'p1', name: 'Bob' }, shares: 3000 }, // 30% + { participant: { id: 'p2', name: 'Carol' }, shares: 2000 }, // 20% + ], + }), + ] + + const balances = getBalances(expenses) + + // Alice: paid 120, owes (40 + 100 + 100) = 240, total = 120 - 240 = -120 + expect(balances.p0.paid).toBe(120) + expect(balances.p0.paidFor).toBe(240) + expect(balances.p0.total).toBe(-120) + + // Bob: paid 600, owes (40 + 200 + 60) = 300, total = 600 - 300 = 300 + expect(balances.p1.paid).toBe(600) + expect(balances.p1.paidFor).toBe(300) + expect(balances.p1.total).toBe(300) + + // Carol: paid 200, owes (40 + 300 + 40) = 380, total = 200 - 380 = -180 + expect(balances.p2.paid).toBe(200) + expect(balances.p2.paidFor).toBe(380) + expect(balances.p2.total).toBe(-180) + + // Verify sum of totals = 0 (within rounding tolerance) + const netTotal = Object.values(balances).reduce( + (sum, b) => sum + b.total, + 0, + ) + expect(Math.abs(netTotal)).toBeLessThan(3) + }) + + it('handles BY_AMOUNT with one participant having 0 shares', () => { + const expenses: BalancesExpense[] = [ + makeExpense({ + id: 'e1', + amount: 100, + splitMode: 'BY_AMOUNT', + paidBy: { id: 'p0', name: 'P0' }, + paidFor: [ + { participant: { id: 'p0', name: 'P0' }, shares: 0 }, + { participant: { id: 'p1', name: 'P1' }, shares: 10 }, + { participant: { id: 'p2', name: 'P2' }, shares: 10 }, + ], + }), + ] + + const balances = getBalances(expenses) + + // p0 paid 100 but has 0 shares, so owes 0 + expect(balances.p0).toEqual({ paid: 100, paidFor: 0, total: 100 }) + // p1 and p2 split the remaining 100 (50 each) + expect(balances.p1).toEqual({ paid: 0, paidFor: 50, total: -50 }) + expect(balances.p2).toEqual({ paid: 0, paidFor: 50, total: -50 }) + }) + + it('handles BY_PERCENTAGE where percentages do not sum to 10000 (remainder assigned to last)', () => { + const expenses: BalancesExpense[] = [ + makeExpense({ + id: 'e1', + amount: 10000, + splitMode: 'BY_PERCENTAGE', + paidBy: { id: 'p0', name: 'P0' }, + paidFor: [ + { participant: { id: 'p0', name: 'P0' }, shares: 2000 }, // 20% + { participant: { id: 'p1', name: 'P1' }, shares: 3000 }, // 30% + // Missing 5000 basis points - should be assigned to last participant + { participant: { id: 'p2', name: 'P2' }, shares: 3000 }, // Only 30% specified, gets remainder + ], + }), + ] + + const balances = getBalances(expenses) + + // p0: paid 10000, owes (20/80)% = 2500 (remainder goes to last) + expect(balances.p0).toEqual({ paid: 10000, paidFor: 2500, total: 7500 }) + // p1: paid 0, owes (30/80)% = 3750 + expect(balances.p1).toEqual({ paid: 0, paidFor: 3750, total: -3750 }) + // p2: paid 0, gets remainder = 3750 (30/80)% + remainder + expect(balances.p2).toEqual({ paid: 0, paidFor: 3750, total: -3750 }) + }) + + it('handles expense where payer is not in paidFor', () => { + const expenses: BalancesExpense[] = [ + makeExpense({ + id: 'e1', + amount: 150, + paidBy: { id: 'p0', name: 'P0' }, + paidFor: [ + { participant: { id: 'p1', name: 'P1' }, shares: 1 }, + { participant: { id: 'p2', name: 'P2' }, shares: 1 }, + ], + }), + ] + + const balances = getBalances(expenses) + + // p0 paid 150 but is not in paidFor, so paidFor = 0 + expect(balances.p0).toEqual({ paid: 150, paidFor: 0, total: 150 }) + // p1 and p2 split the expense evenly (75 each) + expect(balances.p1).toEqual({ paid: 0, paidFor: 75, total: -75 }) + expect(balances.p2).toEqual({ paid: 0, paidFor: 75, total: -75 }) + }) + + it('handles float/decimal amounts correctly with rounding', () => { + // Simulate amounts that would result in float division + const expenses: BalancesExpense[] = [ + makeExpense({ + id: 'e1', + amount: 33, // 33 / 3 = 11 exactly + splitMode: 'EVENLY', + paidBy: { id: 'p0', name: 'P0' }, + paidFor: [ + { participant: { id: 'p0', name: 'P0' }, shares: 1 }, + { participant: { id: 'p1', name: 'P1' }, shares: 1 }, + { participant: { id: 'p2', name: 'P2' }, shares: 1 }, + ], + }), + makeExpense({ + id: 'e2', + amount: 10, // 10 / 3 = 3.333... + splitMode: 'EVENLY', + paidBy: { id: 'p0', name: 'P0' }, + paidFor: [ + { participant: { id: 'p0', name: 'P0' }, shares: 1 }, + { participant: { id: 'p1', name: 'P1' }, shares: 1 }, + { participant: { id: 'p2', name: 'P2' }, shares: 1 }, + ], + }), + ] + + const balances = getBalances(expenses) + + // Verify all values are integers (rounded) + expect(Number.isInteger(balances.p0.paid)).toBe(true) + expect(Number.isInteger(balances.p0.paidFor)).toBe(true) + expect(Number.isInteger(balances.p0.total)).toBe(true) + expect(Number.isInteger(balances.p1.paid)).toBe(true) + expect(Number.isInteger(balances.p1.paidFor)).toBe(true) + expect(Number.isInteger(balances.p1.total)).toBe(true) + expect(Number.isInteger(balances.p2.paid)).toBe(true) + expect(Number.isInteger(balances.p2.paidFor)).toBe(true) + expect(Number.isInteger(balances.p2.total)).toBe(true) + + // Verify no negative zeros + expect(Object.is(balances.p0.paid, -0)).toBe(false) + expect(Object.is(balances.p0.paidFor, -0)).toBe(false) + expect(Object.is(balances.p0.total, -0)).toBe(false) + }) + + it('handles repeated participant IDs in paidFor array', () => { + const expenses: BalancesExpense[] = [ + makeExpense({ + id: 'e1', + amount: 100, + splitMode: 'EVENLY', + paidBy: { id: 'p0', name: 'P0' }, + paidFor: [ + { participant: { id: 'p0', name: 'P0' }, shares: 1 }, + { participant: { id: 'p1', name: 'P1' }, shares: 1 }, + { participant: { id: 'p0', name: 'P0' }, shares: 1 }, // Duplicate + ], + }), + ] + + const balances = getBalances(expenses) + + // p0 appears twice in paidFor, so should owe double + // Total shares = 3, p0 has 2 shares, p1 has 1 share + expect(balances.p0.paid).toBe(100) + expect(balances.p0.paidFor).toBeCloseTo(67, -1) // ~66.67 + expect(balances.p1.paid).toBe(0) + expect(balances.p1.paidFor).toBeCloseTo(33, -1) // ~33.33 + }) + + it('handles all participants with negative balances', () => { + // Simulate a scenario where everyone owes money + const balances = { + p0: { paid: 0, paidFor: 100, total: -100 }, + p1: { paid: 0, paidFor: 50, total: -50 }, + p2: { paid: 0, paidFor: 50, total: -50 }, + } + + const reimbursements = getSuggestedReimbursements(balances) + + // When all are negative, algorithm still produces "settlements" + // Verify the function handles this case without throwing + expect(Array.isArray(reimbursements)).toBe(true) + expect(reimbursements.length).toBeGreaterThanOrEqual(0) + }) + + it('handles heavy chained reimbursements with multiple hops', () => { + // Scenario: A owes B, B owes C, C owes D, etc. + // Creating a chain that requires multiple hops to settle + const balances = { + alice: { paid: 0, paidFor: 100, total: -100 }, // owes 100 + bob: { paid: 150, paidFor: 50, total: 100 }, // overpaid by 100, owes 50 + carol: { paid: 0, paidFor: 50, total: -50 }, // owes 50 + dan: { paid: 200, paidFor: 200, total: 0 }, // settled + eve: { paid: 100, paidFor: 200, total: -100 }, // owes 100 + } + + const reimbursements = getSuggestedReimbursements(balances) + + // Bob has +100, needs to receive from debtors + // Eve owes 100, Carol owes 50, Alice owes 100 + // Total owed = 250, Bob is owed 100 + // Should settle with some debtors + + // Verify reimbursements go to bob (the positive balance) + expect(reimbursements.some((r) => r.to === 'bob')).toBe(true) + + // Verify the sum of amounts going to bob equals his positive balance + const toBob = reimbursements + .filter((r) => r.to === 'bob') + .reduce((sum, r) => sum + r.amount, 0) + expect(toBob).toBe(100) + }) +}) + +describe('getSuggestedReimbursements', () => { + it('sorts balances correctly (positive before negative)', () => { + const balances = { + p0: { paid: 100, paidFor: 50, total: 50 }, // positive + p1: { paid: 0, paidFor: 30, total: -30 }, // negative + p2: { paid: 50, paidFor: 70, total: -20 }, // negative + } + + const reimbursements = getSuggestedReimbursements(balances) + + // Verify positive balances are settled first + expect(reimbursements.length).toBeGreaterThan(0) + expect(reimbursements[0].to).toBe('p0') // p0 has positive balance + }) + + it('handles complex 5+ person scenario', () => { + // Scenario: 5 people, various expenses + // Alice paid 300, owes 100 → +200 + // Bob paid 50, owes 100 → -50 + // Carol paid 150, owes 100 → +50 + // Dave paid 0, owes 100 → -100 + // Eve paid 0, owes 100 → -100 + const balances = { + alice: { paid: 300, paidFor: 100, total: 200 }, + bob: { paid: 50, paidFor: 100, total: -50 }, + carol: { paid: 150, paidFor: 100, total: 50 }, + dave: { paid: 0, paidFor: 100, total: -100 }, + eve: { paid: 0, paidFor: 100, total: -100 }, + } + + const reimbursements = getSuggestedReimbursements(balances) + + // Verify sum of reimbursements balances out + const totalPaid = reimbursements.reduce((sum, r) => sum + r.amount, 0) + const totalOwed = 200 + 50 // alice + carol + expect(totalPaid).toBe(totalOwed) + + // Verify all debtors are covered + const debtorsSettled = new Set(reimbursements.map((r) => r.from)) + expect(debtorsSettled.has('bob')).toBe(true) + expect(debtorsSettled.has('dave')).toBe(true) + expect(debtorsSettled.has('eve')).toBe(true) + + // Verify minimal transactions (should be <= 4 for 5 people) + expect(reimbursements.length).toBeLessThanOrEqual(4) + }) + + it('returns [] when all totals are 0', () => { + const balances = { + p0: { paid: 100, paidFor: 100, total: 0 }, + p1: { paid: 50, paidFor: 50, total: 0 }, + p2: { paid: 0, paidFor: 0, total: 0 }, + } + + const reimbursements = getSuggestedReimbursements(balances) + + expect(reimbursements).toEqual([]) + }) +}) diff --git a/src/lib/currency.test.ts b/src/lib/currency.test.ts new file mode 100644 index 000000000..42f351cc4 --- /dev/null +++ b/src/lib/currency.test.ts @@ -0,0 +1,143 @@ +import { Currency, defaultCurrencyList, getCurrency } from './currency' +import { + amountAsDecimal, + amountAsMinorUnits, + formatAmountAsDecimal, + getCurrencyFromGroup, +} from './utils' + +describe('getCurrency', () => { + it('returns currency by code', () => { + const usd = getCurrency('USD') + + expect(usd.code).toBe('USD') + expect(typeof usd.decimal_digits).toBe('number') + expect(Number.isFinite(usd.decimal_digits)).toBe(true) + + expect(typeof usd.name).toBe('string') + expect(usd.name.length).toBeGreaterThan(0) + }) + + it('returns custom currency for empty code', () => { + const empty = getCurrency('') + expect(empty.code).toBe('') + expect(empty.name).toBe('Custom') + expect(empty.decimal_digits).toBe(2) + + const nullCode = getCurrency(null) + expect(nullCode.code).toBe('') + expect(nullCode.name).toBe('Custom') + + const undefinedCode = getCurrency(undefined) + expect(undefinedCode.code).toBe('') + expect(undefinedCode.name).toBe('Custom') + }) + + it('handles locale variations by falling back to en-US', () => { + const usd = getCurrency('USD', 'en-GB' as any) + expect(usd.code).toBe('USD') + expect(typeof usd.name).toBe('string') + expect(usd.name.length).toBeGreaterThan(0) + + const unknown = getCurrency('USD', 'xx-XX' as any) + expect(unknown.code).toBe('USD') + expect(typeof unknown.name).toBe('string') + expect(unknown.name.length).toBeGreaterThan(0) + }) +}) + +describe('getCurrencyFromGroup', () => { + it('extracts custom currency symbol when no currencyCode', () => { + const currency = getCurrencyFromGroup({ + currency: 'ƃ', + currencyCode: null, + }) + expect(currency.code).toBe('') + expect(currency.symbol).toBe('ƃ') + expect(currency.symbol_native).toBe('ƃ') + expect(currency.decimal_digits).toBe(2) + }) + + it('extracts currency by code when currencyCode exists', () => { + const currency = getCurrencyFromGroup({ + currency: '$', + currencyCode: 'USD', + }) + expect(currency.code).toBe('USD') + expect(typeof currency.name).toBe('string') + expect(currency.name.length).toBeGreaterThan(0) + }) +}) + +describe('defaultCurrencyList', () => { + it('includes custom currency choice when provided', () => { + const list = defaultCurrencyList('en-US', 'My Currency') + expect(list[0]?.code).toBe('') + expect(list[0]?.name).toBe('My Currency') + expect(list[0]?.name_plural).toBe('My Currency') + + const hasUsd = list.some((c: Currency) => c.code === 'USD') + expect(hasUsd).toBe(true) + }) +}) + +describe('amountAsDecimal', () => { + it('converts minor units to decimal major units', () => { + const usd = getCurrency('USD') + + expect(amountAsDecimal(0, usd)).toBe(0) + expect(amountAsDecimal(1, usd)).toBe(0.01) + expect(amountAsDecimal(1050, usd)).toBe(10.5) + expect(amountAsDecimal(1234, usd)).toBe(12.34) + }) + + it('handles negative and large inputs', () => { + const usd = getCurrency('USD') + expect(amountAsDecimal(-1, usd)).toBe(-0.01) + expect(amountAsDecimal(999_999_999, usd)).toBe(9_999_999.99) + }) + + it('respects currencies with 0 decimal digits', () => { + const jpy = getCurrency('JPY') + expect(amountAsDecimal(1000, jpy)).toBe(1000) + }) +}) + +describe('amountAsMinorUnits', () => { + it('converts decimal major units to minor units', () => { + const usd = getCurrency('USD') + expect(amountAsMinorUnits(10, usd)).toBe(1000) + }) + + it('rounds safely for common floating point cases', () => { + const usd = getCurrency('USD') + expect(amountAsMinorUnits(10.01, usd)).toBe(1001) + }) + + it('respects currencies with 0 decimal digits', () => { + const jpy = getCurrency('JPY') + expect(amountAsMinorUnits(1000, jpy)).toBe(1000) + }) +}) + +describe('formatAmountAsDecimal', () => { + it('formats with correct decimals for 2-digit currency', () => { + const usd = getCurrency('USD') + expect(formatAmountAsDecimal(0, usd)).toBe('0.00') + expect(formatAmountAsDecimal(1, usd)).toBe('0.01') + expect(formatAmountAsDecimal(1050, usd)).toBe('10.50') + expect(formatAmountAsDecimal(1234, usd)).toBe('12.34') + }) + + it('formats with correct decimals for 0-digit currency', () => { + const jpy = getCurrency('JPY') + expect(formatAmountAsDecimal(1000, jpy)).toBe('1000') + expect(formatAmountAsDecimal(1, jpy)).toBe('1') + }) + + it('handles negative amounts', () => { + const usd = getCurrency('USD') + expect(formatAmountAsDecimal(-1, usd)).toBe('-0.01') + expect(formatAmountAsDecimal(-1050, usd)).toBe('-10.50') + }) +}) diff --git a/src/lib/recurring-expenses.test.ts b/src/lib/recurring-expenses.test.ts new file mode 100644 index 000000000..027230bad --- /dev/null +++ b/src/lib/recurring-expenses.test.ts @@ -0,0 +1,181 @@ +import { RecurrenceRule } from '@prisma/client' +import { calculateNextDate } from './recurring-expenses' + +describe('calculateNextDate', () => { + describe('DAILY recurrence', () => { + it('increments date by one day', () => { + const input = new Date(Date.UTC(2025, 0, 15, 0, 0, 0)) + const result = calculateNextDate(RecurrenceRule.DAILY, input) + + expect(result.getUTCFullYear()).toBe(2025) + expect(result.getUTCMonth()).toBe(0) + expect(result.getUTCDate()).toBe(16) + }) + + it('handles month boundary', () => { + const input = new Date(Date.UTC(2025, 0, 31, 0, 0, 0)) + const result = calculateNextDate(RecurrenceRule.DAILY, input) + + expect(result.getUTCFullYear()).toBe(2025) + expect(result.getUTCMonth()).toBe(1) + expect(result.getUTCDate()).toBe(1) + }) + + it('handles year boundary', () => { + const input = new Date(Date.UTC(2025, 11, 31, 0, 0, 0)) + const result = calculateNextDate(RecurrenceRule.DAILY, input) + + expect(result.getUTCFullYear()).toBe(2026) + expect(result.getUTCMonth()).toBe(0) + expect(result.getUTCDate()).toBe(1) + }) + }) + + describe('WEEKLY recurrence', () => { + it('increments date by 7 days', () => { + const input = new Date(Date.UTC(2025, 2, 15, 0, 0, 0)) + const result = calculateNextDate(RecurrenceRule.WEEKLY, input) + + expect(result.getUTCFullYear()).toBe(2025) + expect(result.getUTCMonth()).toBe(2) + expect(result.getUTCDate()).toBe(22) + }) + + it('handles month boundary crossing multiple weeks', () => { + const input = new Date(Date.UTC(2025, 0, 28, 0, 0, 0)) + const result = calculateNextDate(RecurrenceRule.WEEKLY, input) + + expect(result.getUTCFullYear()).toBe(2025) + expect(result.getUTCMonth()).toBe(1) + expect(result.getUTCDate()).toBe(4) + }) + }) + + describe('MONTHLY recurrence - month boundary handling', () => { + it('handles Jan 31 to Feb 28 (non-leap year)', () => { + const input = new Date(Date.UTC(2025, 0, 31, 0, 0, 0)) + const result = calculateNextDate(RecurrenceRule.MONTHLY, input) + + expect(result.getUTCFullYear()).toBe(2025) + expect(result.getUTCMonth()).toBe(1) + expect(result.getUTCDate()).toBe(28) + }) + + it('handles Jan 31 to Feb 29 (leap year)', () => { + const input = new Date(Date.UTC(2024, 0, 31, 0, 0, 0)) + const result = calculateNextDate(RecurrenceRule.MONTHLY, input) + + expect(result.getUTCFullYear()).toBe(2024) + expect(result.getUTCMonth()).toBe(1) + expect(result.getUTCDate()).toBe(29) + }) + + it('handles Mar 31 to Apr 30', () => { + const input = new Date(Date.UTC(2025, 2, 31, 0, 0, 0)) + const result = calculateNextDate(RecurrenceRule.MONTHLY, input) + + expect(result.getUTCFullYear()).toBe(2025) + expect(result.getUTCMonth()).toBe(3) + expect(result.getUTCDate()).toBe(30) + }) + + it('handles May 31 to Jun 30', () => { + const input = new Date(Date.UTC(2025, 4, 31, 0, 0, 0)) + const result = calculateNextDate(RecurrenceRule.MONTHLY, input) + + expect(result.getUTCFullYear()).toBe(2025) + expect(result.getUTCMonth()).toBe(5) + expect(result.getUTCDate()).toBe(30) + }) + + it('handles Jul 31 to Aug 31 (August has 31 days)', () => { + const input = new Date(Date.UTC(2025, 6, 31, 0, 0, 0)) + const result = calculateNextDate(RecurrenceRule.MONTHLY, input) + + expect(result.getUTCFullYear()).toBe(2025) + expect(result.getUTCMonth()).toBe(7) + expect(result.getUTCDate()).toBe(31) + }) + + it('handles Aug 31 to Sep 30', () => { + const input = new Date(Date.UTC(2025, 7, 31, 0, 0, 0)) + const result = calculateNextDate(RecurrenceRule.MONTHLY, input) + + expect(result.getUTCFullYear()).toBe(2025) + expect(result.getUTCMonth()).toBe(8) + expect(result.getUTCDate()).toBe(30) + }) + + it('handles Oct 31 to Nov 30', () => { + const input = new Date(Date.UTC(2025, 9, 31, 0, 0, 0)) + const result = calculateNextDate(RecurrenceRule.MONTHLY, input) + + expect(result.getUTCFullYear()).toBe(2025) + expect(result.getUTCMonth()).toBe(10) + expect(result.getUTCDate()).toBe(30) + }) + + it('handles Dec 31 to Jan 31 (next year)', () => { + const input = new Date(Date.UTC(2025, 11, 31, 0, 0, 0)) + const result = calculateNextDate(RecurrenceRule.MONTHLY, input) + + expect(result.getUTCFullYear()).toBe(2026) + expect(result.getUTCMonth()).toBe(0) + expect(result.getUTCDate()).toBe(31) + }) + + it('handles Feb 28 to Mar 28 (non-leap year, not 31)', () => { + const input = new Date(Date.UTC(2025, 1, 28, 0, 0, 0)) + const result = calculateNextDate(RecurrenceRule.MONTHLY, input) + + expect(result.getUTCFullYear()).toBe(2025) + expect(result.getUTCMonth()).toBe(2) + expect(result.getUTCDate()).toBe(28) + }) + + it('handles Feb 29 to Mar 29 (leap year)', () => { + const input = new Date(Date.UTC(2024, 1, 29, 0, 0, 0)) + const result = calculateNextDate(RecurrenceRule.MONTHLY, input) + + expect(result.getUTCFullYear()).toBe(2024) + expect(result.getUTCMonth()).toBe(2) + expect(result.getUTCDate()).toBe(29) + }) + + it('handles Apr 30 to May 30 (keeps same day number)', () => { + const input = new Date(Date.UTC(2025, 3, 30, 0, 0, 0)) + const result = calculateNextDate(RecurrenceRule.MONTHLY, input) + + expect(result.getUTCFullYear()).toBe(2025) + expect(result.getUTCMonth()).toBe(4) + expect(result.getUTCDate()).toBe(30) + }) + + it('handles Jun 30 to Jul 30 (keeps same day number)', () => { + const input = new Date(Date.UTC(2025, 5, 30, 0, 0, 0)) + const result = calculateNextDate(RecurrenceRule.MONTHLY, input) + + expect(result.getUTCFullYear()).toBe(2025) + expect(result.getUTCMonth()).toBe(6) + expect(result.getUTCDate()).toBe(30) + }) + + it('handles Sep 30 to Oct 30 (keeps same day number)', () => { + const input = new Date(Date.UTC(2025, 8, 30, 0, 0, 0)) + const result = calculateNextDate(RecurrenceRule.MONTHLY, input) + + expect(result.getUTCFullYear()).toBe(2025) + expect(result.getUTCMonth()).toBe(9) + expect(result.getUTCDate()).toBe(30) + }) + + it('handles Nov 30 to Dec 30 (Dec has 31 days, keeps 30)', () => { + const input = new Date(Date.UTC(2025, 10, 30, 0, 0, 0)) + const result = calculateNextDate(RecurrenceRule.MONTHLY, input) + + expect(result.getUTCFullYear()).toBe(2025) + expect(result.getUTCMonth()).toBe(11) + expect(result.getUTCDate()).toBe(30) + }) + }) +}) diff --git a/src/lib/recurring-expenses.ts b/src/lib/recurring-expenses.ts new file mode 100644 index 000000000..4ab205758 --- /dev/null +++ b/src/lib/recurring-expenses.ts @@ -0,0 +1,49 @@ +import { RecurrenceRule } from '@prisma/client' + +// TODO: Modify this function to use a more comprehensive recurrence Rule library like rrule (https://github.com/jkbrzt/rrule) +// +// Current limitations: +// - If a date is intended to be repeated monthly on the 29th, 30th or 31st, it will change to repeating on the smallest +// date that the reccurence has encountered. Ex. If a recurrence is created for Jan 31st on 2025, the recurring expense +// will be created for Feb 28th, March 28, etc. until it is cancelled or fixed +export function calculateNextDate( + recurrenceRule: RecurrenceRule, + priorDateToNextRecurrence: Date, +): Date { + const nextDate = new Date(priorDateToNextRecurrence) + switch (recurrenceRule) { + case RecurrenceRule.DAILY: + nextDate.setUTCDate(nextDate.getUTCDate() + 1) + break + case RecurrenceRule.WEEKLY: + nextDate.setUTCDate(nextDate.getUTCDate() + 7) + break + case RecurrenceRule.MONTHLY: { + const nextYear = nextDate.getUTCFullYear() + const nextMonth = nextDate.getUTCMonth() + 1 + let nextDay = nextDate.getUTCDate() + + while (!isDateInNextMonth(nextYear, nextMonth, nextDay)) { + nextDay -= 1 + } + nextDate.setUTCMonth(nextMonth, nextDay) + break + } + } + + return nextDate +} + +function isDateInNextMonth( + utcYear: number, + utcMonth: number, + utcDate: number, +): boolean { + const testDate = new Date(Date.UTC(utcYear, utcMonth, utcDate)) + + if (testDate.getUTCDate() !== utcDate) { + return false + } + + return true +} diff --git a/src/lib/reimbursements.test.ts b/src/lib/reimbursements.test.ts new file mode 100644 index 000000000..70477dc89 --- /dev/null +++ b/src/lib/reimbursements.test.ts @@ -0,0 +1,111 @@ +import { + getPublicBalances, + getSuggestedReimbursements, + type Balances, +} from './balances' + +describe('getSuggestedReimbursements', () => { + it('creates a single reimbursement for one debtor/creditor', () => { + const balances: Balances = { + a: { paid: 0, paidFor: 0, total: 50 }, + b: { paid: 0, paidFor: 0, total: -50 }, + } + + expect(getSuggestedReimbursements(balances)).toEqual([ + { from: 'b', to: 'a', amount: 50 }, + ]) + }) + + it('settles multiple creditors from one debtor', () => { + const balances: Balances = { + a: { paid: 0, paidFor: 0, total: 30 }, + b: { paid: 0, paidFor: 0, total: 20 }, + c: { paid: 0, paidFor: 0, total: -50 }, + } + + expect(getSuggestedReimbursements(balances)).toEqual([ + { from: 'c', to: 'a', amount: 30 }, + { from: 'c', to: 'b', amount: 20 }, + ]) + }) + + it('settles one creditor from multiple debtors in stable order', () => { + const balances: Balances = { + a: { paid: 0, paidFor: 0, total: 100 }, + b: { paid: 0, paidFor: 0, total: -60 }, + c: { paid: 0, paidFor: 0, total: -40 }, + } + + expect(getSuggestedReimbursements(balances)).toEqual([ + { from: 'c', to: 'a', amount: 40 }, + { from: 'b', to: 'a', amount: 60 }, + ]) + }) + + it('filters reimbursements that round to zero', () => { + const balances: Balances = { + a: { paid: 0, paidFor: 0, total: 0.4 }, + b: { paid: 0, paidFor: 0, total: -0.4 }, + } + + expect(getSuggestedReimbursements(balances)).toEqual([]) + }) + + it('public balances match reimbursement net totals', () => { + const balances: Balances = { + a: { paid: 0, paidFor: 0, total: 30 }, + b: { paid: 0, paidFor: 0, total: 20 }, + c: { paid: 0, paidFor: 0, total: -50 }, + z: { paid: 0, paidFor: 0, total: 0 }, + } + + const reimbursements = getSuggestedReimbursements(balances) + const publicBalances = getPublicBalances(reimbursements) + + expect(reimbursements).toEqual([ + { from: 'c', to: 'a', amount: 30 }, + { from: 'c', to: 'b', amount: 20 }, + ]) + + expect(publicBalances).toEqual({ + a: { paid: 30, paidFor: 0, total: 30 }, + b: { paid: 20, paidFor: 0, total: 20 }, + c: { paid: 0, paidFor: 50, total: -50 }, + }) + }) + + it('handles balanced group (all balances zero)', () => { + const balances: Balances = { + a: { paid: 0, paidFor: 0, total: 0 }, + b: { paid: 0, paidFor: 0, total: 0 }, + c: { paid: 0, paidFor: 0, total: 0 }, + } + + expect(getSuggestedReimbursements(balances)).toEqual([]) + }) + + it('sorting ensures stable reimbursement suggestions', () => { + // Test that positive balances come before negative balances, + // and within same sign, participants are sorted by ID + const balances: Balances = { + z: { paid: 0, paidFor: 0, total: 50 }, // creditor + a: { paid: 0, paidFor: 0, total: 30 }, // creditor + m: { paid: 0, paidFor: 0, total: -40 }, // debtor + b: { paid: 0, paidFor: 0, total: -40 }, // debtor + } + + // With stable sorting by ID within same sign: + // Sorted: [a: 30, z: 50] [b: -40, m: -40] + // Greedy pairing (first creditor ↔ last debtor): + // 1. a(30) ↔ m(-40): a pays 30, m owes 10 remaining + // 2. z(50) ↔ m(-10): z gets 10, m settled, z has 40 remaining + // 3. z(40) ↔ b(-40): z gets 40, b settled + const reimbursements = getSuggestedReimbursements(balances) + + expect(reimbursements).toEqual([ + { from: 'm', to: 'a', amount: 30 }, + { from: 'm', to: 'z', amount: 10 }, + { from: 'b', to: 'z', amount: 40 }, + ]) + }) +}) diff --git a/src/lib/schemas.test.ts b/src/lib/schemas.test.ts new file mode 100644 index 000000000..fceaf0e15 --- /dev/null +++ b/src/lib/schemas.test.ts @@ -0,0 +1,268 @@ +import { expenseFormSchema, groupFormSchema } from './schemas' + +describe('expenseFormSchema', () => { + it('validates required fields', () => { + const result = expenseFormSchema.safeParse({ + expenseDate: new Date('2025-01-01T00:00:00.000Z'), + title: 'Dinner', + category: 0, + amount: 1000, + originalAmount: undefined, + originalCurrency: '', + conversionRate: undefined, + paidBy: 'p0', + paidFor: [{ participant: 'p0', shares: 1 }], + splitMode: 'EVENLY', + saveDefaultSplittingOptions: false, + isReimbursement: false, + documents: [], + notes: undefined, + recurrenceRule: 'NONE', + }) + + expect(result.success).toBe(true) + }) + + it('allows valid recurring rules', () => { + const result = expenseFormSchema.safeParse({ + expenseDate: new Date('2025-01-01T00:00:00.000Z'), + title: 'Rent', + category: 0, + amount: 1000, + originalAmount: undefined, + originalCurrency: '', + conversionRate: undefined, + paidBy: 'p0', + paidFor: [{ participant: 'p0', shares: 1 }], + splitMode: 'EVENLY', + saveDefaultSplittingOptions: false, + isReimbursement: false, + documents: [], + notes: undefined, + recurrenceRule: 'MONTHLY', + }) + + expect(result.success).toBe(true) + }) + + it('fails when title is missing', () => { + const result = expenseFormSchema.safeParse({ + expenseDate: new Date('2025-01-01T00:00:00.000Z'), + category: 0, + amount: 1000, + originalCurrency: '', + paidBy: 'p0', + paidFor: [{ participant: 'p0', shares: 1 }], + splitMode: 'EVENLY', + saveDefaultSplittingOptions: false, + isReimbursement: false, + documents: [], + recurrenceRule: 'NONE', + }) + + expect(result.success).toBe(false) + }) + + it('rejects invalid split mode', () => { + const result = expenseFormSchema.safeParse({ + expenseDate: new Date('2025-01-01T00:00:00.000Z'), + title: 'Dinner', + category: 0, + amount: 1000, + paidBy: 'p0', + paidFor: [{ participant: 'p0', shares: 1 }], + splitMode: 'INVALID_MODE', + saveDefaultSplittingOptions: false, + isReimbursement: false, + documents: [], + recurrenceRule: 'NONE', + }) + + expect(result.success).toBe(false) + }) + + it('validates currency format', () => { + const valid = groupFormSchema.safeParse({ + name: 'Trip', + information: undefined, + currency: '€', + currencyCode: 'EUR', + participants: [{ name: 'Alice' }], + }) + + expect(valid.success).toBe(true) + + const invalid = groupFormSchema.safeParse({ + name: 'Trip', + information: undefined, + currency: 'TOO_LONG', + currencyCode: 'EUR', + participants: [{ name: 'Alice' }], + }) + + expect(invalid.success).toBe(false) + }) + + it('validates percentage sums to 100%', () => { + // Invalid: sum < 100% (2500 + 3000 = 5500 = 55%) + const resultLess = expenseFormSchema.safeParse({ + expenseDate: new Date('2025-01-01T00:00:00.000Z'), + title: 'Dinner', + category: 0, + amount: 1000, + paidBy: 'p0', + paidFor: [ + { participant: 'p0', shares: 2500 }, + { participant: 'p1', shares: 3000 }, + ], + splitMode: 'BY_PERCENTAGE', + saveDefaultSplittingOptions: false, + isReimbursement: false, + documents: [], + recurrenceRule: 'NONE', + }) + + expect(resultLess.success).toBe(false) + + // Invalid: sum > 100% (6000 + 5000 = 11000 = 110%) + const resultMore = expenseFormSchema.safeParse({ + expenseDate: new Date('2025-01-01T00:00:00.000Z'), + title: 'Dinner', + category: 0, + amount: 1000, + paidBy: 'p0', + paidFor: [ + { participant: 'p0', shares: 6000 }, + { participant: 'p1', shares: 5000 }, + ], + splitMode: 'BY_PERCENTAGE', + saveDefaultSplittingOptions: false, + isReimbursement: false, + documents: [], + recurrenceRule: 'NONE', + }) + + expect(resultMore.success).toBe(false) + + // Valid: sum = 100% (7000 + 3000 = 10000 = 100%) + const resultValid = expenseFormSchema.safeParse({ + expenseDate: new Date('2025-01-01T00:00:00.000Z'), + title: 'Dinner', + category: 0, + amount: 1000, + paidBy: 'p0', + paidFor: [ + { participant: 'p0', shares: 7000 }, + { participant: 'p1', shares: 3000 }, + ], + splitMode: 'BY_PERCENTAGE', + saveDefaultSplittingOptions: false, + isReimbursement: false, + documents: [], + recurrenceRule: 'NONE', + }) + + expect(resultValid.success).toBe(true) + }) + + it('validates amount sum equals total', () => { + // Invalid: sum < total (300 + 400 = 700 < 1000) + const resultLess = expenseFormSchema.safeParse({ + expenseDate: new Date('2025-01-01T00:00:00.000Z'), + title: 'Dinner', + category: 0, + amount: 1000, + paidBy: 'p0', + paidFor: [ + { participant: 'p0', shares: 300 }, + { participant: 'p1', shares: 400 }, + ], + splitMode: 'BY_AMOUNT', + saveDefaultSplittingOptions: false, + isReimbursement: false, + documents: [], + recurrenceRule: 'NONE', + }) + + expect(resultLess.success).toBe(false) + + // Invalid: sum > total (600 + 700 = 1300 > 1000) + const resultMore = expenseFormSchema.safeParse({ + expenseDate: new Date('2025-01-01T00:00:00.000Z'), + title: 'Dinner', + category: 0, + amount: 1000, + paidBy: 'p0', + paidFor: [ + { participant: 'p0', shares: 600 }, + { participant: 'p1', shares: 700 }, + ], + splitMode: 'BY_AMOUNT', + saveDefaultSplittingOptions: false, + isReimbursement: false, + documents: [], + recurrenceRule: 'NONE', + }) + + expect(resultMore.success).toBe(false) + + // Valid: sum = total (600 + 400 = 1000) + const resultValid = expenseFormSchema.safeParse({ + expenseDate: new Date('2025-01-01T00:00:00.000Z'), + title: 'Dinner', + category: 0, + amount: 1000, + paidBy: 'p0', + paidFor: [ + { participant: 'p0', shares: 600 }, + { participant: 'p1', shares: 400 }, + ], + splitMode: 'BY_AMOUNT', + saveDefaultSplittingOptions: false, + isReimbursement: false, + documents: [], + recurrenceRule: 'NONE', + }) + + expect(resultValid.success).toBe(true) + }) +}) + +describe('groupFormSchema', () => { + it('validates group creation', () => { + const result = groupFormSchema.safeParse({ + name: 'Weekend Trip', + information: 'Beach vacation', + currency: '$', + currencyCode: 'USD', + participants: [{ name: 'Alice' }, { name: 'Bob' }], + }) + + expect(result.success).toBe(true) + }) + + it('requires at least 1 participant (business logic requires 2)', () => { + // Single participant passes schema validation + const resultOne = groupFormSchema.safeParse({ + name: 'Solo Trip', + currency: '$', + currencyCode: 'USD', + participants: [{ name: 'Alice' }], + }) + + expect(resultOne.success).toBe(true) // Current behavior + + // Zero participants fails + const resultZero = groupFormSchema.safeParse({ + name: 'Trip', + currency: '$', + currencyCode: 'USD', + participants: [], + }) + + expect(resultZero.success).toBe(false) + + // Note: Business logic should enforce 2+ participants + // This test documents current schema behavior + }) +}) diff --git a/src/lib/totals.test.ts b/src/lib/totals.test.ts new file mode 100644 index 000000000..1b5f7a7ec --- /dev/null +++ b/src/lib/totals.test.ts @@ -0,0 +1,267 @@ +import { + calculateShare, + getTotalActiveUserPaidFor, + getTotalActiveUserShare, + getTotalGroupSpending, +} from './totals' + +type TotalsExpense = Parameters[1][number] + +type ShareExpense = Parameters[1] + +type PaidFor = ShareExpense['paidFor'][number] + +const makeExpense = (overrides: Partial): TotalsExpense => + ({ + id: 'e1', + expenseDate: new Date('2025-01-01T00:00:00.000Z'), + title: 'Dinner', + amount: 0, + isReimbursement: false, + splitMode: 'EVENLY', + createdAt: new Date('2025-01-01T00:00:00.000Z'), + recurrenceRule: null, + category: null, + paidBy: { id: 'u1', name: 'User 1' }, + paidFor: [ + { + participant: { id: 'u1', name: 'User 1' }, + shares: 1, + }, + ], + _count: { documents: 0 }, + ...overrides, + }) as TotalsExpense + +const makePaidFor = (participantId: string, shares: number): PaidFor => + ({ + participant: { id: participantId, name: participantId }, + shares, + }) as PaidFor + +describe('getTotalGroupSpending', () => { + it('sums all non-reimbursement expenses', () => { + const expenses = [ + makeExpense({ id: 'e1', amount: 100, isReimbursement: false }), + makeExpense({ id: 'e2', amount: 250, isReimbursement: false }), + makeExpense({ id: 'e3', amount: 50, isReimbursement: false }), + ] + + expect(getTotalGroupSpending(expenses)).toBe(400) + }) + + it('excludes reimbursements from total spending', () => { + const expenses = [ + makeExpense({ id: 'e1', amount: 100, isReimbursement: false }), + makeExpense({ id: 'e2', amount: 999, isReimbursement: true }), + makeExpense({ id: 'e3', amount: 250, isReimbursement: false }), + ] + + expect(getTotalGroupSpending(expenses)).toBe(350) + }) + + it('handles empty array', () => { + const expenses: TotalsExpense[] = [] + + expect(getTotalGroupSpending(expenses)).toBe(0) + }) +}) + +describe('getTotalActiveUserPaidFor', () => { + it('sums amounts paid by active user', () => { + const expenses: TotalsExpense[] = [ + makeExpense({ + id: 'e1', + amount: 1250, + paidBy: { id: 'u1', name: 'User 1' }, + }), + makeExpense({ + id: 'e2', + amount: 600, + paidBy: { id: 'u2', name: 'User 2' }, + }), + makeExpense({ + id: 'e3', + amount: 775, + paidBy: { id: 'u1', name: 'User 1' }, + }), + ] + + expect(getTotalActiveUserPaidFor('u1', expenses)).toBe(2025) + }) + + it('excludes reimbursements even if paid by active user', () => { + const expenses: TotalsExpense[] = [ + makeExpense({ + id: 'e1', + amount: 1000, + isReimbursement: false, + paidBy: { id: 'u1', name: 'User 1' }, + }), + makeExpense({ + id: 'e2', + amount: 500, + isReimbursement: true, + paidBy: { id: 'u1', name: 'User 1' }, + }), + ] + + expect(getTotalActiveUserPaidFor('u1', expenses)).toBe(1000) + }) + + it('returns 0 when active user is null', () => { + const expenses: TotalsExpense[] = [makeExpense({ id: 'e1', amount: 1000 })] + + expect(getTotalActiveUserPaidFor(null, expenses)).toBe(0) + }) +}) + +describe('getTotalActiveUserShare', () => { + it('sums active user shares across expenses', () => { + const expenses: TotalsExpense[] = [ + makeExpense({ + id: 'e1', + amount: 100, + isReimbursement: false, + splitMode: 'EVENLY', + paidFor: [ + makePaidFor('u1', 1), + makePaidFor('u2', 1), + makePaidFor('u3', 1), + ], + }), + makeExpense({ + id: 'e2', + amount: 90, + isReimbursement: false, + splitMode: 'BY_AMOUNT', + paidFor: [makePaidFor('u1', 30), makePaidFor('u2', 60)], + }), + makeExpense({ + id: 'e3', + amount: 50, + isReimbursement: false, + splitMode: 'EVENLY', + paidFor: [makePaidFor('u1', 1), makePaidFor('u2', 1)], + }), + ] + + expect(getTotalActiveUserShare('u1', expenses)).toBeCloseTo( + 100 / 3 + 30 + 25, + 2, + ) + }) + + it('rounds total share to 2 decimals', () => { + const expenses: TotalsExpense[] = [ + makeExpense({ + id: 'e1', + amount: 100, + splitMode: 'EVENLY', + paidFor: [ + makePaidFor('u1', 1), + makePaidFor('u2', 1), + makePaidFor('u3', 1), + ], + }), + makeExpense({ + id: 'e2', + amount: 1, + splitMode: 'EVENLY', + paidFor: [ + makePaidFor('u1', 1), + makePaidFor('u2', 1), + makePaidFor('u3', 1), + ], + }), + ] + + const total = getTotalActiveUserShare('u1', expenses) + + expect(total).toBe(33.67) + expect(total.toFixed(2)).toBe('33.67') + }) +}) + +describe('calculateShare', () => { + it('returns 0 for reimbursements', () => { + const expense: ShareExpense = { + amount: 100, + isReimbursement: true, + splitMode: 'EVENLY', + paidFor: [makePaidFor('u1', 1), makePaidFor('u2', 1)], + } + + expect(calculateShare('u1', expense)).toBe(0) + expect(calculateShare('u2', expense)).toBe(0) + }) + + it('returns 0 if participant not in paidFor', () => { + const expense: ShareExpense = { + amount: 100, + isReimbursement: false, + splitMode: 'EVENLY', + paidFor: [makePaidFor('u1', 1), makePaidFor('u2', 1)], + } + + expect(calculateShare('u3', expense)).toBe(0) + }) + + it('EVENLY divides expense amount by participants', () => { + const expense: ShareExpense = { + amount: 100, + isReimbursement: false, + splitMode: 'EVENLY', + paidFor: [ + makePaidFor('u1', 1), + makePaidFor('u2', 1), + makePaidFor('u3', 1), + ], + } + + expect(calculateShare('u1', expense)).toBeCloseTo(100 / 3) + expect(calculateShare('u2', expense)).toBeCloseTo(100 / 3) + expect(calculateShare('u3', expense)).toBeCloseTo(100 / 3) + }) + + it('BY_AMOUNT returns exact share amount', () => { + const expense: ShareExpense = { + amount: 999, + isReimbursement: false, + splitMode: 'BY_AMOUNT', + paidFor: [makePaidFor('u1', 123), makePaidFor('u2', 456)], + } + + expect(calculateShare('u1', expense)).toBe(123) + expect(calculateShare('u2', expense)).toBe(456) + }) + + it('BY_PERCENTAGE calculates share using shares/10000', () => { + const expense: ShareExpense = { + amount: 1000, + isReimbursement: false, + splitMode: 'BY_PERCENTAGE', + paidFor: [makePaidFor('u1', 2500), makePaidFor('u2', 7500)], + } + + expect(calculateShare('u1', expense)).toBe(250) + expect(calculateShare('u2', expense)).toBe(750) + }) + + it('BY_SHARES weights shares by ratio', () => { + const expense: ShareExpense = { + amount: 600, + isReimbursement: false, + splitMode: 'BY_SHARES', + paidFor: [ + makePaidFor('u1', 1), + makePaidFor('u2', 2), + makePaidFor('u3', 3), + ], + } + + expect(calculateShare('u1', expense)).toBe(100) + expect(calculateShare('u2', expense)).toBe(200) + expect(calculateShare('u3', expense)).toBe(300) + }) +}) diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index c781deb09..7db87ab70 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -1,7 +1,51 @@ import { Currency } from './currency' -import { formatCurrency } from './utils' +import { + cn, + delay, + formatAmountAsDecimal, + formatCategoryForAIPrompt, + formatCurrency, + formatDate, + formatDateOnly, + formatFileSize, + normalizeString, +} from './utils' describe('formatCurrency', () => { + it('supports custom currency symbol when currency code is empty', () => { + const currency: Currency = { + name: 'Test', + symbol_native: '', + symbol: 'CUR', + code: '', + name_plural: '', + rounding: 0, + decimal_digits: 2, + } + + const formatted = formatCurrency(currency, 123, 'en-US') + expect(formatted).toContain(currency.symbol) + + const fractional = formatted.match(/\d+(?:[.,](\d+))?/)?.[1] ?? '' + expect(fractional.length).toBe(currency.decimal_digits) + }) + + it('supports zero-decimal currencies (JPY)', () => { + const jpy: Currency = { + name: 'Japanese Yen', + symbol_native: '¥', + symbol: '¥', + code: 'JPY', + name_plural: 'Japanese yen', + rounding: 0, + decimal_digits: 0, + } + + const formatted = formatCurrency(jpy, 1000, 'en-US') + expect(formatted).toContain('¥') + expect(formatted).not.toMatch(/[.,]\d{2}\s*$/) + }) + const currency: Currency = { name: 'Test', symbol_native: '', @@ -78,3 +122,142 @@ describe('formatCurrency', () => { }) } }) + +describe('formatDate', () => { + it('formats using requested locale', () => { + const date = new Date(Date.UTC(2025, 0, 2, 3, 4, 5)) + + const en = formatDate(date, 'en-US', { + dateStyle: 'medium', + timeStyle: 'short', + }) + expect(en).toContain('2025') + + const fr = formatDate(date, 'fr-FR', { + dateStyle: 'medium', + timeStyle: 'short', + }) + expect(fr).toContain('2025') + }) +}) + +describe('formatDateOnly', () => { + it('avoids timezone shifts for DATE fields', () => { + const dateFromDb = new Date('2025-10-17T00:00:00.000Z') + + const formatted = formatDateOnly(dateFromDb, 'en-US', { + dateStyle: 'medium', + }) + expect(formatted).toContain('2025') + expect(formatted).toContain('17') + }) + + it('handles month boundaries without off-by-one', () => { + const endOfMonthDb = new Date('2025-03-31T00:00:00.000Z') + const nextDayDb = new Date('2025-04-01T00:00:00.000Z') + + const formattedEnd = formatDateOnly(endOfMonthDb, 'en-US', { + dateStyle: 'medium', + }) + const formattedNext = formatDateOnly(nextDayDb, 'en-US', { + dateStyle: 'medium', + }) + + expect(formattedEnd).toContain('31') + expect(formattedNext).toContain('1') + }) +}) + +describe('normalizeString', () => { + it('removes accents/diacritics', () => { + expect(normalizeString('áäåèéę')).toBe('aaaeee') + expect(normalizeString('Crème brûlée')).toBe('creme brulee') + }) + + it('lowercases', () => { + expect(normalizeString('HELLO World')).toBe('hello world') + }) +}) + +describe('formatFileSize', () => { + it('formats bytes correctly', () => { + expect(formatFileSize(0, 'en-US')).toBe('0 B') + expect(formatFileSize(1, 'en-US')).toBe('1 B') + }) + + it('handles GB/MB/KB/B units', () => { + expect(formatFileSize(1024 + 1, 'en-US')).toContain('kB') + expect(formatFileSize(1024 ** 2 + 1, 'en-US')).toContain('MB') + expect(formatFileSize(1024 ** 3 + 1, 'en-US')).toContain('GB') + }) +}) + +describe('formatCategoryForAIPrompt', () => { + it('formats correctly', () => { + const category = { + id: 5, + grouping: 'Food', + name: 'Groceries', + } + + expect(formatCategoryForAIPrompt(category as any)).toBe( + '"Food/Groceries" (ID: 5)', + ) + }) +}) + +describe('delay', () => { + it('resolves after ms', async () => { + const start = Date.now() + await delay(50) + const elapsed = Date.now() - start + + expect(elapsed).toBeGreaterThanOrEqual(45) // Allow small variance + expect(elapsed).toBeLessThan(100) + }) +}) + +describe('formatAmountAsDecimal', () => { + it('formats with correct decimals', () => { + const usd: Currency = { + name: 'US Dollar', + symbol_native: '$', + symbol: '$', + code: 'USD', + name_plural: 'US dollars', + rounding: 0, + decimal_digits: 2, + } + + expect(formatAmountAsDecimal(1234, usd)).toBe('12.34') + expect(formatAmountAsDecimal(100, usd)).toBe('1.00') + expect(formatAmountAsDecimal(5, usd)).toBe('0.05') + + const jpy: Currency = { + name: 'Japanese Yen', + symbol_native: '¥', + symbol: '¥', + code: 'JPY', + name_plural: 'Japanese yen', + rounding: 0, + decimal_digits: 0, + } + + expect(formatAmountAsDecimal(1000, jpy)).toBe('1000') + }) +}) + +describe('cn', () => { + it('merges class names', () => { + expect(cn('px-2', 'py-1')).toBe('px-2 py-1') + }) + + it('handles conditional classes', () => { + expect(cn('base', false && 'hidden', 'active')).toBe('base active') + }) + + it('deduplicates conflicting Tailwind classes', () => { + // tailwind-merge keeps the last conflicting class + expect(cn('px-2', 'px-4')).toBe('px-4') + }) +}) diff --git a/tests/e2e/active-user-modal.spec.ts b/tests/e2e/active-user-modal.spec.ts new file mode 100644 index 000000000..ef26f376f --- /dev/null +++ b/tests/e2e/active-user-modal.spec.ts @@ -0,0 +1,256 @@ +import { expect, test } from '@playwright/test' +import { navigateToGroup } from '../helpers' +import { createGroupViaAPI } from '../helpers/batch-api' +import { randomId } from '@/lib/api' + +test.describe('Active User Modal', () => { + test('suppressActiveUserModal flag suppresses modal in createGroup', async ({ + page, + }) => { + // Create a group WITH modal suppression (default behavior) + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `modal suppressed test ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + // Suppress modal by setting localStorage + await page.evaluate((gId) => { + localStorage.setItem(`${gId}-activeUser`, 'None') + }, groupId) + + await page.reload() + + // Modal should NOT be visible + const dialog = page.getByRole('dialog', { name: 'Who are you?' }) + await expect(dialog).not.toBeVisible() + + // Verify localStorage was set + const activeUser = await page.evaluate((gId) => { + return localStorage.getItem(`${gId}-activeUser`) + }, groupId) + + expect(activeUser).toBe('None') + }) + + test('Modal appears on first visit when activeUser localStorage is empty', async ({ + page, + }) => { + // Create group with suppression to test modal appearance separately + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `modal test ${randomId(4)}`, + ['Alice', 'Bob', 'Charlie'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + + // Clear the activeUser localStorage to simulate first visit + await page.evaluate((gId) => { + localStorage.removeItem(`${gId}-activeUser`) + // Also clear newGroup-activeUser in case it interferes + localStorage.removeItem('newGroup-activeUser') + }, groupId) + + // Reload the page to trigger modal logic + await page.reload() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Modal should now appear + const dialog = page.getByRole('dialog', { name: 'Who are you?' }) + await expect(dialog).toBeVisible() + + // Verify modal content + await expect( + page.getByText('Tell us which participant you are'), + ).toBeVisible() + }) + + test('Can select a participant in the modal', async ({ page }) => { + // Create and reload to show modal + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `modal participant test ${randomId(4)}`, + ['Alice', 'Bob', 'Charlie'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + + await page.evaluate((gId) => { + localStorage.removeItem(`${gId}-activeUser`) + localStorage.removeItem('newGroup-activeUser') + }, groupId) + + await page.reload() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Modal should be open + const dialog = page.getByRole('dialog', { name: 'Who are you?' }) + await expect(dialog).toBeVisible() + + // Select Alice + await page.getByRole('radio', { name: 'Alice' }).click() + + // Click save + await page.getByRole('button', { name: 'Save changes' }).click() + + // Modal should close + await expect(dialog).not.toBeVisible() + + // Verify localStorage was set (to a participant ID, not the name) + const activeUser = await page.evaluate((gId) => { + return localStorage.getItem(`${gId}-activeUser`) + }, groupId) + + // Should be set to a value (the participant ID) and not be 'None' + expect(activeUser).not.toBeNull() + expect(activeUser).not.toBe('None') + }) + + test('Can save modal with default "I don\'t want to select anyone" selection', async ({ + page, + }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `modal default test ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + + await page.evaluate((gId) => { + localStorage.removeItem(`${gId}-activeUser`) + localStorage.removeItem('newGroup-activeUser') + }, groupId) + + await page.reload() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Modal should be open + const dialog = page.getByRole('dialog', { name: 'Who are you?' }) + await expect(dialog).toBeVisible() + + // Click save without changing selection (default is "I don't want to select anyone") + await page.getByRole('button', { name: 'Save changes' }).click() + + // Modal should close + await expect(dialog).not.toBeVisible() + + // Verify localStorage was set to 'None' (the default selection) + const activeUser = await page.evaluate((gId) => { + return localStorage.getItem(`${gId}-activeUser`) + }, groupId) + + expect(activeUser).toBe('None') + }) + + test('Modal does not reappear after being dismissed', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `modal reappear test ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + + await page.evaluate((gId) => { + localStorage.removeItem(`${gId}-activeUser`) + localStorage.removeItem('newGroup-activeUser') + }, groupId) + + await page.reload() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Modal should be visible + const dialog = page.getByRole('dialog', { name: 'Who are you?' }) + await expect(dialog).toBeVisible() + + // Select a user and save + await page.getByRole('radio', { name: 'Alice' }).click() + await page.getByRole('button', { name: 'Save changes' }).click() + + // Modal should close + await expect(dialog).not.toBeVisible() + + // Navigate away and back to the group + await page.goto('/groups') + await navigateToGroup(page, groupId, false) + + // Modal should NOT reappear because localStorage is set + await expect(dialog).not.toBeVisible() + }) + + test('navigateToGroup with suppressActiveUserModal: false sets localStorage', async ({ + page, + }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `nav test ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + + // Clear localStorage + await page.evaluate((gId) => { + localStorage.removeItem(`${gId}-activeUser`) + }, groupId) + + // Navigate with suppression disabled (default) + await navigateToGroup(page, groupId, false) + + // Verify localStorage was NOT set (because suppressActiveUserModal: false) + // The modal should appear if we reload + await page.evaluate((gId) => { + localStorage.removeItem('newGroup-activeUser') + }, groupId) + await page.reload() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Modal should appear because we didn't suppress it + const dialog = page.getByRole('dialog', { name: 'Who are you?' }) + await expect(dialog).toBeVisible() + }) + + test('navigateToGroup with suppressActiveUserModal: true sets localStorage', async ({ + page, + }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `nav suppress test ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + + // Clear localStorage + await page.evaluate((gId) => { + localStorage.removeItem(`${gId}-activeUser`) + }, groupId) + + // Navigate with suppression enabled + await navigateToGroup(page, groupId, true) + + // Verify localStorage was set + const activeUser = await page.evaluate((gId) => { + return localStorage.getItem(`${gId}-activeUser`) + }, groupId) + + expect(activeUser).toBe('None') + + // Modal should not appear on reload + await page.reload() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + const dialog = page.getByRole('dialog', { name: 'Who are you?' }) + await expect(dialog).not.toBeVisible() + }) +}) diff --git a/tests/e2e/active-user.spec.ts b/tests/e2e/active-user.spec.ts new file mode 100644 index 000000000..83fd20ac8 --- /dev/null +++ b/tests/e2e/active-user.spec.ts @@ -0,0 +1,212 @@ +import { expect, test } from '@playwright/test' +import { navigateToTab } from '../helpers' +import { createExpenseViaAPI, createGroupViaAPI } from '../helpers/batch-api' +import { randomId } from '@/lib/api' + +test('Active user changes balance view', async ({ page }) => { + const groupName = `active user balances ${randomId(4)}` + const participantA = 'Alice' + const participantB = 'Bob' + const participantC = 'Charlie' + + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, [ + participantA, + participantB, + participantC, + ]) + + // Seed a couple expenses so balances are non-trivial. + await createExpenseViaAPI(page, groupId, { + title: 'Dinner', + amount: 3000, + payerName: participantA, + }) + await createExpenseViaAPI(page, groupId, { + title: 'Taxi', + amount: 1500, + payerName: participantB, + }) + + await page.goto(`/groups/${groupId}/expenses`) + await navigateToTab(page, 'Balances') + + // Verify the reimbursements list is displayed with Mark as paid link + const reimbursementsList = page.getByTestId('reimbursements-list') + await expect(reimbursementsList).toBeVisible() + + // Get the Mark as paid link and verify it contains reimbursement query params + const markAsPaidLink = page + .getByRole('link', { name: 'Mark as paid' }) + .first() + await expect(markAsPaidLink).toBeVisible() + + const hrefBefore = await markAsPaidLink.getAttribute('href') + expect(hrefBefore).toContain('reimbursement=yes') + expect(hrefBefore).toMatch(/\bfrom=/) + expect(hrefBefore).toMatch(/\bto=/) + expect(hrefBefore).toMatch(/\bamount=/) + + // Verify Mark as paid link text is visible (confirms it's rendered) + await expect(markAsPaidLink).toContainText('Mark as paid') + + // Clicking a participant row should set the active context + const participantRow = page.getByTestId(`balance-row-${participantB}`) + await participantRow.click() + + // After switching, the reimbursements list should still be visible + await expect(reimbursementsList).toBeVisible() + + // The participant row itself should still be visible + await expect(participantRow).toBeVisible() + await expect(participantRow).toContainText(participantB) +}) + +test('Clear active user - neutral view', async ({ page }) => { + const groupName = `clear active user ${randomId(4)}` + const participantA = 'Alice' + const participantB = 'Bob' + + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, [ + participantA, + participantB, + 'Charlie', + ]) + + await page.goto(`/groups/${groupId}/expenses`) + // Navigate to balances + await navigateToTab(page, 'Balances') + + // Verify balances list is visible with all participants + const balancesList = page.getByTestId('balances-list') + await expect(balancesList).toBeVisible() + await expect(balancesList).toContainText(participantA) + + // Click on a participant to select them as active + const participantARow = page.getByTestId(`balance-row-${participantA}`) + await participantARow.click() + await expect(participantARow).toBeVisible() + + // Now try to clear selection (click again or look for a clear button) + // This tests that the view can return to neutral state + await participantARow.click() + + // Verify the page is still visible (neutral state) + await expect(balancesList).toBeVisible() + await expect(participantARow).toBeVisible() + await expect(participantARow).toContainText(participantA) +}) + +test('Updates stats when active user changes', async ({ page }) => { + const groupName = `active user stats update ${randomId(4)}` + const participantA = 'Alice' + const participantB = 'Bob' + const participantC = 'Charlie' + + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, [ + participantA, + participantB, + participantC, + ]) + + // Add expenses + await createExpenseViaAPI(page, groupId, { + title: 'Dinner', + amount: 3000, + payerName: participantA, + }) + await createExpenseViaAPI(page, groupId, { + title: 'Taxi', + amount: 1500, + payerName: participantB, + }) + + await page.goto(`/groups/${groupId}/expenses`) + // Set Alice as active user via Settings + await navigateToTab(page, 'Settings') + const activeUserSelector = page.getByTestId('active-user-selector') + await activeUserSelector.click() + await page.getByRole('option', { name: participantA }).click() + await page.getByRole('button', { name: /save/i }).click() + + // Navigate to Stats and verify Alice's stats + await navigateToTab(page, 'Stats') + + // Alice paid 30.00 - verify "Your total spendings" displays $30.00 + const yourSpendings = page.getByTestId('your-total-spendings') + await expect(yourSpendings).toBeVisible() + await expect(yourSpendings).toContainText('30.00') + + // Alice's share is 15.00 (total 45.00 / 3 participants) + const yourShare = page.getByTestId('your-total-share') + await expect(yourShare).toBeVisible() + await expect(yourShare).toContainText('15.00') + + // Change active user to Bob via Settings + await navigateToTab(page, 'Settings') + const activeUserSelectorBob = page.getByTestId('active-user-selector') + await activeUserSelectorBob.click() + await page.getByRole('option', { name: participantB }).click() + await page.getByRole('button', { name: /save/i }).click() + + // Navigate back to Stats and verify Bob's stats have changed + await navigateToTab(page, 'Stats') + + // Bob paid 15.00 - should be different from Alice's 30.00 + const bobSpendings = page.getByTestId('your-total-spendings') + await expect(bobSpendings).toBeVisible() + await expect(bobSpendings).toContainText('15.00') + + // Bob's share is still 15.00 (total 45.00 / 3 participants) + const bobShare = page.getByTestId('your-total-share') + await expect(bobShare).toBeVisible() + await expect(bobShare).toContainText('15.00') +}) + +test('Active user selection persists after page reload', async ({ page }) => { + const groupName = `active user persistence ${randomId(4)}` + const participantA = 'Alice' + const participantB = 'Bob' + const participantC = 'Charlie' + + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, [ + participantA, + participantB, + participantC, + ]) + + await page.goto(`/groups/${groupId}/expenses`) + // Select Alice as active user via Settings + await navigateToTab(page, 'Settings') + const activeUserSelector = page.getByTestId('active-user-selector') + await activeUserSelector.click() + await page.getByRole('option', { name: participantA }).click() + await page.getByRole('button', { name: /save/i }).click() + + // Verify selection is applied by navigating to Stats + await navigateToTab(page, 'Stats') + + // Verify Alice's stats are showing + const yourSpendings = page.getByTestId('your-total-spendings') + await expect(yourSpendings).toBeVisible() + await expect(yourSpendings).toContainText('0.00') + + // Reload the page + await page.reload() + + // Navigate to Settings and verify Alice is still selected + await navigateToTab(page, 'Settings') + + // The active user selector should display Alice as selected + const activeUserSelectorAfterReload = page.getByTestId('active-user-selector') + await expect(activeUserSelectorAfterReload).toContainText(participantA) + + // Alternatively, verify via Stats that Alice's stats are still showing + await navigateToTab(page, 'Stats') + const yourSpendingsAfterReload = page.getByTestId('your-total-spendings') + await expect(yourSpendingsAfterReload).toBeVisible() + await expect(yourSpendingsAfterReload).toContainText('0.00') +}) diff --git a/tests/e2e/activity.spec.ts b/tests/e2e/activity.spec.ts new file mode 100644 index 000000000..cca2b89b4 --- /dev/null +++ b/tests/e2e/activity.spec.ts @@ -0,0 +1,220 @@ +import { expect, test } from '@playwright/test' +import { navigateToGroup, navigateToTab } from '../helpers' +import { + createExpenseViaAPI, + createExpensesViaAPI, + createGroupViaAPI, +} from '../helpers/batch-api' +import { randomId } from '@/lib/api' + +test('View activity page', async ({ page }) => { + // Setup: Create group with 3 participants and immediately create an expense + // (if group has no activity, the page shows empty state which doesn't have activity-list testid) + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `activity test ${randomId(4)}`, + ['Alice', 'Bob', 'Charlie'], + ) + + // Create an expense so activity list will be populated + await createExpenseViaAPI(page, groupId, { + title: 'Activity Test Expense', + amount: 1000, + payerName: 'Alice', + }) + + // Navigate to group page + await page.goto(`/groups/${groupId}/expenses`) + + // Navigate to Activity tab + await navigateToTab(page, 'Activity') + + // Verify Activity page loads with correct heading + const activityHeading = page.getByRole('heading', { + name: 'Activity', + exact: true, + }) + await expect(activityHeading).toBeVisible() + + // Since we created an expense, activity-list should be visible (not empty state) + const activityListWrapper = page.getByTestId('activity-list') + await expect(activityListWrapper).toBeVisible() + + // Verify the test expense appears in the list + await expect(page.getByText('Activity Test Expense')).toBeVisible() +}) + +test('Log shows create', async ({ page }) => { + // Setup: Create group with 2 participants and expense + const groupName = `activity create ${randomId(4)}` + const expenseTitle = `Test Expense ${randomId(4)}` + + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, ['Alice', 'Bob']) + + await createExpenseViaAPI(page, groupId, { + title: expenseTitle, + amount: 2500, + payerName: 'Alice', + }) + + // Navigate to group page + await page.goto(`/groups/${groupId}/expenses`) + + // Navigate to Activity tab + await navigateToTab(page, 'Activity') + + // Verify activity list wrapper is visible + const activityListWrapper = page.getByTestId('activity-list') + await expect(activityListWrapper).toBeVisible() + + // Verify expense title appears in activity + await expect(page.getByText(expenseTitle)).toBeVisible() + + // Verify "created" action text appears in activity (e.g., "Alice created Test Expense") + await expect(page.getByText(/created/i)).toBeVisible() +}) + +test('Log shows update', async ({ page }) => { + // Setup: Create group and expense + const groupName = `activity update ${randomId(4)}` + const expenseTitle = `Update Test Expense ${randomId(4)}` + const updatedTitle = `Updated Expense ${randomId(4)}` + const updatedAmount = '50.00' + + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, ['Alice', 'Bob']) + + await createExpenseViaAPI(page, groupId, { + title: expenseTitle, + amount: 3000, + payerName: 'Alice', + }) + + // Navigate to group page + await navigateToGroup(page, groupId); + + // Wait for the expense to be visible and clickable + const expenseRow = page.getByText(expenseTitle) + await expect(expenseRow).toBeVisible() + + // Click on the expense to open edit page + await expenseRow.click() + await expect(page).toHaveURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + // Update the expense title + const titleInput = page.locator('input[name="title"]') + await expect(titleInput).toBeVisible() + await titleInput.clear() + await titleInput.fill(updatedTitle) + + // Update the amount + const amountInput = page.locator('input[name="amount"]') + await expect(amountInput).toBeVisible() + await amountInput.clear() + await amountInput.fill(updatedAmount) + + // Submit the form using semantic role selector + const submitButton = page.getByRole('button', { name: /save|update/i }) + await expect(submitButton).toBeVisible() + await submitButton.click() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Navigate to Activity tab to verify update was logged + // Note: After editing and saving, ensure we're on the expenses page first + await navigateToTab(page, 'Activity') + + // Wait for updated expense title to appear in activity + await expect(page.getByText(updatedTitle)).toBeVisible() + + // Verify "updated" or "edit" action text appears (e.g., "Alice updated Updated Expense") + await expect(page.getByText(/updated|edit/i)).toBeVisible() +}) + +test('Log shows delete', async ({ page }) => { + // Setup: Create group and expense + const groupName = `activity delete ${randomId(4)}` + const expenseTitle = `Delete Test Expense ${randomId(4)}` + + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, ['Alice', 'Bob']) + + await createExpenseViaAPI(page, groupId, { + title: expenseTitle, + amount: 4000, + payerName: 'Bob', + }) + + // Navigate to group page + await page.goto(`/groups/${groupId}/expenses`) + + // Click on the expense to open edit page + await page.getByText(expenseTitle).click() + await expect(page).toHaveURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + // Click delete button + const deleteButton = page.getByRole('button', { name: /delete/i }) + await expect(deleteButton).toBeVisible() + await deleteButton.click() + + // Verify confirmation dialog appears - wait for the dialog heading with "delete" + const deleteDialogTitle = page + .getByRole('heading') + .filter({ hasText: /delete/i }) + await expect(deleteDialogTitle).toBeVisible() + + // Click confirm delete button using the button with "Yes" text + const confirmButton = page.getByRole('button', { name: /yes/i }) + await expect(confirmButton).toBeVisible() + await confirmButton.click() + + // Wait for navigation back to group page + await page.waitForURL(/\/groups\/[^/]+/) + + // Verify expense is no longer visible in the main list + await expect(page.getByText(expenseTitle)).not.toBeVisible() + + // Navigate to Activity tab to verify delete was logged + await navigateToTab(page, 'Activity') + + // Verify delete action text appears in activity + // The activity list component renders the delete activity with the word "deleted" + const deleteActivity = page.getByText(/deleted/i) + await expect(deleteActivity).toBeVisible() +}) + +test('Log pagination', async ({ page }) => { + // Setup: Create group and many expenses to trigger pagination + const groupName = `activity pagination ${randomId(4)}` + const numExpenses = 25 + + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, ['Alice', 'Bob']) + + // Create 25 expenses via API to populate activity log + const createdExpenses = await createExpensesViaAPI(page, groupId, numExpenses) + expect(createdExpenses).toHaveLength(numExpenses) + + // Navigate to group page + await navigateToGroup(page, groupId); + + // Navigate to Activity tab + await navigateToTab(page, 'Activity') + + // Verify activity list is loaded + const activityListWrapper = page.getByTestId('activity-list') + await expect(activityListWrapper).toBeVisible() + + // Verify the most recent expense appears (last in array) + await expect(page.getByText(`Expense “Expense ${numExpenses}” created`)).toBeVisible() + + // Scroll down to trigger infinite scroll pagination + await page.mouse.wheel(0, 1000) + await expect(page.locator('.animate-pulse').first()).toBeVisible() + + // Verify all created expenses are loaded after scrolling + for (let i = 1; i <= numExpenses; i++) { + await expect(page.getByText(`Expense “Expense ${i}” created`)).toBeVisible() + } +}) diff --git a/tests/e2e/balances.spec.ts b/tests/e2e/balances.spec.ts new file mode 100644 index 000000000..0551145fa --- /dev/null +++ b/tests/e2e/balances.spec.ts @@ -0,0 +1,351 @@ +import { randomId } from '@/lib/api' +import { expect, test } from '@playwright/test' +import { navigateToTab } from '../helpers' +import { createExpenseViaAPI, createGroupViaAPI } from '../helpers/batch-api' + +test('suggested reimbursements displayed', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, `balances ${randomId(4)}`, [ + 'Alice', + 'Bob', + 'Charlie', + ]) + + await createExpenseViaAPI(page, groupId, { + title: 'Dinner', + amount: 30000, + payerName: 'Alice', + }) + await createExpenseViaAPI(page, groupId, { + title: 'Breakfast', + amount: 15000, + payerName: 'Bob', + }) + await createExpenseViaAPI(page, groupId, { + title: 'Lunch', + amount: 12000, + payerName: 'Charlie', + }) + + await page.goto(`/groups/${groupId}/expenses`) + await page.waitForLoadState('networkidle') + await navigateToTab(page, 'Balances') + await page.waitForLoadState('networkidle') + + // Verify Suggested reimbursements section is visible + const reimbursementsHeading = page.getByRole('heading', { + name: 'Suggested reimbursements', + }) + await expect(reimbursementsHeading).toBeVisible() + + // Verify reimbursements list is displayed + const reimbursementsList = page.getByTestId('reimbursements-list') + await expect(reimbursementsList).toBeVisible() + + // Verify specific reimbursement rows with expected visible content + const bobOwesAlice = page.getByTestId('reimbursement-row-Bob-Alice') + await expect(bobOwesAlice).toBeVisible() + await expect(bobOwesAlice).toContainText('Bob owes Alice') + await expect(bobOwesAlice).toContainText('$40.00') + + const charlieOwesAlice = page.getByTestId('reimbursement-row-Charlie-Alice') + await expect(charlieOwesAlice).toBeVisible() + await expect(charlieOwesAlice).toContainText('Charlie owes Alice') + await expect(charlieOwesAlice).toContainText('$70.00') + + // Verify Mark as paid links exist and are clickable + await expect( + bobOwesAlice.getByRole('link', { name: /mark as paid/i }), + ).toBeVisible() + await expect( + charlieOwesAlice.getByRole('link', { name: /mark as paid/i }), + ).toBeVisible() +}) + +test('view balances page - calculates correctly', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `balance calculation ${randomId(4)}`, + ['Alice', 'Bob', 'Charlie'], + ) + + await createExpenseViaAPI(page, groupId, { + title: 'Dinner', + amount: 30000, + payerName: 'Alice', + }) + await createExpenseViaAPI(page, groupId, { + title: 'Breakfast', + amount: 15000, + payerName: 'Bob', + }) + + await page.goto(`/groups/${groupId}/expenses`) + await page.waitForLoadState('networkidle') + await navigateToTab(page, 'Balances') + await page.waitForLoadState('networkidle') + + // Verify Balances section header is visible + const balancesHeading = page.getByRole('heading', { name: 'Balances' }) + await expect(balancesHeading).toBeVisible() + + // Verify balances list is rendered + const balancesList = page.getByTestId('balances-list') + await expect(balancesList).toBeVisible() + + // Verify balance calculations (net amounts) + const aliceRow = page.getByTestId('balance-row-Alice') + await expect(aliceRow).toBeVisible() + await expect(aliceRow).toContainText('Alice') + await expect(aliceRow).toContainText('$150.00') + + const bobRow = page.getByTestId('balance-row-Bob') + await expect(bobRow).toBeVisible() + await expect(bobRow).toContainText('Bob') + await expect(bobRow).toContainText('$0.00') + + const charlieRow = page.getByTestId('balance-row-Charlie') + await expect(charlieRow).toBeVisible() + await expect(charlieRow).toContainText('Charlie') + await expect(charlieRow).toContainText('-$150.00') +}) + +test('Active user balance highlighted', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `active user balance ${randomId(4)}`, + ['Alice', 'Bob', 'Charlie'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + await page.waitForLoadState('networkidle') + await navigateToTab(page, 'Balances') + await page.waitForLoadState('networkidle') + + // Verify balances list loads with all participants + const balancesList = page.getByTestId('balances-list') + await expect(balancesList).toBeVisible() + + await expect(page.getByTestId('balance-row-Alice')).toBeVisible() + await expect(page.getByTestId('balance-row-Bob')).toBeVisible() + await expect(page.getByTestId('balance-row-Charlie')).toBeVisible() +}) + +test('Zero balances display correctly', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `zero balances ${randomId(4)}`, + ['Alice', 'Bob', 'Charlie'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + await page.waitForLoadState('networkidle') + await navigateToTab(page, 'Balances') + await page.waitForLoadState('networkidle') + + // Verify balances list is displayed + const balancesList = page.getByTestId('balances-list') + await expect(balancesList).toBeVisible() + + // With no expenses, all balances should be zero + await expect(page.getByTestId('balance-row-Alice')).toContainText('$0.00') + await expect(page.getByTestId('balance-row-Bob')).toContainText('$0.00') + await expect(page.getByTestId('balance-row-Charlie')).toContainText('$0.00') + + // Verify no reimbursements are needed + await expect(page.getByTestId('no-reimbursements')).toBeVisible() +}) + +test('Balances match expected from expenses', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `balance verification ${randomId(4)}`, + ['Alice', 'Bob', 'Charlie'], + ) + + await createExpenseViaAPI(page, groupId, { + title: 'Dinner', + amount: 30000, + payerName: 'Alice', + }) + await createExpenseViaAPI(page, groupId, { + title: 'Breakfast', + amount: 15000, + payerName: 'Bob', + }) + + await page.goto(`/groups/${groupId}/expenses`) + await page.waitForLoadState('networkidle') + await navigateToTab(page, 'Balances') + await page.waitForLoadState('networkidle') + + // Wait for balances list to be visible + const balancesList = page.getByTestId('balances-list') + await expect(balancesList).toBeVisible() + + // Verify exact balance values by checking visible text content + await expect(page.getByTestId('balance-row-Alice')).toContainText('Alice') + await expect(page.getByTestId('balance-row-Alice')).toContainText('$150.00') + + await expect(page.getByTestId('balance-row-Bob')).toContainText('Bob') + await expect(page.getByTestId('balance-row-Bob')).toContainText('$0.00') + + await expect(page.getByTestId('balance-row-Charlie')).toContainText('Charlie') + await expect(page.getByTestId('balance-row-Charlie')).toContainText( + '-$150.00', + ) + + // Verify reimbursement suggestion exists + const reimbursementsList = page.getByTestId('reimbursements-list') + await expect(reimbursementsList).toBeVisible() + + // Charlie should owe Alice $150 + const charlieOwesAlice = page.getByTestId('reimbursement-row-Charlie-Alice') + await expect(charlieOwesAlice).toBeVisible() + await expect(charlieOwesAlice).toContainText('Charlie owes Alice') + await expect(charlieOwesAlice).toContainText('$150.00') +}) + +test('Suggested reimbursements minimized', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `reimbursement optimization ${randomId(4)}`, + ['Alice', 'Bob', 'Charlie', 'David'], + ) + + await createExpenseViaAPI(page, groupId, { + title: 'Event A', + amount: 40000, + payerName: 'Alice', + }) + await createExpenseViaAPI(page, groupId, { + title: 'Event B', + amount: 30000, + payerName: 'Bob', + }) + await createExpenseViaAPI(page, groupId, { + title: 'Event C', + amount: 20000, + payerName: 'Charlie', + }) + + await page.goto(`/groups/${groupId}/expenses`) + await page.waitForLoadState('networkidle') + await navigateToTab(page, 'Balances') + await page.waitForLoadState('networkidle') + + // Verify suggested reimbursements section exists + const reimbursementsHeading = page.getByRole('heading', { + name: 'Suggested reimbursements', + }) + await expect(reimbursementsHeading).toBeVisible() + + // Verify reimbursements list is displayed + const reimbursementsList = page.getByTestId('reimbursements-list') + await expect(reimbursementsList).toBeVisible() + + // Count reimbursement rows - with optimization should be minimal + // With 4 participants, maximum needed is 3 reimbursements (n-1) + const reimbursementRows = page.locator('[data-testid^="reimbursement-row-"]') + const count = await reimbursementRows.count() + expect(count).toBeGreaterThan(0) + expect(count).toBeLessThanOrEqual(3) +}) + +test('Create reimbursement expense', async ({ page }) => { + await page.goto('/groups') + + const groupId = await createGroupViaAPI( + page, + `create reimburse ${randomId(4)}`, + ['Alice', 'Bob', 'Charlie'], + ) + + await createExpenseViaAPI(page, groupId, { + title: 'Initial Expense', + amount: 30000, + payerName: 'Alice', + }) + + await page.goto(`/groups/${groupId}/expenses`) + await page.waitForLoadState('networkidle') + + // Now create a reimbursement expense directly + const createExpenseLink = page.getByRole('link', { name: 'Create expense' }) + await createExpenseLink.waitFor({ state: 'visible' }) + + await createExpenseLink.click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/create/) + + await page.getByLabel(/title/i).fill('Reimbursement from Bob') + + // Use the amount field with name="amount" specifically + await page.locator('input[name="amount"]').fill('100') + + // Select payer + const payBySelect = page + .getByRole('combobox') + .filter({ hasText: 'Select a participant' }) + await payBySelect.click() + + const reimbPayerOption = page.getByRole('option', { name: 'Bob' }) + await reimbPayerOption.click() + + // Check reimbursement checkbox + const reimbursementCheckbox = page.getByRole('checkbox', { + name: /reimbursement/i, + }) + await reimbursementCheckbox.check() + + // Submit + await page.getByRole('button', { name: /create/i }).click() + await page.waitForURL(/\/groups\/[^/]+/) + + // Verify reimbursement appears + await expect(page.getByText(/Reimbursement from/i)).toBeVisible() +}) + +test('Reimbursement in expenses', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `reimbursement totals ${randomId(4)}`, + ['Alice', 'Bob', 'Charlie'], + ) + + const regularExpense = await createExpenseViaAPI(page, groupId, { + title: 'Regular Expense', + amount: 30000, + payerName: 'Alice', + }) + + await page.goto(`/groups/${groupId}/expenses`) + await page.waitForLoadState('networkidle') + + // Verify expense appears + await expect(page.getByTestId(`expense-item-${regularExpense}`)).toBeVisible() + + // Create a reimbursement expense + const reimbursementExpense = await createExpenseViaAPI(page, groupId, { + title: 'Reimbursement Expense', + amount: 15000, + payerName: 'Bob', + isReimbursement: true, + }) + + await page.reload() + await page.waitForLoadState('networkidle') + await page.goto(`/groups/${groupId}/expenses`) + await page.waitForLoadState('networkidle') + + // Verify both expenses appear + await expect(page.getByTestId(`expense-item-${regularExpense}`)).toBeVisible() + await expect( + page.getByTestId(`expense-item-${reimbursementExpense}`), + ).toBeVisible() +}) diff --git a/tests/e2e/expense-create.spec.ts b/tests/e2e/expense-create.spec.ts new file mode 100644 index 000000000..e719288d5 --- /dev/null +++ b/tests/e2e/expense-create.spec.ts @@ -0,0 +1,269 @@ +import { expect, test } from '@playwright/test' +import { createExpense, navigateToExpenseCreate } from '../helpers' +import { createGroupViaAPI } from '../helpers/batch-api' +import { randomId } from '@/lib/api' + +test.describe('Expense Creation', () => { + test('creates basic expense with correct values', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Expense Create ${randomId(4)}`, + ['Alice', 'Bob', 'Charlie'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + + const expenseTitle = 'Dinner at Restaurant' + const expenseAmount = '150.00' + + await createExpense(page, { + title: expenseTitle, + amount: expenseAmount, + payer: 'Alice', + }) + + // Verify expense appears in list with correct title + const expenseCard = page.getByText(expenseTitle) + await expect(expenseCard).toBeVisible() + + // Verify amount is displayed correctly (formatted with currency) + await expect(page.getByText('$150.00')).toBeVisible() + + // Verify payer info is shown + await expect(page.getByText(/paid by/i).first()).toBeVisible() + }) + + test('creates expense with category', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Category Test ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + await navigateToExpenseCreate(page) + + const expenseTitle = 'Grocery Shopping' + const expenseAmount = '85.50' + + // Fill expense form + await page.locator('input[name="title"]').fill(expenseTitle) + await page.locator('input[name="amount"]').fill(expenseAmount) + + // Select payer + const paidBySelect = page + .getByRole('combobox') + .filter({ hasText: 'Select a participant' }) + await paidBySelect.click() + await page.getByRole('option', { name: 'Alice' }).click() + + // Select category (Food & Drink) + const categorySelect = page + .getByRole('combobox') + .filter({ hasText: /general/i }) + if (await categorySelect.isVisible()) { + await categorySelect.click() + const foodOption = page.getByRole('option', { name: /food|groceries/i }) + if (await foodOption.count()) { + await foodOption.first().click() + } + } + + // Submit + await page.locator('button[type="submit"]').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Verify expense created + await expect(page.getByText(expenseTitle)).toBeVisible() + await expect(page.getByText('$85.50')).toBeVisible() + }) + + test('creates expense with specific date', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, `Date Test ${randomId(4)}`, [ + 'Alice', + 'Bob', + ]) + + await page.goto(`/groups/${groupId}/expenses`) + await navigateToExpenseCreate(page) + + const expenseTitle = 'Historical Expense' + const expenseAmount = '50.00' + const testDate = '2024-06-15' + + // Fill expense form + await page.locator('input[name="title"]').fill(expenseTitle) + await page.locator('input[name="amount"]').fill(expenseAmount) + + // Select payer + const paidBySelect = page + .getByRole('combobox') + .filter({ hasText: 'Select a participant' }) + await paidBySelect.click() + await page.getByRole('option', { name: 'Alice' }).click() + + // Set date + await page.locator('input[type="date"]').fill(testDate) + + // Submit + await page.locator('button[type="submit"]').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Verify expense created + await expect(page.getByText(expenseTitle)).toBeVisible() + + // Click to edit and verify date was saved + await page.getByText(expenseTitle).click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + const dateInput = page.locator('input[type="date"]') + await expect(dateInput).toHaveValue(testDate) + }) + + test('creates expense with notes', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, `Notes Test ${randomId(4)}`, [ + 'Alice', + 'Bob', + ]) + + await page.goto(`/groups/${groupId}/expenses`) + await navigateToExpenseCreate(page) + + const expenseTitle = 'Expense with Notes' + const expenseAmount = '75.00' + const expenseNotes = 'This is a test note for the expense' + + // Fill expense form + await page.locator('input[name="title"]').fill(expenseTitle) + await page.locator('input[name="amount"]').fill(expenseAmount) + + // Select payer + const paidBySelect = page + .getByRole('combobox') + .filter({ hasText: 'Select a participant' }) + await paidBySelect.click() + await page.getByRole('option', { name: 'Alice' }).click() + + // Add notes + await page.locator('textarea').fill(expenseNotes) + + // Submit + await page.locator('button[type="submit"]').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Verify expense created + await expect(page.getByText(expenseTitle)).toBeVisible() + + // Click to edit and verify notes were saved + await page.getByText(expenseTitle).click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + const notesTextarea = page.locator('textarea') + await expect(notesTextarea).toHaveValue(expenseNotes) + }) + + test('creates reimbursement expense', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Reimbursement Test ${randomId(4)}`, + ['Alice', 'Bob', 'Charlie'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + + // First create a regular expense + await createExpense(page, { + title: 'Initial Expense', + amount: '300.00', + payer: 'Alice', + }) + + // Create reimbursement + await navigateToExpenseCreate(page) + + const reimbursementTitle = 'Bob pays Alice' + const reimbursementAmount = '100.00' + + await page.locator('input[name="title"]').fill(reimbursementTitle) + await page.locator('input[name="amount"]').fill(reimbursementAmount) + + // Select Bob as payer + const paidBySelect = page + .getByRole('combobox') + .filter({ hasText: 'Select a participant' }) + await paidBySelect.click() + await page.getByRole('option', { name: 'Bob' }).click() + + // Check reimbursement checkbox + const reimbursementCheckbox = page.getByRole('checkbox', { + name: /reimbursement/i, + }) + await reimbursementCheckbox.check() + + // Submit + await page.locator('button[type="submit"]').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Verify reimbursement created (should appear in italics) + await expect(page.getByText(reimbursementTitle)).toBeVisible() + await expect(page.getByText('$100.00')).toBeVisible() + }) + + test('verifies expense data persists after creation', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Persistence Test ${randomId(4)}`, + ['Alice', 'Bob', 'Charlie'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + + const expenseTitle = 'Persistence Check' + const expenseAmount = '123.45' + const expenseNotes = 'Checking data persistence' + const expenseDate = '2024-07-20' + + await navigateToExpenseCreate(page) + + // Fill all fields + await page.locator('input[name="title"]').fill(expenseTitle) + await page.locator('input[name="amount"]').fill(expenseAmount) + await page.locator('input[type="date"]').fill(expenseDate) + await page.locator('textarea').fill(expenseNotes) + + // Select payer + const paidBySelect = page + .getByRole('combobox') + .filter({ hasText: 'Select a participant' }) + await paidBySelect.click() + await page.getByRole('option', { name: 'Bob' }).click() + + // Submit + await page.locator('button[type="submit"]').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Click to open edit form + await page.getByText(expenseTitle).click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + // Verify all values persisted correctly + await expect(page.locator('input[name="title"]')).toHaveValue(expenseTitle) + await expect(page.locator('input[name="amount"]')).toHaveValue( + expenseAmount, + ) + await expect(page.locator('input[type="date"]')).toHaveValue(expenseDate) + await expect(page.locator('textarea')).toHaveValue(expenseNotes) + + // Verify payer selection persisted (Bob should be selected) + // The payer combobox shows the participant name when selected + await expect( + page.getByRole('combobox').filter({ hasText: 'Bob' }), + ).toBeVisible() + }) +}) diff --git a/tests/e2e/expense-delete.spec.ts b/tests/e2e/expense-delete.spec.ts new file mode 100644 index 000000000..592c30107 --- /dev/null +++ b/tests/e2e/expense-delete.spec.ts @@ -0,0 +1,228 @@ +import { expect, test } from '@playwright/test' +import { createExpenseViaAPI, createGroupViaAPI } from '../helpers/batch-api' +import { randomId } from '@/lib/api' + +test.describe('Expense Deletion', () => { + test('deletes expense with confirmation dialog', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, `Delete Test ${randomId(4)}`, [ + 'Alice', + 'Bob', + 'Charlie', + ]) + + await createExpenseViaAPI(page, groupId, { + title: 'Expense to Delete', + amount: 5000, // $50.00 in cents + payerName: 'Alice', + }) + + await page.goto(`/groups/${groupId}/expenses`) + + // Verify expense exists + await expect(page.getByText('Expense to Delete')).toBeVisible() + + // Click expense to edit + await page.getByText('Expense to Delete').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + // Click delete button + const deleteButton = page.getByRole('button', { name: /delete/i }) + await expect(deleteButton).toBeVisible() + await deleteButton.click() + + // Verify confirmation dialog appears + const dialogTitle = page.getByRole('heading').filter({ hasText: /delete/i }) + await expect(dialogTitle).toBeVisible() + + // Verify dialog has confirmation text + await expect(page.getByText(/do you really want to delete/i)).toBeVisible() + + // Click confirm delete + const confirmButton = page.getByRole('button', { name: /yes/i }) + await expect(confirmButton).toBeVisible() + await confirmButton.click() + + // Wait for navigation back to expenses list + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Verify expense is deleted + await expect(page.getByText('Expense to Delete')).not.toBeVisible() + }) + + test('cancels deletion when clicking cancel', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Cancel Delete ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await createExpenseViaAPI(page, groupId, { + title: 'Expense to Keep', + amount: 7500, // $75.00 in cents + payerName: 'Alice', + }) + + await page.goto(`/groups/${groupId}/expenses`) + + // Click expense to edit + await page.getByText('Expense to Keep').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + // Click delete button + const deleteButton = page.getByRole('button', { name: /delete/i }) + await deleteButton.click() + + // Verify confirmation dialog appears + const dialogTitle = page.getByRole('heading').filter({ hasText: /delete/i }) + await expect(dialogTitle).toBeVisible() + + // Click cancel/no button + const cancelButton = page.getByRole('button', { name: /no|cancel/i }) + await expect(cancelButton).toBeVisible() + await cancelButton.click() + + // Should still be on edit page + await expect(page).toHaveURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + // Navigate back to list + await page.goto(`/groups/${groupId}/expenses`) + + // Verify expense still exists + await expect(page.getByText('Expense to Keep')).toBeVisible() + await expect(page.getByText('$75.00')).toBeVisible() + }) + + test('deletes one of multiple expenses', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Multi Delete ${randomId(4)}`, + ['Alice', 'Bob', 'Charlie'], + ) + + // Create multiple expenses + await createExpenseViaAPI(page, groupId, { + title: 'First Expense', + amount: 10000, // $100.00 in cents + payerName: 'Alice', + }) + + await createExpenseViaAPI(page, groupId, { + title: 'Second Expense', + amount: 20000, // $200.00 in cents + payerName: 'Bob', + }) + + await createExpenseViaAPI(page, groupId, { + title: 'Third Expense', + amount: 30000, // $300.00 in cents + payerName: 'Charlie', + }) + + await page.goto(`/groups/${groupId}/expenses`) + + await page.goto(`/groups/${groupId}/expenses`) + + // Verify all expenses exist + await expect(page.getByText('First Expense')).toBeVisible() + await expect(page.getByText('Second Expense')).toBeVisible() + await expect(page.getByText('Third Expense')).toBeVisible() + + // Delete the second expense + await page.getByText('Second Expense').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + const deleteButton = page.getByRole('button', { name: /delete/i }) + await deleteButton.click() + + const confirmButton = page.getByRole('button', { name: /yes/i }) + await confirmButton.click() + + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Verify only the deleted expense is gone + await expect(page.getByText('First Expense')).toBeVisible() + await expect(page.getByText('Second Expense')).not.toBeVisible() + await expect(page.getByText('Third Expense')).toBeVisible() + + // Verify amounts of remaining expenses + await expect(page.getByText('$100.00')).toBeVisible() + await expect(page.getByText('$200.00')).not.toBeVisible() + await expect(page.getByText('$300.00')).toBeVisible() + }) + + test('deletes reimbursement expense', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Delete Reimbursement ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + // First create a regular expense + await createExpenseViaAPI(page, groupId, { + title: 'Regular Expense', + amount: 20000, // $200.00 in cents + payerName: 'Alice', + }) + + // Create reimbursement + await createExpenseViaAPI(page, groupId, { + title: 'Reimbursement to Delete', + amount: 10000, // $100.00 in cents + payerName: 'Bob', + isReimbursement: true, + }) + + await page.goto(`/groups/${groupId}/expenses`) + + const reimbursementTitle = 'Reimbursement to Delete' + await expect(page.getByText(reimbursementTitle)).toBeVisible() + + // Delete the reimbursement + await page.getByText(reimbursementTitle).click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + const deleteButton = page.getByRole('button', { name: /delete/i }) + await deleteButton.click() + + const confirmButton = page.getByRole('button', { name: /yes/i }) + await confirmButton.click() + + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Verify reimbursement is deleted but regular expense remains + await expect(page.getByText(reimbursementTitle)).not.toBeVisible() + await expect(page.getByText('Regular Expense')).toBeVisible() + }) + + test('delete button is visible in edit form', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Delete Button ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await createExpenseViaAPI(page, groupId, { + title: 'Check Delete Button', + amount: 2500, // $25.00 in cents + payerName: 'Alice', + }) + + await page.goto(`/groups/${groupId}/expenses`) + + await page.goto(`/groups/${groupId}/expenses`) + + // Click expense to edit + await page.getByText('Check Delete Button').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + // Verify delete button is visible and properly styled + const deleteButton = page.getByRole('button', { name: /delete/i }) + await expect(deleteButton).toBeVisible() + await expect(deleteButton).toBeEnabled() + }) +}) diff --git a/tests/e2e/expense-edit.spec.ts b/tests/e2e/expense-edit.spec.ts new file mode 100644 index 000000000..a41239d1b --- /dev/null +++ b/tests/e2e/expense-edit.spec.ts @@ -0,0 +1,286 @@ +import { expect, test } from '@playwright/test' +import { createExpenseViaAPI, createGroupViaAPI } from '../helpers/batch-api' +import { randomId } from '@/lib/api' + +test.describe('Expense Editing', () => { + test('updates expense title and amount', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, `Edit Test ${randomId(4)}`, [ + 'Alice', + 'Bob', + 'Charlie', + ]) + + await createExpenseViaAPI(page, groupId, { + title: 'Original Expense', + amount: 10000, // $100.00 in cents + payerName: 'Alice', + }) + + await page.goto(`/groups/${groupId}/expenses`) + + // Click expense to edit + await page.getByText('Original Expense').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + // Update title + const newTitle = 'Updated Expense Title' + const titleInput = page.locator('input[name="title"]') + await titleInput.clear() + await titleInput.fill(newTitle) + + // Update amount + const newAmount = '250.00' + const amountInput = page.locator('input[name="amount"]') + await amountInput.clear() + await amountInput.fill(newAmount) + + // Submit + const saveButton = page.getByRole('button', { name: /save/i }) + await saveButton.click() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Verify updated values in list + await expect(page.getByText(newTitle)).toBeVisible() + await expect(page.getByText('$250.00')).toBeVisible() + await expect(page.getByText('Original Expense')).not.toBeVisible() + }) + + test('updates expense payer', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Payer Update ${randomId(4)}`, + ['Alice', 'Bob', 'Charlie'], + ) + + await createExpenseViaAPI(page, groupId, { + title: 'Payer Change Test', + amount: 6000, // $60.00 in cents + payerName: 'Alice', + }) + + await page.goto(`/groups/${groupId}/expenses`) + + await page.goto(`/groups/${groupId}/expenses`) + + // Click expense to edit + await page.getByText('Payer Change Test').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + // Change payer from Alice to Bob + const payerSelect = page.getByRole('combobox').filter({ hasText: 'Alice' }) + await payerSelect.click() + await page.getByRole('option', { name: 'Bob' }).click() + + // Submit + const saveButton = page.getByRole('button', { name: /save/i }) + await saveButton.click() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Verify payer updated in list + const expenseCard = page.getByText('Payer Change Test').locator('..') + await expect(page.getByText(/Bob/)).toBeVisible() + }) + + test('updates expense date', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, `Date Update ${randomId(4)}`, [ + 'Alice', + 'Bob', + ]) + + const originalDate = '2024-05-15' + + await createExpenseViaAPI(page, groupId, { + title: 'Date Change Test', + amount: 4500, // $45.00 in cents + payerName: 'Alice', + expenseDate: new Date(originalDate), + }) + + await page.goto(`/groups/${groupId}/expenses`) + + await page.goto(`/groups/${groupId}/expenses`) + + // Click expense to edit + await page.getByText('Date Change Test').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + // Verify original date + await expect(page.locator('input[type="date"]')).toHaveValue(originalDate) + + // Update date + const newDate = '2024-06-20' + await page.locator('input[type="date"]').fill(newDate) + + // Submit + const saveButton = page.getByRole('button', { name: /save/i }) + await saveButton.click() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Click again to verify date was saved + await page.getByText('Date Change Test').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + await expect(page.locator('input[type="date"]')).toHaveValue(newDate) + }) + + test('updates expense notes', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Notes Update ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + const originalNotes = 'Original notes content' + + await createExpenseViaAPI(page, groupId, { + title: 'Notes Update Test', + amount: 3000, // $30.00 in cents + payerName: 'Alice', + notes: originalNotes, + }) + + await page.goto(`/groups/${groupId}/expenses`) + + await page.goto(`/groups/${groupId}/expenses`) + + // Click expense to edit + await page.getByText('Notes Update Test').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + // Verify original notes + await expect(page.locator('textarea')).toHaveValue(originalNotes) + + // Update notes + const newNotes = 'Updated notes with new information' + const notesTextarea = page.locator('textarea') + await notesTextarea.clear() + await notesTextarea.fill(newNotes) + + // Submit + const saveButton = page.getByRole('button', { name: /save/i }) + await saveButton.click() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Click again to verify notes were saved + await page.getByText('Notes Update Test').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + await expect(page.locator('textarea')).toHaveValue(newNotes) + }) + + test('updates all fields simultaneously', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, `Full Update ${randomId(4)}`, [ + 'Alice', + 'Bob', + 'Charlie', + ]) + + // Create initial expense + await createExpenseViaAPI(page, groupId, { + title: 'Initial Full Test', + amount: 10000, // $100.00 in cents + payerName: 'Alice', + notes: 'Original notes', + }) + + await page.goto(`/groups/${groupId}/expenses`) + + await page.goto(`/groups/${groupId}/expenses`) + + // Click expense to edit + await page.getByText('Initial Full Test').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + // Update all fields + const newTitle = 'Completely Updated Expense' + const newAmount = '350.00' + const newDate = '2024-08-10' + const newNotes = 'Completely new notes' + + await page.locator('input[name="title"]').clear() + await page.locator('input[name="title"]').fill(newTitle) + + await page.locator('input[name="amount"]').clear() + await page.locator('input[name="amount"]').fill(newAmount) + + await page.locator('input[type="date"]').fill(newDate) + + await page.locator('textarea').clear() + await page.locator('textarea').fill(newNotes) + + // Change payer + const payerSelect = page.getByRole('combobox').filter({ hasText: 'Alice' }) + await payerSelect.click() + await page.getByRole('option', { name: 'Charlie' }).click() + + // Submit + const saveButton = page.getByRole('button', { name: /save/i }) + await saveButton.click() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Verify in list + await expect(page.getByText(newTitle)).toBeVisible() + await expect(page.getByText('$350.00')).toBeVisible() + await expect(page.getByText('Initial Full Test')).not.toBeVisible() + + // Click to verify all values persisted + await page.getByText(newTitle).click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + await expect(page.locator('input[name="title"]')).toHaveValue(newTitle) + // Amount may lose trailing zeros + await expect(page.locator('input[name="amount"]')).toHaveValue(/350(\.00)?/) + await expect(page.locator('input[type="date"]')).toHaveValue(newDate) + await expect(page.locator('textarea')).toHaveValue(newNotes) + await expect( + page.getByRole('combobox').filter({ hasText: 'Charlie' }), + ).toBeVisible() + }) + + test('toggles reimbursement status', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Reimbursement Toggle ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await createExpenseViaAPI(page, groupId, { + title: 'Reimbursement Toggle Test', + amount: 7500, // $75.00 in cents + payerName: 'Alice', + }) + + await page.goto(`/groups/${groupId}/expenses`) + + await page.goto(`/groups/${groupId}/expenses`) + + // Click expense to edit + await page.getByText('Reimbursement Toggle Test').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + // Check reimbursement + const reimbursementCheckbox = page.getByRole('checkbox', { + name: /reimbursement/i, + }) + await expect(reimbursementCheckbox).not.toBeChecked() + await reimbursementCheckbox.check() + await expect(reimbursementCheckbox).toBeChecked() + + // Submit + const saveButton = page.getByRole('button', { name: /save/i }) + await saveButton.click() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Click again to verify reimbursement status persisted + await page.getByText('Reimbursement Toggle Test').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + await expect( + page.getByRole('checkbox', { name: /reimbursement/i }), + ).toBeChecked() + }) +}) diff --git a/tests/e2e/expense-filter.spec.ts b/tests/e2e/expense-filter.spec.ts new file mode 100644 index 000000000..7fdab52fb --- /dev/null +++ b/tests/e2e/expense-filter.spec.ts @@ -0,0 +1,291 @@ +import { expect, test } from '@playwright/test' +import { createExpenseViaAPI, createGroupViaAPI } from '../helpers/batch-api' +import { navigateToGroup } from '../helpers' +import { randomId } from '@/lib/api' + +test.describe('Expense List Filtering', () => { + test('filters expenses by text search', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, `Filter Test ${randomId(4)}`, [ + 'Alice', + 'Bob', + ]) + + await createExpenseViaAPI(page, groupId, { + title: 'Pizza Dinner', + amount: 5000, + payerName: 'Alice', + }) + + await createExpenseViaAPI(page, groupId, { + title: 'Movie Tickets', + amount: 3000, + payerName: 'Bob', + }) + + await createExpenseViaAPI(page, groupId, { + title: 'Grocery Shopping', + amount: 7500, + payerName: 'Alice', + }) + + await navigateToGroup(page, groupId) + + // Verify all expenses visible initially + await expect(page.getByText('Pizza Dinner')).toBeVisible() + await expect(page.getByText('Movie Tickets')).toBeVisible() + await expect(page.getByText('Grocery Shopping')).toBeVisible() + + // Search for "Pizza" + const searchInput = page.getByPlaceholder(/search/i) + await searchInput.fill('Pizza') + + // Wait for search + await page.waitForResponse('**groups.expenses.list**'); + + // Verify only Pizza visible + await expect(page.getByText('Pizza Dinner')).toBeVisible() + await expect(page.getByText('Movie Tickets')).not.toBeVisible() + await expect(page.getByText('Grocery Shopping')).not.toBeVisible() + + // Clear search and verify all return + await searchInput.clear() + + await expect(page.getByText('Pizza Dinner')).toBeVisible() + await expect(page.getByText('Movie Tickets')).toBeVisible() + await expect(page.getByText('Grocery Shopping')).toBeVisible() + }) + + test('case insensitive search', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, `Case Test ${randomId(4)}`, [ + 'Alice', + 'Bob', + ]) + + await createExpenseViaAPI(page, groupId, { + title: 'UPPERCASE EXPENSE', + amount: 4000, + payerName: 'Alice', + }) + + await createExpenseViaAPI(page, groupId, { + title: 'lowercase expense', + amount: 6000, + payerName: 'Bob', + }) + + await navigateToGroup(page, groupId) + + // Search lowercase for uppercase title + const searchInput = page.getByPlaceholder(/search/i) + await searchInput.fill('uppercase') + await page.waitForResponse('**groups.expenses.list**'); + + await expect(page.getByText('UPPERCASE EXPENSE')).toBeVisible() + await expect(page.getByText('lowercase expense')).not.toBeVisible() + + // Search uppercase for lowercase title + await searchInput.clear() + await searchInput.fill('LOWERCASE') + await page.waitForResponse('**groups.expenses.list**'); + + await expect(page.getByText('UPPERCASE EXPENSE')).not.toBeVisible() + await expect(page.getByText('lowercase expense')).toBeVisible() + }) + + test('partial text match', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Partial Test ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await createExpenseViaAPI(page, groupId, { + title: 'Restaurant Dinner', + amount: 8500, + payerName: 'Alice', + }) + + await createExpenseViaAPI(page, groupId, { + title: 'Breakfast at Cafe', + amount: 2500, + payerName: 'Bob', + }) + + await navigateToGroup(page, groupId) + + // Search for partial match "fast" + const searchInput = page.getByPlaceholder(/search/i) + await searchInput.fill('fast') + await page.waitForResponse('**groups.expenses.list**'); + + // Should match "Breakfast" + await expect(page.getByText('Restaurant Dinner')).not.toBeVisible() + await expect(page.getByText('Breakfast at Cafe')).toBeVisible() + }) + + test('no results found', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, `No Results ${randomId(4)}`, [ + 'Alice', + 'Bob', + ]) + + await createExpenseViaAPI(page, groupId, { + title: 'Regular Expense', + amount: 5000, + payerName: 'Alice', + }) + + await page.goto(`/groups/${groupId}/expenses`) + + // Search for non-existent text + const searchInput = page.getByPlaceholder(/search/i) + await searchInput.fill('xyz123nonexistent') + await page.waitForResponse('**groups.expenses.list**'); + + // Expense should not be visible + await expect(page.getByText('Regular Expense')).not.toBeVisible() + + // There should be some "no expenses" indication or empty state + // Clear search to verify expense returns + await searchInput.clear() + + await expect(page.getByText('Regular Expense')).toBeVisible() + }) + + test('filter with multiple matching expenses', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, `Multi Match ${randomId(4)}`, [ + 'Alice', + 'Bob', + 'Charlie', + ]) + + await createExpenseViaAPI(page, groupId, { + title: 'Dinner at Italian Restaurant', + amount: 8000, + payerName: 'Alice', + }) + + await createExpenseViaAPI(page, groupId, { + title: 'Dinner at Chinese Restaurant', + amount: 6500, + payerName: 'Bob', + }) + + await createExpenseViaAPI(page, groupId, { + title: 'Lunch Break', + amount: 2500, + payerName: 'Charlie', + }) + + await navigateToGroup(page, groupId) + + // Search for "Dinner" + const searchInput = page.getByPlaceholder(/search/i) + await searchInput.fill('Dinner') + await page.waitForResponse('**groups.expenses.list**'); + + // Both dinner expenses visible + await expect(page.getByText('Dinner at Italian Restaurant')).toBeVisible() + await expect(page.getByText('Dinner at Chinese Restaurant')).toBeVisible() + await expect(page.getByText('Lunch Break')).not.toBeVisible() + + // Verify amounts of visible expenses + await expect(page.getByText('$80.00')).toBeVisible() + await expect(page.getByText('$65.00')).toBeVisible() + await expect(page.getByText('$25.00')).not.toBeVisible() + }) + + test('clear search with x button', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Clear Button ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await createExpenseViaAPI(page, groupId, { + title: 'Test Expense One', + amount: 10000, + payerName: 'Alice', + }) + + await createExpenseViaAPI(page, groupId, { + title: 'Test Expense Two', + amount: 20000, + payerName: 'Bob', + }) + + await navigateToGroup(page, groupId) + + // Filter to show only one + const searchInput = page.getByPlaceholder(/search/i) + await searchInput.fill('One') + await page.waitForResponse('**groups.expenses.list**'); + + await expect(page.getByText('Test Expense One')).toBeVisible() + await expect(page.getByText('Test Expense Two')).not.toBeVisible() + + // Try to clear with X button if it exists + const clearButton = page.locator('svg.lucide-x-circle') + if (await clearButton.isVisible()) { + await clearButton.click() + } else { + // Fallback: clear input manually + await searchInput.clear() + } + + // Both should be visible again + await expect(page.getByText('Test Expense One')).toBeVisible() + await expect(page.getByText('Test Expense Two')).toBeVisible() + }) + + test('search persists while typing', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Type Persist ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await createExpenseViaAPI(page, groupId, { + title: 'Electricity Bill', + amount: 15000, + payerName: 'Alice', + }) + + await createExpenseViaAPI(page, groupId, { + title: 'Electric Car Charging', + amount: 4500, + payerName: 'Bob', + }) + + await createExpenseViaAPI(page, groupId, { + title: 'Water Bill', + amount: 3000, + payerName: 'Alice', + }) + + await page.goto(`/groups/${groupId}/expenses`) + + const searchInput = page.getByPlaceholder(/search/i) + + // Type "Elec" progressively + await searchInput.fill('E') + await page.waitForResponse('**groups.expenses.list**'); + + + // Should still show Electric items + await searchInput.fill('Elec') + await page.waitForResponse('**groups.expenses.list**'); + + + await expect(page.getByText('Electricity Bill')).toBeVisible() + await expect(page.getByText('Electric Car Charging')).toBeVisible() + await expect(page.getByText('Water Bill')).not.toBeVisible() + }) +}) diff --git a/tests/e2e/expense-pagination.spec.ts b/tests/e2e/expense-pagination.spec.ts new file mode 100644 index 000000000..0d3f6baa2 --- /dev/null +++ b/tests/e2e/expense-pagination.spec.ts @@ -0,0 +1,219 @@ +import { expect, test } from '@playwright/test' +import { + createExpensesViaAPI, + createGroupViaAPI, + navigateToGroup, +} from '../helpers' +import { randomId } from '@/lib/api' + +test.describe('Expense List Pagination', () => { + test('loads initial page of expenses', async ({ page }) => { + // Create group via API for speed + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Pagination Init ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + // Create 15 expenses (less than PAGE_SIZE of 20) + const createdExpenses = await createExpensesViaAPI(page, groupId, 15, ['Alice', 'Bob']) + + await navigateToGroup(page, groupId) + + // Verify expenses are visible + for (const expense of createdExpenses) { + await expect(page.getByTestId(`expense-item-${expense}`)).toBeVisible() + } + }) + + test('loads more expenses on scroll with infinite scroll', async ({ + page, + }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Pagination Scroll ${randomId(4)}`, + ['Alice', 'Bob', 'Charlie'], + ) + + // Create 25 expenses (more than PAGE_SIZE of 20) + const createdExpenses = await createExpensesViaAPI(page, groupId, 25, [ + 'Alice', + 'Bob', + ]) + expect(createdExpenses).toHaveLength(25) + + await navigateToGroup(page, groupId) + + // Verify most recent expenses visible initially (expenses shown in reverse order) + await expect(page.getByText('Expense 25')).toBeVisible() + + // Scroll to bottom to trigger loading more + await page.evaluate(() => { + window.scrollTo(0, document.documentElement.scrollHeight) + }) + await expect(page.locator('.animate-pulse').first()).toBeVisible() + + // All 25 should eventually be loaded + for (const expense of createdExpenses) { + await expect(page.getByTestId(`expense-item-${expense}`)).toBeVisible() + } + }) + + test('displays correct expense count after loading all pages', async ({ + page, + }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Pagination Count ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + // Create 30 expenses (requires 2 pages) + const createdExpenses = await createExpensesViaAPI(page, groupId, 30, ['Alice', 'Bob']) + + await navigateToGroup(page, groupId) + + // Scroll multiple times to load all + for (let i = 0; i < 3; i++) { + await page.evaluate(() => { + window.scrollTo(0, document.documentElement.scrollHeight) + }) + // Workaround for waiting for network idle after scroll + await page.waitForLoadState('networkidle') + } + + // Verify first and last expenses are visible + for (const expense of createdExpenses) { + await expect(page.getByTestId(`expense-item-${expense}`)).toBeVisible() + } + }) + + test('maintains expense order after pagination', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Pagination Order ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + // Create 22 expenses + const createdExpenses = await createExpensesViaAPI(page, groupId, 22, ['Alice', 'Bob']) + + await navigateToGroup(page, groupId) + + // Most recent should appear first + const expense22 = page.getByText('Expense 22') + const expense21 = page.getByText('Expense 21') + + await expect(expense22).toBeVisible() + await expect(expense21).toBeVisible() + + // Scroll to load more + await page.evaluate(() => { + window.scrollTo(0, document.documentElement.scrollHeight) + }) + await expect(page.locator('.animate-pulse').first()).toBeVisible() + + // After loading more, older expenses should appear + for(const expense of createdExpenses) { + await expect(page.getByTestId(`expense-item-${expense}`)).toBeVisible() + } + }) + + test('pagination works with search filter', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Pagination Filter ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + // Create 25 expenses + await createExpensesViaAPI(page, groupId, 25, ['Alice', 'Bob']) + + await navigateToGroup(page, groupId) + + // Apply search filter + const searchInput = page.getByPlaceholder(/search/i) + await searchInput.fill('Expense 1') + await page.waitForResponse('**groups.expenses.list**'); + + // Should filter to expenses 01, 10-19 + // Expense 10-19 should match "Expense 1" + await expect(page.getByText('Expense 10')).toBeVisible() + await expect(page.getByText('Expense 15')).toBeVisible() + + // Expenses not matching should not appear + await expect(page.getByText('Expense 22')).not.toBeVisible() + await expect(page.getByText('Expense 25')).not.toBeVisible() + }) + + test('empty state when no expenses', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, `Empty State ${randomId(4)}`, [ + 'Alice', + 'Bob', + ]) + + await navigateToGroup(page, groupId) + + // Should show empty state or "create first" message + await expect(page.getByText('Here are the expenses that you created for your group')).toBeVisible() + await expect(page.getByText('Your group doesn’t contain any expense yet. Create the first one')).toBeVisible() + }) + + test('loading indicator appears during pagination', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Loading State ${randomId(4)}`, + ['Alice', 'Bob', 'Charlie'], + ) + + // Create many expenses to ensure pagination is needed + await createExpensesViaAPI(page, groupId, 30, ['Alice', 'Bob']) + + await navigateToGroup(page, groupId) + + // Verify initial content loaded + await expect(page.getByText('Expense 30')).toBeVisible() + + // Scroll and check for loading state (skeleton or spinner) + await page.evaluate(() => { + window.scrollTo(0, document.documentElement.scrollHeight) + }) + await expect(page.locator('.animate-pulse').first()).toBeVisible() + }) + + test('expense amounts display correctly across pages', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Amount Display ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + // Create 25 expenses (amounts will be 1100, 1200, ... based on createExpensesViaAPI) + const createdExpenses = await createExpensesViaAPI(page, groupId, 25, ['Alice', 'Bob']) + + await navigateToGroup(page, groupId) + + // Expense 25 should have amount 25 * 100 + 1000 = 3500 cents = $35.00 + await expect(page.getByText('$35.00')).toBeVisible() + + // Scroll to load all + await page.evaluate(() => { + window.scrollTo(0, document.documentElement.scrollHeight) + }) + + for (let index = 0; index < createdExpenses.length; index++) { + const expense = createdExpenses[index]; + const expectedAmount = ((index + 1) * 100 + 1000) / 100; + const expenseItem = page.getByTestId(`expense-item-${expense}`); + await expect(expenseItem.getByText(`$${expectedAmount.toFixed(2)}`)).toBeVisible(); + } + }) +}) diff --git a/tests/e2e/expense-split-modes.spec.ts b/tests/e2e/expense-split-modes.spec.ts new file mode 100644 index 000000000..6eb8da103 --- /dev/null +++ b/tests/e2e/expense-split-modes.spec.ts @@ -0,0 +1,397 @@ +import { expect, test } from '@playwright/test' +import { navigateToTab } from '../helpers' +import { createGroupViaAPI } from '../helpers/batch-api' +import { randomId } from '@/lib/api' + +test('Create expense - evenly split (most common flow)', async ({ page }) => { + const groupName = `split modes ${randomId(4)}` + const participantA = 'Alice' + const participantB = 'Bob' + const participantC = 'Charlie' + + // Step 1: Create group with 3 participants (Alice, Bob, Charlie) + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, [ + participantA, + participantB, + participantC, + ]) + await page.goto(`/groups/${groupId}/expenses`) + + // Step 2: Navigate to expense creation by clicking the link + const createLink = page + .getByRole('link', { name: /create expense|create the first/i }) + .first() + await createLink.waitFor({ state: 'visible' }) + await createLink.click() + + // Wait for navigation to expense creation page + await page.waitForURL(/\/groups\/[^/]+\/expenses\/create/) + + // Wait for the expense form to load by checking for the title input field + const expenseTitle = page.locator('input[name="title"]') + await expenseTitle.waitFor({ state: 'visible' }) + + // Step 3: Fill title: "Team Dinner" + await expenseTitle.fill('Team Dinner') + + // Step 4: Fill amount: 300.00 + const amountInput = page.locator('input[name="amount"]') + await amountInput.fill('300.00') + + // Step 5: Select payer: Alice + const paidBySelect = page + .getByRole('combobox') + .filter({ hasText: 'Select a participant' }) + await paidBySelect.waitFor({ state: 'visible' }) + await paidBySelect.click() + + const aliceOption = page.getByRole('option', { name: participantA }) + await aliceOption.waitFor({ state: 'visible' }) + await aliceOption.click() + + // Step 6: Verify split section is visible (evenly split is the default when all are checked) + // The form doesn't explicitly show "Evenly" text, but by default all participants are checked + + // Step 7: Verify all 3 participants are included + const participantCheckboxes = page.getByRole('checkbox') + const checkedCount = await participantCheckboxes.count() + expect(checkedCount).toBeGreaterThanOrEqual(3) + + // Step 8: Submit expense + const saveButton = page.locator('button[type="submit"]').first() + await saveButton.click() + + // Wait for redirect back to group page + await page.waitForURL(/\/groups\/[^/]+/, {}) + + // Step 9: Navigate to Expenses tab + await navigateToTab(page, 'Expenses') + + // Step 10: Verify expense appears with correct title and amount + await expect(page.getByText('Team Dinner')).toBeVisible({}) + await expect(page.locator(`text=300.00`)).toBeVisible({}) + + // Step 11: Verify balances + await navigateToTab(page, 'Balances') + + // Wait for the Balances heading to appear + await expect( + page + .locator('h2, h3, h4, h5') + .filter({ hasText: /balance/i }) + .first(), + ).toBeVisible() + + // Verify participants are mentioned in the balances (use .first() to avoid strict mode) + await expect(page.getByText(participantA).first()).toBeVisible() + await expect(page.getByText(participantB).first()).toBeVisible() + await expect(page.getByText(participantC).first()).toBeVisible() +}) + +test('Create expense - by shares split mode', async ({ page }) => { + const groupName = `by shares ${randomId(4)}` + const participantA = 'Alice' + const participantB = 'Bob' + const participantC = 'Charlie' + + // Step 1: Create group with 3 participants + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, [ + participantA, + participantB, + participantC, + ]) + await page.goto(`/groups/${groupId}/expenses`) + + // Step 2: Navigate to expense creation + const createLink = page + .getByRole('link', { name: /create expense|create the first/i }) + .first() + await createLink.waitFor({ state: 'visible' }) + await createLink.click() + + // Wait for navigation to expense creation page + await page.waitForURL(/\/groups\/[^/]+\/expenses\/create/) + + // Wait for the expense form to load + const expenseTitle = page.locator('input[name="title"]') + await expenseTitle.waitFor({ state: 'visible' }) + + // Step 3: Fill title and amount + await expenseTitle.fill('Team Dinner Shares') + const amountInput = page.locator('input[name="amount"]') + await amountInput.fill('300.00') + + // Step 4: Select payer: Alice + const paidBySelect = page + .getByRole('combobox') + .filter({ hasText: 'Select a participant' }) + await paidBySelect.waitFor({ state: 'visible' }) + await paidBySelect.click() + + const aliceOption = page.getByRole('option', { name: participantA }) + await aliceOption.waitFor({ state: 'visible' }) + await aliceOption.click() + + // Step 5: Expand advanced options + await page + .getByRole('button', { name: 'Advanced splitting options…' }) + .click() + + // Step 6: Select split mode: By shares + const splitModeSelect = page + .getByRole('combobox') + .filter({ hasText: 'Evenly' }) + await splitModeSelect.click() + await page.getByRole('option', { name: 'Unevenly – By shares' }).click() + + // Step 7: Fill shares - Alice: 1, Bob: 2, Charlie: 3 + // The split-mode inputs are plain textboxes without stable attributes; + // scope to the "Paid for" section and match by the trailing "share(s)" label. + const paidForSection = page + .locator('h1,h2,h3,h4,h5', { hasText: /^Paid for/i }) + .first() + .locator('..') + .locator('..') + + const shareInputs = paidForSection + .locator('div', { hasText: 'share(s)' }) + .getByRole('textbox') + + await expect(shareInputs).toHaveCount(3) + + await shareInputs.nth(0).fill('1') // Alice + await shareInputs.nth(1).fill('2') // Bob + await shareInputs.nth(2).fill('3') // Charlie + + // Step 7: Submit expense + const saveButton = page.locator('button[type="submit"]').first() + await saveButton.click() + + // Wait for redirect back to group page + await page.waitForURL(/\/groups\/[^/]+/, {}) + + // Step 8: Navigate to Expenses tab + await navigateToTab(page, 'Expenses') + + // Step 9: Verify expense appears + await expect(page.getByText('Team Dinner Shares')).toBeVisible() + await expect(page.locator('text=300.00')).toBeVisible() + + // Step 10: Verify balances (Alice paid 300, shares 1:2:3 so she is owed 250, Bob owes 100, Charlie owes 150) + await navigateToTab(page, 'Balances') + + // Wait for balances to load + await expect( + page + .locator('h2, h3, h4, h5') + .filter({ hasText: /balance/i }) + .first(), + ).toBeVisible({}) + + // Verify participants are shown + await expect(page.getByText(participantA).first()).toBeVisible({}) + await expect(page.getByText(participantB).first()).toBeVisible({}) + await expect(page.getByText(participantC).first()).toBeVisible({}) +}) + +test('Create expense - by percentage split mode', async ({ page }) => { + const groupName = `by percentage ${randomId(4)}` + const participantA = 'Alice' + const participantB = 'Bob' + const participantC = 'Charlie' + + // Step 1: Create group with 3 participants + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, [ + participantA, + participantB, + participantC, + ]) + await page.goto(`/groups/${groupId}/expenses`) + + // Step 2: Navigate to expense creation + const createLink = page + .getByRole('link', { name: /create expense|create the first/i }) + .first() + await createLink.waitFor({ state: 'visible' }) + await createLink.click() + + // Wait for navigation to expense creation page + await page.waitForURL(/\/groups\/[^/]+\/expenses\/create/) + + // Wait for the expense form to load + const expenseTitle = page.locator('input[name="title"]') + await expenseTitle.waitFor({ state: 'visible' }) + + // Step 3: Fill title and amount + await expenseTitle.fill('Team Dinner Percentage') + const amountInput = page.locator('input[name="amount"]') + await amountInput.fill('300.00') + + // Step 4: Select payer: Alice + const paidBySelect = page + .getByRole('combobox') + .filter({ hasText: 'Select a participant' }) + await paidBySelect.waitFor({ state: 'visible' }) + await paidBySelect.click() + + const aliceOption = page.getByRole('option', { name: participantA }) + await aliceOption.waitFor({ state: 'visible' }) + await aliceOption.click() + + // Step 5: Expand advanced options + await page + .getByRole('button', { name: 'Advanced splitting options…' }) + .click() + + // Step 6: Select split mode: By percentage + const splitModeSelect = page + .getByRole('combobox') + .filter({ hasText: 'Evenly' }) + await splitModeSelect.click() + await page.getByRole('option', { name: 'Unevenly – By percentage' }).click() + + // Step 7: Fill percentages - Alice: 25%, Bob: 25%, Charlie: 50% + // Scope to the "Paid for" section and match by the trailing "%" label. + const paidForSection = page + .locator('h1,h2,h3,h4,h5', { hasText: /^Paid for/i }) + .first() + .locator('..') + .locator('..') + + const percentageInputs = paidForSection + .locator('div', { hasText: '%' }) + .getByRole('textbox') + + await expect(percentageInputs).toHaveCount(3) + + await percentageInputs.nth(0).fill('25') // Alice + await percentageInputs.nth(1).fill('25') // Bob + await percentageInputs.nth(2).fill('50') // Charlie + + // Step 7: Submit expense + const saveButton = page.locator('button[type="submit"]').first() + await saveButton.click() + + // Wait for redirect back to group page + await page.waitForURL(/\/groups\/[^/]+/) + + // Step 8: Navigate to Expenses tab + await navigateToTab(page, 'Expenses') + + // Step 9: Verify expense appears + await expect(page.getByText('Team Dinner Percentage')).toBeVisible() + await expect(page.locator('text=300.00')).toBeVisible() + + // Step 10: Verify balances + await navigateToTab(page, 'Balances') + + // Wait for balances to load + await expect( + page + .locator('h2, h3, h4, h5') + .filter({ hasText: /balance/i }) + .first(), + ).toBeVisible() + + // Verify participants are shown + await expect(page.getByText(participantA).first()).toBeVisible() + await expect(page.getByText(participantB).first()).toBeVisible() + await expect(page.getByText(participantC).first()).toBeVisible() +}) + +test('Create expense - by amount split mode', async ({ page }) => { + const groupName = `by amount ${randomId(4)}` + const participantA = 'Alice' + const participantB = 'Bob' + const participantC = 'Charlie' + + // Step 1: Create group with 3 participants + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, [ + participantA, + participantB, + participantC, + ]) + await page.goto(`/groups/${groupId}/expenses`) + + // Step 2: Navigate to expense creation + const createLink = page + .getByRole('link', { name: /create expense|create the first/i }) + .first() + await createLink.waitFor({ state: 'visible' }) + await createLink.click() + + await page.waitForURL(/\/groups\/[^/]+\/expenses\/create/) + + const expenseTitle = page.locator('input[name="title"]') + await expenseTitle.waitFor({ state: 'visible' }) + + // Step 3: Fill title and total amount + await expenseTitle.fill('Team Dinner Amounts') + const amountInput = page.locator('input[name="amount"]') + await amountInput.fill('300.00') + + // Step 4: Select payer: Alice + const paidBySelect = page + .getByRole('combobox') + .filter({ hasText: 'Select a participant' }) + await paidBySelect.waitFor({ state: 'visible' }) + await paidBySelect.click() + await page.getByRole('option', { name: participantA }).click() + + // Step 5: Expand advanced options + await page + .getByRole('button', { name: 'Advanced splitting options…' }) + .click() + + // Step 6: Select split mode: By amount + const splitModeSelect = page + .getByRole('combobox') + .filter({ hasText: 'Evenly' }) + await splitModeSelect.click() + await page.getByRole('option', { name: 'Unevenly – By amount' }).click() + + // Step 7: Fill amounts - Alice: 50, Bob: 100, Charlie: 150 (sum to 300) + // In BY_AMOUNT mode the per-participant amount inputs live in the "Paid for" rows. + const paidForSection = page + .locator('h1,h2,h3,h4,h5', { hasText: /^Paid for/i }) + .first() + .locator('..') + .locator('..') + + const amountSplitInputs = paidForSection + .locator('div', { hasText: '$' }) + .getByRole('textbox') + + await expect(amountSplitInputs).toHaveCount(3) + + await amountSplitInputs.nth(0).fill('50.00') + await amountSplitInputs.nth(1).fill('100.00') + await amountSplitInputs.nth(2).fill('150.00') + + // Step 8: Submit expense + await page.locator('button[type="submit"]').first().click() + await page.waitForURL(/\/groups\/[^/]+/) + + // Step 9: Verify expense appears + await navigateToTab(page, 'Expenses') + await expect(page.getByText('Team Dinner Amounts')).toBeVisible() + await expect(page.locator('text=300.00')).toBeVisible() + + // Step 10: Verify balances page loads and shows participants + await navigateToTab(page, 'Balances') + + await expect( + page + .locator('h2, h3, h4, h5') + .filter({ hasText: /balance/i }) + .first(), + ).toBeVisible() + + await expect(page.getByText(participantA).first()).toBeVisible() + await expect(page.getByText(participantB).first()).toBeVisible() + await expect(page.getByText(participantC).first()).toBeVisible() +}) diff --git a/tests/e2e/expense-validation.spec.ts b/tests/e2e/expense-validation.spec.ts new file mode 100644 index 000000000..c58dd4fda --- /dev/null +++ b/tests/e2e/expense-validation.spec.ts @@ -0,0 +1,277 @@ +import { expect, test } from '@playwright/test' +import { navigateToExpenseCreate } from '../helpers' +import { createGroupViaAPI } from '../helpers/batch-api' +import { randomId } from '@/lib/api' + +test.describe('Expense Form Validation', () => { + test('prevents submission with empty title', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Validation Empty Title ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + await navigateToExpenseCreate(page) + + // Fill amount and payer, but leave title empty + await page.locator('input[name="amount"]').fill('50.00') + + const paidBySelect = page + .getByRole('combobox') + .filter({ hasText: 'Select a participant' }) + await paidBySelect.click() + await page.getByRole('option', { name: 'Alice' }).click() + + // Try to submit + await page.locator('button[type="submit"]').click() + + // Should still be on create page + await expect(page).toHaveURL(/\/groups\/[^/]+\/expenses\/create/) + + // Title field should have error indication + const titleInput = page.locator('input[name="title"]') + await expect(titleInput).toBeVisible() + }) + + test('prevents submission with missing payer', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Validation No Payer ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + await navigateToExpenseCreate(page) + + // Fill title and amount, but don't select payer + await page.locator('input[name="title"]').fill('Test Expense') + await page.locator('input[name="amount"]').fill('75.00') + + // Try to submit without selecting payer + await page.locator('button[type="submit"]').click() + + // Should still be on create page + await expect(page).toHaveURL(/\/groups\/[^/]+\/expenses\/create/) + }) + + test('prevents submission with zero amount', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Validation Zero Amount ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + await navigateToExpenseCreate(page) + + // Fill title, zero amount, and payer + await page.locator('input[name="title"]').fill('Zero Amount Test') + await page.locator('input[name="amount"]').fill('0') + + const paidBySelect = page + .getByRole('combobox') + .filter({ hasText: 'Select a participant' }) + await paidBySelect.click() + await page.getByRole('option', { name: 'Alice' }).click() + + // Try to submit + await page.locator('button[type="submit"]').click() + + // Should still be on create page (zero amount not allowed) + await expect(page).toHaveURL(/\/groups\/[^/]+\/expenses\/create/) + }) + + test('allows negative amount for income', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Validation Negative ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + await navigateToExpenseCreate(page) + + // Fill title first + await page.locator('input[name="title"]').fill('Income Entry') + + // Select payer BEFORE entering negative amount (form changes label when negative) + const paidBySelect = page + .getByRole('combobox') + .filter({ hasText: 'Select a participant' }) + await paidBySelect.click() + await page.getByRole('option', { name: 'Alice' }).click() + + // Now fill with negative amount (income) + await page.locator('input[name="amount"]').fill('-100.00') + + // Submit + await page.locator('button[type="submit"]').click() + + // Should navigate away (successful creation) + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Verify income entry created + await expect(page.getByText('Income Entry')).toBeVisible() + }) + + test('valid form submits successfully', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Validation Success ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + await navigateToExpenseCreate(page) + + const expenseTitle = 'Valid Expense' + const expenseAmount = '123.45' + + // Fill all required fields + await page.locator('input[name="title"]').fill(expenseTitle) + await page.locator('input[name="amount"]').fill(expenseAmount) + + const paidBySelect = page + .getByRole('combobox') + .filter({ hasText: 'Select a participant' }) + await paidBySelect.click() + await page.getByRole('option', { name: 'Alice' }).click() + + // Submit + await page.locator('button[type="submit"]').click() + + // Should navigate to expenses list + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Verify expense created + await expect(page.getByText(expenseTitle)).toBeVisible() + await expect(page.getByText('$123.45')).toBeVisible() + }) + + test('sanitizes amount input to valid decimal', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Validation Sanitize ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + await navigateToExpenseCreate(page) + + // Fill with non-numeric characters + const amountInput = page.locator('input[name="amount"]') + await page.locator('input[name="title"]').fill('Sanitize Test') + + // Type amount with extra characters + await amountInput.fill('50.99') + + // Verify the input shows the sanitized value + await expect(amountInput).toHaveValue('50.99') + + // Complete the form + const paidBySelect = page + .getByRole('combobox') + .filter({ hasText: 'Select a participant' }) + await paidBySelect.click() + await page.getByRole('option', { name: 'Alice' }).click() + + // Submit + await page.locator('button[type="submit"]').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Verify the correct amount was saved + await expect(page.getByText('$50.99')).toBeVisible() + }) + + test('validates date is not in invalid format', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Validation Date ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + await navigateToExpenseCreate(page) + + const expenseTitle = 'Date Validation Test' + + // Fill required fields + await page.locator('input[name="title"]').fill(expenseTitle) + await page.locator('input[name="amount"]').fill('45.00') + + const paidBySelect = page + .getByRole('combobox') + .filter({ hasText: 'Select a participant' }) + await paidBySelect.click() + await page.getByRole('option', { name: 'Alice' }).click() + + // Set a valid date + const dateInput = page.locator('input[type="date"]') + await dateInput.fill('2024-03-15') + + // Submit + await page.locator('button[type="submit"]').click() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Verify expense created + await expect(page.getByText(expenseTitle)).toBeVisible() + }) + + test('form shows error state visually', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Validation Visual ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + await navigateToExpenseCreate(page) + + // Try to submit completely empty form + await page.locator('button[type="submit"]').click() + + // Should remain on create page + await expect(page).toHaveURL(/\/groups\/[^/]+\/expenses\/create/) + + // Form should still be visible (not navigated away) + await expect(page.locator('input[name="title"]')).toBeVisible() + await expect(page.locator('input[name="amount"]')).toBeVisible() + }) + + test('whitespace-only title is invalid', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `Validation Whitespace ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + await navigateToExpenseCreate(page) + + // Fill with whitespace-only title + await page.locator('input[name="title"]').fill(' ') + await page.locator('input[name="amount"]').fill('50.00') + + const paidBySelect = page + .getByRole('combobox') + .filter({ hasText: 'Select a participant' }) + await paidBySelect.click() + await page.getByRole('option', { name: 'Alice' }).click() + + // Try to submit + await page.locator('button[type="submit"]').click() + + // Should still be on create page (whitespace-only title should be invalid) + await expect(page).toHaveURL(/\/groups\/[^/]+\/expenses\/create/) + }) +}) diff --git a/tests/e2e/export.spec.ts b/tests/e2e/export.spec.ts new file mode 100644 index 000000000..9db38b3fb --- /dev/null +++ b/tests/e2e/export.spec.ts @@ -0,0 +1,237 @@ +import { expect, test } from '@playwright/test' +import * as fs from 'fs' +import { createExpenseViaAPI, createGroupViaAPI } from '../helpers/batch-api' +import { randomId } from '@/lib/api' +import { browser } from 'process' + +interface ExpenseData { + title?: string + Description?: string + amount?: string + Cost?: string + paidBy?: string + date?: string + Date?: string + [key: string]: unknown +} + +test.describe('Export functionality', () => { + test('Export JSON download', async ({ page, browserName }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `export JSON ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await createExpenseViaAPI(page, groupId, { + title: 'Dinner', + amount: 5000, + payerName: 'Alice', + }) + + // Navigate to group page + await page.goto(`/groups/${groupId}/expenses`) + + await page + .getByRole('button', { name: /export/i }) + .first() + .click() + + const jsonOption = page.getByRole('menuitem', { name: /json/i }) + await expect(jsonOption).toBeVisible() + + if (browserName === 'webkit' && process.env.CI) { + // https://github.com/microsoft/playwright/issues/38585 + // Skip WebKit on CI due to download issues + return; + } + + const downloadPromise = page.waitForEvent('download') + await jsonOption.click() + const download = await downloadPromise + + expect(download.suggestedFilename()).toMatch(/\.json$/) + + const downloadPath = await download.path() + expect(downloadPath).toBeTruthy() + + const content = fs.readFileSync(downloadPath!, 'utf-8') + const data = JSON.parse(content) as Record + + const rawExpenses = data.expenses as ExpenseData[] | undefined + const expenses = Array.isArray(rawExpenses) ? rawExpenses : [] + expect(Array.isArray(expenses)).toBe(true) + expect(expenses.length).toBeGreaterThan(0) + + const dinnerExpense = expenses.find((e) => + String(e.title || e.Description || '').includes('Dinner'), + ) + expect(dinnerExpense).toBeDefined() + + const amount = String(dinnerExpense?.amount || dinnerExpense?.Cost || '') + expect(amount).toContain('50') + }) + + test('Export JSON content', async ({ page, browserName }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `export content ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await createExpenseViaAPI(page, groupId, { + title: 'Lunch', + amount: 2550, + payerName: 'Alice', + }) + await createExpenseViaAPI(page, groupId, { + title: 'Coffee', + amount: 500, + payerName: 'Bob', + }) + + // Navigate to group page + await page.goto(`/groups/${groupId}/expenses`) + + await page + .getByRole('button', { name: /export/i }) + .first() + .click() + + const jsonOption = page.getByRole('menuitem', { name: /json/i }) + await expect(jsonOption).toBeVisible() + + if (browserName === 'webkit' && process.env.CI) { + // https://github.com/microsoft/playwright/issues/38585 + // Skip WebKit on CI due to download issues + return; + } + + const downloadPromise = page.waitForEvent('download') + await jsonOption.click() + const download = await downloadPromise + + const downloadPath = await download.path() + expect(downloadPath).toBeTruthy() + + const content = fs.readFileSync(downloadPath!, 'utf-8') + const data = JSON.parse(content) as Record + + const rawExpenses = data.expenses as ExpenseData[] | undefined + const expenses = Array.isArray(rawExpenses) ? rawExpenses : [] + expect(Array.isArray(expenses)).toBe(true) + expect(expenses.length).toBe(2) + + const titles = expenses.map((e) => String(e.title || e.Description || '')) + expect(titles).toContain('Lunch') + expect(titles).toContain('Coffee') + }) + + test('Export CSV download', async ({ page, browserName }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `export CSV ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await createExpenseViaAPI(page, groupId, { + title: 'Groceries', + amount: 10000, + payerName: 'Bob', + }) + + // Navigate to group page + await page.goto(`/groups/${groupId}/expenses`) + + await page + .getByRole('button', { name: /export/i }) + .first() + .click() + + const csvOption = page.getByRole('menuitem', { name: /csv/i }) + await expect(csvOption).toBeVisible() + + if (browserName === 'webkit' && process.env.CI) { + // https://github.com/microsoft/playwright/issues/38585 + // Skip WebKit on CI due to download issues + return; + } + + const downloadPromise = page.waitForEvent('download') + await csvOption.click() + const download = await downloadPromise + + expect(download.suggestedFilename()).toMatch(/\.csv$/) + + const downloadPath = await download.path() + expect(downloadPath).toBeTruthy() + + const content = fs.readFileSync(downloadPath!, 'utf-8') + expect(content.length).toBeGreaterThan(0) + + const lines = content.trim().split('\n') + expect(lines.length).toBeGreaterThan(1) + + expect(lines[0]).toContain('Description') + expect(lines[0]).toContain('Cost') + }) + + test('Export CSV format', async ({ page, browserName }) => { + if (browserName === 'webkit' && process.env.CI) { + // https://github.com/microsoft/playwright/issues/38585 + // Skip WebKit on CI due to download issues + return; + } + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `CSV format ${randomId(4)}`, + ['Alice', 'Bob', 'Charlie'], + ) + + const expenseTitle = 'Weekend Trip' + await createExpenseViaAPI(page, groupId, { + title: expenseTitle, + amount: 30000, + payerName: 'Alice', + }) + + // Navigate to group page + await page.goto(`/groups/${groupId}/expenses`) + + await page + .getByRole('button', { name: /export/i }) + .first() + .click() + + const csvOption = page.getByRole('menuitem', { name: /csv/i }) + await expect(csvOption).toBeVisible() + + if (browserName === 'webkit' && process.env.CI) { + // https://github.com/microsoft/playwright/issues/38585 + // Skip WebKit on CI due to download issues + return; + } + + const downloadPromise = page.waitForEvent('download') + await csvOption.click() + const download = await downloadPromise + + const downloadPath = await download.path() + expect(downloadPath).toBeTruthy() + + const content = fs.readFileSync(downloadPath!, 'utf-8') + const lines = content.trim().split('\n') + + expect(lines[0]).toContain('Description') + expect(lines[0]).toContain('Cost') + expect(lines[0]).toContain('Date') + + const dataRow = lines.find((line) => line.includes(expenseTitle)) + expect(dataRow).toBeDefined() + expect(dataRow).toContain('300') + }) +}) diff --git a/tests/e2e/group-creation.spec.ts b/tests/e2e/group-creation.spec.ts new file mode 100644 index 000000000..2430e551c --- /dev/null +++ b/tests/e2e/group-creation.spec.ts @@ -0,0 +1,147 @@ +import { expect, test } from '@playwright/test' +import { fillParticipants, verifyGroupHeading } from '../helpers' +import { randomId } from '@/lib/api' + +test.describe('Group Creation', () => { + test('create group with custom currency', async ({ page }) => { + const groupName = `custom currency ${randomId(4)}` + + await page.goto('/groups') + await page.getByRole('link', { name: 'Create' }).first().click() + + // Verify we're on the creation page + await expect(page).toHaveURL('/groups/create') + + await page.getByLabel('Group name').fill(groupName) + + // Select "Custom" currency (empty code) + await page.locator('[role="combobox"]').first().click() + await page.getByRole('option', { name: 'Custom' }).click() + + // Verify currency symbol input is visible and fill it + const currencySymbolInput = page.getByLabel('Currency symbol') + await expect(currencySymbolInput).toBeVisible() + await currencySymbolInput.fill('$') + + // Verify the currency symbol has the expected value + await expect(currencySymbolInput).toHaveValue('$') + + await fillParticipants(page, ['Alice', 'Bob', 'Charlie']) + + await page.getByRole('button', { name: 'Create' }).click() + + // Verify redirect to group expenses page with correct URL pattern + await expect(page).toHaveURL(/\/groups\/[a-zA-Z0-9_-]+\/expenses$/) + + // Verify group was created with the correct name + await verifyGroupHeading(page, groupName) + + // Verify the expenses tab is active + await expect(page.getByRole('tab', { name: 'Expenses' })).toHaveAttribute( + 'aria-selected', + 'true', + ) + }) + + test('validate group creation form', async ({ page }) => { + await page.goto('/groups') + await page.getByRole('link', { name: 'Create' }).first().click() + + // Test: Submit empty form should show validation errors + await page.getByRole('button', { name: 'Create' }).click() + + // Verify group name validation (requires min 2 characters) + const groupNameErrors = page.getByText('Enter at least two characters.') + await expect(groupNameErrors.first()).toBeVisible() + + // Test: Group name with 1 character should fail + await page.getByLabel('Group name').fill('A') + await page.getByRole('button', { name: 'Create' }).click() + await expect(groupNameErrors.first()).toBeVisible() + + // Test: Valid group name with 2 characters should pass name validation + const validName = `validation ${randomId(4)}` + await page.getByLabel('Group name').fill(validName) + + // Test: Participant name with 1 character should fail + const participantInputs = page.getByRole('textbox', { name: 'New' }) + await expect(participantInputs).toHaveCount(3) + + await participantInputs.nth(0).fill('A') + await page.getByRole('button', { name: 'Create' }).click() + + // Verify participant name validation + await expect( + page.getByText('Enter at least two characters.').first(), + ).toBeVisible() + + // Test: Duplicate participant names should fail + await participantInputs.nth(0).fill('Alice') + await participantInputs.nth(1).fill('Alice') + await participantInputs.nth(2).fill('Bob') + + await page.getByRole('button', { name: 'Create' }).click() + + // Verify duplicate name error + const duplicateError = page.getByText( + 'Another participant already has this name.', + ) + await expect(duplicateError.first()).toBeVisible() + + // Test: Valid form should succeed + await participantInputs.nth(0).fill('Alice') + await participantInputs.nth(1).fill('Bob') + await participantInputs.nth(2).fill('Charlie') + + await page.getByRole('button', { name: 'Create' }).click() + + // Verify successful creation + await expect(page).toHaveURL(/\/groups\/[a-zA-Z0-9_-]+\/expenses$/) + await verifyGroupHeading(page, validName) + }) + + test('create group with default currency', async ({ page }) => { + const groupName = `default currency ${randomId(4)}` + + await page.goto('/groups') + await page.getByRole('link', { name: 'Create' }).first().click() + + await page.getByLabel('Group name').fill(groupName) + + // Verify default currency is pre-selected (should be EUR or USD typically) + const currencyCombobox = page.locator('[role="combobox"]').first() + const currencyText = await currencyCombobox.textContent() + expect(currencyText).toBeTruthy() + expect(currencyText?.length).toBeGreaterThan(0) + + await fillParticipants(page, ['Alice', 'Bob']) + + await page.getByRole('button', { name: 'Create' }).click() + + // Verify successful creation + await expect(page).toHaveURL(/\/groups\/[a-zA-Z0-9_-]+\/expenses$/) + await verifyGroupHeading(page, groupName) + }) + + test('create group with many participants', async ({ page }) => { + const groupName = `many participants ${randomId(4)}` + + await page.goto('/groups') + await page.getByRole('link', { name: 'Create' }).first().click() + + await page.getByLabel('Group name').fill(groupName) + + // Add 5 participants by using the add button + const participants = ['Alice', 'Bob', 'Charlie', 'Dave', 'Eve'] + await fillParticipants(page, participants) + + // Verify we have 5 participant inputs + const participantInputs = page.getByRole('textbox', { name: 'New' }) + await expect(participantInputs).toHaveCount(5) + + await page.getByRole('button', { name: 'Create' }).click() + + // Verify successful creation + await verifyGroupHeading(page, groupName) + }) +}) diff --git a/tests/e2e/group-editing.spec.ts b/tests/e2e/group-editing.spec.ts new file mode 100644 index 000000000..89e80eed8 --- /dev/null +++ b/tests/e2e/group-editing.spec.ts @@ -0,0 +1,281 @@ +import { expect, test } from '@playwright/test' +import { + clickSave, + countProtectedParticipants, + getParticipantNames, + navigateToTab, + removeParticipant, + verifyGroupHeading, + verifyParticipantsOnBalancesTab, +} from '../helpers' +import { createExpenseViaAPI, createGroupViaAPI } from '../helpers/batch-api' +import { randomId } from '@/lib/api' + +test.describe('Group Editing', () => { + test('update group name and information', async ({ page }) => { + const initialGroupName = `edit ${randomId(4)}` + const newGroupName = `Renamed ${randomId(4)}` + const newGroupInfo = `Updated info ${randomId(4)}` + + // Setup via API + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, initialGroupName, [ + 'Alice', + 'Bob', + 'Charlie', + ]) + + await page.goto(`/groups/${groupId}/expenses`) + await navigateToTab(page, 'Settings') + + // Verify initial values in the form + const groupNameInput = page.getByLabel('Group name') + await expect(groupNameInput).toHaveValue(initialGroupName) + + // Update group name + await groupNameInput.clear() + await groupNameInput.fill(newGroupName) + await expect(groupNameInput).toHaveValue(newGroupName) + + // Update group information + const groupInfoInput = page.getByLabel('Group information') + await groupInfoInput.fill(newGroupInfo) + await expect(groupInfoInput).toHaveValue(newGroupInfo) + + // Save changes + await clickSave(page) + + // Navigate to Information tab to verify changes persisted + await navigateToTab(page, 'Information') + + // Verify updated name in heading + await verifyGroupHeading(page, newGroupName) + + // Verify updated information text is visible + await expect(page.getByText(newGroupInfo, { exact: true })).toBeVisible() + + // Verify group name is also updated in the Information section + await expect(page.getByText(newGroupName, { exact: true })).toBeVisible() + }) + + test('add participant to existing group', async ({ page }) => { + const groupName = `add participant ${randomId(4)}` + const initialParticipants = ['Alice', 'Bob', 'Charlie'] + const newParticipant = 'Dave' + + // Setup via API + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + groupName, + initialParticipants, + ) + + await page.goto(`/groups/${groupId}/expenses`) + await navigateToTab(page, 'Settings') + + // Verify initial participant count + const participantInputs = page.getByRole('textbox', { name: 'New' }) + await expect(participantInputs).toHaveCount(initialParticipants.length) + + // Verify initial participant names + const initialNames = await getParticipantNames(page) + expect(initialNames).toEqual(initialParticipants) + + // Add new participant + await page.getByRole('button', { name: 'Add participant' }).click() + await expect(participantInputs).toHaveCount(initialParticipants.length + 1) + + // Fill new participant name + const newParticipantInput = participantInputs.nth( + initialParticipants.length, + ) + await newParticipantInput.fill(newParticipant) + await expect(newParticipantInput).toHaveValue(newParticipant) + + // Save changes + await clickSave(page) + + // Verify new participant appears in Balances tab + await navigateToTab(page, 'Balances') + await verifyParticipantsOnBalancesTab(page, [ + ...initialParticipants, + newParticipant, + ]) + + // Verify participant count in settings + await navigateToTab(page, 'Settings') + const updatedNames = await getParticipantNames(page) + expect(updatedNames).toEqual(expect.arrayContaining([...initialParticipants, newParticipant])) + }) + + test('remove unprotected participant', async ({ page }) => { + const groupName = `remove participant ${randomId(4)}` + const participants = ['Alice', 'Bob', 'Charlie', 'Dave'] + const participantToRemove = 'Dave' + + // Setup via API + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, participants) + + // Create an expense with Alice as payer, excluding Dave from the split + // This protects Alice, Bob, and Charlie (they're involved in the expense) + // but leaves Dave unprotected (not involved in the expense) + await createExpenseViaAPI(page, groupId, { + title: 'Protection seed', + amount: 1000, // 10.00 in cents + payerName: 'Alice', + excludeParticipants: ['Dave'], // Exclude Dave from the expense split + }) + + await page.goto(`/groups/${groupId}/expenses`) + await navigateToTab(page, 'Settings') + + // Verify all 4 participants are present + await expect(page.getByRole('textbox', { name: 'New' })).toHaveCount(4) + + // Verify 3 protected participants (Alice, Bob, Charlie) + const protectedCount = await countProtectedParticipants(page) + expect(protectedCount).toBe(3) + + // Remove Dave (unprotected participant) + const removed = await removeParticipant(page, participantToRemove) + expect(removed).toBe(true) + + // Save changes + await clickSave(page) + + // Verify Dave is removed from Balances tab + await navigateToTab(page, 'Balances') + await expect(page.getByText('Dave', { exact: true })).not.toBeVisible() + + // Verify only 3 participants remain + await verifyParticipantsOnBalancesTab(page, ['Alice', 'Bob', 'Charlie']) + + // Verify in settings that only 3 participants exist + await navigateToTab(page, 'Settings') + const remainingNames = await getParticipantNames(page) + expect(remainingNames).toEqual(['Alice', 'Bob', 'Charlie']) + }) + + test('cannot remove protected participant', async ({ page }) => { + const groupName = `protected participant ${randomId(4)}` + + // Setup via API + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, ['Alice', 'Bob']) + + // Create an expense with Alice as payer, excluding Bob from the split + // This makes only Alice protected (she's the payer and sole participant in the expense) + await createExpenseViaAPI(page, groupId, { + title: 'Protection expense', + amount: 2500, // 25.00 in cents + payerName: 'Alice', + excludeParticipants: ['Bob'], // Exclude Bob from the expense split + }) + + await page.goto(`/groups/${groupId}/expenses`) + await navigateToTab(page, 'Settings') + + // Verify Alice's remove button is disabled (she's protected) + const aliceInput = page.locator('input[value="Alice"]') + await expect(aliceInput).toBeVisible() + + const aliceContainer = aliceInput.locator( + 'xpath=ancestor::div[contains(@class,"flex")][1]', + ) + const aliceRemoveButton = aliceContainer + .locator('button svg.lucide-trash-2') + .first() + const disabledButton = aliceRemoveButton.locator( + 'xpath=ancestor::button[@disabled]', + ) + + // Verify the remove button is disabled + await expect(disabledButton).toBeVisible() + + // Verify only Alice is protected (Bob can be removed) + const protectedCount = await countProtectedParticipants(page) + expect(protectedCount).toBe(1) + + // Verify Bob's remove button is enabled + const bobInput = page.locator('input[value="Bob"]') + await expect(bobInput).toBeVisible() + const bobContainer = bobInput.locator( + 'xpath=ancestor::div[contains(@class,"flex")][1]', + ) + const bobRemoveButton = bobContainer + .locator('button:not([disabled])') + .first() + await expect(bobRemoveButton).toBeVisible() + }) + + test('edit group with empty information field', async ({ page }) => { + const groupName = `empty info ${randomId(4)}` + + // Setup via API + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, ['Alice', 'Bob']) + + await page.goto(`/groups/${groupId}/expenses`) + await navigateToTab(page, 'Settings') + + // Verify information field is initially empty + const groupInfoInput = page.getByLabel('Group information') + await expect(groupInfoInput).toHaveValue('') + + // Add some information + const testInfo = 'Test information' + await groupInfoInput.fill(testInfo) + await clickSave(page); + + // Verify information appears + await navigateToTab(page, 'Information') + await expect(page.getByText(testInfo, { exact: true })).toBeVisible() + + // Now remove the information + await navigateToTab(page, 'Settings') + await groupInfoInput.clear() + await expect(groupInfoInput).toHaveValue('') + await clickSave(page); + + // Verify information is cleared + await navigateToTab(page, 'Information') + await expect(page.getByText(testInfo, { exact: true })).not.toBeVisible() + }) + + test('cannot create duplicate participant names when editing', async ({ + page, + }) => { + const groupName = `duplicate edit ${randomId(4)}` + + // Setup via API + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, [ + 'Alice', + 'Bob', + 'Charlie', + ]) + + await page.goto(`/groups/${groupId}/expenses`) + await navigateToTab(page, 'Settings') + + // Try to rename Bob to Alice (duplicate) + const participantInputs = page.getByRole('textbox', { name: 'New' }) + const bobInput = participantInputs.nth(1) + + await bobInput.clear() + await bobInput.fill('Alice') + + // Save changes + await page.getByRole('button', { name: 'Save' }).click(); + + // Verify duplicate error message appears + await expect( + page.getByText('Another participant already has this name.'), + ).toBeVisible() + + // Verify we're still on settings page (save failed) + await expect(page).toHaveURL(/\/groups\/[^/]+\/edit$/) + }) +}) diff --git a/tests/e2e/group-navigation.spec.ts b/tests/e2e/group-navigation.spec.ts new file mode 100644 index 000000000..6bf108fbd --- /dev/null +++ b/tests/e2e/group-navigation.spec.ts @@ -0,0 +1,242 @@ +import { expect, test } from '@playwright/test' +import { navigateToTab, verifyGroupHeading } from '../helpers' +import { createGroupViaAPI } from '../helpers/batch-api' +import { randomId } from '@/lib/api' + +test.describe('Group Navigation', () => { + test('navigate between multiple groups', async ({ page }) => { + const groupName1 = `navigate 1 ${randomId(4)}` + const groupName2 = `navigate 2 ${randomId(4)}` + + // Create first group + await page.goto('/groups') + const groupId1 = await createGroupViaAPI(page, groupName1, ['Alice', 'Bob']) + await page.goto(`/groups/${groupId1}/expenses`) + + // Verify we're on group 1 + await expect(page).toHaveURL(new RegExp(`/groups/${groupId1}/expenses$`)) + await verifyGroupHeading(page, groupName1) + + // Create second group + await page.goto('/groups') + const groupId2 = await createGroupViaAPI(page, groupName2, [ + 'Charlie', + 'Dave', + ]) + await page.goto(`/groups/${groupId2}/expenses`) + + // Verify we're on group 2 + await expect(page).toHaveURL(new RegExp(`/groups/${groupId2}/expenses$`)) + await verifyGroupHeading(page, groupName2) + + // Navigate to groups list + await page.goto('/groups') + + // Verify both groups appear in the list + const group1Link = page.getByText(groupName1) + const group2Link = page.getByText(groupName2) + await expect(group1Link).toBeVisible() + await expect(group2Link).toBeVisible() + + // Navigate to first group + await group1Link.click() + await expect(page).toHaveURL(new RegExp(`/groups/${groupId1}`)) + await verifyGroupHeading(page, groupName1) + + // Verify we can see group 1 participants in a tab + await navigateToTab(page, 'Balances') + await expect(page.getByText('Alice', { exact: true })).toBeVisible() + await expect(page.getByText('Bob', { exact: true })).toBeVisible() + + // Navigate back to groups list + await page.goto('/groups') + await expect(page).toHaveURL('/groups') + + // Navigate to second group + await page.getByText(groupName2).click() + await expect(page).toHaveURL(new RegExp(`/groups/${groupId2}`)) + await verifyGroupHeading(page, groupName2) + + // Verify we can see group 2 participants + await navigateToTab(page, 'Balances') + await expect(page.getByText('Charlie', { exact: true })).toBeVisible() + await expect(page.getByText('Dave', { exact: true })).toBeVisible() + }) + + test('recent groups persistence across page reloads', async ({ page }) => { + const groupName = `recent ${randomId(4)}` + + // Create a group + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, ['Alice', 'Bob']) + await page.goto(`/groups/${groupId}/expenses`) + + // Verify group was created + await expect(page).toHaveURL(new RegExp(`/groups/${groupId}/expenses$`)) + + // Navigate to groups list + await page.goto('/groups') + await expect(page).toHaveURL('/groups') + + // Verify group appears in recent list + const groupLink = page.getByText(groupName) + await expect(groupLink).toBeVisible() + + // Reload the page to test persistence + await page.reload() + await expect(page).toHaveURL('/groups') + + // Verify group still appears after reload + await expect(page.getByText(groupName)).toBeVisible() + + // Navigate to the group via the link + await page.getByText(groupName).click() + await expect(page).toHaveURL(new RegExp(`/groups/${groupId}`)) + await verifyGroupHeading(page, groupName) + }) + + test('navigate to group information tab', async ({ page }) => { + const groupName = `info tab ${randomId(4)}` + + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, [ + 'Alice', + 'Bob', + 'Charlie', + ]) + await page.goto(`/groups/${groupId}/expenses`) + + // Navigate to Information tab + await navigateToTab(page, 'Information') + + // Verify URL changed + await expect(page).toHaveURL(/\/groups\/[^/]+\/information$/) + + // Verify group name in heading + await verifyGroupHeading(page, groupName) + + // Verify all tabs are visible + await expect(page.getByRole('tab', { name: 'Balances' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'Expenses' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'Settings' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'Stats' })).toBeVisible() + }) + + test('navigate between all group tabs', async ({ page }) => { + const groupName = `all tabs ${randomId(4)}` + + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, ['Alice', 'Bob']) + await page.goto(`/groups/${groupId}/expenses`) + + const tabs: Array<{ + name: 'Expenses' | 'Balances' | 'Stats' | 'Settings' | 'Information' + urlPattern: RegExp + }> = [ + { name: 'Expenses', urlPattern: /\/expenses$/ }, + { name: 'Balances', urlPattern: /\/balances$/ }, + { name: 'Stats', urlPattern: /\/stats$/ }, + { name: 'Information', urlPattern: /\/information$/ }, + { name: 'Settings', urlPattern: /\/edit$/ }, + ] + + // Navigate through each tab and verify + for (const tab of tabs) { + await navigateToTab(page, tab.name) + + // Verify URL + await expect(page).toHaveURL(tab.urlPattern) + + // Verify tab is selected + await expect(page.getByRole('tab', { name: tab.name })).toHaveAttribute( + 'aria-selected', + 'true', + ) + + // Verify group name is still visible in heading + await verifyGroupHeading(page, groupName) + } + }) + + test('direct URL navigation to group tabs', async ({ page }) => { + const groupName = `direct URL ${randomId(4)}` + + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, ['Alice', 'Bob']) + + // Test direct navigation to each tab + const tabUrls = [ + `/groups/${groupId}/expenses`, + `/groups/${groupId}/balances`, + `/groups/${groupId}/stats`, + `/groups/${groupId}/information`, + `/groups/${groupId}/edit`, + ] + + for (const url of tabUrls) { + await page.goto(url) + await expect(page).toHaveURL(url) + await verifyGroupHeading(page, groupName) + + // Verify we can interact with the page (page is fully loaded) + const tabs = page.getByRole('tab') + await expect(tabs.first()).toBeVisible() + } + }) + + test('browser back button navigation', async ({ page }) => { + const groupName = `back button ${randomId(4)}` + + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, ['Alice', 'Bob']) + await page.goto(`/groups/${groupId}/expenses`) + + // Navigate through tabs: Expenses -> Balances -> Settings + await navigateToTab(page, 'Balances') + await expect(page).toHaveURL(/\/balances$/) + + await navigateToTab(page, 'Settings') + await expect(page).toHaveURL(/\/edit$/) + + // Use browser back button + await page.goBack() + await expect(page).toHaveURL(/\/balances$/) + await verifyGroupHeading(page, groupName) + + // Back again + await page.goBack() + await expect(page).toHaveURL(/\/expenses$/) + await verifyGroupHeading(page, groupName) + + // Forward navigation + await page.goForward() + await expect(page).toHaveURL(/\/balances$/) + await verifyGroupHeading(page, groupName) + }) + + test('group list shows multiple recent groups in order', async ({ page }) => { + await page.goto('/groups') + + const groupNames = [ + `recent 1 ${randomId(4)}-1`, + `recent 2 ${randomId(4)}-2`, + `recent 3 ${randomId(4)}-3`, + ] + + const groupIds: string[] = [] + + // Create multiple groups + for (const groupName of groupNames) { + const groupId = await createGroupViaAPI(page, groupName, ['Alice', 'Bob']) + groupIds.push(groupId) + } + + // Reload the groups list page + await page.reload(); + + // Verify all groups are visible + for (const groupName of groupNames) { + await expect(page.getByRole('link', { name: groupName })).toBeVisible() + } + }) +}) diff --git a/tests/e2e/group-sharing.spec.ts b/tests/e2e/group-sharing.spec.ts new file mode 100644 index 000000000..42a79c821 --- /dev/null +++ b/tests/e2e/group-sharing.spec.ts @@ -0,0 +1,212 @@ +import { expect, test } from '@playwright/test' +import { extractGroupId, verifyGroupHeading } from '../helpers' +import { createGroupViaAPI } from '../helpers/batch-api' +import { randomId } from '@/lib/api' + +test.describe('Group Sharing', () => { + test('share group via copy URL button', async ({ page, context }) => { + const groupName = `share ${randomId(4)}` + + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, [ + 'Alice', + 'Bob', + 'Charlie', + ]) + + await page.goto(`/groups/${groupId}/expenses`) + + // Grant clipboard permissions if supported + try { + await context.grantPermissions(['clipboard-read', 'clipboard-write'], { + origin: 'http://localhost:3000', + }) + } catch { + // Not all browsers support clipboard permissions; continue anyway + } + + // Verify we're on the expenses tab + await expect(page).toHaveURL(/\/groups\/[^/]+\/expenses$/) + + // Verify group ID is valid + expect(groupId).toBeTruthy() + expect(groupId).not.toBe('create') + + // Click share button + const shareButton = page.locator('button[title="Share"]') + await expect(shareButton).toBeVisible() + await shareButton.click() + + // Verify we're still on the same page (share opens popover) + await verifyGroupHeading(page, groupName) + await expect(page).toHaveURL(/\/groups\/[^/]+\/expenses$/) + + // Find and click the copy button (has lucide-copy icon) + const copyButton = page + .getByRole('button') + .filter({ has: page.locator('svg.lucide-copy') }) + .first() + + await expect(copyButton).toBeVisible() + await copyButton.click() + + // Verify copy success by checking for the check icon + const checkIcon = page.locator('svg.lucide-check').first() + await expect(checkIcon).toBeVisible() + + // Try to verify clipboard contents if browser supports it + try { + const clipboardText = await page.evaluate(() => + navigator.clipboard.readText(), + ) + + // Verify clipboard contains the correct share URL + const expectedUrlPart = `/groups/${groupId}/expenses?ref=share` + expect(clipboardText).toContain(expectedUrlPart) + + // Verify it's a full URL + expect(clipboardText).toMatch(/^https?:\/\//) + } catch { + // Some browsers (webkit/mobile) deny clipboard reads in automation + // The check icon is sufficient evidence that copy succeeded + console.log('Clipboard read not supported, relying on check icon') + } + }) + + test('share URL includes ref parameter', async ({ page, context }) => { + const groupName = `share ref ${randomId(4)}` + + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, ['Alice', 'Bob']) + + await page.goto(`/groups/${groupId}/expenses`) + + try { + await context.grantPermissions(['clipboard-read', 'clipboard-write'], { + origin: 'http://localhost:3000', + }) + } catch { + // Continue without clipboard permissions + } + + // Open share popover + await page.locator('button[title="Share"]').click() + + // Copy the URL + const copyButton = page + .getByRole('button') + .filter({ has: page.locator('svg.lucide-copy') }) + .first() + await copyButton.click() + + // Wait for check icon + await expect(page.locator('svg.lucide-check').first()).toBeVisible() + + // Verify ref parameter is in the URL + try { + const clipboardText = await page.evaluate(() => + navigator.clipboard.readText(), + ) + expect(clipboardText).toContain('?ref=share') + } catch { + // If clipboard read fails, at least verify the share button worked + console.log('Clipboard verification skipped') + } + }) + + test('shared URL navigation works correctly', async ({ page, context }) => { + const groupName = `share navigation ${randomId(4)}` + + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, ['Alice', 'Bob']) + + await page.goto(`/groups/${groupId}/expenses`) + + const currentUrl = page.url() + const extractedGroupId = extractGroupId(currentUrl) + + // Simulate navigating via a shared URL + const shareUrl = `${page.url()}?ref=share` + await page.goto(shareUrl) + + // Verify we land on the group page + await expect(page).toHaveURL(/\/groups\/[^/]+\/expenses\?ref=share$/) + await verifyGroupHeading(page, groupName) + + // Verify group is functional + await expect(page.getByRole('tab', { name: 'Expenses' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'Balances' })).toBeVisible() + + // Verify participants are accessible + const createExpenseLink = page.getByRole('link', { name: 'Create expense' }) + await expect(createExpenseLink).toBeVisible() + }) + + test('share button is accessible on group page', async ({ page }) => { + const groupName = `share accessible ${randomId(4)}` + + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, ['Alice', 'Bob']) + + await page.goto(`/groups/${groupId}/expenses`) + + // Verify share button is visible and has correct attributes + const shareButton = page.locator('button[title="Share"]') + await expect(shareButton).toBeVisible() + + // Verify button is enabled (not disabled) + await expect(shareButton).toBeEnabled() + + // Click to verify it works + await shareButton.click() + + // Verify popover/dialog appears with share content + // Look for copy button or share URL display + const copyButton = page + .getByRole('button') + .filter({ has: page.locator('svg.lucide-copy') }) + .first() + await expect(copyButton).toBeVisible() + }) + + test('copy feedback changes icon from copy to check', async ({ + page, + context, + }) => { + const groupName = `copy feedback ${randomId(4)}` + + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, ['Alice', 'Bob']) + + await page.goto(`/groups/${groupId}/expenses`) + + try { + await context.grantPermissions(['clipboard-read', 'clipboard-write'], { + origin: 'http://localhost:3000', + }) + } catch { + // Continue without permissions + } + + // Open share popover + await page.locator('button[title="Share"]').click() + + // Verify copy icon is initially visible + const copyIcon = page.locator('svg.lucide-copy').first() + await expect(copyIcon).toBeVisible() + + // Click copy button + const copyButton = page + .getByRole('button') + .filter({ has: page.locator('svg.lucide-copy') }) + .first() + await copyButton.click() + + // Verify check icon appears (indicating success) + const checkIcon = page.locator('svg.lucide-check').first() + await expect(checkIcon).toBeVisible() + + // Verify copy icon is no longer visible (replaced by check) + await expect(copyIcon).not.toBeVisible() + }) +}) diff --git a/tests/e2e/health.spec.ts b/tests/e2e/health.spec.ts new file mode 100644 index 000000000..ebf353901 --- /dev/null +++ b/tests/e2e/health.spec.ts @@ -0,0 +1,17 @@ +import { expect, test } from '@playwright/test' + +test('/api/health/liveness returns 200', async ({ page }) => { + const response = await page.request.get('/api/health/liveness') + expect(response.status()).toBe(200) + + const body = await response.json() + expect(body).toBeTruthy() +}) + +test('/api/health/readiness checks DB', async ({ page }) => { + const response = await page.request.get('/api/health/readiness') + expect(response.status()).toBe(200) + + const body = await response.json() + expect(body).toBeTruthy() +}) diff --git a/tests/e2e/recurring-expense-creation.spec.ts b/tests/e2e/recurring-expense-creation.spec.ts new file mode 100644 index 000000000..bb135f9a8 --- /dev/null +++ b/tests/e2e/recurring-expense-creation.spec.ts @@ -0,0 +1,85 @@ +import { expect, test } from '@playwright/test' +import { + createExpense, + openExpenseForEdit, + verifyExpenseRecurrence, +} from '../helpers/expense' +import { createGroup, navigateToGroup } from '../helpers' +import { randomId } from '@/lib/api' + +test.describe('Recurring Expense Creation', () => { + test('Create daily recurring expense', async ({ page }) => { + const groupId = await createGroup({ + page, + groupName: `daily recurring ${randomId(4)}`, + participants: ['Alice', 'Bob'], + }) + + const expenseTitle = `Daily Recurring ${randomId(4)}` + + await createExpense(page, { + title: expenseTitle, + amount: '25.00', + payer: 'Alice', + recurrence: 'Daily', + }) + + // Verify expense was created and is visible + await navigateToGroup(page, groupId) + await expect(page.getByText(expenseTitle)).toBeVisible() + + // Verify recurrence is set correctly in the edit form + await openExpenseForEdit(page, expenseTitle) + await verifyExpenseRecurrence(page, 'Daily') + }) + + test('Create weekly recurring expense', async ({ page }) => { + const groupId = await createGroup({ + page, + groupName: `weekly recurring ${randomId(4)}`, + participants: ['Alice', 'Bob'], + }) + + const expenseTitle = `Weekly Recurring ${randomId(4)}` + + await createExpense(page, { + title: expenseTitle, + amount: '50.00', + payer: 'Bob', + recurrence: 'Weekly', + }) + + // Verify expense was created and is visible + await navigateToGroup(page, groupId) + await expect(page.getByText(expenseTitle)).toBeVisible() + + // Verify recurrence is set correctly in the edit form + await openExpenseForEdit(page, expenseTitle) + await verifyExpenseRecurrence(page, 'Weekly') + }) + + test('Create monthly recurring expense', async ({ page }) => { + const groupId = await createGroup({ + page, + groupName: `monthly recurring ${randomId(4)}`, + participants: ['Alice', 'Bob', 'Charlie'], + }) + + const expenseTitle = `Monthly Recurring ${randomId(4)}` + + await createExpense(page, { + title: expenseTitle, + amount: '100.00', + payer: 'Charlie', + recurrence: 'Monthly', + }) + + // Verify expense was created and is visible + await navigateToGroup(page, groupId) + await expect(page.getByText(expenseTitle)).toBeVisible() + + // Verify recurrence is set correctly in the edit form + await openExpenseForEdit(page, expenseTitle) + await verifyExpenseRecurrence(page, 'Monthly') + }) +}) diff --git a/tests/e2e/recurring-expense-deletion.spec.ts b/tests/e2e/recurring-expense-deletion.spec.ts new file mode 100644 index 000000000..f95bd2602 --- /dev/null +++ b/tests/e2e/recurring-expense-deletion.spec.ts @@ -0,0 +1,153 @@ +import { expect, test } from '@playwright/test' +import { createExpenseViaAPI, createGroupViaAPI } from '../helpers/batch-api' +import { navigateToGroup } from '../helpers' +import { randomId } from '@/lib/api' + +test.describe('Recurring Expense Deletion', () => { + test('Delete single expense - other expenses remain', async ({ page }) => { + const expenseTitle1 = `Expense 1 ${randomId(4)}-1` + const expenseTitle2 = `Expense 2 ${randomId(4)}-2` + + // Setup via API + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, 'Test Group', [ + 'Alice', + 'Bob', + ]) + await createExpenseViaAPI(page, groupId, { + title: expenseTitle1, + amount: 2500, // 25.00 in cents + payerName: 'Alice', + }) + await createExpenseViaAPI(page, groupId, { + title: expenseTitle2, + amount: 3000, // 30.00 in cents + payerName: 'Bob', + }) + + // Navigate to group and verify both expenses are visible + await navigateToGroup(page, groupId) + await expect(page.getByText(expenseTitle1)).toBeVisible() + await expect(page.getByText(expenseTitle2)).toBeVisible() + + // Click on first expense to edit + await page.getByText(expenseTitle1).first().click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + // Delete the first expense + const deleteButton = page.getByRole('button', { name: /delete/i }) + await expect(deleteButton).toBeVisible() + await deleteButton.click() + + // Confirm deletion in dialog + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + + const confirmDeleteButton = dialog.getByRole('button', { name: /yes/i }) + await expect(confirmDeleteButton).toBeVisible() + await confirmDeleteButton.click() + + // Wait for navigation back to expense list + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Verify first expense is deleted and second remains + await expect(page.getByText(expenseTitle1)).not.toBeVisible() + await expect(page.getByText(expenseTitle2)).toBeVisible() + }) + + test('Delete recurring expense instance - others remain', async ({ + page, + }) => { + const recurringTitle = `Recurring Expense ${randomId(4)}` + const regularTitle = `Regular Expense ${randomId(4)}` + + // Setup via API + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, 'Test Group', [ + 'Alice', + 'Bob', + ]) + + // Create a recurring expense via API with recurrence support + await createExpenseViaAPI(page, groupId, { + title: recurringTitle, + amount: 5000, // 50.00 in cents + payerName: 'Alice', + recurrenceRule: 'DAILY', + }) + + // Create regular expense via API + await createExpenseViaAPI(page, groupId, { + title: regularTitle, + amount: 2500, // 25.00 in cents + payerName: 'Bob', + }) + + // Verify both expenses exist + navigateToGroup(page, groupId) + await expect(page.getByText(recurringTitle)).toBeVisible() + await expect(page.getByText(regularTitle)).toBeVisible() + + // Delete the recurring expense + await page.getByText(recurringTitle).first().click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + const deleteButton = page.getByRole('button', { name: /delete/i }) + await deleteButton.click() + + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + + const confirmDeleteButton = dialog.getByRole('button', { name: /yes/i }) + await confirmDeleteButton.click() + + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Verify recurring expense is deleted but regular expense remains + await expect(page.getByText(recurringTitle)).not.toBeVisible() + await expect(page.getByText(regularTitle)).toBeVisible() + }) + + test('Cancel deletion dialog - expense remains', async ({ page }) => { + const expenseTitle = `Expense ${randomId(4)}` + + // Setup via API + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, 'Test Group', [ + 'Alice', + 'Bob', + ]) + await createExpenseViaAPI(page, groupId, { + title: expenseTitle, + amount: 4000, // 40.00 in cents + payerName: 'Alice', + }) + + navigateToGroup(page, groupId) + await expect(page.getByText(expenseTitle)).toBeVisible() + + // Open expense for editing + await page.getByText(expenseTitle).first().click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + // Click delete button + const deleteButton = page.getByRole('button', { name: /delete/i }) + await deleteButton.click() + + // Cancel deletion in dialog + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + + const cancelButton = dialog.getByRole('button', { name: /cancel|no/i }) + await expect(cancelButton).toBeVisible() + await cancelButton.click() + + // Dialog should close and we should still be on edit page + await expect(dialog).not.toBeVisible() + await expect(page).toHaveURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + // Navigate back and verify expense still exists + await page.goBack() + await expect(page.getByText(expenseTitle)).toBeVisible() + }) +}) diff --git a/tests/e2e/recurring-expense-instances.spec.ts b/tests/e2e/recurring-expense-instances.spec.ts new file mode 100644 index 000000000..88dd0e61e --- /dev/null +++ b/tests/e2e/recurring-expense-instances.spec.ts @@ -0,0 +1,203 @@ +import { prisma } from '@/lib/prisma' +import { expect, test } from '@playwright/test' +import { createGroup, navigateToGroup } from '../helpers' +import { randomId } from '@/lib/api' + +test.describe('Recurring Expense Instances', () => { + test('Verify instances created for recurring expense', async ({ page }) => { + const groupId = await createGroup({ + page, + groupName: `recurring verify ${randomId(4)}`, + participants: ['Alice', 'Bob'], + }) + + // Get the first participant to use as payer + const group = await prisma.group.findUnique({ + where: { id: groupId }, + include: { participants: true }, + }) + + const payer = group?.participants[0] + expect(payer).toBeDefined() + + // Create a recurring expense with a past date to trigger instance creation + const yesterday = new Date() + yesterday.setUTCDate(yesterday.getUTCDate() - 1) + yesterday.setUTCHours(0, 0, 0, 0) + + const expenseTitle = `Recurring Verify ${randomId(4)}` + + const recurringExpense = await prisma.expense.create({ + data: { + id: `recurring-${randomId()}`, + groupId, + expenseDate: yesterday, + title: expenseTitle, + amount: 2500, + paidById: payer!.id, + splitMode: 'EVENLY', + recurrenceRule: 'DAILY', + recurringExpenseLink: { + create: { + id: `link-${randomId()}`, + groupId, + nextExpenseDate: yesterday, + }, + }, + paidFor: { + createMany: { + data: group!.participants.map((p) => ({ + participantId: p.id, + shares: 1, + })), + }, + }, + }, + include: { recurringExpenseLink: true }, + }) + + // Verify only one expense exists initially + const initialExpenseCount = await prisma.expense.count({ + where: { groupId, title: expenseTitle }, + }) + expect(initialExpenseCount).toBe(1) + + // Navigate to the group page + await navigateToGroup(page, groupId) + + // Verify the expense is visible + await expect(page.getByText(expenseTitle).first()).toBeVisible() + + // Reload to trigger instance creation + await page.reload() + + // Verify a new instance was created + const updatedExpenseCount = await prisma.expense.count({ + where: { groupId, title: expenseTitle }, + }) + expect(updatedExpenseCount).toBeGreaterThan(initialExpenseCount) + + // Verify the new expense has the correct date + const newExpense = await prisma.expense.findFirst({ + where: { + groupId, + title: expenseTitle, + id: { not: recurringExpense.id }, + }, + orderBy: { createdAt: 'desc' }, + }) + + expect(newExpense).toBeDefined() + expect(newExpense!.expenseDate.getTime()).toBeGreaterThanOrEqual( + recurringExpense.recurringExpenseLink!.nextExpenseDate.getTime(), + ) + }) + + test('Multiple recurring expenses create instances independently', async ({ + page, + }) => { + const groupId = await createGroup({ + page, + groupName: `multiple recurring ${randomId(4)}`, + participants: ['Alice', 'Bob'], + }) + + const group = await prisma.group.findUnique({ + where: { id: groupId }, + include: { participants: true }, + }) + + const payer = group?.participants[0] + expect(payer).toBeDefined() + + const yesterday = new Date() + yesterday.setUTCDate(yesterday.getUTCDate() - 1) + yesterday.setUTCHours(0, 0, 0, 0) + + const expense1Title = `Recurring 1 ${randomId(4)}-1` + const expense2Title = `Recurring 2 ${randomId(4)}-2` + + // Create two separate recurring expenses + await prisma.expense.create({ + data: { + id: `recurring-1-${randomId()}`, + groupId, + expenseDate: yesterday, + title: expense1Title, + amount: 1000, + paidById: payer!.id, + splitMode: 'EVENLY', + recurrenceRule: 'DAILY', + recurringExpenseLink: { + create: { + id: `link-1-${randomId()}`, + groupId, + nextExpenseDate: yesterday, + }, + }, + paidFor: { + createMany: { + data: group!.participants.map((p) => ({ + participantId: p.id, + shares: 1, + })), + }, + }, + }, + }) + + await prisma.expense.create({ + data: { + id: `recurring-2-${randomId()}`, + groupId, + expenseDate: yesterday, + title: expense2Title, + amount: 2000, + paidById: payer!.id, + splitMode: 'EVENLY', + recurrenceRule: 'WEEKLY', + recurringExpenseLink: { + create: { + id: `link-2-${randomId()}`, + groupId, + nextExpenseDate: yesterday, + }, + }, + paidFor: { + createMany: { + data: group!.participants.map((p) => ({ + participantId: p.id, + shares: 1, + })), + }, + }, + }, + }) + + const initialCount1 = await prisma.expense.count({ + where: { groupId, title: expense1Title }, + }) + const initialCount2 = await prisma.expense.count({ + where: { groupId, title: expense2Title }, + }) + + expect(initialCount1).toBe(1) + expect(initialCount2).toBe(1) + + // Navigate to group and reload to trigger instance creation + await navigateToGroup(page, groupId) + await page.reload() + await page.waitForResponse('**groups.expenses.list**') + + // Verify both expenses created new instances + const updatedCount1 = await prisma.expense.count({ + where: { groupId, title: expense1Title }, + }) + const updatedCount2 = await prisma.expense.count({ + where: { groupId, title: expense2Title }, + }) + + expect(updatedCount1).toBeGreaterThan(initialCount1) + expect(updatedCount2).toBeGreaterThan(initialCount2) + }) +}) diff --git a/tests/e2e/settings.spec.ts b/tests/e2e/settings.spec.ts new file mode 100644 index 000000000..f83deb77e --- /dev/null +++ b/tests/e2e/settings.spec.ts @@ -0,0 +1,118 @@ +import { expect, test } from '@playwright/test' +import { createGroupViaAPI } from '../helpers/batch-api' +import { randomId } from '@/lib/api' + +test('Theme selection persists after reload', async ({ page }) => { + await page.goto('/groups') + + // Open theme toggle menu + const themeToggle = page.getByRole('button', { name: 'Toggle theme' }) + await expect(themeToggle).toBeVisible() + await themeToggle.click() + + // Select Dark theme + const darkOption = page.getByRole('menuitem', { name: 'Dark' }) + await expect(darkOption).toBeVisible() + await darkOption.click() + + // Verify dark theme is applied (body or html should have dark class/attribute) + const html = page.locator('html') + await expect(html).toHaveAttribute('class', /dark/) + + // Reload page + await page.reload() + + // Verify dark theme persisted after reload + await expect(html).toHaveAttribute('class', /dark/) +}) + +test('Expense displays with selected category', async ({ page }) => { + const expenseTitle = `Test Expense ${randomId(4)}` + + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `category test ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + + // Navigate to create expense page + const createExpenseLink = page.getByRole('link', { name: 'Create expense' }) + await expect(createExpenseLink).toBeVisible() + await createExpenseLink.click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/create$/) + + // Fill expense details + await page.getByRole('textbox', { name: 'Expense title' }).fill(expenseTitle) + await page.getByRole('textbox', { name: 'Amount' }).fill('40.00') + + // Select payer + const payerCombobox = page + .getByRole('combobox') + .filter({ hasText: 'Select a participant' }) + await payerCombobox.click() + await page.getByRole('option', { name: 'Alice' }).click() + + // Verify default category is General and select a different one (Entertainment) + const categoryCombobox = page + .getByRole('combobox') + .filter({ hasText: 'General' }) + await expect(categoryCombobox).toBeVisible() + await categoryCombobox.click() + + // Select Entertainment category + const entertainmentOption = page.getByRole('option', { + name: /entertainment/i, + }) + if (await entertainmentOption.isVisible()) { + await entertainmentOption.click() + } else { + // If Entertainment is not available, select any non-General category + const options = page.getByRole('option') + const optionCount = await options.count() + if (optionCount > 1) { + // Select second option (first is General) + await options.nth(1).click() + } + } + + // Create the expense + const createButton = page.getByRole('button', { name: 'Create' }) + await createButton.click() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Verify expense appears with title + const expenseTitleElement = page + .getByTestId('expense-title') + .filter({ hasText: expenseTitle }) + await expect(expenseTitleElement).toBeVisible() +}) + +test('Default category is General', async ({ page }) => { + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `default category ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + await page.goto(`/groups/${groupId}/expenses`) + + // Navigate to create expense page + const createExpenseLink = page.getByRole('link', { name: 'Create expense' }) + await expect(createExpenseLink).toBeVisible() + await createExpenseLink.click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/create$/) + + // Verify the category field defaults to "General" + const categoryCombobox = page + .getByRole('combobox') + .filter({ hasText: 'General' }) + await expect(categoryCombobox).toBeVisible() + + // Verify it contains the text "General" + const categoryText = await categoryCombobox.textContent() + expect(categoryText).toContain('General') +}) diff --git a/tests/e2e/statistics.spec.ts b/tests/e2e/statistics.spec.ts new file mode 100644 index 000000000..5fbc05827 --- /dev/null +++ b/tests/e2e/statistics.spec.ts @@ -0,0 +1,106 @@ +import { expect, test } from '@playwright/test' +import { navigateToTab, setActiveUser } from '../helpers' +import { createExpenseViaAPI, createGroupViaAPI } from '../helpers/batch-api' +import { randomId } from '@/lib/api' + +test('View statistics page', async ({ page }) => { + await page.goto('/groups') + + const groupName = `stats ${randomId(4)}` + const groupId = await createGroupViaAPI(page, groupName, ['Alice', 'Bob', 'Charlie']) + await page.goto(`/groups/${groupId}/expenses`) + + await navigateToTab(page, 'Stats') + + // Verify the Totals heading is visible + await expect(page.getByRole('heading', { name: 'Totals' })).toBeVisible() + + // Verify "Total group spendings" label is present + await expect(page.getByText('Total group spendings')).toBeVisible() +}) + +test('Verify Group Total', async ({ page }) => { + const groupName = `group total ${randomId(4)}` + + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, [ + 'Alice', + 'Bob', + 'Charlie', + ]) + + // Add expenses + await createExpenseViaAPI(page, groupId, { + title: 'Dinner', + amount: 1000, + payerName: 'Alice', + }) + await createExpenseViaAPI(page, groupId, { + title: 'Drinks', + amount: 2050, + payerName: 'Bob', + }) + await createExpenseViaAPI(page, groupId, { + title: 'Snacks', + amount: 500, + payerName: 'Charlie', + }) + + // Navigate to group page + await page.goto(`/groups/${groupId}/expenses`) + + await navigateToTab(page, 'Stats') + + // Verify total is exactly 35.50 (10.00 + 20.50 + 5.00) + const totalGroupSpendings = page.getByTestId('total-group-spendings') + await expect(totalGroupSpendings).toBeVisible() + + // Check for the specific amount with $ symbol + await expect(totalGroupSpendings).toContainText('$35.50') +}) + +test('User statistics calculate paid and share correctly', async ({ page }) => { + const groupName = `user stats ${randomId(4)}` + const participantA = 'Alice' + const participantB = 'Bob' + const participantC = 'Charlie' + + await page.goto('/groups') + const groupId = await createGroupViaAPI(page, groupName, [ + participantA, + participantB, + participantC, + ]) + + // Add expenses + // Alice pays $30 for all 3 people (split evenly: $10 each) + await createExpenseViaAPI(page, groupId, { + title: 'Dinner', + amount: 3000, + payerName: participantA, + }) + // Bob pays $15 for all 3 people (split evenly: $5 each) + await createExpenseViaAPI(page, groupId, { + title: 'Taxi', + amount: 1500, + payerName: participantB, + }) + + // Navigate to group page + await page.goto(`/groups/${groupId}/expenses`) + + // Select Alice as active user via Settings + await setActiveUser(page, participantA) + + await navigateToTab(page, 'Stats') + + // Verify Alice's total spendings: $30.00 (what she paid) + const yourSpendings = page.getByTestId('your-total-spendings') + await expect(yourSpendings).toBeVisible() + await expect(yourSpendings).toContainText('$30.00') + + // Verify Alice's share: $15.00 ($10 from Dinner + $5 from Taxi) + const yourShare = page.getByTestId('your-total-share') + await expect(yourShare).toBeVisible() + await expect(yourShare).toContainText('$15.00') +}) diff --git a/tests/e2e/ui.spec.ts b/tests/e2e/ui.spec.ts new file mode 100644 index 000000000..09b7d4159 --- /dev/null +++ b/tests/e2e/ui.spec.ts @@ -0,0 +1,180 @@ +import { expect, test } from '@playwright/test' +import { navigateToGroup, switchLocale } from '../helpers' +import { createExpenseViaAPI, createGroupViaAPI } from '../helpers/batch-api' +import { randomId } from '@/lib/api' + +test('Mobile navigation uses hamburger menu', async ({ page }) => { + // Set viewport to mobile size (iPhone SE) + await page.setViewportSize({ width: 375, height: 667 }) + + // Create a test group + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `mobile test ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + // Create an expense so we have content to verify + await createExpenseViaAPI(page, groupId, { + title: 'Mobile Test Expense', + amount: 5000, + payerName: 'Alice', + }) + + // Navigate to group page + await page.goto(`/groups/${groupId}/expenses`) + + // Verify the expense is visible in mobile view + const mobileExpenseTitle = page + .getByTestId('expense-title') + .filter({ hasText: 'Mobile Test Expense' }) + await expect(mobileExpenseTitle).toBeVisible() + + // Verify amount is visible in mobile layout + const mobileExpenseAmount = page + .getByTestId('expense-amount') + .filter({ hasText: '$50.00' }) + await expect(mobileExpenseAmount).toBeVisible() + + // Verify tabs are still accessible in mobile view + const statsTab = page.getByRole('tab', { name: 'Stats' }) + await expect(statsTab).toBeVisible() + await statsTab.click() + + // Verify we navigated to Stats + await page.waitForURL(/\/stats$/) + await expect(page.getByRole('heading', { name: 'Totals' })).toBeVisible() +}) + +test('Desktop view displays full layout', async ({ page }) => { + // Set viewport to desktop size + await page.setViewportSize({ width: 1280, height: 1024 }) + + // Create a test group + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `desktop test ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + // Create an expense + await createExpenseViaAPI(page, groupId, { + title: 'Desktop Test Expense', + amount: 10000, + payerName: 'Alice', + }) + + // Navigate to group page + await page.goto(`/groups/${groupId}/expenses`) + + // Verify main content is visible + await expect(page.getByRole('main')).toBeVisible() + + // Verify navigation header is visible + await expect(page.getByRole('navigation', { name: 'Menu' })).toBeVisible() + + // Verify all tabs are visible without scrolling + await expect(page.getByRole('tab', { name: 'Expenses' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'Balances' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'Stats' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'Settings' })).toBeVisible() + + // Verify expense card details are fully visible + const desktopExpenseTitle = page + .getByTestId('expense-title') + .filter({ hasText: 'Desktop Test Expense' }) + await expect(desktopExpenseTitle).toBeVisible() + + const desktopExpenseAmount = page + .getByTestId('expense-amount') + .filter({ hasText: '$100.00' }) + await expect(desktopExpenseAmount).toBeVisible() + + await expect(page.getByText('Paid by')).toBeVisible() +}) + +test('Date format changes with locale selection', async ({ page }) => { + // Create a test group + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `i18n date test ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + // Create an expense with a known date + const expense = await createExpenseViaAPI(page, groupId, { + title: 'i18n Date Test', + amount: 5000, + payerName: 'Alice', + expenseDate: new Date('2026-01-17'), // January 17, 2026 + }) + + // Navigate to group page + await navigateToGroup(page, groupId) + + // Verify expense is visible + const expenseItem = page + .getByTestId(`expense-item-${expense}`) + await expect(expenseItem).toBeVisible() + + // Get the date text in English format (e.g., "Jan 17, 2026") + const expenseDateElement = page.getByTestId('expense-date').first() + await expect(expenseDateElement).toHaveText('Jan 17, 2026') + + // Switch to Spanish locale + await switchLocale(page, 'Español') + + await expect(expenseDateElement).toHaveText('17 ene 2026') +}) + +test('Currency displays with correct format for locale', async ({ page }) => { + // Create a test group with USD currency + await page.goto('/groups') + const groupId = await createGroupViaAPI( + page, + `currency format test ${randomId(4)}`, + ['Alice', 'Bob'], + ) + + // Create an expense with a specific amount + await createExpenseViaAPI(page, groupId, { + title: 'Currency Format Test', + amount: 123456, + payerName: 'Alice', + }) + + // Navigate to group page + await page.goto(`/groups/${groupId}/expenses`) + + // Verify expense is visible + const currencyExpenseTitle = page + .getByTestId('expense-title') + .filter({ hasText: 'Currency Format Test' }) + await expect(currencyExpenseTitle).toBeVisible() + + // In English (US) locale, USD amounts display as $1,234.56 + // Verify the amount displays with $ prefix and period as decimal separator + const expenseAmount = page + .getByTestId('expense-amount') + .filter({ hasText: '$1,234.56' }) + await expect(expenseAmount).toBeVisible() + + // Navigate to Stats to see total + await page.getByRole('tab', { name: 'Stats' }).click() + await page.waitForURL(/\/stats$/) + + // Verify the total also uses correct format + const totalGroupSpending = page.getByTestId('total-group-spendings') + await expect(totalGroupSpending).toContainText('$1,234.56') + + // Switch to French locale which uses different number formatting + await switchLocale(page, 'Français') + + // In French locale, numbers use space as thousands separator and comma as decimal + // $1,234.56 becomes 1 234,56 $ or similar format + // At minimum, verify the page still works and displays amounts + await expect(page.getByText(/1.*234.*56/)).toBeVisible() +}) diff --git a/tests/group-create-happy-path.spec.ts b/tests/group-create-happy-path.spec.ts new file mode 100644 index 000000000..0accb853e --- /dev/null +++ b/tests/group-create-happy-path.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from '@playwright/test' +import { createGroup, navigateToTab } from './helpers' + +test('create group - happy path', async ({ page }) => { + const groupName = `PW E2E group ${Date.now()}` + const participantA = 'Alice' + const participantB = 'Bob' + + await createGroup({ + page, + groupName, + participants: [participantA, participantB, 'Charlie'], + }) + + // Show balances tab; this page is stable for participant assertions. + await navigateToTab(page, 'Balances') + await expect(page.getByText(participantA, { exact: true })).toBeVisible() + await expect(page.getByText(participantB, { exact: true })).toBeVisible() +}) diff --git a/tests/helpers/batch-api.ts b/tests/helpers/batch-api.ts new file mode 100644 index 000000000..71d1e9161 --- /dev/null +++ b/tests/helpers/batch-api.ts @@ -0,0 +1,224 @@ +import { saveRecentGroup } from '@/app/groups/recent-groups-helpers' +import type { AppRouter } from '@/trpc/routers/_app' +import type { Page } from '@playwright/test' +import { RecurrenceRule, SplitMode } from '@prisma/client' +import { createTRPCClient, httpBatchLink } from '@trpc/client' +import superjson from 'superjson' + +interface ExpenseFormValues { + expenseDate: Date + title: string + category: number + amount: number + paidBy: string + paidFor: Array<{ participant: string; shares: number }> + splitMode: SplitMode + isReimbursement: boolean + recurrenceRule: RecurrenceRule | 'NONE' + saveDefaultSplittingOptions: boolean + documents?: Array<{ id: string; url: string; width: number; height: number }> + notes?: string +} + +interface GroupFormValues { + name: string + information?: string + currency: string + currencyCode: string + participants: Array<{ id?: string; name: string }> +} + +function createTrpcClient(page: Page) { + return createTRPCClient({ + links: [ + httpBatchLink({ + url: `${new URL(page.url()).origin}/api/trpc`, + async headers() { + return { + cookie: await page.evaluate(() => document.cookie), + } + }, + transformer: superjson, + }), + ], + }) +} + +export async function createGroupViaAPI( + page: Page, + groupName: string, + participants: string[], + currency = 'USD', + persistOptions: { + suppressActiveUserModal: boolean + addGroupToRecent: boolean + } = { suppressActiveUserModal: true, addGroupToRecent: true }, +): Promise { + const trpc = createTrpcClient(page) + + const groupFormValues: GroupFormValues = { + name: groupName, + currency, + currencyCode: currency, + participants: participants.map((name) => ({ name })), + } + + const result = await trpc.groups.create.mutate({ groupFormValues }) + + if (persistOptions.suppressActiveUserModal) { + await page.evaluate((gId) => { + localStorage.setItem(`${gId}-activeUser`, 'None') + }, result.groupId) + } + if (persistOptions.addGroupToRecent) { + await page.evaluate( + (group) => { + const existing = JSON.parse( + localStorage.getItem('recentGroups') ?? '[]', + ) as { id: string; name: string }[] + if (existing.some((g) => g.id === group.id)) return + localStorage.setItem( + 'recentGroups', + JSON.stringify([group, ...existing]), + ) + }, + { id: result.groupId, name: groupName }, + ) + } + + return result.groupId +} + +export async function createExpensesViaAPI( + page: Page, + groupId: string, + expenses: + | Array<{ + title: string + amount: number // in cents + payerName: string + isReimbursement?: boolean + category?: number + splitMode?: SplitMode + expenseDate?: Date + notes?: string + paidFor?: Array<{ participant: string; shares: number }> + excludeParticipants?: string[] // Participant names to exclude from the split + recurrenceRule?: RecurrenceRule | 'NONE' + }> + | number, // If number, creates that many expenses with default values + payerNames?: string[], // Only used when first param is a number +): Promise { + const trpc = createTrpcClient(page) + + const groupData = await trpc.groups.get.query({ groupId }) + const participants = groupData.group?.participants + + if (!participants) { + throw new Error('Group participants not found') + } + + // Handle legacy signature: createExpensesViaAPI(page, groupId, count, payerNames) + let expensesToCreate: Array<{ + title: string + amount: number + payerName: string + isReimbursement?: boolean + category?: number + splitMode?: SplitMode + expenseDate?: Date + notes?: string + paidFor?: Array<{ participant: string; shares: number }> + excludeParticipants?: string[] + recurrenceRule?: RecurrenceRule | 'NONE' + }> + + if (typeof expenses === 'number') { + // Legacy mode: generate expenses + const count = expenses + const payers = groupData.group?.participants.map((p) => p.name) ?? ['Alice', 'Bob'] + expensesToCreate = [] + for (let i = 1; i <= count; i++) { + const payerName = payers[i % payers.length]! + expensesToCreate.push({ + title: `Expense ${i}`, + amount: 1000 + i * 100, + payerName, + }) + } + } else { + expensesToCreate = expenses + } + + const expenseIds: string[] = [] + + for (const expense of expensesToCreate) { + const payer = participants.find((p) => p.name === expense.payerName) + if (!payer) { + throw new Error(`Participant ${expense.payerName} not found in group`) + } + + // Handle excludeParticipants if provided + let paidFor = expense.paidFor + if (!paidFor && expense.excludeParticipants) { + // Exclude specified participants from the split + paidFor = participants + .filter((p) => !expense.excludeParticipants!.includes(p.name)) + .map((p) => ({ + participant: p.id, + shares: 1, + })) + } else if (!paidFor) { + // Include all participants + paidFor = participants.map((p) => ({ + participant: p.id, + shares: 1, + })) + } + + const expenseFormValues: ExpenseFormValues = { + expenseDate: expense.expenseDate || new Date(), + title: expense.title, + category: expense.category ?? 0, + amount: expense.amount, + paidBy: payer.id, + paidFor, + splitMode: expense.splitMode || SplitMode.EVENLY, + isReimbursement: expense.isReimbursement || false, + recurrenceRule: expense.recurrenceRule || 'NONE', + saveDefaultSplittingOptions: true, + notes: expense.notes, + } + + const result =await trpc.groups.expenses.create.mutate({ + groupId, + expenseFormValues, + participantId: payer.id, + }) + + expenseIds.push(result.expenseId) + } + + return expenseIds +} + +export async function createExpenseViaAPI( + page: Page, + groupId: string, + expense: { + title: string + amount: number // in cents + payerName: string + isReimbursement?: boolean + category?: number + splitMode?: SplitMode + expenseDate?: Date + notes?: string + paidFor?: Array<{ participant: string; shares: number }> + excludeParticipants?: string[] // Participant names to exclude from the split + recurrenceRule?: RecurrenceRule | 'NONE' + }, +): Promise { + const expenseIds = await createExpensesViaAPI(page, groupId, [expense]) + return expenseIds[0]! +} diff --git a/tests/helpers/expense.ts b/tests/helpers/expense.ts new file mode 100644 index 000000000..8e66c0b19 --- /dev/null +++ b/tests/helpers/expense.ts @@ -0,0 +1,298 @@ +import { expect, type Page } from '@playwright/test' + +export interface CreateExpenseOptions { + title: string + amount: string + payer: string + category?: string + date?: string + notes?: string + isReimbursement?: boolean + splitMode?: 'evenly' | 'shares' | 'percentage' | 'amount' + splitValues?: Record + recurrence?: 'Daily' | 'Weekly' | 'Monthly' +} + +/** + * Navigates to the expense creation page + */ +export async function navigateToExpenseCreate(page: Page): Promise { + // The button is an icon button with title "Create expense" + const createExpenseButton = page.getByRole('link', { + name: /create expense/i, + }) + await createExpenseButton.waitFor({ state: 'visible' }) + + await createExpenseButton.click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/create/) +} + +/** + * Creates an expense with the specified options + * @param excludeParticipants - Optional array of participant names to exclude from the expense split + */ +export async function createExpense( + page: Page, + options: CreateExpenseOptions, + excludeParticipants?: string[], +): Promise { + await navigateToExpenseCreate(page) + await fillExpenseForm(page, options) + + // Exclude specific participants if provided + if (excludeParticipants && excludeParticipants.length > 0) { + for (const participant of excludeParticipants) { + const checkbox = page.getByRole('checkbox', { name: participant }) + await expect(checkbox).toBeVisible() + await checkbox.uncheck() + } + } + + await submitExpenseAndVerify(page, options.title) +} + +/** + * Sets the recurrence for an expense + */ +export async function setExpenseRecurrence( + page: Page, + recurrence: 'Daily' | 'Weekly' | 'Monthly', +): Promise { + // Find the recurrence combobox + const recurrenceCombobox = page + .getByRole('combobox') + .filter({ hasText: /None|Daily|Weekly|Monthly/ }) + .last() + + await recurrenceCombobox.waitFor({ state: 'visible' }) + await recurrenceCombobox.click() + + // Select the recurrence option + const recurrenceOption = page.getByRole('option', { name: recurrence }) + await recurrenceOption.waitFor({ state: 'visible'}) + await recurrenceOption.click() +} + +/** + * Fills the expense form with the provided options + */ +export async function fillExpenseForm( + page: Page, + options: CreateExpenseOptions, +): Promise { + // Wait for form to be visible + const expenseTitle = page.locator('input[name="title"]') + await expenseTitle.waitFor({ state: 'visible' }) + + // Fill title + await expenseTitle.fill(options.title) + + // Fill amount + const amountInput = page.locator('input[name="amount"]') + await amountInput.fill(options.amount) + + // Select payer + const paidBySelect = page + .getByRole('combobox') + .filter({ hasText: 'Select a participant' }) + await paidBySelect.waitFor({ state: 'visible' }) + await paidBySelect.click() + + const payerOption = page.getByRole('option', { name: options.payer }) + await payerOption.waitFor({ state: 'visible', timeout: 5000 }) + await payerOption.click() + + // Optional: Select category + if (options.category) { + const categorySelects = page.locator('[role="combobox"]') + if ((await categorySelects.count()) >= 2) { + await categorySelects.nth(1).click() + await page.getByRole('option', { name: options.category }).click() + } + } + + // Optional: Set date + if (options.date) { + const dateInputs = page.locator('input[type="date"]') + if ((await dateInputs.count()) > 0) { + await dateInputs.first().fill(options.date) + } + } + + // Optional: Add notes + if (options.notes) { + const textareas = page.locator('textarea') + if ((await textareas.count()) > 0) { + await textareas.first().fill(options.notes) + } + } + + // Optional: Check reimbursement checkbox + if (options.isReimbursement) { + const reimbursementLabel = page.getByText(/this is a reimbursement/i) + await reimbursementLabel.click() + } + + // Optional: Set recurrence + if (options.recurrence) { + await setExpenseRecurrence(page, options.recurrence) + } +} + +/** + * Submits the expense form and verifies it was created + */ +export async function submitExpenseAndVerify( + page: Page, + expenseTitle: string, +): Promise { + // Use more specific selector for the Create button + const createButton = page.getByRole('button', { name: 'Create' }) + await createButton.click() + + // Wait for navigation to expenses list (more specific pattern) + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Verify expense appears in the list using data-testid + const expenseTitleElement = page + .getByTestId('expense-title') + .filter({ hasText: expenseTitle }) + await expect(expenseTitleElement).toBeVisible() +} + +/** + * Deletes an expense by clicking on it and confirming deletion + */ +export async function deleteExpense( + page: Page, + expenseTitle: string, +): Promise { + // Click expense to edit (use data-testid) + const expenseTitleElement = page + .getByTestId('expense-title') + .filter({ hasText: expenseTitle }) + await expenseTitleElement.click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + + // Click delete button + const deleteButton = page.getByRole('button', { name: /delete/i }) + await deleteButton.click() + + // Confirm deletion + const confirmButton = page.getByRole('button', { name: /yes/i }) + await confirmButton.click() + + // Wait for navigation back to expenses list + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + // Verify expense is deleted (check that title element no longer exists) + const deletedExpense = page + .getByTestId('expense-title') + .filter({ hasText: expenseTitle }) + await expect(deletedExpense).not.toBeVisible() +} + +/** + * Opens an expense for editing + */ +export async function openExpenseForEdit( + page: Page, + expenseTitle: string, +): Promise { + // Click expense using data-testid + const expenseTitleElement = page + .getByTestId('expense-title') + .filter({ hasText: expenseTitle }) + await expenseTitleElement.click() + await page.waitForURL(/\/groups\/[^/]+\/expenses\/[^/]+\/edit/) + await expect(page.locator('input[name="title"]')).toHaveValue(expenseTitle) +} + +/** + * Updates an expense's fields and saves + */ +export async function updateExpense( + page: Page, + updates: Partial, +): Promise { + // Update title if provided + if (updates.title) { + const titleInput = page.locator('input[name="title"]') + await titleInput.clear() + await titleInput.fill(updates.title) + } + + // Update amount if provided + if (updates.amount) { + const amountInput = page.locator('input[name="amount"]') + await amountInput.clear() + await amountInput.fill(updates.amount) + } + + // Update date if provided + if (updates.date) { + await page.locator('input[type="date"]').fill(updates.date) + } + + // Update notes if provided + if (updates.notes !== undefined) { + const notesTextarea = page.locator('textarea') + await notesTextarea.clear() + await notesTextarea.fill(updates.notes) + } + + // Save + const saveButton = page.getByRole('button', { name: /save/i }) + await saveButton.click() + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) +} + +/** + * Verifies expense values in edit form + */ +export async function verifyExpenseValues( + page: Page, + expected: { + title?: string + amount?: string + date?: string + notes?: string + payer?: string + }, +): Promise { + if (expected.title) { + await expect(page.locator('input[name="title"]')).toHaveValue( + expected.title, + ) + } + if (expected.amount) { + await expect(page.locator('input[name="amount"]')).toHaveValue( + expected.amount, + ) + } + if (expected.date) { + await expect(page.locator('input[type="date"]')).toHaveValue(expected.date) + } + if (expected.notes) { + await expect(page.locator('textarea')).toHaveValue(expected.notes) + } + if (expected.payer) { + // The payer combobox shows the participant name when selected + await expect( + page.getByRole('combobox').filter({ hasText: expected.payer }), + ).toBeVisible() + } +} + +/** + * Verifies that an expense has a specific recurrence setting in the edit form + */ +export async function verifyExpenseRecurrence( + page: Page, + expectedRecurrence: 'None' | 'Daily' | 'Weekly' | 'Monthly', +): Promise { + const recurrenceCombobox = page + .getByRole('combobox') + .filter({ hasText: expectedRecurrence }) + await expect(recurrenceCombobox).toBeVisible() +} diff --git a/tests/helpers/form.ts b/tests/helpers/form.ts new file mode 100644 index 000000000..37a8ca1bf --- /dev/null +++ b/tests/helpers/form.ts @@ -0,0 +1,39 @@ +import type { Page } from '@playwright/test' + +/** + * Selects an option from a combobox by placeholder text + */ +export async function selectComboboxOption( + page: Page, + placeholder: string, + optionName: string, +): Promise { + const select = page.getByRole('combobox').filter({ hasText: placeholder }) + await select.click() + await page.getByRole('option', { name: optionName }).click() +} + +/** + * Finds and checks a checkbox by searching for label text + * @returns true if checkbox was found and checked, false otherwise + */ +export async function checkCheckboxByLabel( + page: Page, + labelSubstring: string, +): Promise { + const checkboxes = page.locator('input[type="checkbox"]') + const count = await checkboxes.count() + + for (let i = 0; i < count; i++) { + const checkbox = checkboxes.nth(i) + const label = await checkbox.evaluate((el) => { + return el.parentElement?.textContent?.toLowerCase() || '' + }) + if (label.includes(labelSubstring.toLowerCase())) { + await checkbox.check({ force: true }) + return true + } + } + + return false +} diff --git a/tests/helpers/group.ts b/tests/helpers/group.ts new file mode 100644 index 000000000..1e8b01e7e --- /dev/null +++ b/tests/helpers/group.ts @@ -0,0 +1,176 @@ +import { expect, type Page } from '@playwright/test' + +/** + * Creates a group with the specified name and participants + * @returns The groupId extracted from the URL + */ +export async function createGroup({ + page, + groupName, + participants, + suppressActiveUserModal = true, +}: { + page: Page + groupName: string + participants: string[] + suppressActiveUserModal?: boolean +}): Promise { + await page.goto('/groups') + await page.getByRole('link', { name: 'Create' }).first().click() + + await page.getByLabel('Group name').fill(groupName) + + await fillParticipants(page, participants) + + await page.getByRole('button', { name: 'Create' }).click() + // Wait for the redirect to complete - webkit needs explicit URL match + await page.waitForURL(/.*\/groups\/\S+\/expenses$/) + + const groupId = extractGroupId(page.url()) + + if (suppressActiveUserModal) { + await page.evaluate((gId) => { + localStorage.setItem(`${gId}-activeUser`, 'None') + }, groupId) + } + + return groupId +} + +/** + * Fills in participant inputs, adding more if needed, and removes excess ones + */ +export async function fillParticipants( + page: Page, + participants: string[], +): Promise { + const participantInputs = page.getByRole('textbox', { name: 'New' }) + const initialCount = await participantInputs.count() + + // Fill needed participants + for (let i = 0; i < participants.length; i++) { + if (i >= initialCount) { + await page.getByRole('button', { name: 'Add participant' }).click() + await expect(participantInputs).toHaveCount(i + 1) + } + + await participantInputs.nth(i).fill(participants[i]!) + } + + // Remove excess participant inputs if we have fewer than the default (usually 3) + if (participants.length < initialCount) { + for (let i = initialCount - 1; i >= participants.length; i--) { + // Find the remove button for this participant input + const input = participantInputs.nth(i) + const container = input.locator( + 'xpath=ancestor::div[contains(@class,"flex")][1]', + ) + const removeButton = container.locator('button').first() + + if (await removeButton.isVisible()) { + await removeButton.click() + } + } + + // Verify we have the correct count + await expect(participantInputs).toHaveCount(participants.length) + } +} + +/** + * Extracts the groupId from a group URL + * @throws Error if groupId cannot be extracted + */ +export function extractGroupId(url: string): string { + const groupId = url.match(/\/groups\/([^/]+)(?:\/expenses)?$/)?.[1] + if (!groupId || groupId === 'create') { + throw new Error(`Failed to extract groupId from URL: ${url}`) + } + return groupId +} + +/** + * Verifies that a group with the specified name exists on the page + */ +export async function verifyGroupHeading( + page: Page, + expectedName: string, +): Promise { + await expect(page.getByRole('heading', { name: expectedName })).toBeVisible() +} + +/** + * Gets the list of participant names from the settings page + */ +export async function getParticipantNames(page: Page): Promise { + const participantInputs = page.getByRole('textbox', { name: 'New' }) + const count = await participantInputs.count() + const names: string[] = [] + + for (let i = 0; i < count; i++) { + const value = await participantInputs.nth(i).inputValue() + names.push(value) + } + + return names +} + +/** + * Verifies participants exist on the Balances tab + */ +export async function verifyParticipantsOnBalancesTab( + page: Page, + expectedParticipants: string[], +): Promise { + for (const participant of expectedParticipants) { + await expect(page.getByTestId(`balance-row-${participant}`)).toBeVisible() + } +} + +/** + * Gets the current group info text from the Information tab + */ +export async function getGroupInfo(page: Page): Promise { + const infoElement = page.locator('text=/Info/').first() + if (!(await infoElement.isVisible())) { + return null + } + return await infoElement.textContent() +} + +/** + * Removes a participant by name from the settings page + * @returns true if participant was found and removed, false otherwise + */ +export async function removeParticipant( + page: Page, + participantName: string, +): Promise { + const participantInput = page.locator(`input[value="${participantName}"]`) + + if (!(await participantInput.isVisible())) { + return false + } + + const container = participantInput.locator( + 'xpath=ancestor::div[contains(@class,"flex")][1]', + ) + const removeButton = container.locator('button:not([disabled])').first() + + if (!(await removeButton.isVisible())) { + return false + } + + await removeButton.click() + return true +} + +/** + * Counts disabled remove buttons (protected participants) on settings page + */ +export async function countProtectedParticipants(page: Page): Promise { + const disabledRemoveButtons = page.locator( + 'button[disabled] svg.lucide-trash-2', + ) + return await disabledRemoveButtons.count() +} diff --git a/tests/helpers/index.ts b/tests/helpers/index.ts new file mode 100644 index 000000000..b2e12f8b1 --- /dev/null +++ b/tests/helpers/index.ts @@ -0,0 +1,6 @@ +// Re-export all helpers for convenient importing +export * from './batch-api' +export * from './expense' +export * from './form' +export * from './group' +export * from './navigation' diff --git a/tests/helpers/navigation.ts b/tests/helpers/navigation.ts new file mode 100644 index 000000000..afe537384 --- /dev/null +++ b/tests/helpers/navigation.ts @@ -0,0 +1,93 @@ +import { expect, type Page } from '@playwright/test' + +export type GroupTab = + | 'Expenses' + | 'Balances' + | 'Stats' + | 'Settings' + | 'Information' + | 'Activity' + +const TAB_URL_PATTERNS: Record = { + Expenses: /\/groups\/[^/]+\/expenses$/, + Balances: /\/groups\/[^/]+\/balances$/, + Stats: /\/groups\/[^/]+\/stats$/, + Settings: /\/groups\/[^/]+\/edit$/, + Information: /\/groups\/[^/]+\/information$/, + Activity: /\/groups\/[^/]+\/activity$/, +} + +/** + * Navigates to a group's expenses page with proper handling of redirects. + * The /groups/{id} page redirects to /groups/{id}/expenses, so we navigate + * directly to the final URL to avoid timing issues in webkit. + */ +export async function navigateToGroup( + page: Page, + groupId: string, + suppressActiveUserModal = true, +): Promise { + await page.goto(`/groups/${groupId}/expenses`) + await page.waitForURL(/\/groups\/[^/]+\/expenses$/) + + if (suppressActiveUserModal) { + await page.evaluate((gId) => { + localStorage.setItem(`${gId}-activeUser`, 'None') + }, groupId) + } +} + +/** + * Navigates to a specific tab in the group view + */ +export async function navigateToTab(page: Page, tab: GroupTab): Promise { + const tabButton = page.getByRole('tab', { name: tab }) + await tabButton.waitFor({ state: 'visible' }) + await tabButton.click() + await page.waitForURL(TAB_URL_PATTERNS[tab]) +} + +/** + * Switches the application locale/language + */ +export async function switchLocale( + page: Page, + localeName: string, +): Promise { + // Click the current locale button to open menu + const localeButton = page + .getByRole('button') + .filter({ hasText: /English|Español|Français|Deutsch/ }) + await localeButton.click() + + // Select the desired locale + const localeOption = page.getByRole('menuitem', { name: localeName }) + await localeOption.click() + await expect(localeButton).toHaveText(localeName) +} + +/** + * Sets the active user in group settings + */ +export async function setActiveUser( + page: Page, + userName: string, +): Promise { + await navigateToTab(page, 'Settings') + + // Open the active user selector + const activeUserSelector = page.getByTestId('active-user-selector') + await activeUserSelector.click() + + // Select the user + await page.getByRole('option', { name: userName }).click() + + // Save the settings + await clickSave(page) +} + +export async function clickSave(page: Page): Promise { + await page.getByRole('button', { name: 'Save' }).click() + await expect(page.getByRole('main').locator('div').filter({ hasText: 'Saving…' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled() +} \ No newline at end of file