Files
splitt-the-bill/test/api/pots.test.ts
T
anbratenandClaude Sonnet 5 f9085d68cd
tinyedge/deploy Deployed by TinyEdge
Rename paypalLink to paymentLink and allow inline editing
Generalizes the participant link field beyond PayPal and lets users
set/update their payment link directly from the balance summary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 17:56:11 +02:00

177 lines
7.3 KiB
TypeScript

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 })
})
})