Files
TREK/server/tests/integration/leg-mode-incoming.test.ts
T
Maurice 0d3be17f26 Merge branch 'dev' into feat/storage-driver
Seven conflicts, six of them the same shape: dev fixed `res.sendFile` to take
a root plus a basename, because an absolute path 404s under the Nest
ExpressAdapter, and this branch had already replaced those call sites with the
storage layer. Nothing on those paths calls sendFile any more, so the storage
version stands and dev's fix has nothing left to apply to.

The three that needed thought:

**migrations.ts** — both sides appended. The array is index-addressed against
schema_version, so a slot that has shipped in dev keeps the index it shipped
with: journey_books (#1973) stays where it landed and the trip_files prefix
strip goes after it. Safe only because this branch has never been published;
the note above the slot says so.

**journey.controller.ts** — both sides added a constructor dependency, and the
controller needs both. StorageModule is already imported and JourneyBookService
is already a provider, so the module needed nothing.

**journey.controller.test.ts** — dev introduced a `ctl()` factory precisely
because naming every dependency at ninety call sites does not survive a third
one. Kept the factory and gave it the storage stub, then took this branch's
versions of the file cases: they are the ones that await the async storage
calls, where dev's still assume a synchronous unlink. The three Studio book
cases came across unchanged.

The trailing-migration guard now rewinds six slots instead of five and undoes
both sides' work.

Server and client typecheck clean, and the tests that touch any of this pass.
2026-08-19 23:57:44 +02:00

313 lines
15 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 migrations land on a database that is 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 trailing migrations did
// and rewinding is the only way to prove they still run.
//
// >>> Appending a migration? Re-point the undo below at whatever yours
// >>> does. That is the whole maintenance cost of this guard.
// Trailing migrations today (two release windows landed on top of each
// other, so the rewind spans all six):
// version-6 — reservation_day_positions cross-trip cleanup: undone by
// re-inserting a mismatched reservation/day pair.
// version-5 — #1939 instance API key promotion: nothing to undo, the
// seeded users are not admins and hold no keys, so the
// replay is a no-op by its own guards.
// version-4 — trek_photos capture metadata (#1614): column-guarded, so
// the replay is a no-op; the from-scratch case above covers it.
// version-3 — journey_share_tokens.newest_first: undone by dropping the
// column and letting the replay put it back.
// version-2 — journey_books, the TREK Studio document store (#1973):
// undone by dropping the table and its index.
// version-1 — storage slice-2 trip_files 'files/' prefix strip: undone
// by re-inserting a prefixed row.
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.prepare("INSERT INTO users (id, username, email, password_hash) VALUES (1, 'a', 'a@test.local', 'x')").run();
upgraded.prepare("INSERT INTO users (id, username, email, password_hash) VALUES (2, 'b', 'b@test.local', 'x')").run();
const tripA = upgraded.prepare("INSERT INTO trips (user_id, title) VALUES (1, 'A')").run().lastInsertRowid;
const tripB = upgraded.prepare("INSERT INTO trips (user_id, title) VALUES (2, 'B')").run().lastInsertRowid;
const reservationOnA = upgraded.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'r', 'other')").run(tripA).lastInsertRowid;
const dayOnA = upgraded.prepare("INSERT INTO days (trip_id, day_number, date) VALUES (?, 1, '2026-01-01')").run(tripA).lastInsertRowid;
const dayOnB = upgraded.prepare("INSERT INTO days (trip_id, day_number, date) VALUES (?, 1, '2026-01-01')").run(tripB).lastInsertRowid;
const insertPosition = upgraded.prepare('INSERT INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)');
insertPosition.run(reservationOnA, dayOnB, 1); // the mismatched pair the migration clears
insertPosition.run(reservationOnA, dayOnA, 2); // a legitimate one it must leave alone
upgraded.prepare("INSERT INTO trip_files (trip_id, filename, original_name) VALUES (?, 'files/aaa.pdf', 'a.pdf')").run(tripA);
const hasNewestFirst = () =>
(upgraded.prepare("SELECT name FROM pragma_table_info('journey_share_tokens')").all() as { name: string }[])
.some(c => c.name === 'newest_first');
expect(hasNewestFirst()).toBe(true);
upgraded.exec('ALTER TABLE journey_share_tokens DROP COLUMN newest_first');
expect(hasNewestFirst()).toBe(false);
upgraded.exec('DROP INDEX IF EXISTS idx_journey_books_journey');
upgraded.exec('DROP TABLE IF EXISTS journey_books');
expect(
upgraded.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'journey_books'").get()
).toBeUndefined();
upgraded.prepare('UPDATE schema_version SET version = ?').run(version - 6);
runMigrations(upgraded);
const positions = upgraded.prepare('SELECT day_id FROM reservation_day_positions ORDER BY day_id').all() as { day_id: number }[];
expect(positions).toEqual([{ day_id: Number(dayOnA) }]);
const files = upgraded.prepare('SELECT filename FROM trip_files').all() as { filename: string }[];
expect(files.map(r => r.filename)).toEqual(['aaa.pdf']);
expect(hasNewestFirst()).toBe(true);
expect(
upgraded.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'journey_books'").get()
).toEqual({ name: 'journey_books' });
expect(
upgraded.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_journey_books_journey'").get()
).toEqual({ name: 'idx_journey_books_journey' });
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');
});
});
});