Files
readest/apps/readest-app/src/__tests__/services/webdav-connect-settings.test.ts
T
Huang Xin 99b9adfe85 refactor(sync): provider-agnostic file-sync engine with incremental WebDAV sync (#4784)
* refactor(sync): extract provider-agnostic layout paths

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): extract wire envelope module

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): extract pure merge module with law tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): add FileSyncProvider and LocalStore interfaces

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): FileSyncEngine orchestration over a provider

Port WebDAVSync's per-book + library-wide sync onto FileSyncProvider +
LocalStore. Behavior preserved; the #4756 metadata-reconciliation test is
retargeted to drive the engine through a fake provider + store.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): move WebDAV client + connect settings under providers/webdav

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): WebDAVProvider implementing FileSyncProvider

Wraps the WebDAV transport client, maps WebDAVRequestError to the neutral
FileSyncError, and owns Tauri streaming upload/download. Adds a
provider-conformance suite future backends can run against.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): shared appService-backed LocalStore bridge

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reader): drive WebDAV sync through FileSyncEngine

Construct a WebDAVProvider + shared LocalStore + engine once per hook; the
inline buffered/streaming book-file loader collapses into the provider +
store, so the hook no longer imports tauriUpload or the file path helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(settings): drive WebDAV library sync + browse through the provider

WebDAVForm now builds a WebDAVProvider + shared LocalStore + engine and calls
engine.syncLibrary; the ~170-line inline callback block (buffered/streaming
loaders, URL+auth construction) is gone. WebDAVBrowsePane builds a provider for
the engine-level deleteRemoteBookDir cleanup helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): remove WebDAV-specific sync module, WebDAV is now a provider

Delete src/services/webdav (WebDAVSync/WebDAVPaths + the transitional client
and connect-settings shims). The superseded webdav-metadata-sync test is
replaced by engine-metadata-sync; webdav-delete now drives deleteRemoteBookDir
through a WebDAVProvider and asserts FileSyncError.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sync): hydrate library before WebDAV Sync now to prevent clobber

Sync now while the library store was unloaded (app launched into reader/
settings without mounting the Library view) merged the engine's
addBookToLibrary / updateBookMetadata against an empty in-memory library,
persisting a downloaded book or a metadata update as the entire library and
wiping what was on disk. Pre-existing bug surfaced during the file-sync
review. Hydrate the store in handleSyncNow and harden the store bridge with a
load-if-unloaded guard (mirrors useLibraryStore.updateBooks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): make listDirectory honor the FileSyncError contract

listDirectory threw a plain Error (and let raw fetch failures escape), so
WebDAVProvider flattened every list() failure to FileSyncError(UNKNOWN). Throw
the same WebDAVRequestError taxonomy as the file-level helpers (AUTH_FAILED /
NOT_FOUND / NETWORK) so the provider maps them correctly. Add list() cases to
the provider-conformance suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(sync): cover streaming upload, discovery/download, and receive paths

The metadata-sync gate only exercised the buffered metadata + config-merge
paths. Add engine tests for streaming uploadStream (+ HEAD short-circuit +
one-shot retry), remote-only discovery -> streaming download -> addBook, and
the receive strategy (pull-only, no config or index writes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): incremental WebDAV Sync now + bounded concurrency

Sync now was a full walk of every book each run (675 round-trips even when
nothing changed). Default to incremental: diff the local library against the
shared library.json index per hash and only process books whose local copy is
newer (or absent). book.updatedAt bumps on every progress/notes/metadata save
(bookDataStore.saveConfig), so the index is a reliable per-book change marker.
Remote-newer books pull their config in the reconcile pass so peer progress
still propagates. A new 'Full Sync' toggle (default off) re-checks everything.

Also run the reconcile / download / push phases over a bounded worker pool
(default concurrency 4) instead of one book at a time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): simplify Sync now toast to a single book count

The completion toast built a multi-line success bullet list (downloaded /
pulled / pushed / uploaded). Replace it with the same single-line info toast
the native cloud sync uses: '{{count}} book(s) synced'. Add a booksSynced
counter to the engine result (a Set of distinct hashes touched in any
direction, since the per-action counters overlap under Full Sync). Failures
still surface as a warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ui): raise toasts above modals so they aren't hidden by open dialogs

Toasts rendered at z-50, below the Settings dialog (z-110) and ModalPortal
(z-120), so a toast dispatched from an open dialog (e.g. WebDAV 'Sync now')
was buried. The documented overlay scale already places toast at 130; the
component just hadn't followed it. Move the toast to z-[130] and extend the
zIndexScale invariant test to guard TOAST > MODAL/SETTINGS and APP_LOCK > TOAST.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:57:52 +02:00

88 lines
3.1 KiB
TypeScript

import { describe, test, expect } from 'vitest';
import { buildWebDAVConnectSettings } from '@/services/sync/providers/webdav/connectSettings';
import type { WebDAVSettings } from '@/types/settings';
describe('buildWebDAVConnectSettings', () => {
test('applies form fields onto a blank previous state', () => {
const result = buildWebDAVConnectSettings(undefined, {
serverUrl: ' https://dav.example.com ',
username: 'alice',
password: 'hunter2',
rootPath: '/Readest',
});
expect(result).toEqual({
enabled: true,
serverUrl: 'https://dav.example.com',
username: 'alice',
password: 'hunter2',
rootPath: '/Readest',
});
});
test('preserves prior bookkeeping fields across reconnect', () => {
// Simulates the disconnect → reconnect flow: the user previously
// synced (deviceId minted, syncBooks toggled on), disabled WebDAV,
// and is now reconnecting with the same credentials.
const previous: WebDAVSettings = {
enabled: false,
serverUrl: 'https://dav.example.com',
username: 'alice',
password: 'hunter2',
rootPath: '/Readest',
syncProgress: true,
syncNotes: true,
syncBooks: true,
strategy: 'send',
deviceId: 'device-uuid-9f3c',
lastSyncedAt: 1_700_000_001_500,
};
const next = buildWebDAVConnectSettings(previous, {
serverUrl: 'https://dav.example.com',
username: 'alice',
password: 'hunter2',
rootPath: '/Readest',
});
expect(next.enabled).toBe(true);
// Stable per-device id MUST survive — losing it makes the next sync
// look like a brand-new device and breaks cross-device clobber
// detection in `RemoteBookConfig.writerDeviceId`.
expect(next.deviceId).toBe('device-uuid-9f3c');
expect(next.syncBooks).toBe(true);
expect(next.strategy).toBe('send');
expect(next.syncProgress).toBe(true);
expect(next.syncNotes).toBe(true);
expect(next.lastSyncedAt).toBe(1_700_000_001_500);
});
test('updates the credentials when the user reconnects to a different account', () => {
const previous: WebDAVSettings = {
enabled: false,
serverUrl: 'https://old.example.com',
username: 'alice',
password: 'old-pw',
rootPath: '/Old',
deviceId: 'device-keep',
syncBooks: false,
};
const next = buildWebDAVConnectSettings(previous, {
serverUrl: 'https://new.example.com/',
username: 'bob',
password: 'new-pw',
rootPath: '/New',
});
expect(next.serverUrl).toBe('https://new.example.com/');
expect(next.username).toBe('bob');
expect(next.password).toBe('new-pw');
expect(next.rootPath).toBe('/New');
// The deviceId is intentionally NOT rotated even when the user
// reconnects to a different server/account: it identifies the
// physical device, not the remote account. A user moving between
// self-hosted instances still wants their device to be recognised
// by whichever server it's currently talking to.
expect(next.deviceId).toBe('device-keep');
expect(next.syncBooks).toBe(false);
});
});