293 lines
9.1 KiB
TypeScript
293 lines
9.1 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 });
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|