Tours/wanderer: cover the new service, repository and restore paths

- unit suites for WandererService (every refusal and fallback), the
  wanderer connection and tour source repositories, and restoring a
  recorded track (GPX and wanderer imports, empty or missing track, 404s)
- client cases for a body that breaks off and the trail count fallbacks
- an import whose realtime notification fails still stands (e2e)
- the search controller passes the validated body on; the schema already
  defaults q and page, so the fallbacks there could never run
This commit is contained in:
Maurice
2026-10-15 20:15:17 +02:00
parent cab742e641
commit 018d2d1425
6 changed files with 650 additions and 1 deletions
@@ -30,7 +30,7 @@ export class ToursWandererController {
@HttpCode(200)
@ResponseContract(wandererSearchResponseSchema)
search(@Param('tripId') tripId: string, @CurrentUser() user: User, @Body() body: WandererSearchDto) {
return this.wanderer.search(tripId, user.id, { q: body.q ?? '', page: body.page ?? 1, bbox: body.bbox });
return this.wanderer.search(tripId, user.id, { q: body.q, page: body.page, bbox: body.bbox });
}
@RequirePermission('place_edit')
+11
View File
@@ -381,6 +381,17 @@ describe('wanderer e2e', () => {
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);
@@ -0,0 +1,142 @@
/**
* WandererConnectionsRepository and TourSourcesRepository: a user's wanderer
* connection, and the provenance rows of tours imported through it.
*/
import { TourSources } from '../../../../src/db/entities/TourSources.entity';
import { WandererConnections } from '../../../../src/db/entities/WandererConnections.entity';
import type { TourSourcesRepository } from '../../../../src/db/repositories/TourSources.repository';
import type { WandererConnectionsRepository } from '../../../../src/db/repositories/WandererConnections.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 connections: WandererConnectionsRepository;
let sources: TourSourcesRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
connections = t.repo(WandererConnections);
sources = t.repo(TourSources);
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
});
afterAll(async () => {
await t.close();
testDb.close();
});
describe('WandererConnectionsRepository', () => {
it('WANDREPO-001: no row reads as null', async () => {
const { user } = createUser(testDb);
expect(await connections.findRow(user.id)).toBeNull();
});
it('WANDREPO-002: saves, keeps the token when none is given, drops it on null, and forgets the check on a new host', async () => {
const { user } = createUser(testDb);
await connections.save(user.id, {
url: 'https://a.example',
allowInsecureTls: true,
token: 'enc-1',
hostChanged: false,
});
expect(await connections.findRow(user.id)).toMatchObject({
url: 'https://a.example',
token: 'enc-1',
allow_insecure_tls: 1,
});
await connections.recordCheck(user.id, false, 'unauthorized');
await connections.save(user.id, { url: 'https://a.example/', allowInsecureTls: false, hostChanged: false });
expect(await connections.findRow(user.id)).toMatchObject({
token: 'enc-1',
allow_insecure_tls: 0,
last_check_ok: 0,
last_check_error: 'unauthorized',
});
await connections.save(user.id, {
url: 'https://b.example',
allowInsecureTls: false,
token: null,
hostChanged: true,
});
expect(await connections.findRow(user.id)).toMatchObject({
url: 'https://b.example',
token: null,
last_checked_at: null,
last_check_ok: null,
last_check_error: null,
});
});
it('WANDREPO-003: records a passing check and removes only its own row', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const saveFor = (id: number) =>
connections.save(id, { url: 'https://a.example', allowInsecureTls: false, token: 't', hostChanged: false });
await saveFor(user.id);
await saveFor(other.id);
await connections.recordCheck(user.id, true, null);
expect(await connections.findRow(user.id)).toMatchObject({ last_check_ok: 1, last_check_error: null });
expect((await connections.findRow(user.id))?.last_checked_at).toEqual(expect.any(String));
await connections.remove(user.id);
expect(await connections.findRow(user.id)).toBeNull();
expect(await connections.findRow(other.id)).not.toBeNull();
});
});
describe('TourSourcesRepository', () => {
function importedTour(tripId: number, name: string): number {
const place = createPlace(testDb, tripId, { name });
createTour(testDb, place.id);
return place.id;
}
const source = (placeId: number, externalId: string, importedBy: number, contentHash = 'c1') => ({
place_id: placeId,
provider: 'wanderer',
instance: 'https://a.example',
external_id: externalId,
external_url: `https://a.example/trail/view/@me/${externalId}`,
external_updated: null,
content_hash: contentHash,
local_hash: 'l1',
imported_by: importedBy,
});
it('TOURSRC-001: asking about no trails asks nothing', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(await sources.listSnapshots(trip.id, 'wanderer', 'https://a.example', [])).toEqual([]);
});
it('TOURSRC-002: lists the snapshots of the asked trails in this trip and instance, newest first', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const other = createTrip(testDb, user.id);
const first = importedTour(trip.id, 'Ridge');
const second = importedTour(trip.id, 'Ridge (new)');
const foreign = importedTour(other.id, 'Ridge elsewhere');
const unasked = importedTour(trip.id, 'Gorge');
await sources.insertSource(source(first, 'trail1', user.id, 'c1'));
await sources.insertSource(source(second, 'trail1', user.id, 'c2'));
await sources.insertSource(source(foreign, 'trail1', user.id));
await sources.insertSource(source(unasked, 'trail2', user.id));
const rows = await sources.listSnapshots(trip.id, 'wanderer', 'https://a.example', ['trail1']);
expect(rows.map((row) => [row.place_id, row.content_hash, row.name])).toEqual([
[second, 'c2', 'Ridge (new)'],
[first, 'c1', 'Ridge'],
]);
expect(await sources.listSnapshots(trip.id, 'wanderer', 'https://b.example', ['trail1'])).toEqual([]);
});
});
@@ -530,3 +530,92 @@ describe('ToursService updates', () => {
expect(broadcast).not.toHaveBeenCalled();
});
});
describe('ToursService restoring a recorded track (PR1-05B)', () => {
const recorded = [
[47.45, 11.1],
[47.46, 11.11],
[47.47, 11.12],
];
/** A tour planned over in place: waypoints of its own, the import's line kept beside them. */
async function plannedOverImport(
fields: Partial<Pick<Tours, 'wanderer_ref' | 'difficulty' | 'recorded_route_geometry'>>,
) {
const created = await service.createTour(tripId, request);
const placeId = created.tour.place_id;
await updateRows(
await sharedTestOrm(testDb),
Tours,
{ place: placeId },
{
recorded_route_geometry: JSON.stringify(recorded),
...fields,
},
);
(await sharedTestOrm(testDb)).clear();
broadcast.mockReset();
return String(placeId);
}
it('PR1-05B-SVC-001: a GPX import gets its line back without heights or grade, and loses its waypoints', async () => {
const placeId = await plannedOverImport({});
broadcast.mockImplementation((_tripId: string, event: string) => {
if (event === 'place:updated') throw new Error('socket closed');
});
const warn = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
const restored = await service.restoreRecordedTrack(tripId, placeId, 'socket');
// Its stored waypoints are gone; the detail names the two ends from the line, as for any import.
expect(await count(TourWaypoints)).toBe(0);
expect(restored.waypoints.map((point) => point.role)).toEqual(['start', 'end']);
expect(restored.tour).toMatchObject({ max_hiking_difficulty: null, has_recorded_track: false });
const tour = await findRow(await sharedTestOrm(testDb), Tours, { place: Number(placeId) });
expect(tour).toMatchObject({ match_confidence: 0.3, recorded_route_geometry: null });
expect(JSON.parse(String((await placeById(Number(placeId)))?.route_geometry))).toEqual(recorded);
expect(warn).toHaveBeenCalled();
});
it('PR1-05B-SVC-002: a wanderer import gets the grade its source gave, on the SAC scale', async () => {
const placeId = await plannedOverImport({
wanderer_ref: 'https://w.example#trail1',
difficulty: 'difficult',
recorded_route_geometry: JSON.stringify(recorded.map(([lat, lng], i) => [lat, lng, 700 + i * 50])),
});
const restored = await service.restoreRecordedTrack(tripId, placeId);
expect(restored.tour).toMatchObject({ max_hiking_difficulty: 3 });
expect(await findRow(await sharedTestOrm(testDb), Tours, { place: Number(placeId) })).toMatchObject({
match_confidence: 1,
});
expect(broadcast).toHaveBeenCalledWith(tripId, 'tours:changed', { placeIds: [Number(placeId)] }, undefined);
});
it('PR1-05B-SVC-003: no kept track, or an empty one, is a 409 that changes nothing', async () => {
const created = await service.createTour(tripId, request);
const placeId = String(created.tour.place_id);
const refusal = { status: 409, details: { code: 'no_recorded_track' } };
await expect(service.restoreRecordedTrack(tripId, placeId)).rejects.toMatchObject(refusal);
await updateRows(
await sharedTestOrm(testDb),
Tours,
{ place: created.tour.place_id },
{ recorded_route_geometry: '[]' },
);
(await sharedTestOrm(testDb)).clear();
await expect(service.restoreRecordedTrack(tripId, placeId)).rejects.toMatchObject(refusal);
expect((await service.getTour(tripId, placeId)).waypoints).toEqual(request.waypoints);
});
it('PR1-05B-SVC-004: a plain place, a tour of another trip or a malformed id is a 404', async () => {
const plain = createPlace(testDb, Number(tripId), { name: 'Plain' });
const foreign = await service.createTour(otherTripId, request);
await expect(service.restoreRecordedTrack(tripId, String(plain.id))).rejects.toBeInstanceOf(NotFoundException);
await expect(service.restoreRecordedTrack(tripId, String(foreign.tour.place_id))).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(service.restoreRecordedTrack('abc', '1')).rejects.toBeInstanceOf(NotFoundException);
await expect(service.restoreRecordedTrack(tripId, 'abc')).rejects.toBeInstanceOf(NotFoundException);
});
});
@@ -230,6 +230,36 @@ describe('WandererClient', () => {
expect(await transport(new Error('ECONNREFUSED'))).toBe('unreachable');
});
it('RS-03: a body that breaks off while it is read is a reach failure, for JSON and for the GPX', async () => {
answer({ '/api/v1/trail/abc': reply({ id: 'abc', name: 'x' }) });
readCappedJsonMock.mockRejectedValueOnce(new Error('socket hang up'));
expect((await failure(() => client.trail(CREDS, 'abc'))).code).toBe('unreachable');
answer({
'/api/v1/files/trails/abc/track.gpx': {
ok: true,
status: 200,
text: async () => {
throw new Error('socket hang up');
},
},
});
expect((await failure(() => client.gpx(CREDS, { id: 'abc', name: 'x', gpx: 'track.gpx' }))).code).toBe(
'unreachable',
);
});
it('RS-03: the test counts the owner trails from either total, and still connects when the count fails', async () => {
const countWith = async (search: Reply) => {
answer({ ...OWNER, '/api/v1/search/trails': search });
return (await client.check(CREDS)).trailCount;
};
expect(await countWith(reply({ hits: [], estimatedTotalHits: 7 }))).toBe(7);
expect(await countWith(reply({ hits: [], totalHits: 5 }))).toBe(5);
expect(await countWith(reply({ hits: [] }))).toBeNull();
expect(await countWith(reply('Bad gateway', 502))).toBeNull();
});
it('RS-03: a self-signed certificate is accepted only when the user allowed it', async () => {
answer({ '/api/v1/trail/abc': reply({ id: 'abc', name: 'x' }) });
await client.trail({ ...CREDS, allowInsecureTls: true }, 'abc');
@@ -0,0 +1,377 @@
import { WandererConnections } from '../../../src/db/entities/WandererConnections.entity';
import type { WandererConnectionsRepository } from '../../../src/db/repositories/WandererConnections.repository';
import { AuditService } from '../../../src/nest/audit/audit.service';
import { maybe_encrypt_api_key } from '../../../src/nest/common/crypto/apiKeyCrypto';
import { DomainError } from '../../../src/nest/common/domain-error';
import { WandererClient, WandererError, type WandererSearchHit } from '../../../src/nest/wanderer/wanderer.client';
import { WandererModule } from '../../../src/nest/wanderer/wanderer.module';
import { WandererService } from '../../../src/nest/wanderer/wanderer.service';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { createTestModule, type TestModule } from '../../helpers/test-module';
import { getRepositoryToken } from '@mikro-orm/nestjs';
import { WANDERER_TOKEN_MASK } from '@trek/shared';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const { checkSsrf } = vi.hoisted(() => ({ checkSsrf: vi.fn() }));
vi.mock('../../../src/utils/ssrfGuard', () => ({ checkSsrf, safeFetch: vi.fn() }));
const PUBLIC = { allowed: true, isPrivate: false };
const PRIVATE = { allowed: false, isPrivate: true, resolvedIp: '192.168.1.5', error: 'private' };
const BLOCKED = { allowed: false, isPrivate: false, error: 'not a public address' };
type Row = Awaited<ReturnType<WandererConnectionsRepository['findRow']>>;
function savedRow(over: Partial<NonNullable<Row>> = {}): NonNullable<Row> {
return {
user_id: 1,
url: 'https://trails.example',
token: maybe_encrypt_api_key('saved-token'),
allow_insecure_tls: 0,
last_checked_at: null,
last_check_ok: null,
last_check_error: null,
...over,
} as NonNullable<Row>;
}
function hit(over: Partial<WandererSearchHit> = {}): WandererSearchHit {
return { id: 'trail1', name: 'Ridge', ...over };
}
const state: { row: Row } = { row: undefined };
const connections = {
findRow: vi.fn(async () => state.row),
save: vi.fn(async () => undefined),
remove: vi.fn(async () => undefined),
recordCheck: vi.fn(async () => undefined),
};
const client = {
check: vi.fn(),
ownActors: vi.fn(),
search: vi.fn(),
searchById: vi.fn(),
trail: vi.fn(),
gpx: vi.fn(),
};
const audit = { writeAudit: vi.fn(async () => undefined) };
const db = createSnapshotTestDb();
let t: TestModule;
beforeAll(async () => {
t = await createTestModule({
db,
imports: [WandererModule],
overrides: [
{ provide: getRepositoryToken(WandererConnections), useValue: connections },
{ provide: WandererClient, useValue: client },
{ provide: AuditService, useValue: audit },
],
});
});
afterAll(async () => {
await t.close();
db.close();
});
/** The service over a saved row (or none), every collaborator answering the happy path. */
function setup(row: Row = undefined) {
state.row = row;
for (const mock of [...Object.values(connections), ...Object.values(client), audit.writeAudit]) mock.mockClear();
client.check.mockReset().mockResolvedValue({ trailCount: 4 });
client.ownActors.mockReset().mockResolvedValue(['actor1']);
client.search.mockReset().mockResolvedValue({ hits: [hit()], total: 1 });
client.searchById.mockReset().mockResolvedValue(hit());
client.trail.mockReset().mockResolvedValue({ id: 'trail1', name: 'Ridge', author: 'actor1' });
client.gpx.mockReset().mockResolvedValue('<gpx/>');
return { service: t.get(WandererService), connections, client, audit };
}
async function refusal(promise: Promise<unknown>): Promise<DomainError> {
const err = await promise.then(
() => null,
(e: unknown) => e,
);
expect(err).toBeInstanceOf(DomainError);
return err as DomainError;
}
beforeEach(() => {
checkSsrf.mockReset();
checkSsrf.mockResolvedValue(PUBLIC);
});
describe('WandererService connection card', () => {
it('WAND-SVC-001: no saved row reads as an empty, unconnected card', async () => {
const { service } = setup();
expect(await service.getConnection(1)).toEqual({
url: '',
tokenMasked: '',
allowInsecureTls: false,
connected: false,
lastCheckedAt: null,
lastCheckOk: null,
lastCheckError: null,
});
});
it('WAND-SVC-002: a saved row shows the mask, never the token, and its last check either way', async () => {
const failed = setup(savedRow({ allow_insecure_tls: 1, last_check_ok: 0, last_check_error: 'unauthorized' }));
expect(await failed.service.getConnection(1)).toMatchObject({
url: 'https://trails.example',
tokenMasked: WANDERER_TOKEN_MASK,
allowInsecureTls: true,
connected: true,
lastCheckOk: false,
lastCheckError: 'unauthorized',
});
const passed = setup(savedRow({ last_check_ok: 1 }));
expect((await passed.service.getConnection(1)).lastCheckOk).toBe(true);
const tokenless = setup(savedRow({ token: null }));
expect(await tokenless.service.getConnection(1)).toMatchObject({ tokenMasked: '', connected: false });
});
});
describe('WandererService.save', () => {
it('WAND-SVC-003: a public address that is not allowed is refused as invalid_url', async () => {
checkSsrf.mockResolvedValue(BLOCKED);
const { service, connections } = setup();
const err = await refusal(
service.save(1, { url: 'https://bad.example', token: 't', allowInsecureTls: false }, null),
);
expect(err.getStatus()).toBe(400);
expect(connections.save).not.toHaveBeenCalled();
});
it('WAND-SVC-004: a first save stores the typed token encrypted and audits a connection', async () => {
const { service, connections, audit } = setup();
expect(
await service.save(1, { url: 'https://trails.example/api', token: 'new', allowInsecureTls: false }, '1.2.3.4'),
).toEqual({
success: true,
});
const saved = connections.save.mock.calls[0] as unknown as [
number,
{ url: string; token: string; hostChanged: boolean },
];
expect(saved[1].url).toBe('https://trails.example');
expect(saved[1].token).not.toBe('new');
expect(saved[1].hostChanged).toBe(false);
expect(audit.writeAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: 'wanderer.connected',
details: { url: 'https://trails.example', token_changed: true },
}),
);
});
it('WAND-SVC-005: a private address saves with a warning and records where it resolved', async () => {
checkSsrf.mockResolvedValue(PRIVATE);
const { service, audit } = setup(savedRow({ url: 'http://192.168.1.5:3000' }));
expect(
await service.save(
1,
{ url: 'http://192.168.1.5:3000', token: WANDERER_TOKEN_MASK, allowInsecureTls: false },
null,
),
).toEqual({
success: true,
warning: 'private_ip',
});
expect(audit.writeAudit).toHaveBeenCalledWith(
expect.objectContaining({
action: 'wanderer.updated',
details: expect.objectContaining({ token_changed: false, resolved_ip: '192.168.1.5' }),
}),
);
});
it('WAND-SVC-006: the mask keeps the stored token on the same host and drops it on another', async () => {
const same = setup(savedRow());
await same.service.save(
1,
{ url: 'https://trails.example', token: WANDERER_TOKEN_MASK, allowInsecureTls: true },
null,
);
expect(same.connections.save).toHaveBeenCalledWith(
1,
expect.objectContaining({ token: undefined, hostChanged: false }),
);
const moved = setup(savedRow());
await moved.service.save(1, { url: 'https://elsewhere.example', allowInsecureTls: false }, null);
expect(moved.connections.save).toHaveBeenCalledWith(1, expect.objectContaining({ token: null, hostChanged: true }));
});
it('WAND-SVC-007: disconnecting removes the row and audits it', async () => {
const { service, connections, audit } = setup(savedRow());
await service.disconnect(1, '1.2.3.4');
expect(connections.remove).toHaveBeenCalledWith(1);
expect(audit.writeAudit).toHaveBeenCalledWith(
expect.objectContaining({ action: 'wanderer.disconnected', ip: '1.2.3.4' }),
);
});
});
describe('WandererService.test', () => {
it('WAND-SVC-008: without an address or a token there is nothing to test', async () => {
expect(await setup().service.test(1, {})).toEqual({ ok: false, code: 'not_connected' });
// A typed address on another host never borrows the saved token.
expect(await setup(savedRow()).service.test(1, { url: 'https://elsewhere.example' })).toEqual({
ok: false,
code: 'not_connected',
});
});
it('WAND-SVC-009: an address the guard refuses answers blocked_address or invalid_url', async () => {
checkSsrf.mockResolvedValue(PRIVATE);
expect(await setup().service.test(1, { url: 'http://192.168.1.5', token: 't' })).toEqual({
ok: false,
code: 'blocked_address',
});
checkSsrf.mockResolvedValue(BLOCKED);
expect(await setup().service.test(1, { url: 'https://bad.example', token: 't' })).toEqual({
ok: false,
code: 'invalid_url',
});
});
it('WAND-SVC-010: testing the saved connection notes the outcome on it', async () => {
const ok = setup(savedRow({ allow_insecure_tls: 1 }));
expect(await ok.service.test(1, {})).toEqual({ ok: true, trailCount: 4 });
expect(ok.client.check).toHaveBeenCalledWith({
baseUrl: 'https://trails.example',
token: 'saved-token',
allowInsecureTls: true,
});
expect(ok.connections.recordCheck).toHaveBeenCalledWith(1, true, null);
const bad = setup(savedRow());
bad.client.check.mockRejectedValueOnce(new WandererError('unauthorized', 'rejected'));
expect(await bad.service.test(1, { url: 'https://trails.example' })).toEqual({ ok: false, code: 'unauthorized' });
expect(bad.connections.recordCheck).toHaveBeenCalledWith(1, false, 'unauthorized');
});
it('WAND-SVC-011: a typed token is tested without touching the saved state', async () => {
const { service, client, connections } = setup(savedRow());
client.check.mockRejectedValueOnce(new WandererError('unreachable', 'down'));
expect(await service.test(1, { url: 'https://trails.example', token: 'typed', allowInsecureTls: false })).toEqual({
ok: false,
code: 'unreachable',
});
expect(client.check).toHaveBeenCalledWith(expect.objectContaining({ token: 'typed', allowInsecureTls: false }));
expect(connections.recordCheck).not.toHaveBeenCalled();
});
it('WAND-SVC-012: an error that is not wanderer answering is not swallowed', async () => {
const { service, client } = setup(savedRow());
client.check.mockRejectedValueOnce(new TypeError('bug'));
await expect(service.test(1, {})).rejects.toThrow(TypeError);
});
});
describe('WandererService reads for an import', () => {
it.each([
['no row', undefined],
['no token', savedRow({ token: null })],
['no address', savedRow({ url: '' })],
])('WAND-SVC-013: a read with %s is a 409 not_connected', async (_case, row) => {
expect((await refusal(setup(row).service.search(1, { q: '', page: 1 }))).getStatus()).toBe(409);
});
it('WAND-SVC-013b: the saved connection names its instance', async () => {
expect(await setup(savedRow()).service.instanceOf(1)).toBe('https://trails.example');
});
it('WAND-SVC-014: search lists only the own trails, each as a list row', async () => {
const { service, client } = setup(savedRow());
client.search.mockResolvedValueOnce({
hits: [
hit({
author_name: 'maurice',
domain: 'trails.example',
description: '',
location: 'Alps',
duration: 0,
difficulty: 1,
public: true,
polyline: '_p~iF~ps|U_ulLnnqC',
}),
hit({ id: 'trail2', author_name: 'julia' }),
hit({ id: 'trail3', difficulty: 9 }),
],
total: 3,
});
const found = await service.search(1, { q: 'ridge', page: 2, bbox: undefined });
expect(client.search).toHaveBeenCalledWith(expect.anything(), { q: 'ridge', page: 2, bbox: undefined }, ['actor1']);
expect(found.total).toBe(3);
const [first, second, third] = found.trails;
expect(first).toMatchObject({
author: 'maurice@trails.example',
description: null,
location: 'Alps',
durationS: null,
difficulty: 'moderate',
public: true,
externalUrl: 'https://trails.example/trail/view/@maurice%40trails.example/trail1',
});
expect(first.line.length).toBeGreaterThan(1);
expect(second).toMatchObject({ author: 'julia', line: [] });
expect(third).toMatchObject({
author: null,
difficulty: null,
public: false,
externalUrl: 'https://trails.example/trail/view/@/trail3',
});
});
it.each([
['not_found', 404, 0],
['forbidden', 403, 0],
['unauthorized', 502, 1],
['unreachable', 502, 0],
] as const)(
'WAND-SVC-015: an upstream %s answers %i, noted on the connection %i times',
async (code, status, notes) => {
const { service, client, connections } = setup(savedRow());
client.ownActors.mockRejectedValueOnce(new WandererError(code, code));
expect((await refusal(service.search(1, { q: '', page: 1 }))).getStatus()).toBe(status);
expect(connections.recordCheck).toHaveBeenCalledTimes(notes);
},
);
it.each(['someone-else', null])('WAND-SVC-016: download refuses a trail whose author is %s', async (author) => {
const { service, client } = setup(savedRow());
client.trail.mockResolvedValueOnce({ id: 'trail1', name: 'Ridge', author });
expect((await refusal(service.download(1, 'trail1'))).getStatus()).toBe(403);
expect(client.gpx).not.toHaveBeenCalled();
});
it('WAND-SVC-017: download hands over the own trail with its GPX', async () => {
const { service } = setup(savedRow());
expect(await service.download(1, 'trail1')).toEqual({
instance: 'https://trails.example',
trail: { id: 'trail1', name: 'Ridge', author: 'actor1' },
gpx: '<gpx/>',
});
});
it('WAND-SVC-018: findTrail answers the row, null when the search has none, and maps a failure', async () => {
const found = setup(savedRow());
expect(await found.service.findTrail(1, 'trail1')).toMatchObject({ id: 'trail1', name: 'Ridge' });
const missing = setup(savedRow());
missing.client.searchById.mockResolvedValueOnce(null as unknown as WandererSearchHit);
expect(await missing.service.findTrail(1, 'trail1')).toBeNull();
const failing = setup(savedRow());
failing.client.searchById.mockRejectedValueOnce(new WandererError('not_found', 'gone'));
expect((await refusal(failing.service.findTrail(1, 'trail1'))).getStatus()).toBe(404);
});
it('WAND-SVC-019: a failure that is not wanderer answering is rethrown as it is', async () => {
const { service, client } = setup(savedRow());
client.trail.mockRejectedValueOnce(new TypeError('bug'));
await expect(service.download(1, 'trail1')).rejects.toThrow(TypeError);
});
});