mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
2e1dca9fe9
Settings, Integrations, wanderer gains an optional background check of the trails you imported, off until you switch it on. While on, TREK reads the short record of each such trail at most once a day (never its GPX file) and leaves a hint when the trail may have moved on: "may have changed" when only wanderer's change time moved, "changed" when its name or description proves it, and "not checkable" when the trail is gone, no longer yours or carries no change time. Nothing is ever said to be unchanged. A new hint arrives as a notification that opens the tour (a new ?place= link in the planner); the tour's provenance shows it with the source check right beside it, and the card lists all open hints in a dialog, each leading to its tour. The check reads the source in full as before and spends the hint; taking a change in stays the confirmed reimport. No tour is ever changed or deleted. The check only asks through your own connection, only about trails your account imported into trips you can still open, and stops when you switch it off, remove the connection, wanderer refuses the token (until a test passes or a new token is saved) or the admin switches wanderer off; hints and snapshots stay. A run asks at most 25 connections for at most 10 records each, one after another, and an instance that fails (an outage, a rate limit) is left alone for an hour, doubling up to two days. Server: migration 046120 adds the switch and the run bookkeeping to wanderer_connections and the tour_source_hints table (one row per snapshot, for its importer, gone with it). WandererWatchService and an hourly wanderer-watch job on the CronRegistrar live in the tours domain and read through WandererService; GET and PUT /api/integrations/wanderer/watch and GET /api/trips/:tripId/tours/wanderer/sources/:placeId/hint (place_edit, like the check) answer with @trek/shared contracts. A new user notification event, wanderer_source_hint, can be muted per channel. Saving a new token now clears what the last check said about the old one. There is no MCP tool, as for the rest of the personal wanderer connection. All 27 locales carry the strings, and the admin wiki page says what the check does.
609 lines
26 KiB
TypeScript
609 lines
26 KiB
TypeScript
/**
|
|
* wanderer e2e: the personal connection (/api/integrations/wanderer), its
|
|
* optional background watch (/api/integrations/wanderer/watch, WAN-02) and the
|
|
* trip-side search, preview and import (/api/trips/:tripId/tours/wanderer)
|
|
* through the real JwtAuthGuard, AddonGuard, WandererEnabledGuard and
|
|
* TripAccessGuard, over the real services and repositories on a migrated temp
|
|
* SQLite db handed to them.
|
|
*
|
|
* Replaced: `WandererClient`, so no test ever opens a socket to an instance
|
|
* (each method is a spy answering from the fixtures below), `checkSsrf`, whose
|
|
* DNS lookup is not what this file is about, the permission check (a spy) and
|
|
* the journey hooks. Broadcasts go to a FakeRealtimeService.
|
|
*/
|
|
import { Places } from '../../src/db/entities/Places.entity';
|
|
import { TourSources } from '../../src/db/entities/TourSources.entity';
|
|
import { Trips } from '../../src/db/entities/Trips.entity';
|
|
import { WandererConnections } from '../../src/db/entities/WandererConnections.entity';
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
import { withRequestContext } from '../../src/nest/database/request-context';
|
|
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
|
|
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
|
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
|
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
|
|
import { ToursModule } from '../../src/nest/tours/tours.module';
|
|
import { WandererWatchService } from '../../src/nest/tours/wanderer-watch.service';
|
|
import { WandererClient, WandererError, type WandererSearchHit } from '../../src/nest/wanderer/wanderer.client';
|
|
import { WandererModule } from '../../src/nest/wanderer/wanderer.module';
|
|
import { createSnapshotTestDb } from '../helpers/db-mock';
|
|
import { countRows, deleteRows, findRow, insertRows } from '../helpers/factories/rows';
|
|
import { setAddonEnabled, setAppSetting } from '../helpers/factories/settings';
|
|
import { addTripMember } from '../helpers/factories/trips';
|
|
import { makeUser } from '../helpers/factories/users';
|
|
import { FakeRealtimeService } from '../helpers/fake-realtime';
|
|
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
|
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
|
import { sessionCookie } from './harness';
|
|
import { MikroORM } from '@mikro-orm/core';
|
|
import { Test } from '@nestjs/testing';
|
|
|
|
import cookieParser from 'cookie-parser';
|
|
import type { Server } from 'http';
|
|
import request from 'supertest';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest';
|
|
|
|
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
|
LOG_LEVEL: 'error',
|
|
logInfo: vi.fn(),
|
|
logDebug: vi.fn(),
|
|
logError: vi.fn(),
|
|
logWarn: vi.fn(),
|
|
}));
|
|
const { checkSsrf } = vi.hoisted(() => ({ checkSsrf: vi.fn() }));
|
|
vi.mock('../../src/utils/ssrfGuard', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../src/utils/ssrfGuard')>()),
|
|
checkSsrf,
|
|
}));
|
|
|
|
const { journeyHooks } = vi.hoisted(() => ({
|
|
journeyHooks: {
|
|
reconcileTripSkeletons: vi.fn().mockResolvedValue(undefined),
|
|
onPlaceCreated: vi.fn().mockResolvedValue(undefined),
|
|
onPlaceUpdated: vi.fn().mockResolvedValue(undefined),
|
|
onPlaceDeleted: vi.fn().mockResolvedValue(undefined),
|
|
},
|
|
}));
|
|
|
|
/** A migrated temp database handed to the code under test, rather than a mocked database module. */
|
|
const db = createSnapshotTestDb();
|
|
|
|
const OWNER = 1;
|
|
const VIEWER = 2;
|
|
const OUTSIDER = 3;
|
|
const TOKEN = 'wanderer_key_abcdef';
|
|
|
|
const HIT: WandererSearchHit = {
|
|
id: 'ukzj2hazx6dgzam',
|
|
name: 'Partnachklamm',
|
|
description: 'Through the gorge',
|
|
location: 'Garmisch',
|
|
distance: 2000,
|
|
duration: 3600,
|
|
elevation_gain: 150,
|
|
elevation_loss: 150,
|
|
difficulty: 1,
|
|
public: false,
|
|
author_name: 'maurice',
|
|
domain: 'wanderer.example.org',
|
|
polyline: '_p~iF~ps|U_ulLnnqC',
|
|
};
|
|
const GPX = `<?xml version="1.0"?>
|
|
<gpx version="1.1" creator="wanderer"><trk><name>Partnachklamm</name><trkseg>
|
|
<trkpt lat="47.45" lon="11.1"><ele>700</ele></trkpt>
|
|
<trkpt lat="47.46" lon="11.11"><ele>800</ele></trkpt>
|
|
<trkpt lat="47.47" lon="11.12"><ele>850</ele></trkpt>
|
|
</trkseg></trk></gpx>`;
|
|
const RECORD = {
|
|
id: HIT.id,
|
|
name: HIT.name,
|
|
description: HIT.description,
|
|
gpx: 'track.gpx',
|
|
updated: '2026-10-01',
|
|
public: false,
|
|
author: 'actor1',
|
|
};
|
|
|
|
const realtime = new FakeRealtimeService();
|
|
const client = {
|
|
check: vi.fn(),
|
|
ownActors: vi.fn(),
|
|
search: vi.fn(),
|
|
searchById: vi.fn(),
|
|
trail: vi.fn(),
|
|
gpx: vi.fn(),
|
|
};
|
|
|
|
let orm: TestOrm;
|
|
let server: Server;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
let checkPermission: MockInstance;
|
|
|
|
async function build() {
|
|
const moduleRef = await Test.createTestingModule({
|
|
imports: [
|
|
await TestUnitOfWorkModule.forRoot(db),
|
|
await createTestMikroOrmModule(db),
|
|
RealtimeModule,
|
|
WandererModule,
|
|
ToursModule,
|
|
],
|
|
})
|
|
.overrideProvider(RealtimeService)
|
|
.useValue(realtime)
|
|
.overrideProvider(JourneyDomainService)
|
|
.useValue(journeyHooks)
|
|
.overrideProvider(WandererClient)
|
|
.useValue(client)
|
|
.compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalPipes(new ZodValidationPipe());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
|
|
const connect = (userId = OWNER) =>
|
|
request(server)
|
|
.put('/api/integrations/wanderer/settings')
|
|
.set(as(userId))
|
|
.send({ url: 'https://wanderer.example.org/', token: TOKEN });
|
|
const search = (body: Record<string, unknown> = {}, userId = OWNER, tripId = 5) =>
|
|
request(server).post(`/api/trips/${tripId}/tours/wanderer/search`).set(as(userId)).send(body);
|
|
const preview = (trailId = HIT.id, userId = OWNER) =>
|
|
request(server).get(`/api/trips/5/tours/wanderer/trails/${trailId}`).set(as(userId));
|
|
const importTrail = (contentHash: string, userId = OWNER) =>
|
|
request(server).post('/api/trips/5/tours/wanderer/import').set(as(userId)).send({ trailId: HIT.id, contentHash });
|
|
|
|
beforeAll(async () => {
|
|
orm = await createTestOrm(db);
|
|
await makeUser(orm, { id: 1, username: 'owner', email: 'owner@example.test', role: 'user', password_version: 0 });
|
|
await makeUser(orm, { id: 2, username: 'viewer', email: 'viewer@example.test', role: 'user', password_version: 0 });
|
|
await makeUser(orm, {
|
|
id: 3,
|
|
username: 'outsider',
|
|
email: 'outsider@example.test',
|
|
role: 'user',
|
|
password_version: 0,
|
|
});
|
|
await insertRows(orm, Trips, [{ id: 5, user: 1, title: 'Alps' }]);
|
|
await addTripMember(orm, 5, 2);
|
|
app = await build();
|
|
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
|
|
server = app.getHttpServer();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
vi.clearAllMocks();
|
|
checkPermission.mockImplementation(
|
|
(action: string, _role: string, _owner: number, userId: number) => !(action === 'place_edit' && userId === VIEWER),
|
|
);
|
|
checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false });
|
|
client.check.mockResolvedValue({ trailCount: 3 });
|
|
client.ownActors.mockResolvedValue(['actor1']);
|
|
client.search.mockResolvedValue({ hits: [HIT], total: 1 });
|
|
client.searchById.mockResolvedValue(HIT);
|
|
client.trail.mockResolvedValue(RECORD);
|
|
client.gpx.mockResolvedValue(GPX);
|
|
await setAddonEnabled(orm, 'tours', true);
|
|
await setAppSetting(orm, 'tours_wanderer_enabled', 'true');
|
|
await deleteRows(orm, TourSources);
|
|
await deleteRows(orm, Places);
|
|
await deleteRows(orm, WandererConnections);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
await orm.close();
|
|
});
|
|
|
|
describe('wanderer e2e', () => {
|
|
describe('the connection', () => {
|
|
it('RS-03: saves a connection, keeps the token encrypted and never hands it back', async () => {
|
|
expect((await connect()).body).toEqual({ success: true });
|
|
const row = await findRow(orm, WandererConnections, { user: OWNER });
|
|
expect(row?.token).toBeTruthy();
|
|
expect(row?.token).not.toContain(TOKEN);
|
|
|
|
const res = await request(server).get('/api/integrations/wanderer/settings').set(as(OWNER));
|
|
expect(res.body).toMatchObject({ url: 'https://wanderer.example.org', tokenMasked: '••••••••', connected: true });
|
|
expect(JSON.stringify(res.body)).not.toContain(TOKEN);
|
|
});
|
|
|
|
it('RS-03: a private address is saved with a warning, an invalid one refused', async () => {
|
|
checkSsrf.mockResolvedValueOnce({ allowed: false, isPrivate: true, resolvedIp: '10.0.0.5' });
|
|
expect((await connect()).body).toEqual({ success: true, warning: 'private_ip' });
|
|
|
|
checkSsrf.mockResolvedValueOnce({ allowed: false, isPrivate: false, error: 'Invalid URL' });
|
|
const refused = await connect();
|
|
expect(refused.status).toBe(400);
|
|
expect(refused.body).toMatchObject({ code: 'invalid_url' });
|
|
});
|
|
|
|
it('RS-03: a new host without a new token drops the stored one', async () => {
|
|
await connect();
|
|
await request(server)
|
|
.put('/api/integrations/wanderer/settings')
|
|
.set(as(OWNER))
|
|
.send({ url: 'https://other.example.org' });
|
|
expect((await findRow(orm, WandererConnections, { user: OWNER }))?.token).toBeNull();
|
|
});
|
|
|
|
it('RS-03: the test answers with the reason in the body and notes it on the saved connection', async () => {
|
|
await connect();
|
|
expect((await request(server).post('/api/integrations/wanderer/test').set(as(OWNER)).send({})).body).toEqual({
|
|
ok: true,
|
|
trailCount: 3,
|
|
});
|
|
|
|
client.check.mockRejectedValueOnce(new WandererError('unauthorized', 'no'));
|
|
expect((await request(server).post('/api/integrations/wanderer/test').set(as(OWNER)).send({})).body).toEqual({
|
|
ok: false,
|
|
code: 'unauthorized',
|
|
});
|
|
expect((await request(server).get('/api/integrations/wanderer/settings').set(as(OWNER))).body).toMatchObject({
|
|
lastCheckOk: false,
|
|
lastCheckError: 'unauthorized',
|
|
});
|
|
});
|
|
|
|
it('RS-03: disconnecting forgets only the caller connection', async () => {
|
|
await connect(OWNER);
|
|
await connect(VIEWER);
|
|
expect((await request(server).delete('/api/integrations/wanderer/settings').set(as(OWNER))).status).toBe(200);
|
|
expect(await countRows(orm, WandererConnections, { user: OWNER })).toBe(0);
|
|
expect(await countRows(orm, WandererConnections, { user: VIEWER })).toBe(1);
|
|
});
|
|
|
|
it('404 for every wanderer route while the admin has not allowed it under Tours, or Tours is off', async () => {
|
|
await connect();
|
|
await setAppSetting(orm, 'tours_wanderer_enabled', 'false');
|
|
const settings = await request(server).get('/api/integrations/wanderer/settings').set(as(OWNER));
|
|
expect(settings.status).toBe(404);
|
|
expect(settings.body).toMatchObject({ code: 'disabled' });
|
|
expect((await search()).status).toBe(404);
|
|
|
|
await setAppSetting(orm, 'tours_wanderer_enabled', 'true');
|
|
await setAddonEnabled(orm, 'tours', false);
|
|
expect((await search()).status).toBe(404);
|
|
expect(client.search).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('search, preview and import', () => {
|
|
it('RS-04: 409 not_connected before a connection exists', async () => {
|
|
const res = await search();
|
|
expect(res.status).toBe(409);
|
|
expect(res.body).toMatchObject({ code: 'not_connected' });
|
|
});
|
|
|
|
it('RS-04/MF-02: searches the caller own trails, in the map box when asked, and marks what is in the trip', async () => {
|
|
await connect();
|
|
const box = { south: 47, west: 11, north: 48, east: 12 };
|
|
const res = await search({ q: 'klamm', bbox: box });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({
|
|
total: 1,
|
|
trails: [{ id: HIT.id, name: 'Partnachklamm', difficulty: 'moderate', importedAs: [] }],
|
|
});
|
|
expect(client.search).toHaveBeenCalledWith(
|
|
expect.objectContaining({ token: TOKEN }),
|
|
{ q: 'klamm', page: 1, bbox: box },
|
|
['actor1'],
|
|
);
|
|
});
|
|
|
|
it('RS-04: only who may add places to the trip looks; an outsider learns nothing of it', async () => {
|
|
await connect(VIEWER);
|
|
expect((await search({}, VIEWER)).status).toBe(403);
|
|
expect((await search({}, OUTSIDER)).status).toBe(404);
|
|
});
|
|
|
|
it('RS-05: previews, then imports what was previewed as a snapshot with its provenance', async () => {
|
|
await connect();
|
|
const shown = await preview();
|
|
expect(shown.status).toBe(200);
|
|
expect(shown.body).toMatchObject({ state: 'new', hasElevation: true, points: 3 });
|
|
|
|
const res = await importTrail(shown.body.contentHash);
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.tour).toMatchObject({
|
|
name: 'Partnachklamm',
|
|
has_waypoints: false,
|
|
difficulty: 'moderate',
|
|
max_hiking_difficulty: 2,
|
|
});
|
|
const placeId = res.body.tour.place_id as number;
|
|
expect(await findRow(orm, TourSources, { place: placeId })).toMatchObject({
|
|
provider: 'wanderer',
|
|
external_id: HIT.id,
|
|
});
|
|
|
|
// Imported once, the same content is not imported again.
|
|
const again = await importTrail(shown.body.contentHash);
|
|
expect(again.status).toBe(409);
|
|
expect(again.body).toMatchObject({ code: 'already_imported' });
|
|
expect((await search()).body.trails[0].importedAs).toEqual([placeId]);
|
|
});
|
|
|
|
it('RS-05: a fingerprint that no longer matches the trail imports nothing', async () => {
|
|
await connect();
|
|
const res = await importTrail('0'.repeat(64));
|
|
expect(res.status).toBe(409);
|
|
expect(res.body).toMatchObject({ code: 'preview_stale' });
|
|
expect(await countRows(orm, Places)).toBe(0);
|
|
});
|
|
|
|
it('RS-06: a changed trail previews as changed and comes in as a new snapshot beside the old one', async () => {
|
|
await connect();
|
|
const first = await importTrail((await preview()).body.contentHash);
|
|
client.trail.mockResolvedValue({ ...RECORD, name: 'Partnachklamm (new route)' });
|
|
const changed = await preview();
|
|
expect(changed.body).toMatchObject({ state: 'changed', snapshot: { placeId: first.body.tour.place_id } });
|
|
expect(changed.body.changes).toContain('name');
|
|
|
|
const second = await importTrail(changed.body.contentHash);
|
|
expect(second.status).toBe(201);
|
|
expect(second.body.tour.place_id).not.toBe(first.body.tour.place_id);
|
|
expect(await countRows(orm, Places)).toBe(2);
|
|
});
|
|
|
|
it('RS-05: a trail without a track or route is refused before anything is written', async () => {
|
|
await connect();
|
|
client.gpx.mockResolvedValue('<?xml version="1.0"?><gpx version="1.1"><wpt lat="47" lon="11"/></gpx>');
|
|
const res = await preview();
|
|
expect(res.status).toBe(422);
|
|
expect(res.body).toMatchObject({ code: 'no_track' });
|
|
expect(await countRows(orm, Places)).toBe(0);
|
|
});
|
|
|
|
it('RS-05: a trail without heights comes in marked to use with caution', async () => {
|
|
await connect();
|
|
client.gpx.mockResolvedValue(GPX.replace(/<ele>\d+<\/ele>/g, ''));
|
|
client.trail.mockResolvedValue({ ...RECORD, description: ' ' });
|
|
const shown = await preview();
|
|
expect(shown.body).toMatchObject({ hasElevation: false, elevationGainM: null });
|
|
const res = await importTrail(shown.body.contentHash);
|
|
expect(res.body.tour).toMatchObject({ caution: true, description: null });
|
|
});
|
|
|
|
it('RS-06: says whether a snapshot was edited here since its import, and when nothing changed there', async () => {
|
|
await connect();
|
|
const imported = await importTrail((await preview()).body.contentHash);
|
|
expect((await preview()).body).toMatchObject({
|
|
state: 'unchanged',
|
|
changes: [],
|
|
snapshot: { editedLocally: false },
|
|
});
|
|
|
|
await request(server)
|
|
.put(`/api/trips/5/places/${imported.body.tour.place_id}`)
|
|
.set(as(OWNER))
|
|
.send({ name: 'Renamed here' });
|
|
expect((await preview()).body).toMatchObject({ state: 'unchanged', snapshot: { editedLocally: true } });
|
|
});
|
|
|
|
it('RS-05: an import that is written stays written when the realtime notification fails', async () => {
|
|
await connect();
|
|
const shown = await preview();
|
|
realtime.broadcastMock.mockImplementationOnce(() => {
|
|
throw new Error('socket closed');
|
|
});
|
|
const res = await importTrail(shown.body.contentHash);
|
|
expect(res.status).toBe(201);
|
|
expect(await countRows(orm, Places)).toBe(1);
|
|
});
|
|
|
|
it("someone else's trail is neither previewed nor imported", async () => {
|
|
await connect();
|
|
client.searchById.mockResolvedValue(null);
|
|
expect((await preview()).status).toBe(404);
|
|
expect((await importTrail('0'.repeat(64))).status).toBe(404);
|
|
expect(client.gpx).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('checking the source of a snapshot (MF-03)', () => {
|
|
const check = (placeId: number, userId = OWNER) =>
|
|
request(server).post(`/api/trips/5/tours/wanderer/sources/${placeId}/check`).set(as(userId));
|
|
const imported = async () => {
|
|
await connect();
|
|
const res = await importTrail((await preview()).body.contentHash);
|
|
expect(res.status).toBe(201);
|
|
return res.body.tour.place_id as number;
|
|
};
|
|
|
|
it('an unchanged trail says so and offers nothing to take in; the check writes nothing', async () => {
|
|
const placeId = await imported();
|
|
const before = await countRows(orm, Places);
|
|
const res = await check(placeId);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({
|
|
state: 'unchanged',
|
|
changes: [],
|
|
externalUpdated: RECORD.updated,
|
|
importedUpdated: RECORD.updated,
|
|
editedLocally: false,
|
|
reimport: null,
|
|
});
|
|
expect(await countRows(orm, Places)).toBe(before);
|
|
});
|
|
|
|
it('a changed trail names what changed, and its hash takes it in as a new snapshot beside the old one', async () => {
|
|
const placeId = await imported();
|
|
client.trail.mockResolvedValue({ ...RECORD, name: 'Partnachklamm (new route)', updated: '2026-10-14' });
|
|
const res = await check(placeId);
|
|
expect(res.body).toMatchObject({
|
|
state: 'changed',
|
|
changes: ['name'],
|
|
externalUpdated: '2026-10-14',
|
|
importedUpdated: '2026-10-01',
|
|
reimport: { trailId: HIT.id },
|
|
});
|
|
const again = await importTrail(res.body.reimport.contentHash);
|
|
expect(again.status).toBe(201);
|
|
expect(again.body.tour.place_id).not.toBe(placeId);
|
|
expect(await countRows(orm, Places)).toBe(2);
|
|
});
|
|
|
|
it('a trail that is gone or no longer the caller own leaves the snapshot alone', async () => {
|
|
const placeId = await imported();
|
|
client.searchById.mockResolvedValue(null);
|
|
expect((await check(placeId)).body).toMatchObject({ state: 'unavailable', reimport: null });
|
|
client.searchById.mockResolvedValue(HIT);
|
|
client.trail.mockResolvedValue({ ...RECORD, author: 'someone-else' });
|
|
expect((await check(placeId)).body).toMatchObject({ state: 'unavailable', reimport: null });
|
|
expect(await countRows(orm, Places)).toBe(1);
|
|
});
|
|
|
|
it('a trail that lost its track cannot be compared, and the check says why', async () => {
|
|
const placeId = await imported();
|
|
client.gpx.mockResolvedValue('<?xml version="1.0"?><gpx version="1.1"><wpt lat="47" lon="11"/></gpx>');
|
|
const res = await check(placeId);
|
|
expect(res.status).toBe(422);
|
|
expect(res.body).toMatchObject({ code: 'no_track' });
|
|
expect(await countRows(orm, Places)).toBe(1);
|
|
});
|
|
|
|
it('a trail without a change date still compares, and names no date', async () => {
|
|
const placeId = await imported();
|
|
client.trail.mockResolvedValue({ ...RECORD, updated: undefined });
|
|
expect((await check(placeId)).body).toMatchObject({ state: 'unchanged', externalUpdated: null });
|
|
});
|
|
|
|
it('without a connection, or one to another instance, there is nothing to ask', async () => {
|
|
const placeId = await imported();
|
|
await request(server)
|
|
.put('/api/integrations/wanderer/settings')
|
|
.set(as(OWNER))
|
|
.send({ url: 'https://other.example.org/', token: TOKEN });
|
|
expect((await check(placeId)).body).toMatchObject({ state: 'other_instance' });
|
|
await request(server).delete('/api/integrations/wanderer/settings').set(as(OWNER));
|
|
expect((await check(placeId)).body).toMatchObject({ state: 'not_connected' });
|
|
});
|
|
|
|
it('a snapshot without its import record cannot be compared, the current trail can still come in', async () => {
|
|
const placeId = await imported();
|
|
await deleteRows(orm, TourSources, { place: placeId });
|
|
expect((await check(placeId)).body).toMatchObject({
|
|
state: 'unknown',
|
|
editedLocally: null,
|
|
reimport: { trailId: HIT.id },
|
|
});
|
|
});
|
|
|
|
it('only a wanderer snapshot of the trip is checked, and only by who may add places', async () => {
|
|
const placeId = await imported();
|
|
expect((await check(placeId, VIEWER)).status).toBe(403);
|
|
expect((await check(999_999)).status).toBe(404);
|
|
expect((await request(server).post('/api/trips/5/tours/wanderer/sources/abc/check').set(as(OWNER))).status).toBe(
|
|
404,
|
|
);
|
|
const planned = await request(server)
|
|
.post('/api/trips/5/tours')
|
|
.set(as(OWNER))
|
|
.send({
|
|
name: 'Drawn',
|
|
tour_type: 'hike',
|
|
route_geometry: [
|
|
[47.45, 11.1, 700],
|
|
[47.46, 11.11, 800],
|
|
],
|
|
waypoints: [
|
|
{ lat: 47.45, lng: 11.1, role: 'start', sequence: 0 },
|
|
{ lat: 47.46, lng: 11.11, role: 'end', sequence: 1 },
|
|
],
|
|
});
|
|
const res = await check(planned.body.tour.place_id as number);
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toMatchObject({ code: 'no_source' });
|
|
});
|
|
|
|
it('a wanderer import says where it came from in its detail (P2C-02)', async () => {
|
|
const placeId = await imported();
|
|
const detail = await request(server).get(`/api/trips/5/tours/${placeId}`).set(as(OWNER));
|
|
expect(detail.body.provenance).toMatchObject({
|
|
origin: 'wanderer',
|
|
source: {
|
|
kind: 'wanderer',
|
|
instance: 'https://wanderer.example.org',
|
|
externalId: HIT.id,
|
|
externalUpdated: RECORD.updated,
|
|
},
|
|
editedLocally: false,
|
|
});
|
|
expect(detail.body.provenance.source.importedAt).toEqual(expect.any(String));
|
|
});
|
|
});
|
|
|
|
describe('the background watch over the caller own snapshots (WAN-02)', () => {
|
|
const watchRoute = (userId = OWNER) => request(server).get('/api/integrations/wanderer/watch').set(as(userId));
|
|
const switchWatch = (enabled: unknown, userId = OWNER) =>
|
|
request(server).put('/api/integrations/wanderer/watch').set(as(userId)).send({ enabled });
|
|
const hint = (placeId: number, userId = OWNER) =>
|
|
request(server).get(`/api/trips/5/tours/wanderer/sources/${placeId}/hint`).set(as(userId));
|
|
const imported = async () => {
|
|
await connect();
|
|
const res = await importTrail((await preview()).body.contentHash);
|
|
return res.body.tour.place_id as number;
|
|
};
|
|
|
|
it('is off until switched on, and only for a connection that exists', async () => {
|
|
expect((await watchRoute()).body).toEqual({
|
|
enabled: false,
|
|
connected: false,
|
|
lastRunAt: null,
|
|
pausedUntil: null,
|
|
blocked: false,
|
|
hints: [],
|
|
});
|
|
const refused = await switchWatch(true);
|
|
expect(refused.status).toBe(409);
|
|
expect(refused.body).toMatchObject({ code: 'not_connected' });
|
|
expect((await switchWatch('yes')).status).toBe(400);
|
|
|
|
await connect();
|
|
expect((await switchWatch(true)).body).toMatchObject({ enabled: true, connected: true });
|
|
expect((await switchWatch(false)).body).toMatchObject({ enabled: false });
|
|
});
|
|
|
|
it('a moved trail leaves a hint for its importer, the manual check clears it, and no tour changes', async () => {
|
|
const placeId = await imported();
|
|
await switchWatch(true);
|
|
client.trail.mockResolvedValue({ ...RECORD, updated: '2026-10-14' });
|
|
const before = await countRows(orm, Places);
|
|
const gpxReads = client.gpx.mock.calls.length;
|
|
// The cron's tick, in the request context the registrar gives every tick.
|
|
await withRequestContext(app.get(MikroORM), () => app.get(WandererWatchService).run());
|
|
expect(client.gpx).toHaveBeenCalledTimes(gpxReads);
|
|
expect(await countRows(orm, Places)).toBe(before);
|
|
|
|
expect((await watchRoute()).body.hints).toEqual([
|
|
expect.objectContaining({ placeId, tripId: 5, tripTitle: 'Alps', state: 'possible_change' }),
|
|
]);
|
|
expect((await hint(placeId)).body).toEqual({ hint: expect.objectContaining({ state: 'possible_change' }) });
|
|
|
|
const checked = await request(server).post(`/api/trips/5/tours/wanderer/sources/${placeId}/check`).set(as(OWNER));
|
|
expect(checked.body).toMatchObject({ state: 'unchanged' });
|
|
expect((await hint(placeId)).body).toEqual({ hint: null });
|
|
expect((await watchRoute()).body.hints).toEqual([]);
|
|
});
|
|
|
|
it('the hint of a tour is the caller own and asks the right of the check', async () => {
|
|
const placeId = await imported();
|
|
expect((await hint(placeId, VIEWER)).status).toBe(403);
|
|
expect((await hint(placeId, OUTSIDER)).status).toBe(404);
|
|
checkPermission.mockReturnValue(true);
|
|
expect((await hint(placeId, VIEWER)).body).toEqual({ hint: null });
|
|
});
|
|
|
|
it('404 while the admin has not allowed wanderer under Tours', async () => {
|
|
await setAppSetting(orm, 'tours_wanderer_enabled', 'false');
|
|
expect((await watchRoute()).status).toBe(404);
|
|
expect((await switchWatch(true)).status).toBe(404);
|
|
});
|
|
});
|
|
});
|