Files
TREK/server/tests/e2e/budget.e2e.test.ts
T
2026-07-27 23:48:05 +02:00

172 lines
7.0 KiB
TypeScript

/**
* Budget module e2e — exercises the migrated /api/trips/:tripId/budget endpoints
* through the real JwtAuthGuard against a temp SQLite db carrying the full real
* schema (createTables + runMigrations), so the folded BudgetService runs its
* real SQL. Only the db singleton (trip access) and the WebSocket broadcast are
* mocked; the permission check is a spy on the container's PermissionsService.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { Test } from '@nestjs/testing';
import { sessionCookie } from './harness';
const { db } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3');
const tmp = new Database(':memory:');
tmp.exec('PRAGMA journal_mode = WAL');
tmp.exec('PRAGMA foreign_keys = ON');
return { db: tmp };
});
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
vi.mock('../../src/db/database', () => ({
db,
closeDb: () => {},
reinitialize: () => {},
canAccessTrip,
getPlaceWithTags: () => null,
isOwner: () => false,
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
// Since the permissions DI migration, the check is a spy on the container's
// PermissionsService singleton (created in beforeAll, after build()).
let checkPermission: MockInstance;
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { BudgetModule } from '../../src/nest/budget/budget.module';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
describe('Budget e2e (real auth guard + temp SQLite, real budget SQL)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
let tripId: number;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, BudgetModule] }).compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
nest.useGlobalPipes(new ZodValidationPipe());
await nest.init();
return nest;
}
beforeAll(async () => {
createTables(db);
runMigrations(db);
// The temp db carries the real schema (password_hash NOT NULL), so seed the
// auth users directly instead of via the trimmed-DDL seedUser helper.
db.prepare(
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)",
).run();
db.prepare(
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (2, 'e2e-peer', 'peer@example.test', 'x', 'user', 0)",
).run();
tripId = Number(db.prepare("INSERT INTO trips (user_id, title, currency) VALUES (1, 'E2E Trip', 'EUR')").run().lastInsertRowid);
app = await build();
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
server = app.getHttpServer();
});
beforeEach(() => {
canAccessTrip.mockReturnValue({ id: tripId, user_id: 1, currency: 'EUR' });
checkPermission.mockReturnValue(true);
});
afterAll(async () => {
await app.close();
});
it('401 without a session cookie', async () => {
const res = await request(server).get(`/api/trips/${tripId}/budget`);
expect(res.status).toBe(401);
});
it('404 when the trip is not accessible', async () => {
canAccessTrip.mockReturnValue(undefined);
const res = await request(server).get(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('201 on create with permission, then 200 list returns the stored row', async () => {
const created = await request(server)
.post(`/api/trips/${tripId}/budget`)
.set('Cookie', sessionCookie(1))
.send({ name: 'Hotel', total_price: 200 });
expect(created.status).toBe(201);
expect(created.body.item).toMatchObject({ name: 'Hotel', total_price: 200, category: 'other', members: [], payers: [] });
const row = db.prepare('SELECT name, total_price FROM budget_items WHERE id = ?').get(created.body.item.id);
expect(row).toEqual({ name: 'Hotel', total_price: 200 });
const list = await request(server).get(`/api/trips/${tripId}/budget`).set('Cookie', sessionCookie(1));
expect(list.status).toBe(200);
expect(list.body.items.map((i: { id: number }) => i.id)).toContain(created.body.item.id);
});
it('403 on create without permission', async () => {
checkPermission.mockReturnValue(false);
const res = await request(server)
.post(`/api/trips/${tripId}/budget`)
.set('Cookie', sessionCookie(1))
.send({ name: 'Hotel' });
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: 'No permission' });
});
it('400 on member update with a non-array user_ids (Zod pipe envelope)', async () => {
const res = await request(server)
.put(`/api/trips/${tripId}/budget/9/members`)
.set('Cookie', sessionCookie(1))
.send({ user_ids: 'no' });
expect(res.status).toBe(400);
expect(res.body.error).toContain('user_ids');
});
it('400 on create without a name (Zod pipe envelope)', async () => {
const res = await request(server)
.post(`/api/trips/${tripId}/budget`)
.set('Cookie', sessionCookie(1))
.send({ total_price: 10 });
expect(res.status).toBe(400);
expect(res.body.error.toLowerCase()).toContain('name');
});
it('200 on settlement update with permission, persisting the new amount', async () => {
const created = await request(server)
.post(`/api/trips/${tripId}/budget/settlements`)
.set('Cookie', sessionCookie(1))
.send({ from_user_id: 2, to_user_id: 1, amount: 10 });
expect(created.status).toBe(201);
const res = await request(server)
.put(`/api/trips/${tripId}/budget/settlements/${created.body.settlement.id}`)
.set('Cookie', sessionCookie(1))
.send({ from_user_id: 2, to_user_id: 1, amount: 15 });
expect(res.status).toBe(200);
expect(res.body.settlement).toMatchObject({ id: created.body.settlement.id, from_user_id: 2, to_user_id: 1, amount: 15 });
const row = db.prepare('SELECT amount FROM budget_settlements WHERE id = ?').get(created.body.settlement.id);
expect(row).toEqual({ amount: 15 });
});
it('404 on settlement update when it does not exist', async () => {
const res = await request(server)
.put(`/api/trips/${tripId}/budget/settlements/424242`)
.set('Cookie', sessionCookie(1))
.send({ from_user_id: 2, to_user_id: 1, amount: 15 });
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Settlement not found' });
});
});