mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
49ba73fcae
Eleven more suites (atlas and settings e2e, the bucket list, shadow pick
and Dawarich suggestion repositories, the budget and collab MCP tools,
notification preferences, VAPID keys, school holidays and tours) write
and read their rows through the ORM factories. Time-relative fixtures
that leaned on SQLite's date('now', ...) build the same text in JS with
dbNow(), and helpers that returned undefined for a missing row keep doing
so where an assertion depends on it. The legacy statements the parity
cases compare against stay raw, marked as oracles.
327 lines
16 KiB
TypeScript
327 lines
16 KiB
TypeScript
/**
|
|
* 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 { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
|
|
|
|
vi.mock('../../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
vi.mock('../../../src/config', () => ({
|
|
JWT_SECRET: 'test-secret',
|
|
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
|
|
import { db as testDb } from '../../../src/db/database';
|
|
import { tourCreateRequestSchema, type TourCreateRequest } from '@trek/shared';
|
|
import { ToursService } from '../../../src/nest/tours/tours.service';
|
|
import type { PlacesService } from '../../../src/nest/places/places.service';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createPlace, createTrip, createUser } from '../../helpers/factories';
|
|
import { createTestPlacesRepo, createTestUnitOfWork, sharedTestOrm } from '../../helpers/test-uow';
|
|
import type { EntityClass } from '@mikro-orm/core';
|
|
import { countRows, deleteRows, findRow, findRows } from '../../helpers/factories/rows';
|
|
import { Places } from '../../../src/db/entities/Places.entity';
|
|
import { Tours } from '../../../src/db/entities/Tours.entity';
|
|
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
|
|
import { createTestToursRepo, createTestTourTypesRepo, createTestTourWaypointsRepo, createTour } from '../../helpers/tours-repos';
|
|
|
|
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 = async <T extends object>(entity: EntityClass<T>) => countRows(await sharedTestOrm(testDb), entity);
|
|
|
|
/** The first stored place and tour, the only ones most cases leave behind. */
|
|
const firstPlace = async () => (await findRows(await sharedTestOrm(testDb), Places, {}, { id: 'asc' }))[0];
|
|
const firstTour = async () => (await findRows(await sharedTestOrm(testDb), Tours, {}, { place: 'asc' }))[0];
|
|
const placeById = async (id: number) => findRow(await sharedTestOrm(testDb), Places, { id });
|
|
|
|
beforeAll(async () => {
|
|
service = new ToursService(
|
|
await createTestUnitOfWork(testDb),
|
|
{ broadcast } as unknown as PlacesService,
|
|
await createTestToursRepo(testDb),
|
|
await createTestTourTypesRepo(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 orm = await sharedTestOrm(testDb);
|
|
const place = await findRow(orm, Places, { id: placeId });
|
|
const tour = await findRow(orm, Tours, { place: placeId });
|
|
const points = (await findRows(orm, TourWaypoints, { place: placeId }, { sequence: 'asc' })).map((w) => ({
|
|
role: w.role,
|
|
sequence: w.sequence,
|
|
}));
|
|
if (!place || !tour) throw new Error('createTour should have stored the place and its tour');
|
|
|
|
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(await count(Places)).toBe(0);
|
|
expect(await count(Tours)).toBe(0);
|
|
expect(await count(TourWaypoints)).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(await count(Places)).toBe(1);
|
|
expect(await count(Tours)).toBe(1);
|
|
expect(await count(TourWaypoints)).toBe(2);
|
|
expect((await firstTour()).duration).toBe(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('TOURS-SVC-010: a type the catalogue holds but has not enabled is a 400 without a write', async () => {
|
|
// The contract only lets `hike` through today; this pins what happens the
|
|
// day it lets more keys in before the planner offers them.
|
|
const bike = { ...request, tour_type: 'bike' } as unknown as typeof request;
|
|
|
|
await expect(service.createTour(tripId, bike)).rejects.toBeInstanceOf(BadRequestException);
|
|
|
|
expect(await count(Places)).toBe(0);
|
|
expect(await count(Tours)).toBe(0);
|
|
expect(broadcast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('TOURS-SVC-011: an edit to a disabled type is a 400 and leaves the saved tour alone', async () => {
|
|
const created = await service.createTour(tripId, request);
|
|
broadcast.mockReset();
|
|
const bike = { ...request, name: 'Renamed', tour_type: 'bike' } as unknown as typeof request;
|
|
|
|
await expect(service.updateTour(tripId, String(created.tour.place_id), bike)).rejects.toBeInstanceOf(BadRequestException);
|
|
|
|
const storedTour = await firstTour();
|
|
expect({ tour_type: storedTour.tour_type, name: (await placeById(Number(storedTour.place_id)))?.name })
|
|
.toEqual({ tour_type: 'hike', name: 'Ridge walk' });
|
|
expect(broadcast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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(await count(Places)).toBe(1);
|
|
expect(await 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');
|
|
|
|
await deleteRows(await sharedTestOrm(testDb), TourWaypoints, { place: 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(await count(TourWaypoints)).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);
|
|
const storedPlace = await firstPlace();
|
|
expect({ name: storedPlace.name, lat: storedPlace.lat, lng: storedPlace.lng, transport_mode: storedPlace.transport_mode }).toEqual({
|
|
name: 'Updated ridge walk', lat: 49, lng: 12, transport_mode: 'walking',
|
|
});
|
|
const storedTour = await firstTour();
|
|
expect({ duration: storedTour.duration, elevation_gain: storedTour.elevation_gain, elevation_loss: storedTour.elevation_loss }).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((await firstPlace()).name).toBe('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');
|
|
|
|
const untouched = await placeById(Number(created.tour.place_id));
|
|
expect({ trip_id: untouched?.trip_id, name: untouched?.name })
|
|
.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((await placeById(plain.id))?.name).toBe('Plain');
|
|
expect(await count(Tours)).toBe(0);
|
|
expect(broadcast).not.toHaveBeenCalled();
|
|
});
|
|
});
|