Files
TREK/server/tests/e2e/wanderer.e2e.test.ts
T
Maurice 2e1dca9fe9 feat(tours): let a background check hint at changes of your own wanderer trails
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.
2026-10-20 15:35:32 +02:00

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);
});
});
});