mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +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
454 lines
17 KiB
TypeScript
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()
|
|
})
|
|
})
|