diff --git a/client/src/api/dawarich.test.ts b/client/src/api/dawarich.test.ts new file mode 100644 index 000000000..3230b5823 --- /dev/null +++ b/client/src/api/dawarich.test.ts @@ -0,0 +1,406 @@ +/** + * FE-DAWARICH-API-001 to FE-DAWARICH-API-019 + * + * `dawarichApi` is one line per endpoint: a verb, a URL, and the shape of what + * goes into the body or the query string. There is no logic in it to get wrong, + * which is exactly why a mistake here is so quiet: a wrong path or a dropped + * query parameter does not throw, it hands the layer above an empty answer, and + * the panel three levels up simply shows nothing. Every method is therefore + * driven through MSW and its verb, path and payload pinned. + * + * Three things here matter more than the URL bookkeeping: + * + * - **`offset`.** Recorded points are bare instants, so the reader's own UTC + * offset is the one fact the server cannot derive. Both track calls send it, + * and they send it negated: `getTimezoneOffset()` counts minutes *behind* + * local time, the wire wants minutes *ahead* of UTC. Were it to stop being + * sent, or to be sent unsigned, an evening east of UTC would land on the + * previous day of the trip and nothing would fail anywhere. The line would + * just be drawn on the wrong day, which is the whole reason the parameter + * exists. Hence two zones rather than one: a single European fixture cannot + * tell a negation from a magnitude. + * - **The failure reaches the caller intact.** `useDawarichConnection` and + * `useDawarichSuggestions` both translate `err.response.data.code` into the + * reader's language. A `.catch()` added anywhere in this file would turn + * every upstream failure into a silent success with an undefined body. + * - **An abort really cancels.** The track overlay issues a fresh request + * whenever the looked-at window changes and aborts the previous one; a call + * that resolved anyway would repaint the map with the older answer. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { http, HttpResponse, delay, type JsonBodyType } from 'msw' +import { server } from '../../tests/helpers/msw/server' +import { dawarichApi } from './dawarich' +import type { + DawarichAtlasSuggestions, + DawarichBucketScan, + DawarichConnection, + DawarichSuggestion, + DawarichSuggestionList, + DawarichTrack, +} from '@trek/shared' + +const BASE = '/api/integrations/dawarich' + +let requestUrl = '' +let requestBody: unknown + +/** Records the url and the parsed JSON body of the intercepted request, then answers with `data`. */ +function record(data: T) { + return async ({ request }: { request: Request }) => { + requestUrl = request.url + const text = await request.text() + requestBody = text ? JSON.parse(text) : undefined + return HttpResponse.json(data) + } +} + +function query(): URLSearchParams { + return new URL(requestUrl).searchParams +} + +const connection: DawarichConnection = { + url: 'https://dawarich.example', + apiKeyMasked: '••••••••', + allowInsecureTls: false, + syncEnabled: true, + connected: true, + lastSyncAt: '2026-09-13T21:00:00Z', + lastSyncState: 'ok', + lastSyncError: null, + capabilities: null, +} + +const suggestion: DawarichSuggestion = { + id: 41, + sourceVisitId: 'v-41', + tripId: 7, + tripTitle: 'Koeln', + name: 'Cafe Reichard', + lat: 50.94, + lng: 6.96, + startedAt: '2026-09-10T10:15:00+02:00', + endedAt: '2026-09-10T12:40:00+02:00', + durationMinutes: 145, + localDate: '2026-09-10', + sourceStatus: 'suggested', + confidence: 82, + confidenceBand: 'high', + state: 'new', + target: null, + acceptedPlaceId: null, + acceptedJournalEntryId: null, + acceptedBucketListItemId: null, + sourceChanged: false, + sourceMissing: false, + matchedBucketListItemId: null, + matchedBucketListName: null, + countryCode: 'DE', + firstSeenAt: '2026-09-10T06:00:00Z', + lastSeenAt: '2026-09-10T06:00:00Z', +} + +const suggestionList: DawarichSuggestionList = { + suggestions: [suggestion], + connected: true, + lastSyncAt: '2026-09-13T21:00:00Z', + lastSyncState: 'ok', + lastSyncError: null, +} + +const track: DawarichTrack = { + days: [{ date: '2026-09-10', segments: [] }], + source: 'tracks', + fetchedAt: '2026-09-13T21:05:00Z', + pointCount: 0, + truncated: false, +} + +const bucketScan: DawarichBucketScan = { + matches: [{ itemId: 3, name: 'Uluru', match: null, alreadyVisited: false }], + skippedWithoutCoordinates: 1, + truncated: false, + fetchedAt: '2026-09-13T21:05:00Z', +} + +const atlas: DawarichAtlasSuggestions = { + countries: [{ countryCode: 'DE', sourceName: 'Germany', cities: [], alreadyVisited: false }], + unresolved: ['Kosovo'], + fetchedAt: '2026-09-13T21:05:00Z', +} + +beforeEach(() => { + requestUrl = '' + requestBody = undefined + // Berlin in summer. localOffsetMinutes() negates getTimezoneOffset(), so the + // value on the wire is +120 (positive for a zone ahead of UTC), which is the + // sign convention the server's day resolution expects. + vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(-120) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('dawarichApi connection', () => { + it('FE-DAWARICH-API-001: getSettings() reads the connection card and unwraps the body', async () => { + server.use(http.get(`${BASE}/settings`, record(connection))) + + const res = await dawarichApi.getSettings() + + expect(requestUrl).toContain(`${BASE}/settings`) + expect(res).toEqual(connection) + }) + + // The three warning fields are why this endpoint does not just answer + // `{ success }`: the server writes its sentence in English, the client renders + // the code in the reader's language and quotes the resolved address back. + // Dropping any of them leaves a private-IP connection looking perfectly healthy. + it('FE-DAWARICH-API-002: saveSettings() puts the settings and keeps the warning triple', async () => { + server.use( + http.put( + `${BASE}/settings`, + record({ + success: true, + warning: 'That host resolves to a private address.', + warningCode: 'private_ip', + warningIp: '192.168.178.72', + }), + ), + ) + + const res = await dawarichApi.saveSettings({ + url: 'https://dawarich.lan', + apiKey: 'secret', + allowInsecureTls: true, + syncEnabled: false, + }) + + expect(requestBody).toEqual({ + url: 'https://dawarich.lan', + apiKey: 'secret', + allowInsecureTls: true, + syncEnabled: false, + }) + expect(res.warningCode).toBe('private_ip') + expect(res.warningIp).toBe('192.168.178.72') + expect(res.warning).toContain('private address') + }) + + it('FE-DAWARICH-API-003: disconnect() deletes the stored connection', async () => { + server.use(http.delete(`${BASE}/settings`, record({ success: true }))) + + expect(await dawarichApi.disconnect()).toEqual({ success: true }) + expect(requestUrl).toContain(`${BASE}/settings`) + }) + + // A probe runs before anything is stored, so the body is deliberately partial: + // a url with no key is the shape the connect dialog sends on its first attempt. + it('FE-DAWARICH-API-004: test() posts a partial settings body and returns the probe verdict', async () => { + server.use(http.post(`${BASE}/test`, record({ connected: false, error: 'unauthorized' }))) + + const res = await dawarichApi.test({ url: 'https://dawarich.example' }) + + expect(requestBody).toEqual({ url: 'https://dawarich.example' }) + expect(res.connected).toBe(false) + expect(res.error).toBe('unauthorized') + }) + + // `alreadyRunning` is how a second press of Sync Now is told apart from a sync + // that genuinely found nothing; both report created/updated/missing as zero. + it('FE-DAWARICH-API-005: syncNow() posts to the sync endpoint and passes alreadyRunning through', async () => { + server.use(http.post(`${BASE}/sync`, record({ state: 'ok', created: 0, updated: 0, missing: 0, alreadyRunning: true }))) + + const res = await dawarichApi.syncNow() + + expect(requestUrl).toContain(`${BASE}/sync`) + expect(res.alreadyRunning).toBe(true) + }) +}) + +describe('dawarichApi suggestions', () => { + it('FE-DAWARICH-API-006: listSuggestions() sends the filter as query params, and none when unfiltered', async () => { + server.use(http.get(`${BASE}/suggestions`, record(suggestionList))) + + const res = await dawarichApi.listSuggestions({ tripId: 7, state: 'new' }) + expect(query().get('tripId')).toBe('7') + expect(query().get('state')).toBe('new') + expect(res.suggestions).toHaveLength(1) + + await dawarichApi.listSuggestions() + expect(new URL(requestUrl).search).toBe('') + }) + + it('FE-DAWARICH-API-007: accept() posts the corrected stay to the suggestion it belongs to', async () => { + server.use( + http.post( + `${BASE}/suggestions/:id/accept`, + record({ suggestion, createdPlaceId: 900, createdJournalEntryId: null, bucketListItemId: null }), + ), + ) + + const res = await dawarichApi.accept(41, { target: 'place', tripId: 7, dayId: 12, name: 'Cafe Reichard', time: '10:15' }) + + expect(requestUrl).toContain(`${BASE}/suggestions/41/accept`) + expect(requestBody).toEqual({ target: 'place', tripId: 7, dayId: 12, name: 'Cafe Reichard', time: '10:15' }) + expect(res.createdPlaceId).toBe(900) + }) + + it('FE-DAWARICH-API-008: setState() puts the new state on the suggestion', async () => { + server.use(http.put(`${BASE}/suggestions/:id/state`, record({ ...suggestion, state: 'dismissed' }))) + + const res = await dawarichApi.setState(41, 'dismissed') + + expect(requestUrl).toContain(`${BASE}/suggestions/41/state`) + expect(requestBody).toEqual({ state: 'dismissed' }) + expect(res.state).toBe('dismissed') + }) +}) + +describe('dawarichApi bucket list', () => { + it('FE-DAWARICH-API-009: scanBucketList() posts to the scan endpoint', async () => { + server.use(http.post(`${BASE}/bucket-list/scan`, record(bucketScan))) + + const res = await dawarichApi.scanBucketList() + + expect(requestUrl).toContain(`${BASE}/bucket-list/scan`) + expect(res.skippedWithoutCoordinates).toBe(1) + }) + + // `visitedAt` is optional because the default is "whenever the recording says", + // which only the server knows. An omitted one must not turn into a null on the + // wire, which the server would read as an instruction to store no date at all. + it('FE-DAWARICH-API-010: confirmBucketVisits() sends visitedAt only when the caller supplied one', async () => { + server.use(http.post(`${BASE}/bucket-list/confirm`, record({ updated: 2 }))) + + const res = await dawarichApi.confirmBucketVisits([3, 4], '2026-09-10T10:15:00Z') + expect(requestBody).toEqual({ itemIds: [3, 4], visitedAt: '2026-09-10T10:15:00Z' }) + expect(res.updated).toBe(2) + + await dawarichApi.confirmBucketVisits([3]) + expect(requestBody).toEqual({ itemIds: [3] }) + }) + + it('FE-DAWARICH-API-011: clearBucketVisit() deletes the visit off one entry', async () => { + server.use(http.delete(`${BASE}/bucket-list/:id/visit`, record({ success: true }))) + + expect(await dawarichApi.clearBucketVisit(3)).toEqual({ success: true }) + expect(requestUrl).toContain(`${BASE}/bucket-list/3/visit`) + }) +}) + +describe('dawarichApi atlas', () => { + it('FE-DAWARICH-API-012: atlasSuggestions() sends the window as from/to', async () => { + server.use(http.get(`${BASE}/atlas/suggestions`, record(atlas))) + + const res = await dawarichApi.atlasSuggestions('2026-01-01', '2026-12-31') + + expect(query().get('from')).toBe('2026-01-01') + expect(query().get('to')).toBe('2026-12-31') + expect(res.unresolved).toEqual(['Kosovo']) + }) + + it('FE-DAWARICH-API-013: acceptAtlasCountries() posts the codes the user ticked', async () => { + server.use(http.post(`${BASE}/atlas/accept`, record({ marked: 2 }))) + + const res = await dawarichApi.acceptAtlasCountries(['DE', 'NL']) + + expect(requestBody).toEqual({ countryCodes: ['DE', 'NL'] }) + expect(res.marked).toBe(2) + }) +}) + +describe('dawarichApi track overlay', () => { + it('FE-DAWARICH-API-014: tripTrack() sends the range plus the reader\'s own UTC offset', async () => { + server.use(http.get(`${BASE}/trips/:id/track`, record(track))) + + const res = await dawarichApi.tripTrack(7, { from: '2026-09-10', to: '2026-09-12' }) + + expect(new URL(requestUrl).pathname).toBe(`${BASE}/trips/7/track`) + expect(query().get('from')).toBe('2026-09-10') + expect(query().get('to')).toBe('2026-09-12') + expect(query().get('offset')).toBe('120') + expect(res.source).toBe('tracks') + }) + + // The whole-trip case passes no range at all. The offset still has to be there: + // without it the server has to guess a day boundary for every recorded instant. + it('FE-DAWARICH-API-015: tripTrack() without a range still sends the offset and nothing else', async () => { + server.use(http.get(`${BASE}/trips/:id/track`, record(track))) + + await dawarichApi.tripTrack(7) + + expect([...query().keys()]).toEqual(['offset']) + expect(query().get('offset')).toBe('120') + }) + + it('FE-DAWARICH-API-016: windowTrack() sends from, to and the offset to the window endpoint', async () => { + server.use(http.get(`${BASE}/track`, record(track))) + + const res = await dawarichApi.windowTrack('2026-09-10', '2026-09-12') + + expect(new URL(requestUrl).pathname).toBe(`${BASE}/track`) + expect(query().get('from')).toBe('2026-09-10') + expect(query().get('to')).toBe('2026-09-12') + expect(query().get('offset')).toBe('120') + expect(res.pointCount).toBe(0) + }) + + // The health handler is here because the axios error interceptor probes + // reachability whenever an error carries no response, and a cancellation is + // exactly that. Answering the probe keeps the detour short; whichever verdict + // it comes back with, the cancellation has to be re-rejected untouched, and + // that is what this pins. + it('FE-DAWARICH-API-017: an aborted windowTrack rejects as a cancellation, not as a result', async () => { + server.use( + http.get('/api/health', () => HttpResponse.json({ status: 'ok' })), + http.get(`${BASE}/track`, async () => { + await delay(50) + return HttpResponse.json(track) + }), + ) + + const ctrl = new AbortController() + const pending = dawarichApi.windowTrack('2026-09-10', '2026-09-12', ctrl.signal) + ctrl.abort() + + await expect(pending).rejects.toMatchObject({ code: 'ERR_CANCELED' }) + }) + + // Nothing in this file catches, on purpose: the connection card and the + // suggestions hook both read the upstream reason off `response.data.code` to + // say it in the reader's language. A swallowed rejection would reach them as a + // successful call carrying an undefined body. + it('FE-DAWARICH-API-018: a 502 rejects with the upstream reason code intact', async () => { + server.use( + http.get(`${BASE}/suggestions`, () => + HttpResponse.json({ error: 'Dawarich is unreachable', code: 'unreachable' }, { status: 502 }), + ), + ) + + await expect(dawarichApi.listSuggestions()).rejects.toMatchObject({ + response: { status: 502, data: { code: 'unreachable' } }, + }) + }) + + // Every other test here runs in Berlin, where -120 negated and -120 made + // positive look identical. New York separates them: the platform reports +300 + // and the wire has to carry -300. A `Math.abs` slipped in would leave every + // European install working perfectly and put every American one a day out, + // which is the kind of bug that gets reported as "sometimes wrong". + // + // UTC is the second half of the pair: the offset there is falsy, and a guard + // that only sends it "when there is one" would drop it exactly for the readers + // whose day boundary the server would then have to guess. + it('FE-DAWARICH-API-019: the offset is the negation of getTimezoneOffset, not its magnitude', async () => { + server.use(http.get(`${BASE}/track`, record(track))) + + vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(300) + await dawarichApi.windowTrack('2026-09-10', '2026-09-12') + expect(query().get('offset')).toBe('-300') + + vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(0) + await dawarichApi.windowTrack('2026-09-10', '2026-09-12') + expect(query().get('offset')).toBe('0') + }) +}) diff --git a/client/src/components/Dawarich/DawarichAcceptDialog.test.tsx b/client/src/components/Dawarich/DawarichAcceptDialog.test.tsx new file mode 100644 index 000000000..9ec20f1d9 --- /dev/null +++ b/client/src/components/Dawarich/DawarichAcceptDialog.test.tsx @@ -0,0 +1,527 @@ +// FE-DAWARICH-ACCEPT-001 to FE-DAWARICH-ACCEPT-024 +/** + * The review step between a detector's guess and a row in somebody's trip. + * + * This dialog is the only thing standing between Dawarich's visit detector and + * real data, and almost all of its behaviour is invisible until it is wrong: + * which fields are prefilled, which of them are sent back, and which are left + * out because the user did not actually change them. The server takes the body + * at face value (an accidental `name` or `date` in it silently overrides the + * recording), so "only what was corrected travels" is the contract worth + * pinning, and it is the one a refactor breaks first. + * + * The rest is the shape of the three targets. `place` needs a trip and offers a + * day, `journal` needs a journey and asks for a story, `bucket_list` needs a + * matched wish and hides the clock entirely because ticking a wish off uses the + * recorded timestamp and nothing else. Each of the three can be in a state + * where there is nothing to write to (no trips, no journeys, no matched wish), + * and in each of those the confirm button has to be inert rather than sending a + * body the server will reject with a 400. + * + * The seeding is keyed on the stay, not on the target, and one live instance + * serves all three rails. That makes two things true at once that are easy to + * get wrong together: what the user typed survives a switch from the place rail + * to the journal rail, and the trip and day they chose under the place rail + * must not travel in the journal body they then confirm. + * + * Both shells are covered: the phone gets an MSheet with its own footer, the + * desktop a Modal, and the fields in between are the same element tree. A + * regression that dropped one shell would otherwise only show up on a device. + */ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { fireEvent } from '@testing-library/react' +import type { DawarichSuggestion } from '@trek/shared' +import { render, screen } from '../../../tests/helpers/render' +import DawarichAcceptDialog, { type AcceptTargetOption } from './DawarichAcceptDialog' + +// The one thing the dialog reads about the device. A hoisted flag rather than a +// resize: useIsPhone is matchMedia-driven and the global stub always answers false. +const phone = vi.hoisted(() => ({ value: false })) +vi.mock('../../mobile/useIsPhone', () => ({ useIsPhone: () => phone.value })) + +/** + * A stay as the suggestions endpoint hands it over. The offsets are real ones: + * `clockOf` slices the local wall clock straight out of the string rather than + * converting it, so a fixture in UTC would quietly test a different code path + * than the one production sees. + */ +function stay(over: Partial = {}): DawarichSuggestion { + return { + id: 1, + sourceVisitId: '1', + tripId: null, + tripTitle: null, + name: 'Cafe Reichard', + lat: 50.94, + lng: 6.96, + startedAt: '2026-09-10T10:15:00+02:00', + endedAt: '2026-09-10T12:40:00+02:00', + durationMinutes: 145, + localDate: '2026-09-10', + sourceStatus: 'suggested', + confidence: null, + confidenceBand: null, + state: 'new', + target: null, + acceptedPlaceId: null, + acceptedJournalEntryId: null, + acceptedBucketListItemId: null, + sourceChanged: false, + sourceMissing: false, + matchedBucketListItemId: null, + matchedBucketListName: null, + countryCode: null, + firstSeenAt: '2026-09-10T06:00:00Z', + lastSeenAt: '2026-09-10T06:00:00Z', + ...over, + } +} + +const TRIPS: AcceptTargetOption[] = [ + { id: 31, label: 'Rhineland' }, + { id: 32, label: 'Alps' }, +] + +// Distinct labels per trip so "the day list followed the trip" is observable +// without reading ids out of the DOM. +const DAYS: Record = { + 31: [ + { id: 401, label: 'Day 1', badge: '10 Sept' }, + { id: 402, label: 'Day 2', badge: '11 Sept' }, + ], + 32: [{ id: 501, label: 'Alpine day', badge: '01 Oct' }], +} +const daysForTrip = (tripId: number): AcceptTargetOption[] => DAYS[tripId] ?? [] + +const JOURNALS: AcceptTargetOption[] = [ + { id: 71, label: 'Rhine notebook' }, + { id: 72, label: 'Alpine notebook' }, +] + +type Props = React.ComponentProps + +function open(props: Partial = {}) { + const onConfirm = vi.fn() + const onCancel = vi.fn() + const all: Props = { suggestion: stay(), target: 'place', ...props, onConfirm, onCancel } + const view = render() + return { + ...view, + onConfirm, + onCancel, + /** Re-render with the same props bar the ones named: the parent's own update path. */ + update: (next: Partial) => view.rerender(), + } +} + +/** Open a CustomSelect by its trigger, then pick the option with this label. */ +function pick(triggerName: string | RegExp, optionLabel: string): void { + fireEvent.click(screen.getByRole('button', { name: triggerName })) + fireEvent.click(screen.getByText(optionLabel)) +} + +/** Type into the date field's keyboard entry, which takes ISO as-is. */ +function typeDate(iso: string): void { + fireEvent.click(screen.getByRole('button', { name: 'Enter date manually' })) + const input = screen.getByLabelText('Enter date manually') + fireEvent.change(input, { target: { value: iso } }) + fireEvent.keyDown(input, { key: 'Enter' }) +} + +beforeEach(() => { + phone.value = false +}) + +describe('DawarichAcceptDialog', () => { + it('FE-DAWARICH-ACCEPT-001: nothing is on screen while no stay is pending', () => { + open({ suggestion: null, trips: TRIPS }) + // The panel portals to document.body, so "empty container" proves nothing: + // the absence of the title and the confirm button does. + expect(screen.queryByText('Add this stay as a place')).toBeNull() + expect(screen.queryByRole('button', { name: 'Add place' })).toBeNull() + }) + + it('FE-DAWARICH-ACCEPT-002: every field opens prefilled from the recording', () => { + open({ trips: TRIPS, daysForTrip }) + + expect(screen.getByRole('heading', { name: 'Add this stay as a place' })).toBeInTheDocument() + expect(screen.getByLabelText('Name')).toHaveValue('Cafe Reichard') + // The wall clock out of the offset the recording carries, not UTC. + expect(screen.getByLabelText('Arrived')).toHaveValue('10:15') + expect(screen.getByLabelText('Left')).toHaveValue('12:40') + expect(screen.getByLabelText('Notes')).toHaveValue('') + expect(screen.getByText(/^Recorded /)).toBeInTheDocument() + expect(screen.getByText('145 min')).toBeInTheDocument() + // No trip of its own, so the first trip the user may write to. + expect(screen.getByRole('button', { name: 'Rhineland' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Not on a day yet' })).toBeInTheDocument() + }) + + it('FE-DAWARICH-ACCEPT-003: an untouched stay sends only the target, its trip and the recorded clock', () => { + const { onConfirm } = open({ suggestion: stay({ tripId: 32 }), trips: TRIPS, daysForTrip }) + + fireEvent.click(screen.getByRole('button', { name: 'Add place' })) + + // No name, no notes, no date: the server falls back to the recording for + // each of those, and repeating them would turn every accept into an edit. + expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 32, time: '10:15', endTime: '12:40' }) + }) + + it('FE-DAWARICH-ACCEPT-004: every correction travels, the day included', () => { + const { onConfirm } = open({ trips: TRIPS, daysForTrip }) + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Café Reichard am Dom' } }) + fireEvent.change(screen.getByLabelText('Notes'), { target: { value: 'Two hours and a lot of cake.' } }) + typeDate('2026-09-12') + fireEvent.change(screen.getByLabelText('Arrived'), { target: { value: '09:00' } }) + fireEvent.change(screen.getByLabelText('Left'), { target: { value: '11:45' } }) + pick('Not on a day yet', 'Day 1') + + fireEvent.click(screen.getByRole('button', { name: 'Add place' })) + + expect(onConfirm).toHaveBeenCalledWith({ + target: 'place', + tripId: 31, + dayId: 401, + name: 'Café Reichard am Dom', + notes: 'Two hours and a lot of cake.', + date: '2026-09-12', + time: '09:00', + endTime: '11:45', + }) + }) + + it('FE-DAWARICH-ACCEPT-005: a name or note that says nothing new is left out of the body', () => { + const { onConfirm } = open({ trips: TRIPS, daysForTrip }) + const untouched = { target: 'place', tripId: 31, time: '10:15', endTime: '12:40' } + + // Emptied entirely: the recording's own name is what should survive. + fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' ' } }) + fireEvent.change(screen.getByLabelText('Notes'), { target: { value: ' ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add place' })) + expect(onConfirm).toHaveBeenLastCalledWith(untouched) + + // Retyped with padding around it: after trimming this is the same name, and + // sending it would write a "correction" nobody made. + fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Cafe Reichard ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add place' })) + expect(onConfirm).toHaveBeenLastCalledWith(untouched) + }) + + it('FE-DAWARICH-ACCEPT-006: unplanned is an answer, not a missing one', () => { + const { onConfirm } = open({ trips: TRIPS, daysForTrip }) + + pick('Not on a day yet', 'Day 1') + // The date rides along as the trigger's badge rather than inside its label. + expect(screen.getByRole('button', { name: /Day 1/ })).toHaveTextContent('10 Sept') + + // Back to "not on a day yet": the sentinel option has to clear the choice + // rather than be stored as a day id of its own. + pick(/Day 1/, 'Not on a day yet') + + fireEvent.click(screen.getByRole('button', { name: 'Add place' })) + expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 31, time: '10:15', endTime: '12:40' }) + }) + + it('FE-DAWARICH-ACCEPT-007: changing the trip drops the day that belonged to the old one', () => { + const { onConfirm } = open({ trips: TRIPS, daysForTrip }) + + pick('Not on a day yet', 'Day 2') + pick('Rhineland', 'Alps') + + // Day 2 is a day of the Rhineland trip; keeping it would attach the place to + // somebody else's day, which the server refuses with a 400. + expect(screen.getByRole('button', { name: 'Not on a day yet' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Not on a day yet' })) + expect(screen.getByText('Alpine day')).toBeInTheDocument() + expect(screen.queryByText('Day 2')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Add place' })) + expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 32, time: '10:15', endTime: '12:40' }) + }) + + it('FE-DAWARICH-ACCEPT-008: the trip falls back from the stay to the first writable one to nothing', () => { + // The stay's own trip wins: it is the one whose dates the recording fell into. + const own = open({ suggestion: stay({ tripId: 32 }), trips: TRIPS, daysForTrip }) + expect(screen.getByRole('button', { name: 'Alps' })).toBeInTheDocument() + own.unmount() + + const first = open({ trips: TRIPS, daysForTrip }) + expect(screen.getByRole('button', { name: 'Rhineland' })).toBeInTheDocument() + first.unmount() + + // No trip at all: the picker admits it and the confirm is inert, because a + // place with no trip is a 400 rather than a partial success. + open({ trips: [], daysForTrip }) + expect(screen.getByRole('button', { name: 'Choose a trip' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add place' })).toBeDisabled() + }) + + it('FE-DAWARICH-ACCEPT-009: with no day resolver the day picker offers only "not on a day yet"', () => { + // The trip rail passes a resolver; an Atlas-side or MCP-shaped caller need + // not, and the picker has to survive that rather than throw. + open({ trips: TRIPS }) + + fireEvent.click(screen.getByRole('button', { name: 'Not on a day yet' })) + expect(screen.getAllByText('Not on a day yet')).toHaveLength(2) + expect(screen.queryByText('Day 1')).toBeNull() + }) + + it('FE-DAWARICH-ACCEPT-010: the journal target asks for a story and sends the chosen journey', () => { + const { onConfirm } = open({ target: 'journal', journals: JOURNALS }) + + expect(screen.getByRole('heading', { name: 'Write a journal entry' })).toBeInTheDocument() + // A story gets room to be one; notes on a place do not need five rows. + expect(screen.getByLabelText('Your story')).toHaveAttribute('rows', '5') + expect(screen.getByText('Add photos to the entry after it is created.')).toBeInTheDocument() + // No trip or day rail here: the entry belongs to a journey, not to a day. + expect(screen.queryByText('Day')).toBeNull() + expect(screen.queryByText('Trip')).toBeNull() + + fireEvent.change(screen.getByLabelText('Your story'), { target: { value: 'Cake, then the cathedral.' } }) + pick('Rhine notebook', 'Alpine notebook') + fireEvent.click(screen.getByRole('button', { name: 'Add entry' })) + + expect(onConfirm).toHaveBeenCalledWith({ + target: 'journal', + journalId: 72, + notes: 'Cake, then the cathedral.', + time: '10:15', + endTime: '12:40', + }) + }) + + it('FE-DAWARICH-ACCEPT-011: with no journey to write to, the journal confirm is inert', () => { + open({ target: 'journal' }) + + expect(screen.getByRole('button', { name: 'Choose a journal' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add entry' })).toBeDisabled() + }) + + it('FE-DAWARICH-ACCEPT-012: ticking a wish off states the rule and sends the matched wish', () => { + const { onConfirm } = open({ + target: 'bucket_list', + suggestion: stay({ matchedBucketListItemId: 88, matchedBucketListName: 'Museum Ludwig' }), + }) + + expect(screen.getByRole('heading', { name: 'Tick off a wish' })).toBeInTheDocument() + // Why a stay at a café is being offered against a museum: the rule, spelled out. + expect(screen.getByText(/On your wishlist: Museum Ludwig/)).toHaveTextContent('within 250 m and after 20 minutes') + // Nothing here is editable but the wish it ticks off: the visit's own + // timestamp is what gets stored, so a clock or a note would be theatre. + expect(screen.queryByLabelText('Arrived')).toBeNull() + expect(screen.queryByLabelText('Left')).toBeNull() + expect(screen.queryByLabelText('Notes')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Tick it off' })) + expect(onConfirm).toHaveBeenCalledWith({ + target: 'bucket_list', + bucketListItemId: 88, + time: '10:15', + endTime: '12:40', + }) + }) + + it('FE-DAWARICH-ACCEPT-013: a stay that matched no wish cannot be ticked off', () => { + open({ target: 'bucket_list', suggestion: stay() }) + + expect(screen.getByRole('button', { name: 'Tick it off' })).toBeDisabled() + expect(screen.queryByText(/On your wishlist/)).toBeNull() + }) + + it('FE-DAWARICH-ACCEPT-014: a write already in flight blocks a second one', () => { + // Everything is answered, so only `busy` can be holding the button, which is + // the point: double-accepting a stay creates two places out of one recording. + open({ trips: TRIPS, daysForTrip, busy: true }) + + expect(screen.getByRole('button', { name: 'Add place' })).toBeDisabled() + }) + + it('FE-DAWARICH-ACCEPT-015: backing out writes nothing, by button or by Escape', () => { + const { onCancel, onConfirm } = open({ trips: TRIPS, daysForTrip }) + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + fireEvent.keyDown(document, { key: 'Escape' }) + + expect(onCancel).toHaveBeenCalledTimes(2) + expect(onConfirm).not.toHaveBeenCalled() + }) + + it("FE-DAWARICH-ACCEPT-016: the source's own verdict is shown only while it is unconfirmed", () => { + const unconfirmed = open({ trips: TRIPS, suggestion: stay({ sourceStatus: 'suggested' }) }) + expect(screen.getByText('Detected, unconfirmed')).toBeInTheDocument() + unconfirmed.unmount() + + // Confirmed in Dawarich: saying so would be noise, the badge is simply gone. + open({ trips: TRIPS, suggestion: stay({ sourceStatus: 'confirmed' }) }) + expect(screen.queryByText('Detected, unconfirmed')).toBeNull() + }) + + it('FE-DAWARICH-ACCEPT-017: only the bands Dawarich actually emits are shown, and only "low" warns', () => { + const low = open({ trips: TRIPS, suggestion: stay({ confidenceBand: 'low', confidence: 20 }) }) + expect(screen.getByText('Uncertain detection').parentElement?.className).toContain('text-warning') + low.unmount() + + const high = open({ trips: TRIPS, suggestion: stay({ confidenceBand: 'high', confidence: 90 }) }) + // A confident detection is a fact, not a warning. Same badge, neutral tone. + expect(screen.getByText('Confident detection').parentElement?.className).toContain('text-content-secondary') + high.unmount() + + const none = open({ trips: TRIPS, suggestion: stay({ confidenceBand: null }) }) + expect(screen.queryByText(/detection$/)).toBeNull() + none.unmount() + + // A band a later Dawarich invents: nothing at all, rather than a raw key. + open({ trips: TRIPS, suggestion: stay({ confidenceBand: 'certain', confidence: 100 }) }) + expect(screen.queryByText(/dawarich\.confidence/)).toBeNull() + expect(screen.queryByText(/detection$/)).toBeNull() + }) + + it('FE-DAWARICH-ACCEPT-018: a recording with no readable clock or date sends neither', () => { + // Dawarich has shipped date-only and non-ISO timestamps; the clock parser + // answers with an empty string rather than a guess, and an empty string must + // not reach the body as `time: ""`. + const { onConfirm } = open({ + trips: TRIPS, + daysForTrip, + suggestion: stay({ startedAt: '2026-09-10', endedAt: '2026-09-10', localDate: '' }), + }) + + expect(screen.getByLabelText('Arrived')).toHaveValue('') + expect(screen.getByLabelText('Left')).toHaveValue('') + expect(screen.getByRole('button', { name: 'Date' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Add place' })) + expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 31 }) + }) + + it('FE-DAWARICH-ACCEPT-019: the fields follow the stay, and survive a re-render of the same one', () => { + const { update } = open({ trips: TRIPS, daysForTrip }) + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Edited by hand' } }) + + // A fresh object for the same row: a parent re-render, not a new decision. + // Re-seeding here would eat what the user typed mid-sentence. + update({ suggestion: stay() }) + expect(screen.getByLabelText('Name')).toHaveValue('Edited by hand') + + update({ + suggestion: stay({ + id: 2, + name: 'Kölner Dom', + tripId: 32, + localDate: '2026-09-11', + startedAt: '2026-09-11T08:00:00+02:00', + endedAt: '2026-09-11T09:30:00+02:00', + }), + }) + expect(screen.getByLabelText('Name')).toHaveValue('Kölner Dom') + expect(screen.getByLabelText('Arrived')).toHaveValue('08:00') + expect(screen.getByRole('button', { name: 'Alps' })).toBeInTheDocument() + }) + + it('FE-DAWARICH-ACCEPT-020: on a phone it is a sheet, and its footer confirms', () => { + phone.value = true + const { onConfirm } = open({ trips: TRIPS, daysForTrip }) + + expect(screen.getByRole('dialog', { name: 'Add this stay as a place' })).toBeInTheDocument() + // The desktop modal's own heading is the tell that the wrong shell rendered. + expect(screen.queryByRole('heading', { name: 'Add this stay as a place' })).toBeNull() + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Reichard' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add place' })) + + expect(onConfirm).toHaveBeenCalledWith({ + target: 'place', + tripId: 31, + name: 'Reichard', + time: '10:15', + endTime: '12:40', + }) + }) + + it('FE-DAWARICH-ACCEPT-021: the phone sheet backs out from its header and its footer, and blocks an impossible write', () => { + phone.value = true + const { onCancel, onConfirm } = open({ target: 'journal' }) + + expect(screen.getByRole('button', { name: 'Add entry' })).toBeDisabled() + + // Two ways out of the sheet, both the same door: the header's close circle + // and the footer's cancel pill. + const exits = screen.getAllByRole('button', { name: 'Cancel' }) + expect(exits).toHaveLength(2) + exits.forEach(button => fireEvent.click(button)) + + expect(onCancel).toHaveBeenCalledTimes(2) + expect(onConfirm).not.toHaveBeenCalled() + }) + + it('FE-DAWARICH-ACCEPT-022: the name stays editable on the wishlist rail, and a correction travels', () => { + // The clock, the date and the notes are all hidden for a wish, which is + // exactly why the one field that survives is worth naming: the detector's + // guess at a place name is what a tick-off is filed under, and "hide + // everything that is not the wish" is the tidy-up that would take it away. + const { onConfirm } = open({ + target: 'bucket_list', + suggestion: stay({ matchedBucketListItemId: 88, matchedBucketListName: 'Museum Ludwig' }), + }) + + expect(screen.getByLabelText('Name')).toHaveValue('Cafe Reichard') + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Museum Ludwig' } }) + fireEvent.click(screen.getByRole('button', { name: 'Tick it off' })) + + expect(onConfirm).toHaveBeenCalledWith({ + target: 'bucket_list', + bucketListItemId: 88, + name: 'Museum Ludwig', + time: '10:15', + endTime: '12:40', + }) + }) + + it('FE-DAWARICH-ACCEPT-023: a new stay arrives with no day chosen, whatever the last one had', () => { + const { onConfirm, update } = open({ trips: TRIPS, daysForTrip }) + + pick('Not on a day yet', 'Day 2') + + // Same trip, so the day list does not change and a stale `dayId` would stay + // a legal value, which is the dangerous version of this bug: the second + // stay is filed silently on the day somebody picked for the first. + update({ suggestion: stay({ id: 3, name: 'Kölner Dom', tripId: 31 }) }) + expect(screen.getByRole('button', { name: 'Not on a day yet' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Add place' })) + expect(onConfirm).toHaveBeenCalledWith({ target: 'place', tripId: 31, time: '10:15', endTime: '12:40' }) + }) + + it('FE-DAWARICH-ACCEPT-024: switching rails keeps what was typed and leaks no trip into the journal body', () => { + const { onConfirm, update } = open({ trips: TRIPS, daysForTrip }) + + fireEvent.change(screen.getByLabelText('Notes'), { target: { value: 'Cake, then the cathedral.' } }) + pick('Not on a day yet', 'Day 1') + + // Backing out of "add as a place" and choosing "write a journal entry" on + // the same row is one instance and one stay, so nothing is re-seeded. + update({ target: 'journal', journals: JOURNALS }) + expect(screen.getByLabelText('Your story')).toHaveValue('Cake, then the cathedral.') + + // The journal was never chosen, because the seeding runs off the stay and + // the stay has not changed. Offering a confirm here would send a body with + // no journalId in it. + expect(screen.getByRole('button', { name: 'Choose a journal' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add entry' })).toBeDisabled() + + pick('Choose a journal', 'Rhine notebook') + fireEvent.click(screen.getByRole('button', { name: 'Add entry' })) + + // Trip 31 and day 401 are still held in state. A journal entry belongs to a + // journey, and the server reads the body at face value. + expect(onConfirm).toHaveBeenCalledWith({ + target: 'journal', + journalId: 71, + notes: 'Cake, then the cathedral.', + time: '10:15', + endTime: '12:40', + }) + }) +}) diff --git a/client/src/components/Dawarich/DawarichAtlasDialog.test.tsx b/client/src/components/Dawarich/DawarichAtlasDialog.test.tsx index a1aff35ee..033640387 100644 --- a/client/src/components/Dawarich/DawarichAtlasDialog.test.tsx +++ b/client/src/components/Dawarich/DawarichAtlasDialog.test.tsx @@ -1,8 +1,35 @@ -// FE-DAWARICH-ATLASDLG-001 to FE-DAWARICH-ATLASDLG-009 +// FE-DAWARICH-ATLASDLG-001 to FE-DAWARICH-ATLASDLG-029 +/** + * The room where Dawarich's two Atlas questions get answered. + * + * Nothing in here runs on its own, and that is the whole design: the Atlas is a + * curated list that carries tombstones precisely so a country somebody removed + * stays removed, so every read and every write starts with a person pressing a + * button. What is worth pinning is therefore not "does it fetch" but everything + * arranged around the fetch. + * + * - **What is written, and dated when.** A wish is ticked off on the day it was + * reached, not on today, and wishes reached on the same day travel in one + * request. A regression here either back-dates somebody's list wrongly or + * turns one confirmation into twenty requests. + * - **The five states each half can be in.** Unasked, in flight, answered, + * answered with nothing, answered with caveats. The confirm button is + * rendered or absent rather than merely greyed out, so each state has to + * decide whether that control exists at all, and the caveats under the list + * are only said when there is something to say. + * - **Refusing to write blind.** Offline is not a server error: nothing was + * sent, and "something went wrong talking to Dawarich" for a train tunnel + * sends people hunting for a bug in their own instance. + * - **Both shells.** The phone gets an MSheet with its own footer, the desktop + * a Modal; the body between them is the same tree, so a regression that + * dropped one shell would otherwise only surface on a device. + */ import React from 'react' -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, waitFor } from '../../../tests/helpers/render' -import { fireEvent } from '@testing-library/react' +import { act, fireEvent } from '@testing-library/react' +import { setForcedOffline } from '../../sync/networkMode' +import { DawarichOfflineError } from '../../repo/dawarichRepo' import DawarichAtlasDialog from './DawarichAtlasDialog' const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() } @@ -31,6 +58,11 @@ vi.mock('../../api/dawarich', () => ({ }, })) +// The one thing the dialog reads about the device. A hoisted flag rather than a +// resize: useIsPhone is matchMedia-driven and the global stub always answers false. +const phone = vi.hoisted(() => ({ value: false })) +vi.mock('../../mobile/useIsPhone', () => ({ useIsPhone: () => phone.value })) + const SCAN = { matches: [ { @@ -61,11 +93,77 @@ const COUNTRIES = { fetchedAt: '2026-09-12T10:00:00Z', } +/** + * Three wishes reached on two days. The pair sharing a timestamp is the whole + * point of the fixture: the dialog groups by the date it is about to write, and + * a regression that lost the grouping would send one request per wish. The + * third one sits far enough away to be reported in kilometres rather than + * metres, which is the other half of the row's arithmetic. + */ +const SAME_DAY = { + matches: [ + { + itemId: 21, + name: 'Van Gogh Museum', + match: { at: '2026-08-02T11:00:00Z', minutes: 95, distanceMeters: 30, points: 40 }, + alreadyVisited: false, + }, + { + itemId: 22, + name: 'Rijksmuseum', + match: { at: '2026-08-02T11:00:00Z', minutes: 120, distanceMeters: 55, points: 51 }, + alreadyVisited: false, + }, + { + itemId: 23, + name: 'Kinderdijk', + match: { at: '2026-07-30T09:00:00Z', minutes: 70, distanceMeters: 1400, points: 22 }, + alreadyVisited: false, + }, + ], + skippedWithoutCoordinates: 0, + truncated: false, + fetchedAt: '2026-09-12T10:00:00Z', +} + +/** + * A reading with nothing to apologise for: no unmatched names, one country with + * more cities than fit beside a flag and one with none at all. + */ +const MORE_COUNTRIES = { + countries: [ + { + countryCode: 'FR', + sourceName: 'France', + cities: ['Paris', 'Lyon', 'Nice', 'Nantes'].map(name => ({ + name, + minutes: 120, + lastSeenAt: '2026-08-02T10:00:00Z', + })), + alreadyVisited: false, + }, + { countryCode: 'BE', sourceName: 'Belgium', cities: [], alreadyVisited: false }, + ], + unresolved: [], + fetchedAt: '2026-09-12T10:00:00Z', +} + +/** A promise this test settles by hand, to pin down what is on screen mid-flight. */ +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(res => { + resolve = res + }) + return { promise, resolve } +} + function open(props: Partial> = {}) { return render() } beforeEach(() => { + phone.value = false + setForcedOffline(false) toast.success.mockReset() toast.error.mockReset() repo.bucketScan.mockReset().mockResolvedValue(SCAN) @@ -74,6 +172,10 @@ beforeEach(() => { api.acceptAtlasCountries.mockReset().mockResolvedValue({ marked: 1 }) }) +// Force-offline is module state, not component state: left on, it would leak +// into the next file's writes. +afterEach(() => setForcedOffline(false)) + describe('DawarichAtlasDialog', () => { it('FE-DAWARICH-ATLASDLG-001: asks nothing on its own — the scan runs when the reader says so', () => { open() @@ -168,4 +270,368 @@ describe('DawarichAtlasDialog', () => { await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Dawarich rejected the API key.')) expect(screen.getByRole('button', { name: 'Check wishlist' })).toBeInTheDocument() }) + + it('FE-DAWARICH-ATLASDLG-010: while the recordings are being read there are placeholders and nothing to press', async () => { + const scan = deferred() + repo.bucketScan.mockReturnValueOnce(scan.promise) + open() + + fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' })) + + expect(screen.getByText('Checking…')).toBeInTheDocument() + expect(screen.queryByRole('checkbox')).toBeNull() + // Neither control exists yet: there is nothing to confirm, and "check again" + // before the first answer is a second request for the same thing. + expect(screen.queryByRole('button', { name: /Tick off/ })).toBeNull() + expect(screen.queryByRole('button', { name: 'Check again' })).toBeNull() + + await act(async () => { + scan.resolve(SCAN) + }) + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument()) + }) + + it('FE-DAWARICH-ATLASDLG-011: a scan that found nothing says so in a sentence and offers no confirmation', async () => { + repo.bucketScan.mockResolvedValue({ ...SCAN, matches: [], skippedWithoutCoordinates: 0 }) + open() + + fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' })) + + await waitFor(() => + expect(screen.getByText('Nothing on your wishlist turned up in your recordings.')).toBeInTheDocument(), + ) + // Absent, not greyed out: a dead "Tick off 0" under an empty state only asks + // to be understood. + expect(screen.queryByRole('button', { name: /Tick off/ })).toBeNull() + // The rule stays on screen, because it is the answer to "why did nothing + // turn up": driving past a wish does not count as reaching it. + expect(screen.getByText(/A wish counts as reached within 250 m and after 20 minutes/)).toBeInTheDocument() + }) + + it('FE-DAWARICH-ATLASDLG-012: the caveats under a scan are only said when there are any', async () => { + repo.bucketScan.mockResolvedValue({ ...SCAN, skippedWithoutCoordinates: 0, truncated: true }) + open() + + fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' })) + + await waitFor(() => + expect(screen.getByText('Only the first entries were checked. Run it again for the rest.')).toBeInTheDocument(), + ) + // Nothing was skipped this time, so nothing is said about skipping: a + // "0 entries have no coordinates" line is noise dressed as information. + expect(screen.queryByText(/have no coordinates/)).toBeNull() + }) + + it('FE-DAWARICH-ATLASDLG-013: a country shows the first of its cities, one without any shows none', async () => { + repo.atlasSuggestions.mockResolvedValue(MORE_COUNTRIES) + open({ initialTab: 'countries' }) + + fireEvent.click(screen.getByRole('button', { name: 'Look for countries' })) + + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'France' })).toBeInTheDocument()) + const france = screen.getByRole('checkbox', { name: 'France' }) + // The badge counts all four; the line beside it names three, which is what + // fits beside a flag at the width a phone gives this dialog. + expect(france.textContent).toContain('4 cities') + expect(france.textContent).toContain('Paris, Lyon, Nice') + expect(france.textContent).not.toContain('Nantes') + expect(screen.getByRole('checkbox', { name: 'Belgium' }).textContent).not.toContain('cities') + // Everything matched, so the dialog does not mention matching at all. + expect(screen.queryByText(/could not match these to a country/)).toBeNull() + expect(screen.getByText('The last 12 months were looked at.')).toBeInTheDocument() + }) + + it('FE-DAWARICH-ATLASDLG-014: a reading with nothing new in it says so rather than showing an empty list', async () => { + // Germany alone, and the Atlas already has it. + repo.atlasSuggestions.mockResolvedValue({ ...COUNTRIES, countries: [COUNTRIES.countries[0]] }) + open({ initialTab: 'countries' }) + + fireEvent.click(screen.getByRole('button', { name: 'Look for countries' })) + + await waitFor(() => + expect(screen.getByText('Your recordings show no countries TREK does not already have.')).toBeInTheDocument(), + ) + expect(screen.queryByRole('button', { name: /Add \d+ countries/ })).toBeNull() + }) + + it('FE-DAWARICH-ATLASDLG-015: wishes reached on one day travel in one request, another day in its own', async () => { + repo.bucketScan.mockResolvedValue(SAME_DAY) + open() + + fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' })) + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Rijksmuseum' })).toBeInTheDocument()) + // Over a kilometre out is said in kilometres: "1400 m away" is a + // measurement, "1.4 km away" is how far it was. + expect(screen.getByRole('checkbox', { name: 'Kinderdijk' }).textContent).toContain('1.4 km away') + + fireEvent.click(screen.getByRole('button', { name: /Tick off 3/ })) + + await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledTimes(2)) + // Grouped by date, not ordered by it: the row order inside a day is the + // sort's business and nothing here depends on it. + const calls = api.confirmBucketVisits.mock.calls as Array<[number[], string]> + const byDate = new Map(calls.map(([ids, at]) => [at, [...ids].sort((a, b) => a - b)])) + expect(byDate.get('2026-08-02T11:00:00Z')).toEqual([21, 22]) + expect(byDate.get('2026-07-30T09:00:00Z')).toEqual([23]) + // Both answers counted, not just the last one to come back. + expect(toast.success).toHaveBeenCalledWith('2 wishes ticked off') + }) + + it('FE-DAWARICH-ATLASDLG-016: an unticked wish is left out of the write entirely', async () => { + repo.bucketScan.mockResolvedValue(SAME_DAY) + open() + + fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' })) + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Kinderdijk' })).toBeInTheDocument()) + + fireEvent.click(screen.getByRole('checkbox', { name: 'Kinderdijk' })) + fireEvent.click(screen.getByRole('checkbox', { name: 'Rijksmuseum' })) + fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ })) + + // One request, for one day, carrying one id. The day whose other wish was + // dropped does not travel half-empty, and the other day does not travel at all. + await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledTimes(1)) + expect(api.confirmBucketVisits).toHaveBeenCalledWith([21], '2026-08-02T11:00:00Z') + }) + + it('FE-DAWARICH-ATLASDLG-017: a row that was unticked can be put back', async () => { + open() + fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' })) + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument()) + + fireEvent.click(screen.getByRole('checkbox', { name: 'Museum Ludwig' })) + expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'false') + + fireEvent.click(screen.getByRole('checkbox', { name: 'Museum Ludwig' })) + + expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('button', { name: /Tick off 1/ })).toBeEnabled() + }) + + it('FE-DAWARICH-ATLASDLG-018: offline, a wish confirmation is refused before anything is sent', async () => { + open() + fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' })) + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument()) + + setForcedOffline(true) + fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ })) + + expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('TREK is offline right now')) + // Not attempted at all: a confirmation fired into a dead connection hangs to + // a timeout and then reports a server error for something no server saw. + expect(api.confirmBucketVisits).not.toHaveBeenCalled() + // And the selection survives, so pressing it again once there is signal is enough. + expect(screen.getByRole('button', { name: /Tick off 1/ })).toBeEnabled() + }) + + it('FE-DAWARICH-ATLASDLG-019: offline, a country confirmation is refused before anything is sent', async () => { + open({ initialTab: 'countries' }) + fireEvent.click(screen.getByRole('button', { name: 'Look for countries' })) + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument()) + + setForcedOffline(true) + fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ })) + + expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('TREK is offline right now')) + expect(api.acceptAtlasCountries).not.toHaveBeenCalled() + }) + + it('FE-DAWARICH-ATLASDLG-020: a write that failed leaves every row exactly as it was', async () => { + // A rejection with no `{ error }` envelope at all: a socket that died + // mid-request rather than a server that answered. + api.confirmBucketVisits.mockRejectedValue(new Error('socket hang up')) + const onChanged = vi.fn() + open({ onChanged }) + + fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' })) + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument()) + fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ })) + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Something went wrong talking to Dawarich.')) + // Nothing was marked off locally, so a second attempt still has something to + // send, and the Atlas is not asked to re-read for a write that never landed. + expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeEnabled() + expect(onChanged).not.toHaveBeenCalled() + }) + + it('FE-DAWARICH-ATLASDLG-021: a refusal carrying no readable reason still gets a sentence', async () => { + // An `{ error }` that is not a string is not a message: falling through to + // the generic sentence beats printing "[object Object]" at somebody. + api.acceptAtlasCountries.mockRejectedValue({ response: { data: { error: { code: 502 } } } }) + open({ initialTab: 'countries' }) + + fireEvent.click(screen.getByRole('button', { name: 'Look for countries' })) + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument()) + fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ })) + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Something went wrong talking to Dawarich.')) + expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toHaveAttribute('aria-checked', 'true') + }) + + it('FE-DAWARICH-ATLASDLG-022: a read that never left the device reads as a connection, not a failure', async () => { + // The repo refuses offline reads itself, and its refusal has to survive the + // trip through failureText as the connection message rather than as + // "something went wrong talking to Dawarich", which points at the instance. + repo.atlasSuggestions.mockRejectedValue(new DawarichOfflineError()) + open({ initialTab: 'countries' }) + + fireEvent.click(screen.getByRole('button', { name: 'Look for countries' })) + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('TREK is offline right now')), + ) + // Still unasked, so the button that asks is the one on screen. + expect(screen.getByRole('button', { name: 'Look for countries' })).toBeInTheDocument() + }) + + it('FE-DAWARICH-ATLASDLG-023: the other tab asks the other question, and each keeps its own answer', async () => { + open() + fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' })) + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument()) + + fireEvent.click(screen.getByRole('button', { name: /Countries/ })) + + // Switching tabs asks nothing on its own: a country reading costs an + // upstream request, and nobody asked for it by moving their eyes. + expect(repo.atlasSuggestions).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Look for countries' })) + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument()) + + fireEvent.click(screen.getByRole('button', { name: /Wishlist/ })) + + // The scan is still there, and was not repeated for the return trip. + expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument() + expect(repo.bucketScan).toHaveBeenCalledTimes(1) + }) + + it('FE-DAWARICH-ATLASDLG-024: "Check again" re-runs the scan and is inert while it runs', async () => { + open() + fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' })) + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument()) + + const second = deferred() + repo.bucketScan.mockReturnValueOnce(second.promise) + fireEvent.click(screen.getByRole('button', { name: 'Check again' })) + + // A second press while the first is in flight would run the whole scan twice: + // one upstream request per wish, for the same answer. + expect(screen.getByRole('button', { name: 'Check again' })).toBeDisabled() + + await act(async () => { + second.resolve(SCAN) + }) + + await waitFor(() => expect(screen.getByRole('button', { name: 'Check again' })).toBeEnabled()) + expect(repo.bucketScan).toHaveBeenCalledTimes(2) + }) + + it('FE-DAWARICH-ATLASDLG-025: "Check again" on the countries tab re-reads the countries, not the wishes', async () => { + open({ initialTab: 'countries' }) + fireEvent.click(screen.getByRole('button', { name: 'Look for countries' })) + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument()) + + fireEvent.click(screen.getByRole('button', { name: 'Check again' })) + + await waitFor(() => expect(repo.atlasSuggestions).toHaveBeenCalledTimes(2)) + expect(repo.bucketScan).not.toHaveBeenCalled() + // A year back from the moment it was asked, both times: the window is the + // same question asked again, not a widening one. + const calls = repo.atlasSuggestions.mock.calls as Array<[string, string]> + for (const [from, to] of calls) { + expect(Date.parse(to) - Date.parse(from)).toBe(365 * 24 * 60 * 60 * 1000) + } + expect(Date.parse(calls[1][0])).toBeGreaterThanOrEqual(Date.parse(calls[0][0])) + }) + + it('FE-DAWARICH-ATLASDLG-026: on a phone it is a sheet, and the count and the confirm live in its own footer', async () => { + phone.value = true + const onClose = vi.fn() + open({ onClose }) + + expect(screen.getByRole('dialog', { name: 'Dawarich' })).toBeInTheDocument() + // The desktop modal puts the title in a heading: its absence is the tell + // that the right shell rendered. + expect(screen.queryByRole('heading', { name: /Dawarich/ })).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' })) + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Museum Ludwig' })).toBeInTheDocument()) + + expect(screen.getByText('1 selected')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /Tick off 1/ })) + await waitFor(() => expect(api.confirmBucketVisits).toHaveBeenCalledWith([11], '2026-09-10T14:00:00Z')) + + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(onClose).toHaveBeenCalled() + }) + + it('FE-DAWARICH-ATLASDLG-027: a country that was just added stops being offered, and the Atlas is told', async () => { + const onChanged = vi.fn() + open({ initialTab: 'countries', onChanged }) + + fireEvent.click(screen.getByRole('button', { name: 'Look for countries' })) + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Netherlands' })).toBeInTheDocument()) + + fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ })) + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 countries added')) + // Marked in place rather than re-read: the reading costs an upstream request + // and the only thing that changed is what the reader just pressed. The row + // leaving the list is what says the write landed. + await waitFor(() => + expect(screen.getByText('Your recordings show no countries TREK does not already have.')).toBeInTheDocument(), + ) + expect(screen.queryByRole('checkbox', { name: 'Netherlands' })).toBeNull() + expect(screen.queryByRole('button', { name: /Add \d+ countries/ })).toBeNull() + expect(repo.atlasSuggestions).toHaveBeenCalledTimes(1) + // The Atlas keeps its own map and its own counts; without this the country + // it was just told about only appears there after a reload. + expect(onChanged).toHaveBeenCalledTimes(1) + }) + + it('FE-DAWARICH-ATLASDLG-028: a request that died without a reason at all still gets a sentence', async () => { + // An aborted request rejects with nothing: not an Error, not a response. + // Reaching into `.response.data` on that must not be the thing that takes + // the dialog down, because a crash here loses the scan the reader is + // looking at as well as the message they needed. + repo.bucketScan.mockRejectedValue(undefined) + open() + + fireEvent.click(screen.getByRole('button', { name: 'Check wishlist' })) + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Something went wrong talking to Dawarich.')) + expect(screen.getByRole('button', { name: 'Check wishlist' })).toBeInTheDocument() + }) + + it('FE-DAWARICH-ATLASDLG-029: a country can be unticked and put back, and only the ticked ones are written', async () => { + // Both offers start ticked, which is the useful default but not the answer + // every time: a border crossed on the way to somewhere else is a country + // Dawarich saw and the reader does not want in their Atlas. Taking one out + // has to actually take it out of the write, and changing one's mind has to + // put it back, because the Atlas carries tombstones and an unwanted country + // added here is removed by hand afterwards. + repo.atlasSuggestions.mockResolvedValue(MORE_COUNTRIES) + open({ initialTab: 'countries' }) + + fireEvent.click(screen.getByRole('button', { name: 'Look for countries' })) + await waitFor(() => expect(screen.getByRole('checkbox', { name: 'Belgium' })).toBeInTheDocument()) + expect(screen.getByRole('button', { name: /Add 2 countries/ })).toBeEnabled() + + fireEvent.click(screen.getByRole('checkbox', { name: 'Belgium' })) + expect(screen.getByRole('checkbox', { name: 'Belgium' })).toHaveAttribute('aria-checked', 'false') + + // Out and back in again: the tick is a toggle, not a one-way door. + fireEvent.click(screen.getByRole('checkbox', { name: 'France' })) + expect(screen.getByRole('button', { name: /Add 0 countries/ })).toBeDisabled() + fireEvent.click(screen.getByRole('checkbox', { name: 'France' })) + expect(screen.getByRole('checkbox', { name: 'France' })).toHaveAttribute('aria-checked', 'true') + + fireEvent.click(screen.getByRole('button', { name: /Add 1 countries/ })) + + // Belgium was never sent, so nothing has to be undone in the Atlas after. + await waitFor(() => expect(api.acceptAtlasCountries).toHaveBeenCalledWith(['FR'])) + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('1 countries added')) + // And it is still on offer, still unticked, for the trip it does belong to. + expect(screen.getByRole('checkbox', { name: 'Belgium' })).toHaveAttribute('aria-checked', 'false') + }) }) diff --git a/client/src/components/Dawarich/DawarichAtlasSidePanel.test.tsx b/client/src/components/Dawarich/DawarichAtlasSidePanel.test.tsx new file mode 100644 index 000000000..bb5a73cdc --- /dev/null +++ b/client/src/components/Dawarich/DawarichAtlasSidePanel.test.tsx @@ -0,0 +1,174 @@ +// FE-DAWARICH-ATLASPANEL-001 to FE-DAWARICH-ATLASPANEL-007 +/** + * Dawarich's own panel on the Atlas, beside the stats. + * + * The panel itself holds almost no logic, and the little it does hold is the + * kind that is invisible until it is wrong: + * + * - **It has to disappear completely when the addon is off.** Not empty, not a + * blank pane of glass in the row beside the stats: absent, so an Atlas + * without Dawarich looks exactly as it did before the integration landed. + * - **It has to ask the question that was pressed.** Two tiles, one dialog, one + * `initialTab` between them. Wiring both tiles to the same tab is a bug that + * looks like a working panel. + * - **It mounts the dialog on open and throws it away on close**, deliberately, + * so a second visit starts from a fresh question rather than from a scan + * somebody ran an hour ago. + * - **It is made of the same glass as the Atlas panel next to it.** The tint + * and the cursor glare are hand-written rgba rather than tokens (a solid + * surface token would punch an opaque hole in a panel floating over a blurred + * map), so nothing but a test stops the two panels drifting apart in the + * dark theme. + * + * The dialog is pinned by its own file and stands in for itself here, so the + * panel's decisions are observable without a scan and without mocking Dawarich. + */ +import React from 'react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { fireEvent } from '@testing-library/react' +import { render, screen } from '../../../tests/helpers/render' +import { useAddonStore } from '../../store/addonStore' +import DawarichAtlasSidePanel from './DawarichAtlasSidePanel' + +vi.mock('./DawarichAtlasDialog', () => ({ + default: ({ + initialTab, + onClose, + onChanged, + }: { + initialTab: string + onClose: () => void + onChanged?: () => void + }) => ( +
+ asked: {initialTab} + + +
+ ), +})) + +const ADDON = { id: 'dawarich', name: 'Dawarich', type: 'integration', icon: 'route', enabled: true } + +/** The glass the Atlas hands down, reduced to the one property jsdom can answer for. */ +const GLASS: React.CSSProperties = { borderRadius: 20 } + +/** Inline rgba comes back from jsdom respaced; nothing here cares about the spaces. */ +const rgba = (el: HTMLElement): string => el.style.background.replace(/\s/g, '') + +function mount(props: Partial> = {}) { + return render() +} + +beforeEach(() => { + useAddonStore.setState({ addons: [ADDON], loaded: true }) +}) + +afterEach(() => { + useAddonStore.setState({ addons: [], loaded: false }) +}) + +describe('DawarichAtlasSidePanel', () => { + it('FE-DAWARICH-ATLASPANEL-001: an Atlas without the addon gets no panel at all', () => { + useAddonStore.setState({ addons: [{ ...ADDON, enabled: false }] }) + + const { container } = mount() + + // Nothing rendered, rather than an empty pane of glass in the row: the + // panel is stretched to the height of the stats beside it, so an empty one + // would be a visible hole. + expect(container).toBeEmptyDOMElement() + expect(screen.queryByRole('button', { name: 'Wishlist' })).toBeNull() + }) + + it('FE-DAWARICH-ATLASPANEL-002: with the addon on it offers two questions and asks neither', () => { + const { container } = mount() + + expect(screen.getByText('Dawarich')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Wishlist' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Countries' })).toBeInTheDocument() + // A scan costs one upstream request per wish. Nothing is asked until asked. + expect(screen.queryByTestId('atlas-dialog')).toBeNull() + // And it wears the glass it was handed rather than one of its own. + expect(container.firstElementChild).toHaveStyle({ borderRadius: '20px' }) + }) + + it('FE-DAWARICH-ATLASPANEL-003: the wishlist tile opens the dialog on the wishlist, and closing puts it away', () => { + mount() + + fireEvent.click(screen.getByRole('button', { name: 'Wishlist' })) + + expect(screen.getByText('asked: wishes')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'dismiss' })) + + // Unmounted, not hidden: the next visit starts from a clean question rather + // than from an answer that is an hour old. + expect(screen.queryByTestId('atlas-dialog')).toBeNull() + }) + + it('FE-DAWARICH-ATLASPANEL-004: the countries tile opens the same dialog on the other question', () => { + mount() + + fireEvent.click(screen.getByRole('button', { name: 'Countries' })) + + expect(screen.getByText('asked: countries')).toBeInTheDocument() + expect(screen.queryByText('asked: wishes')).toBeNull() + }) + + it('FE-DAWARICH-ATLASPANEL-005: what the dialog wrote is reported up to the Atlas', () => { + // The Atlas keeps its own counts and its own map. Without this the confirmed + // countries only appear after a reload. + const onChanged = vi.fn() + mount({ onChanged }) + + fireEvent.click(screen.getByRole('button', { name: 'Countries' })) + fireEvent.click(screen.getByRole('button', { name: 'wrote something' })) + + expect(onChanged).toHaveBeenCalledTimes(1) + }) + + it('FE-DAWARICH-ATLASPANEL-006: the tiles are a tint of the light the panel is made of, per theme', () => { + const { rerender } = render() + const wishes = screen.getByRole('button', { name: 'Wishlist' }) + + expect(rgba(wishes)).toBe('rgba(0,0,0,0.05)') + fireEvent.mouseEnter(wishes) + expect(rgba(wishes)).toBe('rgba(0,0,0,0.09)') + fireEvent.mouseLeave(wishes) + expect(rgba(wishes)).toBe('rgba(0,0,0,0.05)') + + rerender() + + // Inverted on the dark map, not merely darkened: the tile is light added to + // the glass, and a black tint on a dark blurred map is an invisible button. + expect(rgba(screen.getByRole('button', { name: 'Wishlist' }))).toBe('rgba(255,255,255,0.07)') + }) + + it('FE-DAWARICH-ATLASPANEL-007: the glass glare follows the cursor and goes out behind it', () => { + const { container } = mount() + const panel = container.firstElementChild as HTMLElement + const [glare, borderGlare] = Array.from(panel.children) as HTMLElement[] + + expect(glare.style.opacity).toBe('0') + + fireEvent.mouseMove(panel, { clientX: 140, clientY: 60 }) + + // The pool of light has to sit under the pointer, not merely exist: the + // panel's box measures as the origin here, so the cursor's own coordinates + // are what should end up in the gradient. An effect that lit up in a fixed + // spot would pass an opacity-only assertion and look broken on screen. + expect(glare.style.background).toContain('at 140px 60px') + expect(glare.style.opacity).toBe('1') + expect(borderGlare.style.opacity).toBe('1') + + fireEvent.mouseLeave(panel) + + expect(glare.style.opacity).toBe('0') + expect(borderGlare.style.opacity).toBe('0') + }) +}) diff --git a/client/src/components/Dawarich/DawarichBadge.test.tsx b/client/src/components/Dawarich/DawarichBadge.test.tsx new file mode 100644 index 000000000..bbe3eb5fa --- /dev/null +++ b/client/src/components/Dawarich/DawarichBadge.test.tsx @@ -0,0 +1,172 @@ +// FE-DAWARICH-BADGE-001 to FE-DAWARICH-BADGE-007 +/** + * The one pill the whole Dawarich surface is drawn from, pinned prop by prop. + * + * It is nine lines of JSX, which is exactly why it is worth a test: the review + * list, the accept dialog and the Atlas dialog all render it, so a change here + * lands in three places at once and none of their tests would name it as the + * culprit. + * + * What is worth holding still, roughly in the order of what breaks worst: + * + * - **Icon-only means named.** `children === undefined` is the switch that + * turns a badge into a round dot of colour, and it is the same switch that + * adds `role="img"` and the accessible name. Lose the pairing and the low + * confidence flag, the "source gone" flag and the "source changed" flag all + * become unlabelled squares that a screen reader reads as nothing at all. + * - **`children === undefined`, not `!children`.** The day header counts its + * stays with the array length, so a falsy check would turn a legitimate + * count of zero into an unlabelled dot. + * - **The tone is the meaning.** Five tones, each a pair of theme tokens; a + * warning that quietly maps to the neutral pair still reads as a fact worth + * ignoring, which is the opposite of what it is there to say. + * - **The type scales with the reader's setting.** Both sizes go through + * `--fs-scale-caption`; a raw `fontSize: 11` would look identical in review + * and ignore the text-size preference entirely. + * - **A badge with something to add gets TREK's tooltip, one without gets + * none.** `title` wins over `label` as the hover text, so a pointer user can + * be told more than the accessible name says. + */ +import React from 'react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, act } from '../../../tests/helpers/render' +import { fireEvent } from '@testing-library/react' +import DawarichBadge from './DawarichBadge' + +type BadgeProps = React.ComponentProps + +// The clock goes in before anything renders, the way Tooltip's own suite does +// it: swapping timers out from under a mounted tree leaves whatever it already +// scheduled on the real clock. `shouldAdvanceTime` keeps real time moving, so +// the locale promise the render helper's provider starts still settles. +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +/** + * Stands in for a lucide icon. All the badge does with one is hand it a + * className, so that string is the only thing worth asserting about it. + */ +function StubIcon({ className }: { className?: string }) { + return +} + +function badge(props: BadgeProps) { + const view = render() + return { ...view, el: view.container.firstElementChild as HTMLElement } +} + +/** + * What the badge says on hover, or null when it has nothing to add and is + * therefore not wrapped in a tooltip at all. The tooltip opens on a 250 ms + * timer, so the clock has to be moved on by hand for either answer to be true + * rather than merely early. + */ +function hoverText(el: HTMLElement): string | null { + fireEvent.mouseEnter(el) + // One window covers both steps: the 250 ms open timer, and the effect that + // measures the trigger afterwards. Until that effect lands the portal is + // `visibility: hidden`, which is invisible to a role query as well as to a + // reader, so a half-flushed tooltip would read as no tooltip at all. + act(() => { vi.advanceTimersByTime(300) }) + return screen.queryByRole('tooltip')?.textContent ?? null +} + +describe('DawarichBadge', () => { + it('FE-DAWARICH-BADGE-001: the default pill is neutral, xs, and says nothing on hover', () => { + const { el } = badge({ children: '3 stays' }) + + expect(el).toHaveTextContent('3 stays') + expect(el).toHaveClass('h-[18px]', 'px-1.5', 'bg-surface-secondary', 'text-content-secondary') + // A pill that already reads "3 stays" needs no image role and no name of + // its own: both would make a screen reader announce the same thing twice. + expect(el).not.toHaveAttribute('role') + expect(el).not.toHaveAttribute('aria-label') + expect(el.style.fontSize).toBe('calc(10px * var(--fs-scale-caption, 1))') + expect(el.querySelector('[data-testid="badge-icon"]')).toBeNull() + // No title and no label, so there is nothing a tooltip could add. + expect(hoverText(el)).toBeNull() + }) + + it('FE-DAWARICH-BADGE-002: sm raises the box, the icon and the type together', () => { + const { el } = badge({ icon: StubIcon, size: 'sm', children: '2 h 25 min' }) + + expect(el).toHaveClass('h-[20px]', 'px-1.5') + expect(el.style.fontSize).toBe('calc(11px * var(--fs-scale-caption, 1))') + expect(screen.getByTestId('badge-icon').getAttribute('class')).toBe('w-3 h-3 flex-shrink-0') + // The text truncates and the glyph does not: a long name must run out of + // room before it squeezes the icon out of shape. + expect(el.querySelector('.truncate')).toHaveTextContent('2 h 25 min') + }) + + it('FE-DAWARICH-BADGE-003: an icon-only pill is square, named, and offers its name on hover', () => { + const { el } = badge({ icon: StubIcon, tone: 'warning', label: 'Low confidence' }) + + expect(el).toHaveAttribute('role', 'img') + expect(el).toHaveAttribute('aria-label', 'Low confidence') + expect(el).toHaveClass('w-[18px]', 'justify-center', 'bg-warning-soft', 'text-warning') + // Side padding would make the circle an oval, and there is no text to pad + // away from in the first place. + expect(el).not.toHaveClass('px-1.5') + expect(el.querySelector('.truncate')).toBeNull() + expect(screen.getByTestId('badge-icon').getAttribute('class')).toBe('w-2.5 h-2.5 flex-shrink-0') + // No title, so the accessible name doubles as the hover text and a pointer + // user is told exactly what a screen reader is told. + expect(hoverText(el)).toBe('Low confidence') + }) + + it('FE-DAWARICH-BADGE-004: the square grows with the size, so the dot stays a circle', () => { + const { el } = badge({ icon: StubIcon, size: 'sm', label: 'Source gone' }) + + expect(el).toHaveClass('h-[20px]', 'w-[20px]', 'justify-center') + expect(el).not.toHaveClass('w-[18px]') + }) + + it('FE-DAWARICH-BADGE-005: title is the hover text, label stays the name', () => { + const { el } = badge({ + icon: StubIcon, + tone: 'warning', + label: 'Low confidence', + title: 'Dawarich is not sure this stay happened', + }) + + // The two are not interchangeable: the name is what the badge is, the title + // is what it has to add, and a screen reader would drown in the long one. + expect(el).toHaveAttribute('aria-label', 'Low confidence') + expect(hoverText(el)).toBe('Dawarich is not sure this stay happened') + }) + + it('FE-DAWARICH-BADGE-006: every tone keeps its own pair of theme tokens', () => { + const tones: Array<[NonNullable, string[]]> = [ + ['neutral', ['bg-surface-secondary', 'text-content-secondary']], + ['quiet', ['bg-surface-secondary', 'text-content-faint']], + ['accent', ['bg-accent-subtle', 'text-accent-on']], + ['success', ['bg-success-soft', 'text-success']], + ['warning', ['bg-warning-soft', 'text-warning']], + ] + + for (const [tone, classes] of tones) { + const { el, unmount } = badge({ tone, children: tone }) + // Tokens rather than colour literals, or the badge stops following the + // user's scheme the moment they pick one. + expect(el).toHaveClass(...classes) + unmount() + } + }) + + it('FE-DAWARICH-BADGE-007: a zero is a child, not an absent one', () => { + // The day header counts its stays with the array length. Icon-only is + // `children === undefined` and not a falsy check for exactly this reason: a + // count of 0 that turned into an unlabelled dot would be a day that lost + // its number. + const { el } = badge({ tone: 'quiet', children: 0 }) + + expect(el).toHaveTextContent('0') + expect(el).not.toHaveAttribute('role') + expect(el).toHaveClass('px-1.5') + }) +}) diff --git a/client/src/components/Dawarich/DawarichSuggestionsPanel.test.tsx b/client/src/components/Dawarich/DawarichSuggestionsPanel.test.tsx new file mode 100644 index 000000000..cb3f29aec --- /dev/null +++ b/client/src/components/Dawarich/DawarichSuggestionsPanel.test.tsx @@ -0,0 +1,640 @@ +// FE-DAWARICH-PANEL-001 to FE-DAWARICH-PANEL-033 +/** + * The review panel both shells share, pinned end to end. + * + * `useDawarichSuggestions` is mocked rather than the network: the hook is + * already covered on its own, and driving the panel through six `status` + * values, a busy row and a refused accept via MSW would mean six round trips to + * assert one branch each. What is NOT mocked is anything the panel renders + * itself, `DawarichAcceptDialog` included, because the wiring between a row's + * button and that dialog's confirm is exactly the part that would rot silently. + * + * What is worth pinning here, roughly in the order of what breaks worst: + * + * - **The panel renders nothing when there is nothing to review.** A connected, + * quiet integration is meant to be invisible; the moment that early return + * goes, every trip in the app grows a permanent empty card explaining that + * Dawarich found nothing. + * - **`accept` is the only path that writes.** The dialog closes and the host + * is notified only when the hook says the write landed, so a refused accept + * has to leave the dialog standing with the user's corrections still in it. + * - **The two accept buttons are conditional on there being somewhere to put + * the stay.** The planner has no journey list and the journal has no trip + * list; offering the missing one opens an empty picker. + * - **A shrinking place count means somebody deleted what an acceptance + * created**, which puts the stay back into review on the server. If that + * effect stops firing, the row stays hidden until a full reload. + * - **The phone shell paints from `--m-*`.** Both shells run this one + * component, so every palette fork inside it is real behaviour that no + * mobile-screen test covers. + */ +import React from 'react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, act, within } from '../../../tests/helpers/render' +import { fireEvent } from '@testing-library/react' +import type { DawarichSuggestion } from '@trek/shared' +import type { Place } from '../../types' +import type { DawarichSuggestionsState } from '../../hooks/useDawarichSuggestions' +import { useTripStore } from '../../store/tripStore' +import DawarichSuggestionsPanel from './DawarichSuggestionsPanel' + +// The hook is the panel's whole world: status, rows, the busy row and the three +// mutations. Held in a `let` the factory reads lazily, so each test hands the +// panel a different world without re-importing the component. +let S: DawarichSuggestionsState +const seenFilter = vi.fn() +vi.mock('../../hooks/useDawarichSuggestions', () => ({ + useDawarichSuggestions: (filter?: { tripId?: number }) => { + seenFilter(filter) + return S + }, +})) + +// Both the panel and the accept dialog read this module, and the relative +// specifier resolves to the same file from either, so one mock flips the whole +// tree between the desktop tokens and the phone's own palette. +let phone = false +vi.mock('../../mobile/useIsPhone', () => ({ useIsPhone: () => phone })) + +function stay(over: Partial & { id: number; localDate: string }): DawarichSuggestion { + return { + id: over.id, + sourceVisitId: String(over.id), + tripId: over.tripId ?? null, + tripTitle: over.tripTitle ?? null, + name: over.name ?? 'Cafe Reichard', + lat: over.lat ?? 50.94, + lng: over.lng ?? 6.96, + startedAt: over.startedAt ?? `${over.localDate}T10:15:00+02:00`, + endedAt: over.endedAt ?? `${over.localDate}T12:40:00+02:00`, + durationMinutes: over.durationMinutes ?? 145, + localDate: over.localDate, + sourceStatus: over.sourceStatus ?? 'suggested', + confidence: over.confidence ?? null, + confidenceBand: over.confidenceBand ?? null, + state: over.state ?? 'new', + target: over.target ?? null, + acceptedPlaceId: over.acceptedPlaceId ?? null, + acceptedJournalEntryId: over.acceptedJournalEntryId ?? null, + acceptedBucketListItemId: over.acceptedBucketListItemId ?? null, + sourceChanged: over.sourceChanged ?? false, + sourceMissing: over.sourceMissing ?? false, + matchedBucketListItemId: over.matchedBucketListItemId ?? null, + matchedBucketListName: over.matchedBucketListName ?? null, + countryCode: over.countryCode ?? null, + firstSeenAt: over.firstSeenAt ?? '2026-09-10T06:00:00Z', + lastSeenAt: over.lastSeenAt ?? '2026-09-10T06:00:00Z', + } +} + +const accept = vi.fn<(id: number, body: unknown) => Promise>() +const dismiss = vi.fn<(id: number) => Promise>() +const restore = vi.fn<(id: number) => Promise>() +const reload = vi.fn() + +function hookState(over: Partial = {}): DawarichSuggestionsState { + return { + suggestions: [], + status: 'ready', + lastSyncAt: null, + lastSyncState: 'ok', + lastSyncError: null, + busyId: null, + reload, + accept, + dismiss, + restore, + ...over, + } +} + +/** Only the one field the panel's place-count effect reads. */ +function places(count: number): Place[] { + return Array.from({ length: count }, (_, i) => ({ id: i + 1 })) as unknown as Place[] +} + +const TRIPS = [{ id: 5, label: 'Cologne' }] +const JOURNALS = [{ id: 9, label: 'Rhine notes' }] + +function panel(props: Partial> = {}) { + return render() +} + +/** The collapsible header, which is also the only element carrying aria-expanded. */ +function header(): HTMLElement { + return screen.getByRole('button', { name: /From Dawarich/ }) +} + +/** The row card holding a given stay name, for scoping a query to one row. */ +function rowOf(container: HTMLElement, name: string): HTMLElement { + const row = within(container).getByText(name).closest('.items-stretch') + if (!row) throw new Error(`no row found for ${name}`) + return row as HTMLElement +} + +beforeEach(() => { + phone = false + S = hookState() + seenFilter.mockReset() + accept.mockReset().mockResolvedValue(true) + dismiss.mockReset().mockResolvedValue(undefined) + restore.mockReset().mockResolvedValue(undefined) + reload.mockReset() + useTripStore.setState({ places: [] }) +}) + +afterEach(() => { + useTripStore.setState({ places: [] }) +}) + +describe('DawarichSuggestionsPanel: whether it shows up at all', () => { + it('FE-DAWARICH-PANEL-001: an addon nobody switched on renders nothing', () => { + S = hookState({ status: 'idle' }) + const { container } = panel() + expect(container).toBeEmptyDOMElement() + }) + + it('FE-DAWARICH-PANEL-002: connected and quiet is invisible, not an empty card', () => { + // Read successfully, nothing pending and nothing in the history: the panel + // stays out of the rail rather than explaining that it found nothing. + S = hookState({ status: 'ready', suggestions: [] }) + const { container } = panel() + expect(container).toBeEmptyDOMElement() + }) + + it('FE-DAWARICH-PANEL-003: it does show up while it is still reading, before it knows', () => { + S = hookState({ status: 'loading', suggestions: [] }) + panel() + // Twice: once under the title, once in the opened body. + expect(screen.getAllByText('Reading Dawarich…')).toHaveLength(2) + expect(header()).toHaveAttribute('aria-expanded', 'true') + }) + + it('FE-DAWARICH-PANEL-004: a failed read is said in the danger colour, without the stale error under it', () => { + S = hookState({ status: 'unavailable', lastSyncError: 'Dawarich rejected the API key.' }) + const { container } = panel() + + const line = container.querySelector('p.text-danger') + expect(line).not.toBeNull() + expect(line).toHaveTextContent('Dawarich could not be read.') + // The sync error belongs above a list that did load. Beside "could not be + // read" it is the same news twice in two different wordings. + expect(screen.queryByText('Dawarich rejected the API key.')).toBeNull() + }) + + it('FE-DAWARICH-PANEL-005: not-connected and offline each get their own sentence', () => { + S = hookState({ status: 'disconnected' }) + const { unmount } = panel() + expect(screen.getAllByText('Connect Dawarich in Settings to see your stays here.')).toHaveLength(2) + unmount() + + S = hookState({ status: 'offline' }) + panel() + expect(screen.getAllByText('The recorded route needs a connection')).toHaveLength(2) + }) + + it('FE-DAWARICH-PANEL-006: freshness reads as a relative time, and says so when there is none', () => { + S = hookState({ + suggestions: [stay({ id: 1, localDate: '2026-09-10' })], + lastSyncAt: new Date(Date.now() - 120_000).toISOString(), + }) + const { unmount } = panel() + // "checked 2 minutes ago" answers the question; a stamp makes the reader + // do the subtraction. + expect(header()).toHaveTextContent(/checked 2 minutes ago/) + unmount() + + S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })], lastSyncAt: null }) + panel() + expect(header()).toHaveTextContent('Not checked yet') + }) + + it('FE-DAWARICH-PANEL-007: the trip filter is handed to the hook', () => { + S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] }) + panel({ tripId: 5 }) + expect(seenFilter).toHaveBeenCalledWith({ tripId: 5 }) + }) +}) + +describe('DawarichSuggestionsPanel: the list', () => { + it('FE-DAWARICH-PANEL-008: stays are grouped by day, newest day first, each day counted', () => { + S = hookState({ + suggestions: [ + stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig', startedAt: '2026-09-10T18:00:00+02:00' }), + stay({ id: 2, localDate: '2026-09-10', name: 'Cafe Reichard' }), + stay({ id: 3, localDate: '2026-09-11', name: 'Koelner Dom' }), + ], + }) + const { container } = panel({ trips: TRIPS }) + + const headings = Array.from(container.querySelectorAll('.uppercase')).map(node => node.textContent) + expect(headings).toEqual(['Fri, Sep 11', 'Thu, Sep 10']) + // The badge in the header repeats what the open list adds up to. + expect(header()).toHaveTextContent('3') + // The times, which are the only thing telling two stays on one day apart. + const row = rowOf(container, 'Cafe Reichard') + expect(row).toHaveTextContent('10:15') + expect(row).toHaveTextContent('12:40') + expect(row).toHaveTextContent('2 h 25 min') + }) + + it('FE-DAWARICH-PANEL-009: collapsed to start, and the header is the way back in', () => { + S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' })] }) + panel({ initiallyCollapsed: true, trips: TRIPS }) + + expect(header()).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('Museum Ludwig')).toBeNull() + + fireEvent.click(header()) + + expect(header()).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('Museum Ludwig')).toBeInTheDocument() + }) + + it('FE-DAWARICH-PANEL-010: bare drops the card and its header, and cannot be collapsed shut', () => { + S = hookState({ + suggestions: [stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' })], + lastSyncAt: null, + }) + // The phone journey opens this list inside a sheet that already carries a + // title, and `initiallyCollapsed` must not fold it away in there. + const { container } = panel({ bare: true, initiallyCollapsed: true, trips: TRIPS }) + + expect(screen.queryByRole('button', { name: /From Dawarich/ })).toBeNull() + expect(screen.getByText('Museum Ludwig')).toBeInTheDocument() + // Read the property rather than the attribute: what matters is that the + // card is gone, not how React chooses to serialise an empty className. + expect(container.querySelector('section')?.className).toBe('') + // The freshness line survives the missing header, as a plain paragraph. + expect(screen.getByText('Not checked yet')).toBeInTheDocument() + }) + + it('FE-DAWARICH-PANEL-011: a sync error rides above a list that did load', () => { + S = hookState({ + status: 'ready', + suggestions: [stay({ id: 1, localDate: '2026-09-10' })], + lastSyncError: 'Dawarich asked TREK to slow down. Try again shortly.', + }) + panel({ trips: TRIPS }) + expect(screen.getByText('Dawarich asked TREK to slow down. Try again shortly.')).toBeInTheDocument() + }) + + it('FE-DAWARICH-PANEL-012: a row carries the flags worth interrupting for, and gone beats changed', () => { + S = hookState({ + suggestions: [ + stay({ + id: 1, + localDate: '2026-09-10', + name: 'Museum Ludwig', + confidenceBand: 'low', + // Both set on purpose: a stay that vanished upstream may well have + // changed before it went, and saying both is two warnings about one + // row where only the fatal one matters. + sourceChanged: true, + sourceMissing: true, + }), + ], + }) + panel({ trips: TRIPS }) + + expect(screen.getByRole('img', { name: 'Unsure' })).toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Gone from Dawarich' })).toBeInTheDocument() + expect(screen.queryByRole('img', { name: 'Changed in Dawarich' })).toBeNull() + expect(screen.getByText(/no longer exists in Dawarich/)).toBeInTheDocument() + expect(screen.queryByText(/has changed in Dawarich/)).toBeNull() + }) + + it('FE-DAWARICH-PANEL-013: a stay that only changed says exactly that, in full', () => { + S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', sourceChanged: true })] }) + panel({ trips: TRIPS }) + + expect(screen.getByRole('img', { name: 'Changed in Dawarich' })).toBeInTheDocument() + expect(screen.getByText(/has changed in Dawarich since you used it/)).toBeInTheDocument() + }) + + it('FE-DAWARICH-PANEL-014: the trip name shows only where rows can come from more than one trip', () => { + const rows = [stay({ id: 1, localDate: '2026-09-10', tripId: 5, tripTitle: 'Cologne' })] + S = hookState({ suggestions: rows }) + const { unmount } = panel({ trips: TRIPS }) + expect(screen.getByText('Cologne')).toBeInTheDocument() + unmount() + + // Inside one trip's rail every row is from that trip, so the name is noise. + S = hookState({ suggestions: rows }) + panel({ tripId: 5, trips: TRIPS }) + expect(screen.queryByText('Cologne')).toBeNull() + }) + + it('FE-DAWARICH-PANEL-015: a stay with no trip behind it gets no empty badge', () => { + S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', tripId: null, tripTitle: null })] }) + const { container } = panel({ trips: TRIPS }) + // Two badges on the row, the clock and the duration, and nothing else. + expect(rowOf(container, 'Cafe Reichard').querySelectorAll('.rounded-full.font-semibold')).toHaveLength(2) + }) +}) + +describe('DawarichSuggestionsPanel: acting on a stay', () => { + it('FE-DAWARICH-PANEL-016: an action is offered only where there is somewhere to put the stay', () => { + S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] }) + const { unmount } = panel({ trips: [], journals: [] }) + + // No trips and no journeys: only "not a place I visited" is still an answer. + expect(screen.queryByRole('button', { name: 'Add as a place' })).toBeNull() + expect(screen.queryByRole('button', { name: 'Write a journal entry' })).toBeNull() + expect(screen.getByRole('button', { name: 'Not a place I visited' })).toBeInTheDocument() + unmount() + + S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] }) + panel({ trips: TRIPS, journals: JOURNALS }) + expect(screen.getByRole('button', { name: 'Add as a place' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Write a journal entry' })).toBeInTheDocument() + }) + + it('FE-DAWARICH-PANEL-017: dismissing hands the row id straight to the hook', () => { + S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] }) + panel({ trips: TRIPS }) + + fireEvent.click(screen.getByRole('button', { name: 'Not a place I visited' })) + + expect(dismiss).toHaveBeenCalledWith(42) + }) + + it('FE-DAWARICH-PANEL-018: the row being written is the row that goes dead, and only that one', () => { + S = hookState({ + suggestions: [ + stay({ id: 42, localDate: '2026-09-10', name: 'Museum Ludwig' }), + stay({ id: 43, localDate: '2026-09-11', name: 'Koelner Dom' }), + ], + busyId: 42, + }) + const { container } = panel({ trips: TRIPS }) + + expect(within(rowOf(container, 'Museum Ludwig')).getByRole('button', { name: 'Add as a place' })).toBeDisabled() + expect(within(rowOf(container, 'Koelner Dom')).getByRole('button', { name: 'Add as a place' })).toBeEnabled() + }) + + it('FE-DAWARICH-PANEL-019: accepting opens the review step and reports a write that landed', async () => { + const onAccepted = vi.fn() + const daysForTrip = vi.fn((tripId: number) => [{ id: 70 + tripId, label: 'Day 1', badge: 'Thu, Sep 10' }]) + S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10', name: 'Museum Ludwig' })] }) + panel({ trips: TRIPS, journals: JOURNALS, daysForTrip, onAccepted }) + + fireEvent.click(screen.getByRole('button', { name: 'Add as a place' })) + + // The dialog opens on the row that was pressed, for the target that was pressed. + expect(screen.getByRole('heading', { name: 'Add this stay as a place' })).toBeInTheDocument() + expect(daysForTrip).toHaveBeenCalledWith(5) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Add place' })) + }) + + expect(accept).toHaveBeenCalledWith(42, expect.objectContaining({ target: 'place', tripId: 5 })) + // Only now: the host refreshes its own list off the back of a write that landed. + expect(onAccepted).toHaveBeenCalledWith(expect.objectContaining({ id: 42 })) + expect(screen.queryByRole('heading', { name: 'Add this stay as a place' })).toBeNull() + }) + + it('FE-DAWARICH-PANEL-020: a refused write leaves the dialog standing, with the correction still in it', async () => { + const onAccepted = vi.fn() + accept.mockResolvedValue(false) + S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] }) + panel({ trips: TRIPS, journals: JOURNALS, onAccepted }) + + fireEvent.click(screen.getByRole('button', { name: 'Write a journal entry' })) + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Corrected name' } }) + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Add entry' })) + }) + + expect(accept).toHaveBeenCalledWith( + 42, + expect.objectContaining({ target: 'journal', journalId: 9, name: 'Corrected name' }), + ) + // Closing here would throw away what the user typed and leave them nothing + // to retry with. + expect(screen.getByRole('heading', { name: 'Write a journal entry' })).toBeInTheDocument() + expect(screen.getByLabelText('Name')).toHaveValue('Corrected name') + expect(onAccepted).not.toHaveBeenCalled() + }) + + it('FE-DAWARICH-PANEL-021: a host that wants no callback still gets its dialog closed', async () => { + S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] }) + panel({ trips: TRIPS, journals: JOURNALS }) + + fireEvent.click(screen.getByRole('button', { name: 'Write a journal entry' })) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Add entry' })) + }) + + expect(accept).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('heading', { name: 'Write a journal entry' })).toBeNull() + }) + + it('FE-DAWARICH-PANEL-022: cancelling writes nothing', () => { + S = hookState({ suggestions: [stay({ id: 42, localDate: '2026-09-10' })] }) + panel({ trips: TRIPS, journals: JOURNALS }) + + fireEvent.click(screen.getByRole('button', { name: 'Add as a place' })) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + + expect(screen.queryByRole('heading', { name: 'Add this stay as a place' })).toBeNull() + expect(accept).not.toHaveBeenCalled() + }) +}) + +describe('DawarichSuggestionsPanel: what was already dealt with', () => { + const handledRows = [ + stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig', state: 'accepted', target: 'place' }), + stay({ id: 2, localDate: '2026-09-09', name: 'Koelner Dom', state: 'dismissed' }), + // Accepted before `target` was recorded: the wording falls back rather than + // printing a missing translation key at somebody. + stay({ id: 3, localDate: '2026-09-08', name: 'Rheinpark', state: 'accepted', target: null }), + ] + + it('FE-DAWARICH-PANEL-023: the history stays folded away until it is asked for', () => { + S = hookState({ suggestions: handledRows }) + panel({ trips: TRIPS }) + + expect(screen.queryByText('Museum Ludwig')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Show 3 already dealt with' })) + + expect(screen.getByText('Museum Ludwig')).toBeInTheDocument() + expect(screen.getByText('Added as a place')).toBeInTheDocument() + expect(screen.getByText('Dismissed')).toBeInTheDocument() + expect(screen.getByText('In the journal')).toBeInTheDocument() + // A history row carries its own date, which the open list keeps in the day + // heading it no longer sits under. + expect(screen.getByText('Thu, Sep 10')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Hide the ones already dealt with' })) + expect(screen.queryByText('Museum Ludwig')).toBeNull() + }) + + it('FE-DAWARICH-PANEL-024: only a dismissal can be put back, and it goes through the hook', () => { + S = hookState({ suggestions: handledRows }) + const { container } = panel({ trips: TRIPS }) + fireEvent.click(screen.getByRole('button', { name: 'Show 3 already dealt with' })) + + // An accepted stay already produced a place or an entry; "put back" there + // would mean silently unpicking something the user wrote. + expect(within(rowOf(container, 'Museum Ludwig')).queryByRole('button', { name: 'Put back' })).toBeNull() + // And no history row offers the accept buttons a second time. + expect(within(rowOf(container, 'Koelner Dom')).queryByRole('button', { name: 'Add as a place' })).toBeNull() + + fireEvent.click(within(rowOf(container, 'Koelner Dom')).getByRole('button', { name: 'Put back' })) + + expect(restore).toHaveBeenCalledWith(2) + }) + + it('FE-DAWARICH-PANEL-025: a restore in flight disables the button that started it', () => { + S = hookState({ suggestions: handledRows, busyId: 2 }) + const { container } = panel({ trips: TRIPS }) + fireEvent.click(screen.getByRole('button', { name: 'Show 3 already dealt with' })) + + expect(within(rowOf(container, 'Koelner Dom')).getByRole('button', { name: 'Put back' })).toBeDisabled() + }) + + it('FE-DAWARICH-PANEL-026: nothing left open says so instead of showing an empty day', () => { + S = hookState({ suggestions: [handledRows[0]] }) + const { container } = panel({ trips: TRIPS }) + + expect(screen.getByText('Everything recorded here has been dealt with.')).toBeInTheDocument() + // And the count badge goes with the last open row rather than showing a zero. + expect(container.querySelector('.text-accent-text')).toBeNull() + }) +}) + +describe('DawarichSuggestionsPanel: the deleted-place hint', () => { + it('FE-DAWARICH-PANEL-027: a shrinking place count reloads, a growing one does not', () => { + useTripStore.setState({ places: places(3) }) + S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] }) + panel({ tripId: 5, trips: TRIPS }) + + // Mounting is not a shrink. + expect(reload).not.toHaveBeenCalled() + + // Deleting the place an acceptance created is how somebody says "not that + // one" after the fact, and the server puts the stay back into review. + act(() => { useTripStore.setState({ places: places(2) }) }) + expect(reload).toHaveBeenCalledTimes(1) + + // A place appearing is usually this panel's own doing. + act(() => { useTripStore.setState({ places: places(6) }) }) + expect(reload).toHaveBeenCalledTimes(1) + }) + + it('FE-DAWARICH-PANEL-028: outside a trip the place count is not this panel\'s business', () => { + useTripStore.setState({ places: places(3) }) + S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] }) + // The journal shell mounts this without a trip, and whatever trip happens to + // be loaded has nothing to do with the rows it shows. + panel({ trips: TRIPS }) + + act(() => { useTripStore.setState({ places: places(1) }) }) + + expect(reload).not.toHaveBeenCalled() + }) +}) + +describe('DawarichSuggestionsPanel: the phone shell', () => { + it('FE-DAWARICH-PANEL-029: the phone paints from its own palette, header to action pill', () => { + phone = true + S = hookState({ + suggestions: [ + stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig', sourceChanged: true }), + stay({ id: 2, localDate: '2026-09-09', name: 'Koelner Dom', state: 'dismissed' }), + ], + lastSyncAt: new Date(Date.now() - 60_000).toISOString(), + }) + const { container } = panel({ trips: TRIPS, journals: JOURNALS }) + fireEvent.click(screen.getByRole('button', { name: 'Show 1 already dealt with' })) + + // A card in the desktop surface tokens glares inside the mobile shell, and + // neither token set exists outside its own shell, so every surface here has + // to come from the `--m-*` palette. + expect(container.querySelector('section')?.className).toContain('var(--m-ic)') + expect(header().className).toContain('var(--m-inner)') + expect(container.querySelector('.bg-m-card')).not.toBeNull() + expect(container.querySelector('.bg-m-act')).not.toBeNull() + expect(container.querySelector('.text-m-ink')).not.toBeNull() + expect(container.querySelectorAll('.text-m-muted').length).toBeGreaterThan(0) + expect(container.querySelector('.text-m-faint')).not.toBeNull() + // The actions keep their accessible names, which is what makes dropping the + // hover-only tooltip on a touch device cost nothing. + expect(screen.getByRole('button', { name: 'Add as a place' })).toBeInTheDocument() + }) + + it('FE-DAWARICH-PANEL-030: bare on a phone, with nothing left open', () => { + phone = true + S = hookState({ + suggestions: [stay({ id: 1, localDate: '2026-09-10', state: 'accepted', target: 'journal' })], + lastSyncAt: null, + }) + const { container } = panel({ bare: true, trips: TRIPS }) + + expect(screen.queryByRole('button', { name: /From Dawarich/ })).toBeNull() + expect(screen.getByText('Everything recorded here has been dealt with.').className).toContain('text-m-muted') + expect(screen.getByText('Not checked yet').className).toContain('text-m-muted') + expect(container.querySelector('section')?.className).toBe('') + }) + + it('FE-DAWARICH-PANEL-033: folded shut on a phone, the arrow that says so comes from the phone palette', () => { + phone = true + S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' })] }) + // The planner mounts this one collapsed, so on a phone the right-pointing + // arrow is the header's ordinary state and not an edge case. It is also the + // only part of the header an expanded phone never paints, which is exactly + // how a desktop token would survive in there unnoticed. + const { container } = panel({ initiallyCollapsed: true, trips: TRIPS }) + + expect(header()).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('Museum Ludwig')).toBeNull() + expect(container.querySelector('.lucide-chevron-right.text-m-faint')).not.toBeNull() + expect(container.querySelector('.lucide-chevron-down')).toBeNull() + expect(container.querySelector('.text-content-faint')).toBeNull() + }) +}) + +describe('DawarichSuggestionsPanel: the desktop tooltip', () => { + it('FE-DAWARICH-PANEL-031: a row action explains itself on focus, where there is a pointer to do it with', () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + try { + S = hookState({ suggestions: [stay({ id: 1, localDate: '2026-09-10' })] }) + panel({ trips: TRIPS }) + + fireEvent.focus(screen.getByRole('button', { name: 'Not a place I visited' })) + act(() => { vi.advanceTimersByTime(300) }) + + // The icon is the whole button, so without this a square is a mystery to + // anybody who has not pressed one before. Reaching it by keyboard is the + // one route where there is no hover to discover it with. + expect(screen.getByRole('tooltip')).toHaveTextContent('Not a place I visited') + } finally { + vi.useRealTimers() + } + }) +}) + +describe('DawarichSuggestionsPanel: open and handled in the same list', () => { + it('FE-DAWARICH-PANEL-032: the two halves are counted separately and only the open half gets days', () => { + S = hookState({ + suggestions: [ + stay({ id: 1, localDate: '2026-09-10', name: 'Museum Ludwig' }), + stay({ id: 2, localDate: '2026-09-09', name: 'Koelner Dom', state: 'accepted', target: 'place' }), + stay({ id: 3, localDate: '2026-09-08', name: 'Rheinpark', state: 'dismissed' }), + ], + }) + const { container } = panel({ trips: TRIPS }) + + // Counting all three would send somebody looking for work that is done. + expect(container.querySelector('.bg-accent')).toHaveTextContent('1') + expect(screen.getByRole('button', { name: 'Show 2 already dealt with' })).toBeInTheDocument() + // And the day rail is built from the open half alone: a heading for a day + // whose only stay was dismissed is an empty day with a coloured dot on it. + expect(container.querySelectorAll('.uppercase')).toHaveLength(1) + expect(screen.getByText('Thu, Sep 10')).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Dawarich/dawarichAtlasModel.test.ts b/client/src/components/Dawarich/dawarichAtlasModel.test.ts index 9cef77144..ef0c7fd49 100644 --- a/client/src/components/Dawarich/dawarichAtlasModel.test.ts +++ b/client/src/components/Dawarich/dawarichAtlasModel.test.ts @@ -1,4 +1,22 @@ -// FE-DAWARICH-ATLASMODEL-001 to FE-DAWARICH-ATLASMODEL-010 +// FE-DAWARICH-ATLASMODEL-001 to FE-DAWARICH-ATLASMODEL-014 +/** + * The Atlas dialog's arithmetic, with no React around it. + * + * Everything here is small enough to look correct and wrong enough to matter if + * it is not. Three of the five carry a real trap: + * + * 1. `orderedMatches` sorts on two keys with a hand-written comparator that + * never returns 0. That is fine for ordering and a hazard for anything else, + * so the cases below check that the order is the same whichever way round + * the input arrives and that two wishes reached at the same moment both + * survive the sort rather than one of them being swapped away. + * 2. `countryLabel` runs on whatever `Intl` the device shipped. A WebView + * without `Intl.DisplayNames` throws, one with a partial region table + * answers `undefined`, and both have to end up as Dawarich's own spelling + * rather than as a crash or an empty label beside a flag. + * 3. `formatDistance` switches units at exactly a kilometre, which is the one + * input a refactor gets wrong. + */ import { describe, it, expect } from 'vitest' import type { DawarichAtlasCountry, DawarichBucketMatch } from '@trek/shared' import { @@ -67,6 +85,32 @@ describe('orderedMatches', () => { orderedMatches(input) expect(input.map(m => m.itemId)).toEqual([1, 2]) }) + + it('FE-DAWARICH-ATLASMODEL-011: the order they arrive in does not change the order they are shown in', () => { + // Both arms of both comparisons. A comparator that answers consistently one + // way round and not the other sorts whatever the scan happened to return, + // which is a list that reshuffles itself between two identical scans. + const ticked = match({ itemId: 1, alreadyVisited: true }) + const openWish = match({ itemId: 2 }) + expect(orderedMatches([ticked, openWish]).map(m => m.itemId)).toEqual([2, 1]) + expect(orderedMatches([openWish, ticked]).map(m => m.itemId)).toEqual([2, 1]) + + const older = match({ itemId: 3, match: { at: '2026-01-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } }) + const newer = match({ itemId: 4, match: { at: '2026-06-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } }) + expect(orderedMatches([older, newer]).map(m => m.itemId)).toEqual([4, 3]) + expect(orderedMatches([newer, older]).map(m => m.itemId)).toEqual([4, 3]) + }) + + it('FE-DAWARICH-ATLASMODEL-012: two wishes reached in the same moment both survive the sort', () => { + // The comparator never answers 0, so equal timestamps are reported as "swap" + // in both directions. Ordering them either way is fine; losing one is not. + const at = '2026-08-02T11:00:00Z' + const ordered = orderedMatches([ + match({ itemId: 1, match: { at, minutes: 95, distanceMeters: 30, points: 40 } }), + match({ itemId: 2, match: { at, minutes: 120, distanceMeters: 55, points: 51 } }), + ]) + expect(ordered.map(m => m.itemId).sort((a, b) => a - b)).toEqual([1, 2]) + }) }) describe('newCountries', () => { @@ -89,6 +133,28 @@ describe('countryLabel', () => { // name a reader would recognise. expect(countryLabel('ZZZZ', 'Freedonia', 'en')).toBe('Freedonia') }) + + it('FE-DAWARICH-ATLASMODEL-013: a runtime whose Intl names nothing still labels the row', () => { + // The other half of the fallback, and the one no desktop browser reproduces: + // a WebView that ships Intl.DisplayNames with a partial region table answers + // `undefined` instead of throwing, and an empty string beside a flag is a + // row nobody can identify. + const real = Intl.DisplayNames + Object.defineProperty(Intl, 'DisplayNames', { + configurable: true, + writable: true, + value: class { + of(): string | undefined { + return undefined + } + }, + }) + try { + expect(countryLabel('NL', 'Netherlands', 'en')).toBe('Netherlands') + } finally { + Object.defineProperty(Intl, 'DisplayNames', { configurable: true, writable: true, value: real }) + } + }) }) describe('cityLine', () => { @@ -110,4 +176,13 @@ describe('formatDistance', () => { expect(formatDistance(999, t)).toBe('dawarich.bucket.metersAway:{"meters":999}') expect(formatDistance(1400, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.4"}') }) + + it('FE-DAWARICH-ATLASMODEL-014: exactly a kilometre is already a kilometre', () => { + // The switch is `< 1000`, so 1000 belongs to the other unit. Off by one here + // reads as "1000 m away", which is the phrasing the rule exists to avoid. + expect(formatDistance(1000, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.0"}') + // One decimal, rounded, not truncated: 1049 and 1051 are not the same walk. + expect(formatDistance(1049, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.0"}') + expect(formatDistance(1051, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.1"}') + }) }) diff --git a/client/src/components/Dawarich/dawarichSuggestionModel.test.ts b/client/src/components/Dawarich/dawarichSuggestionModel.test.ts index 82a8eddab..3eb967e1a 100644 --- a/client/src/components/Dawarich/dawarichSuggestionModel.test.ts +++ b/client/src/components/Dawarich/dawarichSuggestionModel.test.ts @@ -1,4 +1,4 @@ -// FE-DAWARICH-SUGMODEL-001 to FE-DAWARICH-SUGMODEL-012 +// FE-DAWARICH-SUGMODEL-001 to FE-DAWARICH-SUGMODEL-014 import { describe, it, expect } from 'vitest' import type { DawarichSuggestion } from '@trek/shared' import { @@ -74,6 +74,24 @@ describe('groupByDay', () => { it('FE-DAWARICH-SUGMODEL-003: nothing in, nothing out', () => { expect(groupByDay([])).toEqual([]) }) + + it('FE-DAWARICH-SUGMODEL-013: a day that already reads in order is left in it', () => { + // Three stays rather than two: with two, a comparator that answered the + // same thing both ways would still look right half the time. + const input = [ + stay({ id: 1, localDate: '2026-09-10', startedAt: '2026-09-10T09:00:00+02:00' }), + stay({ id: 2, localDate: '2026-09-10', startedAt: '2026-09-10T18:00:00+02:00' }), + stay({ id: 3, localDate: '2026-09-10', startedAt: '2026-09-10T13:30:00+02:00' }), + ] + + const days = groupByDay(input) + + expect(days).toHaveLength(1) + expect(days[0].stays.map(s => s.id)).toEqual([1, 3, 2]) + // The list handed in belongs to the store. Sorting it in place would + // reorder what the panel is rendering from underneath it. + expect(input.map(s => s.id)).toEqual([1, 2, 3]) + }) }) describe('clockOf / timeRange', () => { @@ -115,6 +133,16 @@ describe('formatDayHeading', () => { it('FE-DAWARICH-SUGMODEL-010: an unparseable date is shown as it came', () => { expect(formatDayHeading('not-a-date', 'en-GB')).toBe('not-a-date') }) + + it('FE-DAWARICH-SUGMODEL-014: a date missing its month or its day is shown as it came too', () => { + // Each guard on its own, because `Date` would rather guess than refuse: + // month 0 rolls the heading back into December of the previous year, and a + // missing day silently becomes the first of the month. Either one reads as + // a real date, which is worse than showing the string that arrived. + expect(formatDayHeading('2026-00-10', 'en-GB')).toBe('2026-00-10') + expect(formatDayHeading('2026-09-00', 'en-GB')).toBe('2026-09-00') + expect(formatDayHeading('2026-09', 'en-GB')).toBe('2026-09') + }) }) describe('formatDayOption', () => { diff --git a/client/src/components/Dawarich/dawarichTripRefresh.test.ts b/client/src/components/Dawarich/dawarichTripRefresh.test.ts new file mode 100644 index 000000000..4130ba63c --- /dev/null +++ b/client/src/components/Dawarich/dawarichTripRefresh.test.ts @@ -0,0 +1,112 @@ +/** + * FE-DAWARICH-API-030 to FE-DAWARICH-API-032 + * + * Two lines of code, and both of them are the kind that rot without a sound. + * + * Accepting a recorded stay into a trip is written entirely server-side, so the + * planner learns nothing about the place it produced until something pulls the + * trip back in. This is that something, and both trip shells (the desktop + * `PlacesSidebar` and the phone `MPlacesBrowser`) call it as a one-liner + * precisely so the two copies cannot drift apart. What the tests hold is: + * + * - **Both refreshes are started, not one.** A stay accepted onto a day creates + * an assignment as well as a place, so refreshing only the places leaves the + * day rail one entry short until a reload. That is a plausible "simplification" + * for someone reading the call site, and nothing else in the app would notice. + * - **They run together, not in sequence.** `Promise.all` fires both before + * awaiting either; rewritten as two awaits it still works, just twice as + * slowly, and the button keeps spinning for both round trips. The order check + * below is synchronous, so it distinguishes the two shapes without timing. + * - **The id goes through verbatim.** The phone shell hands over a route param, + * which is a string; the desktop one hands over a number. Coercing either + * direction here would break the other caller. + */ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { useTripStore } from '../../store/tripStore' +import { refreshTripAfterAccept } from './dawarichTripRefresh' + +const original = { + refreshPlaces: useTripStore.getState().refreshPlaces, + refreshDays: useTripStore.getState().refreshDays, +} + +afterEach(() => { + useTripStore.setState(original) +}) + +describe('refreshTripAfterAccept', () => { + it('FE-DAWARICH-API-030: starts the day refresh without waiting for the place refresh', async () => { + const order: string[] = [] + let releasePlaces!: () => void + // A gate rather than a timer: with Promise.all both calls are made before + // anything is awaited, so the day refresh has already run by the time control + // returns. Rewritten as `await a; await b` it would still be waiting here. + const placesGate = new Promise(resolve => { + releasePlaces = resolve + }) + + useTripStore.setState({ + refreshPlaces: vi.fn(async (_tripId: number | string) => { + order.push('places:called') + await placesGate + order.push('places:settled') + }), + refreshDays: vi.fn(async (_tripId: number | string) => { + order.push('days:called') + }), + }) + + const pending = refreshTripAfterAccept(7) + expect(order).toEqual(['places:called', 'days:called']) + + releasePlaces() + await pending + expect(order).toEqual(['places:called', 'days:called', 'places:settled']) + }) + + it('FE-DAWARICH-API-031: resolves only once both refreshes have finished', async () => { + let releaseDays!: () => void + const daysGate = new Promise(resolve => { + releaseDays = resolve + }) + let settled = false + + useTripStore.setState({ + refreshPlaces: vi.fn(async (_tripId: number | string) => {}), + refreshDays: vi.fn(async (_tripId: number | string) => { + await daysGate + }), + }) + + const pending = refreshTripAfterAccept(7).then(() => { + settled = true + }) + + // Drain the microtask queue: the places half is already done, so anything + // that only awaited that one would be finished by now. + await Promise.resolve() + await Promise.resolve() + expect(settled).toBe(false) + + releaseDays() + await pending + expect(settled).toBe(true) + }) + + it('FE-DAWARICH-API-032: hands both refreshes the id exactly as the caller gave it', async () => { + const refreshPlaces = vi.fn(async (_tripId: number | string) => {}) + const refreshDays = vi.fn(async (_tripId: number | string) => {}) + useTripStore.setState({ refreshPlaces, refreshDays }) + + // The phone shell reads the trip id off the route, so it is a string there + // and a number on the desktop. Both repos accept either; a cast added here + // would only break whichever caller was not the one being looked at. + await refreshTripAfterAccept('42') + expect(refreshPlaces).toHaveBeenCalledWith('42') + expect(refreshDays).toHaveBeenCalledWith('42') + + await refreshTripAfterAccept(42) + expect(refreshPlaces).toHaveBeenLastCalledWith(42) + expect(refreshDays).toHaveBeenLastCalledWith(42) + }) +}) diff --git a/client/src/components/Map/DawarichTrailLayer.test.tsx b/client/src/components/Map/DawarichTrailLayer.test.tsx new file mode 100644 index 000000000..9a4343532 --- /dev/null +++ b/client/src/components/Map/DawarichTrailLayer.test.tsx @@ -0,0 +1,169 @@ +/** + * FE-DAWARICH-TRAILUI-010 to FE-DAWARICH-TRAILUI-015: the recorded-route + * overlay on the Leaflet renderer. + * + * Everything this component decides is a decision about legibility on top of + * somebody else's map, and every one of them is the kind that quietly rots: + * + * - **two lines per segment, casing first.** The white casing is what keeps + * the route visible over a satellite tile. It only works while it is drawn + * *under* the coloured line, so the order of the two `map()` blocks is + * load-bearing and not cosmetic. + * - **`interactive: false` on both.** A Leaflet path without it grows an + * invisible hit area that lies over the places and the planned route and + * eats the clicks meant for them. Losing this flag breaks selection on a + * surface that has nothing to do with Dawarich, which is why it is pinned + * here rather than left to a reviewer's eye. + * - **colour through `pathOptions`**, because react-leaflet only calls + * `setStyle` when that object's identity changes; a bare `color` prop sticks + * at its mount-time value and a day recolour never arrives. + * - **the day filter keeps the day's colour.** The colour index comes from the + * day's place in the whole track, so scrubbing to a single day dims the rest + * of the trip instead of repainting the day the user is looking at. + * + * The empty case matters too: returning `null` rather than an empty fragment is + * what stops Leaflet from being handed zero-length polylines on every trip that + * has no recording. + */ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import type { DawarichTrack, DawarichTrackDay } from '@trek/shared' +import { DAWARICH_DAY_COLORS, DAWARICH_TRAIL_CASING } from './dawarichTrail' + +// Leaflet itself is irrelevant here. What is under test is which polylines are +// asked for and with what options, so the mock reflects the props back as data +// attributes and the assertions read them in render order. +vi.mock('react-leaflet', () => ({ + Polyline: ({ + positions, + pathOptions, + pane, + interactive, + }: { + positions: Array<[number, number]> + pathOptions?: Record + pane?: string + interactive?: boolean + }) => ( +
+ ), +})) + +import DawarichTrailLayer from './DawarichTrailLayer' + +function day(date: string, ...segments: Array>): DawarichTrackDay { + return { + date, + segments: segments.map((points, i) => ({ + points, + mode: 'walking', + startedAt: date + 'T08:0' + i + ':00.000Z', + endedAt: date + 'T09:0' + i + ':00.000Z', + distanceMeters: 1200, + })), + } +} + +function track(days: DawarichTrackDay[]): DawarichTrack { + return { + days, + source: 'tracks', + fetchedAt: '2026-05-03T10:00:00.000Z', + pointCount: days.reduce((sum, d) => sum + d.segments.reduce((n, s) => n + s.points.length, 0), 0), + truncated: false, + } +} + +const DAY_ONE = day('2026-05-01', [[48.2, 16.37], [48.21, 16.38]]) +const DAY_TWO = day('2026-05-02', [[45.46, 9.18], [45.47, 9.19]], [[45.5, 9.2], [45.51, 9.21]]) + +const lines = () => screen.queryAllByTestId('polyline') + +describe('DawarichTrailLayer', () => { + it('FE-DAWARICH-TRAILUI-010: draws nothing at all when there is no recording', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('FE-DAWARICH-TRAILUI-011: ignores a segment that is a single fix rather than a line', () => { + // Dawarich reports a lone point for a day someone only opened the app on. + // Leaflet would accept the one-point polyline and draw nothing visible, but + // it would still be a layer, and every such day would add two of them. + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('FE-DAWARICH-TRAILUI-012: draws every casing first, then every coloured line on top', () => { + render() + + const drawn = lines() + expect(drawn).toHaveLength(6) // three segments, casing + line each + + // The first half is the casing pass. Reversing these two blocks would put + // the white line over the coloured one and the overlay would go white. + const casings = drawn.slice(0, 3) + const coloured = drawn.slice(3) + expect(casings.every(el => el.getAttribute('data-color') === DAWARICH_TRAIL_CASING)).toBe(true) + expect(coloured.some(el => el.getAttribute('data-color') === DAWARICH_TRAIL_CASING)).toBe(false) + }) + + it('FE-DAWARICH-TRAILUI-013: gives the casing the pane and the weight that make it a casing, and nothing catches clicks', () => { + render() + + const [casing, line] = lines() + expect(casing.getAttribute('data-pane')).toBe('trek-track-casing') + expect(casing.getAttribute('data-weight')).toBe('6') + expect(casing.getAttribute('data-opacity')).toBe('0.55') + + // Only the casing takes the pane; the coloured line is left in Leaflet's + // default overlay pane. Pinned as the fact it is rather than as a rule: + // that pane also holds the planned route, and the trail is mounted after + // it, so the recorded line currently draws over the plan while the GL twin + // puts both of its layers under it. + expect(line.getAttribute('data-pane')).toBe('') + expect(line.getAttribute('data-weight')).toBe('3') + expect(line.getAttribute('data-dash')).toBe('6 5') + expect(line.getAttribute('data-points')).toBe(JSON.stringify([[48.2, 16.37], [48.21, 16.38]])) + + expect(lines().every(el => el.getAttribute('data-interactive') === 'false')).toBe(true) + }) + + it('FE-DAWARICH-TRAILUI-014: works without a casing pane on a renderer that has none', () => { + render() + + // An older Leaflet build, or the GL renderer's Leaflet fallback, has no + // pane API and MapView passes undefined. Everything then lands in the + // default overlay pane in insertion order, which still keeps the casing + // under its own line, the one piece of the stacking that has to hold. + expect(lines().every(el => el.getAttribute('data-pane') === '')).toBe(true) + expect(lines()).toHaveLength(2) + }) + + it('FE-DAWARICH-TRAILUI-015: scrubbing to one day keeps that day its own colour', () => { + const { rerender } = render() + + // Two segments on the second day, so two casings and two lines, and the hue + // is the SECOND of the palette even though it is now the only day drawn. + expect(lines()).toHaveLength(4) + expect(lines().slice(2).map(el => el.getAttribute('data-color'))) + .toEqual([DAWARICH_DAY_COLORS[1], DAWARICH_DAY_COLORS[1]]) + + rerender() + expect(lines().slice(1).map(el => el.getAttribute('data-color'))).toEqual([DAWARICH_DAY_COLORS[0]]) + + // A day nobody recorded on falls back to the empty case rather than to the + // whole trip, which would be the worst possible answer to "show me Sunday". + rerender() + expect(lines()).toHaveLength(0) + }) +}) diff --git a/client/src/components/Map/DawarichTrailPill.test.tsx b/client/src/components/Map/DawarichTrailPill.test.tsx new file mode 100644 index 000000000..7cff5608b --- /dev/null +++ b/client/src/components/Map/DawarichTrailPill.test.tsx @@ -0,0 +1,127 @@ +/** + * FE-DAWARICH-TRAILUI-001 to FE-DAWARICH-TRAILUI-007: the recorded-route + * toggle that sits with the compass and the layer switcher on the trip map. + * + * The pill is small but it carries the only explanation the user ever gets for + * an empty overlay. Turning the layer on and seeing no line has four different + * causes (it is still loading, nothing was recorded on these dates, the + * Dawarich instance did not answer, the device is offline), and all four are + * answered by the label on this one control. If the label chain collapses to + * "hide recorded route" the map silently lies: the user sees a pressed button, + * no line, and no reason. + * + * So what is pinned here is the mapping from `status` to the label, and the + * second, wordless channel next to it: a problem state drains the mark to 0.35 + * so it is visible without hovering, while loading (which is not a problem) + * stays at full strength. The off state has to win over all of it, because a + * dimmed logo on a switched-off control would read as broken rather than as + * off. + */ +import React from 'react' +import { describe, it, expect, vi, afterEach } from 'vitest' +import { render, screen, fireEvent, act } from '../../../tests/helpers/render' +import { DawarichTrailPill } from './DawarichTrailPill' +import type { DawarichTrailStatus } from './useDawarichTrail' + +function pill(props: Partial> = {}) { + return render( + , + ) +} + +const button = () => screen.getByTestId('dawarich-trail-pill') +const label = () => button().getAttribute('aria-label') + +afterEach(() => { + vi.useRealTimers() +}) + +describe('DawarichTrailPill', () => { + it('FE-DAWARICH-TRAILUI-001: offers to show the route while the layer is off, whatever the last status was', () => { + const { rerender } = pill() + + expect(label()).toBe('Show recorded route') + expect(button()).toHaveAttribute('aria-pressed', 'false') + expect(button().style.opacity).toBe('0.6') + + // A stale failure from the last time the layer was on must not dim a + // switched-off control: off already has its own resting opacity, and a + // second, dimmer step would read as "this button is broken". + rerender() + expect(label()).toBe('Show recorded route') + expect(button().style.opacity).toBe('0.6') + }) + + it('FE-DAWARICH-TRAILUI-002: offers to hide the route once a line is on the map', () => { + const { rerender } = pill({ active: true, status: 'ready' }) + + expect(label()).toBe('Hide recorded route') + expect(button()).toHaveAttribute('aria-pressed', 'true') + expect(button().style.opacity).toBe('1') + + // `idle` is the state between the toggle flipping and the first fetch + // starting. It falls through the same arm as `ready` on purpose: the button + // has just been pressed and "hide" is the only honest thing it can offer. + rerender() + expect(label()).toBe('Hide recorded route') + expect(button().style.opacity).toBe('1') + }) + + it('FE-DAWARICH-TRAILUI-003: says it is loading without dimming the mark', () => { + pill({ active: true, status: 'loading' }) + + expect(label()).toMatch(/^Loading the recorded route/) + // Loading is not a problem, so the wordless "something is wrong" channel + // stays off. Otherwise every refresh would blink the control. + expect(button().style.opacity).toBe('1') + }) + + it('FE-DAWARICH-TRAILUI-004: names each reason there is no line and dims the mark for it', () => { + const reasons: Array<[DawarichTrailStatus, string]> = [ + ['empty', 'Nothing was recorded on these dates'], + ['offline', 'The recorded route needs a connection'], + ['unavailable', 'The recorded route could not be loaded'], + ] + + for (const [status, expected] of reasons) { + const { unmount } = pill({ active: true, status }) + expect(label()).toBe(expected) + expect(button().style.opacity).toBe('0.35') + // The pressed state survives the problem: the layer really is on, and + // un-pressing it here would make the toggle disagree with the map. + expect(button()).toHaveAttribute('aria-pressed', 'true') + unmount() + } + }) + + it('FE-DAWARICH-TRAILUI-005: reports a press once', () => { + const onToggle = vi.fn() + pill({ onToggle }) + + fireEvent.click(button()) + expect(onToggle).toHaveBeenCalledTimes(1) + }) + + it('FE-DAWARICH-TRAILUI-006: lifts the mark under the pointer and puts it back', () => { + pill({ active: true, status: 'ready' }) + + fireEvent.mouseEnter(button()) + expect(button().style.transform).toBe('scale(1.06)') + + fireEvent.mouseLeave(button()) + expect(button().style.transform).toBe('none') + }) + + it('FE-DAWARICH-TRAILUI-007: repeats the status in a readable tooltip rather than the native one', () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + pill({ active: true, status: 'empty' }) + + fireEvent.mouseEnter(button()) + act(() => { vi.advanceTimersByTime(300) }) + + // The whole reason this control carries TREK's own tooltip: the native one + // never appears on a touch device, which is exactly where someone taps the + // button to find out why the map is empty. + expect(screen.getByRole('tooltip')).toHaveTextContent('Nothing was recorded on these dates') + }) +}) diff --git a/client/src/components/Map/dawarichTrail.test.ts b/client/src/components/Map/dawarichTrail.test.ts new file mode 100644 index 000000000..65c335e81 --- /dev/null +++ b/client/src/components/Map/dawarichTrail.test.ts @@ -0,0 +1,229 @@ +/** + * dawarichTrail unit tests: FE-DAWARICH-TRAIL-001 to FE-DAWARICH-TRAIL-008. + * + * This module is the whole of what the two map renderers agree on about the + * recorded route: Leaflet turns `trailSegments` into `` elements, the + * GL map turns `trailGeoJson` into one source, and the pill reports + * `trailDistanceKm`. Nothing here touches a map, so every decision it makes can + * be pinned directly, and every one of them breaks silently, because a wrong + * line still looks exactly like a line. + * + * Four claims are worth holding: + * + * - **the day colour comes from the day's place in the whole track, never in + * the filtered list.** Picking a day on the timeline has to dim the rest of + * the trip while leaving the picked day the colour it already had; an index + * read off the filtered array would repaint every selection blue, and the + * map would quietly stop meaning "we were here on Tuesday". + * - **a segment that cannot be drawn is dropped, not drawn badly.** The pairs + * are narrowed at runtime rather than cast, so a ragged answer from someone + * else's Dawarich instance costs a point instead of putting a vertex at NaN + * and taking the rest of the overlay down with it. + * - **`[lat, lng]` becomes `[lng, lat]` exactly once.** This is the only + * coordinate flip in the overlay. A second flip, or none, drops the whole + * recording in the Gulf of Guinea, and nothing else in the repo watches + * that boundary. + * - **an unknown distance is not zero.** `null` means "Dawarich did not say", + * which the panel has to render as nothing rather than as "0 km travelled". + */ +import { describe, it, expect } from 'vitest' +import type { DawarichTrack, DawarichTrackDay, DawarichTrackSegment } from '@trek/shared' +import { + DAWARICH_DAY_COLORS, + DAWARICH_TRAIL_CASING, + DAWARICH_TRAIL_COLOR, + dayColor, + trailDistanceKm, + trailGeoJson, + trailSegments, + type DawarichTrailSegment, +} from './dawarichTrail' + +const seg = (over: Partial = {}): DawarichTrackSegment => ({ + points: over.points ?? [ + [52.52, 13.405], + [52.51, 13.41], + ], + mode: over.mode === undefined ? 'walking' : over.mode, + startedAt: over.startedAt ?? '2026-05-01T08:00:00Z', + endedAt: over.endedAt ?? '2026-05-01T09:00:00Z', + distanceMeters: over.distanceMeters === undefined ? 1200 : over.distanceMeters, +}) + +const track = (days: DawarichTrackDay[]): DawarichTrack => ({ + days, + source: 'tracks', + fetchedAt: '2026-05-03T10:00:00Z', + pointCount: 42, + truncated: false, +}) + +/** Three calendar days, so "the second day" has a colour that is not the first day's. */ +const THREE_DAYS = track([ + { date: '2026-05-01', segments: [seg(), seg({ mode: 'car', distanceMeters: 40_000 })] }, + { date: '2026-05-02', segments: [seg({ mode: null, distanceMeters: null })] }, + { date: '2026-05-03', segments: [seg()] }, +]) + +/** + * The wire shapes a Dawarich instance can actually produce but the Zod tuple + * type cannot express: a pair that lost its longitude, and a pair whose + * latitude came back as text. The casts reproduce the bad answer rather than + * pretending the schema makes it impossible, which is exactly the assumption + * the runtime narrowing in `trailSegments` refuses to make. + */ +const RAGGED = [ + [52.52, 13.405], + [52.51], + [52.5, 13.4], +] as unknown as Array<[number, number]> + +const UNDRAWABLE = [ + ['north', 13.405], + [52.51, 13.41], +] as unknown as Array<[number, number]> + +describe('dayColor', () => { + it('FE-DAWARICH-TRAIL-001: cycles the six day hues and wraps instead of running off the end', () => { + expect(DAWARICH_DAY_COLORS).toHaveLength(6) + expect(dayColor(0)).toBe(DAWARICH_DAY_COLORS[0]) + expect(dayColor(5)).toBe(DAWARICH_DAY_COLORS[5]) + // A fortnight-long trip must not hand back undefined on day seven. + expect(dayColor(6)).toBe(DAWARICH_DAY_COLORS[0]) + expect(dayColor(13)).toBe(DAWARICH_DAY_COLORS[1]) + // These two are what the Leaflet path options and the GL paint expressions + // both read. Changing either is a review decision, not a drive-by diff. + expect(DAWARICH_TRAIL_COLOR).toBe('#2563EB') + expect(DAWARICH_TRAIL_CASING).toBe('#FFFFFF') + }) +}) + +describe('trailSegments', () => { + it('FE-DAWARICH-TRAIL-002: has nothing to draw before a track has been fetched', () => { + expect(trailSegments(null)).toEqual([]) + // The timeline passes its selected day even while the first fetch is out. + expect(trailSegments(null, '2026-05-01')).toEqual([]) + }) + + it('FE-DAWARICH-TRAIL-003: flattens every day into keyed segments carrying the day colour', () => { + const out = trailSegments(THREE_DAYS) + + expect(out.map(s => s.id)).toEqual([ + '2026-05-01-0', + '2026-05-01-1', + '2026-05-02-0', + '2026-05-03-0', + ]) + expect(out.map(s => s.color)).toEqual([ + DAWARICH_DAY_COLORS[0], + DAWARICH_DAY_COLORS[0], + DAWARICH_DAY_COLORS[1], + DAWARICH_DAY_COLORS[2], + ]) + expect(out[1].mode).toBe('car') + expect(out[1].distanceMeters).toBe(40_000) + // An instance too old to classify the movement reports null, and null is + // what reaches the renderer: no invented transport mode. + expect(out[2].mode).toBeNull() + expect(out[2].distanceMeters).toBeNull() + expect(out[0].startedAt).toBe('2026-05-01T08:00:00Z') + expect(out[0].endedAt).toBe('2026-05-01T09:00:00Z') + expect(out[0].points).toEqual([ + [52.52, 13.405], + [52.51, 13.41], + ]) + }) + + it('FE-DAWARICH-TRAIL-004: filtering to one day keeps that day its own colour', () => { + const only = trailSegments(THREE_DAYS, '2026-05-03') + + expect(only.map(s => s.id)).toEqual(['2026-05-03-0']) + // The regression this test exists for: an index taken from the filtered + // array would be 0 here and repaint the third day in the first day's blue. + expect(only[0].color).toBe(DAWARICH_DAY_COLORS[2]) + // An explicit null is the "no day picked" case the scrubber sends on reset. + expect(trailSegments(THREE_DAYS, null)).toHaveLength(4) + // A day with no recording is an empty overlay, not the whole trip. + expect(trailSegments(THREE_DAYS, '2026-04-30')).toEqual([]) + }) + + it('FE-DAWARICH-TRAIL-005: drops what cannot be drawn and keeps the rest of the day numbered', () => { + const out = trailSegments( + track([ + { + date: '2026-05-01', + segments: [ + // A single recorded fix is a dot, not a line: Leaflet would draw + // nothing and the GL source would reject the LineString. + seg({ points: [[52.52, 13.405]] }), + // Two pairs, one of them without a usable latitude, so only one + // point survives the narrowing and this segment goes with it. + seg({ points: UNDRAWABLE }), + seg({ points: RAGGED }), + ], + }, + ]), + ) + + expect(out).toHaveLength(1) + // The id keeps the segment's index inside the day rather than its index in + // the output: a renderer keys on it, and renumbering would remount every + // surviving line as soon as an earlier segment went missing. + expect(out[0].id).toBe('2026-05-01-2') + expect(out[0].points).toEqual([ + [52.52, 13.405], + [52.5, 13.4], + ]) + }) + + it('FE-DAWARICH-TRAIL-006: a day that recorded nothing contributes nothing', () => { + expect(trailSegments(track([{ date: '2026-05-01', segments: [] }]))).toEqual([]) + expect(trailSegments(track([]))).toEqual([]) + }) +}) + +describe('trailGeoJson', () => { + it('FE-DAWARICH-TRAIL-007: flips lat/lng once and carries the paint properties across', () => { + const collection = trailGeoJson(trailSegments(THREE_DAYS, '2026-05-02')) + + expect(collection.type).toBe('FeatureCollection') + expect(collection.features).toHaveLength(1) + const [feature] = collection.features + expect(feature.type).toBe('Feature') + expect(feature.properties).toEqual({ + id: '2026-05-02-0', + color: DAWARICH_DAY_COLORS[1], + date: '2026-05-02', + mode: null, + }) + expect(feature.geometry.type).toBe('LineString') + // TREK stores [lat, lng]; GeoJSON wants [lng, lat]. Berlin sits at 52 N + // 13 E, so a missed flip lands the recording off the coast of Ghana. + expect(feature.geometry.coordinates).toEqual([ + [13.405, 52.52], + [13.41, 52.51], + ]) + + // Nothing recorded is an empty collection, which a GeoJSON source accepts, + // not the null it would choke on. + expect(trailGeoJson([])).toEqual({ type: 'FeatureCollection', features: [] }) + }) +}) + +describe('trailDistanceKm', () => { + it('FE-DAWARICH-TRAIL-008: sums the known metres into kilometres and says nothing when none are known', () => { + const withDistances: DawarichTrailSegment[] = trailSegments(THREE_DAYS) + + // 1200 + 40000 + 1200 metres; the day Dawarich gave no distance for is + // skipped rather than counted as a zero-kilometre leg. + expect(trailDistanceKm(withDistances)).toBeCloseTo(42.4, 6) + + const unknownOnly = trailSegments( + track([{ date: '2026-05-02', segments: [seg({ distanceMeters: null })] }]), + ) + // null, not 0: "Dawarich did not say" and "you went nowhere" are different + // sentences and the pill is allowed to render only one of them. + expect(trailDistanceKm(unknownOnly)).toBeNull() + expect(trailDistanceKm([])).toBeNull() + }) +}) diff --git a/client/src/components/Map/useDawarichTrail.test.ts b/client/src/components/Map/useDawarichTrail.test.ts new file mode 100644 index 000000000..e2731303f --- /dev/null +++ b/client/src/components/Map/useDawarichTrail.test.ts @@ -0,0 +1,430 @@ +/** + * useDawarichTrail unit tests: FE-DAWARICH-TRAIL-009 to FE-DAWARICH-TRAIL-024 + * (the numbering continues the one in `dawarichTrail.test.ts`, so a grep for + * FE-DAWARICH-TRAIL finds the whole overlay in one list). + * + * This hook is the only fetcher for the recorded route. It is called once in + * `MapViewAuto` and the answer is handed to both renderers as a prop, so a bug + * here is not a rendering glitch. It is either a request storm against a + * server the user pays for and runs themselves, or a line on the map that is + * telling them about a different moment than the one they are looking at. + * + * What is pinned, and why each one matters: + * + * - **the gate.** `enabled && tripId && the addon is on`: three separate ways + * to be switched off, and the hook must reach the network for none of them. + * The addon check is the one a reviewer forgets, and forgetting it means an + * instance with Dawarich turned off still hammers `/api/dawarich/...` every + * two minutes. + * - **the offline contract.** `isEffectivelyOffline()` and not + * `navigator.onLine`, so the manual switch someone flipped before a flight + * is honoured; a request fired anyway blocks on a timeout instead of + * failing, and that is the case the user cannot see. + * - **staleness is said, never faked.** A refresh keeps the previous line on + * screen and only moves the status, because blanking the map makes a routine + * two-minute poll look like the recording was deleted. A failure keeps the + * line too, and says `unavailable`. + * - **nothing that arrives late is allowed to paint.** Three ways a response + * can be obsolete by the time it lands (the layer was switched off, the + * device went offline, a newer request superseded it), and all three have to + * end in the answer being dropped rather than drawn over a newer truth. + * - **the timer is torn down with the hook.** An interval that outlives the + * map is an unattributable request every two minutes, for the rest of the + * session. + */ +import { act, renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { DawarichTrack } from '@trek/shared' + +const { tripTrack } = vi.hoisted(() => ({ tripTrack: vi.fn() })) +vi.mock('../../repo/dawarichRepo', () => ({ dawarichRepo: { tripTrack } })) + +import { seedStore } from '../../../tests/helpers/store' +import { useAddonStore } from '../../store/addonStore' +import { _resetNetworkMode, setForcedOffline } from '../../sync/networkMode' +import { useDawarichTrail } from './useDawarichTrail' + +const REFRESH_MS = 120_000 + +const trackWith = (dates: string[], fetchedAt = '2026-05-03T10:00:00Z'): DawarichTrack => ({ + days: dates.map(date => ({ + date, + segments: [ + { + points: [ + [52.52, 13.405], + [52.51, 13.41], + ], + mode: 'walking', + startedAt: `${date}T08:00:00Z`, + endedAt: `${date}T09:00:00Z`, + distanceMeters: 1200, + }, + ], + })), + source: 'tracks', + fetchedAt, + pointCount: 2 * dates.length, + truncated: false, +}) + +const TRACK = trackWith(['2026-05-01']) +const NEWER = trackWith(['2026-05-01', '2026-05-02'], '2026-05-03T10:02:00Z') +const EMPTY: DawarichTrack = { ...trackWith([]), pointCount: 0 } + +const addon = (enabled: boolean) => [ + { id: 'dawarich', name: 'Dawarich', description: '', type: 'integration', icon: 'map', enabled }, +] + +/** + * A promise the test settles by hand. "In flight" is a state this hook has + * opinions about (it keeps the old line, it aborts on the next load), and none + * of them are observable against a promise that has already resolved. + */ +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +/** Let the repo promise and the state updates it schedules settle inside act. */ +const flush = () => + act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + +/** jsdom's onLine is a prototype getter; an own property shadows it for one test. */ +const setBrowserOnline = (value: boolean) => + Object.defineProperty(navigator, 'onLine', { value, writable: true, configurable: true }) + +const signalOf = (call: number): AbortSignal => tripTrack.mock.calls[call][2] as AbortSignal + +beforeEach(() => { + tripTrack.mockReset() + tripTrack.mockResolvedValue(TRACK) + // The `isEnabled` selector is a stable function reference, so a store change + // after mount does not re-render the hook. Every test seeds before it renders. + seedStore(useAddonStore, { addons: addon(true), loaded: true }) + _resetNetworkMode() + setBrowserOnline(true) +}) + +afterEach(() => { + vi.useRealTimers() + _resetNetworkMode() + setBrowserOnline(true) +}) + +describe('useDawarichTrail: the gate', () => { + it('FE-DAWARICH-TRAIL-009: stays idle and reaches for nothing while the layer is off', () => { + const { result } = renderHook(() => useDawarichTrail(7, false)) + + expect(result.current.status).toBe('idle') + expect(result.current.track).toBeNull() + expect(result.current.fetchedAt).toBeNull() + expect(tripTrack).not.toHaveBeenCalled() + }) + + it('FE-DAWARICH-TRAIL-010: a trip that has not loaded its id yet is not a trip to fetch for', () => { + const { result } = renderHook(() => useDawarichTrail(undefined, true)) + + expect(result.current.status).toBe('idle') + expect(tripTrack).not.toHaveBeenCalled() + }) + + it('FE-DAWARICH-TRAIL-011: an instance with the addon switched off never calls the integration', () => { + seedStore(useAddonStore, { addons: addon(false), loaded: true }) + + const { result } = renderHook(() => useDawarichTrail(7, true)) + + expect(result.current.status).toBe('idle') + expect(tripTrack).not.toHaveBeenCalled() + }) +}) + +describe('useDawarichTrail: fetching', () => { + it('FE-DAWARICH-TRAIL-012: fetches the trip window with an abort signal and reports it ready', async () => { + const { result } = renderHook(() => + useDawarichTrail(7, true, { from: '2026-05-01', to: '2026-05-03' }), + ) + + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(result.current.track).toEqual(TRACK) + // fetchedAt comes off the answer, not off the clock: it is the server's + // statement about how fresh the recording is, and the pill quotes it. + expect(result.current.fetchedAt).toBe('2026-05-03T10:00:00Z') + expect(tripTrack).toHaveBeenCalledTimes(1) + expect(tripTrack.mock.calls[0][0]).toBe(7) + expect(tripTrack.mock.calls[0][1]).toEqual({ from: '2026-05-01', to: '2026-05-03' }) + expect(signalOf(0)).toBeInstanceOf(AbortSignal) + expect(signalOf(0).aborted).toBe(false) + }) + + it('FE-DAWARICH-TRAIL-013: a window with no recording is empty, which is not the same as ready', async () => { + tripTrack.mockResolvedValue(EMPTY) + + const { result } = renderHook(() => useDawarichTrail(7, true)) + + await waitFor(() => expect(result.current.status).toBe('empty')) + expect(result.current.track).toEqual(EMPTY) + expect(result.current.fetchedAt).toBe(EMPTY.fetchedAt) + }) + + it('FE-DAWARICH-TRAIL-014: a failed refresh keeps the line that is already drawn', async () => { + tripTrack.mockResolvedValueOnce(TRACK).mockRejectedValueOnce(new Error('502 from Dawarich')) + + const { result } = renderHook(() => useDawarichTrail(7, true)) + await waitFor(() => expect(result.current.status).toBe('ready')) + + act(() => { + result.current.reload() + }) + + await waitFor(() => expect(result.current.status).toBe('unavailable')) + // A line from two minutes ago beats an empty map; the status is the part + // that admits it may be stale. + expect(result.current.track).toEqual(TRACK) + expect(result.current.fetchedAt).toBe(TRACK.fetchedAt) + expect(tripTrack).toHaveBeenCalledTimes(2) + }) + + it('FE-DAWARICH-TRAIL-015: a refresh does not blank the map while the next answer is in flight', async () => { + const second = deferred() + tripTrack.mockResolvedValueOnce(TRACK).mockReturnValueOnce(second.promise) + + const { result } = renderHook(() => useDawarichTrail(7, true)) + await waitFor(() => expect(result.current.status).toBe('ready')) + + act(() => { + result.current.reload() + }) + + // Not 'loading': flipping back to the spinner every two minutes would make + // a routine poll look like the recording had gone away. + expect(result.current.status).toBe('ready') + expect(result.current.track).toEqual(TRACK) + expect(tripTrack).toHaveBeenCalledTimes(2) + + await act(async () => { + second.resolve(NEWER) + await Promise.resolve() + }) + await waitFor(() => expect(result.current.track).toEqual(NEWER)) + expect(result.current.fetchedAt).toBe(NEWER.fetchedAt) + }) + + it('FE-DAWARICH-TRAIL-016: changing the window refetches, re-rendering with the same window does not', async () => { + type Range = { from?: string; to?: string } + const { result, rerender } = renderHook( + ({ range }: { range: Range }) => useDawarichTrail(7, true, range), + { initialProps: { range: { from: '2026-05-01' } as Range } }, + ) + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(tripTrack).toHaveBeenCalledTimes(1) + + // A fresh object literal with the same window: the parent produces one on + // every render, and re-fetching on each of them is the bug the range key + // exists to prevent. + rerender({ range: { from: '2026-05-01' } }) + await flush() + expect(tripTrack).toHaveBeenCalledTimes(1) + + rerender({ range: { from: '2026-05-01', to: '2026-05-04' } }) + await waitFor(() => expect(tripTrack).toHaveBeenCalledTimes(2)) + expect(tripTrack.mock.calls[1][1]).toEqual({ from: '2026-05-01', to: '2026-05-04' }) + }) +}) + +describe('useDawarichTrail: offline', () => { + it('FE-DAWARICH-TRAIL-017: a device that is offline by choice is never asked to reach the network', () => { + setForcedOffline(true) + + const { result } = renderHook(() => useDawarichTrail(7, true)) + + expect(result.current.status).toBe('offline') + expect(tripTrack).not.toHaveBeenCalled() + }) + + it('FE-DAWARICH-TRAIL-018: going offline mid-flight aborts the request and drops the answer', async () => { + const first = deferred() + tripTrack.mockReturnValueOnce(first.promise) + + const { result } = renderHook(() => useDawarichTrail(7, true)) + expect(result.current.status).toBe('loading') + + act(() => { + setForcedOffline(true) + }) + + // The hook subscribes to network-mode changes, so the switch re-enters the + // load immediately rather than waiting out the two-minute timer. + expect(signalOf(0).aborted).toBe(true) + expect(result.current.status).toBe('offline') + expect(tripTrack).toHaveBeenCalledTimes(1) + + await act(async () => { + first.resolve(TRACK) + await Promise.resolve() + }) + // The answer belonged to a request the hook has already given up on. + // Painting it now would contradict the status the user is reading. + expect(result.current.status).toBe('offline') + expect(result.current.track).toBeNull() + }) + + it('FE-DAWARICH-TRAIL-019: a request that fails after the connection dropped says offline, not unavailable', async () => { + tripTrack.mockImplementationOnce(async () => { + // The browser drops the connection after the request left: the pre-flight + // check passed, and only the failure knows the device is gone. + setBrowserOnline(false) + throw new Error('network error') + }) + + const { result } = renderHook(() => useDawarichTrail(7, true)) + + await waitFor(() => expect(result.current.status).toBe('offline')) + expect(result.current.track).toBeNull() + }) +}) + +describe('useDawarichTrail: lifecycle', () => { + it('FE-DAWARICH-TRAIL-020: switching the layer off mid-flight discards the answer instead of drawing it', async () => { + const first = deferred() + tripTrack.mockReturnValueOnce(first.promise) + + const { result, rerender } = renderHook(({ on }: { on: boolean }) => useDawarichTrail(7, on), { + initialProps: { on: true }, + }) + expect(result.current.status).toBe('loading') + + rerender({ on: false }) + expect(result.current.status).toBe('idle') + expect(signalOf(0).aborted).toBe(true) + + await act(async () => { + first.resolve(TRACK) + await Promise.resolve() + }) + // Nobody asked for this line any more; drawing it would put the overlay + // back on a map the user just cleared. + expect(result.current.status).toBe('idle') + expect(result.current.track).toBeNull() + }) + + it('FE-DAWARICH-TRAIL-021: a failure that lands after the layer was switched off stays quiet', async () => { + const first = deferred() + tripTrack.mockReturnValueOnce(first.promise) + + const { result, rerender } = renderHook(({ on }: { on: boolean }) => useDawarichTrail(7, on), { + initialProps: { on: true }, + }) + rerender({ on: false }) + + await act(async () => { + first.reject(new Error('too late')) + await Promise.resolve() + }) + // 'unavailable' here would light up a warning pill about a layer that is + // not on screen. + expect(result.current.status).toBe('idle') + }) + + it('FE-DAWARICH-TRAIL-022: switching off clears the drawn track and switching back on refetches', async () => { + const { result, rerender } = renderHook(({ on }: { on: boolean }) => useDawarichTrail(7, on), { + initialProps: { on: true }, + }) + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(result.current.track).toEqual(TRACK) + + rerender({ on: false }) + expect(result.current.track).toBeNull() + expect(result.current.status).toBe('idle') + // fetchedAt is deliberately not cleared: it describes the last answer the + // hook actually received, and the pill reads it again on the way back. + expect(result.current.fetchedAt).toBe(TRACK.fetchedAt) + + rerender({ on: true }) + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(tripTrack).toHaveBeenCalledTimes(2) + expect(result.current.track).toEqual(TRACK) + }) + + it('FE-DAWARICH-TRAIL-023: refreshes on the two-minute timer and stops the timer with the hook', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + const { result, unmount } = renderHook(() => useDawarichTrail(7, true)) + await act(async () => { + await vi.advanceTimersByTimeAsync(1) + }) + expect(result.current.status).toBe('ready') + expect(tripTrack).toHaveBeenCalledTimes(1) + + await act(async () => { + await vi.advanceTimersByTimeAsync(REFRESH_MS) + }) + expect(tripTrack).toHaveBeenCalledTimes(2) + // Each poll supersedes the one before it, so a slow answer can never land + // on top of a newer one. + expect(signalOf(0).aborted).toBe(true) + + unmount() + expect(signalOf(1).aborted).toBe(true) + + await act(async () => { + await vi.advanceTimersByTimeAsync(REFRESH_MS * 3) + }) + // An interval that outlived the map would be a request every two minutes + // for the rest of the session, against somebody's own hardware. + expect(tripTrack).toHaveBeenCalledTimes(2) + }) + + it('FE-DAWARICH-TRAIL-024: a superseded request that fails afterwards leaves the newer one alone', async () => { + const superseded = deferred() + const current = deferred() + tripTrack.mockReturnValueOnce(superseded.promise).mockReturnValueOnce(current.promise) + + const { result } = renderHook(() => useDawarichTrail(7, true)) + expect(result.current.status).toBe('loading') + + // Offline and straight back: two network-mode notifications inside the same + // effect, so the first request is abandoned without the hook being torn down + // or reloaded. That is the only way to get an aborted request whose failure + // still arrives at a live listener, which is what the abort check in the + // failure path is for. A timer tick does the same thing two minutes later. + act(() => { + setForcedOffline(true) + }) + expect(result.current.status).toBe('offline') + act(() => { + setForcedOffline(false) + }) + + expect(tripTrack).toHaveBeenCalledTimes(2) + expect(signalOf(0).aborted).toBe(true) + expect(signalOf(1).aborted).toBe(false) + expect(result.current.status).toBe('loading') + + await act(async () => { + superseded.reject(new Error('aborted while the next request was already out')) + // The failure travels through the then() before it reaches the catch(), + // so one tick is not enough to see it land. + await Promise.resolve() + await Promise.resolve() + }) + // 'unavailable' here would put a warning on the map about a request the hook + // threw away itself, while the request it is actually waiting for is fine. + expect(result.current.status).toBe('loading') + + await act(async () => { + current.resolve(TRACK) + await Promise.resolve() + }) + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(result.current.track).toEqual(TRACK) + }) +}) diff --git a/client/src/components/Map/useDawarichTrailGL.test.ts b/client/src/components/Map/useDawarichTrailGL.test.ts new file mode 100644 index 000000000..db241f567 --- /dev/null +++ b/client/src/components/Map/useDawarichTrailGL.test.ts @@ -0,0 +1,317 @@ +/** + * FE-DAWARICH-TRAILUI-020 to FE-DAWARICH-TRAILUI-029: the recorded-route + * overlay on the GL renderer, the imperative twin of `DawarichTrailLayer`. + * + * A declarative overlay that gets its ordering wrong looks wrong. An imperative + * one that gets its lifecycle wrong leaks: sources and layers survive the React + * tree that created them, and a GL map carrying a layer nobody owns any more + * throws on the next style change instead of at the point of the mistake. So + * the things pinned here are the ones whose failure is invisible at the call + * site: + * + * - **the guard.** `mapRef.current` is null on the first render and the style + * is not loaded for several frames after that. Adding a layer in either + * window throws inside an effect, which React reports as a render error on a + * component that has nothing to do with Dawarich. + * - **the anchor.** `beforeId` is only usable once that layer actually exists + * on the current style; passing an unknown id to `addLayer` is a throw, not + * a no-op, and the layer it names belongs to a different overlay that may + * not have mounted yet. + * - **the `trek-` prefix on every id**, which is what `applySatellite` looks + * for when it decides where to slot the satellite tile. A layer called + * `dawarich-trail` would end up underneath the satellite image and simply + * not be there for the people most likely to want it. + * - **`setData` over remove-and-add** when the source is still there, because + * a rebuilt source flickers. The two-minute refresh does not reach that arm: + * a fresh track object re-runs the effect and the teardown takes the source + * with it, so what setData actually covers is a style change that kept its + * sources. Both paths are pinned, because which one a refresh takes is the + * difference between a line that blinks every two minutes and one that does + * not. + * - **one `style.load` subscription at a time.** The effect re-runs on every + * refresh, and a cleanup that forgot to unsubscribe would add one silent + * redraw per refresh for the life of the map. + * - **the teardown**, which has to take both layers and the source away, and + * has to survive a style rebuild that already took them. + */ +import { describe, it, expect, vi } from 'vitest' +import { renderHook } from '@testing-library/react' +import type { Map as MapboxMap } from 'mapbox-gl' +import type { DawarichTrack, DawarichTrackDay } from '@trek/shared' +import { DAWARICH_DAY_COLORS, DAWARICH_TRAIL_CASING } from './dawarichTrail' +import { useDawarichTrailGL } from './useDawarichTrailGL' + +const SOURCE = 'trek-dawarich-trail' +const CASING_LAYER = 'trek-dawarich-trail-casing' +const LINE_LAYER = 'trek-dawarich-trail-line' + +interface FakeSource { + data: unknown + setData: ReturnType +} + +/** + * A GL map double that keeps real registries rather than returning fixed + * values: the hook asks "is this already there?" before every decision it + * makes, so a double that always answers the same way cannot exercise the + * decisions at all. + */ +function fakeGlMap({ styleLoaded = true, styleLayers = [] as string[] } = {}) { + const sources = new Map() + const layers = new Map }; before: string | undefined }>() + for (const id of styleLayers) layers.set(id, { spec: { id }, before: undefined }) + const listeners = new Map void>>() + + const state = { styleLoaded } + const map = { + isStyleLoaded: vi.fn(() => state.styleLoaded), + getSource: vi.fn((id: string) => sources.get(id)), + addSource: vi.fn((id: string, spec: { type: string; data: unknown }) => { + const entry: FakeSource = { + data: spec.data, + setData: vi.fn((next: unknown) => { entry.data = next }), + } + sources.set(id, entry) + }), + removeSource: vi.fn((id: string) => { sources.delete(id) }), + getLayer: vi.fn((id: string) => layers.get(id)), + addLayer: vi.fn((spec: { id: string; paint?: Record }, before?: string) => { + layers.set(spec.id, { spec, before }) + }), + removeLayer: vi.fn((id: string) => { layers.delete(id) }), + on: vi.fn((type: string, fn: () => void) => { + const set = listeners.get(type) ?? new Set<() => void>() + set.add(fn) + listeners.set(type, set) + }), + off: vi.fn((type: string, fn: () => void) => { listeners.get(type)?.delete(fn) }), + } + + return { + map: map as unknown as MapboxMap, + spies: map, + sources, + layers, + state, + styleLoadListeners: () => listeners.get('style.load')?.size ?? 0, + emitStyleLoad: () => { listeners.get('style.load')?.forEach(fn => fn()) }, + /** What a style rebuild does to the map before `style.load` fires. */ + dropStyle: () => { sources.clear(); layers.clear() }, + drawn: () => sources.get(SOURCE)?.data as + | { features: Array<{ properties: { color: string; date: string }; geometry: { coordinates: Array<[number, number]> } }> } + | undefined, + } +} + +interface Props { + map: MapboxMap | null + ready: boolean + track?: DawarichTrack | null + date?: string | null + before?: string +} + +const mount = (props: Props) => + renderHook((p: Props) => useDawarichTrailGL(p.map, p.ready, p.track, p.date, p.before), { + initialProps: props, + }) + +function day(date: string, ...segments: Array>): DawarichTrackDay { + return { + date, + segments: segments.map(points => ({ + points, + mode: 'driving', + startedAt: date + 'T08:00:00.000Z', + endedAt: date + 'T09:00:00.000Z', + distanceMeters: 4200, + })), + } +} + +function track(days: DawarichTrackDay[]): DawarichTrack { + return { days, source: 'tracks', fetchedAt: '2026-05-03T10:00:00.000Z', pointCount: 4, truncated: false } +} + +const DAY_ONE = day('2026-05-01', [[48.2, 16.37], [48.21, 16.38]]) +const DAY_TWO = day('2026-05-02', [[45.46, 9.18], [45.47, 9.19]]) + +describe('useDawarichTrailGL', () => { + it('FE-DAWARICH-TRAILUI-020: touches nothing before there is a map and a ready style', () => { + const gl = fakeGlMap() + const view = mount({ map: null, ready: true, track: track([DAY_ONE]) }) + expect(gl.spies.addSource).not.toHaveBeenCalled() + + // A map that exists but is not ready is the more dangerous half: the object + // answers every call and throws from inside GL rather than from here. + view.rerender({ map: gl.map, ready: false, track: track([DAY_ONE]) }) + expect(gl.spies.addSource).not.toHaveBeenCalled() + expect(gl.styleLoadListeners()).toBe(0) + + view.rerender({ map: gl.map, ready: true, track: track([DAY_ONE]) }) + expect(gl.spies.addSource).toHaveBeenCalledTimes(1) + }) + + it('FE-DAWARICH-TRAILUI-021: builds the source and both layers under TREK-prefixed ids, anchored below the planned route', () => { + const gl = fakeGlMap({ styleLayers: ['trip-route-casing'] }) + mount({ map: gl.map, ready: true, track: track([DAY_ONE]), before: 'trip-route-casing' }) + + expect(gl.spies.addSource).toHaveBeenCalledWith(SOURCE, expect.objectContaining({ type: 'geojson' })) + + const [casingCall, lineCall] = gl.spies.addLayer.mock.calls + expect(casingCall[0].id).toBe(CASING_LAYER) + expect(casingCall[0].paint['line-color']).toBe(DAWARICH_TRAIL_CASING) + expect(casingCall[0].paint['line-width']).toBe(6) + expect(lineCall[0].id).toBe(LINE_LAYER) + // Per-feature colour, with the casing white as the fallback a feature + // without one falls back to. The GPX layer uses the same shape. + expect(lineCall[0].paint['line-color']).toEqual(['coalesce', ['get', 'color'], DAWARICH_TRAIL_CASING]) + // Dashed, so the recording is distinguishable from the planned route for + // someone who cannot tell the two hues apart. + expect(lineCall[0].paint['line-dasharray']).toEqual([2, 1.6]) + + // Both go beneath the planned route's casing. The line is inserted second + // and therefore ends up above its own casing and still below the plan. + expect(casingCall[1]).toBe('trip-route-casing') + expect(lineCall[1]).toBe('trip-route-casing') + }) + + it('FE-DAWARICH-TRAILUI-022: appends on top when the anchor layer is not on the style', () => { + // The trip-route overlay may not have mounted yet, or may be switched off + // entirely. Passing its id to addLayer then throws, so the hook has to look + // before it leaps and simply append instead. + const absent = fakeGlMap() + mount({ map: absent.map, ready: true, track: track([DAY_ONE]), before: 'trip-route-casing' }) + expect(absent.spies.addLayer.mock.calls.map(call => call[1])).toEqual([undefined, undefined]) + + const none = fakeGlMap() + mount({ map: none.map, ready: true, track: track([DAY_ONE]) }) + expect(none.spies.addLayer.mock.calls.map(call => call[1])).toEqual([undefined, undefined]) + }) + + it('FE-DAWARICH-TRAILUI-023: waits for style.load instead of drawing into a half-loaded style', () => { + const gl = fakeGlMap({ styleLoaded: false }) + mount({ map: gl.map, ready: true, track: track([DAY_ONE]) }) + + expect(gl.spies.addSource).not.toHaveBeenCalled() + expect(gl.styleLoadListeners()).toBe(1) + + // The whole GL map is torn down and reassembled when the provider, the + // style or the token changes; this subscription is what puts the overlay + // back afterwards. + gl.state.styleLoaded = true + gl.emitStyleLoad() + expect(gl.spies.addSource).toHaveBeenCalledTimes(1) + expect(gl.spies.addLayer).toHaveBeenCalledTimes(2) + }) + + it('FE-DAWARICH-TRAILUI-024: updates a surviving source in place rather than rebuilding it', () => { + const gl = fakeGlMap() + mount({ map: gl.map, ready: true, track: track([DAY_ONE]) }) + expect(gl.spies.addSource).toHaveBeenCalledTimes(1) + + // A style change that kept its sources, which is what setStyle does when it + // can diff the two. Rebuilding the source here would flicker the line for + // nothing, so the hook writes into the one that is already on the map. + gl.emitStyleLoad() + expect(gl.spies.addSource).toHaveBeenCalledTimes(1) + expect(gl.spies.addLayer).toHaveBeenCalledTimes(2) + expect(gl.sources.get(SOURCE)?.setData).toHaveBeenCalledTimes(1) + }) + + it('FE-DAWARICH-TRAILUI-025: hands GL the recording in GeoJSON order, and an empty collection when there is none', () => { + const empty = fakeGlMap() + mount({ map: empty.map, ready: true, track: null }) + // An empty collection rather than a skipped source: the layers still have + // to be on the style, so a style change that keeps its sources finds + // something to write into instead of leaving the map bare. + expect(empty.drawn()?.features).toEqual([]) + + // The track parameter is optional, so the overlay also has to build before + // anyone has handed the hook a track at all. Same empty collection, reached + // through the `?? null` rather than through an explicit null. + const unfetched = fakeGlMap() + mount({ map: unfetched.map, ready: true }) + expect(unfetched.drawn()?.features).toEqual([]) + + const gl = fakeGlMap() + mount({ map: gl.map, ready: true, track: track([DAY_ONE, DAY_TWO]) }) + const features = gl.drawn()?.features ?? [] + expect(features).toHaveLength(2) + // TREK stores [lat, lng] everywhere; GeoJSON wants the other order, and the + // overlay is the single place that flips it. Getting this backwards puts + // Vienna in the Indian Ocean. + expect(features[0].geometry.coordinates).toEqual([[16.37, 48.2], [16.38, 48.21]]) + expect(features[0].properties.color).toBe(DAWARICH_DAY_COLORS[0]) + expect(features[1].properties.color).toBe(DAWARICH_DAY_COLORS[1]) + }) + + it('FE-DAWARICH-TRAILUI-026: redraws for a picked day without repainting it', () => { + const gl = fakeGlMap() + const view = mount({ map: gl.map, ready: true, track: track([DAY_ONE, DAY_TWO]) }) + + view.rerender({ map: gl.map, ready: true, track: track([DAY_ONE, DAY_TWO]), date: '2026-05-02' }) + const features = gl.drawn()?.features ?? [] + expect(features).toHaveLength(1) + expect(features[0].properties.date).toBe('2026-05-02') + // Still the second hue, because the index comes from the day's place in the + // whole track. Picking a day must dim the others, not recolour the one. + expect(features[0].properties.color).toBe(DAWARICH_DAY_COLORS[1]) + }) + + it('FE-DAWARICH-TRAILUI-027: takes both layers and the source away with it, and stops listening', () => { + const gl = fakeGlMap() + const view = mount({ map: gl.map, ready: true, track: track([DAY_ONE]) }) + + view.unmount() + + // Layers before the source, in that order: a GL map refuses to remove a + // source that still carries a layer, so a teardown that started with the + // source would throw and leave both layers behind. + expect(gl.spies.removeLayer.mock.calls.map(call => call[0])).toEqual([LINE_LAYER, CASING_LAYER]) + expect(gl.spies.removeSource).toHaveBeenCalledWith(SOURCE) + expect(gl.layers.size).toBe(0) + expect(gl.sources.size).toBe(0) + expect(gl.styleLoadListeners()).toBe(0) + }) + + it('FE-DAWARICH-TRAILUI-028: does not try to remove what a style rebuild already took', () => { + const gl = fakeGlMap() + const view = mount({ map: gl.map, ready: true, track: track([DAY_ONE]) }) + + // setStyle drops every source and layer the previous style carried. The + // teardown then runs against a map that has never heard of them, and a + // blind removeLayer there throws out of a cleanup function. + gl.dropStyle() + view.unmount() + + expect(gl.spies.removeLayer).not.toHaveBeenCalled() + expect(gl.spies.removeSource).not.toHaveBeenCalled() + expect(gl.spies.off).toHaveBeenCalledWith('style.load', expect.any(Function)) + }) + + it('FE-DAWARICH-TRAILUI-029: rebuilds the overlay when the refresh hands it a new track, without leaving a listener behind', () => { + const gl = fakeGlMap() + const view = mount({ map: gl.map, ready: true, track: track([DAY_ONE]) }) + const firstSource = gl.sources.get(SOURCE) + expect(firstSource?.setData).toBeTypeOf('function') + + // `useDawarichTrail` refetches every two minutes and returns a fresh object + // every time, so the effect's dependency changes and React tears the whole + // overlay down before rebuilding it. The setData path above belongs to a + // style change that kept its sources, not to a refresh. Pinned as the + // behaviour it is, so that making the refresh cheap later is a visible + // change to this file rather than a silent one on the map. + view.rerender({ map: gl.map, ready: true, track: track([DAY_ONE, DAY_TWO]) }) + + expect(gl.spies.removeSource).toHaveBeenCalledWith(SOURCE) + expect(gl.spies.addSource).toHaveBeenCalledTimes(2) + expect(firstSource?.setData).not.toHaveBeenCalled() + expect(gl.drawn()?.features).toHaveLength(2) + + // One subscription, not two. The cleanup unsubscribes before the next + // effect subscribes; without that the overlay would redraw once per style + // change per refresh, for as long as the map lives. + expect(gl.styleLoadListeners()).toBe(1) + }) +}) diff --git a/client/src/components/Settings/DawarichConnectionSection.test.tsx b/client/src/components/Settings/DawarichConnectionSection.test.tsx new file mode 100644 index 000000000..7a3f78e7c --- /dev/null +++ b/client/src/components/Settings/DawarichConnectionSection.test.tsx @@ -0,0 +1,597 @@ +/** + * FE-DAWARICH-SETTINGS-001 to FE-DAWARICH-SETTINGS-018: the desktop + * Settings -> Integrations -> Dawarich card. + * + * The card is markup over `useDawarichConnection`, so it is driven here the way + * the AirTrail card next to it is driven: the real hook, the real axios client, + * MSW answering the four endpoints. A test that handed the section a fake state + * object would pin the JSX and nothing else, and the things that actually break + * on this surface are round trips: what leaves in the PUT body, what the card + * believes after the answer comes back. + * + * What is worth pinning, and what breaks if it regresses: + * + * - **The key is never prefilled.** The server answers with a mask, never the + * stored key, and the field stays empty; the mask is only a placeholder. If + * the field were ever bound to the masked value, saving would send the + * bullets as the new key and lock the user out of their own instance. + * - **Blank key means "keep the stored one".** A connected instance must save a + * changed URL or a changed toggle without the user digging their key out + * again, so `apiKey` is absent from the body, not an empty string, which the + * server would read as "clear it". + * - **`canSave`.** An address alone is not a connection: saving needs either an + * existing connection or a typed key. And nothing may be saved while the card + * is still reading the settings it is about to overwrite. + * - **The status block**, which is the part of this card that earns its space. + * A connection to somebody else's server fails in ways the owner can act on + * (a rejected key, a certificate, a Dawarich too old for half the endpoints), + * and every one of those reasons reaches the reader through this one block. + * Each branch of it (probe message, never-synced vs. last-synced, the partial + * suffix, the stored error, the server version, the missing-capability list) + * is pinned, because a silent one turns "not connected" into a support ticket. + * - **The capability list's `tracks || points` rule.** The recorded route can + * come from either endpoint, so only an instance offering neither is missing + * it. Reporting "recorded route" as missing on an instance that serves points + * would tell the user to upgrade for something they already have. + * + * Toasts are asserted through a mocked `useToast` rather than a rendered + * container: two of them repeat the sentence the status block already shows, and + * a duplicated string makes every `getByText` in this file ambiguous. + */ +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DawarichCapabilities } from '@trek/shared'; +import { render, screen, waitFor } from '../../../tests/helpers/render'; +import { server } from '../../../tests/helpers/msw/server'; +import { resetAllStores } from '../../../tests/helpers/store'; +import DawarichConnectionSection from './DawarichConnectionSection'; + +const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }; +vi.mock('../shared/Toast', () => ({ + useToast: () => toast, + default: () => toast, + ToastContainer: () => null, +})); + +const SETTINGS_URL = '/api/integrations/dawarich/settings'; +const TEST_URL = '/api/integrations/dawarich/test'; +const SYNC_URL = '/api/integrations/dawarich/sync'; + +/** The desktop card renders this exact placeholder in place of a stored key. */ +const MASK = '••••••••'; + +interface SavedBody { + url: string; + apiKey?: string; + allowInsecureTls?: boolean; + syncEnabled?: boolean; +} + +/** A Dawarich that answers everything TREK knows how to ask for. */ +const CAPS_ALL: DawarichCapabilities = { + visits: true, + tracks: true, + points: true, + locations: true, + visitedCities: true, + visitUpdatedAt: true, + visitCountryCode: true, + serverVersion: '1.14.4', + probedAt: '2026-09-12T09:00:00.000Z', +}; + +function caps(over: Partial = {}): DawarichCapabilities { + return { ...CAPS_ALL, ...over }; +} + +/** + * The stored connection, as the GET answers it. Mutable because saving and + * disconnecting re-read it: the card's post-write state comes from this second + * read, not from the write's own answer, so the stub has to behave like a store + * rather than a fixture. + */ +let stored: Record; + +const DISCONNECTED = { + url: '', + apiKeyMasked: '', + allowInsecureTls: false, + syncEnabled: true, + connected: false, + lastSyncAt: null, + lastSyncState: 'never', + lastSyncError: null, + capabilities: null, +}; + +const CONNECTED = { + ...DISCONNECTED, + url: 'https://dawarich.example.com', + apiKeyMasked: MASK, + connected: true, + capabilities: CAPS_ALL, +}; + +function stubSettings(over: Record = {}): void { + stored = { ...DISCONNECTED, ...over }; + server.use(http.get(SETTINGS_URL, () => HttpResponse.json(stored))); +} + +/** A promise the test releases by hand, to observe an in-flight request. */ +function deferred(): { promise: Promise; release: () => void } { + let release!: () => void; + const promise = new Promise(resolve => { + release = resolve; + }); + return { promise, release }; +} + +function renderSection() { + return render(); +} + +/** Both switches carry their label as an aria-label, so the role query reaches them. */ +function toggle(label: string): HTMLElement { + return screen.getByRole('button', { name: label }); +} + +const SYNC_TOGGLE = 'Check for new stays automatically'; +const TLS_TOGGLE = 'Allow self-signed certificate'; + +function urlField(): HTMLElement { + return screen.getByLabelText('Instance address'); +} + +function keyField(): HTMLElement { + return screen.getByLabelText('API key'); +} + +function saveButton(): HTMLElement { + return screen.getByRole('button', { name: /^Save$/ }); +} + +function testButton(): HTMLElement { + return screen.getByRole('button', { name: 'Test connection' }); +} + +beforeEach(() => { + resetAllStores(); + vi.clearAllMocks(); + stubSettings(); +}); + +describe('DawarichConnectionSection', () => { + it('FE-DAWARICH-SETTINGS-001: an unconfigured card is empty, disconnected, blocked and shows no status block', async () => { + // An unconfigured card looks identical before and after its settings land, + // so there is no rendered marker to wait on. Counting the read is what + // makes the assertions below about a hydrated card rather than first paint. + const read = vi.fn(); + server.use( + http.get(SETTINGS_URL, () => { + read(); + return HttpResponse.json(stored); + }), + ); + renderSection(); + + await waitFor(() => expect(read).toHaveBeenCalled()); + expect(testButton()).toBeDisabled(); + expect(saveButton()).toBeDisabled(); + expect(urlField()).toHaveValue(''); + expect(keyField()).toHaveValue(''); + // No connection means no key to hide, so the field says what it wants. + expect(keyField()).toHaveAttribute('placeholder', 'Paste your Dawarich API key'); + expect(screen.getByText('Not connected')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Check now' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Disconnect' })).toBeNull(); + // Nothing to report yet: the block would be an empty grey box. + expect(screen.queryByText('Not checked yet')).toBeNull(); + }); + + it('FE-DAWARICH-SETTINGS-002: a stored connection hydrates the fields, the toggles, the badge and the status block', async () => { + stubSettings({ + ...CONNECTED, + allowInsecureTls: true, + syncEnabled: false, + lastSyncAt: '2026-09-12T08:30:00.000Z', + lastSyncState: 'ok', + }); + renderSection(); + + expect(await screen.findByDisplayValue('https://dawarich.example.com')).toBeInTheDocument(); + expect(toggle(TLS_TOGGLE)).toHaveAttribute('aria-pressed', 'true'); + expect(toggle(SYNC_TOGGLE)).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByText('Connected')).toBeInTheDocument(); + // The stored key is hidden behind the placeholder and never put in the field. + expect(keyField()).toHaveAttribute('placeholder', MASK); + expect(keyField()).toHaveValue(''); + expect(screen.getByRole('button', { name: 'Check now' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Disconnect' })).toBeEnabled(); + expect(screen.getByText(/^Last checked /)).toBeInTheDocument(); + expect(screen.getByText('Dawarich 1.14.4')).toBeInTheDocument(); + // Everything answered, so there is nothing to warn about. + expect(screen.queryByText(/does not offer/)).toBeNull(); + expect(screen.queryByText('some trips could not be read')).toBeNull(); + }); + + it('FE-DAWARICH-SETTINGS-003: a failed read of our own settings leaves an empty card rather than a false verdict', async () => { + // Same as -001: a failed read changes nothing on screen, so the request + // itself is the only proof the card got as far as failing. + const read = vi.fn(); + server.use( + http.get(SETTINGS_URL, () => { + read(); + return HttpResponse.json({ error: 'boom' }, { status: 500 }); + }), + ); + renderSection(); + + // Losing our own settings is a TREK problem: the card must not claim the + // user's instance is unreachable, and must not stay locked in `loading`. + await waitFor(() => expect(read).toHaveBeenCalled()); + await waitFor(() => expect(testButton()).toBeDisabled()); + expect(screen.getByText('Not connected')).toBeInTheDocument(); + expect(urlField()).toHaveValue(''); + expect(screen.queryByText('Not checked yet')).toBeNull(); + }); + + it('FE-DAWARICH-SETTINGS-004: nothing can be saved while the stored settings are still being read', async () => { + const user = userEvent.setup(); + const gate = deferred(); + server.use( + http.get(SETTINGS_URL, async () => { + await gate.promise; + return HttpResponse.json({ ...CONNECTED, connected: false }); + }), + ); + renderSection(); + + await user.type(urlField(), 'https://typed.example.com'); + await user.type(keyField(), 'typed-key'); + // URL and key are both there, so only the in-flight read is holding Save. + expect(saveButton()).toBeDisabled(); + // And the same lock on Test. With an address typed this is the one place + // the `loading` term of that condition is visible at all. Everywhere else + // the empty-address term hides it. + expect(testButton()).toBeDisabled(); + + gate.release(); + await waitFor(() => expect(saveButton()).toBeEnabled()); + }); + + it('FE-DAWARICH-SETTINGS-005: a stored address that never connected still needs a typed key before it can be saved', async () => { + const user = userEvent.setup(); + // The state a failed first attempt leaves behind: the address was stored, + // the key was not accepted, so there is no connection to fall back on. + stubSettings({ url: 'https://dawarich.example.com', connected: false }); + renderSection(); + + await screen.findByDisplayValue('https://dawarich.example.com'); + // The address is enough to probe with, but not enough to store. Waited for + // rather than read straight off the render that showed the address: the + // hook drops the `loading` lock in a later tick than it sets the fields. + await waitFor(() => expect(testButton()).toBeEnabled()); + expect(saveButton()).toBeDisabled(); + + await user.type(keyField(), 'key-123'); + expect(saveButton()).toBeEnabled(); + }); + + it('FE-DAWARICH-SETTINGS-006: a connected instance saves without retyping the key, and typing one drops the mask', async () => { + const user = userEvent.setup(); + let body: SavedBody | undefined; + stubSettings(CONNECTED); + server.use( + http.put(SETTINGS_URL, async ({ request }) => { + body = (await request.json()) as SavedBody; + return HttpResponse.json({ success: true }); + }), + ); + renderSection(); + + await screen.findByDisplayValue('https://dawarich.example.com'); + // A click on a disabled button is swallowed rather than failing, so every + // click below waits for the `loading` lock to lift first. + await waitFor(() => expect(saveButton()).toBeEnabled()); + await user.click(saveButton()); + + // No `apiKey` key at all: an empty string would read as "clear the key". + await waitFor(() => + expect(body).toEqual({ + url: 'https://dawarich.example.com', + allowInsecureTls: false, + syncEnabled: true, + }), + ); + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Dawarich connection saved')); + + await user.type(keyField(), 'replacement'); + // Once there is something to send, the mask stops pretending the field is full. + expect(keyField()).toHaveAttribute('placeholder', 'Paste your Dawarich API key'); + }); + + it('FE-DAWARICH-SETTINGS-007: the save sends the trimmed address, the typed key and both toggles, then clears the key field', async () => { + const user = userEvent.setup(); + let body: SavedBody | undefined; + // Auto-sync stored as off, so the switch flipping to on is the proof that + // the settings read landed before anything is typed: the effect that + // hydrates the card also rewrites the URL field. + stubSettings({ syncEnabled: false }); + server.use( + http.put(SETTINGS_URL, async ({ request }) => { + body = (await request.json()) as SavedBody; + // The card believes the re-read, not the write's own answer. + stored = { ...stored, url: body.url, connected: true, capabilities: CAPS_ALL }; + return HttpResponse.json({ success: true }); + }), + ); + renderSection(); + + await waitFor(() => expect(toggle(SYNC_TOGGLE)).toHaveAttribute('aria-pressed', 'false')); + // Both fields are padded, but only the key actually pins `trim()`: jsdom + // runs the value-sanitisation algorithm for input[type=url], which strips + // the surrounding whitespace before the hook ever sees the address. The + // password field has no such algorithm, so the space it keeps is real. + await user.type(urlField(), ' https://dawarich.example.com '); + await user.type(keyField(), ' key-123 '); + await user.click(toggle(SYNC_TOGGLE)); + await user.click(toggle(TLS_TOGGLE)); + await user.click(saveButton()); + + // Both switches send what is on screen, not what was stored. + await waitFor(() => + expect(body).toEqual({ + url: 'https://dawarich.example.com', + allowInsecureTls: true, + syncEnabled: true, + apiKey: 'key-123', + }), + ); + expect(toggle(SYNC_TOGGLE)).toHaveAttribute('aria-pressed', 'true'); + expect(toggle(TLS_TOGGLE)).toHaveAttribute('aria-pressed', 'true'); + await waitFor(() => expect(screen.getByText('Connected')).toBeInTheDocument()); + expect(keyField()).toHaveValue(''); + expect(keyField()).toHaveAttribute('placeholder', MASK); + }); + + it('FE-DAWARICH-SETTINGS-008: an in-flight save locks both Save and Disconnect', async () => { + const user = userEvent.setup(); + const gate = deferred(); + stubSettings(CONNECTED); + server.use( + http.put(SETTINGS_URL, async () => { + await gate.promise; + return HttpResponse.json({ success: true }); + }), + ); + renderSection(); + + await screen.findByDisplayValue('https://dawarich.example.com'); + await waitFor(() => expect(saveButton()).toBeEnabled()); + await user.click(saveButton()); + + await waitFor(() => expect(saveButton()).toBeDisabled()); + // Disconnecting mid-save would race the write it is trying to undo. + expect(screen.getByRole('button', { name: 'Disconnect' })).toBeDisabled(); + + gate.release(); + await waitFor(() => expect(saveButton()).toBeEnabled()); + }); + + it('FE-DAWARICH-SETTINGS-009: a rejected save reports the server reason code as a sentence, not as a code', async () => { + const user = userEvent.setup(); + stubSettings(CONNECTED); + server.use( + http.put(SETTINGS_URL, () => + HttpResponse.json({ code: 'unauthorized', error: 'Unauthorized' }, { status: 400 }), + ), + ); + renderSection(); + + await screen.findByDisplayValue('https://dawarich.example.com'); + await waitFor(() => expect(saveButton()).toBeEnabled()); + await user.click(saveButton()); + + // The code wins over the server's English prose, so a German install reads German. + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Dawarich rejected the API key.')); + // A refused save leaves the card usable so the reader can correct the key. + await waitFor(() => expect(saveButton()).toBeEnabled()); + }); + + it('FE-DAWARICH-SETTINGS-010: a probe shows a spinner while it runs, then reports the stay count and flips the badge', async () => { + const user = userEvent.setup(); + const gate = deferred(); + let body: Partial | undefined; + stubSettings({ url: 'https://dawarich.example.com' }); + server.use( + http.post(TEST_URL, async ({ request }) => { + body = (await request.json()) as Partial; + await gate.promise; + return HttpResponse.json({ connected: true, visitCount: 12, capabilities: CAPS_ALL }); + }), + ); + renderSection(); + + await screen.findByDisplayValue('https://dawarich.example.com'); + await waitFor(() => expect(testButton()).toBeEnabled()); + await user.type(keyField(), 'probe-key'); + await user.click(testButton()); + + await waitFor(() => expect(testButton()).toBeDisabled()); + expect(testButton().querySelector('.animate-spin')).not.toBeNull(); + + gate.release(); + expect(await screen.findByText('Connected. 12 stays found in the last 30 days.')).toBeInTheDocument(); + expect(screen.getByText('Connected')).toBeInTheDocument(); + // The probe uses what is on screen, not what is stored. That is the point of it. + expect(body).toEqual({ url: 'https://dawarich.example.com', allowInsecureTls: false, apiKey: 'probe-key' }); + expect(screen.getByText('Dawarich 1.14.4')).toBeInTheDocument(); + }); + + it('FE-DAWARICH-SETTINGS-011: a refused probe keeps the badge off and puts the reason plus the detail in the status block', async () => { + const user = userEvent.setup(); + stubSettings({ url: 'https://dawarich.example.com' }); + server.use( + http.post(TEST_URL, () => + HttpResponse.json({ + connected: false, + error: 'unreachable', + errorDetail: 'self-signed certificate in chain', + }), + ), + ); + renderSection(); + + await screen.findByDisplayValue('https://dawarich.example.com'); + await waitFor(() => expect(testButton()).toBeEnabled()); + await user.click(testButton()); + + // The block gets the detail, the toast stays one line: a self-hoster needs + // to know it was the certificate and not the host being down. + expect( + await screen.findByText('TREK could not reach that address. (self-signed certificate in chain)'), + ).toBeInTheDocument(); + expect(toast.error).toHaveBeenCalledWith('TREK could not reach that address.'); + expect(screen.getByText('Not connected')).toBeInTheDocument(); + }); + + it('FE-DAWARICH-SETTINGS-012: a probe that never reaches the server falls back to the generic sentence', async () => { + const user = userEvent.setup(); + stubSettings({ url: 'https://dawarich.example.com' }); + server.use(http.post(TEST_URL, () => HttpResponse.json({}, { status: 500 }))); + renderSection(); + + await screen.findByDisplayValue('https://dawarich.example.com'); + await waitFor(() => expect(testButton()).toBeEnabled()); + await user.click(testButton()); + + // No code and no prose from the server: exactly one sentence, no empty parentheses. + expect(await screen.findByText('Could not reach Dawarich.')).toBeInTheDocument(); + expect(toast.error).toHaveBeenCalledWith('Could not reach Dawarich.'); + }); + + it('FE-DAWARICH-SETTINGS-013: Check now spins while it runs and replaces "never" with a timestamp', async () => { + const user = userEvent.setup(); + const gate = deferred(); + stubSettings(CONNECTED); + server.use( + http.post(SYNC_URL, async () => { + await gate.promise; + return HttpResponse.json({ state: 'ok', created: 3, updated: 0, missing: 0 }); + }), + ); + renderSection(); + + expect(await screen.findByText('Not checked yet')).toBeInTheDocument(); + const syncBtn = screen.getByRole('button', { name: 'Check now' }); + await user.click(syncBtn); + + await waitFor(() => expect(syncBtn).toBeDisabled()); + expect(syncBtn.querySelector('.animate-spin')).not.toBeNull(); + + gate.release(); + expect(await screen.findByText(/^Last checked /)).toBeInTheDocument(); + expect(screen.queryByText('Not checked yet')).toBeNull(); + // Said as a count: a sync that found nothing and one that failed silently + // look identical if the toast only says "done". + expect(toast.success).toHaveBeenCalledWith('3 new stays found'); + }); + + it('FE-DAWARICH-SETTINGS-014: a sync that is already running reports itself and invents no result', async () => { + const user = userEvent.setup(); + stubSettings(CONNECTED); + server.use( + http.post(SYNC_URL, () => + HttpResponse.json({ state: 'ok', created: 0, updated: 0, missing: 0, alreadyRunning: true }), + ), + ); + renderSection(); + + await screen.findByText('Not checked yet'); + await user.click(screen.getByRole('button', { name: 'Check now' })); + + await waitFor(() => expect(toast.info).toHaveBeenCalledWith('A check is already running')); + // "0 new stays" would be a result, and there is no result yet. + expect(toast.success).not.toHaveBeenCalled(); + expect(screen.getByText('Not checked yet')).toBeInTheDocument(); + }); + + it('FE-DAWARICH-SETTINGS-015: a failed sync falls back to the generic message and leaves the last timestamp alone', async () => { + const user = userEvent.setup(); + stubSettings({ ...CONNECTED, lastSyncAt: '2026-09-12T08:30:00.000Z', lastSyncState: 'ok' }); + server.use(http.post(SYNC_URL, () => HttpResponse.json({}, { status: 502 }))); + renderSection(); + + await screen.findByText(/^Last checked /); + await user.click(screen.getByRole('button', { name: 'Check now' })); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Could not read Dawarich')); + // A failed attempt is not a check: the timestamp still belongs to the last real one. + expect(screen.getByText(/^Last checked /)).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Check now' })).toBeEnabled()); + }); + + it('FE-DAWARICH-SETTINGS-016: a partial sync on an old instance shows the suffix, the stored reason and every missing capability', async () => { + stubSettings({ + ...CONNECTED, + lastSyncAt: '2026-09-12T08:30:00.000Z', + lastSyncState: 'partial', + lastSyncError: 'unauthorized', + capabilities: caps({ + visits: false, + tracks: false, + points: false, + locations: false, + visitedCities: false, + serverVersion: null, + }), + }); + renderSection(); + + expect(await screen.findByText(/some trips could not be read/)).toBeInTheDocument(); + expect(screen.getByText('Dawarich rejected the API key.')).toBeInTheDocument(); + expect( + screen.getByText( + 'This Dawarich version does not offer: stays, recorded route, wishlist matching, countries and cities.', + ), + ).toBeInTheDocument(); + // An instance that does not report its version gets no version line at all, + // rather than a line with a blank in it. (The section heading is the bare + // word "Dawarich", so the version line is the one followed by a digit.) + expect(screen.queryByText(/^Dawarich \d/)).toBeNull(); + }); + + it('FE-DAWARICH-SETTINGS-017: an instance serving points but not tracks is not told its recorded route is missing', async () => { + stubSettings({ + ...CONNECTED, + capabilities: caps({ tracks: false, points: true, visitedCities: false, serverVersion: '1.15.0' }), + }); + renderSection(); + + // Either endpoint can draw the route, so only the Atlas feed is actually gone. + expect(await screen.findByText('This Dawarich version does not offer: countries and cities.')).toBeInTheDocument(); + expect(screen.getByText('Dawarich 1.15.0')).toBeInTheDocument(); + }); + + it('FE-DAWARICH-SETTINGS-018: disconnecting empties the card back to its unconfigured state', async () => { + const user = userEvent.setup(); + stubSettings({ ...CONNECTED, lastSyncAt: '2026-09-12T08:30:00.000Z', lastSyncState: 'ok' }); + server.use(http.delete(SETTINGS_URL, () => HttpResponse.json({ success: true }))); + renderSection(); + + await screen.findByDisplayValue('https://dawarich.example.com'); + await user.click(screen.getByRole('button', { name: 'Disconnect' })); + + await waitFor(() => expect(screen.getByText('Not connected')).toBeInTheDocument()); + expect(toast.success).toHaveBeenCalledWith('Dawarich disconnected'); + expect(urlField()).toHaveValue(''); + expect(keyField()).toHaveAttribute('placeholder', 'Paste your Dawarich API key'); + expect(screen.queryByRole('button', { name: 'Check now' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Disconnect' })).toBeNull(); + // Nothing left to report, so the whole status block goes with it. + expect(screen.queryByText(/^Last checked /)).toBeNull(); + expect(screen.queryByText('Not checked yet')).toBeNull(); + expect(screen.queryByText('Dawarich 1.14.4')).toBeNull(); + }); +}); diff --git a/client/src/hooks/useDawarichConnection.test.ts b/client/src/hooks/useDawarichConnection.test.ts new file mode 100644 index 000000000..ba8ac0960 --- /dev/null +++ b/client/src/hooks/useDawarichConnection.test.ts @@ -0,0 +1,721 @@ +/** + * FE-DAWARICH-CONN-001 to FE-DAWARICH-CONN-027: the connection card's one hook. + * + * Everything the Dawarich connection card does lives here, desktop shell and + * phone shell alike, which means everything it can get wrong lives here too. + * Four things are worth pinning, and the cases below are grouped around them. + * + * 1. **The reason-code chain.** Dawarich answers in English and occasionally + * quotes the URL back, so the hook renders the server's *code* and keeps the + * upstream sentence only as the fallback for a code this build predates. A + * regression here crashes nothing: it quietly ships English, or a bare + * `dawarich.error.foo` key, onto a German install. Every branch of it is a + * sentence a real person reads, so `lastSyncError`, the probe panel, the + * private-IP warning and all four error toasts each get their own case. + * 2. **"Blank means keep the stored key".** The key field is never prefilled, + * so an untouched field must leave `apiKey` out of the payload entirely. + * Sending `apiKey: ''` instead would wipe a working connection on the next + * save of an unrelated switch. + * 3. **The flags nobody sets by hand.** `canSave` (an address alone is not a + * connection), `alreadyRunning` (a sync in flight has no result yet, so + * "0 new stays" would be a claim rather than a report), and the three + * in-flight booleans the buttons disable themselves with. + * 4. **The unmount guard** on the initial read, the reason a card closed + * mid-request does not write into a dead component. + * + * The API and the toaster are mocked, the way the neighbouring + * `useDawarichSuggestions` test does it. Translation is not: `en` is bundled + * statically, so the real provider answers synchronously, and the hook's + * fallbacks depend on `t()` returning the key untouched for a string this build + * does not have, which only the real one does. + */ +import { createElement, type ReactNode } from 'react' +import { act, renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { DawarichCapabilities, DawarichConnection, DawarichStatus } from '@trek/shared' +import { TranslationProvider } from '../i18n/TranslationContext' +import { useDawarichConnection } from './useDawarichConnection' + +const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() } +vi.mock('../components/shared/Toast', () => ({ useToast: () => toast, default: () => toast })) + +const api = { + getSettings: vi.fn(), + saveSettings: vi.fn(), + test: vi.fn(), + syncNow: vi.fn(), + disconnect: vi.fn(), +} +vi.mock('../api/dawarich', () => ({ + dawarichApi: { + getSettings: (...args: unknown[]) => api.getSettings(...args), + saveSettings: (...args: unknown[]) => api.saveSettings(...args), + test: (...args: unknown[]) => api.test(...args), + syncNow: (...args: unknown[]) => api.syncNow(...args), + disconnect: (...args: unknown[]) => api.disconnect(...args), + }, +})) + +const CAPS: DawarichCapabilities = { + visits: true, + tracks: true, + points: false, + locations: true, + visitedCities: false, + visitUpdatedAt: false, + visitCountryCode: false, + serverVersion: '1.14.4', + probedAt: '2026-09-01T08:00:00.000Z', +} + +/** + * A connected instance with a clean history. Written out in full rather than + * cast from a fragment so the day the contract grows a field, this file is one + * of the places that has to answer for it. + */ +function connection(over: Partial = {}): DawarichConnection { + return { + url: 'https://dawarich.example', + apiKeyMasked: '****abcd', + allowInsecureTls: false, + syncEnabled: true, + connected: true, + lastSyncAt: '2026-09-01T07:00:00.000Z', + lastSyncState: 'ok', + lastSyncError: null, + capabilities: CAPS, + ...over, + } +} + +/** A promise this test controls the settling of, to pin down state mid-flight. */ +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children) + +function mount() { + return renderHook(() => useDawarichConnection(), { wrapper }) +} + +async function mounted(data: DawarichConnection = connection()) { + api.getSettings.mockResolvedValue(data) + const hook = mount() + await waitFor(() => expect(hook.result.current.loading).toBe(false)) + return hook +} + +// The shipped English for the keys this hook reaches for. Written out rather +// than read back from the bundle so a reader of this file sees the sentence that +// ends up on screen, and so a reworded string is a decision somebody makes here +// too. The private-IP warning is the one exception: it is long enough that +// transcribing it would test the transcription, so it is asserted by its parts. +const EN = { + probeOk: (count: number) => `Connected. ${count} stays found in the last 30 days.`, + probeFailed: 'Could not reach Dawarich.', + saved: 'Dawarich connection saved', + saveError: 'Could not save the connection', + disconnected: 'Dawarich disconnected', + synced: (count: number) => `${count} new stays found`, + syncError: 'Could not read Dawarich', + syncRunning: 'A check is already running', + unreachable: 'TREK could not reach that address.', + unauthorized: 'Dawarich rejected the API key.', + unknown: 'Something went wrong talking to Dawarich.', +} + +/** The last thing a toaster was handed, for the assertions that read a sentence apart. */ +function lastToast(fn: typeof toast.warning): string { + const calls = fn.mock.calls + return calls[calls.length - 1]?.[0] as string +} + +beforeEach(() => { + toast.success.mockReset() + toast.error.mockReset() + toast.info.mockReset() + toast.warning.mockReset() + api.getSettings.mockReset().mockResolvedValue(connection()) + api.saveSettings.mockReset().mockResolvedValue({ success: true }) + api.test.mockReset().mockResolvedValue({ connected: true, visitCount: 4, capabilities: CAPS }) + api.syncNow.mockReset().mockResolvedValue({ state: 'ok', created: 2, updated: 1, missing: 0 }) + api.disconnect.mockReset().mockResolvedValue({ success: true }) +}) + +describe('useDawarichConnection: the initial read', () => { + it('FE-DAWARICH-CONN-001: starts loading and then hydrates every field from the stored connection', async () => { + const settings = deferred() + api.getSettings.mockReturnValue(settings.promise) + const { result } = mount() + + expect(result.current.loading).toBe(true) + expect(result.current.url).toBe('') + expect(result.current.connected).toBe(false) + + await act(async () => { + settings.resolve(connection({ lastSyncState: 'partial', lastSyncError: 'unauthorized' })) + await settings.promise + }) + + expect(result.current.loading).toBe(false) + expect(result.current.url).toBe('https://dawarich.example') + expect(result.current.connected).toBe(true) + expect(result.current.syncEnabled).toBe(true) + expect(result.current.allowInsecureTls).toBe(false) + expect(result.current.lastSyncAt).toBe('2026-09-01T07:00:00.000Z') + expect(result.current.lastSyncState).toBe('partial') + expect(result.current.lastSyncError).toBe(EN.unauthorized) + expect(result.current.capabilities).toEqual(CAPS) + // The stored key never leaves the server, so the field is blank even though + // the connection is live. Everything below about `canSave` follows from it. + expect(result.current.apiKey).toBe('') + }) + + it('FE-DAWARICH-CONN-002: an instance that was never connected leaves the card empty and keeps sync switched off', async () => { + const { result } = await mounted(connection({ + url: '', + allowInsecureTls: true, + syncEnabled: false, + connected: false, + lastSyncAt: null, + lastSyncState: 'never', + capabilities: null, + })) + + expect(result.current.url).toBe('') + expect(result.current.allowInsecureTls).toBe(true) + // `syncEnabled` defaults to on, so an explicit false is the one value that + // has to survive the round trip intact. + expect(result.current.syncEnabled).toBe(false) + expect(result.current.connected).toBe(false) + expect(result.current.capabilities).toBeNull() + expect(result.current.lastSyncAt).toBeNull() + expect(result.current.lastSyncError).toBeNull() + expect(result.current.canSave).toBe(false) + }) + + it('FE-DAWARICH-CONN-003: a failed read of our own settings clears loading without blaming the instance', async () => { + api.getSettings.mockRejectedValue(new Error('500')) + const { result } = mount() + + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.url).toBe('') + expect(result.current.connected).toBe(false) + expect(result.current.lastSyncState).toBe('never') + // Our own endpoint failing is a TREK problem. An error toast here would send + // the reader off editing a URL that was never at fault. + expect(toast.error).not.toHaveBeenCalled() + }) + + it('FE-DAWARICH-CONN-004: settings that arrive after the card is gone are dropped, not written into a dead component', async () => { + const settings = deferred() + api.getSettings.mockReturnValue(settings.promise) + const { result, unmount } = mount() + + unmount() + await act(async () => { + settings.resolve(connection()) + await settings.promise + }) + + // The last rendered values are the pre-flight ones: nothing was set after + // the unmount, not even the loading flag in the `finally`. + expect(result.current.loading).toBe(true) + expect(result.current.url).toBe('') + }) + + it('FE-DAWARICH-CONN-005: the last sync error is rendered from its code, and an unrecognised code still gets a sentence', async () => { + const known = await mounted(connection({ lastSyncState: 'failed', lastSyncError: 'unreachable' })) + expect(known.result.current.lastSyncError).toBe(EN.unreachable) + + // A code from a server newer than this build. It must not reach the screen + // as the bare key, which is what happens the day the fallback goes. + const ahead = await mounted(connection({ lastSyncState: 'failed', lastSyncError: 'quota_exhausted' })) + expect(ahead.result.current.lastSyncError).toBe(EN.unknown) + expect(ahead.result.current.lastSyncError).not.toBe('dawarich.error.quota_exhausted') + + const clean = await mounted(connection({ lastSyncError: null })) + expect(clean.result.current.lastSyncError).toBeNull() + }) +}) + +describe('useDawarichConnection: the save gate', () => { + it('FE-DAWARICH-CONN-006: saving needs an address plus either a live connection or a freshly typed key', async () => { + const { result } = await mounted(connection({ + url: '', + connected: false, + capabilities: null, + lastSyncAt: null, + lastSyncState: 'never', + })) + expect(result.current.canSave).toBe(false) + + act(() => result.current.setUrl(' ')) + expect(result.current.canSave).toBe(false) + + // An address on its own: nothing stored, nothing typed, so there is no key + // to save it with. + act(() => result.current.setUrl('https://dawarich.example')) + expect(result.current.canSave).toBe(false) + + act(() => result.current.setApiKey(' ')) + expect(result.current.canSave).toBe(false) + + act(() => result.current.setApiKey('k-123')) + expect(result.current.canSave).toBe(true) + }) + + it('FE-DAWARICH-CONN-007: an already connected instance stays saveable with the key field left blank', async () => { + const { result } = await mounted() + expect(result.current.apiKey).toBe('') + expect(result.current.canSave).toBe(true) + }) + + it('FE-DAWARICH-CONN-008: both switches flip, and the save they trigger carries no key at all', async () => { + const { result } = await mounted() + + act(() => result.current.toggleInsecureTls()) + act(() => result.current.toggleSync()) + expect(result.current.allowInsecureTls).toBe(true) + expect(result.current.syncEnabled).toBe(false) + + await act(async () => { await result.current.save() }) + + // No `apiKey` property whatsoever: an empty string would clear the stored + // key, and this save was about two switches. + expect(api.saveSettings).toHaveBeenCalledWith({ + url: 'https://dawarich.example', + allowInsecureTls: true, + syncEnabled: false, + }) + + act(() => result.current.toggleInsecureTls()) + expect(result.current.allowInsecureTls).toBe(false) + }) +}) + +describe('useDawarichConnection: save', () => { + it('FE-DAWARICH-CONN-009: save trims the address, sends the typed key once, re-reads the connection and clears the field', async () => { + const { result } = await mounted(connection({ + connected: false, + capabilities: null, + lastSyncState: 'failed', + lastSyncError: 'unreachable', + })) + act(() => { + result.current.setUrl(' https://new.example ') + result.current.setApiKey(' k-123 ') + }) + + const pending = deferred<{ success: boolean }>() + api.saveSettings.mockReturnValue(pending.promise) + api.getSettings.mockResolvedValue(connection({ + url: 'https://new.example', + connected: true, + lastSyncState: 'ok', + lastSyncError: null, + })) + + let done!: Promise + act(() => { done = result.current.save() }) + expect(result.current.saving).toBe(true) + + await act(async () => { + pending.resolve({ success: true }) + await done + }) + + expect(api.saveSettings).toHaveBeenCalledWith({ + url: 'https://new.example', + allowInsecureTls: false, + syncEnabled: true, + apiKey: 'k-123', + }) + // Cleared, so the next save of an unrelated switch does not resend it. + expect(result.current.apiKey).toBe('') + expect(result.current.connected).toBe(true) + expect(result.current.capabilities).toEqual(CAPS) + expect(result.current.lastSyncState).toBe('ok') + expect(result.current.lastSyncError).toBeNull() + expect(result.current.saving).toBe(false) + expect(toast.success).toHaveBeenCalledWith(EN.saved) + }) + + it('FE-DAWARICH-CONN-010: a save the server accepts but cannot connect with flips the card back to disconnected', async () => { + const { result } = await mounted() + expect(result.current.connected).toBe(true) + + api.getSettings.mockResolvedValue(connection({ + connected: false, + capabilities: null, + lastSyncState: 'failed', + lastSyncError: 'unauthorized', + })) + await act(async () => { await result.current.save() }) + + // A saved record and a working connection are two different things, and the + // badge follows the second one. + expect(result.current.connected).toBe(false) + expect(result.current.capabilities).toBeNull() + expect(result.current.lastSyncError).toBe(EN.unauthorized) + }) + + it('FE-DAWARICH-CONN-011: a save that answers with an empty body still counts as saved', async () => { + const { result } = await mounted() + // A 204 arrives as no body at all; the warning branch must not trip on it. + api.saveSettings.mockResolvedValue(undefined) + + await act(async () => { await result.current.save() }) + + expect(toast.success).toHaveBeenCalledWith(EN.saved) + expect(toast.warning).not.toHaveBeenCalled() + }) + + it('FE-DAWARICH-CONN-012: a save whose follow-up read fails keeps the card as it was and still reports success', async () => { + const { result } = await mounted() + api.getSettings.mockRejectedValue(new Error('boom')) + + await act(async () => { await result.current.save() }) + + // The write landed. Losing the refresh must not blank the badge, the + // capabilities or the sync history that are still true. + expect(result.current.connected).toBe(true) + expect(result.current.capabilities).toEqual(CAPS) + expect(result.current.lastSyncState).toBe('ok') + expect(toast.success).toHaveBeenCalledWith(EN.saved) + }) + + it('FE-DAWARICH-CONN-013: the private-IP warning is rendered from its code, with the address the server resolved', async () => { + const serverSentence = 'Dawarich resolved to 192.168.1.4, which is a private address.' + const { result } = await mounted() + api.saveSettings.mockResolvedValue({ + success: true, + warning: serverSentence, + warningCode: 'private_ip', + warningIp: '192.168.1.4', + }) + + await act(async () => { await result.current.save() }) + + const warned = lastToast(toast.warning) + expect(warned).toContain('private IP (192.168.1.4)') + // The fix belongs in the sentence, and it is the half a self-hoster needs. + expect(warned).toContain('ALLOW_INTERNAL_NETWORK') + // The server's own English never reaches the reader while the code is known. + expect(warned).not.toBe(serverSentence) + expect(toast.success).not.toHaveBeenCalled() + + // A coded warning with no address attached still translates; the slot is + // filled with nothing rather than left as the literal "{ip}". + api.saveSettings.mockResolvedValue({ success: true, warning: serverSentence, warningCode: 'private_ip' }) + await act(async () => { await result.current.save() }) + expect(lastToast(toast.warning)).toContain('private IP ()') + }) + + it('FE-DAWARICH-CONN-014: a warning code this build has no string for falls back to the server sentence', async () => { + const { result } = await mounted() + api.saveSettings.mockResolvedValue({ + success: true, + warning: 'Heads up: that host answers very slowly.', + warningCode: 'slow_host', + warningIp: '10.0.0.9', + }) + + await act(async () => { await result.current.save() }) + // English prose beats a raw `dawarich.warning.slow_host` on the screen. + expect(toast.warning).toHaveBeenCalledWith('Heads up: that host answers very slowly.') + + // An older server that attaches prose without a code at all. + api.saveSettings.mockResolvedValue({ success: true, warning: 'Saved, but that address looks odd.' }) + await act(async () => { await result.current.save() }) + expect(toast.warning).toHaveBeenLastCalledWith('Saved, but that address looks odd.') + + // A warning is not a success: the card must not say both. + expect(toast.success).not.toHaveBeenCalled() + }) + + it('FE-DAWARICH-CONN-015: a rejected save is reported by code first, then by the server sentence, then generically', async () => { + const { result } = await mounted() + + api.saveSettings.mockRejectedValue({ + response: { data: { code: 'unauthorized', error: 'Dawarich answered 401 for https://dawarich.example' } }, + }) + await act(async () => { await result.current.save() }) + // The code wins over the prose, which is English and quotes the URL back. + expect(toast.error).toHaveBeenLastCalledWith(EN.unauthorized) + + api.saveSettings.mockRejectedValue({ + response: { data: { code: 'quota_exhausted', error: 'Quota exhausted until 14:00' } }, + }) + await act(async () => { await result.current.save() }) + expect(toast.error).toHaveBeenLastCalledWith('Quota exhausted until 14:00') + + // Not our envelope at all: a numeric code is ignored, the prose still reads. + api.saveSettings.mockRejectedValue({ response: { data: { code: 409, error: 'conflict' } } }) + await act(async () => { await result.current.save() }) + expect(toast.error).toHaveBeenLastCalledWith('conflict') + + // Nothing usable anywhere in the error (a transport failure, typically). + api.saveSettings.mockRejectedValue(new Error('Network Error')) + await act(async () => { await result.current.save() }) + expect(toast.error).toHaveBeenLastCalledWith(EN.saveError) + + expect(result.current.saving).toBe(false) + expect(toast.success).not.toHaveBeenCalled() + }) + + it('FE-DAWARICH-CONN-027: a rejection carrying no error object at all still reaches the reader as a sentence', async () => { + const { result } = await mounted() + // Not a shape axios produces on its own, but the optional chain at the top + // of errorText() is there for it: an aborted or torn-down call rejects with + // nothing, and reading .response off that would throw inside the catch, + // which loses the toast entirely and rejects save() into whoever pressed + // the button. The generic sentence is the floor, and it has to hold. + api.saveSettings.mockRejectedValue(undefined) + + await act(async () => { await result.current.save() }) + + expect(toast.error).toHaveBeenCalledWith(EN.saveError) + expect(result.current.saving).toBe(false) + }) +}) + +describe('useDawarichConnection: the connection probe', () => { + it('FE-DAWARICH-CONN-016: a successful probe reports the count in the panel and the toast, and adopts what it found', async () => { + const { result } = await mounted(connection({ connected: false, capabilities: null })) + act(() => { + result.current.setUrl(' https://probe.example ') + result.current.setApiKey('k-123') + }) + + const probe = deferred() + api.test.mockReturnValue(probe.promise) + let done!: Promise + act(() => { done = result.current.test() }) + expect(result.current.testing).toBe(true) + + await act(async () => { + probe.resolve({ connected: true, visitCount: 12, capabilities: CAPS }) + await done + }) + + // The probe runs against what is typed, not against what is stored. That is + // the whole point of testing before saving. + expect(api.test).toHaveBeenCalledWith({ + url: 'https://probe.example', + allowInsecureTls: false, + apiKey: 'k-123', + }) + expect(result.current.connected).toBe(true) + expect(result.current.capabilities).toEqual(CAPS) + expect(result.current.probeMessage).toBe(EN.probeOk(12)) + expect(toast.success).toHaveBeenCalledWith(EN.probeOk(12)) + expect(result.current.testing).toBe(false) + }) + + it('FE-DAWARICH-CONN-017: a probe that answers without a count or capabilities says zero and keeps what was known', async () => { + const { result } = await mounted() + api.test.mockResolvedValue({ connected: true }) + + await act(async () => { await result.current.test() }) + + // "Connected" with no number reads the same as a reachable instance holding + // nothing at all, which is a genuinely different situation. + expect(result.current.probeMessage).toBe(EN.probeOk(0)) + // A probe that reported no capabilities must not erase the stored ones. + expect(result.current.capabilities).toEqual(CAPS) + }) + + it('FE-DAWARICH-CONN-018: a refused probe puts the reason in the panel and keeps the toast to one line', async () => { + const { result } = await mounted() + api.test.mockResolvedValue({ + connected: false, + error: 'unreachable', + errorDetail: 'self-signed certificate', + }) + + await act(async () => { await result.current.test() }) + + expect(result.current.connected).toBe(false) + // The detail is what separates "the host is down" from "we rejected the + // certificate", and the self-hoster needs it to know which knob to turn. + expect(result.current.probeMessage).toBe(`${EN.unreachable} (self-signed certificate)`) + expect(toast.error).toHaveBeenCalledWith(EN.unreachable) + }) + + it('FE-DAWARICH-CONN-019: a refused probe with no reason at all still says something, and an unrecognised code does too', async () => { + const { result } = await mounted() + + api.test.mockResolvedValue({ connected: false }) + await act(async () => { await result.current.test() }) + expect(result.current.probeMessage).toBe(EN.probeFailed) + + api.test.mockResolvedValue({ connected: false, error: 'quota_exhausted' }) + await act(async () => { await result.current.test() }) + expect(result.current.probeMessage).toBe(EN.unknown) + expect(toast.success).not.toHaveBeenCalled() + }) + + it('FE-DAWARICH-CONN-020: a thrown probe clears the stale panel line first, then reports the coded reason or a generic one', async () => { + const { result } = await mounted() + api.test.mockResolvedValue({ connected: true, visitCount: 3 }) + await act(async () => { await result.current.test() }) + expect(result.current.probeMessage).toBe(EN.probeOk(3)) + + const thrown = deferred() + api.test.mockReturnValue(thrown.promise) + let done!: Promise + act(() => { done = result.current.test() }) + // A success belonging to the previous address must not sit under a probe of + // the one being typed now. + expect(result.current.probeMessage).toBeNull() + + await act(async () => { + thrown.reject({ response: { data: { code: 'unauthorized' } } }) + await done + }) + expect(result.current.probeMessage).toBe(EN.unauthorized) + expect(toast.error).toHaveBeenLastCalledWith(EN.unauthorized) + + api.test.mockRejectedValue(new Error('Network Error')) + await act(async () => { await result.current.test() }) + expect(result.current.probeMessage).toBe(EN.probeFailed) + expect(result.current.testing).toBe(false) + }) +}) + +describe('useDawarichConnection: sync now', () => { + it('FE-DAWARICH-CONN-021: a sync that is already running says so and invents no result', async () => { + const { result } = await mounted(connection({ lastSyncState: 'partial', lastSyncError: 'unreachable' })) + api.syncNow.mockResolvedValue({ state: 'running', created: 0, updated: 0, missing: 0, alreadyRunning: true }) + + await act(async () => { await result.current.syncNow() }) + + expect(toast.info).toHaveBeenCalledWith(EN.syncRunning) + // Nothing finished, so nothing about the last run may move, least of all a + // "0 new stays" that would read as this run's answer. + expect(result.current.lastSyncAt).toBe('2026-09-01T07:00:00.000Z') + expect(result.current.lastSyncState).toBe('partial') + expect(result.current.lastSyncError).toBe(EN.unreachable) + expect(toast.success).not.toHaveBeenCalled() + expect(result.current.syncing).toBe(false) + }) + + it('FE-DAWARICH-CONN-022: a finished sync stamps the time, takes the server state and clears the previous error', async () => { + const { result } = await mounted(connection({ + lastSyncAt: '2026-08-01T00:00:00.000Z', + lastSyncState: 'failed', + lastSyncError: 'unreachable', + })) + + const run = deferred<{ state: string; created: number; updated: number; missing: number }>() + api.syncNow.mockReturnValue(run.promise) + const before = Date.now() + let done!: Promise + act(() => { done = result.current.syncNow() }) + expect(result.current.syncing).toBe(true) + + await act(async () => { + run.resolve({ state: 'partial', created: 3, updated: 1, missing: 2 }) + await done + }) + + // A partial run is not a clean one, so the server's word is kept rather than + // flattened to "ok", but the stale failure underneath it has to go. + expect(result.current.lastSyncState).toBe('partial') + expect(result.current.lastSyncError).toBeNull() + expect(Date.parse(result.current.lastSyncAt ?? '')).toBeGreaterThanOrEqual(before) + expect(toast.success).toHaveBeenCalledWith(EN.synced(3)) + expect(result.current.syncing).toBe(false) + }) + + it('FE-DAWARICH-CONN-023: a sync answer without a state or a count is treated as a clean run that found nothing', async () => { + const { result } = await mounted(connection({ lastSyncAt: null, lastSyncState: 'never' })) + api.syncNow.mockResolvedValue({}) + + await act(async () => { await result.current.syncNow() }) + + expect(result.current.lastSyncState).toBe('ok') + expect(result.current.lastSyncAt).not.toBeNull() + expect(toast.success).toHaveBeenCalledWith(EN.synced(0)) + }) + + it('FE-DAWARICH-CONN-024: a sync that throws marks the state failed and reports the coded reason, or a generic one', async () => { + const { result } = await mounted() + + api.syncNow.mockRejectedValue({ response: { data: { code: 'unauthorized' } } }) + await act(async () => { await result.current.syncNow() }) + // The card has to stop claiming the last run was fine. + expect(result.current.lastSyncState).toBe('failed') + expect(toast.error).toHaveBeenCalledWith(EN.unauthorized) + + // An envelope whose `error` is an object rather than a sentence: unusable, + // so the generic line stands instead of "[object Object]". + api.syncNow.mockRejectedValue({ response: { data: { error: { reason: 'locked' } } } }) + await act(async () => { await result.current.syncNow() }) + expect(toast.error).toHaveBeenLastCalledWith(EN.syncError) + expect(result.current.syncing).toBe(false) + }) +}) + +describe('useDawarichConnection: disconnect', () => { + it('FE-DAWARICH-CONN-025: disconnect empties the card down to the probe message', async () => { + const { result } = await mounted(connection({ lastSyncState: 'failed', lastSyncError: 'unreachable' })) + act(() => result.current.setApiKey('k-123')) + api.test.mockResolvedValue({ connected: true, visitCount: 1 }) + await act(async () => { await result.current.test() }) + expect(result.current.probeMessage).not.toBeNull() + + const drop = deferred<{ success: boolean }>() + api.disconnect.mockReturnValue(drop.promise) + let done!: Promise + act(() => { done = result.current.disconnect() }) + // Disconnect borrows the saving flag: both writes touch the same record, and + // the card disables the same buttons for either. + expect(result.current.saving).toBe(true) + + await act(async () => { + drop.resolve({ success: true }) + await done + }) + + // Every leftover matters here: a stale probe line or sync history under an + // empty form reads as a connection that is still half there. + expect(result.current.url).toBe('') + expect(result.current.apiKey).toBe('') + expect(result.current.connected).toBe(false) + expect(result.current.capabilities).toBeNull() + expect(result.current.lastSyncAt).toBeNull() + expect(result.current.lastSyncState).toBe('never') + expect(result.current.lastSyncError).toBeNull() + expect(result.current.probeMessage).toBeNull() + expect(result.current.canSave).toBe(false) + expect(result.current.saving).toBe(false) + expect(toast.success).toHaveBeenCalledWith(EN.disconnected) + }) + + it('FE-DAWARICH-CONN-026: a failed disconnect leaves the connection on screen rather than pretending it is gone', async () => { + const { result } = await mounted() + + api.disconnect.mockRejectedValue({ response: { data: { error: 'Database is locked' } } }) + await act(async () => { await result.current.disconnect() }) + + expect(result.current.url).toBe('https://dawarich.example') + expect(result.current.connected).toBe(true) + expect(result.current.capabilities).toEqual(CAPS) + expect(toast.error).toHaveBeenCalledWith('Database is locked') + expect(result.current.saving).toBe(false) + + api.disconnect.mockRejectedValue(new Error('offline')) + await act(async () => { await result.current.disconnect() }) + expect(toast.error).toHaveBeenLastCalledWith(EN.saveError) + expect(result.current.connected).toBe(true) + }) +}) diff --git a/client/src/hooks/useDawarichJournalTrail.test.ts b/client/src/hooks/useDawarichJournalTrail.test.ts new file mode 100644 index 000000000..508849582 --- /dev/null +++ b/client/src/hooks/useDawarichJournalTrail.test.ts @@ -0,0 +1,396 @@ +/** + * FE-DAWARICH-JTRAIL-001 to FE-DAWARICH-JTRAIL-013: the recorded route a + * journal draws over its own dates (#2279). + * + * This hook is the one place a journal reaches across the network to somebody's + * self-hosted location history, and almost everything worth pinning about it is + * a thing that must NOT happen. + * + * The gate first. Three separate switches have to agree before a single request + * leaves the browser (the journal's own "show trip tracks" preference, the + * Dawarich addon being enabled on the instance, and the journal actually + * covering some dates), and each of them is a promise to the user: a journal + * whose tracks are switched off must not go and fetch a GPS archive anyway. + * They are `&&`-chained, so a regression in any one of them looks identical on + * screen (nothing is drawn) while the request goes out regardless. Only the + * absence of the call catches that, which is why the first three cases assert + * on `windowTrack` never having been called rather than on what was rendered. + * + * Then the window. A `Set` has no order and a journal's dates are accumulated + * from several trips, so the earliest and latest day are found by sorting, not + * by trusting insertion order. Get that wrong and the fetched span is some + * arbitrary pair of days out of the middle of the journey. + * + * Then the shape. The overlay rides the journey map's existing `JourneyTrack[]` + * layer, which keys its polylines on `place_id`. The synthetic ids are negative + * precisely so they can never collide with a real place's. A collision would + * make the map drop or duplicate a line rather than fail loudly. + * + * Finally the four endings, because the journal map has no other way to say + * what happened: a route, nothing recorded, the instance not answering, and the + * device being offline. The last one is checked twice, before the request and + * again when it fails, so a connection that dies mid-flight is reported as + * "offline" rather than blamed on the user's Dawarich instance. + * + * The late-answer cases exist because this hook lives on a page the user leaves + * and a switch they flip: an in-flight request that lands after the layer has + * been switched off must not paint a line onto a map that no longer wants one. + * + * That switch going off after a route is already drawn is its own case. The + * line has to leave the map with it rather than linger until something else + * happens to clear it, and turning it back on has to ask again rather than + * replay a recording that may have grown in the meantime. This hook keeps no + * cache, deliberately, because a cached copy of somebody's location history is + * the thing the integration exists not to keep. + */ +import { act, renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { DawarichTrack, DawarichTrackDay } from '@trek/shared' + +const { windowTrack, offline } = vi.hoisted(() => ({ windowTrack: vi.fn(), offline: vi.fn() })) +vi.mock('../repo/dawarichRepo', () => ({ dawarichRepo: { windowTrack } })) +// Only `isEffectivelyOffline` is swapped out. `addonStore` reaches `api/client`, +// which imports from this module as well, and a factory that named a single +// export would hand every other importer in the graph `undefined`. +vi.mock('../sync/networkMode', async importOriginal => ({ + ...(await importOriginal()), + isEffectivelyOffline: offline, +})) + +import { useAddonStore } from '../store/addonStore' +import { DAWARICH_DAY_COLORS } from '../components/Map/dawarichTrail' +import { useDawarichJournalTrail } from './useDawarichJournalTrail' + +const DAY_ONE = '2026-03-03' +const DAY_TWO = '2026-03-04' +const DAY_THREE = '2026-03-05' + +const FETCHED_AT = '2026-03-06T09:12:00.000Z' + +const journalDates = (...days: string[]) => new Set(days) + +function segment(points: Array<[number, number]>) { + return { + points, + mode: 'walking', + startedAt: `${DAY_ONE}T08:00:00Z`, + endedAt: `${DAY_ONE}T09:00:00Z`, + distanceMeters: 1200, + } +} + +/** One recorded day with a single two-point line: the smallest thing that draws. */ +const day = (date: string, points: Array<[number, number]> = [[53.5, 10.0], [53.6, 10.1]]): DawarichTrackDay => + ({ date, segments: [segment(points)] }) + +function track(over: Partial = {}): DawarichTrack { + return { days: [], source: 'tracks', fetchedAt: FETCHED_AT, pointCount: 0, truncated: false, ...over } +} + +/** + * A request left hanging, so the world can change underneath it: the dates + * edited, the layer switched off, the network lost. None of that is observable + * against a promise that has already resolved. + */ +function held() { + let settle!: (value: DawarichTrack) => void + let fail!: (reason: Error) => void + const promise = new Promise((resolve, reject) => { settle = resolve; fail = reject }) + return { promise, settle, fail } +} + +/** + * Land a held request and let the hook's whole `.then`/`.catch` chain run + * before anything is asserted. Two ticks, because a rejection travels through + * the `.then` that has no rejection handler before it reaches the `.catch`. + * + * Worth being exact about: the late-answer cases assert that nothing changed, + * so a chain that had not run yet would pass them for the wrong reason and + * never reach the `cancelled` guard they exist to pin. + */ +const settleAndFlush = (fire: () => void) => + act(async () => { + fire() + await Promise.resolve() + await Promise.resolve() + }) + +const spanOf = (call: unknown[]) => ({ from: call[0] as string, to: call[1] as string }) +const signalOf = (call: unknown[]) => call[2] as AbortSignal + +const render = (initialProps: { dates: Set; enabled: boolean }) => + renderHook(({ dates, enabled }) => useDawarichJournalTrail(dates, enabled), { initialProps }) + +beforeEach(() => { + windowTrack.mockReset().mockResolvedValue(track()) + offline.mockReset().mockReturnValue(false) + useAddonStore.setState({ + addons: [{ id: 'dawarich', name: 'Dawarich', type: 'integration', icon: 'Route', enabled: true }], + loaded: true, + }) +}) + +describe('useDawarichJournalTrail', () => { + it('FE-DAWARICH-JTRAIL-001: asks nothing on an instance where the addon is switched off', () => { + useAddonStore.setState({ addons: [] }) + + const set = journalDates(DAY_ONE, DAY_TWO) + const { result, rerender } = render({ dates: set, enabled: true }) + + // The addon being off is an instance-wide "do not talk to Dawarich". A + // journal that fetched anyway would keep hitting a server the admin has + // disconnected, and nothing on screen would show it. + expect(windowTrack).not.toHaveBeenCalled() + expect(result.current.status).toBe('idle') + expect(result.current.tracks).toHaveLength(0) + expect(result.current.fetchedAt).toBeNull() + + // The empty result is one shared array, not a fresh literal per pass: the + // journey map memoises on `tracks`, so a new identity every render would + // rebuild every polyline on the map for nothing. + const first = result.current.tracks + rerender({ dates: set, enabled: false }) + expect(result.current.tracks).toBe(first) + }) + + it('FE-DAWARICH-JTRAIL-002: asks nothing while the journal has its track layer switched off', async () => { + const set = journalDates(DAY_ONE, DAY_TWO) + const { result, rerender } = render({ dates: set, enabled: false }) + + // `show_trip_tracks` gates the REQUEST, not just the drawing. Fetching and + // then hiding would send the journal's whole date range to Dawarich on + // behalf of somebody who asked for no tracks at all. + expect(windowTrack).not.toHaveBeenCalled() + expect(result.current.status).toBe('idle') + + rerender({ dates: set, enabled: true }) + await waitFor(() => expect(windowTrack).toHaveBeenCalledTimes(1)) + }) + + it('FE-DAWARICH-JTRAIL-003: a journal with no dated trips has no window to ask about', () => { + // Entries whose trips carry no start/end date contribute nothing to the + // set. With an empty set there are no bounds to build a window from, and + // sending one anyway would mean sending `undefined` as a timestamp. + const { result } = render({ dates: journalDates(), enabled: true }) + + expect(windowTrack).not.toHaveBeenCalled() + expect(result.current.status).toBe('idle') + expect(result.current.tracks).toHaveLength(0) + expect(result.current.fetchedAt).toBeNull() + }) + + it('FE-DAWARICH-JTRAIL-004: spans earliest to latest day whatever order the dates arrived in', async () => { + // The dates come out of several trips in whatever order those trips are + // listed, so the set is deliberately built back to front here. Trusting + // insertion order would fetch the 5th to the 3rd: a backwards window, which + // comes back empty rather than failing. + const { result } = render({ dates: journalDates(DAY_THREE, DAY_ONE, DAY_TWO), enabled: true }) + + expect(result.current.status).toBe('loading') + await waitFor(() => expect(result.current.status).toBe('ready')) + + // Whole-day bounds in UTC: a window that started at the moment of the fetch + // would silently drop the morning of the first day. + expect(spanOf(windowTrack.mock.calls[0])).toEqual({ + from: `${DAY_ONE}T00:00:00Z`, + to: `${DAY_THREE}T23:59:59Z`, + }) + expect(signalOf(windowTrack.mock.calls[0])).toBeInstanceOf(AbortSignal) + expect(signalOf(windowTrack.mock.calls[0]).aborted).toBe(false) + }) + + it('FE-DAWARICH-JTRAIL-005: turns each drawable segment into one journey track the map cannot confuse with a place', async () => { + windowTrack.mockResolvedValue(track({ + days: [ + { + date: DAY_ONE, + segments: [ + segment([[53.5, 10.0], [53.6, 10.1]]), + // A single fix is not a line. It has to disappear here rather than + // reach the renderer, which would draw a zero-length polyline. + segment([[53.7, 10.2]]), + ], + }, + day(DAY_TWO, [[48.1, 11.5], [48.2, 11.6], [48.3, 11.7]]), + ], + pointCount: 6, + })) + + const { result } = render({ dates: journalDates(DAY_ONE, DAY_TWO), enabled: true }) + await waitFor(() => expect(result.current.status).toBe('ready')) + + expect(result.current.tracks).toEqual([ + { + place_id: -1, + trip_id: 0, + name: DAY_ONE, + color: DAWARICH_DAY_COLORS[0], + points: [[53.5, 10.0], [53.6, 10.1]], + }, + { + place_id: -2, + trip_id: 0, + name: DAY_TWO, + // Per-day hues, so "we were here on Tuesday" is readable; a second day + // in the first day's colour would read as one continuous recording. + color: DAWARICH_DAY_COLORS[1], + points: [[48.1, 11.5], [48.2, 11.6], [48.3, 11.7]], + }, + ]) + // The journey map keys its polylines on place_id. Real places are positive, + // so negative-and-distinct is what keeps a recorded line from evicting the + // GPX track of an actual place. + const ids = result.current.tracks.map(t => t.place_id) + expect(ids.every(id => id < 0)).toBe(true) + expect(new Set(ids).size).toBe(ids.length) + + expect(result.current.fetchedAt).toBe(FETCHED_AT) + }) + + it('FE-DAWARICH-JTRAIL-006: a window with nothing recorded is an answer, not a failure', async () => { + // Somebody who was not carrying a phone that week gets an empty track back, + // and that is a perfectly good reply. Treating it as unavailable would + // accuse their Dawarich instance of being broken. + windowTrack.mockResolvedValue(track({ days: [] })) + + const { result } = render({ dates: journalDates(DAY_ONE), enabled: true }) + await waitFor(() => expect(result.current.status).toBe('ready')) + + expect(result.current.tracks).toHaveLength(0) + expect(result.current.fetchedAt).toBe(FETCHED_AT) + }) + + it('FE-DAWARICH-JTRAIL-007: an instance that does not answer leaves the journal without a line and says so', async () => { + // Anything the repo throws lands here: a 502 from the proxy, a body that + // does not parse against the shared schema, a missing API key. None of them + // is the journal's fault and none may leave a half-drawn route. + windowTrack.mockRejectedValue(new Error('502 from the Dawarich instance')) + + const { result } = render({ dates: journalDates(DAY_ONE, DAY_TWO), enabled: true }) + await waitFor(() => expect(result.current.status).toBe('unavailable')) + + expect(result.current.tracks).toHaveLength(0) + }) + + it('FE-DAWARICH-JTRAIL-008: a connection that dies mid-flight is reported as offline, not as a broken instance', async () => { + // The device is online when the request leaves and gone by the time it + // fails. The flag is flipped while the request is held in the air, rather + // than queued per call, so the case says what it means and does not depend + // on how many times the hook happens to consult it. Blaming the instance + // here would send the user reading server logs over a dropped wifi. + const inFlight = held() + windowTrack.mockImplementationOnce(() => inFlight.promise) + + const { result } = render({ dates: journalDates(DAY_ONE), enabled: true }) + expect(result.current.status).toBe('loading') + + offline.mockReturnValue(true) + await settleAndFlush(() => inFlight.fail(new Error('network error'))) + + expect(result.current.status).toBe('offline') + expect(windowTrack).toHaveBeenCalledTimes(1) + expect(result.current.tracks).toHaveLength(0) + }) + + it('FE-DAWARICH-JTRAIL-009: a device already offline is not made to wait for a timeout', () => { + // `isEffectivelyOffline()` includes the manual switch, so this is also the + // person who turned offline mode on before a flight. Firing the request + // anyway would block the journal map until the request timed out, for an + // answer that cannot arrive. + offline.mockReturnValue(true) + + const { result } = render({ dates: journalDates(DAY_ONE, DAY_THREE), enabled: true }) + + expect(windowTrack).not.toHaveBeenCalled() + expect(result.current.status).toBe('offline') + expect(result.current.tracks).toHaveLength(0) + }) + + it('FE-DAWARICH-JTRAIL-010: a journal whose dates change abandons the answer to the old span', async () => { + const first = held() + windowTrack.mockImplementationOnce(() => first.promise) + + const { result, rerender } = render({ dates: journalDates(DAY_ONE), enabled: true }) + expect(result.current.status).toBe('loading') + + windowTrack.mockResolvedValueOnce(track({ days: [day(DAY_THREE)] })) + rerender({ dates: journalDates(DAY_THREE), enabled: true }) + await waitFor(() => expect(result.current.status).toBe('ready')) + + expect(spanOf(windowTrack.mock.calls[1]).from).toBe(`${DAY_THREE}T00:00:00Z`) + // The first request is cancelled at the wire, not merely ignored: a journal + // being edited would otherwise pile up requests against somebody's own + // server, each one a full location-history query. + expect(signalOf(windowTrack.mock.calls[0]).aborted).toBe(true) + + // The abandoned request answers late, the way a slow one does. Landing it + // now would draw the old span's route under the new dates. + await settleAndFlush(() => first.settle(track({ days: [day(DAY_ONE)] }))) + + expect(result.current.tracks.map(t => t.name)).toEqual([DAY_THREE]) + }) + + it('FE-DAWARICH-JTRAIL-011: an answer that arrives after the layer was switched off is dropped', async () => { + const pending = held() + windowTrack.mockImplementationOnce(() => pending.promise) + + const set = journalDates(DAY_ONE) + const { result, rerender } = render({ dates: set, enabled: true }) + expect(result.current.status).toBe('loading') + + rerender({ dates: set, enabled: false }) + expect(result.current.status).toBe('idle') + + // Switching the layer off is an instruction about the map, and a request + // that was already in the air must not undo it a second later. + await settleAndFlush(() => pending.settle(track({ days: [day(DAY_ONE)] }))) + + expect(result.current.status).toBe('idle') + expect(result.current.tracks).toHaveLength(0) + }) + + it('FE-DAWARICH-JTRAIL-012: a failure that arrives after the layer was switched off stays silent', async () => { + const pending = held() + windowTrack.mockImplementationOnce(() => pending.promise) + + const set = journalDates(DAY_ONE, DAY_TWO) + const { result, rerender } = render({ dates: set, enabled: true }) + expect(result.current.status).toBe('loading') + + rerender({ dates: set, enabled: false }) + + // The mirror of the case above: a late failure would put the layer into + // `unavailable` while it is switched off, which is a complaint about a + // request nobody is waiting for any more. + await settleAndFlush(() => pending.fail(new Error('too late to matter'))) + + expect(result.current.status).toBe('idle') + expect(result.current.tracks).toHaveLength(0) + }) + + it('FE-DAWARICH-JTRAIL-013: a route already drawn leaves the map with the switch, and is asked for again when it comes back', async () => { + windowTrack.mockResolvedValue(track({ days: [day(DAY_ONE)], pointCount: 2 })) + + const set = journalDates(DAY_ONE, DAY_TWO) + const { result, rerender } = render({ dates: set, enabled: true }) + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(result.current.tracks).toHaveLength(1) + + // Switching off has to withdraw the line that is already on screen, not + // merely stop the next fetch: a hook that kept handing out the last answer + // would leave somebody's recorded route drawn over a journal that asked for + // it to be hidden, and nothing would clear it until the page was reloaded. + rerender({ dates: set, enabled: false }) + expect(result.current.status).toBe('idle') + expect(result.current.tracks).toHaveLength(0) + + rerender({ dates: set, enabled: true }) + expect(result.current.status).toBe('loading') + await waitFor(() => expect(result.current.status).toBe('ready')) + + // Asked again rather than replayed. Nothing here is cached by design, and + // the recording may well have grown while the layer was off. + expect(windowTrack).toHaveBeenCalledTimes(2) + expect(result.current.tracks).toHaveLength(1) + }) +}) diff --git a/client/src/hooks/useDawarichSuggestions.test.ts b/client/src/hooks/useDawarichSuggestions.test.ts new file mode 100644 index 000000000..84b9384e4 --- /dev/null +++ b/client/src/hooks/useDawarichSuggestions.test.ts @@ -0,0 +1,513 @@ +/** + * FE-DAWARICH-SUGG-001 to FE-DAWARICH-SUGG-019: the review list both Dawarich + * shells read from. + * + * This hook is the only place the integration's review logic lives: the planner + * panel and the journal sheet both call it, so a regression here is a + * regression in both at once, and the two shells disagreeing about what is + * still pending is precisely what it was written to prevent. + * + * Four things are worth pinning, and the cases below are grouped around them. + * + * - **The status ladder.** `idle` / `loading` / `ready` / `disconnected` / + * `offline` / `unavailable` are not decoration: the panel renders nothing at + * all on `idle`, so an instance without the addon has to produce `idle` and + * no request, and a read that failed must not collapse into the same value + * as "connected and quiet". + * - **The stale-response guard.** Every read carries a `cancelled` flag. Lose + * it and the answer to the trip the reader just left overwrites the list for + * the trip they are looking at now, which reads as rows appearing from + * nowhere. + * - **In-place updates.** Accepting or dismissing replaces the one row and + * never refetches, deliberately: a refetch reorders under the cursor, and + * somebody working down a backlog loses their place on every press. + * - **The message chain.** The server ships a reason code beside its English + * prose so a non-English install never gets English. The code wins when this + * build has a string for it, the prose is the fallback for a code it does + * not know, and a generic sentence is the last resort. Each step is a branch, + * and each of them is a sentence a real person reads. + * + * The repo and the API are mocked, the way the neighbouring + * `DawarichAtlasDialog` test does it. Connectivity is not: `setForcedOffline` + * drives the real `networkMode`, which is the single source of truth the hook + * gates its writes on, and translation is the real provider, because the two + * helpers at the bottom of the source depend on `t()` returning the key + * untouched for a string this build does not have. + */ +import { createElement, type ReactNode } from 'react' +import { act, renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { DawarichAccept, DawarichAcceptResult, DawarichSuggestion, DawarichSuggestionList } from '@trek/shared' +import { TranslationProvider } from '../i18n/TranslationContext' +import { useAddonStore } from '../store/addonStore' +import { setForcedOffline } from '../sync/networkMode' +import { DawarichOfflineError } from '../repo/dawarichRepo' +import { useDawarichSuggestions } from './useDawarichSuggestions' + +const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() } +vi.mock('../components/shared/Toast', () => ({ useToast: () => toast, default: () => toast })) + +const repo = { suggestions: vi.fn() } +vi.mock('../repo/dawarichRepo', () => { + // The hook branches on `err instanceof DawarichOfflineError`, so the class the + // test throws has to be the very one the hook imported. Defined inside the + // factory and read back through the mocked module, it is. + class DawarichOfflineError extends Error {} + return { + dawarichRepo: { suggestions: (...args: unknown[]) => repo.suggestions(...args) }, + DawarichOfflineError, + } +}) + +const api = { accept: vi.fn(), setState: vi.fn() } +vi.mock('../api/dawarich', () => ({ + dawarichApi: { + accept: (...args: unknown[]) => api.accept(...args), + setState: (...args: unknown[]) => api.setState(...args), + }, +})) + +const ADDON = { id: 'dawarich', name: 'Dawarich', type: 'integration', icon: 'route', enabled: true } + +/** + * One recorded stay. Only a handful of these fields matter to this hook (id, + * state and target), but the contract demands all of them, and a partial cast + * would hide the day a required field is added. + */ +function stay(over: Partial & { id: number }): DawarichSuggestion { + return { + id: over.id, + sourceVisitId: String(over.id), + tripId: null, + tripTitle: null, + name: `Stay ${over.id}`, + lat: 50.94, + lng: 6.96, + startedAt: '2026-09-10T10:15:00+02:00', + endedAt: '2026-09-10T12:40:00+02:00', + durationMinutes: 145, + localDate: '2026-09-10', + sourceStatus: 'suggested', + confidence: null, + confidenceBand: null, + state: 'new', + target: null, + acceptedPlaceId: null, + acceptedJournalEntryId: null, + acceptedBucketListItemId: null, + sourceChanged: false, + sourceMissing: false, + matchedBucketListItemId: null, + matchedBucketListName: null, + countryCode: null, + firstSeenAt: '2026-09-10T06:00:00Z', + lastSeenAt: '2026-09-10T06:00:00Z', + ...over, + } +} + +/** Two rows, because "replaced in place" is only provable against a neighbour. */ +function list(over: Partial = {}): DawarichSuggestionList { + return { + suggestions: [stay({ id: 1 }), stay({ id: 2 })], + connected: true, + lastSyncAt: '2026-09-12T06:00:00Z', + lastSyncState: 'ok', + lastSyncError: null, + ...over, + } +} + +function acceptResult(suggestion: DawarichSuggestion): DawarichAcceptResult { + return { + suggestion, + createdPlaceId: suggestion.acceptedPlaceId, + createdJournalEntryId: suggestion.acceptedJournalEntryId, + bucketListItemId: null, + } +} + +/** A promise this test controls the settling of, to pin down state mid-flight. */ +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +type Filter = Parameters[0] + +const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children) + +function mount(filter?: Filter) { + return renderHook((props: Filter) => useDawarichSuggestions(props), { wrapper, initialProps: filter }) +} + +const PLACE_BODY: DawarichAccept = { target: 'place', tripId: 7, dayId: 3 } + +// The shipped English for the keys the hook reaches for. Written out rather than +// read back from the bundle so a reader of this file sees the sentence that ends +// up on screen, and so a reworded string is a decision somebody makes here too. +const EN = { + offline: 'This needs a connection — TREK is offline right now.', + unauthorized: 'Dawarich rejected the API key.', + unknown: 'Something went wrong talking to Dawarich.', + acceptError: 'Could not add this', + updateError: 'Could not update this suggestion', + acceptedPlace: 'Added to the trip', + acceptedJournal: 'Added to the journal', +} + +beforeEach(() => { + useAddonStore.setState({ addons: [ADDON], loaded: true }) + setForcedOffline(false) + repo.suggestions.mockReset().mockResolvedValue(list()) + api.accept.mockReset() + api.setState.mockReset() + toast.success.mockReset() + toast.error.mockReset() +}) + +// The force-offline flag is module state, not component state: left on, it would +// leak into the next file's writes. +afterEach(() => setForcedOffline(false)) + +describe('useDawarichSuggestions', () => { + it('FE-DAWARICH-SUGG-001: an instance without the addon asks nothing and reports idle', () => { + useAddonStore.setState({ addons: [{ ...ADDON, enabled: false }] }) + + const { result } = mount() + + // `idle` is what the panel keys "render nothing at all" off, so the empty + // defaults underneath it matter as much as the status itself. + expect(result.current.status).toBe('idle') + expect(repo.suggestions).not.toHaveBeenCalled() + expect(result.current.suggestions).toEqual([]) + expect(result.current.lastSyncState).toBe('never') + expect(result.current.lastSyncError).toBeNull() + }) + + it('FE-DAWARICH-SUGG-002: the enabled addon reads with the filter it was handed and reports the sync', async () => { + const read = deferred() + repo.suggestions.mockReturnValueOnce(read.promise) + + const { result } = mount({ tripId: 7, state: 'new' }) + + expect(result.current.status).toBe('loading') + await act(async () => read.resolve(list())) + + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(repo.suggestions).toHaveBeenCalledWith({ tripId: 7, state: 'new' }) + expect(result.current.suggestions.map(s => s.id)).toEqual([1, 2]) + expect(result.current.lastSyncAt).toBe('2026-09-12T06:00:00Z') + expect(result.current.lastSyncState).toBe('ok') + expect(result.current.lastSyncError).toBeNull() + }) + + it('FE-DAWARICH-SUGG-003: a list from an instance nobody is connected to still shows its rows', async () => { + // The suggestions survive a disconnect: they are TREK's own rows by then, + // and hiding them would lose a backlog somebody still has to work through. + repo.suggestions.mockResolvedValue(list({ connected: false, lastSyncState: 'never', lastSyncAt: null })) + + const { result } = mount() + + await waitFor(() => expect(result.current.status).toBe('disconnected')) + expect(result.current.suggestions).toHaveLength(2) + expect(result.current.lastSyncAt).toBeNull() + }) + + it('FE-DAWARICH-SUGG-004: a read refused for being offline says offline, and says it quietly', async () => { + repo.suggestions.mockRejectedValue(new DawarichOfflineError()) + + const { result } = mount() + + await waitFor(() => expect(result.current.status).toBe('offline')) + // No toast: a read that could not run is a state the panel renders, not an + // incident to interrupt somebody with. + expect(toast.error).not.toHaveBeenCalled() + }) + + it('FE-DAWARICH-SUGG-005: any other failure is unavailable, kept apart from offline', async () => { + repo.suggestions.mockRejectedValue({ response: { data: { code: 'unreachable' } } }) + + const { result } = mount() + + await waitFor(() => expect(result.current.status).toBe('unavailable')) + expect(result.current.suggestions).toEqual([]) + }) + + it('FE-DAWARICH-SUGG-006: reload re-reads with the same (absent) filter and takes the new answer', async () => { + repo.suggestions + .mockResolvedValueOnce(list()) + .mockResolvedValueOnce(list({ suggestions: [stay({ id: 3 })] })) + + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + act(() => result.current.reload()) + + await waitFor(() => expect(result.current.suggestions.map(s => s.id)).toEqual([3])) + expect(repo.suggestions).toHaveBeenCalledTimes(2) + expect(repo.suggestions).toHaveBeenLastCalledWith({ tripId: undefined, state: undefined }) + }) + + it('FE-DAWARICH-SUGG-007: a changed filter re-reads, an equal one does not', async () => { + const { result, rerender } = mount({ tripId: 7 }) + await waitFor(() => expect(result.current.status).toBe('ready')) + + rerender({ tripId: 9, state: 'dismissed' }) + await waitFor(() => expect(repo.suggestions).toHaveBeenLastCalledWith({ tripId: 9, state: 'dismissed' })) + // The request is fired synchronously by the effect, so the assertion above + // can pass while the answer is still in the air. Let it land first: what the + // next render must not do is start a third read, and that is only a clean + // statement once the second one is finished. + await waitFor(() => expect(result.current.status).toBe('ready')) + + // A fresh object carrying the same two values. The effect depends on the + // unpacked primitives precisely so this does not fire a second request. + // Every caller builds its filter object inline on every render. + rerender({ tripId: 9, state: 'dismissed' }) + expect(repo.suggestions).toHaveBeenCalledTimes(2) + }) + + it('FE-DAWARICH-SUGG-008: the answer to the previous filter never overwrites the current list', async () => { + const first = deferred() + const second = deferred() + repo.suggestions.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + + const { result, rerender } = mount({ tripId: 7 }) + rerender({ tripId: 8 }) + + await act(async () => second.resolve(list({ suggestions: [stay({ id: 8 })] }))) + await waitFor(() => expect(result.current.suggestions.map(s => s.id)).toEqual([8])) + + // The slow trip-7 read lands last. Without the cancelled flag the reader + // would now be looking at another trip's stays under trip 8's heading. + await act(async () => first.resolve(list({ suggestions: [stay({ id: 7 })] }))) + expect(result.current.suggestions.map(s => s.id)).toEqual([8]) + expect(result.current.status).toBe('ready') + }) + + it('FE-DAWARICH-SUGG-009: a read that fails after the panel closed changes nothing', async () => { + const read = deferred() + repo.suggestions.mockReturnValueOnce(read.promise) + + const { result, unmount } = mount() + expect(result.current.status).toBe('loading') + unmount() + + await act(async () => read.reject(new DawarichOfflineError())) + + // The last value the hook ever rendered, untouched by the late rejection. + expect(result.current.status).toBe('loading') + }) + + it('FE-DAWARICH-SUGG-010: accepting while offline is refused before the request, not after a timeout', async () => { + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + // The reader flips the offline switch while the list is on screen. Reads go + // through the repo, which knows; this write does not, so the hook says so. + setForcedOffline(true) + let ok: boolean | undefined + await act(async () => { + ok = await result.current.accept(1, PLACE_BODY) + }) + + expect(ok).toBe(false) + expect(api.accept).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledWith(EN.offline) + expect(result.current.busyId).toBeNull() + }) + + it('FE-DAWARICH-SUGG-011: accepting marks the row busy, replaces only that row, and never refetches', async () => { + const settled = deferred() + api.accept.mockReturnValueOnce(settled.promise) + const accepted = stay({ id: 1, state: 'accepted', target: 'place', acceptedPlaceId: 41 }) + + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + let pending!: Promise + act(() => { + pending = result.current.accept(1, PLACE_BODY) + }) + // The row is disabled while its write is in the air; only that row. + expect(result.current.busyId).toBe(1) + + await act(async () => settled.resolve(acceptResult(accepted))) + + await expect(pending).resolves.toBe(true) + expect(api.accept).toHaveBeenCalledWith(1, PLACE_BODY) + expect(result.current.suggestions[0]).toEqual(accepted) + expect(result.current.suggestions[1]).toEqual(stay({ id: 2 })) + expect(result.current.busyId).toBeNull() + expect(toast.success).toHaveBeenCalledWith(EN.acceptedPlace) + // One read, for the mount. A refetch here would reorder the list under the + // cursor of somebody working down a backlog. + expect(repo.suggestions).toHaveBeenCalledTimes(1) + + // The target picks the sentence: a stay written into a journal must not be + // reported as added to the trip. + const journalled = stay({ id: 2, state: 'accepted', target: 'journal', acceptedJournalEntryId: 88 }) + api.accept.mockResolvedValueOnce(acceptResult(journalled)) + await act(async () => { + await result.current.accept(2, { target: 'journal', journalId: 5 }) + }) + expect(toast.success).toHaveBeenLastCalledWith(EN.acceptedJournal) + }) + + it('FE-DAWARICH-SUGG-012: a failure carrying a known code is said in the language of the install', async () => { + // The server sends both. The code wins, so a German install never gets the + // English sentence sitting right beside it in the envelope. + api.accept.mockRejectedValue({ response: { data: { code: 'unauthorized', error: 'Dawarich rejected the API key' } } }) + + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + let ok: boolean | undefined + await act(async () => { + ok = await result.current.accept(1, PLACE_BODY) + }) + + expect(ok).toBe(false) + expect(toast.error).toHaveBeenCalledWith(EN.unauthorized) + // Nothing was written, so nothing in the list may look as if it had been. + expect(result.current.suggestions[0].state).toBe('new') + expect(result.current.busyId).toBeNull() + }) + + it('FE-DAWARICH-SUGG-013: an unknown code falls back to the server prose, and no envelope to the generic sentence', async () => { + // A code from a server newer than this build: the prose beside it is the + // only thing left that describes what actually happened. + api.accept.mockRejectedValueOnce({ response: { data: { code: 'meteor_strike', error: 'Dawarich fell over' } } }) + + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + await act(async () => { + await result.current.accept(1, PLACE_BODY) + }) + expect(toast.error).toHaveBeenCalledWith('Dawarich fell over') + + // A transport-level failure carries no envelope at all. + api.accept.mockRejectedValueOnce(new Error('Network Error')) + await act(async () => { + await result.current.accept(1, PLACE_BODY) + }) + expect(toast.error).toHaveBeenLastCalledWith(EN.acceptError) + }) + + it('FE-DAWARICH-SUGG-014: dismiss and restore move the one row between the two states', async () => { + api.setState.mockImplementation(async (id: number, next: 'new' | 'dismissed') => stay({ id, state: next })) + + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + await act(async () => { + await result.current.dismiss(2) + }) + expect(api.setState).toHaveBeenCalledWith(2, 'dismissed') + expect(result.current.suggestions.map(s => s.state)).toEqual(['new', 'dismissed']) + + // Restoring is the same call with the other value. The panel offers it on + // the handled rows, and it has to land on the same row it left. + await act(async () => { + await result.current.restore(2) + }) + expect(api.setState).toHaveBeenLastCalledWith(2, 'new') + expect(result.current.suggestions.map(s => s.state)).toEqual(['new', 'new']) + expect(result.current.busyId).toBeNull() + }) + + it('FE-DAWARICH-SUGG-015: dismissing while offline is refused before the request too', async () => { + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + setForcedOffline(true) + await act(async () => { + await result.current.dismiss(1) + }) + + expect(api.setState).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledWith(EN.offline) + expect(result.current.busyId).toBeNull() + }) + + it('FE-DAWARICH-SUGG-016: a state change that fails leaves the row alone and clears the busy flag', async () => { + // An envelope with neither a code nor prose: a 500 through a proxy that + // rewrote the body. The generic sentence is all that is left to say. + api.setState.mockRejectedValue({ response: { data: {} } }) + + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + await act(async () => { + await result.current.dismiss(1) + }) + + expect(toast.error).toHaveBeenCalledWith(EN.updateError) + expect(result.current.suggestions[0].state).toBe('new') + // Cleared in `finally`: a row left busy after a failure can never be pressed + // again, and the only way out would be a reload. + expect(result.current.busyId).toBeNull() + }) + + it('FE-DAWARICH-SUGG-017: the last sync reason is translated, with a sentence for a code this build does not know', async () => { + repo.suggestions.mockResolvedValue(list({ lastSyncState: 'failed', lastSyncError: 'unauthorized' })) + const known = mount() + await waitFor(() => expect(known.result.current.lastSyncError).toBe(EN.unauthorized)) + expect(known.result.current.lastSyncState).toBe('failed') + known.unmount() + + // A newer server reporting a reason this build has no string for. Showing + // the bare code would put `dawarich.error.meteor_strike` on screen. + repo.suggestions.mockResolvedValue(list({ lastSyncState: 'partial', lastSyncError: 'meteor_strike' })) + const unfamiliar = mount() + await waitFor(() => expect(unfamiliar.result.current.lastSyncError).toBe(EN.unknown)) + }) + + it('FE-DAWARICH-SUGG-018: a refused state change is said in the language of the install too', async () => { + // Dismissing has its own error chain, and the code path through it is the + // one somebody hits when the API key was revoked while the list sat open. + // Only the generic fallback is exercised above, so without this the panel + // could start answering half its failures in English and nothing would say so. + api.setState.mockRejectedValue({ + response: { data: { code: 'unauthorized', error: 'Dawarich rejected the API key' } }, + }) + + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + await act(async () => { + await result.current.restore(1) + }) + + expect(toast.error).toHaveBeenCalledWith(EN.unauthorized) + expect(result.current.suggestions[0].state).toBe('new') + expect(result.current.busyId).toBeNull() + }) + + it('FE-DAWARICH-SUGG-019: a rejection carrying no reason at all still ends in a sentence and a false', async () => { + // `Promise.reject()` with nothing in it is what an aborted request hands the + // catch block. Reading `.response` off that unguarded throws inside the error + // handler itself, and the dialog waiting on `accept` would then get a + // rejected promise where it expects a plain `false` and stay open forever. + api.accept.mockRejectedValueOnce(undefined) + + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + let ok: boolean | undefined + await act(async () => { + ok = await result.current.accept(1, PLACE_BODY) + }) + + expect(ok).toBe(false) + expect(toast.error).toHaveBeenCalledWith(EN.acceptError) + expect(result.current.busyId).toBeNull() + }) +}) diff --git a/client/src/mobile/screens/settings/MDawarichConnectionSection.test.tsx b/client/src/mobile/screens/settings/MDawarichConnectionSection.test.tsx new file mode 100644 index 000000000..1ea318045 --- /dev/null +++ b/client/src/mobile/screens/settings/MDawarichConnectionSection.test.tsx @@ -0,0 +1,324 @@ +/** + * MDawarichConnectionSection: FE-MOB-DAWARICH-001 to FE-MOB-DAWARICH-012. + * + * The phone twin of the desktop Dawarich card. It owns no behaviour at all: + * `useDawarichConnection` holds every bit of it so the two shells cannot drift + * apart and so a feature touching both does not spend the whole duplication + * budget on a second copy of the connect / test / sync logic. What is left in + * this file is the wiring, and the wiring is precisely what a self-hoster + * notices when it breaks. + * + * Each of the states below is one such break: + * + * - a Save or Test button that stays tappable while the request it starts is + * still in flight fires it a second time, and on a phone that is one + * mis-timed thumb away rather than a deliberate double click; + * - "Check now" or "Disconnect" offered before a connection exists asks the + * server to sync an instance it has no address for; + * - the key field advertising "Paste your Dawarich API key" while a key is + * already stored sends the reader back to Dawarich for a key TREK already + * has, and the hook reads anything typed there as a replacement, so the + * placeholder is the only signal that the field is empty on purpose; + * - the status block claiming "Connected" (or a last-sync time, or a server + * version) from stale state is the card lying about the one thing it exists + * to report. + * + * So the hook is mocked and the card is rendered against every state it can + * return: the empty card, the three placeholder states of the key field, each + * separate reason either button locks, the connected card with and without a + * previous sync, a sync in flight, a failed one, and the version line that + * appears only once a probe actually reported a version. + */ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import type { DawarichCapabilities } from '@trek/shared' +import { render, screen, fireEvent } from '../../../../tests/helpers/render' +import { getLocaleForLanguage } from '../../../i18n' +import { useSettingsStore } from '../../../store/settingsStore' +import type { DawarichConnectionState } from '../../../hooks/useDawarichConnection' +import MDawarichConnectionSection from './MDawarichConnectionSection' + +// The card reads the hook on every render, so swapping this module-level value +// and re-rendering is enough to walk it through a state the real hook would +// take an API round trip to reach. +let state: DawarichConnectionState + +vi.mock('../../../hooks/useDawarichConnection', () => ({ + useDawarichConnection: () => state, +})) + +/** + * Defaults are the card as it looks once the settings read has come back and + * found nothing: no address, no key, nothing connected, nothing in flight. + * `canSave` is false because the real hook derives it from url + key/connection + * and would say the same here. + */ +function makeState(over: Partial = {}): DawarichConnectionState { + return { + url: '', + setUrl: vi.fn(), + apiKey: '', + setApiKey: vi.fn(), + allowInsecureTls: false, + toggleInsecureTls: vi.fn(), + syncEnabled: true, + toggleSync: vi.fn(), + connected: false, + loading: false, + saving: false, + testing: false, + syncing: false, + lastSyncAt: null, + lastSyncState: 'never', + lastSyncError: null, + capabilities: null, + probeMessage: null, + canSave: false, + save: vi.fn(async () => {}), + test: vi.fn(async () => {}), + syncNow: vi.fn(async () => {}), + disconnect: vi.fn(async () => {}), + ...over, + } +} + +function renderCard(over: Partial = {}) { + state = makeState(over) + const view = render() + // Re-renders the same tree against a fresh hook return, the way the real card + // moves from "idle" to "saving" without being unmounted in between. + const show = (next: Partial) => { + state = makeState(next) + view.rerender() + } + return { ...view, show } +} + +const urlField = () => screen.getByLabelText('Instance address') +const keyField = () => screen.getByLabelText('API key') +const saveButton = () => screen.getByRole('button', { name: /Save/ }) +const testButton = () => screen.getByRole('button', { name: /Test connection/ }) +const syncButton = () => screen.getByRole('button', { name: /Check now/ }) +const disconnectButton = () => screen.getByRole('button', { name: /Disconnect/ }) + +/** + * A capability probe as 1.14.4 answers it: the two `visit*` flags are false + * there, which is exactly why the shape carries flags rather than a version + * comparison. `serverVersion` is the only field this card reads. + */ +const CAPABILITIES: DawarichCapabilities = { + visits: true, + tracks: true, + points: true, + locations: true, + visitedCities: true, + visitUpdatedAt: false, + visitCountryCode: false, + serverVersion: '1.14.4', + probedAt: '2026-09-12T10:00:00Z', +} + +describe('MDawarichConnectionSection', () => { + it('FE-MOB-DAWARICH-001 claims nothing before a connection exists', () => { + renderCard() + + expect(screen.getByText('Dawarich')).toBeInTheDocument() + expect(screen.getByText(/Connect your own Dawarich instance/)).toBeInTheDocument() + expect(screen.getByText('Not connected')).toBeInTheDocument() + + // Everything below only makes sense once an instance answered, so none of + // it may be on screen while the card is empty. + expect(screen.queryByRole('button', { name: /Check now/ })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /Disconnect/ })).not.toBeInTheDocument() + expect(screen.queryByText('Not checked yet')).not.toBeInTheDocument() + expect(screen.queryByText(/^Dawarich \d/)).not.toBeInTheDocument() + + // No address and no key: neither button has anything to send. + expect(saveButton()).toBeDisabled() + expect(testButton()).toBeDisabled() + }) + + it('FE-MOB-DAWARICH-002 hands every keystroke and tap straight to the shared hook', () => { + renderCard({ url: 'https://dawarich.example.com', apiKey: 'abc', canSave: true }) + + fireEvent.change(urlField(), { target: { value: 'https://tracks.example.com' } }) + expect(state.setUrl).toHaveBeenCalledWith('https://tracks.example.com') + + fireEvent.change(keyField(), { target: { value: 'new-key' } }) + expect(state.setApiKey).toHaveBeenCalledWith('new-key') + + // An address plus a typed key is the one combination that unlocks both. + expect(saveButton()).toBeEnabled() + expect(testButton()).toBeEnabled() + + fireEvent.click(saveButton()) + expect(state.save).toHaveBeenCalledTimes(1) + + fireEvent.click(testButton()) + expect(state.test).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-DAWARICH-003 masks the key field only while a stored key is untouched', () => { + const { show } = renderCard({ connected: true, apiKey: '' }) + + // Connected and the field blank means "a key is stored, we just never show + // it again": the dots say so; the invitation to paste one would not. + expect(keyField()).toHaveAttribute('placeholder', '••••••••') + + // The moment the reader types, the field is a replacement key again. + show({ connected: true, apiKey: 'typed' }) + expect(keyField()).toHaveAttribute('placeholder', 'Paste your Dawarich API key') + + show({ connected: false, apiKey: '' }) + expect(keyField()).toHaveAttribute('placeholder', 'Paste your Dawarich API key') + }) + + it('FE-MOB-DAWARICH-004 locks Save for each of its three separate reasons', () => { + const { show } = renderCard({ url: 'https://dawarich.example.com', canSave: true }) + expect(saveButton()).toBeEnabled() + + // A save already in flight: a second tap would send the settings twice. + show({ url: 'https://dawarich.example.com', canSave: true, saving: true }) + expect(saveButton()).toBeDisabled() + + // The initial settings read has not come back, so the form does not yet + // hold what the server has: saving now would overwrite it with blanks. + show({ url: 'https://dawarich.example.com', canSave: true, loading: true }) + expect(saveButton()).toBeDisabled() + + // An address with neither a stored connection nor a typed key. + show({ url: 'https://dawarich.example.com', canSave: false }) + expect(saveButton()).toBeDisabled() + }) + + it('FE-MOB-DAWARICH-005 locks Test for each of its three separate reasons', () => { + const { show } = renderCard({ url: 'https://dawarich.example.com' }) + expect(testButton()).toBeEnabled() + + show({ url: 'https://dawarich.example.com', testing: true }) + expect(testButton()).toBeDisabled() + + show({ url: 'https://dawarich.example.com', loading: true }) + expect(testButton()).toBeDisabled() + + // Whitespace is not an address. Trimming here is what stops a probe of ''. + show({ url: ' ' }) + expect(testButton()).toBeDisabled() + }) + + it('FE-MOB-DAWARICH-006 mirrors both switches and reports each tap back', () => { + const { show } = renderCard({ syncEnabled: true, allowInsecureTls: false }) + + const sync = screen.getByRole('switch', { name: 'Check for new stays automatically' }) + const tls = screen.getByRole('switch', { name: 'Allow self-signed certificate' }) + expect(sync).toHaveAttribute('aria-checked', 'true') + expect(tls).toHaveAttribute('aria-checked', 'false') + + fireEvent.click(sync) + expect(state.toggleSync).toHaveBeenCalledTimes(1) + fireEvent.click(tls) + expect(state.toggleInsecureTls).toHaveBeenCalledTimes(1) + + // Inverted, because a switch that renders the same in both positions is + // the failure this assertion exists for. + show({ syncEnabled: false, allowInsecureTls: true }) + expect(screen.getByRole('switch', { name: 'Check for new stays automatically' })).toHaveAttribute( + 'aria-checked', + 'false', + ) + expect(screen.getByRole('switch', { name: 'Allow self-signed certificate' })).toHaveAttribute( + 'aria-checked', + 'true', + ) + }) + + it('FE-MOB-DAWARICH-007 offers Check now and Disconnect once connected, and says no sync has run', () => { + const { container } = renderCard({ connected: true, lastSyncAt: null }) + + expect(screen.getByText('Connected')).toBeInTheDocument() + // Connected but never synced: "Last checked never" would read as a time. + expect(screen.getByText('Not checked yet')).toBeInTheDocument() + expect(container.querySelector('.animate-spin')).toBeNull() + + fireEvent.click(syncButton()) + expect(state.syncNow).toHaveBeenCalledTimes(1) + + expect(disconnectButton()).toBeEnabled() + fireEvent.click(disconnectButton()) + expect(state.disconnect).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-DAWARICH-008 renders the last sync in the reader locale, never the raw timestamp', () => { + const when = '2026-09-10T14:00:00Z' + renderCard({ connected: true, lastSyncAt: when }) + + // Computed the same way the card does (same locale, same formatter), so + // the assertion holds in whatever timezone the runner sits in and survives + // a change of default language. The whitespace collapse is not cosmetic: + // Testing Library normalises the rendered text before comparing but leaves + // the expected string alone, and ICU writes a narrow no-break space before + // AM/PM on some builds, which the two sides would then never agree on. + const locale = getLocaleForLanguage(useSettingsStore.getState().settings.language || 'en') + const line = `Last checked ${new Date(when).toLocaleString(locale)}`.replace(/\s+/g, ' ') + expect(screen.getByText(line)).toBeInTheDocument() + expect(screen.queryByText(new RegExp(when))).not.toBeInTheDocument() + expect(screen.queryByText('Not checked yet')).not.toBeInTheDocument() + }) + + it('FE-MOB-DAWARICH-009 spins the icon and locks Check now while a sync is running', () => { + const { container } = renderCard({ connected: true, syncing: true }) + + expect(syncButton()).toBeDisabled() + expect(container.querySelector('.animate-spin')).toBeInTheDocument() + }) + + it('FE-MOB-DAWARICH-010 shows the probe result and the last sync error, the error marked as one', () => { + const { show } = renderCard({ + connected: true, + // Both lines at once on purpose: a successful probe does not clear the + // error the last background sync left behind, and hiding one behind the + // other would let a broken sync read as healthy. + probeMessage: 'Could not reach Dawarich. (self-signed certificate)', + lastSyncError: 'Dawarich refused the key.', + }) + + expect(screen.getByText('Could not reach Dawarich. (self-signed certificate)')).toBeInTheDocument() + const error = screen.getByText('Dawarich refused the key.') + expect(error.className).toContain('--m-st-danger') + + // And the state both lines matter most in is the one where there is no + // connection: a probe that just failed leaves the card disconnected, so + // folding either line into the `connected &&` block above would swallow the + // only explanation the reader ever gets. + show({ + connected: false, + probeMessage: 'TREK could not reach that address. (ENOTFOUND)', + lastSyncError: 'Dawarich rejected the API key.', + }) + expect(screen.getByText('Not connected')).toBeInTheDocument() + expect(screen.getByText('TREK could not reach that address. (ENOTFOUND)')).toBeInTheDocument() + expect(screen.getByText('Dawarich rejected the API key.')).toBeInTheDocument() + }) + + it('FE-MOB-DAWARICH-011 names the server version only once a probe reported one', () => { + const { show } = renderCard({ connected: true, capabilities: null }) + expect(screen.queryByText(/^Dawarich 1\.14\.4$/)).not.toBeInTheDocument() + + // A probe that ran against a build too old to send X-Dawarich-Version: the + // capabilities are real, the version is not, and inventing one would be + // worse than leaving the line out. + show({ connected: true, capabilities: { ...CAPABILITIES, serverVersion: null } }) + expect(screen.queryByText(/^Dawarich 1\.14\.4$/)).not.toBeInTheDocument() + + show({ connected: true, capabilities: CAPABILITIES }) + expect(screen.getByText('Dawarich 1.14.4')).toBeInTheDocument() + }) + + it('FE-MOB-DAWARICH-012 locks Disconnect while a save is still in flight', () => { + renderCard({ connected: true, saving: true }) + + // Same `saving` flag as Save: disconnecting mid-save would race the write + // it is about to undo. + expect(disconnectButton()).toBeDisabled() + }) +}) diff --git a/client/src/repo/dawarichRepo.test.ts b/client/src/repo/dawarichRepo.test.ts new file mode 100644 index 000000000..3f82370fa --- /dev/null +++ b/client/src/repo/dawarichRepo.test.ts @@ -0,0 +1,294 @@ +/** + * FE-DAWARICH-API-020 to FE-DAWARICH-API-028 + * + * `dawarichRepo` is the seam the rest of the client is supposed to read + * Dawarich through, and it is deliberately the odd one out in this directory: + * no Dexie table, no read-through cache, nothing written to the device. That + * decision is the integration's privacy promise in code form (a cached copy of + * somebody's GPS archive is the exact thing this feature exists not to keep), + * and it is invisible in a type. Only a test can hold it. + * + * So what is pinned here is the two guarantees the seam actually makes: + * + * - **Nothing leaves the device while it is offline.** `requireOnline()` throws + * BEFORE a request is built, and it asks `isEffectivelyOffline()` rather than + * `navigator.onLine`, so the manual Offline switch counts as well. Someone + * who flipped that switch before a flight has said they do not want requests + * fired; firing them anyway would hang the panel on an eight-second axios + * timeout for every one of the five reads. The forced-offline case therefore + * gets its own test, because it is the half that a naive `navigator.onLine` + * rewrite would silently drop. + * - **A response is parsed, not cast.** The upstream here is a server TREK does + * not own, running a version nobody controls. An answer that does not match + * the contract has to fail at this line, naming the field, instead of + * arriving three components later as an undefined that renders as blank. + * - **Nothing is remembered between reads.** Every other repo in this directory + * reads through a cache, so adding one here is the natural-looking change, + * and it is the one thing this repo must never do. A second read therefore + * has to reach the server again. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { server } from '../../tests/helpers/msw/server' +import { dawarichRepo, DawarichOfflineError } from './dawarichRepo' +import { setForcedOffline, _resetNetworkMode } from '../sync/networkMode' +import type { + DawarichAtlasSuggestions, + DawarichBucketScan, + DawarichSuggestion, + DawarichSuggestionList, + DawarichTrack, +} from '@trek/shared' + +const BASE = '/api/integrations/dawarich' + +function setOnline(v: boolean): void { + Object.defineProperty(navigator, 'onLine', { value: v, writable: true, configurable: true }) +} + +const suggestion: DawarichSuggestion = { + id: 41, + sourceVisitId: 'v-41', + tripId: 7, + tripTitle: 'Koeln', + name: 'Cafe Reichard', + lat: 50.94, + lng: 6.96, + startedAt: '2026-09-10T10:15:00+02:00', + endedAt: '2026-09-10T12:40:00+02:00', + durationMinutes: 145, + localDate: '2026-09-10', + sourceStatus: 'suggested', + confidence: 82, + confidenceBand: 'high', + state: 'new', + target: null, + acceptedPlaceId: null, + acceptedJournalEntryId: null, + acceptedBucketListItemId: null, + sourceChanged: false, + sourceMissing: false, + matchedBucketListItemId: null, + matchedBucketListName: null, + countryCode: 'DE', + firstSeenAt: '2026-09-10T06:00:00Z', + lastSeenAt: '2026-09-10T06:00:00Z', +} + +const suggestionList: DawarichSuggestionList = { + suggestions: [suggestion], + connected: true, + lastSyncAt: '2026-09-13T21:00:00Z', + lastSyncState: 'partial', + lastSyncError: 'rate_limited', +} + +const track: DawarichTrack = { + days: [ + { + date: '2026-09-10', + segments: [ + { + points: [ + [50.94, 6.96], + [50.95, 6.97], + ], + mode: 'walking', + startedAt: '2026-09-10T10:15:00+02:00', + endedAt: '2026-09-10T10:40:00+02:00', + distanceMeters: 1200, + }, + ], + }, + ], + source: 'tracks', + fetchedAt: '2026-09-13T21:05:00Z', + pointCount: 2, + truncated: false, +} + +const bucketScan: DawarichBucketScan = { + matches: [ + { itemId: 3, name: 'Uluru', match: { at: '2026-04-02T08:00:00Z', minutes: 95, distanceMeters: 120, points: 41 }, alreadyVisited: false }, + { itemId: 4, name: 'Machu Picchu', match: null, alreadyVisited: true }, + ], + skippedWithoutCoordinates: 1, + truncated: true, + fetchedAt: '2026-09-13T21:05:00Z', +} + +const atlas: DawarichAtlasSuggestions = { + countries: [ + { + countryCode: 'DE', + sourceName: 'Germany', + cities: [{ name: 'Koeln', minutes: 4200, lastSeenAt: '2026-09-12T18:00:00Z' }], + alreadyVisited: false, + }, + ], + unresolved: ['Kosovo'], + fetchedAt: '2026-09-13T21:05:00Z', +} + +beforeEach(() => { + setOnline(true) + _resetNetworkMode() +}) + +afterEach(() => { + _resetNetworkMode() + setOnline(true) +}) + +describe('dawarichRepo online reads', () => { + it('FE-DAWARICH-API-020: suggestions() parses the list and keeps the provenance fields', async () => { + let seen = '' + server.use( + http.get(`${BASE}/suggestions`, ({ request }) => { + seen = request.url + return HttpResponse.json(suggestionList) + }), + ) + + const res = await dawarichRepo.suggestions({ tripId: 7, state: 'new' }) + + expect(new URL(seen).searchParams.get('tripId')).toBe('7') + expect(res.suggestions.map(s => s.id)).toEqual([41]) + // The panel shows "last sync failed" from these, so they have to survive the + // parse rather than being trimmed off as extras. + expect(res.lastSyncState).toBe('partial') + expect(res.lastSyncError).toBe('rate_limited') + }) + + it('FE-DAWARICH-API-021: tripTrack() forwards the range and returns the parsed segments', async () => { + let seen = '' + server.use( + http.get(`${BASE}/trips/:id/track`, ({ request }) => { + seen = request.url + return HttpResponse.json(track) + }), + ) + + const res = await dawarichRepo.tripTrack(7, { from: '2026-09-10', to: '2026-09-12' }, new AbortController().signal) + + expect(new URL(seen).pathname).toBe(`${BASE}/trips/7/track`) + expect(new URL(seen).searchParams.get('from')).toBe('2026-09-10') + expect(res.days[0]!.segments[0]!.points).toEqual([ + [50.94, 6.96], + [50.95, 6.97], + ]) + }) + + it('FE-DAWARICH-API-022: windowTrack() parses a track for a bare date window', async () => { + server.use(http.get(`${BASE}/track`, () => HttpResponse.json({ ...track, source: 'points', truncated: true }))) + + const res = await dawarichRepo.windowTrack('2026-09-10', '2026-09-12') + + // `points` is the fallback source and `truncated` says the line is + // incomplete; both are shown to the user, so neither may be normalised away. + expect(res.source).toBe('points') + expect(res.truncated).toBe(true) + }) + + it('FE-DAWARICH-API-023: bucketScan() parses matched and unmatched wishes alike', async () => { + server.use(http.post(`${BASE}/bucket-list/scan`, () => HttpResponse.json(bucketScan))) + + const res = await dawarichRepo.bucketScan() + + expect(res.matches.map(m => m.match?.minutes ?? null)).toEqual([95, null]) + expect(res.skippedWithoutCoordinates).toBe(1) + expect(res.truncated).toBe(true) + }) + + it('FE-DAWARICH-API-024: atlasSuggestions() parses the countries and names the unresolved ones', async () => { + server.use(http.get(`${BASE}/atlas/suggestions`, () => HttpResponse.json(atlas))) + + const res = await dawarichRepo.atlasSuggestions('2026-01-01', '2026-12-31') + + expect(res.countries[0]!.cities[0]!.lastSeenAt).toBe('2026-09-12T18:00:00Z') + expect(res.unresolved).toEqual(['Kosovo']) + }) +}) + +describe('dawarichRepo offline refusal', () => { + /** Every read on the repo, so a sixth one added without the guard shows up as an omission here. */ + const reads: Array<[string, () => Promise]> = [ + ['suggestions', () => dawarichRepo.suggestions()], + ['tripTrack', () => dawarichRepo.tripTrack(7)], + ['windowTrack', () => dawarichRepo.windowTrack('2026-09-10', '2026-09-12')], + ['bucketScan', () => dawarichRepo.bucketScan()], + ['atlasSuggestions', () => dawarichRepo.atlasSuggestions('2026-01-01', '2026-12-31')], + ] + + it('FE-DAWARICH-API-025: a disconnected browser refuses all five reads before any request is built', async () => { + let reached = false + server.use(http.all(`${BASE}/*`, () => { + reached = true + return HttpResponse.json({}) + })) + setOnline(false) + + for (const [name, run] of reads) { + await expect(run(), name).rejects.toBeInstanceOf(DawarichOfflineError) + } + + expect(reached).toBe(false) + }) + + // The manual switch is the half a `navigator.onLine` rewrite would lose: the + // browser is perfectly online here and the reads still have to refuse, because + // the user said so before boarding. + it('FE-DAWARICH-API-026: the manual offline switch refuses just as hard, with the browser online', async () => { + let reached = false + server.use(http.all(`${BASE}/*`, () => { + reached = true + return HttpResponse.json({}) + })) + setOnline(true) + setForcedOffline(true) + + for (const [name, run] of reads) { + await expect(run(), name).rejects.toBeInstanceOf(DawarichOfflineError) + } + + expect(reached).toBe(false) + + const err = await dawarichRepo.suggestions().catch((e: unknown) => e) + expect((err as Error).name).toBe('DawarichOfflineError') + expect((err as Error).message).toBe('Dawarich needs a connection') + }) +}) + +describe('dawarichRepo contract enforcement', () => { + // `source` is an enum of exactly two values. An instance answering a third + // must fail here, naming the field, rather than reaching the overlay as an + // unknown string that quietly draws nothing. + it('FE-DAWARICH-API-027: an answer that does not match the contract fails in the repo, naming the field', async () => { + server.use(http.get(`${BASE}/track`, () => HttpResponse.json({ ...track, source: 'guesswork' }))) + + const err = await dawarichRepo.windowTrack('2026-09-10', '2026-09-12').catch((e: unknown) => e) + + expect(err).toBeInstanceOf(Error) + expect((err as { issues?: Array<{ path: Array }> }).issues?.[0]?.path).toEqual(['source']) + }) + + // The no-cache promise, stated at the seam that would have to break it. Every + // sibling repo in this directory answers a repeat read from Dexie, so a cache + // here would look like consistency rather than like a regression, and it + // would mean a copy of somebody's movements outliving the panel that asked + // for it. Two identical reads, two requests. + it('FE-DAWARICH-API-028: a repeated read asks the server again instead of replaying an answer', async () => { + let calls = 0 + server.use( + http.get(`${BASE}/track`, () => { + calls += 1 + return HttpResponse.json(track) + }), + ) + + await dawarichRepo.windowTrack('2026-09-10', '2026-09-12') + await dawarichRepo.windowTrack('2026-09-10', '2026-09-12') + + expect(calls).toBe(2) + }) +}) diff --git a/server/tests/unit/nest/dawarich-suggestions.service.test.ts b/server/tests/unit/nest/dawarich-suggestions.service.test.ts index 8e91fcdce..00c912e35 100644 --- a/server/tests/unit/nest/dawarich-suggestions.service.test.ts +++ b/server/tests/unit/nest/dawarich-suggestions.service.test.ts @@ -1,5 +1,5 @@ /** - * Unit tests for DawarichSuggestionsService — DAWARICH-SUG-001..030. + * Unit tests for DawarichSuggestionsService: DAWARICH-SUG-001..062. * * This is the only service in the Dawarich domain that writes into TREK proper, * and every write it makes crosses an ownership line: a suggestion belongs to @@ -24,7 +24,11 @@ const { testDb, dbMock } = vi.hoisted(() => { db, closeDb: () => {}, reinitialize: () => {}, - getPlaceWithTags: () => null, + // A spy rather than a constant: the acceptance re-reads the place it just + // created so the broadcast carries the `source` mark, and the fallback for + // a re-read that finds nothing is a branch of its own. armStubs() gives it + // the real lookup; one case takes it away again. + getPlaceWithTags: vi.fn(), canAccessTrip: (tripId: unknown, userId: number) => db.prepare(` SELECT t.id, t.user_id FROM trips t @@ -45,6 +49,7 @@ vi.mock('../../../src/config', () => ({ })); vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); +import { DAWARICH_BUCKET_SCAN_LIMIT } from '@trek/shared'; import type { DawarichConnection } from '@trek/shared'; import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; @@ -133,6 +138,13 @@ function insertPlaceRow(tripId: string, body: { name?: string; lat?: number; lng } function armStubs(): void { + // The real helper's job, minus the tag join nothing here asserts on: the row + // as it stands after the acceptance stamped `source` on it. + dbMock.getPlaceWithTags + .mockReset() + .mockImplementation( + (placeId: unknown) => testDb.prepare('SELECT * FROM places WHERE id = ?').get(placeId) ?? null, + ); dawarichStub.getConnection.mockReset().mockReturnValue({ ...CONNECTION }); dawarichStub.getCredentials.mockReset().mockReturnValue({ ...CREDS }); clientStub.findVisitsNear.mockReset(); @@ -364,6 +376,34 @@ describe('DawarichSuggestionsService — the review list', () => { expect(svc.getOne(user.id, theirs)).toBeNull(); expect(svc.getOne(stranger.id, theirs)?.name).toBe('Their Kitchen'); }); + + it('DAWARICH-SUG-059: the flags the panel warns with are read off the row, never acted on', () => { + const { user } = createUser(testDb); + const wish = seedBucketItem(user.id); + const id = seedSuggestion({ userId: user.id }); + svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: wish }); + + // Everything a later sync is allowed to do to a row somebody already + // accepted: the detector re-ran and the hash moved, it upgraded the stay + // from suggested to confirmed, and then the visit disappeared from + // Dawarich altogether. The tick the user made stands through all of it. + testDb + .prepare( + `UPDATE dawarich_visit_suggestions + SET source_hash = 'hash-after-redetection', source_status = 'confirmed', + source_missing_at = '2026-09-07T00:00:00Z' + WHERE id = ?`, + ) + .run(id); + + const wire = svc.getOne(user.id, id)!; + expect(wire.sourceStatus).toBe('confirmed'); + expect(wire.sourceChanged).toBe(true); + expect(wire.sourceMissing).toBe(true); + expect(wire.state).toBe('accepted'); + expect(wire.target).toBe('bucket_list'); + expect(wire.acceptedBucketListItemId).toBe(wish); + }); }); // ── accept: place ──────────────────────────────────────────────────────────── @@ -455,6 +495,89 @@ describe('DawarichSuggestionsService — accepting into a trip', () => { expect(placesStub.create).not.toHaveBeenCalled(); }); + it('DAWARICH-SUG-043: every field the review step lets someone correct beats the recorded one', () => { + // The review step exists so a detector's guess can be fixed rather than + // swallowed; a correction that silently lost to the recording would make + // the whole form decorative. + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const id = seedSuggestion({ userId: user.id, tripId: trip.id, name: 'Unnamed stay' }); + + svc.accept(user.id, id, { + target: 'place', + tripId: trip.id, + name: ' Cafe Central ', + notes: 'the good table by the window', + time: '10:00', + endTime: '12:15', + lat: 48.2, + lng: 16.3, + }); + + expect(placesStub.create).toHaveBeenCalledWith( + String(trip.id), + expect.objectContaining({ + // Trimmed here as well as by the contract: this path is also reached + // from MCP, where nothing has trimmed it first. + name: 'Cafe Central', + notes: 'the good table by the window', + place_time: '10:00', + end_time: '12:15', + lat: 48.2, + lng: 16.3, + }), + ); + }); + + it('DAWARICH-SUG-044: a stay with no position and no measurable length passes neither on', () => { + // `undefined` rather than null or zero: PlacesService applies its own + // defaults for a field nobody supplied, and a place pinned at 0,0 off the + // coast of Africa with a duration of "0 min" is worse than one with none. + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const id = seedSuggestion({ userId: user.id, tripId: trip.id, lat: null, lng: null, durationMinutes: 0 }); + + svc.accept(user.id, id, { target: 'place', tripId: trip.id }); + + const body = placesStub.create.mock.calls[0][1] as Record; + expect(body.lat).toBeUndefined(); + expect(body.lng).toBeUndefined(); + expect(body.duration_minutes).toBeUndefined(); + }); + + it('DAWARICH-SUG-045: with no trip in the body, the one the sync filed the stay under is used', () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const id = seedSuggestion({ userId: user.id, tripId: trip.id }); + + const result = svc.accept(user.id, id, { target: 'place' }); + + expect(placesStub.create).toHaveBeenCalledWith(String(trip.id), expect.anything()); + expect(result.createdPlaceId).not.toBeNull(); + }); + + it('DAWARICH-SUG-046: a timestamp with no clock time in it yields no time rather than a wrong one', () => { + // Dawarich normally sends a full ISO instant, but a visit imported from a + // GPX or an older schema can arrive as a bare date. Slicing characters 11 + // to 16 out of that would put the last five characters of the date into a + // time field. + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const id = seedSuggestion({ + userId: user.id, + tripId: trip.id, + startedAt: '2026-09-01', + endedAt: '2026-09-01', + }); + + svc.accept(user.id, id, { target: 'place', tripId: trip.id }); + + expect(placesStub.create).toHaveBeenCalledWith( + String(trip.id), + expect.objectContaining({ place_time: null, end_time: null }), + ); + }); + it("DAWARICH-SUG-009: accepting a stranger's suggestion is a 404 before any target is looked at", () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); @@ -526,6 +649,60 @@ describe('DawarichSuggestionsService — accepting into a journey', () => { expect(journeyStub.canEdit).not.toHaveBeenCalled(); }); + it('DAWARICH-SUG-047: a corrected entry carries the corrections, not the recording', () => { + const { user } = createUser(testDb); + const id = seedSuggestion({ userId: user.id, name: 'Cafe Central', localDate: '2026-09-01' }); + + svc.accept(user.id, id, { + target: 'journal', + journalId: 88, + name: 'Dinner at the Naschmarkt', + notes: 'the one with the bad wine', + date: '2026-09-02', + time: '19:30', + lat: 48.198, + lng: 16.363, + }); + + expect(journeyStub.createEntry).toHaveBeenCalledWith( + 88, + user.id, + expect.objectContaining({ + title: 'Dinner at the Naschmarkt', + story: 'the one with the bad wine', + entry_date: '2026-09-02', + entry_time: '19:30', + location_name: 'Dinner at the Naschmarkt', + location_lat: 48.198, + location_lng: 16.363, + }), + undefined, + ); + }); + + it('DAWARICH-SUG-048: a stay with no position and no clock time writes an entry without either', () => { + // The journey domain treats a missing field and an empty one differently + // (a location_lat of null would put a pin on the map at the equator), so + // everything unknown has to arrive as undefined rather than as null. + const { user } = createUser(testDb); + const id = seedSuggestion({ + userId: user.id, + lat: null, + lng: null, + startedAt: '2026-09-03', + localDate: '2026-09-03', + }); + + svc.accept(user.id, id, { target: 'journal', journalId: 88 }); + + const body = journeyStub.createEntry.mock.calls[0][2] as Record; + expect(body.entry_date).toBe('2026-09-03'); + expect(body.entry_time).toBeUndefined(); + expect(body.location_lat).toBeUndefined(); + expect(body.location_lng).toBeUndefined(); + expect(body.story).toBeUndefined(); + }); + it('DAWARICH-SUG-013: a journey domain that returns nothing leaves the suggestion in review', () => { const { user } = createUser(testDb); const id = seedSuggestion({ userId: user.id }); @@ -683,6 +860,21 @@ describe('DawarichSuggestionsService — confirming a scan', () => { expect(bucketOf(mine).visited_at).not.toBeNull(); }); + it('DAWARICH-SUG-056: a confirmed wish is dated from the stay that matched it, not from today', () => { + // A wish somebody reached in 2023, ticked off with today's date, is a wrong + // entry in a list people keep for years. The clock is only the answer when + // nothing else knows better. + const { user } = createUser(testDb); + const wish = seedBucketItem(user.id, { name: 'Hallstatt' }); + seedSuggestion({ userId: user.id, matchedBucketListItemId: wish, startedAt: '2023-07-14T08:20:00Z' }); + + const updated = svc.confirmBucketVisits(user.id, [wish]); + + expect(updated).toBe(1); + expect(bucketOf(wish).visited_at).toBe('2023-07-14T08:20:00Z'); + expect(bucketOf(wish).visited_source).toBe('dawarich'); + }); + it('DAWARICH-SUG-024: clearBucketVisit clears the source with the date, and only for the owner', () => { const { user } = createUser(testDb); const { user: stranger } = createUser(testDb); @@ -746,6 +938,157 @@ describe('DawarichSuggestionsService — scanning for stays', () => { }); }); + it('DAWARICH-SUG-049: past the cap the scan says so instead of reporting the rest as unmatched', async () => { + // One upstream request per wish is why there is a cap at all. A user with + // eighty wishes being told the other thirty had no match is the failure + // mode this flag exists to prevent. + const { user } = createUser(testDb); + for (let i = 0; i < DAWARICH_BUCKET_SCAN_LIMIT + 1; i++) { + seedBucketItem(user.id, { name: `Wish ${i}` }); + } + clientStub.findVisitsNear.mockResolvedValue([]); + + const scan = await svc.scanBucketList(user.id); + + expect(scan.matches).toHaveLength(DAWARICH_BUCKET_SCAN_LIMIT); + expect(scan.truncated).toBe(true); + expect(scan.skippedWithoutCoordinates).toBe(0); + // Exactly one request per wish that was actually looked at. + expect(clientStub.findVisitsNear).toHaveBeenCalledTimes(DAWARICH_BUCKET_SCAN_LIMIT); + }); + + it('DAWARICH-SUG-050: a wish already ticked is scanned last and marked as such', async () => { + // Still scanned, because the recordings may date it better than the hand + // tick did, but flagged, so the list reads as "these are new" rather than + // as a wall of things the user has known for years. + const { user } = createUser(testDb); + const open = seedBucketItem(user.id, { name: 'Hallstatt' }); + const ticked = seedBucketItem(user.id, { name: 'Kyoto', visitedAt: '2023-04-02T09:00:00Z' }); + clientStub.findVisitsNear.mockResolvedValue([]); + + const scan = await svc.scanBucketList(user.id); + + expect(scan.matches.map((m) => m.itemId)).toEqual([open, ticked]); + expect(scan.matches[0].alreadyVisited).toBe(false); + expect(scan.matches[1].alreadyVisited).toBe(true); + }); + + it('DAWARICH-SUG-051: a stay beyond the radius is no match, however long it lasted', async () => { + // `radius_override` is a request, not a guarantee: an instance that ignores + // it answers with stays from its own default 500 m, and a four-hour stay + // 600 m away is somewhere else entirely. + const { user } = createUser(testDb); + seedBucketItem(user.id, { name: 'Hallstatt' }); + clientStub.findVisitsNear.mockResolvedValue([ + { + timestamp: 1757000000, + distance_meters: 600, + points_count: 90, + visit_details: { + start_time: '2026-09-04T10:00:00Z', + end_time: '2026-09-04T14:00:00Z', + duration_minutes: 240, + }, + }, + ]); + + const scan = await svc.scanBucketList(user.id); + + expect(scan.matches[0].match).toBeNull(); + }); + + it('DAWARICH-SUG-052: with no duration reported the two timestamps decide, and a stay with no details is skipped', async () => { + const { user } = createUser(testDb); + seedBucketItem(user.id, { name: 'Hallstatt' }); + clientStub.findVisitsNear.mockResolvedValue([ + // Nothing to measure at all: no duration, no times. It cannot clear the + // dwell threshold, so it is not a visit. + { timestamp: 1757000000, distance_meters: 10, visit_details: null }, + { + timestamp: 1757003600, + distance_meters: 20, + visit_details: { start_time: '2026-09-04T10:00:00Z', end_time: '2026-09-04T10:45:00Z' }, + }, + ]); + + const scan = await svc.scanBucketList(user.id); + + expect(scan.matches[0].match).toEqual({ + at: '2026-09-04T10:00:00Z', + minutes: 45, + distanceMeters: 20, + // No points_count reported is zero, not undefined on the wire. + points: 0, + }); + }); + + it('DAWARICH-SUG-053: with no distance reported the stay is taken at the radius and dated from its instant', async () => { + // The endpoint does not always carry `distance_meters`, and it was asked + // for a 250 m radius: treating the answer as "at most the radius" keeps the + // stay rather than dropping a real match over a missing field. + const { user } = createUser(testDb); + seedBucketItem(user.id, { name: 'Hallstatt' }); + clientStub.findVisitsNear.mockResolvedValue([ + { + timestamp: 1_757_000_000, + points_count: 12, + visit_details: { end_time: '2025-09-04T16:13:20Z', duration_minutes: 40 }, + }, + ]); + + const scan = await svc.scanBucketList(user.id); + + expect(scan.matches[0].match).toEqual({ + at: '2025-09-04T15:33:20.000Z', + minutes: 40, + distanceMeters: 250, + points: 12, + }); + }); + + it('DAWARICH-SUG-054: a stay that cannot be dated at all is skipped, not ticked off on 1970-01-01', async () => { + const { user } = createUser(testDb); + seedBucketItem(user.id, { name: 'Hallstatt' }); + clientStub.findVisitsNear.mockResolvedValue([ + { timestamp: null, distance_meters: 10, points_count: 8, visit_details: { duration_minutes: 60 } }, + ]); + + const scan = await svc.scanBucketList(user.id); + + expect(scan.matches[0].match).toBeNull(); + }); + + it('DAWARICH-SUG-055: the longest qualifying stay wins, and a later shorter one does not displace it', async () => { + const { user } = createUser(testDb); + seedBucketItem(user.id, { name: 'Hallstatt' }); + clientStub.findVisitsNear.mockResolvedValue([ + { + timestamp: 1757000000, + distance_meters: 30, + points_count: 3, + visit_details: { start_time: '2026-09-04T08:00:00Z', end_time: '2026-09-04T08:30:00Z', duration_minutes: 30 }, + }, + { + timestamp: 1757010000, + distance_meters: 200, + points_count: 40, + visit_details: { start_time: '2026-09-04T10:00:00Z', end_time: '2026-09-04T11:30:00Z', duration_minutes: 90 }, + }, + { + timestamp: 1757020000, + distance_meters: 5, + points_count: 20, + visit_details: { start_time: '2026-09-04T14:00:00Z', end_time: '2026-09-04T14:45:00Z', duration_minutes: 45 }, + }, + ]); + + const scan = await svc.scanBucketList(user.id); + + // The nearest stay is the 5 m one and the first one is the earliest; the + // ninety-minute one in the middle is still the visit. + expect(scan.matches[0].match).toMatchObject({ minutes: 90, distanceMeters: 200 }); + }); + it('DAWARICH-SUG-027: a wish whose lookup fails reads as "no match", not as a failed scan', async () => { const { user } = createUser(testDb); seedBucketItem(user.id, { name: 'Hallstatt' }); @@ -810,6 +1153,60 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { // A country TREK cannot code is still a country the user went to. expect(suggestions.unresolved).toEqual(['Absurdistan']); }); + + it('DAWARICH-SUG-057: atlasSuggestions without a connection refuses before a request', async () => { + const { user } = createUser(testDb); + dawarichStub.getCredentials.mockReturnValue(null); + + const err = await asyncRefusalFrom(() => + svc.atlasSuggestions(user.id, new Date('2026-08-01'), new Date('2026-09-01')), + ); + + expect(err.status).toBe(400); + expect(err.code).toBe('not_connected'); + expect(clientStub.listVisitedCities).not.toHaveBeenCalled(); + }); + + it('DAWARICH-SUG-058: a country with no name is dropped, and a nameless city does not become a blank row', async () => { + // The endpoint groups by a name string it derived itself, so an entry can + // arrive with that field empty or missing entirely. Those are the rows that + // would otherwise reach the Atlas dialog as an empty checkbox. + clientStub.listVisitedCities.mockResolvedValue([ + { cities: [] }, + { country: ' ', cities: [{ city: 'Nowhere' }] }, + { country: 'Austria' }, + { country: 'Germany', cities: [{ city: '' }, { city: 'Berlin' }] }, + ]); + + const { user } = createUser(testDb); + const suggestions = await svc.atlasSuggestions(user.id, new Date('2026-08-01'), new Date('2026-09-01')); + + expect(suggestions.countries.map((c) => c.countryCode)).toEqual(['AT', 'DE']); + // No cities array at all is an empty list, not a crash. + expect(suggestions.countries[0].cities).toEqual([]); + // A city with neither a stay length nor a timestamp still counts as a city + // the user was in; it just has nothing to say about when. + expect(suggestions.countries[1].cities).toEqual([{ name: 'Berlin', minutes: 0, lastSeenAt: null }]); + expect(suggestions.unresolved).toEqual([]); + }); + + it('DAWARICH-SUG-062: a hole in the cities array, and a city object without the field, are both dropped', async () => { + // `/countries/visited_cities` is upstream JSON that TREK does not validate, + // so the array can hold a null where a city object should be, or an object + // that simply never got the field. Both have to fall through to the empty + // name the filter removes: reading `.city` off a null would take the whole + // Atlas hand-off down, and one malformed city is not worth losing the + // country it sat in. + clientStub.listVisitedCities.mockResolvedValue([ + { country: 'Germany', cities: [null, {}, { city: 'Hamburg', stayed_for: 45 }] }, + ]); + + const { user } = createUser(testDb); + const suggestions = await svc.atlasSuggestions(user.id, new Date('2026-08-01'), new Date('2026-09-01')); + + expect(suggestions.countries.map((c) => c.countryCode)).toEqual(['DE']); + expect(suggestions.countries[0].cities).toEqual([{ name: 'Hamburg', minutes: 45, lastSeenAt: null }]); + }); // ── What an acceptance tells the rest of the trip ────────────────────────── it('DAWARICH-SUG-031: accepting as a place broadcasts it like any other place, socket id and all', () => { @@ -829,6 +1226,41 @@ describe('DawarichSuggestionsService — the Atlas hand-off', () => { expect(placesStub.onCreated).toHaveBeenCalledWith(String(trip.id), result.createdPlaceId); }); + it('DAWARICH-SUG-060: the broadcast carries the re-read row, so everyone on the trip sees the dawarich mark', () => { + // `source` is stamped after PlacesService.create returns, so the object + // that call handed back does not have it. Broadcasting that one would leave + // every other client showing the place without its provenance until the + // next full load, which is the state this re-read exists to avoid. + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const id = seedSuggestion({ userId: user.id, tripId: trip.id, name: 'Museum Ludwig' }); + + const result = svc.accept(user.id, id, { target: 'place', tripId: trip.id }); + + const payload = placesStub.broadcast.mock.calls[0][2] as { place: { id: number; source: string | null } }; + expect(payload.place.id).toBe(result.createdPlaceId); + expect(payload.place.source).toBe('dawarich'); + }); + + it('DAWARICH-SUG-061: a re-read that comes back empty still broadcasts the place that was created', () => { + // The re-read is an improvement on the payload, not a precondition for it: + // if it finds nothing the acceptance has still happened, and a broadcast + // the trip never receives is a place that needs a page reload to appear. + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const id = seedSuggestion({ userId: user.id, tripId: trip.id }); + dbMock.getPlaceWithTags.mockReturnValue(null); + + const result = svc.accept(user.id, id, { target: 'place', tripId: trip.id }, 'socket-3'); + + expect(placesStub.broadcast).toHaveBeenCalledWith( + String(trip.id), + 'place:created', + { place: { id: result.createdPlaceId } }, + 'socket-3', + ); + }); + it('DAWARICH-SUG-039: a member refused place_edit is refused here too', () => { const { user: owner } = createUser(testDb); const { user: member } = createUser(testDb); diff --git a/server/tests/unit/nest/dawarich-sync.job.test.ts b/server/tests/unit/nest/dawarich-sync.job.test.ts new file mode 100644 index 000000000..6d7d23459 --- /dev/null +++ b/server/tests/unit/nest/dawarich-sync.job.test.ts @@ -0,0 +1,202 @@ +/** + * DawarichSyncJob (DAWARICH-JOB-001..012): the scheduling shell around the + * Dawarich poller, built with `new`, collaborators as plain mocks, no Nest + * container and no timer. DawarichSyncService has its own suite; what is pinned + * here is only what the job itself decides, and every one of those decisions is + * the kind that regresses silently rather than loudly: + * + * - **the test gate.** `registrar.isEnabled()` is the single reason the suites + * never grow a live cron. A job that registered past it would have every + * integration boot start polling whatever Dawarich URL sits in the fixture + * database. + * - **the interval clamp.** `dawarich_poll_interval_minutes` is typed by an + * admin, so '0', '600' and a stray word all have to land on the 15-minute + * default instead of producing a cron expression the scheduler would reject + * or, worse, one that fires every minute. The numbers differ from the + * AirTrail job next door on purpose (floor 5 against its 1, default 15 + * against its 5), which is exactly the kind of difference a later + * copy-paste flattens: a flight lands once, while a visit detector emits + * continuously, and a minute-by-minute poll would hammer a self-hosted box + * for suggestions nobody is waiting on. + * - **the single read.** The setting is resolved once at bootstrap and the + * cron registered once, which is what the job's docblock promises. A tick + * that re-read app_settings would put a query on a timer for a value it + * could not act on anyway without re-registering. + * - **the swallow.** `tick` is the cron callback, so an escaping rejection is + * an unhandled rejection inside the scheduler, not a failed sync. Whatever + * was thrown (an Error, or the bare value an HTTP layer sometimes rejects + * with) has to end as one log line and a resolved promise. + * - **the addon gate's absence here.** It lives per tick inside `runSync`, + * which is what lets an admin switch the addon off without a restart. A copy + * of it in the job would freeze that decision at bootstrap, and the bug + * would look like "the toggle needs a restart", not like a failure. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const logMock = vi.hoisted(() => ({ LOG_LEVEL: 'error', logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() })); +vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock); + +import { DawarichSyncJob } from '../../../src/nest/integrations/dawarich-sync.job'; +import type { DawarichSyncService } from '../../../src/nest/integrations/dawarich-sync.service'; +import type { DatabaseService } from '../../../src/nest/database/database.service'; +import type { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service'; + +const SETTING_KEY = 'dawarich_poll_interval_minutes'; + +/** + * `undefined` for the interval stands for "no app_settings row at all" (the + * shape the optional chain in the job handles), while '' stands for a row + * someone blanked, which only the `|| '15'` fallback catches. Two different + * paths to the same default, and both have shipped as bugs elsewhere. + */ +function makeJob(intervalSetting?: string, enabled = true) { + let onTick: (() => void | Promise) | undefined; + const registrar = { + isEnabled: vi.fn(() => enabled), + register: vi.fn((_name: string, _expression: string, cb: () => void | Promise) => { + onTick = cb; + return enabled; + }), + unregister: vi.fn(), + }; + // Shaped like the real `get(sql, ...params)` rather than a bare `vi.fn()` + // so the stub cannot quietly drift from the signature the job calls; the + // statement and the key are the whole point of DAWARICH-JOB-002. + const db = { + get: vi.fn((_sql: string, ..._params: unknown[]) => (intervalSetting === undefined ? undefined : { value: intervalSetting })), + }; + const sync = { + runSync: vi.fn<() => Promise>().mockResolvedValue(undefined), + syncGloballyEnabled: vi.fn(() => false), + }; + const job = new DawarichSyncJob( + db as unknown as DatabaseService, + sync as unknown as DawarichSyncService, + registrar as unknown as CronRegistrarService, + ); + return { job, registrar, db, sync, takeTick: () => onTick }; +} + +beforeEach(() => vi.clearAllMocks()); + +describe('DawarichSyncJob bootstrap', () => { + it('DAWARICH-JOB-001: registers */N under the job name the scheduler reports, and logs the banner', () => { + const { job, registrar } = makeJob('30'); + job.onApplicationBootstrap(); + expect(registrar.register).toHaveBeenCalledWith('dawarich-sync', '*/30 * * * *', expect.any(Function)); + expect(logMock.logInfo).toHaveBeenCalledWith('Dawarich sync: scheduled every 30m'); + }); + + it('DAWARICH-JOB-002: reads its own setting key, not the AirTrail one', () => { + // Both jobs run the identical statement against app_settings; a copy-paste + // that kept 'airtrail_poll_interval_minutes' would silently inherit the + // other integration's cadence and nothing anywhere would fail. + const { job, db } = makeJob('20'); + job.onApplicationBootstrap(); + expect(db.get).toHaveBeenCalledWith('SELECT value FROM app_settings WHERE key = ?', SETTING_KEY); + }); + + it('DAWARICH-JOB-003: clamps the interval to 5-59 minutes and falls back to 15 on anything else', () => { + for (const [setting, minutes] of [ + [undefined, 15], // no row at all + ['', 15], // row present but blank + ['not-a-number', 15], // parseInt -> NaN + ['0', 15], // below the floor + ['4', 15], // one under the floor + ['60', 15], // one over the ceiling + ['5', 5], // the floor itself is allowed + ['59', 59], // and so is the ceiling + ] as const) { + vi.clearAllMocks(); + const { job, registrar } = makeJob(setting); + job.onApplicationBootstrap(); + expect(registrar.register).toHaveBeenCalledWith('dawarich-sync', `*/${minutes} * * * *`, expect.any(Function)); + expect(logMock.logInfo).toHaveBeenCalledWith(`Dawarich sync: scheduled every ${minutes}m`); + } + }); + + it('DAWARICH-JOB-004: takes the leading integer of a sloppy value rather than refusing it', () => { + // parseInt is lenient and the clamp is what makes that safe: '20 minutes' + // out of a hand-edited setting is still a usable 20, and everything the + // clamp dislikes has already gone to the default in the case above. + const { job, registrar } = makeJob('20 minutes'); + job.onApplicationBootstrap(); + expect(registrar.register).toHaveBeenCalledWith('dawarich-sync', '*/20 * * * *', expect.any(Function)); + }); + + it('DAWARICH-JOB-005: the test gate stops the job before it registers, logs or even touches the database', () => { + const { job, registrar, db } = makeJob('15', false); + job.onApplicationBootstrap(); + expect(registrar.register).not.toHaveBeenCalled(); + expect(db.get).not.toHaveBeenCalled(); + expect(logMock.logInfo).not.toHaveBeenCalled(); + }); + + it('DAWARICH-JOB-006: the callback handed to the registrar is the tick, so a fired cron reaches runSync', async () => { + const { job, sync, takeTick } = makeJob('15'); + job.onApplicationBootstrap(); + const onTick = takeTick(); + expect(onTick).toBeTypeOf('function'); + await onTick?.(); + expect(sync.runSync).toHaveBeenCalledTimes(1); + }); + + it('DAWARICH-JOB-012: resolves the interval once at bootstrap, so a tick neither re-reads the setting nor re-registers', async () => { + // Reading it per tick would be invisible in behaviour (the cadence would + // still be right) and would put an app_settings SELECT on a timer forever. + // Unregistering is the registrar's own business inside register(); the job + // calling it would leave a window where no cron exists at all. + const { job, db, registrar, takeTick } = makeJob('15'); + job.onApplicationBootstrap(); + await takeTick()?.(); + expect(db.get).toHaveBeenCalledTimes(1); + expect(registrar.register).toHaveBeenCalledTimes(1); + expect(registrar.unregister).not.toHaveBeenCalled(); + }); +}); + +describe('DawarichSyncJob tick', () => { + it('DAWARICH-JOB-007: delegates to runSync and stays quiet on success', async () => { + const { job, sync } = makeJob('15'); + await expect(job.tick()).resolves.toBeUndefined(); + expect(sync.runSync).toHaveBeenCalledTimes(1); + expect(logMock.logError).not.toHaveBeenCalled(); + }); + + it('DAWARICH-JOB-008: an unreachable instance becomes one log line and a resolved promise, never an unhandled rejection in the scheduler', async () => { + const { job, sync } = makeJob('15'); + sync.runSync.mockRejectedValue(new Error('fetch failed')); + await expect(job.tick()).resolves.toBeUndefined(); + expect(logMock.logError).toHaveBeenCalledWith('Dawarich sync tick failed: fetch failed'); + }); + + it('DAWARICH-JOB-009: a non-Error rejection is logged by value instead of as an empty message', async () => { + // Rejecting with a bare string is what a couple of fetch/abort paths do; + // reading .message off that would have logged "undefined" and buried the + // one piece of information the operator needs. + const { job, sync } = makeJob('15'); + sync.runSync.mockRejectedValue('ECONNREFUSED'); + await expect(job.tick()).resolves.toBeUndefined(); + expect(logMock.logError).toHaveBeenCalledWith('Dawarich sync tick failed: ECONNREFUSED'); + }); + + it('DAWARICH-JOB-010: a failed tick does not poison the next one', async () => { + // The job keeps no state across ticks, so the cron recovers on its own + // after an outage instead of needing a restart. + const { job, sync } = makeJob('15'); + sync.runSync.mockRejectedValueOnce(new Error('down')); + await job.tick(); + await job.tick(); + expect(sync.runSync).toHaveBeenCalledTimes(2); + expect(logMock.logError).toHaveBeenCalledTimes(1); + }); + + it('DAWARICH-JOB-011: does not re-check the addon gate itself, leaving the per-tick check inside runSync as the only one', async () => { + // Copying the gate up here would make an admin toggle need a restart, the + // exact behaviour the job's docblock says it avoids. + const { job, sync } = makeJob('15'); + await job.tick(); + expect(sync.runSync).toHaveBeenCalledTimes(1); + expect(sync.syncGloballyEnabled).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/nest/dawarich-sync.service.test.ts b/server/tests/unit/nest/dawarich-sync.service.test.ts index 4dbf9ec66..cbccb36d5 100644 --- a/server/tests/unit/nest/dawarich-sync.service.test.ts +++ b/server/tests/unit/nest/dawarich-sync.service.test.ts @@ -534,6 +534,23 @@ describe('DawarichSyncService — bucket-list matching', () => { expect(matched).not.toBe(far); }); + it('DAWARICH-SYNC-073: the first wish in range keeps it when the next one is farther away', async () => { + // The mirror image of 033. Wishes come back in the order they were stored, + // so storing the near one first is what makes the farther candidate arrive + // with a winner already held. The loop then has to keep what it has instead + // of taking whatever it looked at last; both wishes are inside the radius, + // so nothing else in the loop can decide it. + const near = bucketItem('Brandenburger Tor', LAT + 0.0005, LNG); // ~56 m + const far = bucketItem('Pariser Platz', LAT + 0.002, LNG); // ~222 m, still inside 250 m + + withVisits(visit({ id: 808 })); + await svc.syncUser(USER); + + const matched = only().matched_bucket_list_item_id; + expect(matched).toBe(near); + expect(matched).not.toBe(far); + }); + it('DAWARICH-SYNC-034: ignores a wish belonging to another user', async () => { const other = createUser(testDb).user.id; bucketItem('Brandenburger Tor', LAT + 0.0005, LNG, other); @@ -587,6 +604,90 @@ describe('DawarichSyncService — bucket-list matching', () => { expect(all.find((r) => r.source_visit_id === '822')!.matched_bucket_list_item_id).toBeNull(); }); + it('DAWARICH-SYNC-068: of two stays exactly as close, the longer one takes the wish', async () => { + // Both stays are at the wish's own coordinate, so the distances are the + // same number rather than merely similar and the tie-break is the only + // thing left to decide it. Standing somewhere for three hours is a better + // answer to "were you there" than half an hour on the way past. + const wish = bucketItem('Museum Ludwig', LAT, LNG); + withVisits( + visit({ + id: 840, + name: 'A quick look', + started_at: `${VISIT_DAY}T09:00:00Z`, + ended_at: `${VISIT_DAY}T09:30:00Z`, + place: { latitude: LAT, longitude: LNG, id: 1 }, + }), + visit({ + id: 841, + name: 'The whole afternoon', + started_at: `${VISIT_DAY}T13:00:00Z`, + ended_at: `${VISIT_DAY}T16:00:00Z`, + place: { latitude: LAT, longitude: LNG, id: 1 }, + }), + ); + + await svc.syncUser(USER); + + const all = rows(); + expect(all.find((r) => r.source_visit_id === '841')!.matched_bucket_list_item_id).toBe(wish); + expect(all.find((r) => r.source_visit_id === '840')!.matched_bucket_list_item_id).toBeNull(); + }); + + it('DAWARICH-SYNC-069: the tie-break holds when the longer stay is the one already holding the wish', async () => { + // The mirror image of 068. Here the incoming stay is the short one, so the + // claim has to be refused rather than won. Otherwise the answer would + // depend on the order the payload happened to list them in. + const wish = bucketItem('Museum Ludwig', LAT, LNG); + withVisits( + visit({ + id: 850, + name: 'The whole afternoon', + started_at: `${VISIT_DAY}T13:00:00Z`, + ended_at: `${VISIT_DAY}T16:00:00Z`, + place: { latitude: LAT, longitude: LNG, id: 1 }, + }), + visit({ + id: 851, + name: 'A quick look', + started_at: `${VISIT_DAY}T17:00:00Z`, + ended_at: `${VISIT_DAY}T17:30:00Z`, + place: { latitude: LAT, longitude: LNG, id: 1 }, + }), + ); + + await svc.syncUser(USER); + + const all = rows(); + expect(all.find((r) => r.source_visit_id === '850')!.matched_bucket_list_item_id).toBe(wish); + expect(all.find((r) => r.source_visit_id === '851')!.matched_bucket_list_item_id).toBeNull(); + }); + + it('DAWARICH-SYNC-070: a holder that lost its coordinates cannot block a stay that still has them', async () => { + // A suggestion whose place came back without a position keeps its link but + // can no longer be measured against anything. Skipping it is what lets the + // next real stay take the wish; treating an unmeasurable holder as the + // winner would freeze the match on a row nobody can act on. Dated well + // outside the synced window so the reconciliation leaves it alone. + const wish = bucketItem('Brandenburger Tor', LAT, LNG); + testDb + .prepare( + `INSERT INTO dawarich_visit_suggestions + (user_id, source_visit_id, trip_id, name, lat, lng, started_at, ended_at, duration_minutes, + local_date, source_status, state, source_hash, matched_bucket_list_item_id) + VALUES (?, '990', ?, 'Stay without a position', NULL, NULL, '2019-01-01T10:00:00Z', + '2019-01-01T12:00:00Z', 120, '2019-01-01', 'suggested', 'new', 'deadbeef', ?)`, + ) + .run(USER, TRIP, wish); + + withVisits(visit({ id: 842 })); + await svc.syncUser(USER); + + const all = rows(); + expect(all.find((r) => r.source_visit_id === '842')!.matched_bucket_list_item_id).toBe(wish); + expect(all.find((r) => r.source_visit_id === '990')!.matched_bucket_list_item_id).toBeNull(); + }); + it('DAWARICH-SYNC-036: a suggestion still in state "new" is re-matched on a later run', async () => { withVisits(visit({ id: 807 })); await svc.syncUser(USER); @@ -666,6 +767,21 @@ describe('DawarichSyncService — syncUser result state', () => { expect(connection()).toMatchObject({ last_sync_state: 'ok', last_sync_error: null }); }); + it('DAWARICH-SYNC-072: a trip whose start date is not a date is skipped, not asked about', async () => { + // An imported or hand-edited trip can hold something that passes the SQL + // filter and still is not a date. Without the window guard the request + // would go out with a NaN boundary, which Dawarich reads as "everything", + // and the answer would be the user's entire archive. + testDb.prepare("UPDATE trips SET start_date = '0000-00-00', end_date = NULL WHERE id = ?").run(TRIP); + + const result = await svc.syncUser(USER); + + expect(listVisits).not.toHaveBeenCalled(); + // Nothing failed: there was simply nothing answerable to ask. + expect(result).toMatchObject({ state: 'ok', created: 0, updated: 0, missing: 0 }); + expect(connection()).toMatchObject({ last_sync_state: 'ok', last_sync_error: null }); + }); + it('DAWARICH-SYNC-045: an archived trip is not polled', async () => { testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(TRIP); @@ -776,6 +892,24 @@ describe('DawarichSyncService — runSync', () => { expect(recordSyncResult).toHaveBeenCalledWith(other, 'ok', null); }); + it('DAWARICH-SYNC-071: a throw that is not an Error is survived just the same', async () => { + // The catch reads `.message` off whatever arrived. A rejection that is not + // an Error (a string from a native module, a plain object from a + // credential store) would otherwise throw a second time inside the + // handler, out of the loop, and take every remaining user with it. + const other = createUser(testDb).user.id; + createTrip(testDb, other, { start_date: TRIP_START, end_date: TRIP_END }); + connect(other); + withVisits(); + getCredentials.mockImplementationOnce(() => { + throw 'credential store returned a string'; + }); + + await svc.runSync(); + + expect(recordSyncResult).toHaveBeenCalledWith(other, 'ok', null); + }); + it('DAWARICH-SYNC-062: skips a connection whose background sync is switched off', async () => { connect(USER, { syncEnabled: false }); withVisits(); @@ -825,4 +959,43 @@ describe('DawarichSyncService — runSync', () => { // And the guard is released, so the next press does run. expect((await svc.syncUser(USER)).alreadyRunning).toBeUndefined(); }); + + it('DAWARICH-SYNC-066: the refused press reports the state the card already shows, not a fresh one', async () => { + // The run in flight will record its own result. Until it does, the honest + // answer is what the connection currently holds. Answering "ok" would + // clear a warning nobody fixed, and answering "never" would wipe the + // history of a connection that has synced for months. + testDb + .prepare("UPDATE dawarich_connections SET last_sync_state = 'partial' WHERE user_id = ?") + .run(USER); + let release: (value: { visits: DawarichVisitRaw[]; truncated: boolean; version: string | null }) => void = + () => {}; + listVisits.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const first = svc.syncUser(USER); + const second = await svc.syncUser(USER); + + expect(second).toEqual({ state: 'partial', created: 0, updated: 0, missing: 0, alreadyRunning: true }); + release({ visits: [], truncated: false, version: null }); + await first; + }); + + it('DAWARICH-SYNC-067: a connection that no longer exists reads as "never" rather than as undefined', async () => { + // Disconnecting mid-sync is a real sequence: the settings card deletes the + // row while the button press it triggered is still walking windows. There + // is no stored state left to report, and `never` is the one the wire + // contract allows. + testDb.prepare('DELETE FROM dawarich_connections WHERE user_id = ?').run(USER); + + const first = svc.syncUser(USER); + const second = await svc.syncUser(USER); + + expect(second.state).toBe('never'); + expect(second.alreadyRunning).toBe(true); + await first; + }); }); diff --git a/server/tests/unit/nest/dawarich-tracks.service.test.ts b/server/tests/unit/nest/dawarich-tracks.service.test.ts new file mode 100644 index 000000000..bd940481b --- /dev/null +++ b/server/tests/unit/nest/dawarich-tracks.service.test.ts @@ -0,0 +1,718 @@ +/** + * DawarichTracksService unit tests, DAWARICH-TRACKS-001 through + * DAWARICH-TRACKS-053. The service is built with `new` and every collaborator + * is a plain mock: no Nest container, no database, no socket. The helpers it + * delegates the actual geometry to (`bucketPointsByDay`, `bucketTracksByDay`) + * are the real ones, because what this service decides is *which* of them runs + * and with which window, and stubbing them would assert nothing about that. + * + * Four things here are worth pinning, because losing any of them is invisible + * until somebody's map is quietly wrong: + * + * - **the early returns.** A trip the caller cannot read, a trip that does not + * exist, a trip with no dates, a user with no connection and a nonsense + * window all leave without touching Dawarich. Two of them return `null` + * (which the controller turns into a 404) and the rest return an *empty + * track* (which the map draws as "nothing recorded"), and the difference + * between those is the difference between "no such trip" and "you were not + * recording". A regression that swapped them would 404 a perfectly good trip; + * - **the tracks-then-points fallback.** `/tracks` is a background-job product, + * so a fresh import has points and no tracks, and an instance old enough has + * no `/tracks` at all. The `catch` around the tracks call is the whole reason + * the overlay works on both, but it must not grow to cover the points call + * as well, or a genuinely broken connection would report "nothing recorded" + * instead of an error nobody can act on; + * - **the cache key.** User, window and UTC offset all belong in it. Drop the + * offset and two readers either side of a timezone change see each other's + * day boundaries; drop the user and they see each other's *routes*, which is + * a leak rather than a glitch. What must NOT be in it is the wording: the key + * is built from the resolved instants, so the journal writing local time and + * the planner writing `Z` share one entry for one window. The TTL boundary + * and the 64-entry eviction are pinned for the same reason those constants + * exist: a day of geometry per entry adds up fast; + * - **the offset reaching the bucketing.** Points carry a Unix timestamp and no + * zone, so "which day was that" is answered by the offset the caller sent. A + * dropped argument silently regroups everybody onto UTC days, and the days + * either side of a midnight are stitched together rather than left with a gap, + * which is why `pointCount` reports what was drawn and not what the archive + * holds. + * + * Every timestamp is written with an explicit offset (`Z` or `+HH:MM`) so the + * cases mean the same thing on a CI runner in UTC and on a laptop in Berlin, + * and the clock is frozen for the whole file so `fetchedAt` is a value a test + * can name rather than merely "some string". + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { DawarichTracksService } from '../../../src/nest/integrations/dawarich-tracks.service'; +import { DawarichError } from '../../../src/nest/integrations/dawarich.client'; +import type { + DawarichClient, + DawarichCreds, + DawarichSlimPoint, + DawarichTrackFeature, +} from '../../../src/nest/integrations/dawarich.client'; +import type { DawarichService } from '../../../src/nest/integrations/dawarich.service'; +import type { DatabaseService } from '../../../src/nest/database/database.service'; +import type { DawarichCapabilities } from '@trek/shared'; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +/** Frozen so `fetchedAt` is assertable. */ +const NOW_ISO = '2026-05-10T08:30:00.000Z'; + +const USER = 7; + +const CREDS: DawarichCreds = { + baseUrl: 'https://dawarich.example', + apiKey: 'secret-key', + allowInsecureTls: false, +}; + +/** What `canAccessTrip` hands back for a trip the caller owns. */ +const TRIP_ACCESS = { id: 5, user_id: USER, currency: 'EUR' }; + +const CAPABILITIES: DawarichCapabilities = { + visits: true, + tracks: true, + points: true, + locations: false, + visitedCities: true, + visitUpdatedAt: false, + visitCountryCode: false, + serverVersion: '1.14.5', + probedAt: '2026-05-09T00:00:00.000Z', +}; + +/** The answer every "there is nothing to ask about" path shares. */ +const EMPTY_TRACK = { + days: [], + source: 'tracks', + fetchedAt: NOW_ISO, + pointCount: 0, + truncated: false, +}; + +/** Unix seconds: what a slim point carries instead of a zoned timestamp. */ +const unix = (iso: string): number => Math.floor(Date.parse(iso) / 1000); + +/** + * Latitude and longitude go out as strings: that is what `/points?slim=true` + * actually sends, and a reader that only handles numbers is the shape of bug + * this fixture exists to keep out. + */ +const point = (lat: number, lng: number, iso: string): DawarichSlimPoint => ({ + latitude: String(lat), + longitude: String(lng), + timestamp: unix(iso), +}); + +/** One `/tracks` GeoJSON feature, two points wide so it survives simplification. */ +function feature( + props: Partial> = {}, + coordinates: unknown = [ + [13.0, 52.0], + [13.4, 52.4], + ], +): DawarichTrackFeature { + return { + type: 'Feature', + geometry: { type: 'LineString', coordinates }, + properties: { + start_at: '2026-05-01T10:00:00+02:00', + end_at: '2026-05-01T12:00:00+02:00', + dominant_mode: 'car', + distance: 41_000, + ...props, + }, + }; +} + +interface Collaborators { + /** `undefined` means the caller has no access to the trip. */ + access?: unknown; + /** `undefined` means the trip row is gone. */ + trip?: { start_date: string | null; end_date: string | null }; + creds?: DawarichCreds | null; + capabilities?: DawarichCapabilities | null; +} + +function harness(over: Collaborators = {}) { + const canAccessTrip = vi.fn().mockReturnValue('access' in over ? over.access : TRIP_ACCESS); + const get = vi + .fn() + .mockReturnValue( + 'trip' in over ? over.trip : { start_date: '2026-05-01', end_date: '2026-05-03' }, + ); + const getCredentials = vi.fn().mockReturnValue('creds' in over ? over.creds : CREDS); + const getCapabilities = vi.fn().mockReturnValue('capabilities' in over ? over.capabilities : null); + const listTracks = vi.fn().mockResolvedValue({ features: [], truncated: false }); + const listPoints = vi.fn().mockResolvedValue({ points: [], truncated: false }); + + const service = new DawarichTracksService( + { canAccessTrip, get } as unknown as DatabaseService, + { getCredentials, getCapabilities } as unknown as DawarichService, + { listTracks, listPoints } as unknown as DawarichClient, + ); + + return { service, canAccessTrip, get, getCredentials, getCapabilities, listTracks, listPoints }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(NOW_ISO)); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +// --------------------------------------------------------------------------- +// forTrip: access, the trip row, and the window the trip's own dates imply +// --------------------------------------------------------------------------- + +describe('DawarichTracksService.forTrip', () => { + it('DAWARICH-TRACKS-001: a trip the caller cannot read is null, and Dawarich is never asked about it', async () => { + const h = harness({ access: undefined }); + + await expect(h.service.forTrip(USER, 5)).resolves.toBeNull(); + // The access check comes first on purpose: reading the trip row before it + // would leak "this trip exists" through timing and through the query log. + expect(h.get).not.toHaveBeenCalled(); + expect(h.getCredentials).not.toHaveBeenCalled(); + expect(h.listTracks).not.toHaveBeenCalled(); + expect(h.canAccessTrip).toHaveBeenCalledWith(5, USER); + }); + + it('DAWARICH-TRACKS-002: a trip row that is gone is the same null: a member of a deleted trip gets a 404, not a crash', async () => { + const h = harness({ trip: undefined }); + + await expect(h.service.forTrip(USER, 5)).resolves.toBeNull(); + expect(h.listTracks).not.toHaveBeenCalled(); + }); + + it('DAWARICH-TRACKS-003: a trip with no dates is an empty track, not an error: there is no window to ask about', async () => { + const h = harness({ trip: { start_date: null, end_date: null } }); + + // An error here would read as "your Dawarich connection is broken", which is + // a different and far more alarming claim than "this trip has no dates yet". + await expect(h.service.forTrip(USER, 5)).resolves.toEqual(EMPTY_TRACK); + expect(h.getCredentials).not.toHaveBeenCalled(); + expect(h.listTracks).not.toHaveBeenCalled(); + }); + + it('DAWARICH-TRACKS-004: a from without any trip dates still has no end, so it is the empty track too', async () => { + const h = harness({ trip: { start_date: null, end_date: null } }); + + await expect(h.service.forTrip(USER, 5, '2026-05-01')).resolves.toEqual(EMPTY_TRACK); + expect(h.listTracks).not.toHaveBeenCalled(); + }); + + it("DAWARICH-TRACKS-005: the trip's own dates become a whole-days window, midnight to the last second", async () => { + const h = harness({ trip: { start_date: '2026-05-01', end_date: '2026-05-03' } }); + + await h.service.forTrip(USER, 5); + + expect(h.listTracks).toHaveBeenCalledWith( + CREDS, + new Date('2026-05-01T00:00:00Z'), + new Date('2026-05-03T23:59:59Z'), + ); + }); + + it('DAWARICH-TRACKS-006: a trip with a start and no end is a single day, not an open-ended window', async () => { + const h = harness({ trip: { start_date: '2026-05-01', end_date: null } }); + + await h.service.forTrip(USER, 5); + + expect(h.listTracks).toHaveBeenCalledWith( + CREDS, + new Date('2026-05-01T00:00:00Z'), + new Date('2026-05-01T23:59:59Z'), + ); + }); + + it('DAWARICH-TRACKS-007: from and to narrow the window, so the planner can ask for one day of a fortnight', async () => { + const h = harness({ trip: { start_date: '2026-05-01', end_date: '2026-05-14' } }); + + await h.service.forTrip(USER, 5, '2026-05-06', '2026-05-06'); + + expect(h.listTracks).toHaveBeenCalledWith( + CREDS, + new Date('2026-05-06T00:00:00Z'), + new Date('2026-05-06T23:59:59Z'), + ); + }); + + it('DAWARICH-TRACKS-008: a caller that sends no offset gets UTC days', async () => { + const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } }); + h.listPoints.mockResolvedValue({ + points: [point(52.0, 13.0, '2026-05-01T23:30:00Z'), point(52.1, 13.1, '2026-05-01T23:45:00Z')], + truncated: false, + }); + + const track = await h.service.forTrip(USER, 5, '2026-05-01', '2026-05-01'); + + expect(track?.days.map((day) => day.date)).toEqual(['2026-05-01']); + }); + + it("DAWARICH-TRACKS-009: the caller's offset decides the local day: half past eleven in Berlin is already tomorrow", async () => { + const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } }); + h.listPoints.mockResolvedValue({ + points: [point(52.0, 13.0, '2026-05-01T23:30:00Z'), point(52.1, 13.1, '2026-05-01T23:45:00Z')], + truncated: false, + }); + + const track = await h.service.forTrip(USER, 5, '2026-05-01', '2026-05-01', 120); + + // The same two instants as the case above; only the reader's zone changed, + // and that is the whole question the overlay answers. + expect(track?.days.map((day) => day.date)).toEqual(['2026-05-02']); + }); + + it('DAWARICH-TRACKS-010: forTrip reads the entry forWindow filled: it is a window calculation, not a second path', async () => { + const h = harness({ trip: { start_date: '2026-05-01', end_date: '2026-05-03' } }); + + const direct = await h.service.forWindow(USER, '2026-05-01T00:00:00Z', '2026-05-03T23:59:59Z', 0); + const viaTrip = await h.service.forTrip(USER, 5); + + // Identity, so this cannot pass on two equal-but-separate fetches: the map + // and the trip planner ask the same question about the same trip seconds + // apart, and a private cache on either side doubles every request. + expect(viaTrip).toBe(direct); + expect(h.listTracks).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// forWindow: the connection, the window, and the defaulted offset +// --------------------------------------------------------------------------- + +describe('DawarichTracksService.forWindow', () => { + it('DAWARICH-TRACKS-020: a user with no connection gets an empty track rather than a failed request', async () => { + const h = harness({ creds: null }); + + await expect( + h.service.forWindow(USER, '2026-05-01T00:00:00Z', '2026-05-02T00:00:00Z'), + ).resolves.toEqual(EMPTY_TRACK); + expect(h.listTracks).not.toHaveBeenCalled(); + expect(h.listPoints).not.toHaveBeenCalled(); + }); + + it.each([ + ['an unparseable from', 'yesterday', '2026-05-02T00:00:00Z'], + ['an unparseable to', '2026-05-01T00:00:00Z', 'tomorrow'], + ['an inverted window', '2026-05-02T00:00:00Z', '2026-05-01T00:00:00Z'], + ['a zero-length window', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z'], + ])('DAWARICH-TRACKS-021: %s is an empty track and no upstream call', async (_label, from, to) => { + const h = harness(); + + await expect(h.service.forWindow(USER, from, to)).resolves.toEqual(EMPTY_TRACK); + expect(h.listTracks).not.toHaveBeenCalled(); + expect(h.listPoints).not.toHaveBeenCalled(); + }); + + it('DAWARICH-TRACKS-022: with no offset argument the offset carried by `from` is used', async () => { + const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } }); + // Just after local midnight in Berlin, which is still the previous day in UTC. + h.listPoints.mockResolvedValue({ + points: [point(52.0, 13.0, '2026-04-30T22:10:00Z'), point(52.1, 13.1, '2026-04-30T22:20:00Z')], + truncated: false, + }); + + const defaulted = await h.service.forWindow( + USER, + '2026-05-01T00:00:00+02:00', + '2026-05-02T00:00:00+02:00', + ); + expect(defaulted.days.map((day) => day.date)).toEqual(['2026-05-01']); + + // The same window asked with an explicit zero groups the very same instants + // onto the UTC day instead, which is what a caller silent about its zone means. + const utc = await h.service.forWindow( + USER, + '2026-05-01T00:00:00+02:00', + '2026-05-02T00:00:00+02:00', + 0, + ); + expect(utc.days.map((day) => day.date)).toEqual(['2026-04-30']); + }); + + it('DAWARICH-TRACKS-023: the window reaches the client as instants, offset resolved', async () => { + const h = harness(); + + await h.service.forWindow(USER, '2026-05-01T00:00:00+02:00', '2026-05-02T00:00:00+02:00'); + + expect(h.listTracks).toHaveBeenCalledWith( + CREDS, + new Date('2026-04-30T22:00:00Z'), + new Date('2026-05-01T22:00:00Z'), + ); + }); +}); + +// --------------------------------------------------------------------------- +// The cache: key, TTL boundary, eviction, invalidation +// --------------------------------------------------------------------------- + +describe('DawarichTracksService cache', () => { + const FROM = '2026-05-01T00:00:00Z'; + const TO = '2026-05-02T00:00:00Z'; + + it('DAWARICH-TRACKS-030: a repeat request inside the TTL is the same object and no second fetch', async () => { + const h = harness(); + h.listTracks.mockResolvedValue({ features: [feature()], truncated: false }); + + const first = await h.service.forWindow(USER, FROM, TO); + const second = await h.service.forWindow(USER, FROM, TO); + + // Identity, not equality: panning the map must not rebuild a day of geometry. + expect(second).toBe(first); + expect(h.listTracks).toHaveBeenCalledTimes(1); + }); + + it('DAWARICH-TRACKS-031: the TTL is exclusive: one millisecond short still hits, sixty seconds exactly refetches', async () => { + const h = harness(); + + await h.service.forWindow(USER, FROM, TO); + vi.advanceTimersByTime(59_999); + await h.service.forWindow(USER, FROM, TO); + expect(h.listTracks).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1); + await h.service.forWindow(USER, FROM, TO); + // A live recording has to visibly catch up, so the entry expires rather than + // being refreshed in place. + expect(h.listTracks).toHaveBeenCalledTimes(2); + }); + + it('DAWARICH-TRACKS-032: the offset is part of the key: the same window grouped two ways is two answers', async () => { + const h = harness(); + + await h.service.forWindow(USER, FROM, TO, 0); + await h.service.forWindow(USER, FROM, TO, 120); + + expect(h.listTracks).toHaveBeenCalledTimes(2); + }); + + it("DAWARICH-TRACKS-033: the user is part of the key: nobody is served somebody else's route", async () => { + const h = harness(); + + await h.service.forWindow(1, FROM, TO, 0); + await h.service.forWindow(2, FROM, TO, 0); + + expect(h.listTracks).toHaveBeenCalledTimes(2); + }); + + it('DAWARICH-TRACKS-034: the cache evicts oldest-first at 64 entries, so flipping through a year cannot pin every day in memory', async () => { + const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } }); + const window = (i: number): [string, string] => { + const start = new Date(Date.UTC(2026, 0, 1) + i * 86_400_000); + return [start.toISOString(), new Date(start.getTime() + 3_600_000).toISOString()]; + }; + + for (let i = 0; i < 65; i++) { + const [from, to] = window(i); + await h.service.forWindow(USER, from, to, 0); + } + expect(h.listPoints).toHaveBeenCalledTimes(65); + + // Inserting the 65th pushed the 1st out. + const [firstFrom, firstTo] = window(0); + await h.service.forWindow(USER, firstFrom, firstTo, 0); + expect(h.listPoints).toHaveBeenCalledTimes(66); + + // The newest entry survived that eviction: it is the oldest that goes, not + // an arbitrary one. + const [lastFrom, lastTo] = window(64); + await h.service.forWindow(USER, lastFrom, lastTo, 0); + expect(h.listPoints).toHaveBeenCalledTimes(66); + }); + + it("DAWARICH-TRACKS-035: forget drops the calling user, so a re-pointed connection does not draw the old instance's route", async () => { + const h = harness(); + + await h.service.forWindow(1, FROM, TO, 0); + h.service.forget(1); + await h.service.forWindow(1, FROM, TO, 0); + + expect(h.listTracks).toHaveBeenCalledTimes(2); + }); + + it('DAWARICH-TRACKS-036: forget matches the whole user segment: forgetting 1 leaves 12 cached', async () => { + const h = harness(); + + await h.service.forWindow(1, FROM, TO, 0); + await h.service.forWindow(12, FROM, TO, 0); + expect(h.listTracks).toHaveBeenCalledTimes(2); + + h.service.forget(1); + + // A prefix test without the separator would throw user 12 out as well, and + // the symptom would be "the overlay reloads for no reason", never a failure. + await h.service.forWindow(12, FROM, TO, 0); + expect(h.listTracks).toHaveBeenCalledTimes(2); + }); + + it('DAWARICH-TRACKS-037: forget for a user with nothing cached leaves everything else alone', async () => { + const h = harness(); + + await h.service.forWindow(1, FROM, TO, 0); + h.service.forget(99); + await h.service.forWindow(1, FROM, TO, 0); + + expect(h.listTracks).toHaveBeenCalledTimes(1); + }); + + it('DAWARICH-TRACKS-038: the key is the resolved instant, not the string a caller happened to write', async () => { + const h = harness(); + + await h.service.forWindow(USER, FROM, TO, 0); + // The same two moments, spelled in Berlin local time instead of UTC. + await h.service.forWindow(USER, '2026-05-01T02:00:00+02:00', '2026-05-02T02:00:00+02:00', 0); + + // The journal sends what its entries carry and the planner sends `Z`. Keyed + // on the raw text those are two entries for one window, which is both a + // duplicate fetch and twice the memory for the same day of geometry. + expect(h.listTracks).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// fetch: tracks first, points as the fallback +// --------------------------------------------------------------------------- + +describe('DawarichTracksService source selection', () => { + const FROM = '2026-05-01T00:00:00Z'; + const TO = '2026-05-02T00:00:00Z'; + + it('DAWARICH-TRACKS-040: tracks answer with the mode and the measured distance, and points are never asked for', async () => { + const h = harness({ capabilities: CAPABILITIES }); + h.listTracks.mockResolvedValue({ features: [feature()], truncated: false }); + + const track = await h.service.forWindow(USER, FROM, TO, 0); + + expect(track).toEqual({ + days: [ + { + date: '2026-05-01', + segments: [ + { + points: [ + [52.0, 13.0], + [52.4, 13.4], + ], + mode: 'car', + // The source's own zoned strings, not a normalised instant: the + // times shown beside the line are the ones Dawarich shows. + startedAt: '2026-05-01T10:00:00+02:00', + endedAt: '2026-05-01T12:00:00+02:00', + distanceMeters: 41_000, + }, + ], + }, + ], + source: 'tracks', + fetchedAt: NOW_ISO, + pointCount: 2, + truncated: false, + }); + expect(h.listPoints).not.toHaveBeenCalled(); + expect(h.getCapabilities).toHaveBeenCalledWith(USER); + }); + + it('DAWARICH-TRACKS-041: a never-probed connection still tries tracks: unknown is not the same as unsupported', async () => { + const h = harness({ capabilities: null }); + h.listTracks.mockResolvedValue({ features: [feature()], truncated: false }); + + const track = await h.service.forWindow(USER, FROM, TO, 0); + + expect(track.source).toBe('tracks'); + expect(h.listTracks).toHaveBeenCalledTimes(1); + }); + + it('DAWARICH-TRACKS-042: a probe that said tracks: false skips the endpoint entirely', async () => { + const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } }); + h.listPoints.mockResolvedValue({ + points: [point(52.0, 13.0, '2026-05-01T10:00:00Z'), point(52.2, 13.2, '2026-05-01T10:10:00Z')], + truncated: false, + }); + + const track = await h.service.forWindow(USER, FROM, TO, 0); + + // An instance without /tracks answers 404 to every call; asking anyway would + // put a failed request in front of every single overlay open. + expect(h.listTracks).not.toHaveBeenCalled(); + expect(track.source).toBe('points'); + expect(track.pointCount).toBe(2); + }); + + it('DAWARICH-TRACKS-043: an empty tracks list falls through to points: a fresh import has points long before the job has run', async () => { + const h = harness({ capabilities: CAPABILITIES }); + h.listTracks.mockResolvedValue({ features: [], truncated: false }); + h.listPoints.mockResolvedValue({ + points: [point(52.0, 13.0, '2026-05-01T10:00:00Z'), point(52.2, 13.2, '2026-05-01T10:10:00Z')], + truncated: false, + }); + + const track = await h.service.forWindow(USER, FROM, TO, 0); + + expect(track.source).toBe('points'); + expect(track.days).toHaveLength(1); + expect(h.listPoints).toHaveBeenCalledWith(CREDS, new Date(FROM), new Date(TO)); + }); + + it('DAWARICH-TRACKS-044: a failing tracks endpoint falls through to points instead of failing the overlay', async () => { + const h = harness({ capabilities: CAPABILITIES }); + h.listTracks.mockRejectedValue(new DawarichError('not_found', 'no such endpoint', 404)); + h.listPoints.mockResolvedValue({ + points: [point(52.0, 13.0, '2026-05-01T10:00:00Z'), point(52.2, 13.2, '2026-05-01T10:10:00Z')], + truncated: false, + }); + + const track = await h.service.forWindow(USER, FROM, TO, 0); + + expect(track.source).toBe('points'); + expect(track.pointCount).toBe(2); + }); + + it('DAWARICH-TRACKS-045: a failing points endpoint is NOT swallowed: the catch covers tracks only', async () => { + const h = harness({ capabilities: CAPABILITIES }); + h.listTracks.mockRejectedValue(new DawarichError('not_found', 'no such endpoint', 404)); + h.listPoints.mockRejectedValue(new DawarichError('unauthorized', 'key rejected', 401)); + + // A genuinely broken connection has to reach the controller as an error. If + // this were caught too, a rejected API key would render as "nothing + // recorded" and nobody would ever find out why the line is missing. + await expect(h.service.forWindow(USER, FROM, TO, 0)).rejects.toThrow('key rejected'); + }); + + it('DAWARICH-TRACKS-046: a failed fetch is not cached, so the next request tries again', async () => { + const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } }); + h.listPoints.mockRejectedValueOnce(new DawarichError('unreachable', 'timed out')); + h.listPoints.mockResolvedValue({ points: [], truncated: false }); + + await expect(h.service.forWindow(USER, FROM, TO, 0)).rejects.toThrow('timed out'); + await h.service.forWindow(USER, FROM, TO, 0); + + expect(h.listPoints).toHaveBeenCalledTimes(2); + }); + + it('DAWARICH-TRACKS-047: nothing anywhere is an honest empty result that still names the endpoint that answered', async () => { + const h = harness({ capabilities: CAPABILITIES }); + + const track = await h.service.forWindow(USER, FROM, TO, 0); + + // Not EMPTY_TRACK: that one says `tracks` because nothing was asked at all. + // This one went all the way to points, came back with nothing, and says so. + expect(track).toEqual({ + days: [], + source: 'points', + fetchedAt: NOW_ISO, + pointCount: 0, + truncated: false, + }); + }); + + it('DAWARICH-TRACKS-048: a truncated tracks page reaches the caller: the line is incomplete and says so', async () => { + const h = harness({ capabilities: CAPABILITIES }); + h.listTracks.mockResolvedValue({ features: [feature()], truncated: true }); + + const track = await h.service.forWindow(USER, FROM, TO, 0); + + expect(track).toMatchObject({ source: 'tracks', truncated: true }); + }); + + it('DAWARICH-TRACKS-049: a truncated points page reaches the caller too', async () => { + const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } }); + h.listPoints.mockResolvedValue({ + points: [point(52.0, 13.0, '2026-05-01T10:00:00Z'), point(52.2, 13.2, '2026-05-01T10:10:00Z')], + truncated: true, + }); + + const track = await h.service.forWindow(USER, FROM, TO, 0); + + expect(track).toMatchObject({ source: 'points', truncated: true }); + }); + + it('DAWARICH-TRACKS-050: unusable points are dropped rather than drawn, and pointCount counts what was drawn', async () => { + const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } }); + h.listPoints.mockResolvedValue({ + points: [ + point(52.0, 13.0, '2026-05-01T10:00:00Z'), + // A latitude that is not a number at all, and one off the planet. Drawn + // as given, these are a detour to null island and a detour to nowhere. + { latitude: 'nope', longitude: '13.05', timestamp: unix('2026-05-01T10:05:00Z') }, + { latitude: '95.0', longitude: '13.06', timestamp: unix('2026-05-01T10:06:00Z') }, + point(52.2, 13.2, '2026-05-01T10:10:00Z'), + ], + truncated: false, + }); + + const track = await h.service.forWindow(USER, FROM, TO, 0); + + expect(track.pointCount).toBe(2); + expect(track.days[0]?.segments[0]).toMatchObject({ + points: [ + [52.0, 13.0], + [52.2, 13.2], + ], + // Points carry no mode and no measured distance. Only tracks do. + mode: null, + startedAt: '2026-05-01T10:00:00.000Z', + endedAt: '2026-05-01T10:10:00.000Z', + distanceMeters: null, + }); + }); + + it('DAWARICH-TRACKS-051: a track feature too thin to draw leaves the day out rather than emitting a one-point line', async () => { + const h = harness({ capabilities: CAPABILITIES }); + // One coordinate pair is a dot, not a route. The tracks branch is taken + // anyway because `features.length > 0`, so the empty `days` has to survive + // it rather than silently falling through to points and asking twice. + h.listTracks.mockResolvedValue({ features: [feature({}, [[13.0, 52.0]])], truncated: false }); + + const track = await h.service.forWindow(USER, FROM, TO, 0); + + expect(track).toMatchObject({ source: 'tracks', days: [], pointCount: 0 }); + expect(h.listPoints).not.toHaveBeenCalled(); + }); + + it("DAWARICH-TRACKS-052: the credentials handed to the client are the calling user's", async () => { + const h = harness(); + + await h.service.forWindow(USER, FROM, TO, 0); + + expect(h.getCredentials).toHaveBeenCalledWith(USER); + expect(h.listTracks.mock.calls[0][0]).toBe(CREDS); + }); + + it('DAWARICH-TRACKS-053: points either side of midnight become two days that meet, and pointCount counts what is drawn', async () => { + const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } }); + h.listPoints.mockResolvedValue({ + points: [ + point(52.0, 13.0, '2026-05-01T22:00:00Z'), + point(52.1, 13.1, '2026-05-01T23:00:00Z'), + // Deliberately well off the line between its neighbours: a point that + // simplification would drop as redundant would make the second day two + // points wide and the count below accidentally right. + point(52.5, 13.1, '2026-05-02T01:00:00Z'), + point(52.6, 13.2, '2026-05-02T02:00:00Z'), + ], + truncated: false, + }); + + const track = await h.service.forWindow(USER, '2026-05-01T00:00:00Z', '2026-05-03T00:00:00Z', 0); + + expect(track.days.map((day) => day.date)).toEqual(['2026-05-01', '2026-05-02']); + // The last point of the first day opens the second, so the two lines join + // instead of showing a jump across midnight that nobody made... + expect(track.days[1]?.segments[0]?.points[0]).toEqual([52.1, 13.1]); + // ...but it is geometry only. The second day still begins at its own first + // recorded instant, an hour after midnight, not at the stitch. + expect(track.days[1]?.segments[0]?.startedAt).toBe('2026-05-02T01:00:00.000Z'); + // Four points upstream, five drawn. The figure beside the overlay describes + // the line on the map, not the size of the archive behind it. + expect(track.pointCount).toBe(5); + }); +}); diff --git a/server/tests/unit/nest/dawarich.client.test.ts b/server/tests/unit/nest/dawarich.client.test.ts index 1a54e9270..b1e926fd7 100644 --- a/server/tests/unit/nest/dawarich.client.test.ts +++ b/server/tests/unit/nest/dawarich.client.test.ts @@ -187,6 +187,31 @@ describe('DawarichClient — URL building', () => { await client.probe({ ...CREDS, baseUrl: 'http://192.168.178.9:3000/api' }); expect(requestedUrl().href).toBe('http://192.168.178.9:3000/api/v1/users/me'); }); + + it('DAWARICH-CLIENT-007: an undefined query value is dropped, not sent as the word "undefined"', async () => { + // Every method today builds its query out of String(), isoUtc() or + // unixSeconds(), so none of them can hand the helper an undefined; the + // helper's signature still accepts one, and the day a caller passes an + // optional filter through, `?per_page=undefined` is a request Dawarich + // would answer wrongly rather than reject. Reached through the private + // `get` because that is the only place the decision is made. + type PrivateGet = ( + creds: DawarichCreds, + path: string, + query: Record, + ) => Promise<{ data: unknown; headers: Headers }>; + + answerWith(reply({ body: { id: 1 } })); + const get = (client as unknown as { get: PrivateGet }).get.bind(client); + await get(CREDS, '/visits', { page: '2', per_page: undefined, order: 'asc' }); + + const url = requestedUrl(); + expect(url.pathname).toBe('/api/v1/visits'); + expect(url.searchParams.get('page')).toBe('2'); + expect(url.searchParams.get('order')).toBe('asc'); + expect(url.searchParams.has('per_page')).toBe(false); + expect(url.search).not.toContain('undefined'); + }); }); describe('DawarichClient — authentication', () => { @@ -289,6 +314,18 @@ describe('DawarichClient — error classification', () => { expect(err.detail).toContain('ENOTFOUND'); }); + it('DAWARICH-CLIENT-042: a rejection that is not an Error is still "unreachable", with no detail invented', async () => { + // undici rejects with an Error, but the SSRF guard sits in front of it and + // is free to reject with anything at all. Reaching for `.message` on + // whatever arrived is how the catch block itself starts throwing. The + // honest answer for a string is no detail rather than the string "undefined" + // shown to the user under "why the connection failed". + safeFetchMock.mockImplementation(() => Promise.reject('socket hang up')); + const err = await failure(() => client.probe(CREDS)); + expect(err.code).toBe('unreachable'); + expect(err.detail).toBeUndefined(); + }); + it('DAWARICH-CLIENT-037: an HTML login page is "invalid_response", not a parse crash', async () => { answerWith(reply({ raw: 'Sign in' })); const err = await failure(() => client.probe(CREDS)); @@ -392,6 +429,17 @@ describe('DawarichClient — listVisits', () => { expect(new URL(calls[19].url).searchParams.get('page')).toBe('20'); }); + it('DAWARICH-CLIENT-055: an X-Total-Pages that is not a number means one page, not twenty', async () => { + // A reverse proxy that rewrites or truncates the header must not turn one + // window into twenty identical requests against somebody's home server. + answerWith(reply({ body: [visit(1)], headers: { 'X-Total-Pages': 'many' } })); + const out = await client.listVisits(CREDS, FROM, TO); + + expect(calls).toHaveLength(1); + expect(out.visits).toHaveLength(1); + expect(out.truncated).toBe(false); + }); + it('DAWARICH-CLIENT-054: a payload that is not a list is "invalid_response"', async () => { answerWith(reply({ body: { visits: [] } })); const err = await failure(() => client.listVisits(CREDS, FROM, TO)); @@ -444,6 +492,14 @@ describe('DawarichClient — listTracks', () => { expect(calls).toHaveLength(20); expect(capped.truncated).toBe(true); }); + + it('DAWARICH-CLIENT-063: an unreadable X-Total-Pages means one page', async () => { + answerWith(reply({ body: { features: [feature(1)] }, headers: { 'X-Total-Pages': 'unknown' } })); + const out = await client.listTracks(CREDS, FROM, TO); + + expect(calls).toHaveLength(1); + expect(out).toEqual({ features: [feature(1)], truncated: false }); + }); }); describe('DawarichClient — listPoints', () => { @@ -481,6 +537,14 @@ describe('DawarichClient — listPoints', () => { expect(capped.truncated).toBe(true); }); + it('DAWARICH-CLIENT-073: an unreadable X-Total-Pages means one page', async () => { + answerWith(reply({ body: [point(1)], headers: { 'X-Total-Pages': 'lots' } })); + const out = await client.listPoints(CREDS, FROM, TO); + + expect(calls).toHaveLength(1); + expect(out).toEqual({ points: [point(1)], truncated: false }); + }); + it('DAWARICH-CLIENT-072: a payload that is not a list is "invalid_response"', async () => { answerWith(reply({ body: { points: [] } })); const err = await failure(() => client.listPoints(CREDS, FROM, TO)); diff --git a/server/tests/unit/nest/dawarich.controller.test.ts b/server/tests/unit/nest/dawarich.controller.test.ts index 2fdecbf73..83fa1d32e 100644 --- a/server/tests/unit/nest/dawarich.controller.test.ts +++ b/server/tests/unit/nest/dawarich.controller.test.ts @@ -342,6 +342,17 @@ describe('DawarichController suggestion routes', () => { expect(r).toEqual({ status: 404, body: { error: 'Bucket-list entry not found' } })); }); + it('DAWARICH-CTRL-076: DELETE bucket-list/:itemId/visit answers 200 { success: true } when a tick was cleared', () => { + // The counterpart to 046: the service reports whether a row changed, and + // only the "nothing changed" answer is a 404. A cleared tick must not come + // back as one, or undoing a wish would look like it failed. + const clearBucketVisit = vi.fn().mockReturnValue(true); + expect(makeController({ suggestions: { clearBucketVisit } }).clearBucketVisit(user, '4')).toEqual({ + success: true, + }); + expect(clearBucketVisit).toHaveBeenCalledWith(7, 4); + }); + it('DAWARICH-CTRL-047: POST atlas/accept wraps the marked count', () => { const acceptAtlasCountries = vi.fn().mockReturnValue(2); expect(makeController({ suggestions: { acceptAtlasCountries } }) @@ -570,13 +581,13 @@ describe('DawarichController GET /trips/:tripId/track', () => { }, ); - it('DAWARICH-CTRL-074: a trip the caller cannot read is a 404 — the service says null, the controller says not found', async () => { + it('DAWARICH-CTRL-077: a trip the caller cannot read is a 404 (the service says null, the controller says not found)', async () => { const forTrip = vi.fn().mockResolvedValue(null); expect(await thrown(() => makeController({ tracks: { forTrip } }).tripTrack(user, '5'))) .toEqual({ status: 404, body: { error: 'Trip not found' } }); }); - it('DAWARICH-CTRL-075: a track comes back untouched', async () => { + it('DAWARICH-CTRL-078: a track comes back untouched', async () => { const track = { days: [{ date: '2026-05-01', segments: [] }], truncated: false }; const forTrip = vi.fn().mockResolvedValue(track); await expect(makeController({ tracks: { forTrip } }).tripTrack(user, '5')).resolves.toBe(track); diff --git a/server/tests/unit/nest/dawarich.helpers.test.ts b/server/tests/unit/nest/dawarich.helpers.test.ts index bc808a0f4..269e5d391 100644 --- a/server/tests/unit/nest/dawarich.helpers.test.ts +++ b/server/tests/unit/nest/dawarich.helpers.test.ts @@ -227,6 +227,15 @@ describe('visitHash', () => { it('DAWARICH-HASH-009: a missing place does not throw', () => { expect(visitHash(visit({ place: null }))).toMatch(/^[0-9a-f]{64}$/); }); + + it('DAWARICH-HASH-010: a missing status and an empty status are the same visit', () => { + // `status` is nullable in the payload, so it is folded to the same blank a + // visit with an empty one gets. Without that, a null would hash as the + // literal "null" and a provider filling the field in with "" later would + // look like every unscored visit changing at once. + expect(visitHash(visit({ status: null }))).toBe(visitHash(visit({ status: '' }))); + expect(visitHash(visit({ status: null }))).not.toBe(visitHash(BASE_VISIT)); + }); }); // ── toNumber ───────────────────────────────────────────────────────────────── @@ -396,6 +405,19 @@ describe('normalizeVisit', () => { expect(Date.parse(result!.endedAt) - Date.parse(result!.startedAt)).toBe(1_800_000); expect(result?.durationMinutes).toBe(30); }); + + it('DAWARICH-VISIT-017: an unreadable end plus a duration of zero or less collapses onto the arrival', () => { + // A reported duration is only usable as a repair while it is positive: zero + // is "the detector had nothing", and a negative one is the same accident + // that produced the unreadable end. Either way the end moves to the + // arrival, which is the one timestamp that was actually readable. It never + // moves backwards from it. + for (const duration of [0, -45, '0'] as const) { + const result = normalizeVisit(visit({ duration, ended_at: 'sometime' })); + expect(result?.endedAt).toBe(new Date(Date.parse(result!.startedAt)).toISOString()); + expect(result?.durationMinutes).toBe(0); + } + }); }); // ── distanceMeters ─────────────────────────────────────────────────────────── @@ -455,6 +477,27 @@ describe('simplify', () => { expect(simplify([], 0.00005)).toEqual([]); }); + it('DAWARICH-GEO-024: a loop back to the start is measured from the point, not from a zero-length baseline', () => { + // A day that ends where it began gives the algorithm a segment whose two + // ends are the same coordinate. The gradient it normally projects onto does + // not exist there, so the distance has to fall back to "how far is this + // point from that one". Without it the division is 0/0 and every detour on + // a round trip would simplify away as NaN. + const loop: Array<[number, number]> = [[0, 0], [0, 1], [0, 0]]; + expect(simplify(loop, 0.00005)).toEqual(loop); + // And the same shape, a metre wide instead of a degree, still collapses. + expect(simplify([[0, 0], [0, 0.00001], [0, 0]], 0.00005)).toEqual([[0, 0], [0, 0]]); + }); + + it('DAWARICH-GEO-025: an unreadable latitude cannot collapse the longitude scale', () => { + // The metric scales longitude by the cosine of the latitude, and guards + // that scale so it is never zero. A latitude that is not a real number + // gives no cosine at all, and the fallback keeps the scale at 1 rather + // than multiplying every longitude by nothing. The bad point measures as + // no deviation and is dropped; the ends of the line still come back. + expect(simplify([[0, 0], [NaN, 0.5], [0, 1]], 0.00005)).toEqual([[0, 0], [0, 1]]); + }); + it('DAWARICH-GEO-011: the first and last point are always kept', () => { const zigzag: Array<[number, number]> = [[0, 0], [1, 0.25], [0, 0.5], [1, 0.75], [0, 1]]; const out = simplify(zigzag, 0.00005); @@ -826,6 +869,27 @@ describe('bucketTracksByDay', () => { expect(days[0]!.segments[0]!.points).toHaveLength(5); }); + it('DAWARICH-TRACK-015: a cut that lands on the last point ends the walk instead of emitting an empty tail', () => { + // Midnight falls at 98% of this track's time span, so the proportional cut + // puts every point before it. What is left afterwards is one point, which + // is not a line: the loop stops there rather than pushing a tail piece that + // would render as nothing and carry a start after its own end. + const days = bucketTracksByDay([ + track('2024-06-15T00:00:00Z', '2024-06-16T00:30:00Z', [[10, 10], [11, 12], [12, 10]], { + distance: 7000, + }), + ]); + + expect(days.map((d) => d.date)).toEqual(['2024-06-15']); + const segment = days[0]!.segments[0]!; + expect(segment.points).toEqual([[10, 10], [12, 11], [10, 12]]); + expect(segment.startedAt).toBe('2024-06-15T00:00:00.000Z'); + expect(segment.endedAt).toBe('2024-06-16T00:00:00.000Z'); + // It was cut, even though only one piece came out, so the measured distance + // no longer describes it. + expect(segment.distanceMeters).toBeNull(); + }); + it('DAWARICH-TRACK-013: an empty feature list is an empty result', () => { expect(bucketTracksByDay([])).toEqual([]); }); diff --git a/server/tests/unit/nest/dawarich.mcp.test.ts b/server/tests/unit/nest/dawarich.mcp.test.ts new file mode 100644 index 000000000..02be7a53b --- /dev/null +++ b/server/tests/unit/nest/dawarich.mcp.test.ts @@ -0,0 +1,699 @@ +/** + * DawarichMcp unit tests: DAWARICH-MCP-001 through DAWARICH-MCP-066. + * + * The class is built with `new` and every collaborator is a plain mock: no Nest + * container, no registry, no MCP server, no network. That is deliberate. The + * tool bodies are small, and what is worth pinning about them is not the + * plumbing but the four things a regression would quietly take away: + * + * - **the decorator metadata**, which is the tool's entire security story. The + * REST half carries `@RequireAddon(ADDON_IDS.DAWARICH)` and a guard order; + * the MCP half carries `when:` and `access:`, and nothing else enforces + * either. A dropped `when:` leaves the tools callable on an instance where + * the addon is off, and a widened `access:` hands a token scoped to one + * thing the power to write another. Accepting is three tools precisely so + * that each claims one scope instead of the union of all three, so the + * group/mode table below is what keeps that true; + * - **the absence of the connection tools**. Settings, test and disconnect + * take an API key for somebody's complete location history. Their absence is + * a decision, and a decision nobody asserts is one somebody re-adds by + * accident, so the tool-name set is pinned exactly and no input schema is + * allowed to grow a credential field; + * - **the input schemas**, which are the only validation these tools have. The + * controller has `parseId`/`parseState`/`parseDate` and a test each; the tool + * has a Zod shape and the SDK. If `suggestionId` loses `.positive()` nothing + * else notices, so the shapes are parsed here directly; + * - **the demo gate and the error shaping**. Every write tool refuses the demo + * account before it reaches the service, and `run()` turns an `AcceptError` + * into a refusal the assistant can read instead of an exception. Anything + * that is not an `AcceptError` is deliberately left to propagate, which is + * pinned too: a swallowed failure reported as a success would be worse. + * + * Suggestion fixtures are cast rather than spelled out in full, because the + * tools pass them through untouched and the unread fields would only be noise. + * Where a tool does reshape a payload (`tripTrack`) the fixture is complete and + * the assertion is a deep equality, because there the point is exactly which + * fields survive and which do not. + */ +import { describe, it, expect, vi } from 'vitest'; +import { z, type ZodRawShape } from 'zod'; +import type { DawarichSuggestionList, DawarichTrack } from '@trek/shared'; + +import { DawarichMcp } from '../../../src/nest/integrations/dawarich.mcp'; +import { AcceptError } from '../../../src/nest/integrations/dawarich-suggestions.service'; +import type { DawarichSuggestionsService } from '../../../src/nest/integrations/dawarich-suggestions.service'; +import type { DawarichTracksService } from '../../../src/nest/integrations/dawarich-tracks.service'; +import type { AuthService } from '../../../src/nest/auth/auth.service'; +import type { AddonsService } from '../../../src/nest/addons/addons.service'; +import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata'; +import type { McpContext, McpTextResult, ToolOptions } from '../../../src/nest-mcp'; +import { ADDON_IDS } from '../../../src/addons'; + +const ctx = { userId: 7, scopes: null, isStaticToken: false } as McpContext; + +interface Mocks { + suggestions?: Partial; + tracks?: Partial; + auth?: Partial; + addons?: Partial; +} + +/** The demo gate is off unless a case turns it on; every write tool asks it. */ +function makeMcp(m: Mocks = {}) { + const auth: Partial = m.auth ?? { isDemoUser: () => false }; + return new DawarichMcp( + (m.suggestions ?? {}) as DawarichSuggestionsService, + (m.tracks ?? {}) as DawarichTracksService, + auth as AuthService, + (m.addons ?? {}) as AddonsService, + ); +} + +/** A tool result is one JSON text block; a success carries no isError flag. */ +function payload(result: McpTextResult): Record { + expect(result.isError).toBeUndefined(); + return JSON.parse(result.content[0].text) as Record; +} + +function refusal(result: McpTextResult): string { + expect(result.isError).toBe(true); + return result.content[0].text; +} + +function toolOptions(methodName: string): ToolOptions { + const entry = getEntry(DawarichMcp as unknown as ClassRef, methodName); + if (!entry || entry.kind !== 'tool') throw new Error(methodName + ' is not a registered MCP tool'); + return entry.options; +} + +/** + * Every decorated method on the class, read off the prototype the same way the + * registry enumerates one. Read rather than listed, because a table this file + * writes down cannot notice a seventh tool: the point of the set assertion + * below is a new tool failing a test, and that only works if the class is the + * thing being enumerated. + */ +function registeredMethods(): string[] { + return Object.getOwnPropertyNames(DawarichMcp.prototype) + .filter((name) => getEntry(DawarichMcp as unknown as ClassRef, name) !== undefined) + .sort(); +} + +/** The tool's declared input shape, as a schema a case can parse against. */ +function inputSchema(methodName: string) { + return z.object(toolOptions(methodName).inputSchema as ZodRawShape); +} + +/** Method name to advertised tool name. Handlers are reached by the key. */ +const TOOLS: Record = { + listSuggestions: 'list_dawarich_suggestions', + acceptAsPlace: 'accept_dawarich_suggestion_as_place', + acceptAsJournalEntry: 'accept_dawarich_suggestion_as_journal_entry', + markBucketVisited: 'mark_bucket_list_item_visited_from_dawarich', + dismiss: 'dismiss_dawarich_suggestion', + tripTrack: 'get_dawarich_trip_track', +}; + +function list(count: number, over: Partial = {}): DawarichSuggestionList { + return { + suggestions: Array.from({ length: count }, (_, i) => ({ id: i + 1, name: 'Stay ' + (i + 1) })), + connected: true, + lastSyncAt: '2026-05-01T06:00:00.000Z', + lastSyncState: 'ok', + lastSyncError: null, + ...over, + } as unknown as DawarichSuggestionList; +} + +// --------------------------------------------------------------------------- +// Decorator metadata: the whole gate, and the tools that are not here +// --------------------------------------------------------------------------- + +describe('DawarichMcp surface', () => { + it('DAWARICH-MCP-001: exactly six tools, and not one of them touches the connection', () => { + // The API key is a credential for somebody's complete location history, so + // settings, test and disconnect have no tool at all. Asserted as an exact + // set, because "we did not add one" stays true only until somebody does. + // The class is enumerated first so that a seventh decorated method fails + // here rather than being quietly absent from the table this file keeps. + expect(registeredMethods()).toEqual(Object.keys(TOOLS).sort()); + + const names = registeredMethods().map((method) => toolOptions(method).name).sort(); + expect(names).toEqual([ + 'accept_dawarich_suggestion_as_journal_entry', + 'accept_dawarich_suggestion_as_place', + 'dismiss_dawarich_suggestion', + 'get_dawarich_trip_track', + 'list_dawarich_suggestions', + 'mark_bucket_list_item_visited_from_dawarich', + ]); + }); + + it('DAWARICH-MCP-002: no tool takes a URL, an API key or a TLS switch', () => { + // The other half of the same decision: a connection surface could also + // arrive as a field bolted onto an existing schema rather than as a tool. + const fields = registeredMethods().flatMap((method) => + Object.keys(toolOptions(method).inputSchema as ZodRawShape), + ); + for (const forbidden of ['url', 'apiKey', 'api_key', 'allowInsecureTls', 'token']) { + expect(fields).not.toContain(forbidden); + } + }); + + it.each(registeredMethods())( + 'DAWARICH-MCP-003: %s is gated on the dawarich addon, the same gate the controller gets from @RequireAddon', + (method) => { + const isAddonEnabled = vi.fn().mockReturnValue(false); + const mcp = makeMcp({ addons: { isAddonEnabled } }); + const { when } = toolOptions(method); + + expect(typeof when).toBe('function'); + expect(when(ctx, mcp)).toBe(false); + expect(isAddonEnabled).toHaveBeenCalledWith(ADDON_IDS.DAWARICH); + + isAddonEnabled.mockReturnValue(true); + expect(when(ctx, mcp)).toBe(true); + }, + ); + + it.each([ + ['listSuggestions', 'journey', 'read'], + ['acceptAsPlace', 'places', 'write'], + ['acceptAsJournalEntry', 'journey', 'write'], + ['markBucketVisited', 'atlas', 'write'], + ['dismiss', 'journey', 'write'], + ['tripTrack', 'journey', 'read'], + ])( + 'DAWARICH-MCP-004: %s claims %s:%s, the scope of what it writes rather than of where the data came from', + (method, group, mode) => { + // Collapse the three accept tools into one with a `target` parameter and + // this table is the first thing that has to change: the single tool would + // have to claim places:write, journey:write and atlas:write together. + expect(toolOptions(method).access).toEqual({ group, mode }); + }, + ); + + it.each([ + // method, readOnlyHint, idempotentHint, openWorldHint + ['listSuggestions', true, true, false], + ['acceptAsPlace', false, false, false], + ['acceptAsJournalEntry', false, false, false], + ['markBucketVisited', false, true, false], + ['dismiss', false, true, false], + ['tripTrack', true, true, true], + ])( + 'DAWARICH-MCP-005: %s advertises readOnly=%s idempotent=%s openWorld=%s', + (method, readOnly, idempotent, openWorld) => { + // openWorldHint is the one a host acts on: the list tool reads TREK's own + // table (the fetch from the remote instance happens on a cron), while the + // track tool reaches the caller's Dawarich instance on every call. + expect(toolOptions(method).annotations).toMatchObject({ + readOnlyHint: readOnly, + destructiveHint: false, + idempotentHint: idempotent, + openWorldHint: openWorld, + }); + }, + ); + + it('DAWARICH-MCP-006: the two read tools never ask the demo gate, so the demo account can still see what it recorded', async () => { + // The counterpart to DAWARICH-MCP-040, and the same line the controller + // draws: reading is not a write. The gate is wired to answer "yes, demo" + // here, so a check added to a read would turn both of these into refusals + // and payload() would fail on the isError flag. + const isDemoUser = vi.fn().mockReturnValue(true); + const listFn = vi.fn().mockReturnValue(list(1)); + const forTrip = vi.fn().mockResolvedValue({ + days: [], source: 'tracks', fetchedAt: '2026-05-02T09:00:00.000Z', pointCount: 0, truncated: false, + }); + const mcp = makeMcp({ suggestions: { list: listFn }, tracks: { forTrip }, auth: { isDemoUser } }); + + payload(mcp.listSuggestions({}, ctx)); + payload(await mcp.tripTrack({ tripId: 5 }, ctx)); + expect(isDemoUser).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Input schemas: the only validation these tools have +// --------------------------------------------------------------------------- + +describe('DawarichMcp input schemas', () => { + it('DAWARICH-MCP-010: every filter on the list tool is optional, so "what is waiting for review" needs no arguments', () => { + expect(inputSchema('listSuggestions').safeParse({}).success).toBe(true); + }); + + it.each([0, -3, 1.5, '42'])('DAWARICH-MCP-011: the list tool refuses the tripId %j', (tripId) => { + expect(inputSchema('listSuggestions').safeParse({ tripId }).success).toBe(false); + }); + + it.each(['new', 'accepted', 'dismissed'])('DAWARICH-MCP-012: %s is a known state and passes', (state) => { + expect(inputSchema('listSuggestions').safeParse({ state }).success).toBe(true); + }); + + it.each(['NEW', 'pending', 'all', ''])( + 'DAWARICH-MCP-013: the unknown state %j is refused rather than ignored, which would answer with an unfiltered list', + (state) => { + expect(inputSchema('listSuggestions').safeParse({ state }).success).toBe(false); + }, + ); + + it.each([ + [1, true], + [200, true], + [0, false], + [201, false], + [50.5, false], + ])( + 'DAWARICH-MCP-014: limit %j is accepted: %s. 200 is the ceiling that keeps a year of stays out of one tool result', + (limit, okay) => { + expect(inputSchema('listSuggestions').safeParse({ limit }).success).toBe(okay); + }, + ); + + it('DAWARICH-MCP-015: accepting as a place needs an id and nothing else, because the trip defaults to the one the stay fell into', () => { + const schema = inputSchema('acceptAsPlace'); + expect(schema.safeParse({ suggestionId: 9 }).success).toBe(true); + expect(schema.safeParse({}).success).toBe(false); + expect(schema.safeParse({ suggestionId: 0 }).success).toBe(false); + expect(schema.safeParse({ suggestionId: -1 }).success).toBe(false); + }); + + it.each([ + [{ lat: 90, lng: 180 }, true], + [{ lat: -90, lng: -180 }, true], + [{ lat: 90.1, lng: 0 }, false], + [{ lat: 0, lng: 180.1 }, false], + ])('DAWARICH-MCP-016: corrected coordinates %j are on the globe: %s', (coords, okay) => { + expect(inputSchema('acceptAsPlace').safeParse({ suggestionId: 1, ...coords }).success).toBe(okay); + }); + + it('DAWARICH-MCP-017: a corrected name is trimmed and cannot be whitespace, and notes stop at 5000 characters', () => { + const schema = inputSchema('acceptAsPlace'); + expect(schema.safeParse({ suggestionId: 1, name: ' Hafen ' })).toMatchObject({ + success: true, + data: { name: 'Hafen' }, + }); + expect(schema.safeParse({ suggestionId: 1, name: ' ' }).success).toBe(false); + expect(schema.safeParse({ suggestionId: 1, notes: 'x'.repeat(5000) }).success).toBe(true); + expect(schema.safeParse({ suggestionId: 1, notes: 'x'.repeat(5001) }).success).toBe(false); + }); + + it('DAWARICH-MCP-018: a journal entry needs a journey to live in, so journalId is the one required companion', () => { + const schema = inputSchema('acceptAsJournalEntry'); + expect(schema.safeParse({ suggestionId: 9, journalId: 3 }).success).toBe(true); + expect(schema.safeParse({ suggestionId: 9 }).success).toBe(false); + }); + + // Spelled with an explicit row type: the rows carry different keys, so left + // to inference the callback's first parameter is a union of one-key object + // types rather than "a partial stamp", which is what it means here. + it.each<[Record, boolean]>([ + [{ date: '2026-05-01' }, true], + [{ date: '2026-5-1' }, false], + [{ date: '2026-05-01T00:00:00Z' }, false], + [{ time: '09:30' }, true], + [{ time: '9:30' }, false], + [{ time: '09:30:00' }, false], + ])('DAWARICH-MCP-019: the journal stamp %j is accepted: %s', (over, okay) => { + expect(inputSchema('acceptAsJournalEntry').safeParse({ suggestionId: 1, journalId: 1, ...over }).success).toBe(okay); + }); + + it('DAWARICH-MCP-020: ticking off a wish needs the stay; the wish itself defaults to the one it was matched to', () => { + const schema = inputSchema('markBucketVisited'); + expect(schema.safeParse({ suggestionId: 4 }).success).toBe(true); + expect(schema.safeParse({ suggestionId: 4, bucketListItemId: 8 }).success).toBe(true); + expect(schema.safeParse({ suggestionId: 4, bucketListItemId: 0 }).success).toBe(false); + expect(schema.safeParse({ bucketListItemId: 8 }).success).toBe(false); + }); + + it('DAWARICH-MCP-021: dismissing takes only the two states that are a review decision, and "accepted" is not one of them', () => { + const schema = inputSchema('dismiss'); + expect(schema.safeParse({ suggestionId: 4 }).success).toBe(true); + expect(schema.safeParse({ suggestionId: 4, state: 'new' }).success).toBe(true); + expect(schema.safeParse({ suggestionId: 4, state: 'dismissed' }).success).toBe(true); + // Accepting writes a place, an entry or a tick. It is never a state change, + // and a tool that let one through would mark a stay handled with nothing + // behind it. + expect(schema.safeParse({ suggestionId: 4, state: 'accepted' }).success).toBe(false); + }); + + it.each<[Record, boolean]>([ + [{ tripId: 5 }, true], + [{ tripId: 5, from: '2026-05-01', to: '2026-05-03' }, true], + [{}, false], + [{ tripId: 5, from: '01.05.2026' }, false], + [{ tripId: 5, to: 'yesterday' }, false], + ])('DAWARICH-MCP-022: the track tool accepts %j: %s', (args, okay) => { + expect(inputSchema('tripTrack').safeParse(args).success).toBe(okay); + }); +}); + +// --------------------------------------------------------------------------- +// list_dawarich_suggestions +// --------------------------------------------------------------------------- + +describe('DawarichMcp list_dawarich_suggestions', () => { + it('DAWARICH-MCP-030: the filters reach the service for the calling user, and the sync provenance rides back', () => { + // A disconnected instance with a failed poll is the interesting case: the + // stays are still there, and without the provenance an assistant would + // present a stale backlog as if it were current. + const listFn = vi.fn().mockReturnValue(list(2, { connected: false, lastSyncState: 'failed', lastSyncAt: null })); + const out = payload(makeMcp({ suggestions: { list: listFn } }).listSuggestions({ tripId: 3, state: 'new' }, ctx)); + + expect(listFn).toHaveBeenCalledWith(7, { tripId: 3, state: 'new' }); + expect(out).toMatchObject({ + total: 2, + truncated: false, + connected: false, + lastSyncAt: null, + lastSyncState: 'failed', + }); + }); + + it('DAWARICH-MCP-031: no arguments means no filters, undefined rather than a filter on nothing', () => { + const listFn = vi.fn().mockReturnValue(list(0)); + payload(makeMcp({ suggestions: { list: listFn } }).listSuggestions({}, ctx)); + expect(listFn).toHaveBeenCalledWith(7, { tripId: undefined, state: undefined }); + }); + + it('DAWARICH-MCP-032: a backlog longer than the default page is cut to 50 and says so, because an assistant must not pay for a year of stays', () => { + const out = payload(makeMcp({ suggestions: { list: vi.fn().mockReturnValue(list(60)) } }).listSuggestions({}, ctx)); + + expect((out.suggestions as unknown[]).length).toBe(50); + expect(out.total).toBe(60); + expect(out.truncated).toBe(true); + }); + + it('DAWARICH-MCP-033: a list that fits is not flagged truncated, so a caller can tell "all of it" from "the first page"', () => { + const out = payload(makeMcp({ suggestions: { list: vi.fn().mockReturnValue(list(50)) } }).listSuggestions({}, ctx)); + + expect((out.suggestions as unknown[]).length).toBe(50); + expect(out.total).toBe(50); + expect(out.truncated).toBe(false); + }); + + it('DAWARICH-MCP-034: an explicit limit wins over the default in both directions', () => { + const mcp = makeMcp({ suggestions: { list: vi.fn().mockReturnValue(list(60)) } }); + + const small = payload(mcp.listSuggestions({ limit: 3 }, ctx)); + expect((small.suggestions as unknown[]).length).toBe(3); + expect(small.truncated).toBe(true); + + const large = payload(mcp.listSuggestions({ limit: 200 }, ctx)); + expect((large.suggestions as unknown[]).length).toBe(60); + expect(large.truncated).toBe(false); + }); + + it('DAWARICH-MCP-035: the stays go out as the service shaped them, because the tool pages and does not reshape', () => { + const out = payload(makeMcp({ suggestions: { list: vi.fn().mockReturnValue(list(2)) } }).listSuggestions({ limit: 1 }, ctx)); + expect(out.suggestions).toEqual([{ id: 1, name: 'Stay 1' }]); + }); + + it('DAWARICH-MCP-036: the reason the last poll failed is the one field the tool drops', () => { + // lastSyncError is on DawarichSuggestionList and the REST list carries it; + // this tool does not. Pinned so the omission stays a decision rather than an + // oversight: an assistant can say the sync failed but not why, which is a + // defensible privacy line (the string can name the host) and, at the same + // time, a real gap in what it is able to explain to the person asking. + const listFn = vi.fn().mockReturnValue(list(1, { lastSyncState: 'failed', lastSyncError: 'unauthorized' })); + const out = payload(makeMcp({ suggestions: { list: listFn } }).listSuggestions({}, ctx)); + + expect(out).not.toHaveProperty('lastSyncError'); + expect(out.lastSyncState).toBe('failed'); + }); +}); + +// --------------------------------------------------------------------------- +// The three accept tools and the dismiss tool +// --------------------------------------------------------------------------- + +describe('DawarichMcp accept tools', () => { + it.each(['acceptAsPlace', 'acceptAsJournalEntry', 'markBucketVisited', 'dismiss'])( + 'DAWARICH-MCP-040: %s refuses the demo account before the service is reached', + (method) => { + // One argument object for all four: the demo check runs before anything + // reads the body, so the extra keys are exactly as irrelevant here as the + // refusal makes them. + const args = { suggestionId: 9, tripId: 2, journalId: 2 }; + const accept = vi.fn(); + const setState = vi.fn(); + const isDemoUser = vi.fn().mockReturnValue(true); + const mcp = makeMcp({ suggestions: { accept, setState }, auth: { isDemoUser } }); + + const call = (mcp as unknown as Record McpTextResult>)[method]; + expect(refusal(call.call(mcp, args, ctx))).toBe('Write operations are disabled in demo mode.'); + expect(isDemoUser).toHaveBeenCalledWith(7); + expect(accept).not.toHaveBeenCalled(); + expect(setState).not.toHaveBeenCalled(); + }, + ); + + it('DAWARICH-MCP-041: accepting as a place forwards every correction under target "place", with the id kept out of the body', () => { + // suggestionId is the second argument, not a body field. Leaving it in the + // body would put a key into DawarichAccept that nothing reads, and the next + // reader would reasonably assume it was the one being accepted. + const accept = vi.fn().mockReturnValue({ createdPlaceId: 5, createdJournalEntryId: null, bucketListItemId: null }); + const out = payload(makeMcp({ suggestions: { accept } }).acceptAsPlace( + { suggestionId: 9, tripId: 2, dayId: 4, name: 'Hafen', notes: 'windy', lat: 53.5, lng: 9.9 }, + ctx, + )); + + expect(accept).toHaveBeenCalledWith(7, 9, { + target: 'place', + tripId: 2, + dayId: 4, + name: 'Hafen', + notes: 'windy', + lat: 53.5, + lng: 9.9, + }); + expect(accept.mock.calls[0][2]).not.toHaveProperty('suggestionId'); + expect(out).toMatchObject({ createdPlaceId: 5 }); + }); + + it('DAWARICH-MCP-042: a bare accept sends only the target, so the service keeps its own defaults for the trip and the coordinates', () => { + const accept = vi.fn().mockReturnValue({ createdPlaceId: 1 }); + makeMcp({ suggestions: { accept } }).acceptAsPlace({ suggestionId: 9 }, ctx); + expect(accept).toHaveBeenCalledWith(7, 9, { target: 'place' }); + }); + + it('DAWARICH-MCP-043: accepting as a journal entry forwards target "journal" with the journey, the story and the corrected stamp', () => { + const accept = vi.fn().mockReturnValue({ createdJournalEntryId: 11 }); + const out = payload(makeMcp({ suggestions: { accept } }).acceptAsJournalEntry( + { suggestionId: 9, journalId: 3, name: 'The harbour', notes: 'It rained.', date: '2026-05-01', time: '09:30' }, + ctx, + )); + + expect(accept).toHaveBeenCalledWith(7, 9, { + target: 'journal', + journalId: 3, + name: 'The harbour', + notes: 'It rained.', + date: '2026-05-01', + time: '09:30', + }); + expect(out).toMatchObject({ createdJournalEntryId: 11 }); + }); + + it('DAWARICH-MCP-044: ticking off a wish names it explicitly when the caller did', () => { + const accept = vi.fn().mockReturnValue({ bucketListItemId: 8 }); + makeMcp({ suggestions: { accept } }).markBucketVisited({ suggestionId: 9, bucketListItemId: 8 }, ctx); + expect(accept).toHaveBeenCalledWith(7, 9, { target: 'bucket_list', bucketListItemId: 8 }); + }); + + it('DAWARICH-MCP-045: an omitted wish stays undefined rather than being guessed here, and the service falls back to the matched one', () => { + const accept = vi.fn().mockReturnValue({ bucketListItemId: 2 }); + makeMcp({ suggestions: { accept } }).markBucketVisited({ suggestionId: 9 }, ctx); + expect(accept).toHaveBeenCalledWith(7, 9, { target: 'bucket_list', bucketListItemId: undefined }); + }); + + it.each([ + ['not_found', 'Suggestion not found', 404], + ['already_accepted', 'Suggestion has already been accepted', 409], + ['day_not_on_trip', 'Day does not belong to this trip', 400], + ['trip_required', 'A trip is required to create a place', 400], + ])( + 'DAWARICH-MCP-046: the AcceptError %s comes back as a refusal an assistant can read (%s), not as an exception and not as the HTTP %i the REST half answers', + (code, message, status) => { + const accept = vi.fn(() => { throw new AcceptError(code, message, status); }); + const result = makeMcp({ suggestions: { accept } }).acceptAsPlace({ suggestionId: 9 }, ctx); + + expect(refusal(result)).toBe(message); + // The status code is the REST half's vocabulary. MCP has isError and a + // sentence, and a number leaking into the sentence would be noise an + // assistant then repeats at the person who asked. + expect(result.content[0].text).not.toContain(String(status)); + }, + ); + + it('DAWARICH-MCP-047: the same shaping covers the journal and bucket-list tools, because run() is shared and a second copy would drift', () => { + const accept = vi.fn(() => { throw new AcceptError('not_found', 'Bucket-list entry not found', 404); }); + const mcp = makeMcp({ suggestions: { accept } }); + + expect(refusal(mcp.acceptAsJournalEntry({ suggestionId: 9, journalId: 1 }, ctx))).toBe('Bucket-list entry not found'); + expect(refusal(mcp.markBucketVisited({ suggestionId: 9 }, ctx))).toBe('Bucket-list entry not found'); + }); + + it('DAWARICH-MCP-048: anything that is not an AcceptError propagates, because a failed write must never be reported as a success', () => { + // A locked database, or a permission lookup that blew up, is not a refusal + // the caller can act on. Flattening it into a text result would hand the + // assistant a message with no isError flag on it to notice. + const accept = vi.fn(() => { throw new Error('SQLITE_BUSY: database is locked'); }); + expect(() => makeMcp({ suggestions: { accept } }).acceptAsPlace({ suggestionId: 9 }, ctx)) + .toThrow('SQLITE_BUSY: database is locked'); + }); +}); + +describe('DawarichMcp dismiss_dawarich_suggestion', () => { + it('DAWARICH-MCP-050: dismissed is the default, because that is what the tool is for', () => { + const updated = { id: 4, state: 'dismissed' }; + const setState = vi.fn().mockReturnValue(updated); + const out = payload(makeMcp({ suggestions: { setState } }).dismiss({ suggestionId: 4 }, ctx)); + + expect(setState).toHaveBeenCalledWith(7, 4, 'dismissed'); + expect(out).toEqual({ suggestion: updated }); + }); + + it('DAWARICH-MCP-051: "new" puts a dismissed stay back into the review list', () => { + const setState = vi.fn().mockReturnValue({ id: 4, state: 'new' }); + makeMcp({ suggestions: { setState } }).dismiss({ suggestionId: 4, state: 'new' }, ctx); + expect(setState).toHaveBeenCalledWith(7, 4, 'new'); + }); + + it("DAWARICH-MCP-052: somebody else's suggestion gets the answer a missing one gets: the service says null, the tool says not found", () => { + // The service scopes its lookup by user_id, so null covers both cases and + // the tool must not separate them. A different message for "exists but is + // not yours" would let a caller enumerate other people's suggestion ids. + const setState = vi.fn().mockReturnValue(null); + expect(refusal(makeMcp({ suggestions: { setState } }).dismiss({ suggestionId: 999 }, ctx))) + .toBe('Suggestion not found'); + }); +}); + +// --------------------------------------------------------------------------- +// get_dawarich_trip_track +// --------------------------------------------------------------------------- + +describe('DawarichMcp get_dawarich_trip_track', () => { + const track = { + source: 'points', + fetchedAt: '2026-05-02T09:00:00.000Z', + truncated: true, + pointCount: 3, + days: [ + { + date: '2026-05-01', + segments: [ + { + mode: 'walking', + startedAt: '2026-05-01T08:00:00.000Z', + endedAt: '2026-05-01T08:20:00.000Z', + distanceMeters: 1234, + points: [[53.5, 9.9], [53.6, 10.0]], + }, + { + // An instance too old to classify reports neither a mode nor a + // distance. Both travel as null rather than being invented here. + mode: null, + startedAt: '2026-05-01T12:00:00.000Z', + endedAt: '2026-05-01T12:05:00.000Z', + distanceMeters: null, + points: [[53.7, 10.1]], + }, + ], + }, + // A recorded day on which nothing was segmented still belongs in the + // answer: "we have that day and it is empty" is not "we have no day". + { date: '2026-05-02', segments: [] }, + ], + } as unknown as DawarichTrack; + + it('DAWARICH-MCP-060: the geometry is replaced by a count, because a month of coordinates in a tool result is the failure mode', async () => { + const forTrip = vi.fn().mockResolvedValue(track); + const out = payload(await makeMcp({ tracks: { forTrip } }).tripTrack({ tripId: 5 }, ctx)); + + expect(out).toEqual({ + source: 'points', + fetchedAt: '2026-05-02T09:00:00.000Z', + truncated: true, + pointCount: 3, + days: [ + { + date: '2026-05-01', + segments: [ + { + mode: 'walking', + startedAt: '2026-05-01T08:00:00.000Z', + endedAt: '2026-05-01T08:20:00.000Z', + distanceMeters: 1234, + points: 2, + }, + { + mode: null, + startedAt: '2026-05-01T12:00:00.000Z', + endedAt: '2026-05-01T12:05:00.000Z', + distanceMeters: null, + points: 1, + }, + ], + }, + { date: '2026-05-02', segments: [] }, + ], + }); + }); + + it('DAWARICH-MCP-061: the narrowing reaches the service, so one day can be asked for without pulling the whole trip', async () => { + const forTrip = vi.fn().mockResolvedValue(track); + await makeMcp({ tracks: { forTrip } }).tripTrack({ tripId: 5, from: '2026-05-01', to: '2026-05-01' }, ctx); + expect(forTrip).toHaveBeenCalledWith(7, 5, '2026-05-01', '2026-05-01'); + }); + + it('DAWARICH-MCP-062: no narrowing leaves the window to the trip dates, and the day grouping falls back to UTC', async () => { + // Four arguments, not five: the tool passes no offsetMinutes, so the + // service's own default of 0 decides which local day a point belongs to. + // The REST route takes the offset from the caller. Pinned as it stands + // rather than as it arguably should be, so a deliberate change to it is + // visible in a diff instead of arriving unnoticed. + const forTrip = vi.fn().mockResolvedValue(track); + await makeMcp({ tracks: { forTrip } }).tripTrack({ tripId: 5 }, ctx); + expect(forTrip).toHaveBeenCalledWith(7, 5, undefined, undefined); + }); + + it('DAWARICH-MCP-063: a trip the caller cannot read is "not found", the same answer a missing trip gets', async () => { + // The service returns null for both on purpose, and the tool has to keep + // them indistinguishable or trip ids become enumerable over MCP. + const forTrip = vi.fn().mockResolvedValue(null); + expect(refusal(await makeMcp({ tracks: { forTrip } }).tripTrack({ tripId: 5 }, ctx))).toBe('Trip not found'); + }); + + it('DAWARICH-MCP-064: an unreachable instance is a refusal carrying the reason, not a rejected promise', async () => { + const forTrip = vi.fn().mockRejectedValue(new Error('Dawarich is unreachable')); + expect(refusal(await makeMcp({ tracks: { forTrip } }).tripTrack({ tripId: 5 }, ctx))) + .toBe('Dawarich is unreachable'); + }); + + it('DAWARICH-MCP-065: a throw that is not an Error still gets a sentence, rather than reaching the assistant as "undefined"', async () => { + const forTrip = vi.fn().mockRejectedValue('boom'); + expect(refusal(await makeMcp({ tracks: { forTrip } }).tripTrack({ tripId: 5 }, ctx))) + .toBe('Could not read the Dawarich recording'); + }); + + it('DAWARICH-MCP-066: an empty recording is a success with no days, not an error, because "we looked and there was nothing" is an answer', async () => { + const forTrip = vi.fn().mockResolvedValue({ + days: [], + source: 'tracks', + fetchedAt: '2026-05-02T09:00:00.000Z', + pointCount: 0, + truncated: false, + } as unknown as DawarichTrack); + + expect(payload(await makeMcp({ tracks: { forTrip } }).tripTrack({ tripId: 5 }, ctx))).toEqual({ + days: [], + source: 'tracks', + fetchedAt: '2026-05-02T09:00:00.000Z', + pointCount: 0, + truncated: false, + }); + }); +}); diff --git a/server/tests/unit/nest/dawarich.service.test.ts b/server/tests/unit/nest/dawarich.service.test.ts new file mode 100644 index 000000000..62cf5f645 --- /dev/null +++ b/server/tests/unit/nest/dawarich.service.test.ts @@ -0,0 +1,1160 @@ +/** + * DawarichService: DAWARICH-SVC-001 through DAWARICH-SVC-143. + * + * This is the credential half of the Dawarich integration. Everything it owns + * is either a secret at rest or a decision about that secret, which is why the + * database here is real in-memory SQLite rather than a stub: the interesting + * behaviour lives in an UPSERT plus three conditional UPDATEs inside one + * transaction, and a spy on `db.run` would only prove that some strings were + * handed along. Reading the row back is the assertion. + * + * The at-rest crypto is real too, for the same reason. `maybe_encrypt_api_key` + * stubbed to the identity function would let a regression that writes the key + * in plaintext pass every case in this file, and "the key is never in the + * column as typed" is the single most important thing the service does. + * + * The two things that would reach outside the process are faked: `checkSsrf`, + * because the save path resolves DNS and a test must not depend on a resolver, + * and `DawarichClient`, because the probe makes six upstream calls. The client + * stub has no default implementation of its own beyond what each case sets, so + * an unstubbed call fails loudly instead of dialling out. + * + * What is worth pinning here, in order of what it costs when it regresses: + * + * - **the key never leaves.** `getConnection` hands back a mask, and a key + * that cannot be decrypted (rotated ENCRYPTION_KEY, restored backup) has to + * read as "not connected" rather than be sent upstream as a garbage bearer + * token; + * - **the key goes with the host.** Re-pointing the connection at a different + * Dawarich without retyping the key must drop the key, because a credential + * minted on one instance shipped to another one is a leak with a helpful UI + * in front of it. Same origin, different path is the same instance and must + * keep it, or every trailing-slash edit logs the user out; + * - **a private address is a warning, not a refusal.** A self-hosted Dawarich + * on a LAN is the common case. A guard that refuses it breaks the majority + * of installs to protect against something that is not happening; + * - **an older Dawarich still connects.** A 404 from an endpoint turns that + * one capability off; anything else propagates, because "connected, but + * nothing works" is a worse answer than an error; + * - **a stored capabilities blob is read defensively.** It is written by + * whatever build was running last, and a malformed value means "not probed + * yet", never a crash on the settings page. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +// ── DB setup (real in-memory SQLite, the pattern the other Dawarich service tests use) ── + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + return { + testDb: db, + dbMock: { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: () => null, + isOwner: () => false, + }, + }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +// Same fixed key the global setup exports, pinned here so the at-rest round +// trip cannot depend on what is lying in server/data. +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-secret', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +// The whole ssrfGuard surface, not only `checkSsrf`: several modules on the +// import graph pull other names off it, and a factory mock that omits one +// throws on access rather than falling back to the real export. +const { checkSsrf } = vi.hoisted(() => ({ checkSsrf: vi.fn() })); +vi.mock('../../../src/utils/ssrfGuard', () => ({ + checkSsrf, + safeFetch: vi.fn(), + safeFetchFollow: vi.fn(), + safeFetchAdminConfigured: vi.fn(), + safeFetchLlm: vi.fn(), + createPinnedDispatcher: vi.fn(() => ({})), + SsrfBlockedError: class extends Error {}, +})); + +import { DAWARICH_KEY_MASK, type DawarichCapabilities } from '@trek/shared'; +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser } from '../../helpers/factories'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { DawarichService } from '../../../src/nest/integrations/dawarich.service'; +import { + DawarichError, + type DawarichClient, + type DawarichVisitRaw, +} from '../../../src/nest/integrations/dawarich.client'; +import type { AuditService } from '../../../src/nest/audit/audit.service'; + +// ── Collaborators ──────────────────────────────────────────────────────────── + +const audit = { writeAudit: vi.fn() }; + +const client = { + probe: vi.fn(), + listVisits: vi.fn(), + listTracks: vi.fn(), + listPoints: vi.fn(), + listVisitedCities: vi.fn(), + findVisitsNear: vi.fn(), +}; + +const dbs = new DatabaseService(testDb); +const svc = new DawarichService( + dbs, + audit as unknown as AuditService, + client as unknown as DawarichClient, +); + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +const DAY_MS = 86_400_000; +const HOST = 'https://dawarich.example'; +const IP = '203.0.113.44'; + +let USER = 0; + +interface ConnFixture { + url: string | null; + /** Written verbatim, so a case can store a legacy plaintext key or a corrupt blob. */ + apiKey: string | null; + allowInsecureTls: 0 | 1; + syncEnabled: 0 | 1; + lastSyncAt: string | null; + lastSyncState: string; + lastSyncError: string | null; + capabilities: string | null; +} + +function connect(userId: number, over: Partial = {}): void { + const row: ConnFixture = { + url: HOST, + apiKey: 'stored-key', + allowInsecureTls: 0, + syncEnabled: 1, + lastSyncAt: null, + lastSyncState: 'never', + lastSyncError: null, + capabilities: null, + ...over, + }; + testDb + .prepare( + `INSERT OR REPLACE INTO dawarich_connections + (user_id, url, api_key, allow_insecure_tls, sync_enabled, + last_sync_at, last_sync_state, last_sync_error, capabilities) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + userId, + row.url, + row.apiKey, + row.allowInsecureTls, + row.syncEnabled, + row.lastSyncAt, + row.lastSyncState, + row.lastSyncError, + row.capabilities, + ); +} + +interface StoredRow { + url: string | null; + api_key: string | null; + allow_insecure_tls: number; + sync_enabled: number; + last_sync_at: string | null; + last_sync_state: string; + last_sync_error: string | null; + capabilities: string | null; +} + +function row(userId = USER): StoredRow | undefined { + return testDb + .prepare('SELECT * FROM dawarich_connections WHERE user_id = ?') + .get(userId) as StoredRow | undefined; +} + +const FULL_CAPS: DawarichCapabilities = { + visits: true, + tracks: true, + points: true, + locations: true, + visitedCities: true, + visitUpdatedAt: false, + visitCountryCode: false, + serverVersion: '1.14.4', + probedAt: '2026-09-12T00:00:00.000Z', +}; + +function visit(over: Partial = {}): DawarichVisitRaw { + return { + id: 501, + area_id: null, + started_at: '2026-09-10T09:00:00Z', + ended_at: '2026-09-10T12:00:00Z', + duration: null, + name: 'Hotel Adlon', + status: 'suggested', + confidence: null, + confidence_band: null, + place: { latitude: 52.5163, longitude: 13.3777, id: 77 }, + ...over, + }; +} + +/** Every probed endpoint answers; nothing is found, which is still an answer. */ +function allEndpointsAnswer(visits: DawarichVisitRaw[] = []): void { + client.probe.mockResolvedValue({ version: '1.14.4' }); + client.listVisits.mockResolvedValue({ visits, truncated: false, version: '1.14.4' }); + client.listTracks.mockResolvedValue({ features: [], truncated: false }); + client.listPoints.mockResolvedValue({ points: [], truncated: false }); + client.listVisitedCities.mockResolvedValue([]); + client.findVisitsNear.mockResolvedValue([]); +} + +const notFound = () => new DawarichError('not_found', 'Dawarich answered HTTP 404', 404); + +/** The five capabilities that are nothing but "did this endpoint answer". */ +type ProbedCapability = 'visits' | 'tracks' | 'points' | 'visitedCities' | 'locations'; +type ProbedMethod = 'listVisits' | 'listTracks' | 'listPoints' | 'listVisitedCities' | 'findVisitsNear'; + +const PROBED_CAPABILITIES: ProbedCapability[] = ['visits', 'tracks', 'points', 'visitedCities', 'locations']; + +/** Which client call decides which capability. Mutable tuples, because it.each wants them. */ +const endpointCases: Array<[ProbedMethod, ProbedCapability]> = [ + ['listVisits', 'visits'], + ['listTracks', 'tracks'], + ['listPoints', 'points'], + ['listVisitedCities', 'visitedCities'], + ['findVisitsNear', 'locations'], +]; + +const updatedAtAbsentCases: Array<[string, Partial]> = [ + ['missing', {}], + ['explicitly null', { updated_at: null }], +]; + +const countryCodeAbsentCases: Array<[string, Partial]> = [ + ['a visit with no place', { place: null }], + ['a place without the field', { place: { latitude: 1, longitude: 2, id: 3 } }], + ['a place with a null code', { place: { latitude: 1, longitude: 2, id: 3, country_code: null } }], +]; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + vi.clearAllMocks(); + checkSsrf.mockResolvedValue({ allowed: true, isPrivate: false, resolvedIp: IP }); + allEndpointsAnswer(); + USER = createUser(testDb).user.id; +}); + +afterAll(() => { + testDb.close(); +}); + +// --------------------------------------------------------------------------- +// getCredentials +// --------------------------------------------------------------------------- + +describe('DawarichService getCredentials', () => { + it('DAWARICH-SVC-001: a user who never connected has no credentials', () => { + expect(svc.getCredentials(USER)).toBeNull(); + }); + + it('DAWARICH-SVC-002: a row without an address is not a connection', () => { + connect(USER, { url: null }); + expect(svc.getCredentials(USER)).toBeNull(); + }); + + it('DAWARICH-SVC-003: a row without a key is not a connection', () => { + connect(USER, { apiKey: null }); + expect(svc.getCredentials(USER)).toBeNull(); + }); + + it('DAWARICH-SVC-004: a key that cannot be decrypted reads as "not connected" rather than travelling upstream as garbage', () => { + // What a rotated ENCRYPTION_KEY or a half-restored backup leaves behind: + // the envelope is intact, the ciphertext is not ours. + connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' }); + expect(svc.getCredentials(USER)).toBeNull(); + }); + + it('DAWARICH-SVC-005: a legacy plaintext key still works, with the TLS flag off', () => { + connect(USER, { apiKey: 'plain-legacy-key', allowInsecureTls: 0 }); + expect(svc.getCredentials(USER)).toEqual({ + baseUrl: HOST, + apiKey: 'plain-legacy-key', + allowInsecureTls: false, + }); + }); + + it('DAWARICH-SVC-006: the self-signed-certificate flag is carried into the credentials as a boolean', async () => { + await svc.saveSettings(USER, HOST, 'typed-secret', true, true, IP); + expect(svc.getCredentials(USER)).toEqual({ + baseUrl: HOST, + apiKey: 'typed-secret', + allowInsecureTls: true, + }); + }); +}); + +// --------------------------------------------------------------------------- +// isConnected / listSyncableUserIds +// --------------------------------------------------------------------------- + +describe('DawarichService isConnected', () => { + it('DAWARICH-SVC-010: both halves present means connected', () => { + connect(USER); + expect(svc.isConnected(USER)).toBe(true); + }); + + it('DAWARICH-SVC-011: an address without a key is not connected', () => { + connect(USER, { apiKey: null }); + expect(svc.isConnected(USER)).toBe(false); + }); + + it('DAWARICH-SVC-012: no row at all is not connected', () => { + expect(svc.isConnected(USER)).toBe(false); + }); + + it('DAWARICH-SVC-013: an undecryptable key still counts as connected here, because this asks about the row and not about the secret', () => { + // Deliberate difference from getCredentials: the settings card has to keep + // showing a connection the user can repair, and only the outbound path + // cares that the ciphertext is unusable. + connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' }); + expect(svc.isConnected(USER)).toBe(true); + expect(svc.getCredentials(USER)).toBeNull(); + }); +}); + +describe('DawarichService listSyncableUserIds', () => { + it('DAWARICH-SVC-020: only rows with background sync on, an address and a key', () => { + const syncable = USER; + const pollOff = createUser(testDb).user.id; + const noUrl = createUser(testDb).user.id; + const blankUrl = createUser(testDb).user.id; + const noKey = createUser(testDb).user.id; + + connect(syncable); + connect(pollOff, { syncEnabled: 0 }); + connect(noUrl, { url: null }); + // An empty string is a distinct case from NULL: the save path stores NULL, + // but an older row or a hand-edited one can hold '' and it must not be + // polled, because the client would then request /api/v1 on no host at all. + connect(blankUrl, { url: '' }); + connect(noKey, { apiKey: null }); + + expect(svc.listSyncableUserIds()).toEqual([syncable]); + }); + + it('DAWARICH-SVC-021: no connections at all is an empty list, not a throw', () => { + expect(svc.listSyncableUserIds()).toEqual([]); + }); + + it('DAWARICH-SVC-022: every qualifying user comes back, not only the first row the cursor lands on', () => { + // The cron fans out over this list. A query that answered with one id would + // leave everyone but the earliest registration silently unsynced, and the + // settings card would still say the poll was on for all of them. + const second = createUser(testDb).user.id; + const pollOff = createUser(testDb).user.id; + connect(USER); + connect(second); + connect(pollOff, { syncEnabled: 0 }); + + const ids = svc.listSyncableUserIds(); + + // No ORDER BY upstream, so the set is the contract and the order is not. + expect(new Set(ids)).toEqual(new Set([USER, second])); + expect(ids).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// getConnection: what the settings card renders +// --------------------------------------------------------------------------- + +describe('DawarichService getConnection', () => { + it('DAWARICH-SVC-030: a user who never opened the card gets empty fields and the poll defaulted ON', () => { + expect(svc.getConnection(USER)).toEqual({ + url: '', + apiKeyMasked: '', + allowInsecureTls: false, + syncEnabled: true, + connected: false, + lastSyncAt: null, + lastSyncState: 'never', + lastSyncError: null, + capabilities: null, + }); + }); + + it('DAWARICH-SVC-031: a stored connection is reported with a mask where the key is, never the key', () => { + connect(USER, { + apiKey: 'stored-key', + allowInsecureTls: 1, + syncEnabled: 0, + lastSyncAt: '2026-09-12T04:15:00.000Z', + lastSyncState: 'partial', + lastSyncError: 'rate_limited', + }); + + const out = svc.getConnection(USER); + + expect(out).toEqual({ + url: HOST, + apiKeyMasked: DAWARICH_KEY_MASK, + allowInsecureTls: true, + syncEnabled: false, + connected: true, + lastSyncAt: '2026-09-12T04:15:00.000Z', + lastSyncState: 'partial', + lastSyncError: 'rate_limited', + capabilities: null, + }); + expect(JSON.stringify(out)).not.toContain('stored-key'); + }); + + it('DAWARICH-SVC-032: an address without a key reports the address and no mask', () => { + connect(USER, { apiKey: null }); + expect(svc.getConnection(USER)).toMatchObject({ url: HOST, apiKeyMasked: '', connected: false }); + }); + + it('DAWARICH-SVC-033: a null address reads as an empty string, so the form binds to a value', () => { + connect(USER, { url: null, apiKey: null }); + expect(svc.getConnection(USER)).toMatchObject({ url: '', connected: false, syncEnabled: true }); + }); + + it.each(['ok', 'partial', 'failed'])('DAWARICH-SVC-034: the known sync state %s passes through', (state) => { + connect(USER, { lastSyncState: state }); + expect(svc.getConnection(USER).lastSyncState).toBe(state); + }); + + it.each(['syncing', 'OK', 'success', ''])( + 'DAWARICH-SVC-035: the unrecognised sync state %j reads as "never" rather than reaching a client that has no branch for it', + (state) => { + connect(USER, { lastSyncState: state }); + expect(svc.getConnection(USER).lastSyncState).toBe('never'); + }, + ); + + it.each(['{not json', '', 'undefined'])( + 'DAWARICH-SVC-036: the malformed capabilities blob %j reads as "not probed yet", never as a crash on the settings page', + (raw) => { + connect(USER, { capabilities: raw }); + expect(svc.getConnection(USER).capabilities).toBeNull(); + }, + ); + + it.each(['null', '42', '"visits"', 'true'])( + 'DAWARICH-SVC-037: the JSON scalar %s is not a capabilities object and reads as not probed', + (raw) => { + connect(USER, { capabilities: raw }); + expect(svc.getConnection(USER).capabilities).toBeNull(); + }, + ); + + it('DAWARICH-SVC-038: a blob written by an older build keeps only the fields it has, with every unknown flag off', () => { + // The column survives an upgrade, so the shape is whatever was current when + // the last probe ran. Missing flags are off and a missing probe timestamp + // is the epoch, which reads as "long ago" everywhere it is compared. + connect(USER, { capabilities: JSON.stringify({ visits: true, serverVersion: 17, extra: 'ignored' }) }); + + expect(svc.getConnection(USER).capabilities).toEqual({ + visits: true, + tracks: false, + points: false, + locations: false, + visitedCities: false, + visitUpdatedAt: false, + visitCountryCode: false, + serverVersion: null, + probedAt: new Date(0).toISOString(), + }); + }); + + it('DAWARICH-SVC-039: a complete blob round-trips unchanged', () => { + connect(USER, { capabilities: JSON.stringify(FULL_CAPS) }); + expect(svc.getConnection(USER).capabilities).toEqual(FULL_CAPS); + }); +}); + +describe('DawarichService getCapabilities', () => { + it('DAWARICH-SVC-040: null when there is no connection', () => { + expect(svc.getCapabilities(USER)).toBeNull(); + }); + + it('DAWARICH-SVC-041: null when the connection has never been probed', () => { + connect(USER); + expect(svc.getCapabilities(USER)).toBeNull(); + }); + + it('DAWARICH-SVC-042: the stored probe otherwise', () => { + connect(USER, { capabilities: JSON.stringify(FULL_CAPS) }); + expect(svc.getCapabilities(USER)).toEqual(FULL_CAPS); + }); +}); + +// --------------------------------------------------------------------------- +// saveSettings +// --------------------------------------------------------------------------- + +describe('DawarichService saveSettings', () => { + it('DAWARICH-SVC-050: a first save creates the row and stores the key encrypted, never as typed', async () => { + const out = await svc.saveSettings(USER, ` ${HOST} `, 'super-secret', true, true, IP); + + expect(out).toEqual({ success: true }); + const stored = row(); + expect(stored?.url).toBe(HOST); + expect(stored?.allow_insecure_tls).toBe(1); + expect(stored?.sync_enabled).toBe(1); + expect(stored?.api_key).toMatch(/^enc:v1:/); + expect(stored?.api_key).not.toContain('super-secret'); + // The round trip is the point: encrypted at rest, usable on the way out. + expect(svc.getCredentials(USER)?.apiKey).toBe('super-secret'); + }); + + it('DAWARICH-SVC-051: turning the background poll off is persisted, and the card reports it', async () => { + await svc.saveSettings(USER, HOST, 'k', false, false, IP); + + expect(row()?.sync_enabled).toBe(0); + expect(row()?.allow_insecure_tls).toBe(0); + expect(svc.getConnection(USER).syncEnabled).toBe(false); + expect(svc.listSyncableUserIds()).toEqual([]); + }); + + it('DAWARICH-SVC-052: a blank key field keeps the stored key, because the field is never prefilled', async () => { + await svc.saveSettings(USER, HOST, 'first-key', false, true, IP); + const encrypted = row()?.api_key; + + await svc.saveSettings(USER, `${HOST}`, undefined, true, true, IP); + + expect(row()?.api_key).toBe(encrypted); + expect(row()?.allow_insecure_tls).toBe(1); + }); + + it.each([DAWARICH_KEY_MASK, ' ', ''])( + 'DAWARICH-SVC-053: the key field %j means "keep what is stored"', + async (typed) => { + await svc.saveSettings(USER, HOST, 'first-key', false, true, IP); + const encrypted = row()?.api_key; + + await svc.saveSettings(USER, HOST, typed, false, true, IP); + + expect(row()?.api_key).toBe(encrypted); + expect(svc.getCredentials(USER)?.apiKey).toBe('first-key'); + }, + ); + + it('DAWARICH-SVC-054: a new key typed over an old one replaces it', async () => { + await svc.saveSettings(USER, HOST, 'first-key', false, true, IP); + await svc.saveSettings(USER, HOST, 'second-key', false, true, IP); + + expect(svc.getCredentials(USER)?.apiKey).toBe('second-key'); + }); + + it('DAWARICH-SVC-055: moving to a different host without retyping the key drops the key and every artefact of the old instance', async () => { + connect(USER, { + url: 'https://old.example', + apiKey: 'stored-key', + lastSyncAt: '2026-09-12T04:15:00.000Z', + lastSyncState: 'ok', + lastSyncError: 'rate_limited', + capabilities: JSON.stringify(FULL_CAPS), + }); + + await svc.saveSettings(USER, 'https://new.example', undefined, false, true, IP); + + const stored = row(); + expect(stored?.url).toBe('https://new.example'); + expect(stored?.api_key).toBeNull(); + expect(stored?.capabilities).toBeNull(); + expect(stored?.last_sync_state).toBe('never'); + expect(stored?.last_sync_error).toBeNull(); + expect(stored?.last_sync_at).toBeNull(); + }); + + it('DAWARICH-SVC-056: a different port is a different instance, so the key goes with it', async () => { + connect(USER, { url: 'https://d.example' }); + await svc.saveSettings(USER, 'https://d.example:8443', undefined, false, true, IP); + expect(row()?.api_key).toBeNull(); + }); + + it.each([ + ['https://d.example/api', 'https://d.example/'], + ['https://d.example', 'https://d.example/api/v1'], + ['https://d.example/', 'https://d.example'], + ])( + 'DAWARICH-SVC-057: %s to %s is the same instance and keeps the key', + async (before, after) => { + connect(USER, { url: before, apiKey: 'stored-key' }); + await svc.saveSettings(USER, after, undefined, false, true, IP); + expect(row()?.api_key).toBe('stored-key'); + expect(row()?.url).toBe(after); + }, + ); + + it('DAWARICH-SVC-058: a key typed at the same time as the host change survives, because it was minted for the new host', async () => { + connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); + + await svc.saveSettings(USER, 'https://new.example', 'minted-for-new', false, true, IP); + + expect(svc.getCredentials(USER)).toEqual({ + baseUrl: 'https://new.example', + apiKey: 'minted-for-new', + allowInsecureTls: false, + }); + }); + + it('DAWARICH-SVC-059: an unparseable stored address compares as written, so retyping it unchanged keeps the key', async () => { + // No scheme, so the URL constructor throws and the comparison falls back to + // string equality. Erring towards keeping the key here is safe precisely + // because the text did not change. + connect(USER, { url: 'dawarich.example', apiKey: 'stored-key' }); + + await svc.saveSettings(USER, 'dawarich.example', undefined, false, true, IP); + + expect(row()?.api_key).toBe('stored-key'); + }); + + it('DAWARICH-SVC-060: an unparseable stored address against a different one drops the key', async () => { + connect(USER, { url: 'dawarich.example', apiKey: 'stored-key' }); + + await svc.saveSettings(USER, 'https://new.example', undefined, false, true, IP); + + expect(row()?.api_key).toBeNull(); + }); + + it('DAWARICH-SVC-061: a row whose address column is null counts as no previous host, so nothing is dropped', async () => { + connect(USER, { url: null, apiKey: 'stored-key' }); + + await svc.saveSettings(USER, HOST, undefined, false, true, IP); + + expect(row()?.api_key).toBe('stored-key'); + expect(row()?.url).toBe(HOST); + }); + + it('DAWARICH-SVC-062: clearing the address wipes the key, because a live credential for a server nobody named is just a stored secret', async () => { + connect(USER, { + apiKey: 'stored-key', + lastSyncAt: '2026-09-12T04:15:00.000Z', + lastSyncState: 'ok', + lastSyncError: 'x', + capabilities: JSON.stringify(FULL_CAPS), + }); + + const out = await svc.saveSettings(USER, ' ', undefined, false, true, IP); + + expect(out).toEqual({ success: true }); + const stored = row(); + expect(stored?.url).toBeNull(); + expect(stored?.api_key).toBeNull(); + expect(stored?.capabilities).toBeNull(); + expect(stored?.last_sync_state).toBe('never'); + expect(stored?.last_sync_at).toBeNull(); + expect(stored?.last_sync_error).toBeNull(); + }); + + it('DAWARICH-SVC-063: a key typed while the address is cleared is discarded with it', async () => { + connect(USER, { apiKey: 'stored-key' }); + await svc.saveSettings(USER, '', 'pointless-key', false, true, IP); + expect(row()?.api_key).toBeNull(); + }); + + it('DAWARICH-SVC-064: an empty address is never resolved, so clearing the connection cannot fail on DNS', async () => { + await svc.saveSettings(USER, undefined, undefined, false, true, IP); + expect(checkSsrf).not.toHaveBeenCalled(); + }); + + it('DAWARICH-SVC-065: an address that cannot work at all is refused with the guard reason and nothing is written', async () => { + checkSsrf.mockResolvedValue({ allowed: false, isPrivate: false, error: 'Only HTTP and HTTPS URLs are allowed' }); + + const out = await svc.saveSettings(USER, 'ftp://dawarich.example', 'k', false, true, IP); + + expect(out).toEqual({ + success: false, + code: 'invalid_url', + error: 'Only HTTP and HTTPS URLs are allowed', + }); + expect(row()).toBeUndefined(); + expect(audit.writeAudit).not.toHaveBeenCalled(); + }); + + it('DAWARICH-SVC-066: a refusal without a reason still carries a sentence a caller reading the API directly can act on', async () => { + checkSsrf.mockResolvedValue({ allowed: false, isPrivate: false }); + + expect(await svc.saveSettings(USER, 'http://nope.invalid', 'k', false, true, IP)).toEqual({ + success: false, + code: 'invalid_url', + error: 'Invalid Dawarich URL', + }); + }); + + it('DAWARICH-SVC-067: a private address is saved with a warning and an audit line, not refused', async () => { + checkSsrf.mockResolvedValue({ allowed: true, isPrivate: true, resolvedIp: '192.168.0.5' }); + + const out = await svc.saveSettings(USER, 'http://dawarich.lan:3000', 'k', false, true, '198.51.100.7'); + + expect(out).toEqual({ + success: true, + warning: expect.stringContaining('192.168.0.5'), + warningCode: 'private_ip', + warningIp: '192.168.0.5', + }); + expect(row()?.url).toBe('http://dawarich.lan:3000'); + expect(audit.writeAudit).toHaveBeenCalledWith({ + userId: USER, + action: 'dawarich.private_ip_configured', + ip: '198.51.100.7', + details: { dawarich_url: 'http://dawarich.lan:3000', resolved_ip: '192.168.0.5' }, + }); + }); + + it('DAWARICH-SVC-068: a private address the guard would also block is still saved, because a self-hosted LAN instance is the common case', async () => { + // ALLOW_INTERNAL_NETWORK unset: checkSsrf says not allowed AND private. + // Refusing here would break the majority of installs. + checkSsrf.mockResolvedValue({ + allowed: false, + isPrivate: true, + resolvedIp: '10.0.0.9', + error: 'Requests to private/internal network addresses are not allowed.', + }); + + const out = await svc.saveSettings(USER, 'http://10.0.0.9:3000', 'k', false, true, IP); + + expect(out).toMatchObject({ success: true, warningCode: 'private_ip', warningIp: '10.0.0.9' }); + expect(row()?.url).toBe('http://10.0.0.9:3000'); + }); + + it('DAWARICH-SVC-069: a private result the resolver could not name still warns, with no IP in the warning payload', async () => { + checkSsrf.mockResolvedValue({ allowed: true, isPrivate: true }); + + const out = await svc.saveSettings(USER, 'http://dawarich.lan', 'k', false, true, null); + + expect(out).toMatchObject({ success: true, warningCode: 'private_ip' }); + expect(out.warningIp).toBeUndefined(); + expect(audit.writeAudit).toHaveBeenCalledTimes(1); + }); + + it('DAWARICH-SVC-070: the address write and the key write are one transaction, so a failed key write cannot leave the old key pointed at the new host', async () => { + connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); + const realRun = dbs.run.bind(dbs); + const spy = vi.spyOn(dbs, 'run').mockImplementation((sql: string, ...params: unknown[]) => { + if (sql.includes('SET api_key = ?')) throw new Error('disk I/O error'); + return realRun(sql, ...params); + }); + + try { + await expect(svc.saveSettings(USER, 'https://new.example', 'fresh', false, true, IP)).rejects.toThrow( + 'disk I/O error', + ); + } finally { + spy.mockRestore(); + } + + const stored = row(); + expect(stored?.url).toBe('https://old.example'); + expect(stored?.api_key).toBe('stored-key'); + }); + + it("DAWARICH-SVC-071: both wipes name one user, so neither the host move nor the cleared address reaches anybody else's credential", async () => { + // A missing WHERE on either UPDATE passes every single-user case in this + // file and then logs the whole install out of Dawarich the first time one + // person edits their own URL. Two victims, one bystander, both statements. + const bystander = createUser(testDb).user.id; + connect(USER, { url: 'https://old.example', apiKey: 'stored-key' }); + connect(bystander, { + apiKey: 'bystander-key', + capabilities: JSON.stringify(FULL_CAPS), + lastSyncState: 'ok', + }); + + await svc.saveSettings(USER, 'https://new.example', undefined, false, true, IP); + await svc.saveSettings(USER, '', undefined, false, true, IP); + + expect(row()?.api_key).toBeNull(); + expect(row(bystander)).toMatchObject({ + url: HOST, + api_key: 'bystander-key', + last_sync_state: 'ok', + capabilities: JSON.stringify(FULL_CAPS), + }); + }); +}); + +// --------------------------------------------------------------------------- +// disconnect +// --------------------------------------------------------------------------- + +describe('DawarichService disconnect', () => { + it('DAWARICH-SVC-080: forgets the credential and says so in the audit log', () => { + connect(USER); + + svc.disconnect(USER, '198.51.100.7'); + + expect(row()).toBeUndefined(); + expect(svc.getConnection(USER).connected).toBe(false); + expect(audit.writeAudit).toHaveBeenCalledWith({ + userId: USER, + action: 'dawarich.disconnected', + ip: '198.51.100.7', + details: {}, + }); + }); + + it('DAWARICH-SVC-081: disconnecting something that was never connected is a no-op that still leaves a trail', () => { + svc.disconnect(USER, null); + + expect(row()).toBeUndefined(); + expect(audit.writeAudit).toHaveBeenCalledWith(expect.objectContaining({ ip: null })); + }); + + it('DAWARICH-SVC-082: only the calling user loses their connection', () => { + const other = createUser(testDb).user.id; + connect(USER); + connect(other); + + svc.disconnect(USER, null); + + expect(row(other)).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// testConnection +// --------------------------------------------------------------------------- + +describe('DawarichService testConnection', () => { + it('DAWARICH-SVC-090: nothing typed and nothing stored answers not_connected without touching the network', async () => { + expect(await svc.testConnection(USER, undefined, undefined, false)).toEqual({ + connected: false, + error: 'not_connected', + }); + expect(client.probe).not.toHaveBeenCalled(); + }); + + it('DAWARICH-SVC-091: an address with no key anywhere is not_connected, not a probe with an empty bearer token', async () => { + expect(await svc.testConnection(USER, HOST, '', false)).toEqual({ + connected: false, + error: 'not_connected', + }); + expect(client.probe).not.toHaveBeenCalled(); + }); + + it('DAWARICH-SVC-092: a key typed with no address falls back to the stored address', async () => { + connect(USER, { apiKey: 'stored-key' }); + + await svc.testConnection(USER, '', 'typed-key', false); + + expect(client.probe).toHaveBeenCalledWith({ baseUrl: HOST, apiKey: 'typed-key', allowInsecureTls: false }); + }); + + it('DAWARICH-SVC-093: the mask means "use the stored key", which is what a form that only changed the TLS flag posts back', async () => { + connect(USER, { apiKey: 'stored-key' }); + + await svc.testConnection(USER, HOST, DAWARICH_KEY_MASK, true); + + expect(client.probe).toHaveBeenCalledWith({ baseUrl: HOST, apiKey: 'stored-key', allowInsecureTls: true }); + }); + + it('DAWARICH-SVC-094: a stored connection tested as it stands persists the freshly probed capabilities', async () => { + connect(USER, { apiKey: 'stored-key' }); + + const out = await svc.testConnection(USER, HOST, undefined, false); + + expect(out.connected).toBe(true); + expect(svc.getCapabilities(USER)).toEqual(out.capabilities); + }); + + it('DAWARICH-SVC-095: a test run against a different address than the stored one must not overwrite what is on file', async () => { + connect(USER, { apiKey: 'stored-key', capabilities: JSON.stringify(FULL_CAPS) }); + + await svc.testConnection(USER, 'https://someone-elses.example', 'their-key', false); + + expect(svc.getCapabilities(USER)).toEqual(FULL_CAPS); + }); + + it('DAWARICH-SVC-096: a test from a form with nothing stored yet writes no capabilities row', async () => { + const out = await svc.testConnection(USER, HOST, 'typed-key', false); + + expect(out.connected).toBe(true); + expect(row()).toBeUndefined(); + }); + + it('DAWARICH-SVC-097: the visit count is the last 30 days, and it is only asked for when the visits endpoint exists', async () => { + connect(USER, { apiKey: 'stored-key' }); + allEndpointsAnswer([visit({ id: 1 }), visit({ id: 2 }), visit({ id: 3 })]); + + const out = await svc.testConnection(USER, HOST, undefined, false); + + expect(out.visitCount).toBe(3); + // Call 0 is the 24h capability probe; call 1 is the count the card shows. + const [, from, to] = client.listVisits.mock.calls[1] as [unknown, Date, Date]; + expect(to.getTime() - from.getTime()).toBe(30 * DAY_MS); + }); + + it('DAWARICH-SVC-098: an instance without a visits endpoint reports zero rather than asking a route that is not there', async () => { + connect(USER, { apiKey: 'stored-key' }); + client.listVisits.mockRejectedValue(notFound()); + + const out = await svc.testConnection(USER, HOST, undefined, false); + + expect(out).toMatchObject({ connected: true, visitCount: 0 }); + expect(out.capabilities?.visits).toBe(false); + expect(client.listVisits).toHaveBeenCalledTimes(1); + }); + + it('DAWARICH-SVC-099: a count that fails after the connection is already proven is not a failed connection', async () => { + connect(USER, { apiKey: 'stored-key' }); + client.listVisits + .mockResolvedValueOnce({ visits: [visit()], truncated: false, version: '1.14.4' }) + .mockRejectedValueOnce(new DawarichError('rate_limited', 'HTTP 429', 429)); + + const out = await svc.testConnection(USER, HOST, undefined, false); + + expect(out).toMatchObject({ connected: true, visitCount: 0 }); + }); + + it('DAWARICH-SVC-100: a rejected key is a 200 the form can render, carrying the code the client translates', async () => { + connect(USER, { apiKey: 'stored-key' }); + client.probe.mockRejectedValue(new DawarichError('unauthorized', 'Dawarich answered HTTP 401', 401)); + + const out = await svc.testConnection(USER, HOST, undefined, false); + + expect(out).toEqual({ connected: false, error: 'unauthorized' }); + expect(out).not.toHaveProperty('errorDetail'); + }); + + it('DAWARICH-SVC-101: the free-text detail rides along when there is one, for the person debugging their own reverse proxy', async () => { + connect(USER, { apiKey: 'stored-key' }); + client.probe.mockRejectedValue( + new DawarichError('invalid_response', 'Not JSON', 200, 'login'), + ); + + expect(await svc.testConnection(USER, HOST, undefined, false)).toEqual({ + connected: false, + error: 'invalid_response', + errorDetail: 'login', + }); + }); + + it('DAWARICH-SVC-102: anything that is not a DawarichError reads as unreachable rather than leaking the message', async () => { + connect(USER, { apiKey: 'stored-key' }); + client.probe.mockRejectedValue(new Error('ECONNRESET on https://dawarich.example?api_key=secret')); + + expect(await svc.testConnection(USER, HOST, undefined, false)).toEqual({ + connected: false, + error: 'unreachable', + }); + }); + + it('DAWARICH-SVC-103: a failed probe leaves the stored capabilities untouched', async () => { + connect(USER, { apiKey: 'stored-key', capabilities: JSON.stringify(FULL_CAPS) }); + client.probe.mockRejectedValue(new DawarichError('unauthorized', 'HTTP 401', 401)); + + await svc.testConnection(USER, HOST, undefined, false); + + expect(svc.getCapabilities(USER)).toEqual(FULL_CAPS); + }); + + it('DAWARICH-SVC-104: a stored key that no longer decrypts is never dialled with, so the form hears "not connected" instead of a ciphertext going upstream as a bearer token', async () => { + // The rotated-ENCRYPTION_KEY case reached from the outbound side rather than + // through getCredentials: the row still reads as connected on the card, and + // the test button has to say what is wrong without sending the blob anywhere. + connect(USER, { apiKey: 'enc:v1:bm90LWEtcmVhbC1ibG9i' }); + + expect(await svc.testConnection(USER, HOST, undefined, false)).toEqual({ + connected: false, + error: 'not_connected', + }); + expect(client.probe).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// probeCapabilities +// --------------------------------------------------------------------------- + +describe('DawarichService probeCapabilities', () => { + const CREDS = { baseUrl: HOST, apiKey: 'k', allowInsecureTls: false }; + + it('DAWARICH-SVC-110: every endpoint answering means every capability is on, with the version the instance reported', async () => { + const caps = await svc.probeCapabilities(CREDS); + + expect(caps).toMatchObject({ + visits: true, + tracks: true, + points: true, + locations: true, + visitedCities: true, + serverVersion: '1.14.4', + }); + expect(Number.isNaN(Date.parse(caps.probedAt))).toBe(false); + }); + + it('DAWARICH-SVC-111: an instance too old to send the version banner still connects, with a null version', async () => { + client.probe.mockResolvedValue({ version: null }); + expect((await svc.probeCapabilities(CREDS)).serverVersion).toBeNull(); + }); + + it('DAWARICH-SVC-112: the probe window is a single day, the same one for every endpoint it asks', async () => { + await svc.probeCapabilities(CREDS); + + const [, visitsFrom, visitsTo] = client.listVisits.mock.calls[0] as [unknown, Date, Date]; + const [, tracksFrom, tracksTo] = client.listTracks.mock.calls[0] as [unknown, Date, Date]; + + expect(visitsTo.getTime() - visitsFrom.getTime()).toBe(DAY_MS); + expect(tracksFrom.getTime()).toBe(visitsFrom.getTime()); + expect(tracksTo.getTime()).toBe(visitsTo.getTime()); + }); + + it('DAWARICH-SVC-113: the locations probe asks about a coordinate no recording will ever be near, because it is about the route answering', async () => { + await svc.probeCapabilities(CREDS); + expect(client.findVisitsNear).toHaveBeenCalledWith(CREDS, 0, 0, 50, 1); + }); + + it.each(endpointCases)( + 'DAWARICH-SVC-114: a 404 from %s turns off only %s, so an older Dawarich still connects', + async (method, capability) => { + client[method].mockRejectedValue(notFound()); + + const caps = await svc.probeCapabilities(CREDS); + + expect(caps[capability]).toBe(false); + const others = PROBED_CAPABILITIES.filter((c) => c !== capability); + for (const other of others) expect(caps[other]).toBe(true); + }, + ); + + it('DAWARICH-SVC-115: a rejected key during the probe propagates, because "connected, but nothing works" is the worse answer', async () => { + client.listTracks.mockRejectedValue(new DawarichError('unauthorized', 'HTTP 401', 401)); + await expect(svc.probeCapabilities(CREDS)).rejects.toBeInstanceOf(DawarichError); + }); + + it('DAWARICH-SVC-116: a plain transport failure during the probe propagates too', async () => { + client.listPoints.mockRejectedValue(new Error('socket hang up')); + await expect(svc.probeCapabilities(CREDS)).rejects.toThrow('socket hang up'); + }); + + it('DAWARICH-SVC-117: a failing authentication check fails the whole probe, before any endpoint is asked', async () => { + client.probe.mockRejectedValue(new DawarichError('unauthorized', 'HTTP 401', 401)); + + await expect(svc.probeCapabilities(CREDS)).rejects.toBeInstanceOf(DawarichError); + expect(client.listVisits).not.toHaveBeenCalled(); + }); + + it('DAWARICH-SVC-118: no visit inside the probe window means the two field capabilities read as "not seen", not as "absent"', async () => { + const caps = await svc.probeCapabilities(CREDS); + expect(caps.visitUpdatedAt).toBe(false); + expect(caps.visitCountryCode).toBe(false); + }); + + it('DAWARICH-SVC-119: with no visits endpoint at all there is no row to sample, and both field capabilities stay off', async () => { + client.listVisits.mockRejectedValue(notFound()); + + const caps = await svc.probeCapabilities(CREDS); + + expect(caps).toMatchObject({ visits: false, visitUpdatedAt: false, visitCountryCode: false }); + }); + + it('DAWARICH-SVC-120: a sampled visit that carries updated_at lights the capability up on its own', async () => { + allEndpointsAnswer([visit({ updated_at: '2026-09-11T08:00:00Z' })]); + expect((await svc.probeCapabilities(CREDS)).visitUpdatedAt).toBe(true); + }); + + it.each(updatedAtAbsentCases)('DAWARICH-SVC-121: an %s updated_at reads as absent', async (_label, over) => { + allEndpointsAnswer([visit(over)]); + expect((await svc.probeCapabilities(CREDS)).visitUpdatedAt).toBe(false); + }); + + it('DAWARICH-SVC-122: a sampled place carrying a country code lights that capability up', async () => { + allEndpointsAnswer([visit({ place: { latitude: 1, longitude: 2, id: 3, country_code: 'DE' } })]); + expect((await svc.probeCapabilities(CREDS)).visitCountryCode).toBe(true); + }); + + it.each(countryCodeAbsentCases)('DAWARICH-SVC-123: %s reads as no country code', async (_label, over) => { + allEndpointsAnswer([visit(over)]); + expect((await svc.probeCapabilities(CREDS)).visitCountryCode).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// storeCapabilities / recordSyncResult: the two columns the card reads back +// --------------------------------------------------------------------------- + +describe('DawarichService storeCapabilities', () => { + it('DAWARICH-SVC-130: writes the probe where getCapabilities reads it', () => { + connect(USER); + + svc.storeCapabilities(USER, FULL_CAPS); + + expect(svc.getCapabilities(USER)).toEqual(FULL_CAPS); + expect(JSON.parse(row()?.capabilities ?? 'null')).toEqual(FULL_CAPS); + }); + + it('DAWARICH-SVC-131: storing against a user with no connection changes nothing instead of creating a row without a credential', () => { + svc.storeCapabilities(USER, FULL_CAPS); + expect(row()).toBeUndefined(); + }); + + it('DAWARICH-SVC-132: only the named user is touched', () => { + const other = createUser(testDb).user.id; + connect(USER); + connect(other); + + svc.storeCapabilities(USER, FULL_CAPS); + + expect(row(other)?.capabilities).toBeNull(); + }); +}); + +describe('DawarichService recordSyncResult', () => { + it('DAWARICH-SVC-140: a failure is stored with its reason and a timestamp the card can show', () => { + connect(USER); + const before = Date.now(); + + svc.recordSyncResult(USER, 'failed', 'unreachable'); + + const out = svc.getConnection(USER); + expect(out.lastSyncState).toBe('failed'); + expect(out.lastSyncError).toBe('unreachable'); + expect(Date.parse(out.lastSyncAt ?? '')).toBeGreaterThanOrEqual(before - 1000); + }); + + it('DAWARICH-SVC-141: a later success clears the error rather than leaving the old one on screen', () => { + connect(USER, { lastSyncState: 'failed', lastSyncError: 'unreachable' }); + + svc.recordSyncResult(USER, 'ok', null); + + expect(svc.getConnection(USER)).toMatchObject({ lastSyncState: 'ok', lastSyncError: null }); + }); + + it('DAWARICH-SVC-142: a partial run is its own state, distinct from both ok and failed', () => { + connect(USER); + svc.recordSyncResult(USER, 'partial', 'too_large'); + expect(svc.getConnection(USER).lastSyncState).toBe('partial'); + }); + + it('DAWARICH-SVC-143: recording against a user with no connection changes nothing', () => { + svc.recordSyncResult(USER, 'ok', null); + expect(row()).toBeUndefined(); + }); +}); diff --git a/shared/src/dawarich/dawarich.schema.spec.ts b/shared/src/dawarich/dawarich.schema.spec.ts new file mode 100644 index 000000000..21be1988b --- /dev/null +++ b/shared/src/dawarich/dawarich.schema.spec.ts @@ -0,0 +1,736 @@ +import { + DAWARICH_BUCKET_MATCH_MIN_MINUTES, + DAWARICH_BUCKET_MATCH_RADIUS_M, + DAWARICH_BUCKET_SCAN_LIMIT, + DAWARICH_ERROR_CODES, + DAWARICH_KEY_MASK, + DAWARICH_SUGGESTION_STATES, + DAWARICH_SUGGESTION_TARGETS, + DAWARICH_SYNC_LOOKAHEAD_DAYS, + DAWARICH_SYNC_LOOKBACK_DAYS, + DAWARICH_SYNC_STATES, + DAWARICH_TRACK_POINTS_PER_DAY, + DAWARICH_VISIT_STATUSES, + dawarichAcceptResultSchema, + dawarichAcceptSchema, + dawarichAtlasAcceptSchema, + dawarichAtlasCountrySchema, + dawarichAtlasSuggestionsSchema, + dawarichBucketConfirmSchema, + dawarichBucketMatchSchema, + dawarichBucketScanSchema, + dawarichCapabilitiesSchema, + dawarichConnectionSchema, + dawarichErrorCodeSchema, + dawarichSettingsSchema, + dawarichStatusSchema, + dawarichSuggestionListSchema, + dawarichSuggestionSchema, + dawarichSuggestionStateSchema, + dawarichSuggestionStateSchemaBody, + dawarichSuggestionTargetSchema, + dawarichSyncStateSchema, + dawarichTrackDaySchema, + dawarichTrackSchema, + dawarichTrackSegmentSchema, + dawarichVisitStatusSchema, +} from './dawarich.schema'; + +import { describe, expect, it } from 'vitest'; + +/** + * DAWARICH-SCHEMA-001..043: the Dawarich contracts. + * + * This is the layer both sides of the integration agree on, and the feature is + * built out of things that are deliberately lenient in one place and + * deliberately strict in another. Pinning it here is what stops the two drifting: + * + * - **the two settings defaults are load-bearing.** `saveSettings` writes + * `allow_insecure_tls` and `sync_enabled` into the row on every save, taking + * whatever the DTO handed it. If `syncEnabled` stopped defaulting to true, a + * user who saved their URL without touching the toggle would silently get no + * background sync; if `allowInsecureTls` stopped defaulting to false, the + * opposite and worse. A string `'true'` off a form must not enable either, + * which is why the boolean-ness is asserted rather than assumed. + * - **the mask has to survive a round trip.** The server hands out + * `DAWARICH_KEY_MASK` in place of the key and the settings form posts back + * what it was given, so if the mask stopped validating as an `apiKey`, saving + * an unchanged connection would 400. + * - **the capability probe has no defaults on purpose.** Every flag is + * required, so a probe that half-answered cannot arrive looking like an + * instance that simply lacks the endpoints. That distinction is the point of + * the probe, and a defaulted `false` would quietly drop the track overlay. + * - **`confidenceBand` is a string where an enum is the obvious choice.** A + * later Dawarich inventing a fourth band must not fail the whole suggestion + * row, so the looseness is a decision and is pinned as one. + * - **the error codes are a closed list** because the client renders them + * through i18n; a code nobody translated reaches the user as a raw key. + * + * Everything here is schema behaviour only. The cross-field rules the comments + * describe ("required for place / journal / bucket_list") are not enforced here + * and must not be: the accept route falls back to the suggestion's own trip, + * matched bucket entry and recorded times, then rejects what is left with an + * `AcceptError` carrying a status. That half lives in the service and its tests. + */ + +/** A fully answered probe of a 1.14.4 instance: the two youngest fields are absent upstream. */ +const capabilities = { + visits: true, + tracks: true, + points: true, + locations: true, + visitedCities: true, + visitUpdatedAt: false, + visitCountryCode: false, + serverVersion: '1.14.4', + probedAt: '2026-09-01T10:00:00.000Z', +}; + +/** A stay as it arrives for review: seen once, accepted into nothing yet. */ +const suggestion = { + id: 12, + sourceVisitId: '904', + tripId: 3, + tripTitle: 'Norway', + name: 'Bryggen', + lat: 60.3971, + lng: 5.3245, + startedAt: '2026-06-02T09:10:00+02:00', + endedAt: '2026-06-02T11:40:00+02:00', + durationMinutes: 150, + localDate: '2026-06-02', + sourceStatus: 'suggested', + confidence: 82, + confidenceBand: 'high', + state: 'new', + target: null, + acceptedPlaceId: null, + acceptedJournalEntryId: null, + acceptedBucketListItemId: null, + sourceChanged: false, + sourceMissing: false, + matchedBucketListItemId: null, + matchedBucketListName: null, + countryCode: 'NO', + firstSeenAt: '2026-06-02T12:00:00.000Z', + lastSeenAt: '2026-06-03T12:00:00.000Z', +}; + +describe('dawarichSettingsSchema', () => { + it('DAWARICH-SCHEMA-001 fills the two flags the save path writes unconditionally', () => { + const filled = dawarichSettingsSchema.parse({ url: 'https://dawarich.example.com' }); + expect(filled.allowInsecureTls).toBe(false); + expect(filled.syncEnabled).toBe(true); + expect(filled.apiKey).toBeUndefined(); + + // An explicit undefined is the same as an absent key, which is what a form + // that clears a field before posting actually sends. + const explicit = dawarichSettingsSchema.parse({ + url: 'https://dawarich.example.com', + allowInsecureTls: undefined, + syncEnabled: undefined, + }); + expect(explicit.allowInsecureTls).toBe(false); + expect(explicit.syncEnabled).toBe(true); + + const chosen = dawarichSettingsSchema.parse({ + url: 'https://dawarich.example.com', + allowInsecureTls: true, + syncEnabled: false, + }); + expect(chosen.allowInsecureTls).toBe(true); + expect(chosen.syncEnabled).toBe(false); + }); + + it('DAWARICH-SCHEMA-002 trims the url and accepts an empty one, because clearing it is how you disconnect', () => { + expect(dawarichSettingsSchema.parse({ url: ' https://dawarich.example.com ' }).url).toBe( + 'https://dawarich.example.com', + ); + expect(dawarichSettingsSchema.safeParse({ url: '' }).success).toBe(true); + // Not a URL type on purpose: the service runs the address through the SSRF + // guard, which is the only check that can say whether it could ever work. + expect(dawarichSettingsSchema.safeParse({ url: 'not a url' }).success).toBe(true); + expect(dawarichSettingsSchema.safeParse({}).success).toBe(false); + expect(dawarichSettingsSchema.safeParse({ url: 123 }).success).toBe(false); + }); + + it('DAWARICH-SCHEMA-003 caps the address at 2048 and the key at 512', () => { + expect(dawarichSettingsSchema.safeParse({ url: 'h'.repeat(2048) }).success).toBe(true); + expect(dawarichSettingsSchema.safeParse({ url: 'h'.repeat(2049) }).success).toBe(false); + expect(dawarichSettingsSchema.safeParse({ url: 'https://d.example', apiKey: 'k'.repeat(512) }).success).toBe(true); + expect(dawarichSettingsSchema.safeParse({ url: 'https://d.example', apiKey: 'k'.repeat(513) }).success).toBe(false); + // A blank key means "keep the stored one" and has to reach the service to say so. + expect(dawarichSettingsSchema.safeParse({ url: 'https://d.example', apiKey: '' }).success).toBe(true); + }); + + it('DAWARICH-SCHEMA-004 keeps the flags boolean and drops what it was not asked about', () => { + const url = 'https://dawarich.example.com'; + // A form posting the string 'true' must not be able to turn off certificate + // checking; coercion here would make that a one-character mistake. + expect(dawarichSettingsSchema.safeParse({ url, allowInsecureTls: 'true' }).success).toBe(false); + expect(dawarichSettingsSchema.safeParse({ url, syncEnabled: 1 }).success).toBe(false); + expect(dawarichSettingsSchema.safeParse({ url, allowInsecureTls: null }).success).toBe(false); + + const parsed = dawarichSettingsSchema.parse({ url, userId: 4, apiKey: 'secret' }); + expect(parsed).toEqual({ url, apiKey: 'secret', allowInsecureTls: false, syncEnabled: true }); + }); + + it('DAWARICH-SCHEMA-005 accepts its own mask back, so re-saving an untouched connection works', () => { + expect(DAWARICH_KEY_MASK.length).toBeGreaterThan(0); + const parsed = dawarichSettingsSchema.safeParse({ url: 'https://d.example', apiKey: DAWARICH_KEY_MASK }); + expect(parsed.success).toBe(true); + }); + + it('DAWARICH-SCHEMA-040 uses a mask nobody could type, because the save path decides by equality', () => { + // `saveSettings` keeps the stored key whenever the posted one is equal to the + // mask. A mask made of characters a person could plausibly type would turn + // that into a trap: typing it would silently keep the old key rather than + // save the new one, and the user would see a saved form and a dead connection. + expect(DAWARICH_KEY_MASK).not.toMatch(/[A-Za-z0-9]/); + // It also has to fit back through the field it is handed out in. + expect(DAWARICH_KEY_MASK.length).toBeLessThanOrEqual(512); + }); +}); + +describe('dawarichCapabilitiesSchema', () => { + it('DAWARICH-SCHEMA-006 demands an answer for every probe, so half a probe cannot read as "not supported"', () => { + expect(dawarichCapabilitiesSchema.safeParse(capabilities).success).toBe(true); + + const keys = Object.keys(capabilities); + const withoutEach = keys.map((key) => { + const partial: Record = { ...capabilities }; + delete partial[key]; + return `${key}:${dawarichCapabilitiesSchema.safeParse(partial).success}`; + }); + expect(withoutEach).toEqual(keys.map((key) => `${key}:false`)); + }); + + it('DAWARICH-SCHEMA-007 lets the version be unknown but never the probe time', () => { + // The version comes from a response header the instance may not send. + expect(dawarichCapabilitiesSchema.safeParse({ ...capabilities, serverVersion: null }).success).toBe(true); + expect(dawarichCapabilitiesSchema.safeParse({ ...capabilities, probedAt: null }).success).toBe(false); + expect(dawarichCapabilitiesSchema.safeParse({ ...capabilities, visits: 1 }).success).toBe(false); + expect(dawarichCapabilitiesSchema.safeParse({ ...capabilities, serverVersion: 1.14 }).success).toBe(false); + }); +}); + +describe('the closed vocabularies', () => { + it('DAWARICH-SCHEMA-008 the connection card knows exactly four sync states', () => { + expect(DAWARICH_SYNC_STATES).toEqual(['never', 'ok', 'partial', 'failed']); + expect(DAWARICH_SYNC_STATES.every((state) => dawarichSyncStateSchema.safeParse(state).success)).toBe(true); + expect(dawarichSyncStateSchema.safeParse('pending').success).toBe(false); + expect(dawarichSyncStateSchema.safeParse('').success).toBe(false); + }); + + it('DAWARICH-SCHEMA-009 the error codes are closed because the client translates them', () => { + expect(DAWARICH_ERROR_CODES).toEqual([ + 'unreachable', + 'unauthorized', + 'forbidden', + 'not_found', + 'rate_limited', + 'server_error', + 'invalid_response', + 'too_large', + 'not_connected', + 'addon_disabled', + ]); + expect(DAWARICH_ERROR_CODES.every((code) => dawarichErrorCodeSchema.safeParse(code).success)).toBe(true); + // A new code would reach the user as a raw i18n key, so it has to be added here first. + expect(dawarichErrorCodeSchema.safeParse('timeout').success).toBe(false); + }); + + it('DAWARICH-SCHEMA-010 an unconfirmed visit is still a visit', () => { + // Importing only `confirmed` leaves most instances looking empty, which is + // the failure mode the Dawarich maintainer warned about. Since 1.12.0 a + // declined visit is deleted upstream, so there is no third status to carry. + expect(DAWARICH_VISIT_STATUSES).toEqual(['suggested', 'confirmed']); + expect(dawarichVisitStatusSchema.safeParse('suggested').success).toBe(true); + expect(dawarichVisitStatusSchema.safeParse('confirmed').success).toBe(true); + expect(dawarichVisitStatusSchema.safeParse('declined').success).toBe(false); + }); + + it('DAWARICH-SCHEMA-011 TREK tracks its own three states and three accept targets', () => { + expect(DAWARICH_SUGGESTION_STATES).toEqual(['new', 'accepted', 'dismissed']); + expect(DAWARICH_SUGGESTION_TARGETS).toEqual(['place', 'journal', 'bucket_list']); + expect(dawarichSuggestionStateSchema.safeParse('accepted').success).toBe(true); + expect(dawarichSuggestionStateSchema.safeParse('archived').success).toBe(false); + expect(dawarichSuggestionTargetSchema.safeParse('bucket_list').success).toBe(true); + expect(dawarichSuggestionTargetSchema.safeParse('trip').success).toBe(false); + }); +}); + +describe('dawarichConnectionSchema', () => { + const connection = { + url: 'https://dawarich.example.com', + apiKeyMasked: DAWARICH_KEY_MASK, + allowInsecureTls: false, + syncEnabled: true, + connected: true, + lastSyncAt: null, + lastSyncState: 'never', + lastSyncError: null, + capabilities: null, + }; + + it('DAWARICH-SCHEMA-012 describes a connection that has never synced and one that has', () => { + expect(dawarichConnectionSchema.safeParse(connection).success).toBe(true); + // No key stored yet: the mask field is empty rather than absent, so the form + // can tell "nothing saved" from "saved and hidden". + const blank = dawarichConnectionSchema.safeParse({ ...connection, apiKeyMasked: '', connected: false }); + expect(blank.success).toBe(true); + expect( + dawarichConnectionSchema.safeParse({ + ...connection, + lastSyncAt: '2026-09-01T10:05:00.000Z', + lastSyncState: 'partial', + lastSyncError: 'rate_limited', + capabilities, + }).success, + ).toBe(true); + }); + + it('DAWARICH-SCHEMA-013 refuses a state the card cannot render and a missing flag', () => { + expect(dawarichConnectionSchema.safeParse({ ...connection, lastSyncState: 'unknown' }).success).toBe(false); + const { connected: _connected, ...withoutConnected } = connection; + expect(dawarichConnectionSchema.safeParse(withoutConnected).success).toBe(false); + // Nullable, not optional: "never synced" is a value, not an absence. + const { lastSyncAt: _lastSyncAt, ...withoutLastSync } = connection; + expect(dawarichConnectionSchema.safeParse(withoutLastSync).success).toBe(false); + }); + + it('DAWARICH-SCHEMA-041 has no slot for the raw key and drops one that is offered anyway', () => { + // The card is the one response that is built next to the stored credential, + // and the contract is what says the credential has no way out: there is a + // masked field and no unmasked one, and an object carrying `apiKey` loses it + // in the parse rather than shipping it to the browser. + const keys = Object.keys(dawarichConnectionSchema.shape); + expect(keys).toContain('apiKeyMasked'); + expect(keys).not.toContain('apiKey'); + const parsed = dawarichConnectionSchema.parse({ ...connection, apiKey: 'dw_live_secret' }); + expect(Object.keys(parsed)).not.toContain('apiKey'); + expect(parsed.apiKeyMasked).toBe(DAWARICH_KEY_MASK); + }); +}); + +describe('dawarichStatusSchema', () => { + it('DAWARICH-SCHEMA-014 is "connected" plus whatever the probe managed to learn', () => { + expect(dawarichStatusSchema.safeParse({ connected: false }).success).toBe(true); + expect(dawarichStatusSchema.safeParse({ connected: true, visitCount: 0, capabilities }).success).toBe(true); + expect( + dawarichStatusSchema.safeParse({ + connected: false, + error: 'unauthorized', + errorDetail: 'API key rejected by https://dawarich.example.com', + }).success, + ).toBe(true); + }); + + it('DAWARICH-SCHEMA-015 will not carry an untranslatable error or a stringified count', () => { + expect(dawarichStatusSchema.safeParse({ connected: false, error: 'bad_key' }).success).toBe(false); + expect(dawarichStatusSchema.safeParse({ connected: true, visitCount: '3' }).success).toBe(false); + expect(dawarichStatusSchema.safeParse({}).success).toBe(false); + }); +}); + +describe('dawarichSuggestionSchema', () => { + it('DAWARICH-SCHEMA-016 takes a fresh stay with every relation still empty', () => { + expect(dawarichSuggestionSchema.safeParse(suggestion).success).toBe(true); + expect( + dawarichSuggestionSchema.safeParse({ + ...suggestion, + tripId: null, + tripTitle: null, + lat: null, + lng: null, + countryCode: null, + }).success, + ).toBe(true); + // An accepted stay points at exactly what it produced. + expect( + dawarichSuggestionSchema.safeParse({ + ...suggestion, + state: 'accepted', + target: 'place', + acceptedPlaceId: 41, + }).success, + ).toBe(true); + }); + + it('DAWARICH-SCHEMA-017 every id is a row id or null, never a zero or a fraction', () => { + expect(dawarichSuggestionSchema.safeParse({ ...suggestion, id: 0 }).success).toBe(false); + expect(dawarichSuggestionSchema.safeParse({ ...suggestion, id: null }).success).toBe(false); + expect(dawarichSuggestionSchema.safeParse({ ...suggestion, tripId: 0 }).success).toBe(false); + expect(dawarichSuggestionSchema.safeParse({ ...suggestion, acceptedPlaceId: 1.5 }).success).toBe(false); + expect(dawarichSuggestionSchema.safeParse({ ...suggestion, acceptedJournalEntryId: -1 }).success).toBe(false); + expect(dawarichSuggestionSchema.safeParse({ ...suggestion, matchedBucketListItemId: 8 }).success).toBe(true); + // Dawarich's own id stays a string: it is a foreign key into somebody else's + // database and is never arithmetic here. + expect(dawarichSuggestionSchema.safeParse({ ...suggestion, sourceVisitId: 904 }).success).toBe(false); + }); + + it('DAWARICH-SCHEMA-018 keeps the confidence band a plain string, so a future band survives', () => { + const unscored = dawarichSuggestionSchema.safeParse({ ...suggestion, confidence: null, confidenceBand: null }); + expect(unscored.success).toBe(true); + // If this were an enum, one unknown band from a newer Dawarich would drop the + // whole row instead of one label. + expect(dawarichSuggestionSchema.safeParse({ ...suggestion, confidenceBand: 'very_high' }).success).toBe(true); + expect(dawarichSuggestionSchema.safeParse({ ...suggestion, confidence: '82' }).success).toBe(false); + }); + + it('DAWARICH-SCHEMA-019 requires both source flags, because absent must not read as unchanged', () => { + const { sourceChanged: _changed, ...withoutChanged } = suggestion; + const { sourceMissing: _missing, ...withoutMissing } = suggestion; + expect(dawarichSuggestionSchema.safeParse(withoutChanged).success).toBe(false); + expect(dawarichSuggestionSchema.safeParse(withoutMissing).success).toBe(false); + const flagged = dawarichSuggestionSchema.safeParse({ ...suggestion, sourceChanged: true, sourceMissing: true }); + expect(flagged.success).toBe(true); + }); + + it('DAWARICH-SCHEMA-020 holds the two vocabularies apart: what Dawarich thinks, what TREK did', () => { + expect(dawarichSuggestionSchema.safeParse({ ...suggestion, sourceStatus: 'declined' }).success).toBe(false); + expect(dawarichSuggestionSchema.safeParse({ ...suggestion, state: 'dismissed' }).success).toBe(true); + expect(dawarichSuggestionSchema.safeParse({ ...suggestion, state: 'suggested' }).success).toBe(false); + expect(dawarichSuggestionSchema.safeParse({ ...suggestion, target: 'journal' }).success).toBe(true); + expect(dawarichSuggestionSchema.safeParse({ ...suggestion, target: 'day' }).success).toBe(false); + }); + + it('DAWARICH-SCHEMA-042 checks a typed coordinate and a typed date, and a recorded one not at all', () => { + // A recorded fix is what Dawarich reported. Range-checking it here would drop + // the entire stay over one bad number instead of showing a pin in the wrong + // place, which the user can see and correct. The accept body is user input and + // is checked (SCHEMA-024 and SCHEMA-025 pin that half). The pair is the point: + // "tidying up" the suggestion to match the request body is a silent data loss. + expect(dawarichSuggestionSchema.safeParse({ ...suggestion, lat: 91, lng: 181 }).success).toBe(true); + expect(dawarichAcceptSchema.safeParse({ target: 'place', lat: 91, lng: 181 }).success).toBe(false); + expect(dawarichSuggestionSchema.safeParse({ ...suggestion, localDate: '02.06.2026' }).success).toBe(true); + expect(dawarichAcceptSchema.safeParse({ target: 'place', date: '02.06.2026' }).success).toBe(false); + }); +}); + +describe('dawarichSuggestionListSchema', () => { + it('DAWARICH-SCHEMA-021 ships the provenance with the rows, so a panel needs one request', () => { + const list = { + suggestions: [suggestion], + connected: true, + lastSyncAt: '2026-09-01T10:05:00.000Z', + lastSyncState: 'ok', + lastSyncError: null, + }; + expect(dawarichSuggestionListSchema.safeParse(list).success).toBe(true); + expect(dawarichSuggestionListSchema.safeParse({ ...list, suggestions: [] }).success).toBe(true); + const neverSynced = dawarichSuggestionListSchema.safeParse({ ...list, lastSyncAt: null, lastSyncState: 'never' }); + expect(neverSynced.success).toBe(true); + + const { lastSyncState: _state, ...withoutState } = list; + expect(dawarichSuggestionListSchema.safeParse(withoutState).success).toBe(false); + // One bad row fails the response rather than being silently skipped. + const withBadRow = dawarichSuggestionListSchema.safeParse({ + ...list, + suggestions: [suggestion, { ...suggestion, id: 0 }], + }); + expect(withBadRow.success).toBe(false); + }); +}); + +describe('dawarichAcceptSchema', () => { + it('DAWARICH-SCHEMA-022 takes a target on its own, because every other field has a recorded fallback', () => { + expect(dawarichAcceptSchema.safeParse({ target: 'place' }).success).toBe(true); + expect(dawarichAcceptSchema.safeParse({ target: 'journal' }).success).toBe(true); + expect(dawarichAcceptSchema.safeParse({ target: 'bucket_list' }).success).toBe(true); + expect(dawarichAcceptSchema.safeParse({}).success).toBe(false); + expect(dawarichAcceptSchema.safeParse({ target: 'trip' }).success).toBe(false); + }); + + it('DAWARICH-SCHEMA-023 makes the corrected name survive its own trim', () => { + expect(dawarichAcceptSchema.parse({ target: 'place', name: ' Bryggen ' }).name).toBe('Bryggen'); + // Whitespace is not a correction; without the trim it would overwrite the + // detected name with nothing. + expect(dawarichAcceptSchema.safeParse({ target: 'place', name: ' ' }).success).toBe(false); + expect(dawarichAcceptSchema.safeParse({ target: 'place', name: '' }).success).toBe(false); + expect(dawarichAcceptSchema.safeParse({ target: 'place', name: 'x'.repeat(255) }).success).toBe(true); + expect(dawarichAcceptSchema.safeParse({ target: 'place', name: 'x'.repeat(256) }).success).toBe(false); + expect(dawarichAcceptSchema.safeParse({ target: 'journal', notes: 'n'.repeat(5000) }).success).toBe(true); + expect(dawarichAcceptSchema.safeParse({ target: 'journal', notes: 'n'.repeat(5001) }).success).toBe(false); + }); + + it('DAWARICH-SCHEMA-024 keeps a corrected pin on the planet, edges included', () => { + expect(dawarichAcceptSchema.safeParse({ target: 'place', lat: 90, lng: 180 }).success).toBe(true); + expect(dawarichAcceptSchema.safeParse({ target: 'place', lat: -90, lng: -180 }).success).toBe(true); + expect(dawarichAcceptSchema.safeParse({ target: 'place', lat: 90.1, lng: 0 }).success).toBe(false); + expect(dawarichAcceptSchema.safeParse({ target: 'place', lat: 0, lng: -180.1 }).success).toBe(false); + expect(dawarichAcceptSchema.safeParse({ target: 'place', lat: '60', lng: 5 }).success).toBe(false); + }); + + it('DAWARICH-SCHEMA-025 checks the shape of the date and the two times, and only the shape', () => { + expect(dawarichAcceptSchema.safeParse({ target: 'place', date: '2026-06-02' }).success).toBe(true); + expect(dawarichAcceptSchema.safeParse({ target: 'place', date: '2026-6-2' }).success).toBe(false); + expect(dawarichAcceptSchema.safeParse({ target: 'place', date: '02.06.2026' }).success).toBe(false); + expect(dawarichAcceptSchema.safeParse({ target: 'place', time: '09:10', endTime: '11:40' }).success).toBe(true); + expect(dawarichAcceptSchema.safeParse({ target: 'place', time: '9:10' }).success).toBe(false); + expect(dawarichAcceptSchema.safeParse({ target: 'place', endTime: '11:40:00' }).success).toBe(false); + // Pinned as it stands rather than as it should be: the pattern is a shape and + // not a calendar, and `acceptAsPlace` writes `time` straight into the place + // row. Tightening it is a contract change and should break this line on purpose. + expect(dawarichAcceptSchema.safeParse({ target: 'place', date: '2026-13-45', time: '99:99' }).success).toBe(true); + }); + + it('DAWARICH-SCHEMA-026 will not carry an id that could not be a row', () => { + expect(dawarichAcceptSchema.safeParse({ target: 'place', tripId: 3, dayId: 9 }).success).toBe(true); + expect(dawarichAcceptSchema.safeParse({ target: 'place', tripId: 0 }).success).toBe(false); + expect(dawarichAcceptSchema.safeParse({ target: 'place', dayId: -1 }).success).toBe(false); + expect(dawarichAcceptSchema.safeParse({ target: 'journal', journalId: 1.5 }).success).toBe(false); + expect(dawarichAcceptSchema.safeParse({ target: 'bucket_list', bucketListItemId: 0 }).success).toBe(false); + expect(dawarichAcceptSchema.safeParse({ target: 'bucket_list', bucketListItemId: 8 }).success).toBe(true); + }); +}); + +describe('dawarichAcceptResultSchema and the re-review body', () => { + it('DAWARICH-SCHEMA-027 answers with the updated row and exactly what was created', () => { + const accepted = { ...suggestion, state: 'accepted', target: 'place', acceptedPlaceId: 41 }; + expect( + dawarichAcceptResultSchema.safeParse({ + suggestion: accepted, + createdPlaceId: 41, + createdJournalEntryId: null, + bucketListItemId: null, + }).success, + ).toBe(true); + // Nullable, not optional: the caller navigates on these, so "nothing was + // created here" has to be said rather than left out. + const halfAnswer = dawarichAcceptResultSchema.safeParse({ + suggestion: accepted, + createdPlaceId: 41, + bucketListItemId: null, + }); + expect(halfAnswer.success).toBe(false); + expect( + dawarichAcceptResultSchema.safeParse({ + suggestion: accepted, + createdPlaceId: 0, + createdJournalEntryId: null, + bucketListItemId: null, + }).success, + ).toBe(false); + }); + + it('DAWARICH-SCHEMA-028 lets a client push a suggestion back to new or dismissed, and no further', () => { + expect(dawarichSuggestionStateSchemaBody.safeParse({ state: 'new' }).success).toBe(true); + expect(dawarichSuggestionStateSchemaBody.safeParse({ state: 'dismissed' }).success).toBe(true); + // Accepting creates a place, a journal entry or a tick. It cannot be done by + // writing a word into a state field. + expect(dawarichSuggestionStateSchemaBody.safeParse({ state: 'accepted' }).success).toBe(false); + expect(dawarichSuggestionStateSchemaBody.safeParse({}).success).toBe(false); + }); + + it('DAWARICH-SCHEMA-043 keeps the re-review body a strict subset of the states TREK tracks', () => { + // The body spells its two words out instead of deriving them from + // DAWARICH_SUGGESTION_STATES, so nothing but this makes the two move together: + // renaming a state would leave a route still accepting a word that no longer + // exists, and adding a fourth one would leave it unreachable without anybody + // deciding that it should be. + const reviewable = DAWARICH_SUGGESTION_STATES.filter((state) => state !== 'accepted'); + expect(reviewable).toEqual(['new', 'dismissed']); + expect(reviewable.every((state) => dawarichSuggestionStateSchemaBody.safeParse({ state }).success)).toBe(true); + expect(reviewable.every((state) => dawarichSuggestionStateSchema.safeParse(state).success)).toBe(true); + }); +}); + +describe('the track overlay', () => { + const segment = { + points: [ + [60.3971, 5.3245], + [60.3988, 5.3301], + ], + mode: 'walking', + startedAt: '2026-06-02T09:10:00+02:00', + endedAt: '2026-06-02T09:40:00+02:00', + distanceMeters: 1420, + }; + + it('DAWARICH-SCHEMA-029 is [lat, lng] pairs and nothing wider', () => { + expect(dawarichTrackSegmentSchema.safeParse(segment).success).toBe(true); + // A third element would be read as a coordinate by everything downstream, + // which stores pairs; an altitude has to be dropped upstream, not smuggled in. + expect(dawarichTrackSegmentSchema.safeParse({ ...segment, points: [[60.39, 5.32, 12]] }).success).toBe(false); + expect(dawarichTrackSegmentSchema.safeParse({ ...segment, points: [[60.39]] }).success).toBe(false); + expect(dawarichTrackSegmentSchema.safeParse({ ...segment, points: [['60.39', '5.32']] }).success).toBe(false); + // An empty segment is legal: a day with a gap in the recording still has the day. + expect(dawarichTrackSegmentSchema.safeParse({ ...segment, points: [] }).success).toBe(true); + }); + + it('DAWARICH-SCHEMA-030 passes the mode through untranslated, or null on an older instance', () => { + // Dawarich has `stationary`, TREK has `ferry`. Mapping one onto the other + // would put a confident wrong label on a recorded fact, so the string travels + // as it came and an unknown value is not a reason to reject the segment. + expect(dawarichTrackSegmentSchema.safeParse({ ...segment, mode: 'stationary' }).success).toBe(true); + expect(dawarichTrackSegmentSchema.safeParse({ ...segment, mode: null, distanceMeters: null }).success).toBe(true); + const { mode: _mode, ...withoutMode } = segment; + expect(dawarichTrackSegmentSchema.safeParse(withoutMode).success).toBe(false); + }); + + it('DAWARICH-SCHEMA-031 groups segments under one local calendar day', () => { + expect(dawarichTrackDaySchema.safeParse({ date: '2026-06-02', segments: [segment] }).success).toBe(true); + expect(dawarichTrackDaySchema.safeParse({ date: '2026-06-02', segments: [] }).success).toBe(true); + expect(dawarichTrackDaySchema.safeParse({ date: '2026-06-02' }).success).toBe(false); + const brokenSegment = dawarichTrackDaySchema.safeParse({ + date: '2026-06-02', + segments: [{ ...segment, points: 'x' }], + }); + expect(brokenSegment.success).toBe(false); + }); + + it('DAWARICH-SCHEMA-032 names the endpoint that answered and admits when the line is cut short', () => { + const track = { + days: [{ date: '2026-06-02', segments: [segment] }], + source: 'tracks', + fetchedAt: '2026-09-01T10:05:00.000Z', + pointCount: 2, + truncated: false, + }; + expect(dawarichTrackSchema.safeParse(track).success).toBe(true); + expect(dawarichTrackSchema.safeParse({ ...track, source: 'points', truncated: true }).success).toBe(true); + expect(dawarichTrackSchema.safeParse({ ...track, source: 'gpx' }).success).toBe(false); + // Required, not defaulted: an incomplete line that forgot to say so is drawn + // as if it were the whole route. + const { truncated: _truncated, ...withoutTruncated } = track; + expect(dawarichTrackSchema.safeParse(withoutTruncated).success).toBe(false); + // A window with no recordings is an answer, not an error. + expect(dawarichTrackSchema.safeParse({ ...track, days: [], pointCount: 0 }).success).toBe(true); + }); +}); + +describe('the atlas contracts', () => { + const country = { + countryCode: 'NO', + sourceName: 'Norway', + cities: [{ name: 'Bergen', minutes: 2400, lastSeenAt: '2026-06-04T18:00:00Z' }], + alreadyVisited: false, + }; + + it('DAWARICH-SCHEMA-033 keeps the source spelling and lets a city have no last sighting', () => { + expect(dawarichAtlasCountrySchema.safeParse(country).success).toBe(true); + expect( + dawarichAtlasCountrySchema.safeParse({ + ...country, + cities: [{ name: 'Bergen', minutes: 2400, lastSeenAt: null }], + alreadyVisited: true, + }).success, + ).toBe(true); + expect(dawarichAtlasCountrySchema.safeParse({ ...country, cities: [] }).success).toBe(true); + // Nullable and not optional on purpose: a city stamped 1970-01-01 looks like + // data and is not. + const undated = dawarichAtlasCountrySchema.safeParse({ + ...country, + cities: [{ name: 'Bergen', minutes: 2400 }], + }); + expect(undated.success).toBe(false); + const stringMinutes = dawarichAtlasCountrySchema.safeParse({ + ...country, + cities: [{ name: 'Bergen', minutes: '2400', lastSeenAt: null }], + }); + expect(stringMinutes.success).toBe(false); + }); + + it('DAWARICH-SCHEMA-034 names the countries it could not resolve instead of dropping them', () => { + expect( + dawarichAtlasSuggestionsSchema.safeParse({ + countries: [country], + unresolved: ['Kosovo'], + fetchedAt: '2026-09-01T10:05:00.000Z', + }).success, + ).toBe(true); + const empty = dawarichAtlasSuggestionsSchema.safeParse({ + countries: [], + unresolved: [], + fetchedAt: '2026-09-01T10:05:00.000Z', + }); + expect(empty.success).toBe(true); + expect(dawarichAtlasSuggestionsSchema.safeParse({ countries: [country], unresolved: ['Kosovo'] }).success).toBe( + false, + ); + }); + + it('DAWARICH-SCHEMA-035 accepts between one and three hundred alpha-2 codes', () => { + expect(dawarichAtlasAcceptSchema.safeParse({ countryCodes: ['NO', 'SE'] }).success).toBe(true); + // Nothing to accept is a client bug, not an empty write. + expect(dawarichAtlasAcceptSchema.safeParse({ countryCodes: [] }).success).toBe(false); + const atCap = dawarichAtlasAcceptSchema.safeParse({ countryCodes: Array.from({ length: 300 }, () => 'NO') }); + expect(atCap.success).toBe(true); + const overCap = dawarichAtlasAcceptSchema.safeParse({ countryCodes: Array.from({ length: 301 }, () => 'NO') }); + expect(overCap.success).toBe(false); + // Alpha-2 and nothing else: the Atlas keys its countries on exactly two letters. + expect(dawarichAtlasAcceptSchema.safeParse({ countryCodes: ['NOR'] }).success).toBe(false); + expect(dawarichAtlasAcceptSchema.safeParse({ countryCodes: ['n'] }).success).toBe(false); + }); +}); + +describe('the bucket-list contracts', () => { + const match = { + itemId: 5, + name: 'See the Northern Lights', + match: { at: '2026-06-02T22:10:00+02:00', minutes: 95, distanceMeters: 120, points: 34 }, + alreadyVisited: false, + }; + + it('DAWARICH-SCHEMA-036 reports both numbers that justify a match, or no match at all', () => { + expect(dawarichBucketMatchSchema.safeParse(match).success).toBe(true); + expect(dawarichBucketMatchSchema.safeParse({ ...match, match: null, alreadyVisited: true }).success).toBe(true); + // Proximity alone is not a visit, so neither number may go missing: the user + // is meant to judge the call, and half the evidence is not a judgement. + const withoutDistance = dawarichBucketMatchSchema.safeParse({ + ...match, + match: { at: '2026-06-02T22:10:00+02:00', minutes: 95, points: 34 }, + }); + expect(withoutDistance.success).toBe(false); + const withoutMinutes = dawarichBucketMatchSchema.safeParse({ + ...match, + match: { at: '2026-06-02T22:10:00+02:00', distanceMeters: 120, points: 34 }, + }); + expect(withoutMinutes.success).toBe(false); + expect(dawarichBucketMatchSchema.safeParse({ ...match, itemId: 0 }).success).toBe(false); + }); + + it('DAWARICH-SCHEMA-037 says out loud what the scan skipped and where it stopped', () => { + const scan = { + matches: [match], + skippedWithoutCoordinates: 2, + truncated: true, + fetchedAt: '2026-09-01T10:05:00.000Z', + }; + expect(dawarichBucketScanSchema.safeParse(scan).success).toBe(true); + const clean = dawarichBucketScanSchema.safeParse({ ...scan, matches: [], skippedWithoutCoordinates: 0 }); + expect(clean.success).toBe(true); + const { truncated: _truncated, ...withoutTruncated } = scan; + expect(dawarichBucketScanSchema.safeParse(withoutTruncated).success).toBe(false); + const { skippedWithoutCoordinates: _skipped, ...withoutSkipped } = scan; + expect(dawarichBucketScanSchema.safeParse(withoutSkipped).success).toBe(false); + }); + + it('DAWARICH-SCHEMA-038 confirms between one and two hundred entries, dated or not', () => { + expect(dawarichBucketConfirmSchema.safeParse({ itemIds: [5] }).success).toBe(true); + const dated = dawarichBucketConfirmSchema.safeParse({ itemIds: [5, 6], visitedAt: '2026-06-02T22:10:00+02:00' }); + expect(dated.success).toBe(true); + expect(dawarichBucketConfirmSchema.safeParse({ itemIds: [] }).success).toBe(false); + expect(dawarichBucketConfirmSchema.safeParse({ itemIds: [0] }).success).toBe(false); + const atCap = dawarichBucketConfirmSchema.safeParse({ itemIds: Array.from({ length: 200 }, () => 5) }); + expect(atCap.success).toBe(true); + const overCap = dawarichBucketConfirmSchema.safeParse({ itemIds: Array.from({ length: 201 }, () => 5) }); + expect(overCap.success).toBe(false); + }); +}); + +describe('the tuning numbers', () => { + it('DAWARICH-SCHEMA-039 pins what TREK calls "being somewhere"', () => { + // These decide whether a wish counts as fulfilled and how much of a month-long + // trip stays drawable. They live in the contract so the server, the help text + // and these tests cannot disagree; changing one is a product decision. + expect(DAWARICH_BUCKET_MATCH_RADIUS_M).toBe(250); + expect(DAWARICH_BUCKET_MATCH_MIN_MINUTES).toBe(20); + expect(DAWARICH_BUCKET_SCAN_LIMIT).toBe(50); + expect(DAWARICH_TRACK_POINTS_PER_DAY).toBe(600); + expect(DAWARICH_SYNC_LOOKBACK_DAYS).toBe(3); + // A stay can end after midnight, so the window reaches one day past the trip. + expect(DAWARICH_SYNC_LOOKAHEAD_DAYS).toBe(1); + }); +});