// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-022 /** * The document-sync hook. * * Everything the panel and the phone sheet know about sync comes through here, * so the interesting behaviour is what a person sees: holdings that arrive on a * different route than the bindings they belong to, a dialog that must not flash * white every time a switch is flipped, a toggle that moves before the server * has answered, and a probe that has to produce a verdict even when the request * itself fell over. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { renderHook, act, waitFor } from '../../../../tests/helpers/render' const providersApi = vi.fn(async (_tripId: number | string): Promise => []) const statusApi = vi.fn(async (_tripId: number | string): Promise => ({})) const listConnectionsApi = vi.fn(async (_tripId: number | string): Promise => []) const listLinksApi = vi.fn(async (_tripId: number | string): Promise => []) const saveConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown): Promise => ({})) const testConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown): Promise => ({ connected: true })) const updateLinkApi = vi.fn(async (_tripId: number | string, _linkId: number, _patch: unknown): Promise => ({})) const syncNowApi = vi.fn(async (_tripId: number | string, _linkId: number, _full: boolean): Promise => ({})) vi.mock('../../../api/client', async (importOriginal) => { const actual = await importOriginal() return { ...actual, docsyncApi: { ...actual.docsyncApi, providers: (tripId: number | string) => providersApi(tripId), status: (tripId: number | string) => statusApi(tripId), listConnections: (tripId: number | string) => listConnectionsApi(tripId), listLinks: (tripId: number | string) => listLinksApi(tripId), saveConnection: (tripId: number | string, data: unknown) => saveConnectionApi(tripId, data), testConnection: (tripId: number | string, data: unknown) => testConnectionApi(tripId, data), updateLink: (tripId: number | string, linkId: number, patch: unknown) => updateLinkApi(tripId, linkId, patch), syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNowApi(tripId, linkId, full), }, } }) import { useDocSync, canManageDocSync } from './useDocSync' const TRIP = 3 const provider = (overrides: Record = {}) => ({ id: 'nextcloud', name: 'Nextcloud', description: null, icon: 'cloud', available: true, fields: [], ...overrides, }) const connection = (overrides: Record = {}) => ({ id: 9, providerId: 'nextcloud', baseUrl: 'https://cloud.example', settings: {}, secrets: {}, allowInsecureTls: false, lastProbeState: 'ok', lastProbeError: null, ...overrides, }) const link = (overrides: Record = {}) => ({ id: 1, connectionId: 9, providerId: 'nextcloud', scopeKey: 'trip', remoteLabel: 'Trip documents', remoteRootPath: '/Trips/Rome', direction: 'both', deletePolicy: 'unlink', conflictPolicy: 'newer', syncEnabled: true, lastSyncAt: null, lastSyncState: 'idle', lastSyncError: null, webhookUrl: null, ...overrides, }) /** A promise this test settles by hand, to hold a request open. */ function deferred() { let resolve!: (value: T) => void let reject!: (reason?: unknown) => void const promise = new Promise((res, rej) => { resolve = res; reject = rej }) return { promise, resolve, reject } } /** Mount the hook and record `loading` on every render it goes through. */ function mount(tripId: number | string = TRIP, enabled = true) { const seenLoading: boolean[] = [] const view = renderHook(() => { const hook = useDocSync(tripId, enabled) seenLoading.push(hook.loading) return hook }) return { ...view, seenLoading } } /** Mount and wait until the first load is through. */ async function mountLoaded(tripId: number | string = TRIP) { const view = mount(tripId) await waitFor(() => expect(view.result.current.loading).toBe(false)) return view } beforeEach(() => { vi.clearAllMocks() providersApi.mockResolvedValue([provider()]) statusApi.mockResolvedValue({ items: {}, links: [] }) listConnectionsApi.mockResolvedValue([connection()]) listLinksApi.mockResolvedValue([link()]) saveConnectionApi.mockResolvedValue({}) testConnectionApi.mockResolvedValue({ connected: true }) updateLinkApi.mockResolvedValue({}) syncNowApi.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 }) }) describe('useDocSync initial load', () => { it('FE-DOCSYNC-HOOK-001: joins the status route holdings onto the binding they belong to', async () => { const holdings = { inTrek: 7, atProvider: 5, paired: 4, missing: 1 } listLinksApi.mockResolvedValue([link()]) statusApi.mockResolvedValue({ items: { conflict: 2, pending: 1 }, links: [{ id: 1, holdings }] }) const { result } = await mountLoaded() expect(result.current.links).toHaveLength(1) expect(result.current.links[0].holdings).toEqual(holdings) expect(result.current.itemCounts).toEqual({ conflict: 2, pending: 1 }) expect(result.current.connections).toEqual([connection()]) }) it('FE-DOCSYNC-HOOK-002: a binding the status route never mentions does not inherit another binding counts', async () => { const holdings = { inTrek: 7, atProvider: 5, paired: 4, missing: 1 } listLinksApi.mockResolvedValue([link(), link({ id: 2, remoteLabel: 'Receipts' })]) statusApi.mockResolvedValue({ items: {}, links: [{ id: 1, holdings }] }) const { result } = await mountLoaded() expect(result.current.links[0].holdings).toEqual(holdings) expect(result.current.links[1].holdings).toBeUndefined() }) it('FE-DOCSYNC-HOOK-003: a status body without an items map leaves the counts empty', async () => { statusApi.mockResolvedValue({}) const { result } = await mountLoaded() expect(result.current.itemCounts).toEqual({}) expect(result.current.links[0].holdings).toBeUndefined() }) it('FE-DOCSYNC-HOOK-004: offers only the providers this instance can actually use', async () => { providersApi.mockResolvedValue([ provider(), provider({ id: 'gdrive', name: 'Google Drive', available: false }), ]) const { result } = await mountLoaded() expect(result.current.providers.map(p => p.id)).toEqual(['nextcloud']) }) it('FE-DOCSYNC-HOOK-005: a failed load leaves nothing to configure instead of blowing up', async () => { // The addon was switched off, or this member lost access to the trip. providersApi.mockRejectedValue(Object.assign(new Error('forbidden'), { response: { status: 403 } })) const { result } = await mountLoaded() expect(result.current.providers).toEqual([]) expect(result.current.links).toEqual([]) expect(result.current.error).toBeNull() expect(result.current.loading).toBe(false) }) it('FE-DOCSYNC-HOOK-006: asks the server nothing while the panel is closed', async () => { const { result } = mount(TRIP, false) await act(async () => { await Promise.resolve() }) expect(providersApi).not.toHaveBeenCalled() expect(listLinksApi).not.toHaveBeenCalled() expect(result.current.loading).toBe(false) }) }) describe('useDocSync spinner', () => { it('FE-DOCSYNC-HOOK-007: shows the spinner for the first load, which has nothing to show yet', async () => { const gate = deferred() listLinksApi.mockReturnValueOnce(gate.promise) const { result, seenLoading } = mount() expect(result.current.loading).toBe(true) expect(seenLoading[seenLoading.length - 1]).toBe(true) await act(async () => { gate.resolve([link()]) }) await waitFor(() => expect(result.current.loading).toBe(false)) }) it('FE-DOCSYNC-HOOK-008: never raises the spinner again while a mutation refetches', async () => { // Regression guard: a refetch that flips `loading` swaps the whole dialog // for a spinner and back, so flipping one switch flashes the panel white. const { result, seenLoading } = await mountLoaded() const gate = deferred() listLinksApi.mockReturnValueOnce(gate.promise) seenLoading.length = 0 let pending!: Promise await act(async () => { pending = result.current.updateLink(1, { syncEnabled: false }) }) expect(result.current.loading).toBe(false) await act(async () => { gate.resolve([link({ syncEnabled: false })]) await pending }) // the refetch really happened... expect(listLinksApi).toHaveBeenCalledTimes(2) // ...and the panel never went blank for it expect(seenLoading).not.toContain(true) expect(result.current.loading).toBe(false) }) }) describe('useDocSync updateLink', () => { it('FE-DOCSYNC-HOOK-009: moves the switch before the server has answered', async () => { const { result } = await mountLoaded() const gate = deferred() updateLinkApi.mockReturnValueOnce(gate.promise) let pending!: Promise await act(async () => { pending = result.current.updateLink(1, { direction: 'pull' }) }) expect(result.current.links[0].direction).toBe('pull') expect(updateLinkApi).toHaveBeenCalledWith(TRIP, 1, { direction: 'pull' }) await act(async () => { gate.resolve({}); await pending }) }) it('FE-DOCSYNC-HOOK-010: reloads afterwards, so the server row is what stays on screen', async () => { const { result } = await mountLoaded() listLinksApi.mockResolvedValue([link({ direction: 'pull', lastSyncState: 'ok', remoteLabel: 'Trip documents (moved)' })]) await act(async () => { await result.current.updateLink(1, { direction: 'pull' }) }) expect(listLinksApi).toHaveBeenCalledTimes(2) expect(result.current.links[0].remoteLabel).toBe('Trip documents (moved)') expect(result.current.links[0].lastSyncState).toBe('ok') }) it('FE-DOCSYNC-HOOK-011: a rejected update does not leave the optimistic value standing', async () => { const { result } = await mountLoaded() updateLinkApi.mockRejectedValueOnce(new Error('conflict')) await act(async () => { await expect(result.current.updateLink(1, { syncEnabled: false })).rejects.toThrow('conflict') }) expect(listLinksApi).toHaveBeenCalledTimes(2) expect(result.current.links[0].syncEnabled).toBe(true) }) }) describe('useDocSync testConnection', () => { it('FE-DOCSYNC-HOOK-012: hands the probe verdict back to the form', async () => { testConnectionApi.mockResolvedValue({ connected: true, account: 'owner@cloud.example' }) const { result } = await mountLoaded() let verdict: { connected: boolean; account?: string; error?: string } | undefined await act(async () => { verdict = await result.current.testConnection('nextcloud', 'https://cloud.example', { token: 'abc' }, false) }) expect(verdict).toEqual({ connected: true, account: 'owner@cloud.example' }) expect(testConnectionApi).toHaveBeenCalledWith(TRIP, { providerId: 'nextcloud', baseUrl: 'https://cloud.example', credentials: { token: 'abc' }, allowInsecureTls: false, }) }) it('FE-DOCSYNC-HOOK-013: a request that throws still produces a verdict the form can render', async () => { testConnectionApi.mockRejectedValueOnce({ response: { data: { error: 'docsync.unreachable' } } }) const { result } = await mountLoaded() let verdict: { connected: boolean; error?: string } | undefined await act(async () => { verdict = await result.current.testConnection('nextcloud', 'https://typo.example', {}, false) }) expect(verdict).toEqual({ connected: false, error: 'docsync.unreachable' }) }) it('FE-DOCSYNC-HOOK-014: an error with no message of its own falls back to a generic one', async () => { testConnectionApi.mockRejectedValueOnce(new Error('socket hang up')) const { result } = await mountLoaded() let verdict: { connected: boolean; error?: string } | undefined await act(async () => { verdict = await result.current.testConnection('nextcloud', 'https://cloud.example', {}, false) }) expect(verdict).toEqual({ connected: false, error: 'unknown' }) }) it('FE-DOCSYNC-HOOK-015: marks the probe busy while it runs and clears it when it lands', async () => { const { result } = await mountLoaded() const gate = deferred() testConnectionApi.mockReturnValueOnce(gate.promise) let pending!: Promise await act(async () => { pending = result.current.testConnection('nextcloud', 'https://cloud.example', {}, false) }) expect(result.current.busy).toBe('test') await act(async () => { gate.resolve({ connected: true }); await pending }) expect(result.current.busy).toBeNull() }) }) describe('useDocSync run and save feedback', () => { it('FE-DOCSYNC-HOOK-016: remembers what the last run moved, per binding', async () => { syncNowApi.mockResolvedValue({ state: 'ok', pulled: 4, pushed: 2, conflicts: 0, missing: 0 }) const { result } = await mountLoaded() await act(async () => { await result.current.syncNow(1) }) expect(syncNowApi).toHaveBeenCalledWith(TRIP, 1, false) expect(result.current.lastRunFor(1)).toEqual({ pulled: 4, pushed: 2 }) // a binding nobody ran in this session reports zeros, not someone else numbers expect(result.current.lastRunFor(2)).toEqual({ pulled: 0, pushed: 0 }) }) it('FE-DOCSYNC-HOOK-017: a run that reports no numbers counts as nothing moved', async () => { syncNowApi.mockResolvedValue({ state: 'ok' }) const { result } = await mountLoaded() await act(async () => { await result.current.syncNow(1, true) }) expect(syncNowApi).toHaveBeenCalledWith(TRIP, 1, true) expect(result.current.lastRunFor(1)).toEqual({ pulled: 0, pushed: 0 }) }) it('FE-DOCSYNC-HOOK-018: a refused save reports failure and surfaces the server reason', async () => { saveConnectionApi.mockRejectedValueOnce({ response: { data: { message: 'docsync.badCredentials' } } }) const { result } = await mountLoaded() let ok: boolean | undefined await act(async () => { ok = await result.current.saveConnection('nextcloud', 'https://cloud.example', { token: 'nope' }, false) }) expect(ok).toBe(false) expect(result.current.error).toBe('docsync.badCredentials') expect(result.current.busy).toBeNull() // nothing was refetched, because nothing changed server-side expect(listLinksApi).toHaveBeenCalledTimes(1) }) }) describe('canManageDocSync', () => { it('FE-DOCSYNC-HOOK-019: the trip owner may repoint a binding', () => { expect(canManageDocSync({ id: 4, role: 'user' }, { user_id: 4 })).toBe(true) }) it('FE-DOCSYNC-HOOK-020: an instance admin may, even on a trip that is not theirs', () => { expect(canManageDocSync({ id: 12, role: 'admin' }, { user_id: 4 })).toBe(true) }) it('FE-DOCSYNC-HOOK-021: an ordinary member may not point the owner archive somewhere else', () => { expect(canManageDocSync({ id: 12, role: 'user' }, { user_id: 4 })).toBe(false) expect(canManageDocSync({ id: 12, role: 'user' }, null)).toBe(false) expect(canManageDocSync({ id: 12, role: 'user' }, {})).toBe(false) }) it('FE-DOCSYNC-HOOK-022: nobody signed in, nobody may manage', () => { expect(canManageDocSync(null, { user_id: 4 })).toBe(false) expect(canManageDocSync(undefined, { user_id: 4 })).toBe(false) // ids arrive as strings on some routes and as numbers on others expect(canManageDocSync({ id: '4', role: 'user' }, { user_id: 4 })).toBe(true) }) })