mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
a6e8c66609
Dawarich landed on dev untested, which is why release PR #2273 reads 79.0% on SonarCloud's 80% new-code gate: 1352 of the release's 4610 uncovered units are Dawarich's own. 946 cases across 30 files take the surface to 100% in shared, 99.7 in server and 99.6 in client, and the gate to roughly 85%. What stays uncovered is thirteen type-narrowing guards and defensive fallbacks the only caller cannot reach, each argued in the file beside it rather than forced with a cast. No source, config or coverage threshold is touched.
722 lines
30 KiB
TypeScript
722 lines
30 KiB
TypeScript
/**
|
|
* 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)
|
|
})
|
|
})
|