Merge pull request #2365 from liketrek/test/dawarich-coverage

test(dawarich): the integration that shipped without a suite
This commit is contained in:
Maurice
2026-09-14 13:02:35 +02:00
committed by GitHub
30 changed files with 10985 additions and 9 deletions
+406
View File
@@ -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<T extends JsonBodyType>(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')
})
})
@@ -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> = {}): 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<number, AcceptTargetOption[]> = {
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<typeof DawarichAcceptDialog>
function open(props: Partial<Props> = {}) {
const onConfirm = vi.fn()
const onCancel = vi.fn()
const all: Props = { suggestion: stay(), target: 'place', ...props, onConfirm, onCancel }
const view = render(<DawarichAcceptDialog {...all} />)
return {
...view,
onConfirm,
onCancel,
/** Re-render with the same props bar the ones named: the parent's own update path. */
update: (next: Partial<Props>) => view.rerender(<DawarichAcceptDialog {...all} {...next} />),
}
}
/** 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',
})
})
})
@@ -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<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(res => {
resolve = res
})
return { promise, resolve }
}
function open(props: Partial<React.ComponentProps<typeof DawarichAtlasDialog>> = {}) {
return render(<DawarichAtlasDialog isOpen onClose={vi.fn()} {...props} />)
}
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<typeof SCAN>()
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<typeof SCAN>()
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')
})
})
@@ -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
}) => (
<div data-testid="atlas-dialog">
<span>asked: {initialTab}</span>
<button type="button" onClick={onClose}>
dismiss
</button>
<button type="button" onClick={() => onChanged?.()}>
wrote something
</button>
</div>
),
}))
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<React.ComponentProps<typeof DawarichAtlasSidePanel>> = {}) {
return render(<DawarichAtlasSidePanel style={GLASS} dark {...props} />)
}
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(<DawarichAtlasSidePanel style={GLASS} dark={false} />)
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(<DawarichAtlasSidePanel style={GLASS} dark />)
// 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')
})
})
@@ -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<typeof DawarichBadge>
// 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 <svg data-testid="badge-icon" className={className} />
}
function badge(props: BadgeProps) {
const view = render(<DawarichBadge {...props} />)
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<BadgeProps['tone']>, 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')
})
})
@@ -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<DawarichSuggestion> & { 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<boolean>>()
const dismiss = vi.fn<(id: number) => Promise<void>>()
const restore = vi.fn<(id: number) => Promise<void>>()
const reload = vi.fn()
function hookState(over: Partial<DawarichSuggestionsState> = {}): 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<React.ComponentProps<typeof DawarichSuggestionsPanel>> = {}) {
return render(<DawarichSuggestionsPanel {...props} />)
}
/** 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()
})
})
@@ -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"}')
})
})
@@ -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', () => {
@@ -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<void>(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<void>(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)
})
})
@@ -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<string, unknown>
pane?: string
interactive?: boolean
}) => (
<div
data-testid="polyline"
data-points={JSON.stringify(positions)}
data-color={String(pathOptions?.color ?? '')}
data-weight={String(pathOptions?.weight ?? '')}
data-opacity={String(pathOptions?.opacity ?? '')}
data-dash={String(pathOptions?.dashArray ?? '')}
data-pane={pane ?? ''}
data-interactive={String(interactive)}
/>
),
}))
import DawarichTrailLayer from './DawarichTrailLayer'
function day(date: string, ...segments: Array<Array<[number, number]>>): 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(<DawarichTrailLayer track={null} />)
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(<DawarichTrailLayer track={track([day('2026-05-01', [[48.2, 16.37]])])} />)
expect(container.firstChild).toBeNull()
})
it('FE-DAWARICH-TRAILUI-012: draws every casing first, then every coloured line on top', () => {
render(<DawarichTrailLayer track={track([DAY_ONE, DAY_TWO])} casingPane="trek-track-casing" />)
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(<DawarichTrailLayer track={track([DAY_ONE])} casingPane="trek-track-casing" />)
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(<DawarichTrailLayer track={track([DAY_ONE])} />)
// 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(<DawarichTrailLayer track={track([DAY_ONE, DAY_TWO])} selectedDate="2026-05-02" />)
// 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(<DawarichTrailLayer track={track([DAY_ONE, DAY_TWO])} selectedDate="2026-05-01" />)
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(<DawarichTrailLayer track={track([DAY_ONE, DAY_TWO])} selectedDate="2026-05-09" />)
expect(lines()).toHaveLength(0)
})
})
@@ -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<React.ComponentProps<typeof DawarichTrailPill>> = {}) {
return render(
<DawarichTrailPill active={false} status="idle" onToggle={vi.fn()} {...props} />,
)
}
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(<DawarichTrailPill active={false} status="unavailable" onToggle={vi.fn()} />)
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(<DawarichTrailPill active status="idle" onToggle={vi.fn()} />)
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')
})
})
@@ -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 `<Polyline>` 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> = {}): 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()
})
})
@@ -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<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((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<DawarichTrack>()
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<DawarichTrack>()
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<DawarichTrack>()
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<DawarichTrack>()
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<DawarichTrack>()
const current = deferred<DawarichTrack>()
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)
})
})
@@ -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<typeof vi.fn>
}
/**
* 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<string, FakeSource>()
const layers = new Map<string, { spec: { id: string; paint?: Record<string, unknown> }; before: string | undefined }>()
for (const id of styleLayers) layers.set(id, { spec: { id }, before: undefined })
const listeners = new Map<string, Set<() => 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<string, unknown> }, 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<Array<[number, number]>>): 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)
})
})
@@ -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> = {}): 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<string, unknown>;
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<string, unknown> = {}): 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<void>; release: () => void } {
let release!: () => void;
const promise = new Promise<void>(resolve => {
release = resolve;
});
return { promise, release };
}
function renderSection() {
return render(<DawarichConnectionSection />);
}
/** 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<SavedBody> | undefined;
stubSettings({ url: 'https://dawarich.example.com' });
server.use(
http.post(TEST_URL, async ({ request }) => {
body = (await request.json()) as Partial<SavedBody>;
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();
});
});
@@ -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> = {}): 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<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((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<DawarichConnection>()
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<DawarichConnection>()
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<void>
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<DawarichStatus>()
api.test.mockReturnValue(probe.promise)
let done!: Promise<void>
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<DawarichStatus>()
api.test.mockReturnValue(thrown.promise)
let done!: Promise<void>
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<void>
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<void>
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)
})
})
@@ -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<typeof import('../sync/networkMode')>()),
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> = {}): 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<DawarichTrack>((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<string>; 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)
})
})
@@ -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<DawarichSuggestion> & { 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> = {}): 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<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
type Filter = Parameters<typeof useDawarichSuggestions>[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<DawarichSuggestionList>()
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<DawarichSuggestionList>()
const second = deferred<DawarichSuggestionList>()
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<DawarichSuggestionList>()
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<DawarichAcceptResult>()
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<boolean>
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()
})
})
@@ -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> = {}): 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<DawarichConnectionState> = {}) {
state = makeState(over)
const view = render(<MDawarichConnectionSection />)
// 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<DawarichConnectionState>) => {
state = makeState(next)
view.rerender(<MDawarichConnectionSection />)
}
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()
})
})
+294
View File
@@ -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<unknown>]> = [
['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<string | number> }> }).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)
})
})
@@ -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<string, unknown>;
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<string, unknown>;
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);
@@ -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<void>) | undefined;
const registrar = {
isEnabled: vi.fn(() => enabled),
register: vi.fn((_name: string, _expression: string, cb: () => void | Promise<void>) => {
onTick = cb;
return enabled;
}),
unregister: vi.fn(),
};
// Shaped like the real `get<T>(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<void>>().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();
});
});
@@ -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;
});
});
@@ -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<NonNullable<DawarichTrackFeature['properties']>> = {},
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);
});
});
@@ -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<string, string | undefined>,
) => 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: '<!doctype html><title>Sign in</title>' }));
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));
@@ -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);
@@ -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([]);
});
+699
View File
@@ -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<DawarichSuggestionsService>;
tracks?: Partial<DawarichTracksService>;
auth?: Partial<AuthService>;
addons?: Partial<AddonsService>;
}
/** The demo gate is off unless a case turns it on; every write tool asks it. */
function makeMcp(m: Mocks = {}) {
const auth: Partial<AuthService> = 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<string, unknown> {
expect(result.isError).toBeUndefined();
return JSON.parse(result.content[0].text) as Record<string, unknown>;
}
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<string, string> = {
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> = {}): 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<string, unknown>, 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<string, unknown>, 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<string, (a: unknown, c: McpContext) => 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,
});
});
});
File diff suppressed because it is too large Load Diff
+736
View File
@@ -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<string, unknown> = { ...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);
});
});