diff --git a/server/src/nest/tours/tours-wanderer.controller.ts b/server/src/nest/tours/tours-wanderer.controller.ts index c83d27a53..52c462f6f 100644 --- a/server/src/nest/tours/tours-wanderer.controller.ts +++ b/server/src/nest/tours/tours-wanderer.controller.ts @@ -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') diff --git a/server/tests/e2e/wanderer.e2e.test.ts b/server/tests/e2e/wanderer.e2e.test.ts index 108ae4843..42bbab261 100644 --- a/server/tests/e2e/wanderer.e2e.test.ts +++ b/server/tests/e2e/wanderer.e2e.test.ts @@ -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); diff --git a/server/tests/unit/db/repositories/WandererConnections.repository.test.ts b/server/tests/unit/db/repositories/WandererConnections.repository.test.ts new file mode 100644 index 000000000..094373b53 --- /dev/null +++ b/server/tests/unit/db/repositories/WandererConnections.repository.test.ts @@ -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([]); + }); +}); diff --git a/server/tests/unit/nest/tours.service.test.ts b/server/tests/unit/nest/tours.service.test.ts index c2bbe11c8..ff9094f43 100644 --- a/server/tests/unit/nest/tours.service.test.ts +++ b/server/tests/unit/nest/tours.service.test.ts @@ -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>, + ) { + 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); + }); +}); diff --git a/server/tests/unit/nest/wanderer.client.test.ts b/server/tests/unit/nest/wanderer.client.test.ts index 8ed570025..856498218 100644 --- a/server/tests/unit/nest/wanderer.client.test.ts +++ b/server/tests/unit/nest/wanderer.client.test.ts @@ -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'); diff --git a/server/tests/unit/nest/wanderer.service.test.ts b/server/tests/unit/nest/wanderer.service.test.ts new file mode 100644 index 000000000..889df226e --- /dev/null +++ b/server/tests/unit/nest/wanderer.service.test.ts @@ -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>; + +function savedRow(over: Partial> = {}): NonNullable { + 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; +} + +function hit(over: Partial = {}): 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(''); + return { service: t.get(WandererService), connections, client, audit }; +} + +async function refusal(promise: Promise): Promise { + 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: '', + }); + }); + + 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); + }); +});