Files
TREK/client/src/components/Files/docsync/DocSyncBinding.test.tsx
T
Maurice 9e216f5ae4 fix(docsync): let the trip owner edit or remove a stored document-store connection
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
2026-10-27 14:45:17 +01:00

454 lines
17 KiB
TypeScript

// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-032
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent, waitFor, within } from '../../../../tests/helpers/render'
import DocSyncBinding from './DocSyncBinding'
import type { DocSyncLink, useDocSync } from './useDocSync'
type Sync = ReturnType<typeof useDocSync>
const syncNow = vi.fn(async (_linkId: number) => ({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 }))
const updateLink = vi.fn(async (_linkId: number, _patch: Record<string, unknown>) => {})
const removeLink = vi.fn(async (_linkId: number) => {})
const WEBHOOK = 'https://trek.example/api/trips/3/docsync/hooks/abc123'
function makeLink(overrides: Partial<DocSyncLink> = {}): DocSyncLink {
return {
id: 4,
connectionId: 1,
providerId: 'paperless',
scopeKey: 'trek-trip-3',
remoteLabel: 'Lisbon papers',
remoteRootPath: '/TREK/Lisbon',
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
lastSyncAt: null,
lastSyncState: 'ok',
lastSyncError: null,
webhookUrl: null,
holdings: { inTrek: 3, atProvider: 2, paired: 2, missing: 1 },
...overrides,
}
}
function makeSync(overrides: Partial<Sync> = {}): Sync {
return {
providers: [],
connections: [],
links: [],
itemCounts: {},
loading: false,
busy: null,
error: null,
connectionFor: () => null,
load: vi.fn(),
saveConnection: vi.fn(),
testConnection: vi.fn(),
loadScopes: vi.fn(),
createScope: vi.fn(),
createLink: vi.fn(),
updateLink,
removeLink,
syncNow,
resolveConflict: vi.fn(),
lastRunFor: () => ({ pulled: 0, pushed: 0 }),
...overrides,
} as unknown as Sync
}
function renderCard(
opts: {
link?: Partial<DocSyncLink>
sync?: Partial<Sync>
canManage?: boolean
providerName?: string
onReconnect?: () => void
onEditConnection?: () => void
} = {},
) {
const utils = render(
<DocSyncBinding
link={makeLink(opts.link)}
providerName={opts.providerName ?? 'Paperless'}
sync={makeSync(opts.sync)}
canManage={opts.canManage ?? true}
onReconnect={opts.onReconnect}
onEditConnection={opts.onEditConnection}
/>,
)
const header = utils.container.querySelector('header') as HTMLElement
return { ...utils, header }
}
const openSettings = () => fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
const rowFor = (label: string) => screen.getByText(label).closest('div') as HTMLElement
const UNLINK_QUESTION = 'Documents stay in TREK and at the store. Only the pairing between them goes.'
/** The confirmation the disconnect button opens; it renders in a portal. */
const unlinkDialog = () => within(screen.getByText(UNLINK_QUESTION).closest('.trek-modal-enter') as HTMLElement)
/** Named through the same fallback chain `t()` uses, so the label can land in the locales later. */
const reconnectButton = () =>
screen.queryByRole('button', { name: (en as unknown as Record<string, string>)['docsync.binding.reconnect'] ?? 'docsync.binding.reconnect' })
beforeEach(() => {
vi.clearAllMocks()
})
describe('DocSyncBinding: running a sync', () => {
it('FE-DOCSYNC-BIND-001: "Sync now" asks the hook to run this binding', () => {
renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Sync now' }))
expect(syncNow).toHaveBeenCalledWith(4)
})
it('FE-DOCSYNC-BIND-002: while this binding runs the button reads "Syncing" and refuses another click', () => {
renderCard({ sync: { busy: 'sync-4' } })
const button = screen.getByRole('button', { name: 'Syncing' })
expect(button).toBeDisabled()
expect(screen.queryByRole('button', { name: 'Sync now' })).not.toBeInTheDocument()
fireEvent.click(button)
expect(syncNow).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-BIND-003: a run on another binding leaves this card ready', () => {
renderCard({ sync: { busy: 'sync-9' } })
expect(screen.getByRole('button', { name: 'Sync now' })).toBeEnabled()
})
it('FE-DOCSYNC-BIND-004: the disconnect button asks first and unlinks once that is confirmed', () => {
// As the phone does. The button sits beside "Sync now", and a binding
// cannot be put back: rebinding the same folder starts from nothing.
renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }))
expect(removeLink).not.toHaveBeenCalled()
expect(screen.getByText(UNLINK_QUESTION)).toBeInTheDocument()
fireEvent.click(unlinkDialog().getByRole('button', { name: 'Disconnect' }))
expect(removeLink).toHaveBeenCalledWith(4)
expect(screen.queryByText(UNLINK_QUESTION)).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-027: cancelling the question leaves the binding as it was', () => {
renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }))
fireEvent.click(unlinkDialog().getByRole('button', { name: 'Cancel' }))
expect(removeLink).not.toHaveBeenCalled()
expect(screen.queryByText(UNLINK_QUESTION)).not.toBeInTheDocument()
})
})
describe('DocSyncBinding: a refused credential', () => {
it('FE-DOCSYNC-BIND-028: offers to sign in again beside the notice, and hands the click to the panel', () => {
// The sidebar opens the credential form only for a store with no
// connection yet, so a rotated token had no way back in from the app.
const onReconnect = vi.fn()
renderCard({ link: { lastSyncState: 'needs_reauth', lastSyncError: 'unauthorized' }, onReconnect })
expect(screen.getByText('Sign in again · The credentials were refused.')).toBeInTheDocument()
fireEvent.click(reconnectButton() as HTMLElement)
expect(onReconnect).toHaveBeenCalledTimes(1)
})
it('FE-DOCSYNC-BIND-029: a refusal recorded as a failed run offers it too', () => {
const onReconnect = vi.fn()
renderCard({ link: { lastSyncState: 'failed', lastSyncError: 'unauthorized' }, onReconnect })
expect(reconnectButton()).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-030: is not offered to a member, for another failure, or while the provider is switched off', () => {
const onReconnect = vi.fn()
const refused = { lastSyncState: 'needs_reauth', lastSyncError: 'unauthorized' } as const
const member = renderCard({ link: refused, onReconnect, canManage: false })
expect(reconnectButton()).not.toBeInTheDocument()
member.unmount()
const other = renderCard({ link: { lastSyncState: 'failed', lastSyncError: 'unreachable' }, onReconnect })
expect(reconnectButton()).not.toBeInTheDocument()
other.unmount()
// An admin has switched the store off: the paused notice stands in front,
// and a new credential would change nothing until it is back on.
const paused = renderCard({ link: { ...refused, providerOff: true }, onReconnect })
expect(reconnectButton()).not.toBeInTheDocument()
paused.unmount()
// The panel could not find the provider's form: nothing to open.
renderCard({ link: refused })
expect(reconnectButton()).not.toBeInTheDocument()
expect(onReconnect).not.toHaveBeenCalled()
})
})
describe('DocSyncBinding: what it says about itself', () => {
it('FE-DOCSYNC-BIND-005: a healthy binding shows its state as a dot, not as a word', () => {
renderCard()
expect(screen.queryByText('In sync')).not.toBeInTheDocument()
const dot = screen.getByRole('img', { name: 'In sync' })
expect(dot).toBeInTheDocument()
expect(dot).toBeEmptyDOMElement()
})
it('FE-DOCSYNC-BIND-006: a failing binding spells the state out and appends the reason', () => {
renderCard({ link: { lastSyncState: 'failed', lastSyncError: 'unauthorized' } })
expect(screen.getByText('Failed')).toBeInTheDocument()
expect(screen.getByText('Failed · The credentials were refused.')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-007: a failing binding without a reason code shows the state alone', () => {
renderCard({ link: { lastSyncState: 'failed', lastSyncError: null } })
// once as the header badge, once as the banner underneath it
expect(screen.getAllByText('Failed')).toHaveLength(2)
})
it('FE-DOCSYNC-BIND-008: a partly synced binding also gets the banner', () => {
renderCard({ link: { lastSyncState: 'partial', lastSyncError: 'too_large' } })
expect(screen.getByText('Partly synced · The file is larger than the provider accepts.')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-009: a binding that never ran names its state but raises no alarm', () => {
renderCard({ link: { lastSyncState: 'never', lastSyncAt: null } })
expect(screen.getAllByText('Not synced yet')).toHaveLength(1)
expect(screen.getByText('not run yet')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-026: a binding whose provider an admin switched off says it is paused, not what the last run said', () => {
// The server leaves the binding's own state as the last run wrote it, so
// without this the card went on reporting an old failure, or nothing.
renderCard({ link: { providerOff: true, lastSyncState: 'failed', lastSyncError: 'unauthorized' } })
expect(screen.getByText('Paused: an administrator has switched this provider off. Syncing resumes once it is back on.')).toBeInTheDocument()
expect(screen.queryByText('Failed · The credentials were refused.')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-010: a paused binding is badged, an active one is not', () => {
const { unmount } = renderCard({ link: { syncEnabled: false } })
expect(screen.getByText('Paused')).toBeInTheDocument()
unmount()
renderCard({ link: { syncEnabled: true } })
expect(screen.queryByText('Paused')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-011: the last run is shown as elapsed time once there is one', () => {
renderCard({ link: { lastSyncAt: new Date(Date.now() - 2 * 3600_000).toISOString() } })
expect(screen.getByText(/hours ago/)).toBeInTheDocument()
expect(screen.queryByText('not run yet')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-012: the folder falls back to the path, then to the scope key', () => {
const { unmount } = renderCard({ link: { remoteLabel: '' } })
expect(screen.getByText('/TREK/Lisbon')).toBeInTheDocument()
unmount()
renderCard({ link: { remoteLabel: '', remoteRootPath: null } })
expect(screen.getByText('trek-trip-3')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-013: names the store it is handed in the header and at the far end of the flow', () => {
const { header } = renderCard({ providerName: 'Paperless-ngx' })
expect(within(header).getByText('Paperless-ngx')).toBeInTheDocument()
expect(screen.getAllByText('Paperless-ngx')).toHaveLength(2)
expect(screen.queryByText('paperless')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-014: the flow bar gets the holdings, and zeros when there are none', () => {
const { unmount } = renderCard()
expect(screen.getByText('3')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
unmount()
renderCard({ link: { holdings: undefined } })
expect(screen.getAllByText('0')).toHaveLength(2)
})
})
describe('DocSyncBinding: the settings section', () => {
it('FE-DOCSYNC-BIND-015: the settings stay folded away until they are opened', () => {
renderCard()
const toggle = screen.getByRole('button', { name: 'Settings' })
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Sync automatically')).not.toBeInTheDocument()
expect(screen.queryByText('When a document is deleted')).not.toBeInTheDocument()
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Sync automatically')).toBeInTheDocument()
expect(screen.getByText('When a document is deleted')).toBeInTheDocument()
fireEvent.click(toggle)
expect(screen.queryByText('Sync automatically')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-016: a member gets no settings section and no disconnect button', () => {
renderCard({ canManage: false })
expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument()
expect(screen.queryByText('Sync automatically')).not.toBeInTheDocument()
expect(screen.queryByText('When a document is deleted')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Disconnect' })).not.toBeInTheDocument()
// a member may still trigger a run
expect(screen.getByRole('button', { name: 'Sync now' })).toBeEnabled()
})
it('FE-DOCSYNC-BIND-017: turning "sync automatically" off patches the link', () => {
renderCard()
openSettings()
fireEvent.click(within(rowFor('Sync automatically')).getByRole('button'))
expect(updateLink).toHaveBeenCalledWith(4, { syncEnabled: false })
})
it('FE-DOCSYNC-BIND-018: turning it back on patches the other way', () => {
renderCard({ link: { syncEnabled: false } })
openSettings()
const toggle = within(rowFor('Sync automatically')).getByRole('button')
expect(toggle).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(toggle)
expect(updateLink).toHaveBeenCalledWith(4, { syncEnabled: true })
})
it('FE-DOCSYNC-BIND-019: choosing another delete policy patches the link', () => {
renderCard()
openSettings()
const trigger = within(rowFor('When a document is deleted')).getByRole('button')
expect(trigger).toHaveTextContent('Keep both copies')
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: 'Move to recycle bin' }))
expect(updateLink).toHaveBeenCalledWith(4, { deletePolicy: 'trash' })
})
})
describe('DocSyncBinding: the webhook URL', () => {
const realClipboard = navigator.clipboard
afterEach(() => {
Object.defineProperty(navigator, 'clipboard', { value: realClipboard, configurable: true, writable: true })
})
const stubClipboard = (writeText: (text: string) => Promise<void>) => {
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true })
}
it('FE-DOCSYNC-BIND-020: a binding without a webhook shows no webhook section', () => {
renderCard({ link: { webhookUrl: null } })
openSettings()
expect(screen.queryByText('Instant updates')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Copy' })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-021: a binding with a webhook shows the URL for pasting', () => {
renderCard({ link: { webhookUrl: WEBHOOK } })
openSettings()
expect(screen.getByText('Instant updates')).toBeInTheDocument()
expect(screen.getByText(WEBHOOK)).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-022: copying the webhook puts it on the clipboard and confirms', async () => {
const writeText = vi.fn(async (_text: string) => {})
stubClipboard(writeText)
renderCard({ link: { webhookUrl: WEBHOOK } })
openSettings()
fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
await waitFor(() => expect(writeText).toHaveBeenCalledWith(WEBHOOK))
expect(await screen.findByRole('button', { name: 'Copied' })).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-023: a blocked clipboard leaves the URL on screen and the button unchanged', async () => {
const writeText = vi.fn(async (_text: string) => { throw new Error('not allowed') })
stubClipboard(writeText)
renderCard({ link: { webhookUrl: WEBHOOK } })
openSettings()
fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
await waitFor(() => expect(writeText).toHaveBeenCalled())
expect(screen.queryByRole('button', { name: 'Copied' })).not.toBeInTheDocument()
expect(screen.getByText(WEBHOOK)).toBeInTheDocument()
})
})
describe('DocSyncBinding: the direction lanes', () => {
it('FE-DOCSYNC-BIND-024: switching a lane off patches the direction', () => {
renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Out to the store' }))
expect(updateLink).toHaveBeenCalledWith(4, { direction: 'pull' })
})
it('FE-DOCSYNC-BIND-025: a member cannot change the direction', () => {
renderCard({ canManage: false })
const lane = screen.getByRole('button', { name: 'Out to the store' })
expect(lane).toBeDisabled()
fireEvent.click(lane)
expect(updateLink).not.toHaveBeenCalled()
})
})
describe('DocSyncBinding: the connection behind it', () => {
const S = en as unknown as Record<string, string>
const editButton = () => screen.queryByRole('button', { name: S['docsync.connection.edit'] })
it('FE-DOCSYNC-BIND-031: the settings open the stored connection at any time, not only after a refusal', () => {
const onEditConnection = vi.fn()
renderCard({ link: { lastSyncState: 'ok' }, onEditConnection })
expect(editButton()).not.toBeInTheDocument()
openSettings()
expect(screen.getByText(S['docsync.connection.title'])).toBeInTheDocument()
fireEvent.click(editButton() as HTMLElement)
expect(onEditConnection).toHaveBeenCalledTimes(1)
})
it('FE-DOCSYNC-BIND-032: a member, or a panel without the form, gets no way to the connection', () => {
const member = renderCard({ canManage: false, onEditConnection: vi.fn() })
expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument()
expect(editButton()).not.toBeInTheDocument()
member.unmount()
renderCard()
openSettings()
expect(editButton()).not.toBeInTheDocument()
})
})