Merge dev into fix/prettier

Brings in the Tours addon and the MikroORM follow-ups. Conflicts were
resolved to dev's content, then every file dev brought in was run through
prettier and eslint --fix so the branch stays uniformly formatted.
This commit is contained in:
Maurice
2026-10-07 15:43:13 +02:00
242 changed files with 22952 additions and 731 deletions
@@ -1,6 +1,7 @@
import {
absDifference,
caseWhenEquals,
caseWhenNotNull,
castInteger,
castIntegerKysely,
coalesce,
@@ -704,6 +705,32 @@ describe('sql-functions (sqlite)', () => {
);
});
// Tours (#2586): the assignment projection's `tour_route_geometry` column.
it('SQLF-098: caseWhenNotNull yields the THEN column only where the test column is not null', async () => {
const { user: a } = createUser(testDb);
const { user: b } = createUser(testDb);
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Shown', a.id);
testDb.prepare('UPDATE users SET display_name = NULL WHERE id = ?').run(b.id);
const platform = t.em.getPlatform();
const rows = await t.em
.createQueryBuilder(Users, 'u')
.select(['u.id', caseWhenNotNull(platform, 'u.display_name', 'u.username').as('picked')])
.where({ id: { $in: [a.id, b.id] } })
.orderBy({ id: 'asc' })
.execute('all', false);
expect(rows).toEqual([
{ id: a.id, picked: a.username },
{ id: b.id, picked: null },
]);
});
it('SQLF-099: an unknown platform fails closed for caseWhenNotNull', () => {
class FakePlatform extends Platform {}
const foreign = new FakePlatform();
expect(() => caseWhenNotNull(foreign, 'u.a', 'u.b')).toThrow(/no implementation for platform FakePlatform/);
});
// Task 1 fix review M1 (`PlaceShadowPicksRepository.countBySource`, PS5):
// `countAll()`'s alias cannot be ordered by (`.orderBy({ count: 'desc' })`
// throws "not existing property" — verified in SQLF-024's comment above);
+5 -4
View File
@@ -30,16 +30,17 @@ describe('ALL_ENTITIES', () => {
});
/**
* Plan 2 Task 3: every one of the 127 entities the migrated schema
* Plan 2 Task 3: every one of the 130 entities the migrated schema
* describes (120 rewritten + the 5 document-sync tables added this task,
* + push_subscriptions and google_api_usage from legacy steps 245 and 254)
* + push_subscriptions and google_api_usage from legacy steps 245 and 254,
* + tour_types, tours and tour_waypoints from the Tours migration)
* is registered, and no two share a class name (a silent duplicate would
* shadow one of them in `orm.getMetadata()`, exactly the kind of drift
* `entity-schema-parity.test.ts` can't see — it iterates the metadata map,
* which de-duplicates by class name).
*/
it('ENT-003: exactly 127 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(127);
it('ENT-003: exactly 130 entities, every class name unique', () => {
expect(ALL_ENTITIES.length).toBe(130);
const names = ALL_ENTITIES.map((s) => s.meta.className);
expect(new Set(names).size).toBe(names.length);
});
@@ -15,17 +15,18 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
* `scripts/generate-entities.ts`'s `RULE1d_fixNaNNumericDefaults` and
* `tests/unit/db/generate-entities.test.ts`'s `RULE1D-*` fixture tests for
* the mechanism). This file pins the OUTPUT of that rule against the real,
* committed entities — every one of the 127, not a sample — and reproduces
* committed entities — every one of the 130, not a sample — and reproduces
* the reviewer's own insert probe so a future regenerate/hand-edit that
* reintroduces a `NaN` initialiser fails loudly here, not silently at a
* caller's first `em.create()`.
*/
/** NOT NULL scalars with neither a column default nor a class-field initialiser (legitimate: the caller must supply them). */
// 424 + push_subscriptions (legacy step 245): id, user_id, endpoint, p256dh, auth, vapid_public_key.
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 430;
// + Tours: tour_types label_key, icon, color; tours.tour_type; tour_waypoints id, place_id, lat, lng, role, sequence.
const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 440;
describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => {
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 127 entities', () => {
it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 130 entities', () => {
const failures: string[] = [];
for (const schema of ALL_ENTITIES) {
const Ctor = schema.class as new () => Record<string, unknown>;
+5 -1
View File
@@ -28,7 +28,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const MIGRATIONS = path.join(__dirname, '../../../src/db/migrations');
const BASELINE = 'Migration20200101000000_baseline_schema';
/** The migrations that carry no legacy step: the baseline, and the post-legacy ones every upgrade runs. */
const UNNUMBERED = [BASELINE, 'Migration20200101040200_trek_photo_cache_meta_cache_key_not_null'];
const UNNUMBERED = [
BASELINE,
'Migration20200101040200_trek_photo_cache_meta_cache_key_not_null',
'Migration20200101042000_tours',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
const LEGACY_FINAL_STEP = 258;
@@ -19,6 +19,7 @@ import {
} from '../../../helpers/factories';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createTour } from '../../../helpers/tours-repos';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -52,9 +53,11 @@ function legacyProjectionRow(id: number): unknown {
COALESCE(da.assignment_end_time, p.end_time) as end_time,
p.duration_minutes, p.notes as place_notes,
p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.amap_poi_id, p.website, p.phone, p.stop_type, p.fill_percent,
t.place_id AS tour_place_id, CASE WHEN t.place_id IS NOT NULL THEN p.route_geometry END AS tour_route_geometry,
c.name as category_name, c.color as category_color, c.icon as category_icon
FROM day_assignments da
JOIN places p ON da.place_id = p.id
LEFT JOIN tours t ON t.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
WHERE da.id = ?
`,
@@ -116,6 +119,36 @@ describe('DayAssignmentsRepository — the DY1/DY3/AS1/AS3 projection', () => {
expect(await assignments.findWithPlaceAndCategory(999999)).toBeNull();
});
it('ASSIGNPLACEREPO-009: the Tour facet columns are set for a Tour and null for a plain place, even one with a route', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const tourPlace = createPlace(testDb, trip.id, { name: 'Ridge walk' });
const trackPlace = createPlace(testDb, trip.id, { name: 'Imported track' });
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[47,11],[47.1,11.1]]', tourPlace.id);
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[1,2],[3,4]]', trackPlace.id);
createTour(testDb, tourPlace.id);
const tour = createDayAssignment(testDb, day.id, tourPlace.id, { order_index: 0 });
const track = createDayAssignment(testDb, day.id, trackPlace.id, { order_index: 1 });
const one = await assignments.findWithPlaceAndCategory(tour.id);
expect(one).toStrictEqual(legacyProjectionRow(tour.id));
expect(one).toMatchObject({ tour_place_id: tourPlace.id, tour_route_geometry: '[[47,11],[47.1,11.1]]' });
expect(await assignments.findWithPlaceAndCategory(track.id)).toMatchObject({
tour_place_id: null,
tour_route_geometry: null,
});
const facets = (rows: { tour_place_id: number | null; tour_route_geometry: string | null }[]) =>
rows.map((r) => [r.tour_place_id, r.tour_route_geometry]);
const expected = [
[tourPlace.id, '[[47,11],[47.1,11.1]]'],
[null, null],
];
expect(facets(await assignments.listForDay(day.id))).toEqual(expected);
expect(facets(await assignments.listWithPlaceAndCategory([day.id]))).toEqual(expected);
});
it("ASSIGNPLACEREPO-004: place_time/end_time COALESCE — the assignment's own time wins, the place's time is the fallback", async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -426,6 +459,16 @@ describe('DayAssignmentsRepository — AS10/AS11/AS13/AS14/AS19 (delete / order
expect(await assignments.getDayId(999999)).toBeUndefined();
});
it('ASSIGNREPO-042 (getPlaceId): the place_id of the assignment, undefined for a missing id', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
expect(await assignments.getPlaceId(a.id)).toBe(place.id);
expect(await assignments.getPlaceId(999999)).toBeUndefined();
});
it('ASSIGNREPO-011 (AS14, moveToDay): writes day_id and order_index together', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -20,6 +20,7 @@ import {
} from '../../../helpers/factories';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createTour } from '../../../helpers/tours-repos';
import { TRACK_COLORS } from '@trek/shared';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
@@ -835,9 +836,10 @@ describe('PlacesRepository.listForTrip (PL3) — toEqual(legacy) over all 32 fil
},
): unknown[] {
let query = `
SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon,
SELECT DISTINCT p.*, t.place_id AS tour_place_id, c.name as category_name, c.color as category_color, c.icon as category_icon,
pr.country_code as country_code, pr.region_name as region_name
FROM places p
LEFT JOIN tours t ON t.place_id = p.id
LEFT JOIN categories c ON p.category_id = c.id
LEFT JOIN place_regions pr ON pr.place_id = p.id
WHERE p.trip_id = ?
@@ -887,8 +889,10 @@ describe('PlacesRepository.listForTrip (PL3) — toEqual(legacy) over all 32 fil
const p3 = createPlace(testDb, trip.id, { name: 'Louvre' });
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(p3.id, tag.id);
createDayAssignment(testDb, day.id, p3.id);
// p4: no category, no tag, unassigned, does NOT match the search term.
createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
// p4: no category, no tag, unassigned, does NOT match the search term,
// and a Tour, so tour_place_id is non-null on exactly one row.
const p4 = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
createTour(testDb, p4.id);
// Another trip's place, named to collide with the search term — must
// never appear in either statement's output (both scope by trip_id).
createPlace(testDb, otherTrip.id, { name: 'Central Perk' });
@@ -1860,3 +1864,77 @@ describe('PlacesRepository — share.service.ts SH12 read', () => {
expect(await places.listPublicForShare(trip.id)).toEqual([]);
});
});
describe('PlacesRepository.insertTourPlace (TO10) / updateTourRoute (TO11)', () => {
it('PLACEREPO-TOUR-001: insertTourPlace writes the route place as a walking place and leaves the rest at its defaults', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const id = await places.insertTourPlace({
trip_id: trip.id,
name: 'Ridge walk',
lat: 47,
lng: 11,
route_geometry: '[[47,11],[47.1,11.1]]',
});
expect(await places.findInTrip(id, trip.id)).toMatchObject({
trip_id: trip.id,
name: 'Ridge walk',
lat: 47,
lng: 11,
transport_mode: 'walking',
route_geometry: '[[47,11],[47.1,11.1]]',
duration_minutes: 60,
reservation_status: 'none',
category_id: null,
});
});
it('PLACEREPO-TOUR-002: updateTourRoute rewrites the route of a place of that trip, stamps updated_at, and refuses another trip', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const other = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Old', lat: 1, lng: 1 });
testDb.prepare("UPDATE places SET transport_mode = 'driving', updated_at = NULL WHERE id = ?").run(place.id);
const route = { name: 'New', lat: 47, lng: 11, route_geometry: '[[47,11],[47.2,11.2]]' };
expect(await places.updateTourRoute(place.id, other.id, route)).toBe(false);
expect(await places.findInTrip(place.id, trip.id)).toMatchObject({
name: 'Old',
transport_mode: 'driving',
updated_at: null,
});
expect(await places.updateTourRoute(place.id, trip.id, route)).toBe(true);
const row = await places.findInTrip(place.id, trip.id);
expect(row).toMatchObject({ ...route, transport_mode: 'walking' });
expect(row?.updated_at).not.toBeNull();
});
});
describe('PlacesRepository.isTour / listForTrip tour_place_id', () => {
it('PLACEREPO-TOUR-003: isTour is true only for a place with a tours row', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const tour = createPlace(testDb, trip.id, { name: 'Ridge walk' });
const plain = createPlace(testDb, trip.id, { name: 'Cafe' });
createTour(testDb, tour.id);
expect(await places.isTour(tour.id)).toBe(true);
expect(await places.isTour(plain.id)).toBe(false);
expect(await places.isTour(999_999)).toBe(false);
});
it('PLACEREPO-TOUR-004: listForTrip carries the place id as tour_place_id for Tours and null otherwise', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const tour = createPlace(testDb, trip.id, { name: 'Ridge walk' });
const plain = createPlace(testDb, trip.id, { name: 'Cafe' });
createTour(testDb, tour.id);
const rows = await places.listForTrip(String(trip.id), {});
const byId = new Map(rows.map((r) => [r.id, r.tour_place_id]));
expect(byId.get(tour.id)).toBe(tour.id);
expect(byId.get(plain.id)).toBeNull();
});
});
@@ -0,0 +1,92 @@
/**
* `TourWaypointsRepository`: TW1 (`listForPlace`), TW2 (`listForTrip`), TW3
* (`insertForPlace`) and TW4 (`deleteForPlace`).
*/
import { TourWaypoints } from '../../../../src/db/entities/TourWaypoints.entity';
import type {
TourWaypointRow,
TourWaypointsRepository,
} from '../../../../src/db/repositories/TourWaypoints.repository';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { createPlace, createTrip, createUser } from '../../../helpers/factories';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createTour } from '../../../helpers/tours-repos';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let repo: TourWaypointsRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
repo = t.repo(TourWaypoints);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
const ROUTE: TourWaypointRow[] = [
{ lat: 47.1, lng: 11.1, role: 'end', sequence: 2 },
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
{ lat: 47.05, lng: 11.05, role: 'via', sequence: 1 },
];
function tourInTrip() {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
createTour(testDb, place.id);
return { trip, place };
}
describe('TourWaypointsRepository', () => {
it('TWREPO-001: insertForPlace writes every point and listForPlace reads them back in route order', async () => {
const { place } = tourInTrip();
await repo.insertForPlace(place.id, ROUTE);
expect(await repo.listForPlace(place.id)).toEqual([...ROUTE].sort((a, b) => a.sequence - b.sequence));
});
it('TWREPO-002: insertForPlace with no points writes nothing', async () => {
const { place } = tourInTrip();
await repo.insertForPlace(place.id, []);
expect(await repo.listForPlace(place.id)).toEqual([]);
});
it('TWREPO-003: deleteForPlace clears one tour and leaves another alone', async () => {
const first = tourInTrip();
const second = tourInTrip();
await repo.insertForPlace(first.place.id, ROUTE);
await repo.insertForPlace(second.place.id, ROUTE);
await repo.deleteForPlace(first.place.id);
expect(await repo.listForPlace(first.place.id)).toEqual([]);
expect(await repo.listForPlace(second.place.id)).toHaveLength(3);
});
it('TWREPO-004: listForTrip returns every waypoint of the trip with its place, ordered by place and sequence', async () => {
const { trip, place } = tourInTrip();
const other = tourInTrip();
const second = createPlace(testDb, trip.id);
createTour(testDb, second.id);
await repo.insertForPlace(second.id, ROUTE.slice(0, 2));
await repo.insertForPlace(place.id, ROUTE);
await repo.insertForPlace(other.place.id, ROUTE);
const rows = await repo.listForTrip(trip.id);
expect(rows.map((r) => [r.place_id, r.sequence])).toEqual([
[place.id, 0],
[place.id, 1],
[place.id, 2],
[second.id, 0],
[second.id, 2],
]);
expect(rows[0]).toEqual({ place_id: place.id, lat: 47, lng: 11, role: 'start', sequence: 0 });
});
});
@@ -0,0 +1,222 @@
/**
* `ToursRepository`: the list/detail projection (TO1/TO2), the facet probes
* places and assignments ask (TO3/TO4/TO5), the trip copy read and write
* (TO6/TO8), and the create/edit writes (TO7/TO9).
*/
import { Tours } from '../../../../src/db/entities/Tours.entity';
import type { ToursRepository } from '../../../../src/db/repositories/Tours.repository';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { createDay, createDayAssignment, createPlace, createTrip, createUser } from '../../../helpers/factories';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createTour } from '../../../helpers/tours-repos';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let repo: ToursRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
repo = t.repo(Tours);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
function fixture() {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const other = createTrip(testDb, user.id);
return { trip, other };
}
function addWaypoint(placeId: number, sequence: number): void {
testDb
.prepare("INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (?, 47, 11, 'via', ?)")
.run(placeId, sequence);
}
const METRICS = {
tour_type: 'hike',
distance: 8.25,
elevation_gain: 420,
elevation_loss: 380,
duration: 150,
match_confidence: 1,
max_hiking_difficulty: 3,
};
describe('ToursRepository reads', () => {
it('TOURREPO-001: listForTrip projects the facet with its place name and both flags, newest first, never another trip', async () => {
const { trip, other } = fixture();
const older = createPlace(testDb, trip.id, { name: 'Older' });
const newer = createPlace(testDb, trip.id, { name: 'Newer' });
const plain = createPlace(testDb, trip.id, { name: 'Not a tour' });
const foreign = createPlace(testDb, other.id, { name: 'Foreign' });
createTour(testDb, older.id, { created_at: '2026-01-01 10:00:00', distance: 5, match_confidence: 0.3 });
createTour(testDb, newer.id, { created_at: '2026-01-02 10:00:00', max_hiking_difficulty: 5 });
createTour(testDb, foreign.id);
createDayAssignment(testDb, createDay(testDb, trip.id).id, older.id);
addWaypoint(newer.id, 0);
expect(await repo.listForTrip(trip.id)).toEqual([
{
place_id: newer.id,
name: 'Newer',
tour_type: 'hike',
distance: null,
elevation_gain: null,
elevation_loss: null,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: null,
tour_group_id: null,
max_hiking_difficulty: 5,
planned: 0,
has_waypoints: 1,
},
{
place_id: older.id,
name: 'Older',
tour_type: 'hike',
distance: 5,
elevation_gain: null,
elevation_loss: null,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 0.3,
tour_group_id: null,
max_hiking_difficulty: 2,
planned: 1,
has_waypoints: 0,
},
]);
expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).not.toContain(plain.id);
});
it('TOURREPO-002: listForTrip breaks a created_at tie by the newer place', async () => {
const { trip } = fixture();
const a = createPlace(testDb, trip.id);
const b = createPlace(testDb, trip.id);
createTour(testDb, a.id, { created_at: '2026-01-01 10:00:00' });
createTour(testDb, b.id, { created_at: '2026-01-01 10:00:00' });
expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).toEqual([b.id, a.id]);
});
it('TOURREPO-003: findInTrip returns the row for its own trip and undefined across trips or for a plain place', async () => {
const { trip, other } = fixture();
const tour = createPlace(testDb, trip.id, { name: 'Ridge' });
const plain = createPlace(testDb, trip.id);
createTour(testDb, tour.id);
expect(await repo.findInTrip(trip.id, tour.id)).toMatchObject({
place_id: tour.id,
name: 'Ridge',
planned: 0,
has_waypoints: 0,
});
expect(await repo.findInTrip(other.id, tour.id)).toBeUndefined();
expect(await repo.findInTrip(trip.id, plain.id)).toBeUndefined();
});
it('TOURREPO-004: existsForPlace and listTourPlaceIds tell tours from plain places', async () => {
const { trip } = fixture();
const tour = createPlace(testDb, trip.id);
const plain = createPlace(testDb, trip.id);
createTour(testDb, tour.id);
expect(await repo.existsForPlace(tour.id)).toBe(true);
expect(await repo.existsForPlace(plain.id)).toBe(false);
expect(await repo.listTourPlaceIds([plain.id, tour.id])).toEqual([tour.id]);
expect(await repo.listTourPlaceIds([])).toEqual([]);
});
it('TOURREPO-005: isOnDay is true only for a tour already on that day, and ignores the assignment being moved', async () => {
const { trip } = fixture();
const day1 = createDay(testDb, trip.id);
const day2 = createDay(testDb, trip.id);
const tour = createPlace(testDb, trip.id);
const plain = createPlace(testDb, trip.id);
createTour(testDb, tour.id);
const onDay1 = createDayAssignment(testDb, day1.id, tour.id);
createDayAssignment(testDb, day1.id, plain.id);
expect(await repo.isOnDay(day1.id, tour.id)).toBe(true);
expect(await repo.isOnDay(day2.id, tour.id)).toBe(false);
expect(await repo.isOnDay(day1.id, plain.id)).toBe(false);
expect(await repo.isOnDay(day1.id, tour.id, onDay1.id)).toBe(false);
});
});
describe('ToursRepository writes', () => {
it('TOURREPO-006: insertTour writes the metrics and leaves the unset metadata NULL', async () => {
const { trip } = fixture();
const place = createPlace(testDb, trip.id, { name: 'Drawn' });
await repo.insertTour({ place_id: place.id, ...METRICS });
expect(await repo.findInTrip(trip.id, place.id)).toEqual({
place_id: place.id,
name: 'Drawn',
...METRICS,
difficulty: null,
wanderer_ref: null,
tour_group_id: null,
planned: 0,
has_waypoints: 0,
});
});
it('TOURREPO-007: updateInTrip replaces the metrics of a tour of that trip and refuses one of another trip', async () => {
const { trip, other } = fixture();
const place = createPlace(testDb, trip.id);
createTour(testDb, place.id);
expect(await repo.updateInTrip(other.id, place.id, METRICS)).toBe(false);
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ distance: null, max_hiking_difficulty: 2 });
expect(await repo.updateInTrip(trip.id, place.id, METRICS)).toBe(true);
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject(METRICS);
});
it('TOURREPO-008: listRowsForTrip and insertCopy carry every column, created_at included, onto the copied place', async () => {
const { trip, other } = fixture();
const source = createPlace(testDb, trip.id);
createTour(testDb, source.id, { created_at: '2026-03-04 05:06:07', distance: 3.5, max_hiking_difficulty: 4 });
testDb
.prepare("UPDATE tours SET difficulty = 'T3', wanderer_ref = 'w-1', tour_group_id = 9 WHERE place_id = ?")
.run(source.id);
const rows = await repo.listRowsForTrip(trip.id);
expect(rows).toEqual([
{
place_id: source.id,
tour_type: 'hike',
distance: 3.5,
elevation_gain: null,
elevation_loss: null,
duration: null,
difficulty: 'T3',
wanderer_ref: 'w-1',
match_confidence: null,
tour_group_id: 9,
created_at: '2026-03-04 05:06:07',
max_hiking_difficulty: 4,
},
]);
expect(await repo.listRowsForTrip(other.id)).toEqual([]);
const copy = createPlace(testDb, other.id);
const { place_id: _sourceId, ...columns } = rows[0];
await repo.insertCopy(copy.id, columns);
expect(await repo.listRowsForTrip(other.id)).toEqual([{ ...rows[0], place_id: copy.id }]);
});
});
@@ -0,0 +1,198 @@
/**
* `Migration20200101042000_tours`: the Tours tables on a fresh install, and on
* a test instance that already carries them from the pre-ORM Tours branch.
*
* Migrate to the step immediately before it, optionally lay down the
* branch's own tables with rows in them, apply just this migration, assert.
*/
import { createMigrationOrm, migrateTo, pendingNames, rawExec, rawQuery } from '../../helpers/migration-step';
import type { MikroORM } from '@mikro-orm/sqlite';
import { describe, expect, it } from 'vitest';
const TARGET = 'Migration20200101042000_tours';
async function ormBeforeTarget(): Promise<MikroORM> {
const orm = await createMigrationOrm();
const names = await pendingNames(orm);
const idx = names.indexOf(TARGET);
expect(idx).toBeGreaterThan(0);
await migrateTo(orm, names[idx - 1]);
return orm;
}
async function indexNames(orm: MikroORM, table: string): Promise<string[]> {
const rows = await rawQuery<{ name: string }>(orm, `SELECT name FROM pragma_index_list('${table}') ORDER BY name`);
return rows.map((r) => r.name);
}
async function seedTripWithPlace(orm: MikroORM): Promise<void> {
await rawExec(orm, "INSERT INTO users (id, username, email, password_hash) VALUES (1, 'owner', 'owner@test', 'x')");
await rawExec(orm, "INSERT INTO trips (id, user_id, title) VALUES (10, 1, 'Alps')");
await rawExec(orm, "INSERT INTO places (id, trip_id, name) VALUES (100, 10, 'Ridge walk')");
}
describe('Tours migration', () => {
it('TOURMIG-001: a fresh install gets the three tables, the hike type and no redundant waypoint index', async () => {
const orm = await ormBeforeTarget();
try {
await migrateTo(orm, TARGET);
expect(
await rawQuery(
orm,
'SELECT key, label_key, icon, color, routing_profile, is_sport, enabled, sort_order FROM tour_types',
),
).toEqual([
{
key: 'hike',
label_key: 'tourTypes.hike',
icon: 'Mountain',
color: '#16a34a',
routing_profile: 'pedestrian',
is_sport: 1,
enabled: 1,
sort_order: 0,
},
]);
const tourColumns = await rawQuery<{ name: string; notnull: number; dflt_value: string | null; pk: number }>(
orm,
'SELECT name, "notnull", dflt_value, pk FROM pragma_table_info(\'tours\') ORDER BY cid',
);
expect(tourColumns.map((c) => c.name)).toEqual([
'place_id',
'tour_type',
'distance',
'elevation_gain',
'elevation_loss',
'duration',
'difficulty',
'wanderer_ref',
'match_confidence',
'tour_group_id',
'created_at',
'max_hiking_difficulty',
]);
expect(tourColumns.find((c) => c.name === 'max_hiking_difficulty')).toMatchObject({
notnull: 1,
dflt_value: '2',
});
expect(tourColumns.find((c) => c.name === 'place_id')).toMatchObject({ pk: 1 });
expect(await indexNames(orm, 'tours')).toContain('idx_tours_tour_type');
expect(await indexNames(orm, 'tour_waypoints')).not.toContain('idx_tour_waypoints_place');
const fks = await rawQuery<{ table: string; from: string; to: string; on_delete: string }>(
orm,
'SELECT "table", "from", "to", on_delete FROM pragma_foreign_key_list(\'tours\') UNION ALL SELECT "table", "from", "to", on_delete FROM pragma_foreign_key_list(\'tour_waypoints\') ORDER BY 1, 2',
);
expect(fks).toEqual([
{ table: 'places', from: 'place_id', to: 'id', on_delete: 'CASCADE' },
{ table: 'tour_types', from: 'tour_type', to: 'key', on_delete: 'NO ACTION' },
{ table: 'tours', from: 'place_id', to: 'place_id', on_delete: 'CASCADE' },
]);
} finally {
await orm.close(true);
}
}, 30000);
it('TOURMIG-002: the CHECKs refuse out-of-range values and a place delete cascades through tours to its waypoints', async () => {
const orm = await ormBeforeTarget();
try {
await migrateTo(orm, TARGET);
await rawExec(orm, 'PRAGMA foreign_keys = ON');
await seedTripWithPlace(orm);
await expect(
rawExec(orm, "INSERT INTO tours (place_id, tour_type, max_hiking_difficulty) VALUES (100, 'hike', 7)"),
).rejects.toThrow(/CHECK/);
await expect(rawExec(orm, "INSERT INTO tours (place_id, tour_type) VALUES (100, 'kayak')")).rejects.toThrow(
/FOREIGN KEY/,
);
await rawExec(orm, "INSERT INTO tours (place_id, tour_type) VALUES (100, 'hike')");
expect(await rawQuery(orm, 'SELECT max_hiking_difficulty FROM tours')).toEqual([{ max_hiking_difficulty: 2 }]);
await expect(
rawExec(orm, "INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 91, 0, 'start', 0)"),
).rejects.toThrow(/CHECK/);
await expect(
rawExec(orm, "INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 0, 0, 'detour', 0)"),
).rejects.toThrow(/CHECK/);
await rawExec(
orm,
"INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 47, 11, 'start', 0), (100, 47.1, 11.1, 'end', 1)",
);
await expect(
rawExec(orm, "INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 47, 11, 'via', 1)"),
).rejects.toThrow(/UNIQUE/);
await rawExec(orm, 'DELETE FROM places WHERE id = 100');
expect(await rawQuery(orm, 'SELECT COUNT(*) AS n FROM tours')).toEqual([{ n: 0 }]);
expect(await rawQuery(orm, 'SELECT COUNT(*) AS n FROM tour_waypoints')).toEqual([{ n: 0 }]);
} finally {
await orm.close(true);
}
}, 30000);
it('TOURMIG-003: on an instance that already has the pre-ORM Tours tables it keeps every row and drops only the redundant index', async () => {
const orm = await ormBeforeTarget();
try {
// The pre-ORM branch's final shape, rows and all, with its custom hike row.
await rawExec(
orm,
`CREATE TABLE tour_types (
key TEXT PRIMARY KEY, label_key TEXT NOT NULL, icon TEXT NOT NULL, color TEXT NOT NULL, routing_profile TEXT,
is_sport INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0)`,
);
await rawExec(
orm,
"INSERT INTO tour_types (key, label_key, icon, color, routing_profile) VALUES ('hike', 'tourTypes.hike', 'Mountain', '#000000', 'pedestrian')",
);
await rawExec(
orm,
`CREATE TABLE tours (
place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE,
tour_type TEXT NOT NULL REFERENCES tour_types(key), distance REAL, elevation_gain REAL, elevation_loss REAL,
duration REAL, difficulty TEXT, wanderer_ref TEXT, match_confidence REAL, tour_group_id INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
max_hiking_difficulty INTEGER NOT NULL DEFAULT 2 CHECK(max_hiking_difficulty BETWEEN 1 AND 6))`,
);
await rawExec(orm, 'CREATE INDEX idx_tours_tour_type ON tours(tour_type)');
await rawExec(
orm,
`CREATE TABLE tour_waypoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
place_id INTEGER NOT NULL REFERENCES tours(place_id) ON DELETE CASCADE,
lat REAL NOT NULL CHECK(lat >= -90 AND lat <= 90), lng REAL NOT NULL CHECK(lng >= -180 AND lng <= 180),
role TEXT NOT NULL CHECK(role IN ('start', 'via', 'end')), sequence INTEGER NOT NULL CHECK(sequence >= 0),
UNIQUE(place_id, sequence))`,
);
await rawExec(orm, 'CREATE INDEX idx_tour_waypoints_place ON tour_waypoints(place_id, sequence)');
await seedTripWithPlace(orm);
await rawExec(
orm,
"INSERT INTO tours (place_id, tour_type, distance, max_hiking_difficulty) VALUES (100, 'hike', 12.5, 4)",
);
await rawExec(
orm,
"INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (100, 47, 11, 'start', 0), (100, 47.1, 11.1, 'end', 1)",
);
await migrateTo(orm, TARGET);
expect(await rawQuery(orm, 'SELECT key, color FROM tour_types')).toEqual([{ key: 'hike', color: '#000000' }]);
expect(await rawQuery(orm, 'SELECT place_id, tour_type, distance, max_hiking_difficulty FROM tours')).toEqual([
{ place_id: 100, tour_type: 'hike', distance: 12.5, max_hiking_difficulty: 4 },
]);
expect(await rawQuery(orm, 'SELECT role, sequence FROM tour_waypoints ORDER BY sequence')).toEqual([
{ role: 'start', sequence: 0 },
{ role: 'end', sequence: 1 },
]);
expect(await indexNames(orm, 'tours')).toContain('idx_tours_tour_type');
expect(await indexNames(orm, 'tour_waypoints')).not.toContain('idx_tour_waypoints_place');
} finally {
await orm.close(true);
}
}, 30000);
});
@@ -46,6 +46,12 @@ describe('global CSP: the other shipped raster presets (#2180)', () => {
expect(await connectSrcSources()).toContain('https://tiles.stadiamaps.com');
});
it('allows the OpenTopoMap apex and tile shards used by the Tours planner', async () => {
const sources = await connectSrcSources();
expect(sources).toContain('https://tile.opentopomap.org');
expect(sources).toContain('https://*.tile.opentopomap.org');
});
it('keeps the routing host, which is a different host and covers nothing here', async () => {
// routing.openstreetmap.de was on the list all along and looks close enough
// to hide the gap: a CSP source matches a host, not a suffix of one.
@@ -139,8 +145,8 @@ describe('forced-HTTPS redirect', () => {
it('redirects to the configured APP_URL host, not the Host header the caller sent', async () => {
process.env.FORCE_HTTPS = 'true';
process.env.APP_URL = 'https://trip.pakulat.org';
expect(await redirectLocation()).toBe('https://trip.pakulat.org/trips');
process.env.APP_URL = 'https://trip.example.invalid';
expect(await redirectLocation()).toBe('https://trip.example.invalid/trips');
});
it('falls back to the request host when APP_URL is unset', async () => {
@@ -159,7 +165,7 @@ describe('forced-HTTPS redirect', () => {
it('leaves an already-secure request alone, and never redirects the health probe', async () => {
process.env.FORCE_HTTPS = 'true';
process.env.APP_URL = 'https://trip.pakulat.org';
process.env.APP_URL = 'https://trip.example.invalid';
const app = express();
applyGlobalMiddleware(app);
app.get('/trips', (_req, res) => res.json({ ok: true }));
@@ -184,7 +190,7 @@ describe('routingCspOrigins', () => {
});
it('CSP-ROUTING-002: a port belongs to the origin and is kept', () => {
expect(routingCspOrigins(['http://192.168.178.72:5000'])).toEqual(['http://192.168.178.72:5000']);
expect(routingCspOrigins(['http://192.0.2.1:5000'])).toEqual(['http://192.0.2.1:5000']);
});
it('CSP-ROUTING-003: anything that is not an http(s) URL widens nothing', () => {
@@ -4,7 +4,7 @@ import {
} from '../../../src/nest/assignments/assignments.controller';
import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
import type { User } from '../../../src/types';
import { HttpException } from '@nestjs/common';
import { ConflictException, HttpException } from '@nestjs/common';
import { describe, it, expect, vi } from 'vitest';
@@ -73,6 +73,22 @@ describe('DayAssignmentsController (parity with the legacy day-assignments route
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:created', { assignment: { id: 9 } }, 'sock');
expect(reconcile).toHaveBeenCalledWith('5', 'sock');
});
it('409 duplicate Tour assignment propagates without announcing a new assignment', async () => {
const broadcast = vi.fn();
const reconcile = vi.fn();
const createAssignment = vi.fn().mockRejectedValue(new ConflictException('Tour is already assigned to this day'));
const controller = new DayAssignmentsController(
svc({ createAssignment, broadcast, reconcile } as Partial<AssignmentsService>),
);
expect(await thrownAsync(() => controller.create(user, '5', '3', { place_id: 42 }))).toMatchObject({
status: 409,
});
expect(createAssignment).toHaveBeenCalledWith('3', 42, undefined);
expect(broadcast).not.toHaveBeenCalled();
expect(reconcile).not.toHaveBeenCalled();
});
});
it('PUT /reorder 404 day, else reorders + broadcasts', async () => {
@@ -51,6 +51,8 @@ import {
sharedTestOrm,
createTestTripsRepo,
} from '../../helpers/test-uow';
import { createTestToursRepo, createTour } from '../../helpers/tours-repos';
import { ConflictException } from '@nestjs/common';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
@@ -128,6 +130,7 @@ beforeAll(async () => {
await createTestPlacesRepo(testDb),
await createTestTripMembersRepo(testDb),
await createTestRoadtripViasRepo(testDb),
await createTestToursRepo(testDb),
);
});
@@ -157,6 +160,12 @@ function fixture() {
return { user, trip, day, place };
}
/** Makes `placeId` a Tour: a `tours` facet row plus the route it was planned along. */
function addTourFacet(placeId: number, routeGeometry = '[[48.1,11.5],[48.2,11.6]]') {
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run(routeGeometry, placeId);
createTour(testDb, placeId);
}
// ── verifyTripAccess / canEdit ────────────────────────────────────────────────
describe('verifyTripAccess', () => {
@@ -307,6 +316,68 @@ describe('createAssignment', () => {
]);
void trip;
});
it('ASG-SVC-031: rejects a duplicate Tour assignment on the same day but allows another day and ordinary Place duplicates', async () => {
const { trip, day, place: tourPlace } = fixture();
const secondDay = createDay(testDb, trip.id);
const ordinaryPlace = createPlace(testDb, trip.id, { name: 'Ordinary place' });
addTourFacet(tourPlace.id);
const firstTourAssignment = await svc.createAssignment(day.id, tourPlace.id);
await expect(svc.createAssignment(day.id, tourPlace.id)).rejects.toThrow(ConflictException);
await expect(svc.createAssignment(day.id, tourPlace.id)).rejects.toThrow('Tour is already assigned to this day');
const otherDayTourAssignment = await svc.createAssignment(secondDay.id, tourPlace.id);
expect(firstTourAssignment?.day_id).toBe(day.id);
expect(otherDayTourAssignment?.day_id).toBe(secondDay.id);
expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(tourPlace.id)).toEqual({
n: 2,
});
await svc.createAssignment(day.id, ordinaryPlace.id);
await svc.createAssignment(day.id, ordinaryPlace.id);
expect(
testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE place_id = ?').get(ordinaryPlace.id),
).toEqual({ n: 2 });
});
it('ASG-SVC-032: near-concurrent Tour assignments serialize to one row and one conflict', async () => {
const { day, place } = fixture();
addTourFacet(place.id);
const results = await Promise.allSettled([
svc.createAssignment(day.id, place.id),
svc.createAssignment(day.id, place.id),
]);
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1);
const rejected = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
expect(rejected).toHaveLength(1);
expect(rejected[0].reason).toBeInstanceOf(ConflictException);
expect(
testDb
.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = ? AND place_id = ?')
.get(day.id, place.id),
).toEqual({ n: 1 });
});
it('ASG-SVC-048: a Tour stop carries its facet id and route geometry, an ordinary stop nulls both', async () => {
const { trip, day, place: tourPlace } = fixture();
const ordinaryPlace = createPlace(testDb, trip.id, { name: 'Ordinary place' });
// Geometry on a plain place (an imported track) must not leak into the Tour fields.
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id = ?').run('[[1,2],[3,4]]', ordinaryPlace.id);
addTourFacet(tourPlace.id, '[[47,11],[47.5,11.5]]');
const tour = await svc.createAssignment(day.id, tourPlace.id);
const ordinary = await svc.createAssignment(day.id, ordinaryPlace.id);
expect(tour).toMatchObject({ tour_place_id: tourPlace.id, tour_route_geometry: '[[47,11],[47.5,11.5]]' });
expect(ordinary).toMatchObject({ tour_place_id: null, tour_route_geometry: null });
const list = await svc.listDayAssignments(day.id);
expect(list.map((a) => [a.tour_place_id, a.tour_route_geometry])).toEqual([
[tourPlace.id, '[[47,11],[47.5,11.5]]'],
[null, null],
]);
});
});
// ── listDayAssignments ────────────────────────────────────────────────────────
@@ -416,6 +487,33 @@ describe('moveAssignment', () => {
expect((await svc.moveAssignment(a.id, day.id, null)).assignment!.order_index).toBe(0);
expect((await svc.moveAssignment(a.id, target.id, 0)).assignment!.order_index).toBe(0);
});
it('ASG-SVC-049: refuses to move a Tour onto a day that already holds it, and leaves the row where it was', async () => {
const { trip, day, place } = fixture();
const target = createDay(testDb, trip.id);
addTourFacet(place.id);
createDayAssignment(testDb, target.id, place.id);
const a = createDayAssignment(testDb, day.id, place.id, { order_index: 3 });
await expect(svc.moveAssignment(a.id, target.id, 0)).rejects.toThrow(ConflictException);
expect(testDb.prepare('SELECT day_id, order_index FROM day_assignments WHERE id = ?').get(a.id)).toEqual({
day_id: day.id,
order_index: 3,
});
});
it('ASG-SVC-050: a Tour may be reordered within its own day, and an ordinary place may join a day that already holds it', async () => {
const { trip, day, place } = fixture();
const target = createDay(testDb, trip.id);
addTourFacet(place.id);
const tour = createDayAssignment(testDb, day.id, place.id, { order_index: 0 });
expect((await svc.moveAssignment(tour.id, day.id, 2)).assignment).toMatchObject({ day_id: day.id, order_index: 2 });
const ordinary = createPlace(testDb, trip.id, { name: 'Ordinary place' });
createDayAssignment(testDb, target.id, ordinary.id);
const second = createDayAssignment(testDb, day.id, ordinary.id);
expect((await svc.moveAssignment(second.id, target.id, 1)).assignment).toMatchObject({ day_id: target.id });
});
});
// ── participants ──────────────────────────────────────────────────────────────
@@ -54,6 +54,8 @@ function makeRow(overrides: Partial<AssignmentWithPlaceRow> = {}): AssignmentWit
phone: '+33 1 2345 6789',
stop_type: null,
fill_percent: null,
tour_place_id: null,
tour_route_geometry: null,
...overrides,
};
}
@@ -106,6 +108,16 @@ describe('formatAssignmentWithPlace', () => {
expect(result.place.category).toBeNull();
});
it('keeps the Tour facet fields at the assignment level, null for an ordinary place', () => {
expect(formatAssignmentWithPlace(makeRow(), [], [])).toMatchObject({
tour_place_id: null,
tour_route_geometry: null,
});
const geometry = '[[48.1,11.5],[48.2,11.6]]';
const tour = formatAssignmentWithPlace(makeRow({ tour_place_id: 100, tour_route_geometry: geometry }), [], []);
expect(tour).toMatchObject({ tour_place_id: 100, tour_route_geometry: geometry });
});
it('passes tags and participants through untouched', () => {
const result = formatAssignmentWithPlace(makeRow(), sampleTags, sampleParticipants);
expect(result.place.tags).toEqual(sampleTags);
@@ -60,6 +60,7 @@ import {
sharedTestOrm,
createTestTripsRepo,
} from '../../helpers/test-uow';
import { createTestToursRepo } from '../../helpers/tours-repos';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
@@ -161,6 +162,7 @@ beforeAll(async () => {
await createTestPlacesRepo(testDb),
await createTestTripMembersRepo(testDb),
await createTestRoadtripViasRepo(testDb),
await createTestToursRepo(testDb),
);
accommodations = new AccommodationsService(
permissions,
@@ -52,6 +52,7 @@ import {
createTestRoadtripViasRepo,
createTestRoadtripDayBoundariesRepo,
} from '../../helpers/test-uow';
import { createTour } from '../../helpers/tours-repos';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
@@ -166,6 +167,27 @@ describe('verifyTripAccess', () => {
// ── getAssignmentsForDay ──────────────────────────────────────────────────────
describe('getAssignmentsForDay', () => {
it('DAY-SVC-TOUR-001: projects only facet-backed Tour geometry in both day read paths', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const tour = createPlace(testDb, trip.id, { name: 'Hike', lat: 48.1, lng: 11.1 });
const track = createPlace(testDb, trip.id, { name: 'Legacy track', lat: 48.2, lng: 11.2 });
const geometry = JSON.stringify([
[48.1, 11.1],
[48.3, 11.3],
]);
testDb.prepare('UPDATE places SET route_geometry = ? WHERE id IN (?, ?)').run(geometry, tour.id, track.id);
createTour(testDb, tour.id);
createDayAssignment(testDb, day.id, tour.id, { order_index: 0 });
createDayAssignment(testDb, day.id, track.id, { order_index: 1 });
for (const assignments of [await svc.getAssignmentsForDay(day.id), (await svc.list(trip.id)).days[0].assignments]) {
expect(assignments?.[0]).toMatchObject({ tour_place_id: tour.id, tour_route_geometry: geometry });
expect(assignments?.[1]).toMatchObject({ tour_place_id: null, tour_route_geometry: null });
}
});
it('DAY-SVC-003 — returns empty array when day has no assignments', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -1,4 +1,5 @@
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { TRIP_PERMISSION_KEY } from '../../../src/nest/permissions/trip-access.guard';
import { PlacesController } from '../../../src/nest/places/places.controller';
import type { PlacesService } from '../../../src/nest/places/places.service';
import type { StorageService } from '../../../src/nest/storage/storage.service';
@@ -7,6 +8,26 @@ import { HttpException } from '@nestjs/common';
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { legacyDatabaseAccess } = vi.hoisted(() => ({
legacyDatabaseAccess: vi.fn((property: string | symbol): never => {
throw new Error(`Unexpected legacy database access: ${String(property)}`);
}),
}));
vi.mock('../../../src/config', () => ({
ENCRYPTION_KEY: 'test-only-inert-key',
JWT_SECRET: 'test-only-inert-secret',
updateJwtSecret: vi.fn(),
}));
vi.mock('../../../src/db/database', () => ({
db: new Proxy(
{},
{
get: (_target, property: string | symbol) => legacyDatabaseAccess(property),
},
),
}));
const storageStub = { put: vi.fn().mockResolvedValue(undefined) } as unknown as StorageService;
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
@@ -43,6 +64,10 @@ async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number
beforeEach(() => vi.spyOn(console, 'error').mockImplementation(() => {}));
describe('PlacesController (parity with the legacy /api/trips/:tripId/places route)', () => {
it('does not access the legacy global database initializer', () => {
expect(legacyDatabaseAccess).not.toHaveBeenCalled();
});
// The trip 404 for this handler is TripAccessGuard's now (see
// trip-access.guard.test.ts and the places e2e), so it is no longer reachable
// by calling the method directly.
@@ -412,13 +437,27 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
new RuntimeEnvService(),
storageStub,
).bulkDelete(user, '5', { ids: [] }),
).toEqual({ deleted: [], count: 0 });
).toEqual({ deleted: [], count: 0, tourPlaceIds: [] });
expect(removeMany).not.toHaveBeenCalled();
});
it('requires place_edit before a bulk deletion can reach the service', async () => {
const removeMany = vi.fn();
const s = svc({ canEdit: vi.fn().mockReturnValue(false), removeMany } as Partial<PlacesService>);
expect(
await thrownAsync(() =>
new PlacesController(s, new RuntimeEnvService(), storageStub).bulkDelete(user, '5', { ids: [1] }),
),
).toEqual({ status: 403, body: { error: 'No permission' } });
expect(removeMany).not.toHaveBeenCalled();
});
it('deletes, fires hooks + broadcasts per deleted id', async () => {
const removeMany = vi
.fn()
.mockReturnValue({ deleted: [1, 2], cancelled: { reservationIds: [], budgetItemIds: [] } });
.mockReturnValue({
deleted: [1, 2],
deletedTourPlaceIds: [2],
cancelled: { reservationIds: [], budgetItemIds: [] },
});
const onDeleted = vi.fn();
const broadcast = vi.fn();
const scopedIds = vi.fn().mockReturnValue([1, 2]);
@@ -430,7 +469,7 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
{ ids: [1, 2] },
'sock',
),
).toEqual({ deleted: [1, 2], count: 2 });
).toEqual({ deleted: [1, 2], count: 2, tourPlaceIds: [2] });
expect(onDeleted).toHaveBeenCalledTimes(2);
expect(broadcast).toHaveBeenCalledTimes(2);
});
@@ -464,7 +503,7 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
});
const removeMany = vi.fn(async () => {
order.push('delete');
return { deleted: [1], cancelled: { reservationIds: [], budgetItemIds: [] } };
return { deleted: [1], deletedTourPlaceIds: [], cancelled: { reservationIds: [], budgetItemIds: [] } };
});
const s = svc({
removeMany,
@@ -827,14 +866,25 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
expect(onDeleted).toHaveBeenCalledWith(9);
expect(onDeleted.mock.invocationCallOrder[0]).toBeLessThan(remove.mock.invocationCallOrder[0]);
const s = svc({
remove: vi.fn().mockReturnValue({ deleted: true, cancelled: { reservationIds: [], budgetItemIds: [] } }),
remove: vi
.fn()
.mockReturnValue({
deleted: true,
deletedTourPlaceIds: [9],
cancelled: { reservationIds: [], budgetItemIds: [] },
}),
broadcast: vi.fn(),
} as Partial<PlacesService>);
expect(await new PlacesController(s, new RuntimeEnvService(), storageStub).remove(user, '5', '9')).toEqual({
success: true,
tourPlaceIds: [9],
});
});
it('requires place_edit for single Place/Tour deletion', () => {
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, PlacesController.prototype.remove)).toBe('place_edit');
});
// invocationCallOrder in the case above only proves the hook was CALLED
// first — a synchronously-resolving mock can't tell an awaited hook from a
// detached (fire-and-forget) one. A hook stubbed with a real macrotask hop,
@@ -848,7 +898,7 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
});
const remove = vi.fn(async () => {
order.push('delete');
return { deleted: true, cancelled: { reservationIds: [], budgetItemIds: [] } };
return { deleted: true, deletedTourPlaceIds: [], cancelled: { reservationIds: [], budgetItemIds: [] } };
});
const s = svc({ remove, onDeleted, broadcast: vi.fn() } as Partial<PlacesService>);
await new PlacesController(s, new RuntimeEnvService(), storageStub).remove(user, '5', '9');
@@ -991,7 +1041,6 @@ describe('PlacesController (parity with the legacy /api/trips/:tripId/places rou
});
});
});
describe('PUT /:id/image/from-file (#1242)', () => {
it('maps every refusal to its status and broadcasts a success', async () => {
const make = (result: unknown, extra: Partial<PlacesService> = {}) =>
@@ -235,6 +235,19 @@ describe('list', () => {
expect(places).toHaveLength(2);
});
it('PLACE-SVC-TOURS-002 — identifies Tours by their facet in the ordinary Place read model', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const tour = createPlace(testDb, trip.id, { name: 'Facet-backed tour' }) as any;
const place = createPlace(testDb, trip.id, { name: 'Ordinary place' }) as any;
testDb.prepare(`INSERT INTO tours (place_id, tour_type) VALUES (?, 'hike')`).run(tour.id);
const byId = new Map(((await svc.list(String(trip.id), {})) as any[]).map((item) => [item.id, item]));
expect(byId.get(tour.id)).toMatchObject({ tour_place_id: tour.id });
expect(byId.get(place.id)).toMatchObject({ tour_place_id: null });
});
it('PLACE-SVC-003 — does not return places from other trips', async () => {
const { user } = createUser(testDb);
const t1 = createTrip(testDb, user.id);
@@ -711,6 +724,66 @@ describe('remove', () => {
expect(testDb.prepare('SELECT id FROM day_assignments WHERE day_id = ?').all(day.id)).toEqual([]);
});
it('PLACE-SVC-TOURS-001 — reports Tour deletion and cascades only its facet, waypoints, assignments, and links', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const tour = createPlace(testDb, trip.id, { name: 'Tour to delete' }) as any;
const keptTour = createPlace(testDb, trip.id, { name: 'Tour to keep' }) as any;
const keptPlace = createPlace(testDb, trip.id, { name: 'Ordinary place' }) as any;
for (const place of [tour, keptTour]) {
testDb
.prepare(`INSERT INTO tours (place_id, tour_type, max_hiking_difficulty) VALUES (?, 'hike', 4)`)
.run(place.id);
testDb
.prepare(`INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (?, 48, 11, 'start', 0)`)
.run(place.id);
testDb
.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, 0)')
.run(day.id, place.id);
}
const tourFileId = Number(
testDb
.prepare(
`
INSERT INTO trip_files (trip_id, place_id, filename, original_name) VALUES (?, ?, 'tour.gpx', 'tour.gpx')
`,
)
.run(trip.id, tour.id).lastInsertRowid,
);
const keptFileId = Number(
testDb
.prepare(
`
INSERT INTO trip_files (trip_id, place_id, filename, original_name) VALUES (?, ?, 'kept.gpx', 'kept.gpx')
`,
)
.run(trip.id, keptTour.id).lastInsertRowid,
);
testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(tourFileId, tour.id);
const result = await svc.remove(String(trip.id), String(tour.id));
expect(result).toMatchObject({ deleted: true, deletedTourPlaceIds: [tour.id] });
expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(tour.id)).toBeUndefined();
expect(testDb.prepare('SELECT place_id FROM tours WHERE place_id = ?').get(tour.id)).toBeUndefined();
expect(testDb.prepare('SELECT id FROM tour_waypoints WHERE place_id = ?').all(tour.id)).toEqual([]);
expect(testDb.prepare('SELECT id FROM day_assignments WHERE place_id = ?').all(tour.id)).toEqual([]);
expect(testDb.prepare('SELECT id, place_id FROM trip_files WHERE id = ?').get(tourFileId)).toMatchObject({
id: tourFileId,
place_id: null,
});
expect(testDb.prepare('SELECT id FROM file_links WHERE file_id = ?').all(tourFileId)).toEqual([]);
expect(testDb.prepare('SELECT place_id FROM tours WHERE place_id = ?').get(keptTour.id)).toBeTruthy();
expect(testDb.prepare('SELECT id FROM tour_waypoints WHERE place_id = ?').all(keptTour.id)).toHaveLength(1);
expect(testDb.prepare('SELECT id FROM day_assignments WHERE place_id = ?').all(keptTour.id)).toHaveLength(1);
expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(keptPlace.id)).toBeTruthy();
expect(testDb.prepare('SELECT id, place_id FROM trip_files WHERE id = ?').get(keptFileId)).toMatchObject({
id: keptFileId,
place_id: keptTour.id,
});
});
it('PLACE-SVC-019e — a place with no booking is untouched by that', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -837,6 +910,24 @@ describe('remove', () => {
// ── removeMany ────────────────────────────────────────────────────────────────
describe('removeMany', () => {
it('PLACE-SVC-TOURS-003 — mixed bulk deletion reports only the deleted Tour facets', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const tour = createPlace(testDb, trip.id, { name: 'Tour to delete' }) as any;
const ordinary = createPlace(testDb, trip.id, { name: 'Ordinary place' }) as any;
const keptTour = createPlace(testDb, trip.id, { name: 'Tour to keep' }) as any;
for (const place of [tour, keptTour]) {
testDb.prepare(`INSERT INTO tours (place_id, tour_type) VALUES (?, 'hike')`).run(place.id);
}
const result = await svc.removeMany(String(trip.id), [ordinary.id, tour.id]);
expect(result.deleted).toEqual([ordinary.id, tour.id]);
expect(result.deletedTourPlaceIds).toEqual([tour.id]);
expect(testDb.prepare('SELECT place_id FROM tours WHERE place_id = ?').get(keptTour.id)).toBeTruthy();
expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(keptTour.id)).toBeTruthy();
});
it('PLACE-SVC-056 — deletes the trip-scoped ids in one transaction and reports them', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -0,0 +1,249 @@
/**
* ToursController and ToursImportController: route metadata, delegation to
* ToursService, and the GPX import's handler-level checks. The import is not
* guarded (a guard answers before multer drains the body and the client sees
* ECONNRESET), so its handler has to reproduce the guards' answers itself, in
* the same order and with the same bodies (parity with AddonGuard and
* TripAccessGuard).
*/
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
import { TRIP_PERMISSION_KEY, TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard';
import type { PlacesService } from '../../../src/nest/places/places.service';
import { ToursImportController } from '../../../src/nest/tours/tours-import.controller';
import { ToursController } from '../../../src/nest/tours/tours.controller';
import type { ToursService } from '../../../src/nest/tours/tours.service';
import type { User } from '../../../src/types';
import { HttpException, RequestMethod } from '@nestjs/common';
import {
GUARDS_METADATA,
HTTP_CODE_METADATA,
INTERCEPTORS_METADATA,
METHOD_METADATA,
PATH_METADATA,
} from '@nestjs/common/constants';
import type { TourCreateRequest } from '@trek/shared';
import 'reflect-metadata';
import { describe, expect, it, vi } from 'vitest';
const { legacyDatabaseAccess } = vi.hoisted(() => ({
legacyDatabaseAccess: vi.fn((property: string | symbol): never => {
throw new Error(`Unexpected legacy database access: ${String(property)}`);
}),
}));
vi.mock('../../../src/config', () => ({
ENCRYPTION_KEY: 'test-only-inert-key',
JWT_SECRET: 'test-only-inert-secret',
updateJwtSecret: vi.fn(),
}));
vi.mock('../../../src/db/database', () => ({
db: new Proxy(
{},
{
get: (_target, property: string | symbol) => legacyDatabaseAccess(property),
},
),
}));
vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} }));
vi.mock('../../../src/nest/addons/addons.service', () => ({ AddonsService: class {} }));
vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} }));
vi.mock('../../../src/nest/tours/tours.service', () => ({ ToursService: class {} }));
vi.mock('../../../src/nest/auth/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() }));
const request: TourCreateRequest = {
name: 'Ridge walk',
tour_type: 'hike' as const,
route_geometry: [
[48, 11, 600],
[48.02, 11.04, 630],
] as [number, number, number][],
waypoints: [
{ lat: 48, lng: 11, role: 'start' as const, sequence: 0 },
{ lat: 48.02, lng: 11.04, role: 'end' as const, sequence: 1 },
],
max_hiking_difficulty: 2,
duration_seconds: 3600,
};
const user = { id: 2, role: 'user', email: 'u@example.test' } as User;
const trip = { id: 7, user_id: 1 };
const file = { buffer: Buffer.from('<gpx/>'), originalname: 'ridge.gpx' } as Express.Multer.File;
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
try {
await fn();
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected throw');
}
function importFixture(
o: { addon?: boolean; trip?: typeof trip | undefined; canEdit?: boolean; result?: unknown } = {},
) {
const addons = { isAddonEnabled: vi.fn(async () => o.addon ?? true) };
const places = {
verifyTripAccess: vi.fn(async () => ('trip' in o ? o.trip : trip)),
canEdit: vi.fn(async () => o.canEdit ?? true),
};
const result = 'result' in o ? o.result : { tours: [{ place_id: 42 }], caution: false, skipped: 0 };
const tours = { importGpxAsTour: vi.fn(async () => result) };
const controller = new ToursImportController(
tours as unknown as ToursService,
places as unknown as PlacesService,
addons as unknown as AddonsService,
);
return { addons, places, tours, controller, result };
}
describe('ToursController planner routes', () => {
it('does not access the legacy global database initializer', () => {
expect(legacyDatabaseAccess).not.toHaveBeenCalled();
});
it('TOURS-CTL-001: hides every Tours route behind the addon guard before auth', () => {
expect(Reflect.getMetadata(PATH_METADATA, ToursController)).toBe('api/trips/:tripId/tours');
expect(Reflect.getMetadata(GUARDS_METADATA, ToursController)).toEqual([AddonGuard, JwtAuthGuard, TripAccessGuard]);
expect(Reflect.getMetadata(REQUIRE_ADDON, ToursController)).toEqual({ addonId: 'tours', label: 'Tours' });
});
it('TOURS-CTL-001b: routes, methods and write permissions', () => {
const routes = [
['list', RequestMethod.GET, '/', undefined],
['detail', RequestMethod.GET, ':placeId', undefined],
['create', RequestMethod.POST, '/', 'place_edit'],
['update', RequestMethod.PUT, ':placeId', 'place_edit'],
] as const;
for (const [handler, method, path, permission] of routes) {
const fn = ToursController.prototype[handler];
expect(Reflect.getMetadata(METHOD_METADATA, fn)).toBe(method);
expect(Reflect.getMetadata(PATH_METADATA, fn)).toBe(path);
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, fn)).toBe(permission);
}
// POST keeps Nest's default 201, as the PR's route answered.
expect(Reflect.getMetadata(HTTP_CODE_METADATA, ToursController.prototype.create)).toBeUndefined();
});
it('TOURS-CTL-002: POST /tours delegates the validated payload and socket id', async () => {
const result = { tour: { place_id: 1 }, waypoints: request.waypoints };
const service = { createTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService;
const controller = new ToursController(service);
expect(await controller.create('7', request, 'socket-1')).toBe(result);
expect(service.createTour).toHaveBeenCalledWith('7', request, 'socket-1');
});
it('TOURS-CTL-003: GET /tours/:placeId delegates within the guarded trip', async () => {
const result = { tour: { place_id: 1 }, waypoints: request.waypoints };
const service = { getTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService;
const controller = new ToursController(service);
expect(await controller.detail('7', '1')).toBe(result);
expect(service.getTour).toHaveBeenCalledWith('7', '1');
});
it('TOURS-CTL-004: PUT /tours/:placeId delegates the validated payload and socket id', async () => {
const result = { tour: { place_id: 1 }, waypoints: request.waypoints };
const service = { updateTour: vi.fn().mockResolvedValue(result) } as unknown as ToursService;
const controller = new ToursController(service);
expect(await controller.update('7', '1', request, 'socket-2')).toBe(result);
expect(service.updateTour).toHaveBeenCalledWith('7', '1', request, 'socket-2');
});
it('TOURS-CTL-005: GET /tours wraps the list in { tours }', async () => {
const tours = [{ place_id: 1 }];
const service = { listTours: vi.fn().mockResolvedValue(tours) } as unknown as ToursService;
expect(await new ToursController(service).list('7')).toEqual({ tours });
expect(service.listTours).toHaveBeenCalledWith('7');
});
});
describe('ToursImportController (POST /api/trips/:tripId/tours/import/gpx)', () => {
it('TOURS-IMP-001: the multipart route carries only the JWT guard and checks the rest in the handler', () => {
expect(Reflect.getMetadata(PATH_METADATA, ToursImportController)).toBe('api/trips/:tripId/tours');
expect(Reflect.getMetadata(GUARDS_METADATA, ToursImportController)).toEqual([JwtAuthGuard]);
expect(Reflect.getMetadata(REQUIRE_ADDON, ToursImportController)).toBeUndefined();
const handler = ToursImportController.prototype.importGpx;
expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(RequestMethod.POST);
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe('import/gpx');
expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toBeUndefined();
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handler)).toBeUndefined();
expect(Reflect.getMetadata(INTERCEPTORS_METADATA, handler)).toHaveLength(1);
});
it('TOURS-IMP-002: delegates the route trip, file and socket id and returns the service result', async () => {
const { controller, tours, places, result } = importFixture();
expect(await controller.importGpx(user, '7', file, 'socket')).toBe(result);
expect(places.verifyTripAccess).toHaveBeenCalledWith('7', user.id);
expect(places.canEdit).toHaveBeenCalledWith(trip, user);
expect(tours.importGpxAsTour).toHaveBeenCalledWith('7', file.buffer, 'ridge.gpx', 'socket');
});
it('TOURS-IMP-003: a duplicate-only import is a successful skipped result', async () => {
const allSkipped = { tours: [], caution: false, skipped: 2 };
const { controller } = importFixture({ result: allSkipped });
expect(await controller.importGpx(user, '7', file)).toBe(allSkipped);
});
it('TOURS-IMP-004: addon off is the AddonGuard 404 before any trip lookup', async () => {
const { controller, places, tours, addons } = importFixture({ addon: false });
expect(await thrownAsync(() => controller.importGpx(user, '7', file))).toEqual({
status: 404,
body: { error: 'Tours addon is not enabled' },
});
expect(addons.isAddonEnabled).toHaveBeenCalledWith('tours');
expect(places.verifyTripAccess).not.toHaveBeenCalled();
expect(tours.importGpxAsTour).not.toHaveBeenCalled();
});
it('TOURS-IMP-005: an inaccessible trip is the TripAccessGuard 404 before any permission check', async () => {
const { controller, places, tours } = importFixture({ trip: undefined });
expect(await thrownAsync(() => controller.importGpx(user, '8', file))).toEqual({
status: 404,
body: { error: 'Trip not found' },
});
expect(places.verifyTripAccess).toHaveBeenCalledWith('8', user.id);
expect(places.canEdit).not.toHaveBeenCalled();
expect(tours.importGpxAsTour).not.toHaveBeenCalled();
});
it('TOURS-IMP-006: without place_edit it is the TripAccessGuard 403, checked before the file', async () => {
const { controller, tours } = importFixture({ canEdit: false });
expect(await thrownAsync(() => controller.importGpx(user, '7', undefined))).toEqual({
status: 403,
body: { error: 'No permission' },
});
expect(tours.importGpxAsTour).not.toHaveBeenCalled();
});
it('TOURS-IMP-007: 400 without a file', async () => {
const { controller, tours } = importFixture();
expect(await thrownAsync(() => controller.importGpx(user, '7', undefined))).toEqual({
status: 400,
body: { error: 'No file uploaded' },
});
expect(tours.importGpxAsTour).not.toHaveBeenCalled();
});
it('TOURS-IMP-008: 400 when the GPX has no track or route', async () => {
const { controller } = importFixture({ result: null });
expect(await thrownAsync(() => controller.importGpx(user, '7', file))).toEqual({
status: 400,
body: { error: 'No track or route found in GPX file' },
});
});
});
@@ -0,0 +1,387 @@
/**
* The tours-mode GPX import end to end through the real PlacesService and
* ToursService over the migrated per-worker DB: the places, their track
* colours and their tours facets commit in one transaction (PlacesService's
* importPreparedGpx nests in ToursService's as a savepoint), any failure on
* the way rolls all of it back, and the realtime events go out only after the
* commit. Failures are injected with SQLite triggers on the real tables.
*/
import { db as testDb } from '../../../src/db/database';
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import type { UnitOfWork } from '../../../src/nest/database/unit-of-work';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { MapsService } from '../../../src/nest/maps/maps.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service';
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
import { gpxParser } from '../../../src/nest/places/places.helpers';
import { PlacesService } from '../../../src/nest/places/places.service';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { ToursService } from '../../../src/nest/tours/tours.service';
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
import { accommodationsOver } from '../../helpers/accommodations-service';
import { createTrip, createUser } from '../../helpers/factories';
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import { noGoogleQuota } from '../../helpers/google-quota';
import {
createTestJourneysRepo,
createTestJourneyContributorsRepo,
createTestJourneyTripsRepo,
createTestJourneyEntriesRepo,
createTestJourneyPhotosRepo,
createTestJourneyEntryPhotosRepo,
} from '../../helpers/journey-repos';
import { makeStorageFixture } from '../../helpers/storage-fixture';
import { resetTestDb } from '../../helpers/test-db';
import {
createTestUnitOfWork,
createTestAppSettingsRepo,
createTestUsersRepo,
createTestTagsRepo,
createTestPlaceRatingsRepo,
createTestAssignmentParticipantsRepo,
createTestPlacesRepo,
createTestTripMembersRepo,
createTestDayAssignmentsRepo,
createTestCategoriesRepo,
createTestTripsRepo,
createTestCollectionPlacesRepo,
sharedTestOrm,
} from '../../helpers/test-uow';
import { createTestToursRepo, createTestTourWaypointsRepo } from '../../helpers/tours-repos';
import { Logger } from '@nestjs/common';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const mixedGpx = Buffer.from(`<gpx>
<wpt lat="1" lon="2"><name>Ignored POI</name></wpt>
<rte><desc>Route description</desc><rtept lat="48" lon="11"/><rtept lat="48.01" lon="11.01"/></rte>
<trk><name>Ridge</name><desc>Track description</desc><trkseg>
<trkpt lat="49" lon="12"><ele>100</ele></trkpt>
<trkpt lat="49.01" lon="12.01"><ele>150</ele></trkpt>
</trkseg><trkseg><trkpt lat="49.02" lon="12.02"><ele>120</ele></trkpt></trkseg></trk>
</gpx>`);
const TRIGGERS = ['fail_facet', 'fail_place', 'fail_color'];
/** The collaborator set the container hands PlacesService (the places.service.test.ts builder). */
async function makePlacesService(): Promise<PlacesService> {
const photoCacheStub = { removeIfUnreferenced: vi.fn() } as unknown as PlacePhotoCacheService;
const noAppSettings = { getValue: async () => null } as unknown as AppSettingsRepository;
const noUsers = { getApiKeyColumn: async () => null } as unknown as UsersRepository;
const storage = makeStorageFixture('').storage;
const orm = await sharedTestOrm(testDb);
return new PlacesService(
new PermissionsService(await createTestAppSettingsRepo(testDb), await createTestUnitOfWork(testDb)),
new RealtimeService(),
new MapsService(photoCacheStub, noAppSettings, noUsers, {} as never, {} as never, noGoogleQuota),
new QueryHelpersService(
await createTestTagsRepo(testDb),
await createTestPlaceRatingsRepo(testDb),
await createTestAssignmentParticipantsRepo(testDb),
),
new UnsplashService(
await createTestAppSettingsRepo(testDb),
await createTestUsersRepo(testDb),
new RuntimeEnvService(),
storage,
),
photoCacheStub,
new JourneyDomainService(
new RealtimeService(),
new TrekPhotoRegistrationService(
orm.repo(TrekPhotos),
orm.repo(TripPhotos),
await createTestJourneyPhotosRepo(testDb),
),
await createTestUnitOfWork(testDb),
await createTestJourneysRepo(testDb),
await createTestJourneyContributorsRepo(testDb),
await createTestJourneyTripsRepo(testDb),
await createTestJourneyEntriesRepo(testDb),
await createTestTripsRepo(testDb),
await createTestJourneyPhotosRepo(testDb),
await createTestJourneyEntryPhotosRepo(testDb),
await createTestPlacesRepo(testDb),
),
storage,
await accommodationsOver(testDb),
await createTestUnitOfWork(testDb),
await createTestPlacesRepo(testDb),
await createTestTagsRepo(testDb),
await createTestPlaceRatingsRepo(testDb),
await createTestTripMembersRepo(testDb),
await createTestDayAssignmentsRepo(testDb),
await createTestCategoriesRepo(testDb),
await createTestTripsRepo(testDb),
await createTestBudgetItemsRepo(testDb),
await createTestCollectionPlacesRepo(testDb),
);
}
let uow: UnitOfWork;
let places: PlacesService;
let tours: ToursService;
let tripId: string;
let broadcast: MockInstance<PlacesService['broadcast']>;
const count = (table: string) => (testDb.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;
beforeAll(async () => {
uow = await createTestUnitOfWork(testDb);
places = await makePlacesService();
tours = new ToursService(
uow,
places,
await createTestToursRepo(testDb),
await createTestTourWaypointsRepo(testDb),
await createTestPlacesRepo(testDb),
);
});
beforeEach(async () => {
resetTestDb(testDb);
(await sharedTestOrm(testDb)).clear();
const { user } = createUser(testDb);
tripId = String(createTrip(testDb, user.id).id);
broadcast = vi.spyOn(places, 'broadcast').mockImplementation(() => {});
});
afterEach(() => {
for (const name of TRIGGERS) testDb.exec(`DROP TRIGGER IF EXISTS ${name}`);
vi.restoreAllMocks();
});
afterAll(() => {
testDb.close();
});
describe('Tours GPX atomic persistence and postcommit publication', () => {
function expectEmpty() {
for (const table of ['places', 'tours', 'tour_waypoints']) expect(count(table)).toBe(0);
expect(broadcast).not.toHaveBeenCalled();
}
it('commits routes, tracks, colors and facets once before publishing, without Planner waypoints', async () => {
const transactional = vi.spyOn(uow, 'transactional');
const seen: { inTransaction: boolean; tours: number }[] = [];
broadcast.mockImplementation(() => {
seen.push({ inTransaction: testDb.inTransaction, tours: count('tours') });
});
const result = (await tours.importGpxAsTour(tripId, mixedGpx, 'walk.gpx', 'socket'))!;
// The tours transaction, with importPreparedGpx and its colouring nested inside as savepoints.
expect(transactional.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(result.tours).toHaveLength(2);
expect(result.skipped).toBe(0);
expect(result.caution).toBe(true);
expect(result.tours[0]).toMatchObject({
name: 'walk',
tour_type: 'hike',
max_hiking_difficulty: 2,
planned: false,
has_waypoints: false,
caution: true,
match_confidence: 0.3,
});
expect(result.tours[1]).toMatchObject({
name: 'Ridge',
elevation_gain: 50,
elevation_loss: 30,
caution: false,
match_confidence: 1,
});
const rows = testDb.prepare('SELECT * FROM places ORDER BY id').all() as {
id: number;
trip_id: number;
description: string;
route_geometry: string;
route_color: string;
}[];
expect(rows.map((row) => row.trip_id)).toEqual([Number(tripId), Number(tripId)]);
expect(rows.map((row) => row.description)).toEqual(['Route description', 'Track description']);
expect(JSON.parse(rows[0].route_geometry)).toEqual([
[48, 11],
[48.01, 11.01],
]);
expect(JSON.parse(rows[1].route_geometry)).toEqual([
[49, 12, 100],
[49.01, 12.01, 150],
[49.02, 12.02, 120],
]);
expect(new Set(rows.map((row) => row.route_color)).size).toBe(2);
expect(testDb.prepare('SELECT place_id, tour_type FROM tours ORDER BY place_id').all()).toEqual(
rows.map((row) => ({ place_id: row.id, tour_type: 'hike' })),
);
expect(count('tour_waypoints')).toBe(0);
expect(seen).toEqual([
{ inTransaction: false, tours: 2 },
{ inTransaction: false, tours: 2 },
{ inTransaction: false, tours: 2 },
]);
expect(broadcast.mock.calls.map((call) => call[1])).toEqual(['tours:changed', 'place:created', 'place:created']);
expect(broadcast.mock.calls[0]).toEqual([
tripId,
'tours:changed',
{ placeIds: rows.map((row) => row.id) },
'socket',
]);
for (const call of broadcast.mock.calls.slice(1)) {
expect(call[3]).toBe('socket');
expect((call[2] as { place: { route_color: string } }).place.route_color).toBeTruthy();
}
expect(broadcast.mock.calls.slice(1).map((call) => (call[2] as { place: { id: number } }).place.id)).toEqual(
result.tours.map((tour) => tour.place_id),
);
});
it.each([1, 2])('rolls back every carrier and facet when facet %i fails', async (ordinal) => {
testDb.exec(`CREATE TRIGGER fail_facet BEFORE INSERT ON tours
WHEN (SELECT COUNT(*) FROM tours) = ${ordinal - 1}
BEGIN SELECT RAISE(ABORT, 'facet failure'); END;`);
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('facet failure');
expectEmpty();
});
it('rolls back earlier carriers when the later Place insert fails', async () => {
testDb.exec(`CREATE TRIGGER fail_place BEFORE INSERT ON places WHEN NEW.name = 'Ridge'
BEGIN SELECT RAISE(ABORT, 'place failure'); END;`);
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('place failure');
expectEmpty();
});
it('rolls back carriers and earlier colors when later coloring fails', async () => {
testDb.exec(`CREATE TRIGGER fail_color BEFORE UPDATE OF route_color ON places WHEN NEW.name = 'Ridge'
BEGIN SELECT RAISE(ABORT, 'color failure'); END;`);
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('color failure');
expectEmpty();
});
it.each([
'<not-gpx/>',
'<gpx/>',
'<gpx><wpt lat="1" lon="2"/></gpx>',
'<gpx><trk><trkseg><trkpt/></trkseg></trk></gpx>',
])('does not write or publish unusable input %s', async (xml) => {
const transactional = vi.spyOn(uow, 'transactional');
expect(await tours.importGpxAsTour(tripId, Buffer.from(xml))).toBeNull();
expect(transactional).not.toHaveBeenCalled();
expectEmpty();
});
it('propagates parser failures before entering the transaction', async () => {
const transactional = vi.spyOn(uow, 'transactional');
vi.spyOn(gpxParser, 'parse').mockImplementationOnce(() => {
throw new Error('parser failure');
});
await expect(tours.importGpxAsTour(tripId, mixedGpx)).rejects.toThrow('parser failure');
expect(transactional).not.toHaveBeenCalled();
expectEmpty();
});
it('keeps a committed response and attempts remaining events if publication throws', async () => {
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
broadcast.mockImplementationOnce(() => {
throw new Error('transport unavailable');
});
expect((await tours.importGpxAsTour(tripId, mixedGpx))?.tours).toHaveLength(2);
expect(count('tours')).toBe(2);
expect(broadcast).toHaveBeenCalledTimes(3);
expect(warn).toHaveBeenCalledOnce();
});
it('keeps committed rows and success response if a place event fails', async () => {
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
broadcast.mockImplementation((_tripId, event) => {
if (event === 'place:created') throw new Error('transport unavailable');
});
expect((await tours.importGpxAsTour(tripId, mixedGpx))?.tours).toHaveLength(2);
expect(count('places')).toBe(2);
expect(warn).toHaveBeenCalledTimes(2);
});
it('keeps committed rows and success response if the Tours invalidation event fails', async () => {
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
broadcast.mockImplementation((_tripId, event) => {
if (event === 'tours:changed') throw new Error('invalidation unavailable');
});
const result = await tours.importGpxAsTour(tripId, mixedGpx);
expect(result?.tours).toHaveLength(2);
expect(count('places')).toBe(2);
expect(count('tours')).toBe(2);
expect(broadcast.mock.calls.map((call) => call[1])).toEqual(['tours:changed', 'place:created', 'place:created']);
expect(warn).toHaveBeenCalledOnce();
});
it('returns a successful skipped result for duplicate-only imports without publishing events', async () => {
expect((await tours.importGpxAsTour(tripId, mixedGpx))?.tours).toHaveLength(2);
const rowsBefore = testDb.prepare('SELECT id, trip_id, name FROM places ORDER BY id').all();
const facetsBefore = testDb.prepare('SELECT place_id FROM tours ORDER BY place_id').all();
broadcast.mockClear();
expect(await tours.importGpxAsTour(tripId, mixedGpx)).toEqual({ tours: [], caution: false, skipped: 2 });
expect(testDb.prepare('SELECT id, trip_id, name FROM places ORDER BY id').all()).toEqual(rowsBefore);
expect(testDb.prepare('SELECT place_id FROM tours ORDER BY place_id').all()).toEqual(facetsBefore);
expect(broadcast).not.toHaveBeenCalled();
});
it('imports only the new tracks and publishes only committed rows in a mixed result', async () => {
const existing = Buffer.from(`<gpx><trk><name>Ridge</name><trkseg>
<trkpt lat="49" lon="12"><ele>100</ele></trkpt><trkpt lat="49.01" lon="12.01"><ele>150</ele></trkpt>
</trkseg></trk></gpx>`);
expect((await tours.importGpxAsTour(tripId, existing))?.tours).toHaveLength(1);
broadcast.mockClear();
const result = await tours.importGpxAsTour(tripId, mixedGpx, 'walk.gpx', 'socket');
expect(result?.tours).toHaveLength(1);
expect(result?.tours[0].name).toBe('walk');
expect(result?.skipped).toBe(1);
expect(count('places')).toBe(2);
expect(count('tours')).toBe(2);
const walkId = result!.tours[0].place_id;
expect(broadcast.mock.calls.map((call) => [call[1], call[2]])).toEqual([
['tours:changed', { placeIds: [walkId] }],
['place:created', expect.objectContaining({ place: expect.objectContaining({ id: walkId, name: 'walk' }) })],
]);
});
it('preserves single-track response and trip scoping', async () => {
const { user } = createUser(testDb);
const otherTripId = String(createTrip(testDb, user.id).id);
const single = Buffer.from(
'<gpx><trk><trkseg><trkpt lat="48" lon="11"/><trkpt lat="48.01" lon="11.01"/></trkseg></trk></gpx>',
);
const result = (await tours.importGpxAsTour(otherTripId, single, 'single.gpx'))!;
expect(result.tours).toHaveLength(1);
expect(result.tours[0].name).toBe('single');
expect(result.caution).toBe(true);
expect(testDb.prepare('SELECT trip_id FROM places').all()).toEqual([{ trip_id: Number(otherTripId) }]);
expect((await tours.listTours(otherTripId)).map((t) => t.place_id)).toEqual([result.tours[0].place_id]);
expect(await tours.listTours(tripId)).toEqual([]);
expect(broadcast.mock.calls.map((call) => call[1])).toEqual(['tours:changed', 'place:created']);
});
});
@@ -0,0 +1,339 @@
/**
* Tours reuse the ordinary place and day-assignment endpoints for deletion and
* planning, so their permissions are those endpoints' permissions: deleting a
* tour needs 'place_edit' only, assigning or unassigning one needs 'day_edit'
* only. Run through the real TripAccessGuard with mocked collaborators (no DB).
* The last block pins ToursService's trip boundary on update against stubbed
* repositories.
*/
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
import type { TourWaypointsRepository } from '../../../src/db/repositories/TourWaypoints.repository';
import type { ToursRepository } from '../../../src/db/repositories/Tours.repository';
import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { DayAssignmentsController } from '../../../src/nest/assignments/assignments.controller';
import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
import type { UnitOfWork } from '../../../src/nest/database/unit-of-work';
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { TripAccessGuard, TRIP_PERMISSION_KEY } from '../../../src/nest/permissions/trip-access.guard';
import { PlacesController } from '../../../src/nest/places/places.controller';
import type { PlacesService } from '../../../src/nest/places/places.service';
import type { StorageService } from '../../../src/nest/storage/storage.service';
import { ToursService } from '../../../src/nest/tours/tours.service';
import type { User } from '../../../src/types';
import type { EntityManager } from '@mikro-orm/core';
import { HttpException, NotFoundException, RequestMethod, type ExecutionContext } from '@nestjs/common';
import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
import { Reflector } from '@nestjs/core';
import type { TourCreateRequest } from '@trek/shared';
import 'reflect-metadata';
import { describe, expect, it, vi } from 'vitest';
const { legacyDatabaseAccess } = vi.hoisted(() => ({
legacyDatabaseAccess: vi.fn((property: string | symbol): never => {
throw new Error(`Unexpected legacy database access: ${String(property)}`);
}),
}));
vi.mock('../../../src/config', () => ({
ENCRYPTION_KEY: 'test-only-inert-key',
JWT_SECRET: 'test-only-inert-secret',
updateJwtSecret: vi.fn(),
}));
vi.mock('../../../src/db/database', () => ({
db: new Proxy(
{},
{
get: (_target, property: string | symbol) => legacyDatabaseAccess(property),
},
),
}));
vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} }));
vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} }));
vi.mock('../../../src/nest/assignments/assignments.service', () => ({ AssignmentsService: class {} }));
vi.mock('../../../src/nest/app-config/runtime-env.service', () => ({ RuntimeEnvService: class {} }));
vi.mock('../../../src/nest/storage/storage.service', () => ({ StorageService: class {} }));
vi.mock('../../../src/nest/auth/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() }));
const matrix = [
[false, false],
[true, false],
[false, true],
[true, true],
] as const;
function authorization(
controller: typeof PlacesController | typeof DayAssignmentsController,
handler: 'create' | 'remove',
canEdit: boolean,
canAssign: boolean,
tripId = '7',
) {
const request = { params: { tripId }, user: { id: 2, role: 'user' } as User };
const trip = { id: 7, user_id: 1 };
const access = vi.fn(async (id: number, userId: number) => (id === 7 && userId === 2 ? trip : undefined));
const permission = vi.fn(async (action: string) =>
action === 'place_edit' ? canEdit : action === 'day_edit' && canAssign,
);
const context = {
switchToHttp: () => ({ getRequest: () => request }),
getHandler: () => controller.prototype[handler],
getClass: () => controller,
} as unknown as ExecutionContext;
const em = { getRepository: vi.fn(() => ({ findAccessible: access })) } as unknown as EntityManager;
const guard = new TripAccessGuard(
em,
{ checkPermission: permission } as unknown as PermissionsService,
new Reflector(),
);
return { request, access, permission, context, guard };
}
async function rejected(action: () => Promise<unknown>, status: number) {
await expect(action()).rejects.toMatchObject({ status });
}
function placesFixture() {
const places = {
get: vi.fn(async () => ({ id: 42, trip_id: 7 })),
onDeleted: vi.fn(async () => {}),
linkedExpenseIds: vi.fn(async () => []),
remove: vi.fn(async () => ({
deleted: true,
deletedTourPlaceIds: [42],
cancelled: { reservationIds: [], budgetItemIds: [] },
})),
broadcast: vi.fn(),
};
const controller = new PlacesController(
places as unknown as PlacesService,
{} as RuntimeEnvService,
{} as StorageService,
);
return { places, controller };
}
function assignmentsFixture() {
const assignment = { id: 51, place_id: 42, day_id: 11 };
const assignments = {
dayExists: vi.fn(async () => true),
placeExists: vi.fn(async () => true),
assignmentExistsInDay: vi.fn(async () => true),
createAssignment: vi.fn(async () => assignment),
deleteAssignment: vi.fn(async () => {}),
broadcast: vi.fn(),
reconcile: vi.fn(async () => {}),
};
return {
assignment,
assignments,
controller: new DayAssignmentsController(assignments as unknown as AssignmentsService),
};
}
describe('Tours deletion and assignment authorization (mock-only)', () => {
it('does not access the legacy global database initializer', () => {
expect(legacyDatabaseAccess).not.toHaveBeenCalled();
});
it('keeps existing Places deletion and day assignment endpoints wired to their exact permissions', () => {
expect(Reflect.getMetadata(PATH_METADATA, PlacesController)).toBe('api/trips/:tripId/places');
expect(Reflect.getMetadata(GUARDS_METADATA, PlacesController)).toEqual([JwtAuthGuard]);
expect(Reflect.getMetadata(GUARDS_METADATA, PlacesController.prototype.remove)).toEqual([TripAccessGuard]);
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, PlacesController.prototype.remove)).toBe('place_edit');
expect(Reflect.getMetadata(METHOD_METADATA, PlacesController.prototype.remove)).toBe(RequestMethod.DELETE);
expect(Reflect.getMetadata(PATH_METADATA, PlacesController.prototype.remove)).toBe(':id');
expect(Reflect.getMetadata(PATH_METADATA, DayAssignmentsController)).toBe(
'api/trips/:tripId/days/:dayId/assignments',
);
expect(Reflect.getMetadata(GUARDS_METADATA, DayAssignmentsController)).toEqual([JwtAuthGuard, TripAccessGuard]);
for (const handler of ['create', 'remove'] as const) {
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, DayAssignmentsController.prototype[handler])).toBe('day_edit');
}
expect(Reflect.getMetadata(METHOD_METADATA, DayAssignmentsController.prototype.create)).toBe(RequestMethod.POST);
expect(Reflect.getMetadata(METHOD_METADATA, DayAssignmentsController.prototype.remove)).toBe(RequestMethod.DELETE);
expect(Reflect.getMetadata(PATH_METADATA, DayAssignmentsController.prototype.remove)).toBe(':id');
});
it.each(matrix)('edit=%s assign=%s: tour deletion requires only place_edit', async (canEdit, canAssign) => {
const auth = authorization(PlacesController, 'remove', canEdit, canAssign);
const { places, controller } = placesFixture();
const invoke = async () => {
await auth.guard.canActivate(auth.context);
return await controller.remove(auth.request.user, '7', '42', 'socket');
};
if (canEdit) {
await expect(invoke()).resolves.toEqual({ success: true, tourPlaceIds: [42] });
expect(places.get).toHaveBeenCalledWith('7', '42');
expect(places.onDeleted).toHaveBeenCalledWith(42);
expect(places.remove).toHaveBeenCalledWith('7', '42');
expect(places.broadcast).toHaveBeenCalledWith('7', 'place:deleted', { placeId: 42 }, 'socket');
} else {
await rejected(invoke, 403);
for (const method of Object.values(places)) expect(method).not.toHaveBeenCalled();
}
expect(auth.permission).toHaveBeenCalledExactlyOnceWith('place_edit', 'user', 1, 2, true);
});
describe.each(['create', 'remove'] as const)('assignment %s', (handler) => {
it.each(matrix)('edit=%s assign=%s: requires only day_edit', async (canEdit, canAssign) => {
const auth = authorization(DayAssignmentsController, handler, canEdit, canAssign);
const { assignment, assignments, controller } = assignmentsFixture();
const invoke = async () => {
await auth.guard.canActivate(auth.context);
return handler === 'create'
? await controller.create(auth.request.user, '7', '11', { place_id: 42, notes: 'Tour' }, 'socket')
: await controller.remove(auth.request.user, '7', '11', '51', 'socket');
};
if (canAssign) {
expect(await invoke()).toEqual(handler === 'create' ? { assignment } : { success: true });
if (handler === 'create') {
expect(assignments.dayExists).toHaveBeenCalledWith('11', '7');
expect(assignments.placeExists).toHaveBeenCalledWith(42, '7');
expect(assignments.createAssignment).toHaveBeenCalledWith('11', 42, 'Tour');
expect(assignments.deleteAssignment).not.toHaveBeenCalled();
expect(assignments.broadcast).toHaveBeenCalledWith('7', 'assignment:created', { assignment }, 'socket');
} else {
expect(assignments.assignmentExistsInDay).toHaveBeenCalledWith('51', '11', '7');
expect(assignments.deleteAssignment).toHaveBeenCalledWith('51');
expect(assignments.createAssignment).not.toHaveBeenCalled();
expect(assignments.broadcast).toHaveBeenCalledWith(
'7',
'assignment:deleted',
{ assignmentId: 51, dayId: 11 },
'socket',
);
}
expect(assignments.reconcile).toHaveBeenCalledWith('7', 'socket');
} else {
await rejected(invoke, 403);
for (const method of Object.values(assignments)) expect(method).not.toHaveBeenCalled();
}
expect(auth.permission).toHaveBeenCalledExactlyOnceWith('day_edit', 'user', 1, 2, true);
});
});
it.each([
[PlacesController, 'remove'],
[DayAssignmentsController, 'create'],
[DayAssignmentsController, 'remove'],
] as const)('%s.%s rejects an inaccessible route trip before checking permissions', async (owner, handler) => {
const auth = authorization(owner, handler, true, true, '8');
await rejected(() => auth.guard.canActivate(auth.context), 404);
expect(auth.access).toHaveBeenCalledExactlyOnceWith(8, 2);
expect(auth.permission).not.toHaveBeenCalled();
});
});
const input: TourCreateRequest = {
name: 'Ridge walk',
tour_type: 'hike',
max_hiking_difficulty: 2,
route_geometry: [
[48, 11, 600],
[48.02, 11.04, 630],
],
waypoints: [
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
{ lat: 48.02, lng: 11.04, role: 'end', sequence: 1 },
],
};
/** Tour 42 belongs to `ownerTripId`; findInTrip answers only for that trip. */
function tourRepositoryFixture(ownerTripId: number) {
const row = {
place_id: 42,
name: 'Ridge walk',
tour_type: 'hike',
distance: 3,
elevation_gain: 30,
elevation_loss: 0,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
tour_group_id: null,
max_hiking_difficulty: 2,
planned: 0,
has_waypoints: 1,
};
const uow = { transactional: vi.fn(async <T>(fn: () => Promise<T>): Promise<T> => await fn()) };
const toursRepo = {
findInTrip: vi.fn(async (tripId: number, placeId: number) =>
tripId === ownerTripId && placeId === 42 ? row : undefined,
),
updateInTrip: vi.fn(async () => true),
};
const waypointsRepo = {
listForPlace: vi.fn(async () => input.waypoints),
deleteForPlace: vi.fn(async () => {}),
insertForPlace: vi.fn(async () => {}),
};
const placesRepo = {
updateTourRoute: vi.fn(async () => true),
findInTrip: vi.fn(async () => undefined),
findWithTagsAndRatings: vi.fn(async () => ({ id: 42, trip_id: ownerTripId })),
};
const places = { broadcast: vi.fn() };
const service = new ToursService(
uow as unknown as UnitOfWork,
places as unknown as PlacesService,
toursRepo as unknown as ToursRepository,
waypointsRepo as unknown as TourWaypointsRepository,
placesRepo as unknown as PlacesRepository,
);
return { uow, toursRepo, waypointsRepo, placesRepo, places, service };
}
describe('Real ToursService update trip boundary (stubbed repositories)', () => {
it('rejects a placeId belonging to another trip before any write', async () => {
const f = tourRepositoryFixture(8);
expect((await f.service.getTour('8', '42')).tour.place_id).toBe(42);
f.toursRepo.findInTrip.mockClear();
f.waypointsRepo.listForPlace.mockClear();
const error: unknown = await f.service.updateTour('7', '42', input, 'socket').catch((e: unknown) => e);
expect(error).toBeInstanceOf(NotFoundException);
expect((error as HttpException).getStatus()).toBe(404);
expect((error as Error).message).toBe('Tour not found');
expect(f.toursRepo.findInTrip).toHaveBeenCalledExactlyOnceWith(7, 42);
for (const method of [
f.placesRepo.updateTourRoute,
f.toursRepo.updateInTrip,
f.waypointsRepo.deleteForPlace,
f.waypointsRepo.insertForPlace,
f.waypointsRepo.listForPlace,
f.placesRepo.findWithTagsAndRatings,
f.places.broadcast,
]) {
expect(method).not.toHaveBeenCalled();
}
});
it('allows a same-trip update through the transaction with trip-scoped writes', async () => {
const f = tourRepositoryFixture(7);
expect((await f.service.updateTour('7', '42', input, 'socket')).tour.place_id).toBe(42);
expect(f.uow.transactional).toHaveBeenCalledOnce();
// Once inside the write, once for the response.
expect(f.toursRepo.findInTrip).toHaveBeenCalledTimes(2);
expect(f.placesRepo.updateTourRoute).toHaveBeenCalledExactlyOnceWith(42, 7, {
name: input.name,
lat: 48,
lng: 11,
route_geometry: JSON.stringify(input.route_geometry),
});
expect(f.toursRepo.updateInTrip).toHaveBeenCalledExactlyOnceWith(
7,
42,
expect.objectContaining({ tour_type: 'hike', duration: null }),
);
expect(f.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42);
expect(f.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, input.waypoints);
expect(f.placesRepo.findWithTagsAndRatings).toHaveBeenCalledWith(42);
expect(f.places.broadcast).toHaveBeenCalledWith('7', 'place:updated', { place: { id: 42, trip_id: 7 } }, 'socket');
});
});
@@ -0,0 +1,195 @@
/**
* The Tours permission contract, run through the real guards with mocked
* collaborators (no DB): reads need trip access only, writes need
* 'place_edit' and never 'day_edit', an inaccessible trip is a 404 before any
* permission check, and the addon and JWT refusals stop a request before the
* trip is resolved. The GPX import answers the same matrix from its handler.
*/
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
import type { AddonsService } from '../../../src/nest/addons/addons.service';
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
import { extractToken, verifyJwtAndLoadUser } from '../../../src/nest/auth/jwt-verify';
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import {
TripAccessGuard,
TRIP_PERMISSION_KEY,
TRIP_REQUEST_KEY,
} from '../../../src/nest/permissions/trip-access.guard';
import type { PlacesService } from '../../../src/nest/places/places.service';
import { ToursImportController } from '../../../src/nest/tours/tours-import.controller';
import { ToursController } from '../../../src/nest/tours/tours.controller';
import type { ToursService } from '../../../src/nest/tours/tours.service';
import type { User } from '../../../src/types';
import type { EntityManager } from '@mikro-orm/core';
import { HttpException, type ExecutionContext } from '@nestjs/common';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { Reflector } from '@nestjs/core';
import 'reflect-metadata';
import { describe, expect, it, vi } from 'vitest';
const { legacyDatabaseAccess } = vi.hoisted(() => ({
legacyDatabaseAccess: vi.fn((property: string | symbol): never => {
throw new Error(`Unexpected legacy database access: ${String(property)}`);
}),
}));
vi.mock('../../../src/config', () => ({
ENCRYPTION_KEY: 'test-only-inert-key',
JWT_SECRET: 'test-only-inert-secret',
updateJwtSecret: vi.fn(),
}));
vi.mock('../../../src/db/database', () => ({
db: new Proxy(
{},
{
get: (_target, property: string | symbol) => legacyDatabaseAccess(property),
},
),
}));
vi.mock('../../../src/nest/permissions/permissions.service', () => ({ PermissionsService: class {} }));
vi.mock('../../../src/nest/addons/addons.service', () => ({ AddonsService: class {} }));
vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} }));
vi.mock('../../../src/nest/tours/tours.service', () => ({ ToursService: class {} }));
vi.mock('../../../src/nest/auth/jwt-verify', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() }));
const matrix = [
[false, false],
[true, false],
[false, true],
[true, true],
] as const;
const user = { id: 2, role: 'user' } as User;
const trip = { id: 7, user_id: 1 };
/** Trip 7 is reachable for user 2; it belongs to user 1, so every check is a shared-trip check. */
function fixture(handler: keyof ToursController, canEdit = false, canAssign = false, tripId = '7') {
const request = { params: { tripId }, user };
const access = vi.fn(async (id: number, userId: number) => (id === 7 && userId === 2 ? trip : undefined));
const permission = vi.fn(async (action: string) =>
action === 'place_edit' ? canEdit : action === 'day_edit' && canAssign,
);
const context = {
switchToHttp: () => ({ getRequest: () => request }),
getHandler: () => ToursController.prototype[handler],
getClass: () => ToursController,
} as unknown as ExecutionContext;
const em = { getRepository: vi.fn(() => ({ findAccessible: access })) } as unknown as EntityManager;
const guard = new TripAccessGuard(
em,
{ checkPermission: permission } as unknown as PermissionsService,
new Reflector(),
);
return { request, access, permission, context, guard };
}
/** The import handler's own checks, with the same access and permission doubles. */
function importFixture(canEdit: boolean, canAssign: boolean) {
const permission = vi.fn(async (action: string, ..._context: unknown[]) =>
action === 'place_edit' ? canEdit : action === 'day_edit' && canAssign,
);
const access = vi.fn(async (id: string, userId: number) => (id === '7' && userId === 2 ? trip : undefined));
const places = {
verifyTripAccess: access,
canEdit: vi.fn(async (t: typeof trip, u: User) =>
permission('place_edit', u.role, t.user_id, u.id, t.user_id !== u.id),
),
};
const importGpxAsTour = vi.fn(async () => ({ tours: [{ place_id: 42 }], caution: false, skipped: 0 }));
const controller = new ToursImportController(
{ importGpxAsTour } as unknown as ToursService,
places as unknown as PlacesService,
{ isAddonEnabled: vi.fn(async () => true) } as unknown as AddonsService,
);
return { controller, access, permission, importGpxAsTour };
}
async function expectStatus(action: () => Promise<unknown>, status: number) {
try {
await action();
expect.fail('Expected rejection');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect((error as HttpException).getStatus()).toBe(status);
}
}
const file = { buffer: Buffer.from('gpx'), originalname: 'ridge.gpx' } as Express.Multer.File;
describe('Tours permission contract (mock-only, no DB)', () => {
it('does not access the legacy global database initializer', () => {
expect(legacyDatabaseAccess).not.toHaveBeenCalled();
});
it('retains addon/JWT/trip guard order and exact write metadata', () => {
expect(Reflect.getMetadata(GUARDS_METADATA, ToursController)).toEqual([AddonGuard, JwtAuthGuard, TripAccessGuard]);
expect(Reflect.getMetadata(GUARDS_METADATA, ToursImportController)).toEqual([JwtAuthGuard]);
for (const handler of ['create', 'update'] as const) {
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, ToursController.prototype[handler])).toBe('place_edit');
}
for (const handler of ['list', 'detail'] as const) {
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, ToursController.prototype[handler])).toBeUndefined();
}
});
it.each(matrix)('edit=%s assign=%s permits reads and only place-edit writes', async (canEdit, canAssign) => {
for (const handler of ['list', 'detail'] as const) {
const test = fixture(handler, canEdit, canAssign);
expect(await test.guard.canActivate(test.context)).toBe(true);
expect(test.permission).not.toHaveBeenCalled();
expect(test.request[TRIP_REQUEST_KEY as keyof typeof test.request]).toEqual(trip);
}
for (const handler of ['create', 'update'] as const) {
const test = fixture(handler, canEdit, canAssign);
if (canEdit) expect(await test.guard.canActivate(test.context)).toBe(true);
else await expectStatus(() => test.guard.canActivate(test.context), 403);
expect(test.permission).toHaveBeenCalledExactlyOnceWith('place_edit', 'user', 1, 2, true);
}
});
it.each(matrix)('edit=%s assign=%s: the GPX import handler requires only place_edit', async (canEdit, canAssign) => {
const test = importFixture(canEdit, canAssign);
if (canEdit) {
expect(await test.controller.importGpx(user, '7', file, 'socket')).toMatchObject({ tours: [{ place_id: 42 }] });
expect(test.importGpxAsTour).toHaveBeenCalledWith('7', file.buffer, 'ridge.gpx', 'socket');
} else {
await expectStatus(() => test.controller.importGpx(user, '7', file), 403);
expect(test.importGpxAsTour).not.toHaveBeenCalled();
}
expect(test.permission).toHaveBeenCalledExactlyOnceWith('place_edit', 'user', 1, 2, true);
});
it.each(['list', 'detail', 'create', 'update'] as const)(
'%s rejects inaccessible route trips before any write permission check',
async (handler) => {
const test = fixture(handler, true, true, '8');
await expectStatus(() => test.guard.canActivate(test.context), 404);
expect(test.access).toHaveBeenCalledWith(8, 2);
expect(test.permission).not.toHaveBeenCalled();
},
);
it('importGpx rejects an inaccessible route trip before any permission check', async () => {
const test = importFixture(true, true);
await expectStatus(() => test.controller.importGpx(user, '8', file), 404);
expect(test.access).toHaveBeenCalledWith('8', 2);
expect(test.permission).not.toHaveBeenCalled();
expect(test.importGpxAsTour).not.toHaveBeenCalled();
});
it('preserves addon and JWT refusal without reaching trip resolution', async () => {
const test = fixture('create', true, true);
const addon = new AddonGuard(
{ isAddonEnabled: vi.fn(async () => false) } as unknown as AddonsService,
new Reflector(),
);
await expectStatus(() => addon.canActivate(test.context), 404);
const jwt = new JwtAuthGuard({ getRepository: vi.fn() } as unknown as EntityManager);
vi.mocked(extractToken).mockReturnValue(null);
await expectStatus(() => jwt.canActivate(test.context), 401);
vi.mocked(extractToken).mockReturnValue('token');
vi.mocked(verifyJwtAndLoadUser).mockResolvedValue(null);
await expectStatus(() => jwt.canActivate(test.context), 401);
expect(test.access).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,288 @@
/**
* ToursService write contracts against stubbed repositories and a stub
* UnitOfWork (the budget.service.test.ts pattern): which repository call runs
* with which arguments, and that every write runs inside the transaction while
* the read-back and the broadcasts run after it. Rollback against a real
* SQLite database is covered by tours.service.test.ts.
*/
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
import type { TourWaypointsRepository } from '../../../src/db/repositories/TourWaypoints.repository';
import type { ToursRepository } from '../../../src/db/repositories/Tours.repository';
import type { UnitOfWork } from '../../../src/nest/database/unit-of-work';
import type { PlacesService } from '../../../src/nest/places/places.service';
import { ToursService } from '../../../src/nest/tours/tours.service';
import { NotFoundException } from '@nestjs/common';
import { tourCreateRequestSchema, type TourCreateRequest } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { legacyDatabaseAccess } = vi.hoisted(() => ({
legacyDatabaseAccess: vi.fn((property: string | symbol): never => {
throw new Error(`Unexpected legacy database access: ${String(property)}`);
}),
}));
vi.mock('../../../src/config', () => ({
ENCRYPTION_KEY: 'test-only-inert-key',
JWT_SECRET: 'test-only-inert-secret',
updateJwtSecret: vi.fn(),
}));
vi.mock('../../../src/db/database', () => ({
db: new Proxy(
{},
{
get: (_target, property: string | symbol) => legacyDatabaseAccess(property),
},
),
}));
vi.mock('../../../src/nest/places/places.service', () => ({ PlacesService: class {} }));
const request: TourCreateRequest = {
name: 'Ridge walk',
tour_type: 'hike',
route_geometry: [
[48, 11, 600],
[48.01, 11.02, 650],
[48.02, 11.04, 630],
],
waypoints: [
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
{ lat: 48.02, lng: 11.04, role: 'end', sequence: 1 },
],
max_hiking_difficulty: 2,
duration_seconds: 3600,
};
function makeService() {
const row = {
place_id: 42,
name: request.name,
tour_type: 'hike',
distance: 3,
elevation_gain: 50,
elevation_loss: 20,
duration: 60,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
tour_group_id: null,
max_hiking_difficulty: 2,
planned: 0,
has_waypoints: 1,
};
const place = { id: 42, trip_id: 7 };
let inTransaction = false;
/** Records whether each write ran inside the transaction. */
const inTx = <T>(value: T) =>
vi.fn(async (..._args: unknown[]) => {
expect(inTransaction).toBe(true);
return value;
});
const uow = {
transactional: vi.fn(async <T>(fn: () => Promise<T>): Promise<T> => {
inTransaction = true;
try {
return await fn();
} finally {
inTransaction = false;
}
}),
};
const toursRepo = {
findInTrip: vi.fn(async (): Promise<typeof row | undefined> => row),
insertTour: inTx(undefined),
updateInTrip: inTx(true),
};
const waypointsRepo = {
listForPlace: vi.fn(async () => request.waypoints),
insertForPlace: inTx(undefined),
deleteForPlace: inTx(undefined),
};
const placesRepo = {
insertTourPlace: inTx(42),
updateTourRoute: inTx(true),
findInTrip: vi.fn(async (): Promise<{ route_geometry: string | null } | undefined> => undefined),
findWithTagsAndRatings: vi.fn(async () => {
expect(inTransaction).toBe(false);
return place;
}),
};
const places = {
broadcast: vi.fn(() => {
expect(inTransaction).toBe(false);
}),
};
const service = new ToursService(
uow as unknown as UnitOfWork,
places as unknown as PlacesService,
toursRepo as unknown as ToursRepository,
waypointsRepo as unknown as TourWaypointsRepository,
placesRepo as unknown as PlacesRepository,
);
return { uow, toursRepo, waypointsRepo, placesRepo, places, place, service };
}
describe('ToursService planner contracts (mock-only; SQLite rollback semantics are covered in tours.service.test.ts)', () => {
let setup: ReturnType<typeof makeService>;
beforeEach(() => {
setup = makeService();
});
it('does not access the legacy global database initializer', () => {
expect(legacyDatabaseAccess).not.toHaveBeenCalled();
});
it('TOURS-SVC-001: sends geometry, metrics, and ordered controls through one transaction', async () => {
const result = await setup.service.createTour('7', request, 'socket-1');
expect(setup.uow.transactional).toHaveBeenCalledOnce();
expect(setup.placesRepo.insertTourPlace).toHaveBeenCalledExactlyOnceWith({
trip_id: 7,
name: request.name,
lat: 48,
lng: 11,
route_geometry: JSON.stringify(request.route_geometry),
});
expect(setup.toursRepo.insertTour).toHaveBeenCalledExactlyOnceWith({
place_id: 42,
tour_type: 'hike',
distance: expect.any(Number),
elevation_gain: 50,
elevation_loss: 20,
duration: 60,
match_confidence: 1,
max_hiking_difficulty: 2,
});
expect((setup.toursRepo.insertTour.mock.calls[0][0] as { distance: number }).distance).toBeGreaterThan(0);
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, request.waypoints);
expect(setup.toursRepo.findInTrip).toHaveBeenCalledWith(7, 42);
expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false, has_waypoints: true });
expect(result.waypoints).toEqual(request.waypoints);
expect(setup.places.broadcast).toHaveBeenNthCalledWith(1, '7', 'tours:changed', { placeIds: [42] }, 'socket-1');
expect(setup.places.broadcast).toHaveBeenNthCalledWith(2, '7', 'place:created', { place: setup.place }, 'socket-1');
});
it('TOURS-SVC-002: propagates a waypoint failure without publishing or reading an uncommitted create', async () => {
const failure = new Error('Injected waypoint failure');
setup.waypointsRepo.insertForPlace.mockRejectedValueOnce(failure);
await expect(setup.service.createTour('7', request)).rejects.toBe(failure);
expect(setup.uow.transactional).toHaveBeenCalledOnce();
expect(setup.toursRepo.findInTrip).not.toHaveBeenCalled();
expect(setup.placesRepo.findWithTagsAndRatings).not.toHaveBeenCalled();
expect(setup.places.broadcast).not.toHaveBeenCalled();
});
it('TOURS-SVC-003: validates and rounds a fractional routed duration before the create write', async () => {
const validated = tourCreateRequestSchema.parse({ ...request, duration_seconds: 3599.5 });
const result = await setup.service.createTour('7', validated);
expect(setup.toursRepo.insertTour).toHaveBeenCalledWith(expect.objectContaining({ place_id: 42, duration: 60 }));
expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false });
});
it('TOURS-SVC-004: scopes detail reads to the trip and derives legacy GPX controls without writes', async () => {
expect((await setup.service.getTour('7', '42')).waypoints).toEqual(request.waypoints);
expect(setup.toursRepo.findInTrip).toHaveBeenCalledWith(7, 42);
expect(setup.waypointsRepo.listForPlace).toHaveBeenCalledWith(42);
setup.toursRepo.findInTrip.mockResolvedValueOnce(undefined);
setup.waypointsRepo.listForPlace.mockClear();
await expect(setup.service.getTour('8', '42')).rejects.toThrow('Tour not found');
expect(setup.waypointsRepo.listForPlace).not.toHaveBeenCalled();
setup.waypointsRepo.listForPlace.mockResolvedValueOnce([]);
setup.placesRepo.findInTrip.mockResolvedValueOnce({ route_geometry: JSON.stringify(request.route_geometry) });
const legacyGpx = await setup.service.getTour('7', '42');
expect(setup.placesRepo.findInTrip).toHaveBeenCalledWith(42, 7);
expect(legacyGpx.waypoints).toEqual([
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
{ lat: 48.02, lng: 11.04, role: 'end', sequence: 1 },
]);
for (const method of [
setup.uow.transactional,
setup.placesRepo.insertTourPlace,
setup.placesRepo.updateTourRoute,
setup.toursRepo.insertTour,
setup.toursRepo.updateInTrip,
setup.waypointsRepo.insertForPlace,
setup.waypointsRepo.deleteForPlace,
setup.places.broadcast,
]) {
expect(method).not.toHaveBeenCalled();
}
});
it('TOURS-SVC-005: updates route data and replaces controls through one transaction', async () => {
const update: TourCreateRequest = {
...request,
name: 'Updated ridge walk',
route_geometry: [
[49, 12, 700],
[49.02, 12.04, 760],
],
waypoints: [
{ lat: 49, lng: 12, role: 'start', sequence: 0 },
{ lat: 49.01, lng: 12.02, role: 'via', sequence: 1 },
{ lat: 49.02, lng: 12.04, role: 'end', sequence: 2 },
],
duration_seconds: 2700.5,
};
await setup.service.updateTour('7', '42', update, 'socket-2');
expect(setup.uow.transactional).toHaveBeenCalledOnce();
expect(setup.placesRepo.updateTourRoute).toHaveBeenCalledExactlyOnceWith(42, 7, {
name: update.name,
lat: 49,
lng: 12,
route_geometry: JSON.stringify(update.route_geometry),
});
expect(setup.toursRepo.updateInTrip).toHaveBeenCalledExactlyOnceWith(7, 42, {
tour_type: 'hike',
distance: expect.any(Number),
elevation_gain: 60,
elevation_loss: 0,
duration: 45,
match_confidence: 1,
max_hiking_difficulty: 2,
});
expect(setup.waypointsRepo.deleteForPlace).toHaveBeenCalledExactlyOnceWith(42);
expect(setup.waypointsRepo.insertForPlace).toHaveBeenCalledExactlyOnceWith(42, update.waypoints);
expect(setup.waypointsRepo.deleteForPlace.mock.invocationCallOrder[0]).toBeLessThan(
setup.waypointsRepo.insertForPlace.mock.invocationCallOrder[0],
);
expect(setup.places.broadcast).toHaveBeenCalledWith('7', 'place:updated', { place: setup.place }, 'socket-2');
});
it('TOURS-SVC-006: propagates a replacement failure without publishing or reloading', async () => {
const failure = new Error('Injected replacement failure');
setup.waypointsRepo.insertForPlace.mockRejectedValueOnce(failure);
await expect(setup.service.updateTour('7', '42', request)).rejects.toBe(failure);
expect(setup.uow.transactional).toHaveBeenCalledOnce();
expect(setup.toursRepo.findInTrip).toHaveBeenCalledTimes(1);
expect(setup.placesRepo.findWithTagsAndRatings).not.toHaveBeenCalled();
expect(setup.places.broadcast).not.toHaveBeenCalled();
});
it.each([
['the tour lookup', 'findInTrip'],
['the trip-scoped place update', 'updateTourRoute'],
['the trip-scoped tour update', 'updateInTrip'],
] as const)('TOURS-SVC-010: a miss in %s is a 404 that stops the write', async (_label, step) => {
if (step === 'findInTrip') setup.toursRepo.findInTrip.mockResolvedValueOnce(undefined);
if (step === 'updateTourRoute') setup.placesRepo.updateTourRoute.mockResolvedValueOnce(false);
if (step === 'updateInTrip') setup.toursRepo.updateInTrip.mockResolvedValueOnce(false);
await expect(setup.service.updateTour('7', '42', request)).rejects.toBeInstanceOf(NotFoundException);
// The existence check runs inside the write's own transaction (F44).
expect(setup.uow.transactional).toHaveBeenCalledOnce();
expect(setup.waypointsRepo.deleteForPlace).not.toHaveBeenCalled();
expect(setup.waypointsRepo.insertForPlace).not.toHaveBeenCalled();
expect(setup.places.broadcast).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,335 @@
/**
* ToursService over the real migrated per-worker DB: the tours facet, its
* ordered control points and the owning place are written through the Tours,
* TourWaypoints and Places repositories inside one UnitOfWork transaction, and
* realtime events go out only after that transaction committed.
*
* PlacesService is a broadcast stub here; the GPX import path, which needs the
* real PlacesService, is covered by tours.gpx.atomic.test.ts.
*/
import { db as testDb } from '../../../src/db/database';
import type { PlacesService } from '../../../src/nest/places/places.service';
import { ToursService } from '../../../src/nest/tours/tours.service';
import { createPlace, createTrip, createUser } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { createTestPlacesRepo, createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
import { createTestToursRepo, createTestTourWaypointsRepo, createTour } from '../../helpers/tours-repos';
import { Logger, NotFoundException } from '@nestjs/common';
import { tourCreateRequestSchema, type TourCreateRequest } from '@trek/shared';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const request: TourCreateRequest = {
name: 'Ridge walk',
tour_type: 'hike',
route_geometry: [
[48, 11, 600],
[48.01, 11.02, 650],
[48.02, 11.04, 630],
],
waypoints: [
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
{ lat: 48.02, lng: 11.04, role: 'end', sequence: 1 },
],
max_hiking_difficulty: 2,
duration_seconds: 3600,
};
/** Two waypoints on the same sequence: the UNIQUE(place_id, sequence) index rejects the second one. */
const duplicateSequence = {
...request,
waypoints: [request.waypoints[0], { ...request.waypoints[1], sequence: 0 }],
} as TourCreateRequest;
const broadcast = vi.fn();
let service: ToursService;
let tripId: string;
let otherTripId: string;
const count = (table: string) => (testDb.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get() as { n: number }).n;
beforeAll(async () => {
service = new ToursService(
await createTestUnitOfWork(testDb),
{ broadcast } as unknown as PlacesService,
await createTestToursRepo(testDb),
await createTestTourWaypointsRepo(testDb),
await createTestPlacesRepo(testDb),
);
});
beforeEach(async () => {
resetTestDb(testDb);
(await sharedTestOrm(testDb)).clear();
broadcast.mockReset();
const { user } = createUser(testDb);
tripId = String(createTrip(testDb, user.id).id);
otherTripId = String(createTrip(testDb, user.id).id);
});
afterEach(() => {
vi.restoreAllMocks();
});
afterAll(() => {
testDb.close();
});
describe('ToursService planner creation', () => {
it('TOURS-SVC-001: atomically stores full geometry, derived metrics, and ordered control points', async () => {
const writeStates: boolean[] = [];
broadcast.mockImplementation(() => {
writeStates.push(testDb.inTransaction);
});
const result = await service.createTour(tripId, request, 'socket-1');
const placeId = result.tour.place_id;
const place = testDb.prepare('SELECT * FROM places WHERE id = ?').get(placeId) as Record<string, unknown>;
const tour = testDb.prepare('SELECT * FROM tours WHERE place_id = ?').get(placeId) as Record<string, unknown>;
const points = testDb
.prepare('SELECT role, sequence FROM tour_waypoints WHERE place_id = ? ORDER BY sequence')
.all(placeId);
expect(place).toMatchObject({
trip_id: Number(tripId),
name: 'Ridge walk',
lat: 48,
lng: 11,
transport_mode: 'walking',
});
expect(JSON.parse(String(place.route_geometry))).toEqual(request.route_geometry);
expect(tour).toMatchObject({ tour_type: 'hike', match_confidence: 1, max_hiking_difficulty: 2 });
expect(tour.distance).toBeGreaterThan(0);
expect(tour.elevation_gain).toBe(50);
expect(tour.elevation_loss).toBe(20);
expect(tour.duration).toBe(60);
expect(points).toEqual([
{ role: 'start', sequence: 0 },
{ role: 'end', sequence: 1 },
]);
expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false, has_waypoints: true, caution: false });
expect(result.waypoints).toEqual(request.waypoints);
expect(broadcast).toHaveBeenNthCalledWith(1, tripId, 'tours:changed', { placeIds: [placeId] }, 'socket-1');
expect(broadcast).toHaveBeenNthCalledWith(
2,
tripId,
'place:created',
{ place: expect.objectContaining({ id: placeId }) },
'socket-1',
);
expect(broadcast).toHaveBeenCalledTimes(2);
// Both events go out after the commit, never from inside the write.
expect(writeStates).toEqual([false, false]);
});
it('TOURS-SVC-002: rolls the owning place and facet back when a waypoint insert fails', async () => {
await expect(service.createTour(tripId, duplicateSequence)).rejects.toThrow();
expect(count('places')).toBe(0);
expect(count('tours')).toBe(0);
expect(count('tour_waypoints')).toBe(0);
expect(broadcast).not.toHaveBeenCalled();
});
it('TOURS-SVC-003: validates a fractional routed duration before the atomic create', async () => {
const validated = tourCreateRequestSchema.parse({ ...request, duration_seconds: 3599.5 });
const result = await service.createTour(tripId, validated);
expect(count('places')).toBe(1);
expect(count('tours')).toBe(1);
expect(count('tour_waypoints')).toBe(2);
expect(testDb.prepare('SELECT duration FROM tours').get()).toEqual({ duration: 60 });
expect(result.tour).toMatchObject({ name: 'Ridge walk', planned: false });
});
it('TOURS-SVC-003b: stores no duration when the client sends none', async () => {
const { duration_seconds: _omitted, ...withoutDuration } = request;
const result = await service.createTour(tripId, withoutDuration);
expect(result.tour.duration).toBeNull();
});
it('keeps a committed create successful when Tours invalidation publication fails', async () => {
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
broadcast.mockImplementation((_tripId: string, event: string) => {
if (event === 'tours:changed') throw new Error('transport unavailable');
});
const result = await service.createTour(tripId, request);
expect(result.tour.name).toBe('Ridge walk');
expect(count('places')).toBe(1);
expect(count('tours')).toBe(1);
expect(broadcast).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledOnce();
});
it('keeps a committed create successful when the place notification fails', async () => {
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
broadcast.mockImplementation((_tripId: string, event: string) => {
if (event === 'place:created') throw new Error('transport unavailable');
});
await expect(service.createTour(tripId, request)).resolves.toMatchObject({ tour: { name: 'Ridge walk' } });
expect(warn).toHaveBeenCalledOnce();
});
});
describe('ToursService reads', () => {
it('TOURS-SVC-004: loads only a trip-owned tour with its ordered control points', async () => {
const created = await service.createTour(tripId, request);
const placeId = String(created.tour.place_id);
expect(await service.getTour(tripId, placeId)).toEqual(created);
await expect(service.getTour(otherTripId, placeId)).rejects.toThrow('Tour not found');
testDb.prepare('DELETE FROM tour_waypoints WHERE place_id = ?').run(created.tour.place_id);
const legacyGpx = await service.getTour(tripId, placeId);
expect(legacyGpx.tour.has_waypoints).toBe(false);
expect(legacyGpx.waypoints).toEqual([
{ lat: 48, lng: 11, role: 'start', sequence: 0 },
{ lat: 48.02, lng: 11.04, role: 'end', sequence: 1 },
]);
// The fallback is read-only: nothing was backfilled.
expect(count('tour_waypoints')).toBe(0);
});
it('TOURS-SVC-004b: a plain place, a malformed id and a geometry-less tour', async () => {
const plain = createPlace(testDb, Number(tripId), { name: 'Not a tour' });
await expect(service.getTour(tripId, String(plain.id))).rejects.toBeInstanceOf(NotFoundException);
await expect(service.getTour(tripId, 'abc')).rejects.toThrow('Tour not found');
await expect(service.getTour('abc', String(plain.id))).rejects.toThrow('Tour not found');
const bare = createPlace(testDb, Number(tripId), { name: 'Bare import' });
createTour(testDb, bare.id);
const detail = await service.getTour(tripId, String(bare.id));
expect(detail.tour).toMatchObject({ place_id: bare.id, has_waypoints: false });
expect(detail.waypoints).toEqual([]);
});
it("TOURS-SVC-007: lists only this trip's tours, newest first, with the caution flag", async () => {
const older = createPlace(testDb, Number(tripId), { name: 'Older' });
const newer = createPlace(testDb, Number(tripId), { name: 'Newer' });
const foreign = createPlace(testDb, Number(otherTripId), { name: 'Foreign' });
createPlace(testDb, Number(tripId), { name: 'Plain place' });
createTour(testDb, older.id, { created_at: '2026-01-01 10:00:00', match_confidence: 0.3 });
createTour(testDb, newer.id, { created_at: '2026-02-01 10:00:00', match_confidence: 1 });
createTour(testDb, foreign.id);
const list = await service.listTours(tripId);
expect(list.map((t) => t.name)).toEqual(['Newer', 'Older']);
expect(list[0]).toMatchObject({ caution: false, planned: false, has_waypoints: false, tour_type: 'hike' });
expect(list[1]).toMatchObject({ caution: true, match_confidence: 0.3 });
expect(await service.listTours('not-a-number')).toEqual([]);
});
});
describe('ToursService updates', () => {
const update: TourCreateRequest = {
...request,
name: 'Updated ridge walk',
route_geometry: [
[49, 12, 700],
[49.02, 12.04, 760],
],
waypoints: [
{ lat: 49, lng: 12, role: 'start', sequence: 0 },
{ lat: 49.01, lng: 12.02, role: 'via', sequence: 1 },
{ lat: 49.02, lng: 12.04, role: 'end', sequence: 2 },
],
duration_seconds: 2700.5,
};
it('TOURS-SVC-005: atomically updates route data and replaces persisted control points', async () => {
const created = await service.createTour(tripId, request);
broadcast.mockReset();
const writeStates: boolean[] = [];
broadcast.mockImplementation(() => {
writeStates.push(testDb.inTransaction);
});
const result = await service.updateTour(tripId, String(created.tour.place_id), update, 'socket-2');
expect(result.tour).toMatchObject({ place_id: created.tour.place_id, name: 'Updated ridge walk' });
expect(result.waypoints).toEqual(update.waypoints);
expect(testDb.prepare('SELECT name, lat, lng, transport_mode FROM places').get()).toEqual({
name: 'Updated ridge walk',
lat: 49,
lng: 12,
transport_mode: 'walking',
});
expect(testDb.prepare('SELECT duration, elevation_gain, elevation_loss FROM tours').get()).toEqual({
duration: 45,
elevation_gain: 60,
elevation_loss: 0,
});
expect(broadcast).toHaveBeenNthCalledWith(
1,
tripId,
'tours:changed',
{ placeIds: [created.tour.place_id] },
'socket-2',
);
expect(broadcast).toHaveBeenNthCalledWith(
2,
tripId,
'place:updated',
expect.objectContaining({ place: expect.objectContaining({ id: created.tour.place_id }) }),
'socket-2',
);
expect(writeStates).toEqual([false, false]);
});
it('TOURS-SVC-006: rolls an invalid waypoint replacement back to the previous saved tour', async () => {
const created = await service.createTour(tripId, request);
broadcast.mockReset();
await expect(
service.updateTour(tripId, String(created.tour.place_id), { ...duplicateSequence, name: 'Must roll back' }),
).rejects.toThrow();
expect(testDb.prepare('SELECT name FROM places').get()).toEqual({ name: 'Ridge walk' });
expect((await service.getTour(tripId, String(created.tour.place_id))).waypoints).toEqual(request.waypoints);
expect(broadcast).not.toHaveBeenCalled();
});
it('TOURS-SVC-008: a tour of another trip is a 404 and stays untouched', async () => {
const created = await service.createTour(otherTripId, request);
broadcast.mockReset();
await expect(service.updateTour(tripId, String(created.tour.place_id), update, 'socket')).rejects.toThrow(
'Tour not found',
);
expect(testDb.prepare('SELECT trip_id, name FROM places WHERE id = ?').get(created.tour.place_id)).toEqual({
trip_id: Number(otherTripId),
name: 'Ridge walk',
});
expect((await service.getTour(otherTripId, String(created.tour.place_id))).waypoints).toEqual(request.waypoints);
expect(broadcast).not.toHaveBeenCalled();
});
it('TOURS-SVC-009: a plain place or a malformed id is a 404 without a write', async () => {
const plain = createPlace(testDb, Number(tripId), { name: 'Plain' });
await expect(service.updateTour(tripId, String(plain.id), update)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.updateTour(tripId, 'abc', update)).rejects.toThrow('Tour not found');
await expect(service.updateTour('abc', String(plain.id), update)).rejects.toThrow('Tour not found');
expect(testDb.prepare('SELECT name FROM places WHERE id = ?').get(plain.id)).toEqual({ name: 'Plain' });
expect(count('tours')).toBe(0);
expect(broadcast).not.toHaveBeenCalled();
});
});
@@ -127,6 +127,7 @@ import {
} from '../../helpers/test-uow';
import { createTestCollectionPlacesRepo } from '../../helpers/test-uow';
import { createTestTodoItemsRepo, createTestTodoCategoryAssigneesRepo } from '../../helpers/todo-repos';
import { createTestTourWaypointsRepo, createTour } from '../../helpers/tours-repos';
import {
createTestVacayPlansRepo,
createTestVacayPlanMembersRepo,
@@ -2609,6 +2610,97 @@ describe('copy — whole-trip parity (Task 8)', () => {
});
});
describe('copy: Tours (#2586)', () => {
/** A Tour on `tripId`: the place, its `tours` facet and three route waypoints. */
function seedTour(tripId: number, name: string) {
const place = createPlace(testDb, tripId, { name });
testDb
.prepare("UPDATE places SET transport_mode = 'walking', route_geometry = '[[47,11],[47.2,11.2]]' WHERE id = ?")
.run(place.id);
createTour(testDb, place.id, {
distance: 12.5,
match_confidence: 0.9,
max_hiking_difficulty: 4,
created_at: '2025-01-02 03:04:05',
});
testDb
.prepare(
"UPDATE tours SET elevation_gain = 800, elevation_loss = 790, duration = 5.5, difficulty = 'T3', wanderer_ref = 'w-1', tour_group_id = 7 WHERE place_id = ?",
)
.run(place.id);
const insert = testDb.prepare(
'INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) VALUES (?, ?, ?, ?, ?)',
);
insert.run(place.id, 47, 11, 'start', 0);
insert.run(place.id, 47.1, 11.1, 'via', 1);
insert.run(place.id, 47.2, 11.2, 'end', 2);
return place;
}
const tourColumns = (placeId: number) => {
const { place_id: _placeId, ...rest } = testDb
.prepare('SELECT * FROM tours WHERE place_id = ?')
.get(placeId) as Record<string, unknown>;
return rest;
};
const waypoints = (placeId: number) =>
testDb
.prepare('SELECT lat, lng, role, sequence FROM tour_waypoints WHERE place_id = ? ORDER BY sequence')
.all(placeId);
it('TRIP-SVC-081: a copied Tour keeps its facet row and waypoints, remapped onto the copied place', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Alps' });
const tour = seedTour(trip.id, 'Ridge walk');
createPlace(testDb, trip.id, { name: 'Plain stop' });
const newTripId = await svc.copy(trip.id, user.id, 'Alps again');
const newTour = testDb
.prepare("SELECT id FROM places WHERE trip_id = ? AND name = 'Ridge walk'")
.get(newTripId) as { id: number };
const newPlain = testDb
.prepare("SELECT id FROM places WHERE trip_id = ? AND name = 'Plain stop'")
.get(newTripId) as { id: number };
expect(newTour.id).not.toBe(tour.id);
expect(tourColumns(newTour.id)).toEqual(tourColumns(tour.id));
expect(tourColumns(newTour.id)).toMatchObject({
tour_type: 'hike',
created_at: '2025-01-02 03:04:05',
max_hiking_difficulty: 4,
tour_group_id: 7,
});
expect(waypoints(newTour.id)).toEqual(waypoints(tour.id));
expect(waypoints(newTour.id)).toHaveLength(3);
// The plain place stays plain, and the source keeps its own rows.
expect(testDb.prepare('SELECT 1 FROM tours WHERE place_id = ?').get(newPlain.id)).toBeUndefined();
expect(
testDb
.prepare('SELECT COUNT(*) AS n FROM tours t JOIN places p ON p.id = t.place_id WHERE p.trip_id = ?')
.get(trip.id),
).toEqual({ n: 1 });
expect(waypoints(tour.id)).toHaveLength(3);
});
it('TRIP-SVC-082: a failing waypoint insert rolls the copied Tour back with the rest of the copy', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Doomed alps' });
seedTour(trip.id, 'Doomed ridge');
const toursBefore = (testDb.prepare('SELECT COUNT(*) AS n FROM tours').get() as { n: number }).n;
const waypointsRepo = await createTestTourWaypointsRepo(testDb);
const spy = vi.spyOn(waypointsRepo, 'insertForPlace').mockRejectedValueOnce(new Error('boom'));
try {
await expect(svc.copy(trip.id, user.id, 'Never lands')).rejects.toThrow('boom');
} finally {
spy.mockRestore();
}
expect((testDb.prepare('SELECT COUNT(*) AS n FROM tours').get() as { n: number }).n).toBe(toursBefore);
expect(testDb.prepare("SELECT id FROM trips WHERE title = 'Never lands'").get()).toBeUndefined();
});
});
// ── Post-fold quirk fixes (transactions, owner display name) ─────────────────
describe('quirk fixes', () => {
+40 -1
View File
@@ -4,13 +4,52 @@ import { FeaturesController } from '../../../src/nest/health/features.controller
import { HttpException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
vi.mock('../../../src/config', async () => {
const { readEnv } = await import('../../../src/app-config');
const env = readEnv();
return {
ENCRYPTION_KEY: 'wiring-test-inert-encryption-key',
JWT_SECRET: 'wiring-test-inert-jwt-secret',
updateJwtSecret: vi.fn(),
DEFAULT_LANGUAGE: env.app.defaultLanguage,
SESSION_DURATION: env.session.duration,
SESSION_DURATION_MS: env.session.durationMs,
SESSION_DURATION_SECONDS: env.session.durationSeconds,
SESSION_DURATION_REMEMBER: env.session.durationRemember,
SESSION_DURATION_REMEMBER_MS: env.session.durationRememberMs,
SESSION_DURATION_REMEMBER_SECONDS: env.session.durationRememberSeconds,
};
});
function ctx(user: unknown) {
return { switchToHttp: () => ({ getRequest: () => ({ user }) }) } as never;
}
describe('AppModule wiring', () => {
it('registers the upstream feature modules and Tours exactly once', () => {
// Only plain class imports: forRoot()/forFeature() entries are dynamic
// (MikroOrmModule legitimately appears twice) and some resolve async.
const imports = Reflect.getMetadata('imports', AppModule) as unknown[];
const names = imports
.filter((entry): entry is { name: string } => typeof entry === 'function')
.map((entry) => entry.name);
expect(new Set(names).size).toBe(names.length);
expect(names).toEqual(
expect.arrayContaining([
'GoogleQuotaModule',
'ReceiptScanModule',
'SchoolHolidaysModule',
'DocSyncModule',
'DawarichModule',
'NotificationsModule',
'ToursModule',
]),
);
expect(names.filter((name) => name === 'ToursModule')).toHaveLength(1);
});
it('compiles with the global filter + DB provider and resolves the controller', async () => {
// Plan 4 Task 4: `DatabaseService` is gone — nothing left in the graph
// needs overriding for this to compile.