mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
9e216f5ae4
Once a store was connected for a trip, its address and login could only be entered again after a sync run had been refused, and the stored connection could not be removed at all: the server's DELETE route had no way in from the app. A binding's settings now open the connection at any time, the folder picker leads back to it before a folder is chosen, and the connection form removes a stored connection after a confirmation, on the desktop panel and the phone sheet alike. Removing it drops the trip's bindings through it; documents stay on both sides. Closes #2842
553 lines
24 KiB
TypeScript
553 lines
24 KiB
TypeScript
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-032
|
|
|
|
/**
|
|
* 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<unknown> => [])
|
|
const statusApi = vi.fn(async (_tripId: number | string): Promise<unknown> => ({}))
|
|
const listConnectionsApi = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
|
|
const listLinksApi = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
|
|
const saveConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({}))
|
|
const testConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ connected: true }))
|
|
const updateLinkApi = vi.fn(async (_tripId: number | string, _linkId: number, _patch: unknown): Promise<unknown> => ({}))
|
|
const syncNowApi = vi.fn(async (_tripId: number | string, _linkId: number, _full: boolean): Promise<unknown> => ({}))
|
|
const listScopesApi = vi.fn(async (_tripId: number | string, _connectionId: number, _q?: string): Promise<unknown> => ({ scopes: [] }))
|
|
const deleteConnectionApi = vi.fn(async (_tripId: number | string, _id: number): Promise<unknown> => ({ success: true }))
|
|
const resolveApi = vi.fn(async (_tripId: number | string, _itemId: number, _keep: string): Promise<unknown> => ({ success: true }))
|
|
|
|
vi.mock('../../../api/client', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../../api/client')>()
|
|
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),
|
|
listScopes: (tripId: number | string, connectionId: number, q?: string) => listScopesApi(tripId, connectionId, q),
|
|
resolve: (tripId: number | string, itemId: number, keep: string) => resolveApi(tripId, itemId, keep),
|
|
deleteConnection: (tripId: number | string, id: number) => deleteConnectionApi(tripId, id),
|
|
},
|
|
}
|
|
})
|
|
|
|
import { useDocSync, canManageDocSync, needsReauth, storeName, type DocSyncLink, type DocSyncProvider } from './useDocSync'
|
|
|
|
const TRIP = 3
|
|
|
|
const provider = (overrides: Record<string, unknown> = {}) => ({
|
|
id: 'nextcloud',
|
|
name: 'Nextcloud',
|
|
description: null,
|
|
icon: 'cloud',
|
|
available: true,
|
|
fields: [],
|
|
...overrides,
|
|
})
|
|
|
|
const connection = (overrides: Record<string, unknown> = {}) => ({
|
|
id: 9,
|
|
providerId: 'nextcloud',
|
|
baseUrl: 'https://cloud.example',
|
|
settings: {},
|
|
secrets: {},
|
|
allowInsecureTls: false,
|
|
lastProbeState: 'ok',
|
|
lastProbeError: null,
|
|
...overrides,
|
|
})
|
|
|
|
const link = (overrides: Record<string, unknown> = {}) => ({
|
|
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<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 }
|
|
}
|
|
|
|
/** 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 })
|
|
listScopesApi.mockResolvedValue({ scopes: [] })
|
|
resolveApi.mockResolvedValue({ success: true })
|
|
deleteConnectionApi.mockResolvedValue({ success: true })
|
|
})
|
|
|
|
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-025: carries the provider switch from the status route onto its binding', async () => {
|
|
listLinksApi.mockResolvedValue([link(), link({ id: 2 }), link({ id: 3 })])
|
|
statusApi.mockResolvedValue({ items: {}, links: [{ id: 1, providerOff: true }, { id: 2, providerOff: false }] })
|
|
|
|
const { result } = await mountLoaded()
|
|
|
|
// The third is one the status route never mentions: running, not paused.
|
|
expect(result.current.links.map(l => l.providerOff)).toEqual([true, false, false])
|
|
})
|
|
|
|
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-023: a reload that finds the addon gone drops the bindings it had loaded', async () => {
|
|
statusApi.mockResolvedValue({ items: { conflict: 1 }, links: [] })
|
|
const { result } = await mountLoaded()
|
|
expect(result.current.links).toHaveLength(1)
|
|
|
|
providersApi.mockRejectedValue(Object.assign(new Error('not found'), { response: { status: 404 } }))
|
|
await act(async () => { await result.current.load() })
|
|
|
|
expect(result.current.providers).toEqual([])
|
|
expect(result.current.connections).toEqual([])
|
|
expect(result.current.links).toEqual([])
|
|
expect(result.current.itemCounts).toEqual({})
|
|
expect(result.current.error).toBeNull()
|
|
})
|
|
|
|
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<unknown>()
|
|
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<unknown>()
|
|
listLinksApi.mockReturnValueOnce(gate.promise)
|
|
seenLoading.length = 0
|
|
|
|
let pending!: Promise<boolean>
|
|
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<unknown>()
|
|
updateLinkApi.mockReturnValueOnce(gate.promise)
|
|
|
|
let pending!: Promise<boolean>
|
|
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({ response: { data: { error: 'forbidden' } } })
|
|
listLinksApi.mockResolvedValueOnce([link({ direction: 'both' })])
|
|
|
|
let ok: boolean | undefined
|
|
await act(async () => { ok = await result.current.updateLink(1, { direction: 'pull' }) })
|
|
|
|
// Reported rather than thrown: both shells call this as `void`, so a
|
|
// rejection reached nobody. The reload puts the server's answer back and
|
|
// the reason stays on screen.
|
|
expect(ok).toBe(false)
|
|
expect(result.current.links[0].direction).toBe('both')
|
|
expect(result.current.error).toBe('forbidden')
|
|
})
|
|
})
|
|
|
|
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<unknown>()
|
|
testConnectionApi.mockReturnValueOnce(gate.promise)
|
|
|
|
let pending!: Promise<unknown>
|
|
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)
|
|
// What a run moved is no longer kept: the flow bar shows standing holdings,
|
|
// which is the question somebody opening the dialog actually has. The run's
|
|
// own numbers were only ever on screen for the moment after pressing it.
|
|
expect(result.current.links).toHaveLength(1)
|
|
})
|
|
|
|
it('FE-DOCSYNC-HOOK-017: a refused run says so instead of vanishing into a void call', async () => {
|
|
// syncNow is called as `void sync.syncNow(...)` from both shells, so a
|
|
// rejection that escaped the hook reached nobody. An orphaned binding
|
|
// answers 409 here, and the person pressing the button deserves the reason.
|
|
syncNowApi.mockRejectedValueOnce({ response: { data: { error: 'link_orphaned' } } })
|
|
const { result } = await mountLoaded()
|
|
|
|
let outcome: unknown
|
|
await act(async () => { outcome = await result.current.syncNow(1) })
|
|
|
|
expect(outcome).toBeNull()
|
|
expect(result.current.error).toBe('link_orphaned')
|
|
})
|
|
|
|
it('FE-DOCSYNC-HOOK-026: a run refused because an admin switched the provider off keeps the code', async () => {
|
|
// Both shells render the error as docsync.error.<code>, so the code has to
|
|
// arrive untouched for the sentence to be the right one.
|
|
syncNowApi.mockRejectedValueOnce({ response: { status: 409, data: { error: 'provider_disabled' } } })
|
|
const { result } = await mountLoaded()
|
|
|
|
await act(async () => { await result.current.syncNow(1) })
|
|
|
|
expect(result.current.error).toBe('provider_disabled')
|
|
})
|
|
|
|
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('useDocSync loadScopes', () => {
|
|
it('FE-DOCSYNC-HOOK-027: a request that fell over still produces a listing the picker can render', async () => {
|
|
// The picker awaits this inside an effect with nothing to catch, so a
|
|
// rejection never reached `setScopes` and the spinner ran forever. A store
|
|
// behind a VPN that is down answers this way every time.
|
|
listScopesApi.mockRejectedValueOnce({ code: 'ECONNABORTED', message: 'timeout of 60000ms exceeded' })
|
|
const { result } = await mountLoaded()
|
|
|
|
let listing: unknown
|
|
await act(async () => { listing = await result.current.loadScopes(9) })
|
|
|
|
expect(listing).toEqual({ scopes: [], error: 'unknown' })
|
|
})
|
|
|
|
it('FE-DOCSYNC-HOOK-028: a refusal with a code keeps the code, a sentence does not become a key', async () => {
|
|
const { result } = await mountLoaded()
|
|
let listing: { scopes: unknown[]; error?: string } | undefined
|
|
|
|
listScopesApi.mockRejectedValueOnce({ response: { status: 400, data: { error: 'unknown_provider' } } })
|
|
await act(async () => { listing = await result.current.loadScopes(9, 'tri') })
|
|
expect(listing?.error).toBe('unknown_provider')
|
|
expect(listScopesApi).toHaveBeenCalledWith(TRIP, 9, 'tri')
|
|
|
|
// The picker renders this as docsync.error.<value>, so prose must not
|
|
// arrive there as a translation key.
|
|
listScopesApi.mockRejectedValueOnce({ response: { status: 404, data: { message: 'Connection not found' } } })
|
|
await act(async () => { listing = await result.current.loadScopes(9) })
|
|
expect(listing?.error).toBe('unknown')
|
|
})
|
|
})
|
|
|
|
describe('useDocSync resolveConflict', () => {
|
|
it('FE-DOCSYNC-HOOK-029: a refused choice says why instead of vanishing into a void call', async () => {
|
|
// Both shells call this as `void conflicts.resolve(...)`. A member's choice
|
|
// answers 403 and a row settled elsewhere answers 400; either way the
|
|
// button came back and nothing said what happened.
|
|
resolveApi.mockRejectedValueOnce({ response: { status: 403, data: { error: 'forbidden' } } })
|
|
const { result } = await mountLoaded()
|
|
|
|
let outcome: boolean | undefined
|
|
await act(async () => { outcome = await result.current.resolveConflict(4, 'trek') })
|
|
|
|
expect(outcome).toBe(false)
|
|
expect(result.current.error).toBe('forbidden')
|
|
// reloaded, so the counts are the server's, and the message survived it
|
|
expect(listLinksApi).toHaveBeenCalledTimes(2)
|
|
|
|
await act(async () => { outcome = await result.current.resolveConflict(4, 'provider') })
|
|
|
|
expect(outcome).toBe(true)
|
|
expect(result.current.error).toBeNull()
|
|
expect(resolveApi).toHaveBeenLastCalledWith(TRIP, 4, 'provider')
|
|
})
|
|
})
|
|
|
|
describe('useDocSync removeConnection', () => {
|
|
it('FE-DOCSYNC-HOOK-031: forgets the connection and reloads, so its bindings leave the screen too', async () => {
|
|
const { result } = await mountLoaded()
|
|
expect(result.current.links).toHaveLength(1)
|
|
// The server drops the bindings with the connection (ON DELETE CASCADE).
|
|
listConnectionsApi.mockResolvedValue([])
|
|
listLinksApi.mockResolvedValue([])
|
|
|
|
let ok: boolean | undefined
|
|
await act(async () => { ok = await result.current.removeConnection(9) })
|
|
|
|
expect(ok).toBe(true)
|
|
expect(deleteConnectionApi).toHaveBeenCalledWith(TRIP, 9)
|
|
expect(result.current.connectionFor('nextcloud')).toBeNull()
|
|
expect(result.current.links).toEqual([])
|
|
expect(result.current.busy).toBeNull()
|
|
})
|
|
|
|
it('FE-DOCSYNC-HOOK-032: a refused removal keeps everything and says why', async () => {
|
|
deleteConnectionApi.mockRejectedValueOnce({ response: { status: 403, data: { error: 'forbidden' } } })
|
|
const { result } = await mountLoaded()
|
|
|
|
let ok: boolean | undefined
|
|
await act(async () => { ok = await result.current.removeConnection(9) })
|
|
|
|
expect(ok).toBe(false)
|
|
expect(result.current.error).toBe('forbidden')
|
|
expect(result.current.connectionFor('nextcloud')).not.toBeNull()
|
|
expect(result.current.busy).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('needsReauth', () => {
|
|
it('FE-DOCSYNC-HOOK-030: reads a refused credential off either the state or the error code', () => {
|
|
expect(needsReauth({ lastSyncState: 'needs_reauth', lastSyncError: null })).toBe(true)
|
|
expect(needsReauth({ lastSyncState: 'failed', lastSyncError: 'unauthorized' })).toBe(true)
|
|
expect(needsReauth({ lastSyncState: 'partial', lastSyncError: 'unauthorized' })).toBe(true)
|
|
expect(needsReauth({ lastSyncState: 'failed', lastSyncError: 'unreachable' })).toBe(false)
|
|
expect(needsReauth({ lastSyncState: 'ok', lastSyncError: null })).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('storeName', () => {
|
|
it('FE-DOCSYNC-HOOK-024: prefers the provider list, then the name on the link, then the raw id', () => {
|
|
const bound = link() as DocSyncLink
|
|
const listed = [provider({ name: 'Nextcloud Hub' }) as DocSyncProvider]
|
|
|
|
expect(storeName({ ...bound, providerName: 'Nextcloud' }, listed)).toBe('Nextcloud Hub')
|
|
// Switched off by an admin, so the providers route no longer lists it.
|
|
expect(storeName({ ...bound, providerName: 'Nextcloud' }, [])).toBe('Nextcloud')
|
|
expect(storeName(bound, [])).toBe('nextcloud')
|
|
})
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|