Files
TREK/server/tests/unit/nest/journey-stats.service.test.ts
T
Maurice 6cbefd23e8 feat(studio): filter photos by entry, upload from Studio, and switch stops off
The content browser gets a filter over the journey's pictures: all of them,
the ones no entry holds, or one entry at a time, with the day and the place
under each name. An entry card in the Entries tab reaches its own pictures
the same way. Pictures can now come in without leaving Studio, into the
entry the filter names or into the gallery, from the panel or dropped
straight onto the page, and a photo element can fill its page or the whole
spread in one press.

Stops can be taken out of the arithmetic. A journal entry carries a
stats_excluded flag; an entry that has it is off the route, out of the
distance, out of the countries and not a step, while staying in the
journal. Studio lists every stop in the travel panel with a switch, the
entry editor carries the same switch on desktop and phone, and the entry
card says when a day is off the route.
2026-09-03 10:47:08 +02:00

515 lines
21 KiB
TypeScript

/**
* journeyStats() — the SQL half of the journey figures (#1973).
*
* A real in-memory SQLite DB, because what is being tested here is the queries:
* which rows become the route, how a place assigned to three days is counted
* once, and which source wins when a journey has both entries and trip places.
* The arithmetic those rows feed into has its own tests in journey-stats.test.ts.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
return {
testDb: db,
dbMock: {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: () => null,
isOwner: () => false,
},
};
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
/*
* The country lookup is stubbed rather than exercised. The real one loads and
* indexes 4MB of gzipped admin-0 boundaries on first call — minutes of work
* across a test suite, to verify a function that belongs to Atlas and has its
* own tests. What matters here is *which coordinates get asked about* and that
* the place_regions cache is preferred, both of which the stub shows.
*/
const countryCalls: [number, number][] = [];
vi.mock('../../../src/nest/atlas/atlas-geo', () => ({
getCountryFromCoords: (lat: number, lng: number) => {
countryCalls.push([lat, lng]);
// Rough boxes, enough to tell two countries apart in a fixture.
if (lat > 63 && lng < -13) return 'IS';
if (lat > 47 && lat < 55 && lng > 5 && lng < 15) return 'DE';
if (lat > 42 && lat < 51 && lng > -5 && lng < 8) return 'FR';
return null;
},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import {
createUser, createTrip, createJourney, createJourneyEntry, createPlace,
createDay, createDayAssignment, linkTripToJourney,
} from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { db as dbConn } from '../../../src/db/database';
const dbs = new DatabaseService(dbConn);
const svc = new JourneyDomainService(dbs, new RealtimeService(), new TrekPhotosRepository(dbs));
/** The factory has no coordinate fields, and a route is made of coordinates. */
function placeEntry(
journeyId: number,
authorId: number,
lat: number,
lng: number,
overrides: { title?: string; entry_date?: string; stats_excluded?: number } = {},
) {
const entry = createJourneyEntry(testDb, journeyId, authorId, overrides);
testDb
.prepare('UPDATE journey_entries SET location_lat = ?, location_lng = ? WHERE id = ?')
.run(lat, lng, entry.id);
return entry;
}
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
countryCalls.length = 0;
});
afterAll(() => {
testDb.close();
});
describe('journeyStats access', () => {
it('is null for a journey the user cannot see', () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
expect(svc.journeyStats(journey.id, stranger.id)).toBeNull();
});
it('is null for a journey that does not exist', () => {
const { user } = createUser(testDb);
expect(svc.journeyStats(999_999, user.id)).toBeNull();
});
it('answers for the owner', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const stats = svc.journeyStats(journey.id, user.id);
expect(stats).not.toBeNull();
expect(stats!.journeyId).toBe(journey.id);
});
});
describe('journeyStats route', () => {
it('builds the route from the entries when they carry coordinates', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' });
placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'Akureyri', entry_date: '2026-06-06' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.map(p => p.label)).toEqual(['Reykjavík', 'Akureyri']);
expect(stats.distance).toBeGreaterThan(240_000);
expect(stats.days).toBe(5);
expect(stats.steps).toBe(2);
});
it('orders the route by entry date, not by insertion', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'second', entry_date: '2026-06-06' });
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'first', entry_date: '2026-06-02' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.map(p => p.label)).toEqual(['first', 'second']);
});
it('skips entries without coordinates but still counts them as steps', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
createJourneyEntry(testDb, journey.id, user.id, { title: 'no place', entry_date: '2026-06-03' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points).toHaveLength(1);
expect(stats.steps).toBe(2);
});
/*
* The fallback that makes the feature work at all for the common case: a
* journey built by adding trips, before anyone has written a word.
*/
it('falls back to the trip places when no entry has coordinates', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
const day1 = createDay(testDb, trip.id, { date: '2026-06-02' });
const day2 = createDay(testDb, trip.id, { date: '2026-06-04' });
const a = createPlace(testDb, trip.id, { name: 'Hallgrímskirkja', lat: 64.14, lng: -21.93 });
const b = createPlace(testDb, trip.id, { name: 'Goðafoss', lat: 65.68, lng: -17.55 });
createDayAssignment(testDb, day2.id, b.id);
createDayAssignment(testDb, day1.id, a.id);
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.map(p => p.label)).toEqual(['Hallgrímskirkja', 'Goðafoss']);
expect(stats.days).toBe(3);
});
it('prefers the entries over the trip places rather than mixing them', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
createPlace(testDb, trip.id, { name: 'a place', lat: 48.85, lng: 2.35 });
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'an entry', entry_date: '2026-06-02' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.map(p => p.label)).toEqual(['an entry']);
});
/*
* A hotel across three nights is one place with three assignments. Left
* un-aggregated the join would put it on the route three times and charge the
* journey two extra legs of zero length — harmless — plus two extra stops in
* the middle of the route, which is not.
*/
it('visits a place assigned to several days only once', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
const hotel = createPlace(testDb, trip.id, { name: 'Hotel', lat: 64.14, lng: -21.94 });
for (const date of ['2026-06-02', '2026-06-03', '2026-06-04']) {
createDayAssignment(testDb, createDay(testDb, trip.id, { date }).id, hotel.id);
}
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.filter(p => p.label === 'Hotel')).toHaveLength(1);
expect(stats.places).toBe(1);
});
it('puts unscheduled places after the scheduled ones', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
const loose = createPlace(testDb, trip.id, { name: 'unscheduled', lat: 64.2, lng: -21.8 });
const planned = createPlace(testDb, trip.id, { name: 'scheduled', lat: 64.14, lng: -21.94 });
createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-02' }).id, planned.id);
expect(loose.id).toBeGreaterThan(0);
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.map(p => p.label)).toEqual(['scheduled', 'unscheduled']);
});
it('counts places across every trip on the journey', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const one = createTrip(testDb, user.id);
const two = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, one.id);
linkTripToJourney(testDb, journey.id, two.id);
createPlace(testDb, one.id, { lat: 48.85, lng: 2.35 });
createPlace(testDb, two.id, { lat: 52.52, lng: 13.4 });
createPlace(testDb, two.id, { lat: 50.11, lng: 8.68 });
expect(svc.journeyStats(journey.id, user.id)!.places).toBe(3);
});
});
describe('journeyStats countries', () => {
it('resolves each stop to a country and names it', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
placeEntry(journey.id, user.id, 52.52, 13.4, { entry_date: '2026-06-20' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.countries.map(c => c.code)).toEqual(['IS', 'DE']);
expect(stats.countries[0].name).toBe('Iceland');
expect(stats.countries[1].name).toBe('Germany');
});
/*
* Atlas already resolved these and wrote them down. Reading its cache instead
* of repeating a point-in-polygon test is the difference between a query and
* a boundary index.
*/
it('prefers the place_regions cache over recomputing a place country', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
const place = createPlace(testDb, trip.id, { name: 'somewhere', lat: 64.14, lng: -21.94 });
testDb
.prepare('INSERT INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)')
.run(place.id, 'fr', 'FR-75', 'Paris');
const stats = svc.journeyStats(journey.id, user.id)!;
// The cached answer wins even though the coordinates say Iceland, and the
// expensive lookup is never reached for that place.
expect(stats.countries.map(c => c.code)).toEqual(['FR']);
expect(countryCalls).toHaveLength(0);
});
it('leaves a stop with no country out of the list rather than inventing one', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, -33.86, 151.2, { entry_date: '2026-06-02' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.countries).toEqual([]);
expect(stats.points).toHaveLength(1);
expect(stats.points[0].country).toBeNull();
});
});
describe('journeyStats totals', () => {
it('counts the gallery photographs', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const photo = testDb
.prepare("INSERT INTO trek_photos (provider, file_path, owner_id) VALUES ('local', 'x.jpg', ?)")
.run(user.id);
testDb
.prepare('INSERT INTO journey_photos (journey_id, photo_id, sort_order, created_at) VALUES (?, ?, 0, ?)')
.run(journey.id, photo.lastInsertRowid, Date.now());
expect(svc.journeyStats(journey.id, user.id)!.photos).toBe(1);
});
it('falls back to the trip dates when the entries carry none', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id, { start_date: '2026-06-01', end_date: '2026-06-10' });
linkTripToJourney(testDb, journey.id, trip.id);
createPlace(testDb, trip.id, { lat: 64.14, lng: -21.94 });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.start).toBe('2026-06-01');
expect(stats.end).toBe('2026-06-10');
expect(stats.days).toBe(10);
});
it('is all zeroes for an empty journey rather than failing', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.distance).toBe(0);
expect(stats.days).toBe(0);
expect(stats.steps).toBe(0);
expect(stats.photos).toBe(0);
expect(stats.places).toBe(0);
expect(stats.countries).toEqual([]);
expect(stats.points).toEqual([]);
});
it('answers for a contributor, not only the owner', () => {
const { user: owner } = createUser(testDb);
const { user: helper } = createUser(testDb);
const journey = createJourney(testDb, owner.id);
testDb
.prepare('INSERT INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, ?, ?)')
.run(journey.id, helper.id, 'editor', Date.now());
expect(svc.journeyStats(journey.id, helper.id)).not.toBeNull();
});
});
/*
* The stop somebody switched off (discussion #2064): the home airport, the
* stopover, the place the trip was planned from. It stays in the journal and
* leaves every figure, and it is named under `excluded` so that it can be
* switched back on.
*/
describe('journeyStats excluded stops', () => {
it('leaves a switched-off entry out of the route, the distance and the countries', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 52.52, 13.4, { title: 'Berlin, the airport', entry_date: '2026-06-01', stats_excluded: 1 });
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' });
placeEntry(journey.id, user.id, 65.68, -18.12, { title: 'Akureyri', entry_date: '2026-06-06' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.map(p => p.label)).toEqual(['Reykjavík', 'Akureyri']);
// Reykjavík to Akureyri and nothing else; the leg in from Berlin is gone.
expect(stats.distance).toBeLessThan(300_000);
expect(stats.countries.map(c => c.code)).toEqual(['IS']);
expect(stats.start).toBe('2026-06-02');
expect(stats.days).toBe(5);
});
it('names the switched-off stop under excluded so it can be switched back on', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const airport = placeEntry(journey.id, user.id, 63.98, -22.62, { title: 'Keflavík', entry_date: '2026-06-01', stats_excluded: 1 });
placeEntry(journey.id, user.id, 64.14, -21.94, { title: 'Reykjavík', entry_date: '2026-06-02' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.excluded).toEqual([{ entryId: airport.id, label: 'Keflavík', date: '2026-06-01' }]);
});
it('does not count a switched-off entry as a step', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
placeEntry(journey.id, user.id, 63.98, -22.62, { entry_date: '2026-06-01', stats_excluded: 1 });
placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
expect(svc.journeyStats(journey.id, user.id)!.steps).toBe(1);
});
/* An entry without a place was never a stop, so there is nothing to offer back. */
it('lists nothing under excluded for a switched-off entry without coordinates', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
createJourneyEntry(testDb, journey.id, user.id, { title: 'a thought', entry_date: '2026-06-03', stats_excluded: 1 });
placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.excluded).toEqual([]);
expect(stats.steps).toBe(1);
});
it('carries the entry on every stop read from the entries', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const a = placeEntry(journey.id, user.id, 64.14, -21.94, { entry_date: '2026-06-02' });
const b = placeEntry(journey.id, user.id, 65.68, -18.12, { entry_date: '2026-06-06' });
expect(svc.journeyStats(journey.id, user.id)!.points.map(p => p.entryId)).toEqual([a.id, b.id]);
});
it('drops the place behind a switched-off skeleton from the place count', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
const airport = createPlace(testDb, trip.id, { name: 'Keflavík', lat: 63.98, lng: -22.62 });
const town = createPlace(testDb, trip.id, { name: 'Vík', lat: 63.42, lng: -19.01 });
createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-01' }).id, airport.id);
createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-02' }).id, town.id);
svc.syncTripPlaces(journey.id, trip.id, user.id);
testDb
.prepare('UPDATE journey_entries SET stats_excluded = 1 WHERE journey_id = ? AND source_place_id = ?')
.run(journey.id, airport.id);
const stats = svc.journeyStats(journey.id, user.id)!;
// The town's skeleton still draws the route, so this is the entries path.
expect(stats.points.map(p => p.label)).toEqual(['Vík']);
expect(stats.excluded.map(s => s.label)).toEqual(['Keflavík']);
expect(stats.places).toBe(1);
});
/*
* The fallback honours the switch too. A skeleton is the entry TREK derives
* from a trip place; switching it off and then drawing the place it came
* from would put the same stop straight back on the map by its other name.
*/
it('leaves the place behind a switched-off skeleton out of the fallback route', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
const airport = createPlace(testDb, trip.id, { name: 'Keflavík', lat: 63.98, lng: -22.62 });
createPlace(testDb, trip.id, { name: 'Vík', lat: 63.42, lng: -19.01 });
// Only the airport is on a day, so only the airport gets a skeleton; the
// town stays a bare trip place that the fallback is there to draw.
createDayAssignment(testDb, createDay(testDb, trip.id, { date: '2026-06-01' }).id, airport.id);
svc.syncTripPlaces(journey.id, trip.id, user.id);
// Switched off, and its point cleared by hand afterwards, which is what
// sends the journey down the fallback while the skeleton still names the
// place it came from.
testDb
.prepare(`
UPDATE journey_entries
SET stats_excluded = 1, location_lat = NULL, location_lng = NULL
WHERE journey_id = ? AND source_place_id = ?
`)
.run(journey.id, airport.id);
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.map(p => p.label)).toEqual(['Vík']);
expect(stats.points[0].entryId).toBeNull();
expect(stats.steps).toBe(0);
expect(stats.places).toBe(1);
});
/*
* The pair of cases that decide which source a journey reads from, now that
* a stop can be taken out of one of them.
*/
it('draws no route at all when every stop of the journal is switched off', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
createPlace(testDb, trip.id, { name: 'a trip place', lat: 48.85, lng: 2.35 });
placeEntry(journey.id, user.id, 52.52, 13.4, { title: 'Berlin, the airport', entry_date: '2026-06-01', stats_excluded: 1 });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points).toEqual([]);
expect(stats.distance).toBe(0);
expect(stats.excluded.map(s => s.label)).toEqual(['Berlin, the airport']);
});
it('still falls back to the trip places for a journal that never had a point', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const trip = createTrip(testDb, user.id);
linkTripToJourney(testDb, journey.id, trip.id);
createPlace(testDb, trip.id, { name: 'a trip place', lat: 48.85, lng: 2.35 });
createJourneyEntry(testDb, journey.id, user.id, { title: 'written, not placed', stats_excluded: 1 });
const stats = svc.journeyStats(journey.id, user.id)!;
expect(stats.points.map(p => p.label)).toEqual(['a trip place']);
});
});