Files
TREK/server/tests/integration/journey-place-order.test.ts
T
Maurice f73f395817 test(server): seed the integration specs through the repositories
The buildApp() suites for auth, bookings, budget, journeys, assignments,
passkeys, profiles, OAuth, the AirTrail tick and the plugin boot order,
plus the storage restore suite, seeded settings, addons and fixture rows
and read back what a request left behind through raw better-sqlite3
statements. They now write and read through the ORM factories and
readers, against the app's own MikroORM (or the suite's test ORM where
one already existed).

The account deletion case keeps every one of its foreign key checks; a
row that used to read back as undefined now reads back as null.
2026-10-08 18:45:32 +02:00

138 lines
6.2 KiB
TypeScript

/**
* Regression for the Task 11 fix-round finding: PlacesService.onDeleted wraps
* a now-async JourneyDomainService.onPlaceDeleted call. The controller must
* await it before the place row (and its `journey_entries.source_place_id`
* FK, ON DELETE SET NULL) is actually deleted — otherwise the hook either
* never runs, or races the delete and finds nothing left to annotate.
*
* Wires PlacesController → PlacesService → JourneyDomainService for real (no
* journey mock), through the real buildApp() HTTP surface, and pins the
* end-to-end detach-then-annotate behaviour that path produces. It is NOT by
* itself a guard against a revert to fire-and-forget: the hook body today has
* no internal await, so it still runs to completion inside the same
* synchronous/microtask turn even if the controller stopped awaiting it, and
* this test would not notice. It becomes a true regression guard once the
* hook body awaits real async DB work of its own — see
* tests/unit/nest/places.controller.test.ts for the unit-level ordering test
* that stubs a macrotask hop and does catch a detached hook today.
*/
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';
vi.mock('../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
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(),
getOnlineUserIds: vi.fn(() => []),
}));
import { db as testDb } from '../../src/db/database';
import { buildApp } from '../../src/bootstrap';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip, createPlace, createJourney, linkTripToJourney } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { invalidatePermissionsCache } from '../../src/nest/permissions/permissions-cache';
import { MikroORM } from '@mikro-orm/core';
import { findRow, insertRow, upsertRow } from '../helpers/factories/rows';
import { Addons } from '../../src/db/entities/Addons.entity';
import { JourneyEntries } from '../../src/db/entities/JourneyEntries.entity';
import { Places } from '../../src/db/entities/Places.entity';
let nestApp: INestApplication;
let app: Application;
let orm: MikroORM;
beforeAll(async () => {
nestApp = await buildApp();
app = nestApp.getHttpAdapter().getInstance();
orm = nestApp.get(MikroORM);
});
beforeEach(async () => {
resetTestDb(testDb);
await resetRateLimits(nestApp);
await invalidatePermissionsCache();
// Enable the journey addon.
await upsertRow(orm, Addons, {
id: 'journey', name: 'Journey', description: 'Travel journal', type: 'global', icon: 'Compass', enabled: true, sort_order: 35,
});
});
afterAll(async () => {
await nestApp.close();
testDb.close();
});
describe('deleting a place detaches its journey entry ahead of the FK cascade', () => {
it('annotates a filled entry with the removal note instead of leaving it silently orphaned', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Café de Flore' });
const journey = createJourney(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
// A filled entry (not a bare skeleton) sourced from this place, the way
// onPlaceDeleted documents: it survives the delete but gets detached and
// annotated, rather than deleted outright (that path is for content-less
// skeletons) or left dangling with a stale source_place_id.
const now = Date.now();
const entryId = await insertRow(orm, JourneyEntries, {
journey: journey.id, sourceTrip: trip.id, sourcePlace: place.id, author: user.id, type: 'entry', title: 'A café',
story: 'Lovely coffee.', entry_date: '2026-01-15', visibility: 'private', sort_order: 0, created_at: now, updated_at: now,
});
const res = await request(app)
.delete(`/api/trips/${trip.id}/places/${place.id}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true, tourPlaceIds: [] });
// The place is gone.
expect(await findRow(orm, Places, { id: place.id })).toBeNull();
// The entry survives, detached and annotated — the FK's ON DELETE SET
// NULL alone would null source_place_id but would never touch story or
// type; only onPlaceDeleted having actually run produces the note.
const entry = (await findRow(orm, JourneyEntries, { id: entryId }))!;
expect(entry.source_place_id).toBeNull();
expect(entry.source_trip_id).toBeNull();
expect(entry.type).toBe('entry');
expect(entry.story).toContain('the original trip place was removed from the trip plan');
});
it('deletes a content-less skeleton outright rather than leaving an orphaned row behind', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Musée Rodin' });
const journey = createJourney(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
const now = Date.now();
const entryId = await insertRow(orm, JourneyEntries, {
journey: journey.id, sourceTrip: trip.id, sourcePlace: place.id, author: user.id, type: 'skeleton', title: place.name,
entry_date: '2026-01-16', visibility: 'private', sort_order: 0, created_at: now, updated_at: now,
});
const res = await request(app)
.delete(`/api/trips/${trip.id}/places/${place.id}`)
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
// onPlaceDeleted ran (and completed) as part of the request: the
// content-less skeleton is gone, not merely detached.
expect(await findRow(orm, JourneyEntries, { id: entryId })).toBeNull();
});
});