mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
f7edd849ab
tour_types now holds every key the shared contract accepts, all but hike disabled, and create and edit turn a disabled type away with a 400 instead of failing on the foreign key. tours.tour_group_id is dropped: nothing ever wrote or read it. difficulty and wanderer_ref stay for the Wanderer import.
298 lines
16 KiB
TypeScript
298 lines
16 KiB
TypeScript
/**
|
|
* 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 { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
|
|
import { Logger } 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 type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
|
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
|
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
|
import type { UnitOfWork } from '../../../src/nest/database/unit-of-work';
|
|
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
|
|
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
|
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
|
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
|
import { PlacesService } from '../../../src/nest/places/places.service';
|
|
import { MapsService } from '../../../src/nest/maps/maps.service';
|
|
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
|
|
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
|
|
import { TrekPhotoRegistrationService } from '../../../src/nest/photos/trek-photo-registration.service';
|
|
import { ToursService } from '../../../src/nest/tours/tours.service';
|
|
import { gpxParser } from '../../../src/nest/places/places.helpers';
|
|
import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity';
|
|
import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createTrip, createUser } from '../../helpers/factories';
|
|
import { accommodationsOver } from '../../helpers/accommodations-service';
|
|
import { makeStorageFixture } from '../../helpers/storage-fixture';
|
|
import { noGoogleQuota } from '../../helpers/google-quota';
|
|
import {
|
|
createTestUnitOfWork, createTestAppSettingsRepo, createTestUsersRepo, createTestTagsRepo, createTestPlaceRatingsRepo,
|
|
createTestAssignmentParticipantsRepo, createTestPlacesRepo, createTestTripMembersRepo, createTestDayAssignmentsRepo,
|
|
createTestCategoriesRepo, createTestTripsRepo, createTestCollectionPlacesRepo, sharedTestOrm,
|
|
} from '../../helpers/test-uow';
|
|
import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
|
|
import {
|
|
createTestJourneysRepo, createTestJourneyContributorsRepo, createTestJourneyTripsRepo, createTestJourneyEntriesRepo,
|
|
createTestJourneyPhotosRepo, createTestJourneyEntryPhotosRepo,
|
|
} from '../../helpers/journey-repos';
|
|
import { createTestToursRepo, createTestTourTypesRepo, createTestTourWaypointsRepo } from '../../helpers/tours-repos';
|
|
|
|
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 createTestTourTypesRepo(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']);
|
|
});
|
|
});
|