99b9adfe85
* 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>
88 lines
3.1 KiB
TypeScript
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);
|
|
});
|
|
});
|