mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
ed5769fc89
Attribution first, because this branch argues the point itself: the card names the sources with the comment that naming them is a licence obligation rather than decoration, and the copy beside it promises that every field says where it came from. The POI path then stamped Overture rows as OpenStreetMap. Overture is not OpenStreetMap — it carries OSM among other sources under other licences — so that is a wrong attribution rather than a rounded one. The wire contract keeps `source` an open string and nothing on the client switches on it, so saying `trek-places` costs nothing. The MCP tool description no longer calls itself an OpenStreetMap tool either, and says which source answered. Adding one place cost two round trips to the index. The details lookup fetches the place and its answer carries the description; toPlaceRecord dropped that field, so the enrichment fetched the same place again a moment later to read it. It is carried through now and read from what the caller already has, with the second lookup left as the fallback for when there is nothing to read. That description's source URL becomes an href on the client and was taken on `typeof === 'string'` alone. It now goes through the same allow-list a place's website goes through — the file already does this for menu_url, with the comment explaining why — so anything but http(s) loses the link and keeps the text. The instance token was hashed from raw APP_URL, which the shipped compose file leaves commented out. Every default install therefore sent the same token, and a token every caller shares is not a caller: one instance running a large import would rate-limit all the others, who would fall back to Nominatim with nothing to point at. getAppUrl() falls through to ALLOWED_ORIGINS and then to the port, so unconfigured instances still differ. Two client-side leaks. A pick made out of the offline cache was recorded into the shadow corpus, where it moved the two figures the corpus exists to measure — and offline is a setting, so the network is usually reachable and the row landed. And the prefetch never re-checked the session after its request came back: a logout in that window repoints the handle at the anonymous database, so one person's trip area was written where the next person's offline search reads. The cache path also ignored the abort signal, so an answer for a keystroke two keystrokes old could overwrite a newer suggestion list. It rejects the way both surfaces already know to swallow. Left alone, and said in a comment rather than hidden: the index cannot localise. The Overpass path picks name:<lang> and falls back to int_name; the service has no language parameter at all, so a German reader exploring Tokyo gets the Japanese primary names on this path.
116 lines
4.1 KiB
TypeScript
116 lines
4.1 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { http, HttpResponse } from 'msw'
|
|
import { server } from '../../tests/helpers/msw/server'
|
|
import { useAuthStore } from '../store/authStore'
|
|
import { recordPlacePick } from './placeShadow'
|
|
import type { PlaceShadowPickRequest } from '@trek/shared'
|
|
|
|
/**
|
|
* The point of this module is that it never gets in the way: it must not fire
|
|
* when the instance has the log switched off, must not throw when the request
|
|
* fails, and must not send coordinates finer than the ranking needs.
|
|
*/
|
|
|
|
const PICK: PlaceShadowPickRequest = {
|
|
query: ' losteria rostock ',
|
|
lang: 'de',
|
|
biasLat: 54.088712,
|
|
biasLng: 12.140493,
|
|
source: 'search:nominatim',
|
|
liveRank: 0,
|
|
liveCount: 8,
|
|
pickedName: "L'Osteria",
|
|
pickedLat: 54.0891234,
|
|
pickedLng: 12.1372987,
|
|
pickedPlaceId: 'osm:node/1',
|
|
}
|
|
|
|
/** Resolves with the body the module posted, or null if it never posted. */
|
|
function capture(status = 200) {
|
|
let seen: unknown = null
|
|
let resolve: (v: unknown) => void = () => {}
|
|
const posted = new Promise<unknown>(r => { resolve = r })
|
|
server.use(
|
|
http.post('/api/place-shadow/pick', async ({ request }) => {
|
|
seen = await request.json()
|
|
resolve(seen)
|
|
return status === 200
|
|
? HttpResponse.json({ recorded: true })
|
|
: HttpResponse.json({ error: 'nope' }, { status })
|
|
}),
|
|
)
|
|
return { posted, seen: () => seen }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
useAuthStore.setState({ placeShadowEnabled: true })
|
|
})
|
|
|
|
describe('recordPlacePick', () => {
|
|
it('sends nothing at all when the instance has the log switched off', async () => {
|
|
useAuthStore.setState({ placeShadowEnabled: false })
|
|
const cap = capture()
|
|
recordPlacePick(PICK)
|
|
await new Promise(r => setTimeout(r, 20))
|
|
expect(cap.seen()).toBeNull()
|
|
})
|
|
|
|
it('trims the query and rounds every coordinate to three decimals', async () => {
|
|
const cap = capture()
|
|
recordPlacePick(PICK)
|
|
expect(await cap.posted).toMatchObject({
|
|
query: 'losteria rostock',
|
|
pickedLat: 54.089,
|
|
pickedLng: 12.137,
|
|
biasLat: 54.089,
|
|
biasLng: 12.14,
|
|
})
|
|
})
|
|
|
|
it('leaves an absent bias absent rather than sending a zero', async () => {
|
|
const cap = capture()
|
|
recordPlacePick({ ...PICK, biasLat: undefined, biasLng: undefined })
|
|
const body = (await cap.posted) as Record<string, unknown>
|
|
expect(body.biasLat).toBeUndefined()
|
|
expect(body.biasLng).toBeUndefined()
|
|
})
|
|
|
|
it('drops a pick with no query, no name or an impossible rank before sending', async () => {
|
|
const cap = capture()
|
|
recordPlacePick({ ...PICK, query: ' ' })
|
|
recordPlacePick({ ...PICK, pickedName: '' })
|
|
recordPlacePick({ ...PICK, liveRank: 8, liveCount: 8 })
|
|
recordPlacePick({ ...PICK, pickedLat: Number.NaN })
|
|
await new Promise(r => setTimeout(r, 20))
|
|
expect(cap.seen()).toBeNull()
|
|
})
|
|
|
|
it('does not record a pick made out of the offline cache', async () => {
|
|
// Such a row describes the local cache's ordering, not a provider's, and
|
|
// the two figures this corpus exists for — how often the live answer was
|
|
// first, and how often it was in the top five — are averaged over every
|
|
// row. Mixing these in moves exactly the number a candidate index is later
|
|
// judged against. Not merely an offline concern either: offline is a
|
|
// setting, so the network is usually reachable and the POST would land.
|
|
let posted = false
|
|
server.use(http.post('/api/place-shadow/pick', () => { posted = true; return HttpResponse.json({ recorded: true }) }))
|
|
|
|
recordPlacePick({ ...PICK, source: 'search:offline-cache' })
|
|
recordPlacePick({ ...PICK, source: 'autocomplete:offline-cache' })
|
|
await new Promise(r => setTimeout(r, 20))
|
|
|
|
expect(posted).toBe(false)
|
|
})
|
|
|
|
it('swallows a server error instead of surfacing it to the pick that triggered it', async () => {
|
|
const cap = capture(500)
|
|
const onUnhandled = vi.fn()
|
|
process.on('unhandledRejection', onUnhandled)
|
|
expect(() => recordPlacePick(PICK)).not.toThrow()
|
|
await cap.posted
|
|
await new Promise(r => setTimeout(r, 20))
|
|
process.off('unhandledRejection', onUnhandled)
|
|
expect(onUnhandled).not.toHaveBeenCalled()
|
|
})
|
|
})
|