import { randomUUID } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import os from 'node:os'; import path from 'node:path'; import { $fetch, setup } from '@nuxt/test-utils/e2e'; import { describe, expect, it } from 'vitest'; describe('Pot API', async () => { const dbPath = path.join(os.tmpdir(), `stb-test-${randomUUID()}.sqlite`); await setup({ rootDir: fileURLToPath(new URL('../..', import.meta.url)), dev: true, env: { DATABASE_URL: `file:${dbPath}` }, }); it('creates a Pot with the creator as first participant', async () => { const result = await $fetch('/api/pots', { method: 'POST', body: { name: 'Weekend Trip', currency: 'eur', creatorName: 'Alice' }, }); expect(result.slug).toMatch(/^[a-z]+-[a-z]+-[a-z0-9]{6}$/); expect(result.name).toBe('Weekend Trip'); expect(result.currency).toBe('EUR'); expect(result.creator.name).toBe('Alice'); }); it('rejects Pot creation with missing fields', async () => { await expect( $fetch('/api/pots', { method: 'POST', body: { name: '', currency: 'EUR', creatorName: 'Alice' }, }), ).rejects.toMatchObject({ statusCode: 400 }); }); it('adds a participant and fetches the Pot with balances', async () => { const pot = await $fetch('/api/pots', { method: 'POST', body: { name: 'Flat Share', currency: 'USD', creatorName: 'Alice' }, }); const bob = await $fetch(`/api/pots/${pot.slug}/participants`, { method: 'POST', body: { name: 'Bob' }, }); expect(bob.name).toBe('Bob'); const fetched = await $fetch(`/api/pots/${pot.slug}`); expect(fetched.pot.slug).toBe(pot.slug); expect(fetched.participants).toHaveLength(2); expect(fetched.expenses).toEqual([]); expect(fetched.netBalances).toEqual({ [pot.creator.id]: 0, [bob.id]: 0, }); expect(fetched.settlements).toEqual([]); }); it('supports full expense CRUD and recomputes balances', async () => { const pot = await $fetch('/api/pots', { method: 'POST', body: { name: 'Birthday Gift', currency: 'EUR', creatorName: 'Alice' }, }); const bob = await $fetch(`/api/pots/${pot.slug}/participants`, { method: 'POST', body: { name: 'Bob' }, }); const alice = pot.creator; const expense = await $fetch(`/api/pots/${pot.slug}/expenses`, { method: 'POST', body: { description: 'Cake', amountCents: 1000, payerId: alice.id, participantIds: [alice.id, bob.id], }, }); expect(expense.participantIds).toEqual([alice.id, bob.id]); let fetched = await $fetch(`/api/pots/${pot.slug}`); expect(fetched.expenses).toHaveLength(1); expect(fetched.netBalances[alice.id]).toBe(500); expect(fetched.netBalances[bob.id]).toBe(-500); expect(fetched.settlements).toEqual([ { fromId: bob.id, toId: alice.id, amountCents: 500 }, ]); await $fetch(`/api/pots/${pot.slug}/expenses/${expense.id}`, { method: 'PATCH', body: { description: 'Cake', amountCents: 2000, payerId: alice.id, participantIds: [alice.id, bob.id], }, }); fetched = await $fetch(`/api/pots/${pot.slug}`); expect(fetched.netBalances[bob.id]).toBe(-1000); await $fetch(`/api/pots/${pot.slug}/expenses/${expense.id}`, { method: 'DELETE', }); fetched = await $fetch(`/api/pots/${pot.slug}`); expect(fetched.expenses).toEqual([]); expect(fetched.netBalances[bob.id]).toBe(0); }); it('rejects an expense referencing a participant from another Pot', async () => { const potA = await $fetch('/api/pots', { method: 'POST', body: { name: 'A', currency: 'EUR', creatorName: 'Alice' }, }); const potB = await $fetch('/api/pots', { method: 'POST', body: { name: 'B', currency: 'EUR', creatorName: 'Zara' }, }); await expect( $fetch(`/api/pots/${potA.slug}/expenses`, { method: 'POST', body: { description: 'x', amountCents: 100, payerId: potB.creator.id, participantIds: [potA.creator.id], }, }), ).rejects.toMatchObject({ statusCode: 400 }); }); it('returns 404 for an unknown Pot slug', async () => { await expect( $fetch('/api/pots/does-not-exist-123456'), ).rejects.toMatchObject({ statusCode: 404 }); }); it('records a settlement payment as a payment-type expense and recomputes balances', async () => { const pot = await $fetch('/api/pots', { method: 'POST', body: { name: 'Ski Trip', currency: 'EUR', creatorName: 'Alice' }, }); const bob = await $fetch(`/api/pots/${pot.slug}/participants`, { method: 'POST', body: { name: 'Bob' }, }); const alice = pot.creator; await $fetch(`/api/pots/${pot.slug}/expenses`, { method: 'POST', body: { description: 'Lift pass', amountCents: 1000, payerId: alice.id, participantIds: [alice.id, bob.id], }, }); const payment = await $fetch(`/api/pots/${pot.slug}/payments`, { method: 'POST', body: { fromId: bob.id, toId: alice.id, amountCents: 500 }, }); expect(payment.type).toBe('payment'); expect(payment.payerId).toBe(bob.id); expect(payment.participantIds).toEqual([alice.id]); const fetched = await $fetch(`/api/pots/${pot.slug}`); expect(fetched.expenses).toHaveLength(2); expect(fetched.expenses.filter((e) => e.type === 'payment')).toHaveLength( 1, ); expect(fetched.netBalances[alice.id]).toBe(0); expect(fetched.netBalances[bob.id]).toBe(0); expect(fetched.settlements).toEqual([]); }); it('rejects a payment with a non-positive amount or matching from/to', async () => { const pot = await $fetch('/api/pots', { method: 'POST', body: { name: 'Retreat', currency: 'EUR', creatorName: 'Alice' }, }); const bob = await $fetch(`/api/pots/${pot.slug}/participants`, { method: 'POST', body: { name: 'Bob' }, }); const alice = pot.creator; await expect( $fetch(`/api/pots/${pot.slug}/payments`, { method: 'POST', body: { fromId: bob.id, toId: alice.id, amountCents: 0 }, }), ).rejects.toMatchObject({ statusCode: 400 }); await expect( $fetch(`/api/pots/${pot.slug}/payments`, { method: 'POST', body: { fromId: alice.id, toId: alice.id, amountCents: 100 }, }), ).rejects.toMatchObject({ statusCode: 400 }); }); it('deletes a settlement payment via the regular expense delete route', async () => { const pot = await $fetch('/api/pots', { method: 'POST', body: { name: 'Housewarming', currency: 'EUR', creatorName: 'Alice' }, }); const bob = await $fetch(`/api/pots/${pot.slug}/participants`, { method: 'POST', body: { name: 'Bob' }, }); const alice = pot.creator; const payment = await $fetch(`/api/pots/${pot.slug}/payments`, { method: 'POST', body: { fromId: bob.id, toId: alice.id, amountCents: 500 }, }); await $fetch(`/api/pots/${pot.slug}/expenses/${payment.id}`, { method: 'DELETE', }); const fetched = await $fetch(`/api/pots/${pot.slug}`); expect(fetched.expenses).toEqual([]); }); it('sets a valid payment link on a participant and rejects an invalid one', async () => { const pot = await $fetch('/api/pots', { method: 'POST', body: { name: 'Reunion', currency: 'EUR', creatorName: 'Alice' }, }); const alice = pot.creator; const updated = await $fetch( `/api/pots/${pot.slug}/participants/${alice.id}`, { method: 'PATCH', body: { paymentLink: 'https://paypal.me/alice' }, }, ); expect(updated.paymentLink).toBe('https://paypal.me/alice'); await expect( $fetch(`/api/pots/${pot.slug}/participants/${alice.id}`, { method: 'PATCH', body: { paymentLink: 'javascript:alert(1)' }, }), ).rejects.toMatchObject({ statusCode: 400 }); }); it('removes a participant without expenses but blocks removing one with expenses', async () => { const pot = await $fetch('/api/pots', { method: 'POST', body: { name: 'Ski Trip', currency: 'EUR', creatorName: 'Alice' }, }); const alice = pot.creator; const bob = await $fetch(`/api/pots/${pot.slug}/participants`, { method: 'POST', body: { name: 'Bob' }, }); await $fetch(`/api/pots/${pot.slug}/expenses`, { method: 'POST', body: { description: 'Lift pass', amountCents: 1000, payerId: alice.id, participantIds: [alice.id, bob.id], }, }); await expect( $fetch(`/api/pots/${pot.slug}/participants/${bob.id}`, { method: 'DELETE', }), ).rejects.toMatchObject({ statusCode: 409 }); const carol = await $fetch(`/api/pots/${pot.slug}/participants`, { method: 'POST', body: { name: 'Carol' }, }); const result = await $fetch( `/api/pots/${pot.slug}/participants/${carol.id}`, { method: 'DELETE' }, ); expect(result.success).toBe(true); const fetched = await $fetch(`/api/pots/${pot.slug}`); expect(fetched.participants.map((p) => p.id)).not.toContain(carol.id); }); });