Files
TREK/server/tests/e2e/journey-recordings.e2e.test.ts
T

333 lines
14 KiB
TypeScript

/**
* Recorded days a journey keeps (JRN-01), through the real AddonGuard and
* JwtAuthGuard against a migrated and seeded temp SQLite db, every answer
* checked against its response contract. The Dawarich client is the one
* thing replaced: a test never opens a socket to a location archive. The
* broadcasts go to a FakeRealtimeService.
*
* Fixtures: user 1 owns journey 7 and has a Dawarich connection, user 2 is an
* editor of it with no connection, user 3 a viewer, user 4 has no access.
*
* The walk: preview, keep, read the day back once the connection is gone,
* take the same day over again without a second copy, replace it on purpose,
* keep the version when Dawarich fails, and drop it. Then the preparation of
* the journey's days (JRN-03) as each caller sees it.
*/
import { DawarichConnections } from '../../src/db/entities/DawarichConnections.entity';
import { JourneyRecordedDays } from '../../src/db/entities/JourneyRecordedDays.entity';
import { Journeys } from '../../src/db/entities/Journeys.entity';
import { encrypt_api_key } from '../../src/nest/common/crypto/apiKeyCrypto';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { withRequestContext } from '../../src/nest/database/request-context';
import { DawarichSyncService } from '../../src/nest/integrations/dawarich-sync.service';
import { DawarichClient, DawarichError } from '../../src/nest/integrations/dawarich.client';
import { JourneyRecordingsModule } from '../../src/nest/journey-recordings/journey-recordings.module';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
import { createSnapshotTestDb } from '../helpers/db-mock';
import { addJourneyContributor, linkTripToJourney, makeJourney } from '../helpers/factories/journeys';
import { countRows, deleteRows, insertRow, updateRows } from '../helpers/factories/rows';
import { setAddonEnabled } from '../helpers/factories/settings';
import { makeTrip } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { FakeRealtimeService } from '../helpers/fake-realtime';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
import { sessionCookie } from './harness';
import { MikroORM } from '@mikro-orm/core';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logDebug: vi.fn(),
logError: vi.fn(),
logWarn: vi.fn(),
}));
const db = createSnapshotTestDb();
const realtime = new FakeRealtimeService();
const client = {
probe: vi.fn(),
listVisits: vi.fn(),
listTracks: vi.fn(),
listPoints: vi.fn(),
listVisitedCities: vi.fn(),
findVisitsNear: vi.fn(),
};
let orm: TestOrm;
let journeyId: number;
const OWNER = 1;
const EDITOR = 2;
const VIEWER = 3;
const OUTSIDER = 4;
const DAY = '2026-05-14';
/** A walk across Venice on the 14th, in the zone Dawarich records in. */
function walk(endLng = 12.3218) {
return {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [
[12.3388, 45.434],
[endLng, 45.4364],
],
},
properties: {
start_at: `${DAY}T09:00:00+02:00`,
end_at: `${DAY}T09:40:00+02:00`,
dominant_mode: 'walking',
distance: 1500,
},
};
}
describe('Journey recordings e2e (real guards + temp SQLite)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
async function build() {
const moduleRef = await Test.createTestingModule({
imports: [
await TestUnitOfWorkModule.forRoot(db),
await createTestMikroOrmModule(db),
RealtimeModule,
JourneyRecordingsModule,
],
})
.overrideProvider(RealtimeService)
.useValue(realtime)
.overrideProvider(DawarichClient)
.useValue(client)
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalPipes(new ZodValidationPipe());
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
return nest;
}
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
const base = () => `/api/journeys/${journeyId}/recordings`;
const preview = (userId = OWNER) =>
request(server)
.post(`${base()}/preview`)
.set(as(userId))
.send({ date: DAY, utcOffsetMinutes: 0, useSourceOffset: true });
beforeAll(async () => {
orm = await createTestOrm(db);
// Pinned ids: the session cookies sign for exactly these users.
const user = (id: number, username: string) =>
makeUser(orm, { id, username, email: `${username}@example.test`, role: 'user', password_version: 0 });
await user(OWNER, 'marta');
await user(EDITOR, 'jonas');
await user(VIEWER, 'viewer');
await user(OUTSIDER, 'outsider');
const journey = await makeJourney(orm, OWNER, { title: 'Venice' });
journeyId = journey.id;
await addJourneyContributor(orm, journeyId, EDITOR, 'editor');
await addJourneyContributor(orm, journeyId, VIEWER, 'viewer');
app = await build();
server = app.getHttpServer();
});
beforeEach(async () => {
await setAddonEnabled(orm, 'journey', true);
await setAddonEnabled(orm, 'dawarich', true);
await deleteRows(orm, JourneyRecordedDays);
await deleteRows(orm, DawarichConnections);
await insertRow(orm, DawarichConnections, {
user: OWNER,
url: 'https://dawarich.example',
api_key: encrypt_api_key('secret-key'),
});
for (const fn of Object.values(client)) fn.mockReset();
client.listTracks.mockResolvedValue({ features: [walk()], truncated: false });
client.listPoints.mockResolvedValue({ points: [], truncated: false });
realtime.broadcastToUserMock.mockClear();
});
afterAll(async () => {
await app.close();
await orm.close();
});
it('answers 404 with the Journey addon off before asking who the caller is, and 401 without a session', async () => {
await setAddonEnabled(orm, 'journey', false);
expect((await request(server).get(base())).status).toBe(404);
await setAddonEnabled(orm, 'journey', true);
expect((await request(server).get(base())).status).toBe(401);
});
it('previews the day for the journey without keeping it, then keeps it on the word of the traveller', async () => {
const shown = await preview();
expect(shown.status).toBe(200);
expect(shown.body).toMatchObject({
journey: { id: journeyId, title: 'Venice' },
date: DAY,
utcOffsetMinutes: 120,
offsetSource: 'source',
window: { from: '2026-05-13T22:00:00.000Z', to: '2026-05-14T22:00:00.000Z' },
sourceKind: 'tracks',
stats: { distanceMeters: 1500, segmentCount: 1, gapCount: 0, modes: ['walking'] },
stored: null,
audience: 2,
});
expect(await countRows(orm, JourneyRecordedDays)).toBe(0);
const kept = await request(server)
.put(`${base()}/${DAY}`)
.set(as(OWNER))
.set('X-Socket-Id', '11')
.send({ utcOffsetMinutes: 120, expectedHash: shown.body.contentHash });
expect(kept.status).toBe(200);
expect(kept.body).toMatchObject({ outcome: 'created', day: { date: DAY, importedBy: { username: 'marta' } } });
expect(realtime.broadcastToUserMock).toHaveBeenCalledWith(
VIEWER,
{ type: 'journey:recording:updated', journeyId, date: DAY },
'11',
);
});
it('keeps the day readable for every member once the connection is gone, and never copies it twice', async () => {
const shown = await preview();
const body = { utcOffsetMinutes: 120, expectedHash: shown.body.contentHash };
await request(server).put(`${base()}/${DAY}`).set(as(OWNER)).send(body);
const again = await request(server).put(`${base()}/${DAY}`).set(as(OWNER)).send(body);
expect(again.status).toBe(200);
expect(again.body.outcome).toBe('unchanged');
expect(await countRows(orm, JourneyRecordedDays)).toBe(1);
await deleteRows(orm, DawarichConnections);
await setAddonEnabled(orm, 'dawarich', false);
client.listTracks.mockReset();
const listed = await request(server).get(base()).set(as(VIEWER));
expect(listed.status).toBe(200);
expect(listed.body.days).toHaveLength(1);
expect(listed.body.days[0]).toMatchObject({ date: DAY, segments: [{ mode: 'walking' }] });
expect(client.listTracks).not.toHaveBeenCalled();
expect((await request(server).get(base()).set(as(OUTSIDER))).status).toBe(404);
});
it('replaces a kept day only when asked, and a failing Dawarich leaves the kept version as it was', async () => {
const first = await preview();
await request(server)
.put(`${base()}/${DAY}`)
.set(as(OWNER))
.send({ utcOffsetMinutes: 120, expectedHash: first.body.contentHash });
client.listTracks.mockResolvedValue({ features: [walk(12.3), walk(12.31)], truncated: false });
const grown = await preview();
expect(grown.body).toMatchObject({ sameAsStored: false, stored: { stats: { segmentCount: 1 } } });
const body = { utcOffsetMinutes: 120, expectedHash: grown.body.contentHash };
const refused = await request(server).put(`${base()}/${DAY}`).set(as(OWNER)).send(body);
expect(refused.status).toBe(409);
expect(refused.body).toEqual({
error: 'The journey already keeps another recording of that day',
code: 'recording_exists',
});
client.listTracks.mockRejectedValue(new DawarichError('server_error', 'Dawarich answered HTTP 500', 500));
client.listPoints.mockRejectedValue(new DawarichError('server_error', 'Dawarich answered HTTP 500', 500));
const failed = await request(server)
.put(`${base()}/${DAY}`)
.set(as(OWNER))
.send({ ...body, replace: true });
expect(failed.status).toBe(502);
expect(failed.body).toMatchObject({ code: 'server_error' });
const unchanged = await request(server).get(base()).set(as(OWNER));
expect(unchanged.body.days[0].contentHash).toBe(first.body.contentHash);
client.listTracks.mockResolvedValue({ features: [walk(12.3), walk(12.31)], truncated: false });
const replaced = await request(server)
.put(`${base()}/${DAY}`)
.set(as(OWNER))
.send({ ...body, replace: true });
expect(replaced.status).toBe(200);
expect(replaced.body.outcome).toBe('replaced');
expect(await countRows(orm, JourneyRecordedDays)).toBe(1);
});
it('an editor without a connection is told so, a viewer may not preview, and a bad body or day is a 400', async () => {
const unconnected = await preview(EDITOR);
expect(unconnected.status).toBe(409);
expect(unconnected.body).toEqual({ error: 'Dawarich is not connected', code: 'not_connected' });
expect((await preview(VIEWER)).status).toBe(403);
expect((await preview(OUTSIDER)).status).toBe(404);
const badBody = await request(server)
.post(`${base()}/preview`)
.set(as(OWNER))
.send({ date: DAY, utcOffsetMinutes: 2000 });
expect(badBody.status).toBe(400);
const badDay = await request(server)
.put(`${base()}/2026-13-01`)
.set(as(OWNER))
.send({ utcOffsetMinutes: 0, expectedHash: 'a'.repeat(64) });
expect(badDay.status).toBe(400);
expect(badDay.body).toEqual({ error: 'Invalid date' });
});
it('drops a kept day for an editor, with Dawarich off, and a second drop is a 404', async () => {
const shown = await preview();
await request(server)
.put(`${base()}/${DAY}`)
.set(as(OWNER))
.send({ utcOffsetMinutes: 120, expectedHash: shown.body.contentHash });
await setAddonEnabled(orm, 'dawarich', false);
expect((await request(server).delete(`${base()}/${DAY}`).set(as(VIEWER))).status).toBe(403);
const dropped = await request(server).delete(`${base()}/${DAY}`).set(as(EDITOR));
expect(dropped.status).toBe(200);
expect(dropped.body).toEqual({ success: true });
expect(await countRows(orm, JourneyRecordedDays)).toBe(0);
expect((await request(server).delete(`${base()}/${DAY}`).set(as(EDITOR))).status).toBe(404);
});
it('reports how the journey prepares its days for each caller, off at first and after a sync run once on', async () => {
const off = await request(server).get(`${base()}/prep`).set(as(OWNER));
expect(off.status).toBe(200);
expect(off.body).toEqual({
enabled: false,
connection: 'ready',
lastAttemptAt: null,
lastSuccessAt: null,
lastError: null,
});
const today = new Date().toISOString().slice(0, 10);
const trip = await makeTrip(orm, OWNER, { start_date: today, end_date: today });
await linkTripToJourney(orm, journeyId, trip.id);
await updateRows(orm, Journeys, { id: journeyId }, { auto_prepare_recordings: 1 });
client.listVisits.mockResolvedValue({ visits: [], truncated: false, version: null });
// The cron runs the sync in a request context of its own; called here, the test gives it one.
await withRequestContext(app.get(MikroORM), () => app.get(DawarichSyncService).syncUser(OWNER));
const on = await request(server).get(`${base()}/prep`).set(as(OWNER));
expect(on.body).toMatchObject({ enabled: true, connection: 'ready', lastError: null });
expect(on.body.lastSuccessAt).toEqual(expect.any(String));
expect((await request(server).get(`${base()}/prep`).set(as(EDITOR))).body).toMatchObject({
enabled: true,
connection: 'not_connected',
lastSuccessAt: null,
});
expect((await request(server).get(`${base()}/prep`).set(as(VIEWER))).status).toBe(403);
expect((await request(server).get(`${base()}/prep`).set(as(OUTSIDER))).status).toBe(404);
});
});