Files
TREK/server/tests/integration/leg-mode-incoming.test.ts
T
Maurice 3c5e406229 feat(places): link an expense to a place, the way bookings already do (#1917)
* feat(places): link an expense to a place, the way bookings already do

Recording what a museum ticket or a guided tour cost meant leaving the place,
opening the Costs tab and typing it in again — while a booking has had a Costs
block in its own form all along.

The place form now carries that block. Same component, same flow: with nothing
linked it offers Create expense, which saves the place and then opens the Costs
editor for a new expense pointing at it; once linked it shows the expense with
edit and remove. budget_items gains place_id beside the reservation_id it
already had, with the same nullable FK and the same ON DELETE SET NULL. Desktop
and phone both, and the mobile editor carries the link through its own save path
rather than the desktop one.

The expense belongs to the place, not to a day assignment, so a place sitting on
five days is not five tickets. Hanging it off the assignment would have meant
adding a place to three days quietly creating three expenses and removing a day
quietly deleting one.

Deleting a place takes its expense with it in the same transaction and announces
it, on every path that deletes a place: REST, both MCP tools and the plugin RPC.

Closes #1298

* test(places): teach the RPC stubs about linkedExpenseIds

The delete paths read the linked expense before the place is gone, so the two
hand-built PlacesService stubs threw on the new method instead of reaching the
hook, the remove and the broadcast — four failures out of one missing line.
2026-08-12 21:37:12 +02:00

266 lines
12 KiB
TypeScript

/**
* Migration test for incoming_leg_transport_mode on day_assignments, plus
* read-path parity: the field must survive both the single-assignment
* projection and the day-LIST projection.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import request from 'supertest';
import type { Application } from 'express';
import type { INestApplication } from '@nestjs/common';
const Database = require('better-sqlite3');
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
let testDb: any;
beforeAll(() => {
testDb = new Database(':memory:');
testDb.exec('PRAGMA journal_mode = WAL');
testDb.exec('PRAGMA foreign_keys = ON');
createTables(testDb);
runMigrations(testDb);
});
afterAll(() => {
testDb.close();
});
describe('incoming_leg_transport_mode migration', () => {
it('adds a nullable incoming_leg_transport_mode column to day_assignments', () => {
const cols = testDb.prepare(`PRAGMA table_info(day_assignments)`).all() as { name: string }[];
expect(cols.map(c => c.name)).toContain('incoming_leg_transport_mode');
});
// The case above starts from an empty database and runs every migration, so it
// passes wherever a migration sits in the array.
it('the trailing migration lands on a database that is one version behind', () => {
// What actually has to hold: existing installs replay only the slots above
// their schema_version, so a migration inserted mid-array is silently
// skipped on every one of them. Undoing what the LAST migration did and
// rewinding one version is the only way to prove it still runs.
//
// >>> Appending a migration? Re-point the DROP below at the column yours
// >>> adds. That is the whole maintenance cost of this guard.
const TRAILING_TABLE = 'budget_items';
const TRAILING_COLUMN = 'place_id';
const upgraded = new Database(':memory:');
upgraded.exec('PRAGMA foreign_keys = ON');
createTables(upgraded);
runMigrations(upgraded);
const { version } = upgraded.prepare('SELECT version FROM schema_version').get() as { version: number };
upgraded.exec(`ALTER TABLE ${TRAILING_TABLE} DROP COLUMN ${TRAILING_COLUMN}`);
upgraded.prepare('UPDATE schema_version SET version = ?').run(version - 1);
runMigrations(upgraded);
const cols = upgraded.prepare(`PRAGMA table_info(${TRAILING_TABLE})`).all() as { name: string }[];
expect(cols.map(c => c.name)).toContain(TRAILING_COLUMN);
expect(upgraded.prepare('SELECT version FROM schema_version').get()).toEqual({ version });
upgraded.close();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Read-path parity (day-LIST endpoint) — mirrors the harness in
// tests/integration/assignments.test.ts verbatim.
// ─────────────────────────────────────────────────────────────────────────────
const { testDb2, dbMock } = vi.hoisted(() => {
const Database2 = require('better-sqlite3');
const db = new Database2(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb2: db, dbMock: mock };
});
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
SESSION_DURATION: '24h',
SESSION_DURATION_MS: 86400000,
SESSION_DURATION_SECONDS: 86400,
DEFAULT_LANGUAGE: 'en',
}));
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { buildApp } from '../../src/bootstrap';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, createDay, createPlace } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
describe('incoming_leg_transport_mode read-path parity', () => {
let nestApp: INestApplication;
let app: Application;
beforeAll(async () => {
createTables(testDb2);
runMigrations(testDb2);
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
});
beforeEach(() => {
resetTestDb(testDb2);
resetRateLimits(nestApp);
});
afterAll(async () => {
await nestApp.close();
testDb2.close();
});
it('round-trips incoming_leg_transport_mode through the day-LIST endpoint', async () => {
const { user } = createUser(testDb2);
const trip = createTrip(testDb2, user.id);
const day = createDay(testDb2, trip.id, { date: '2025-06-01' });
const place = createPlace(testDb2, trip.id, { name: 'Test Place' });
const create = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id });
expect(create.status).toBe(201);
const assignmentId = create.body.assignment.id;
testDb2.prepare('UPDATE day_assignments SET incoming_leg_transport_mode = ? WHERE id = ?').run('transit', assignmentId);
const res = await request(app)
.get(`/api/trips/${trip.id}/days`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
const foundDay = res.body.days.find((d: any) => d.id === day.id);
const a = foundDay.assignments.find((x: any) => x.id === assignmentId);
expect(a.incoming_leg_transport_mode).toBe('transit');
});
// ───────────────────────────────────────────────────────────────────────────
// Write path — PUT .../transport gains `direction` (outgoing|incoming).
// ───────────────────────────────────────────────────────────────────────────
describe('PUT /assignments/:id/transport direction', () => {
it('defaults to outgoing (back-compat)', async () => {
const { user } = createUser(testDb2);
const trip = createTrip(testDb2, user.id);
const day = createDay(testDb2, trip.id, { date: '2025-06-01' });
const place = createPlace(testDb2, trip.id, { name: 'Test Place' });
const create = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id });
const assignmentId = create.body.assignment.id;
const res = await request(app)
.put(`/api/trips/${trip.id}/assignments/${assignmentId}/transport`)
.set('Cookie', authCookie(user.id))
.send({ transport_mode: 'cycling' });
expect(res.status).toBe(200);
const row = testDb2.prepare('SELECT leg_transport_mode, incoming_leg_transport_mode FROM day_assignments WHERE id = ?').get(assignmentId);
expect(row.leg_transport_mode).toBe('cycling');
expect(row.incoming_leg_transport_mode).toBeNull();
});
it("direction: 'incoming' writes the incoming column", async () => {
const { user } = createUser(testDb2);
const trip = createTrip(testDb2, user.id);
const day = createDay(testDb2, trip.id, { date: '2025-06-01' });
const place = createPlace(testDb2, trip.id, { name: 'Test Place' });
const create = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id });
const assignmentId = create.body.assignment.id;
const res = await request(app)
.put(`/api/trips/${trip.id}/assignments/${assignmentId}/transport`)
.set('Cookie', authCookie(user.id))
.send({ transport_mode: 'transit', direction: 'incoming' });
expect(res.status).toBe(200);
const row = testDb2.prepare('SELECT incoming_leg_transport_mode FROM day_assignments WHERE id = ?').get(assignmentId);
expect(row.incoming_leg_transport_mode).toBe('transit');
});
it('rejects an invalid direction with 400', async () => {
const { user } = createUser(testDb2);
const trip = createTrip(testDb2, user.id);
const day = createDay(testDb2, trip.id, { date: '2025-06-01' });
const place = createPlace(testDb2, trip.id, { name: 'Test Place' });
const create = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id });
const assignmentId = create.body.assignment.id;
const res = await request(app)
.put(`/api/trips/${trip.id}/assignments/${assignmentId}/transport`)
.set('Cookie', authCookie(user.id))
.send({ transport_mode: 'walking', direction: 'sideways' });
expect(res.status).toBe(400);
});
it('outgoing and incoming leg modes on one stop persist independently', async () => {
const { user } = createUser(testDb2);
const trip = createTrip(testDb2, user.id);
const day = createDay(testDb2, trip.id, { date: '2025-06-01' });
const place = createPlace(testDb2, trip.id, { name: 'Test Place' });
const create = await request(app)
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
.set('Cookie', authCookie(user.id))
.send({ place_id: place.id });
const assignmentId = create.body.assignment.id;
await request(app)
.put(`/api/trips/${trip.id}/assignments/${assignmentId}/transport`)
.set('Cookie', authCookie(user.id))
.send({ transport_mode: 'cycling' })
.expect(200);
await request(app)
.put(`/api/trips/${trip.id}/assignments/${assignmentId}/transport`)
.set('Cookie', authCookie(user.id))
.send({ transport_mode: 'transit', direction: 'incoming' })
.expect(200);
const row = testDb2.prepare('SELECT leg_transport_mode, incoming_leg_transport_mode FROM day_assignments WHERE id = ?').get(assignmentId);
expect(row.leg_transport_mode).toBe('cycling');
expect(row.incoming_leg_transport_mode).toBe('transit');
const res = await request(app)
.get(`/api/trips/${trip.id}/days`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
const foundDay = res.body.days.find((d: any) => d.id === day.id);
const a = foundDay.assignments.find((x: any) => x.id === assignmentId);
expect(a.leg_transport_mode).toBe('cycling');
expect(a.incoming_leg_transport_mode).toBe('transit');
});
});
});