diff --git a/apps/readest-app/src/__tests__/components/settings/cloudSync.test.ts b/apps/readest-app/src/__tests__/components/settings/cloudSync.test.ts index dcf36d87..cc09b521 100644 --- a/apps/readest-app/src/__tests__/components/settings/cloudSync.test.ts +++ b/apps/readest-app/src/__tests__/components/settings/cloudSync.test.ts @@ -32,6 +32,46 @@ describe('withActiveCloudProvider', () => { expect(next.webdav.serverUrl).toBe('https://dav'); expect(next.googleDrive.accountLabel).toBe('a@b.com'); }); + + // Selecting a third-party provider hands it the book-file channel: + // native Readest Cloud uploads gate off, so without syncBooks the books + // would back up nowhere. Activation therefore turns syncBooks on. + describe('syncBooks auto-enable on activation', () => { + const inactive = { + webdav: { enabled: false, serverUrl: 'https://dav', syncBooks: false }, + googleDrive: { enabled: false, syncBooks: false }, + } as unknown as SystemSettings; + + test('activating a disabled provider turns its syncBooks on', () => { + const next = withActiveCloudProvider(inactive, 'webdav'); + expect(next.webdav.syncBooks).toBe(true); + expect(next.googleDrive.syncBooks).toBe(false); + }); + + test('activating gdrive turns only gdrive syncBooks on', () => { + const next = withActiveCloudProvider(inactive, 'gdrive'); + expect(next.googleDrive.syncBooks).toBe(true); + expect(next.webdav.syncBooks).toBe(false); + }); + + test('re-activating an already-active provider respects an explicit syncBooks opt-out', () => { + const active = { + webdav: { enabled: true, syncBooks: false }, + googleDrive: { enabled: false }, + } as unknown as SystemSettings; + const next = withActiveCloudProvider(active, 'webdav'); + expect(next.webdav.syncBooks).toBe(false); + }); + + test('deactivating a provider leaves its syncBooks untouched', () => { + const active = { + webdav: { enabled: true, syncBooks: true }, + googleDrive: { enabled: false, syncBooks: false }, + } as unknown as SystemSettings; + const next = withActiveCloudProvider(active, null); + expect(next.webdav.syncBooks).toBe(true); + }); + }); }); describe('isCloudSyncInPlan', () => { diff --git a/apps/readest-app/src/__tests__/services/backup-settings.test.ts b/apps/readest-app/src/__tests__/services/backup-settings.test.ts index f238a203..c8095779 100644 --- a/apps/readest-app/src/__tests__/services/backup-settings.test.ts +++ b/apps/readest-app/src/__tests__/services/backup-settings.test.ts @@ -59,6 +59,15 @@ function makeSettings(overrides: Partial = {}): SystemSettings { checksumMethod: 'binary', strategy: 'prompt', }, + webdav: { + enabled: true, + serverUrl: 'https://dav.example', + username: 'wuser', + password: 'wpass', + rootPath: '/', + deviceId: 'webdav-device-id', + lastSyncedAt: 666, + }, readwise: { enabled: true, accessToken: 'rw-token', lastSyncedAt: 999 }, hardcover: { enabled: false, accessToken: 'hc-token', lastSyncedAt: 888 }, googleDrive: { @@ -104,6 +113,11 @@ describe('sanitizeSettingsForBackup - blacklist', () => { expect(rec(out['googleDrive'])['deviceId']).toBeUndefined(); // Non-identity Drive settings still travel with the backup. expect(rec(out['googleDrive'])['enabled']).toBe(true); + // WebDAV device identity and cursor stay on the device; restoring + // them onto a second device would duplicate WebDAV sync identity. + expect(rec(out['webdav'])['deviceId']).toBeUndefined(); + expect(rec(out['webdav'])['lastSyncedAt']).toBeUndefined(); + expect(rec(out['webdav'])['serverUrl']).toBe('https://dav.example'); }); it('strips sync cursors', () => { diff --git a/apps/readest-app/src/__tests__/services/sync/cloudSyncProvider.test.ts b/apps/readest-app/src/__tests__/services/sync/cloudSyncProvider.test.ts new file mode 100644 index 00000000..1e6b1ff2 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/cloudSyncProvider.test.ts @@ -0,0 +1,158 @@ +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import type { SystemSettings } from '@/types/settings'; + +vi.mock('@/utils/access', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isCloudSyncAllowed: vi.fn(actual.isCloudSyncAllowed), + }; +}); + +import { + applySyncBooksAutoEnable, + getCloudSyncProvider, + isReadestCloudStorageActive, + resolveCloudSyncGate, + setCachedUserPlan, +} from '@/services/sync/cloudSyncProvider'; +import { isCloudSyncAllowed } from '@/utils/access'; + +const makeSettings = (overrides: Partial = {}): SystemSettings => + ({ + webdav: { enabled: false }, + googleDrive: { enabled: false }, + ...overrides, + }) as SystemSettings; + +beforeEach(() => { + vi.mocked(isCloudSyncAllowed).mockReturnValue(true); + setCachedUserPlan('free'); +}); + +describe('getCloudSyncProvider', () => { + test('derives readest when no third-party provider is enabled', () => { + expect(getCloudSyncProvider(makeSettings())).toBe('readest'); + }); + + test('derives webdav when webdav is enabled', () => { + const settings = makeSettings({ webdav: { enabled: true } } as Partial); + expect(getCloudSyncProvider(settings)).toBe('webdav'); + }); + + test('derives gdrive when google drive is enabled', () => { + const settings = makeSettings({ googleDrive: { enabled: true } } as Partial); + expect(getCloudSyncProvider(settings)).toBe('gdrive'); + }); + + test('webdav wins deterministically when both flags are enabled (corrupt state)', () => { + const settings = makeSettings({ + webdav: { enabled: true }, + googleDrive: { enabled: true }, + } as Partial); + expect(getCloudSyncProvider(settings)).toBe('webdav'); + }); + + test('defaults to readest for missing slices and missing settings', () => { + expect(getCloudSyncProvider({} as SystemSettings)).toBe('readest'); + expect(getCloudSyncProvider(null)).toBe('readest'); + expect(getCloudSyncProvider(undefined)).toBe('readest'); + }); +}); + +describe('resolveCloudSyncGate', () => { + test('readest provider is never paused, even when cloud sync is disallowed', () => { + vi.mocked(isCloudSyncAllowed).mockReturnValue(false); + expect(resolveCloudSyncGate(makeSettings(), 'free')).toEqual({ + provider: 'readest', + paused: false, + }); + }); + + test('third-party provider stays selected but paused when disallowed (no silent readest fallback)', () => { + vi.mocked(isCloudSyncAllowed).mockReturnValue(false); + const settings = makeSettings({ webdav: { enabled: true } } as Partial); + expect(resolveCloudSyncGate(settings, 'free')).toEqual({ provider: 'webdav', paused: true }); + }); + + test('third-party provider is active when allowed', () => { + const settings = makeSettings({ googleDrive: { enabled: true } } as Partial); + expect(resolveCloudSyncGate(settings, 'plus')).toEqual({ provider: 'gdrive', paused: false }); + }); + + test('falls back to the cached user plan when no plan argument is given', () => { + vi.mocked(isCloudSyncAllowed).mockImplementation((plan) => plan !== 'free'); + const settings = makeSettings({ webdav: { enabled: true } } as Partial); + + setCachedUserPlan('free'); + expect(resolveCloudSyncGate(settings).paused).toBe(true); + + setCachedUserPlan('pro'); + expect(resolveCloudSyncGate(settings).paused).toBe(false); + }); + + test('undefined cached plan is treated as free', () => { + vi.mocked(isCloudSyncAllowed).mockImplementation((plan) => plan !== 'free'); + const settings = makeSettings({ webdav: { enabled: true } } as Partial); + setCachedUserPlan(undefined); + expect(resolveCloudSyncGate(settings).paused).toBe(true); + }); +}); + +describe('applySyncBooksAutoEnable (upgrade migration for already-enabled providers)', () => { + test('flips syncBooks on for an enabled webdav provider, mutating the given settings', () => { + const settings = makeSettings({ + webdav: { enabled: true, syncBooks: false }, + } as Partial); + expect(applySyncBooksAutoEnable(settings)).toBe(true); + expect(settings.webdav?.syncBooks).toBe(true); + }); + + test('flips syncBooks on for an enabled gdrive provider', () => { + const settings = makeSettings({ + googleDrive: { enabled: true, syncBooks: false }, + } as Partial); + expect(applySyncBooksAutoEnable(settings)).toBe(true); + expect(settings.googleDrive?.syncBooks).toBe(true); + }); + + test('no-op when readest is the provider', () => { + const settings = makeSettings(); + expect(applySyncBooksAutoEnable(settings)).toBe(false); + expect(settings.webdav?.syncBooks).toBeUndefined(); + }); + + test('no-op when syncBooks is already on', () => { + const settings = makeSettings({ + webdav: { enabled: true, syncBooks: true }, + } as Partial); + expect(applySyncBooksAutoEnable(settings)).toBe(false); + }); + + test('only the selected provider is flipped when both are enabled', () => { + const settings = makeSettings({ + webdav: { enabled: true, syncBooks: false }, + googleDrive: { enabled: true, syncBooks: false }, + } as Partial); + expect(applySyncBooksAutoEnable(settings)).toBe(true); + expect(settings.webdav?.syncBooks).toBe(true); + expect(settings.googleDrive?.syncBooks).toBe(false); + }); +}); + +describe('isReadestCloudStorageActive', () => { + test('true when readest is the derived provider', () => { + expect(isReadestCloudStorageActive(makeSettings())).toBe(true); + }); + + test('false when a third-party provider is selected', () => { + const settings = makeSettings({ webdav: { enabled: true } } as Partial); + expect(isReadestCloudStorageActive(settings)).toBe(false); + }); + + test('false while paused: uploads must not silently resume to Readest Cloud', () => { + vi.mocked(isCloudSyncAllowed).mockReturnValue(false); + const settings = makeSettings({ googleDrive: { enabled: true } } as Partial); + expect(isReadestCloudStorageActive(settings, 'free')).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/transfer-manager-gating.test.ts b/apps/readest-app/src/__tests__/services/transfer-manager-gating.test.ts new file mode 100644 index 00000000..e3d49d2a --- /dev/null +++ b/apps/readest-app/src/__tests__/services/transfer-manager-gating.test.ts @@ -0,0 +1,445 @@ +import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; +import { useTransferStore, TransferItem } from '@/store/transferStore'; +import { useSettingsStore } from '@/store/settingsStore'; +import type { SystemSettings } from '@/types/settings'; + +vi.mock('@/utils/event', () => ({ + eventDispatcher: { + dispatch: vi.fn(), + dispatchSync: vi.fn(), + }, +})); + +import { transferManager } from '@/services/transferManager'; +import { eventDispatcher } from '@/utils/event'; +import type { Book } from '@/types/book'; + +function makeBook(overrides: Partial = {}): Book { + return { + hash: 'hash1', + format: 'EPUB', + title: 'Test Book', + author: 'Author', + createdAt: 1000, + updatedAt: 2000, + ...overrides, + }; +} + +function makeTransferItem(overrides: Partial = {}): TransferItem { + return { + id: 't1', + kind: 'book', + bookHash: 'hash1', + bookTitle: 'Test Book', + type: 'upload', + status: 'pending', + progress: 0, + totalBytes: 0, + transferredBytes: 0, + transferSpeed: 0, + retryCount: 0, + maxRetries: 3, + createdAt: Date.now(), + priority: 10, + isBackground: false, + ...overrides, + }; +} + +const resetTransferManager = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only introspection + const mgr = transferManager as unknown as Record; + mgr['isInitialized'] = false; + mgr['isProcessing'] = false; + mgr['appService'] = null; + mgr['getLibrary'] = null; + mgr['updateBook'] = null; + mgr['_'] = null; + (mgr['abortControllers'] as Map).clear(); + let resolveReady: () => void = () => {}; + mgr['readyPromise'] = new Promise((res) => { + resolveReady = res; + }); + mgr['readyResolve'] = resolveReady; +}; + +const resetTransferStore = () => { + useTransferStore.setState({ + transfers: {}, + isQueuePaused: false, + isTransferQueueOpen: false, + maxConcurrent: 2, + activeCount: 0, + }); +}; + +const settingsLoaded = (overrides: Partial = {}): void => { + useSettingsStore.setState({ + settings: { + version: 1, + webdav: { enabled: false }, + googleDrive: { enabled: false }, + ...overrides, + } as SystemSettings, + }); +}; + +const settingsNotLoaded = (): void => { + useSettingsStore.setState({ settings: {} as SystemSettings }); +}; + +const webdavSelected = (): void => + settingsLoaded({ webdav: { enabled: true } } as Partial); + +function makeAppService(overrides: Record = {}) { + return { + uploadBook: vi.fn().mockResolvedValue(undefined), + downloadBook: vi.fn().mockResolvedValue(undefined), + deleteBook: vi.fn().mockResolvedValue(undefined), + uploadReplicaFile: vi.fn().mockResolvedValue(undefined), + downloadReplicaFile: vi.fn().mockResolvedValue(undefined), + deleteReplicaBundle: vi.fn().mockResolvedValue(undefined), + isMacOSApp: false, + ...overrides, + } as Record; +} + +const translationFn = (key: string, params?: Record) => { + if (params) { + return Object.entries(params).reduce((acc, [k, v]) => acc.replace(`{{${k}}}`, String(v)), key); + } + return key; +}; + +const initManager = async (appService = makeAppService(), library: Book[] = []) => { + await transferManager.initialize(appService as never, () => library, vi.fn(), translationFn); + return appService; +}; + +const flushAsync = async (ms = 5000) => { + await vi.advanceTimersByTimeAsync(ms); +}; + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + resetTransferStore(); + resetTransferManager(); + vi.clearAllMocks(); + localStorage.clear(); + settingsLoaded(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'info').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('provider gating of book uploads', () => { + test('queueUpload returns null and queues nothing when a third-party provider is selected', async () => { + webdavSelected(); + await initManager(); + + const id = transferManager.queueUpload(makeBook()); + expect(id).toBeNull(); + expect(Object.keys(useTransferStore.getState().transfers)).toHaveLength(0); + }); + + test('queueUpload works when Readest Cloud is the provider', async () => { + await initManager(); + + const id = transferManager.queueUpload(makeBook()); + expect(id).toBeTruthy(); + }); + + test('queueBatchUploads returns empty when gated', async () => { + webdavSelected(); + await initManager(); + + const ids = transferManager.queueBatchUploads([makeBook(), makeBook({ hash: 'hash2' })]); + expect(ids).toEqual([]); + }); + + test('downloads and replica transfers are never gated', async () => { + webdavSelected(); + await initManager(); + + const downloadId = transferManager.queueDownload(makeBook()); + expect(downloadId).toBeTruthy(); + + const replicaId = transferManager.queueReplicaUpload( + 'font', + 'font-1', + 'A Font', + [{ logical: 'font.ttf', lfp: '/tmp/font.ttf', byteSize: 10 }], + 'Data' as never, + ); + expect(replicaId).toBeTruthy(); + }); +}); + +describe('settings-loaded barrier', () => { + test('a pending book upload does not execute before settings hydration', async () => { + settingsNotLoaded(); + const persisted = { + transfers: { t1: makeTransferItem() }, + isQueuePaused: false, + }; + localStorage.setItem('readest_transfer_queue', JSON.stringify(persisted)); + + const appService = makeAppService(); + await initManager(appService, [makeBook()]); + await flushAsync(); + + expect(appService['uploadBook']).not.toHaveBeenCalled(); + expect(useTransferStore.getState().transfers['t1']?.status).toBe('pending'); + }); + + test('the deferred upload executes once settings hydrate with readest selected', async () => { + settingsNotLoaded(); + localStorage.setItem( + 'readest_transfer_queue', + JSON.stringify({ transfers: { t1: makeTransferItem() }, isQueuePaused: false }), + ); + + const appService = makeAppService(); + await initManager(appService, [makeBook()]); + await flushAsync(); + expect(appService['uploadBook']).not.toHaveBeenCalled(); + + settingsLoaded(); + await flushAsync(); + + expect(appService['uploadBook']).toHaveBeenCalledTimes(1); + }); + + test('replica transfers are not stalled by the barrier', async () => { + settingsNotLoaded(); + const appService = makeAppService(); + await initManager(appService); + + transferManager.queueReplicaDownload( + 'font', + 'font-1', + 'A Font', + [{ logical: 'font.ttf', lfp: '/tmp/font.ttf', byteSize: 10 }], + 'Data' as never, + ); + await flushAsync(); + + expect(appService['downloadReplicaFile']).toHaveBeenCalled(); + }); +}); + +describe('policy cancellation on restore/reconcile', () => { + test('pre-switch pending book uploads are policy-cancelled on restore; downloads and replicas survive', async () => { + webdavSelected(); + const persisted = { + transfers: { + up1: makeTransferItem({ id: 'up1' }), + down1: makeTransferItem({ id: 'down1', type: 'download', bookHash: 'hash2' }), + rep1: makeTransferItem({ + id: 'rep1', + kind: 'replica', + type: 'upload', + bookHash: '', + replicaKind: 'font', + replicaId: 'font-1', + replicaFiles: [{ logical: 'font.ttf', lfp: '/tmp/font.ttf', byteSize: 10 }], + replicaBase: 'Data' as never, + }), + }, + isQueuePaused: true, + }; + localStorage.setItem('readest_transfer_queue', JSON.stringify(persisted)); + + await initManager(); + await flushAsync(); + + const transfers = useTransferStore.getState().transfers; + expect(transfers['up1']?.status).toBe('cancelled'); + expect(transfers['up1']?.cancelReason).toBe('policy'); + expect(transfers['down1']?.status).toBe('pending'); + expect(transfers['rep1']?.status).toBe('pending'); + }); + + test('switching providers after init policy-cancels pending book uploads', async () => { + await initManager(); + useTransferStore.getState().pauseQueue(); + const id = transferManager.queueUpload(makeBook())!; + + webdavSelected(); + await flushAsync(); + + const transfer = useTransferStore.getState().transfers[id]; + expect(transfer?.status).toBe('cancelled'); + expect(transfer?.cancelReason).toBe('policy'); + }); + + test('policy-cancelled rows from a previous session are pruned on restore', async () => { + const persisted = { + transfers: { + old1: makeTransferItem({ id: 'old1', status: 'cancelled', cancelReason: 'policy' }), + user1: makeTransferItem({ id: 'user1', status: 'cancelled' }), + }, + isQueuePaused: false, + }; + localStorage.setItem('readest_transfer_queue', JSON.stringify(persisted)); + + await initManager(); + + const transfers = useTransferStore.getState().transfers; + expect(transfers['old1']).toBeUndefined(); + expect(transfers['user1']).toBeDefined(); + }); + + test('legacy persisted entries without kind or cancelReason restore cleanly', async () => { + const legacy = makeTransferItem({ id: 'l1', status: 'completed' }) as Partial; + delete legacy.kind; + localStorage.setItem( + 'readest_transfer_queue', + JSON.stringify({ transfers: { l1: legacy }, isQueuePaused: false }), + ); + + await initManager(); + + const restored = useTransferStore.getState().transfers['l1']; + expect(restored?.kind).toBe('book'); + expect(restored?.status).toBe('completed'); + }); +}); + +describe('cancelled bucket accounting', () => { + test('policy-cancelled rows are excluded from failed stats and getFailedTransfers; user-cancelled stay', () => { + useTransferStore.setState({ + transfers: { + p1: makeTransferItem({ id: 'p1', status: 'cancelled', cancelReason: 'policy' }), + u1: makeTransferItem({ id: 'u1', status: 'cancelled', cancelReason: 'user' }), + f1: makeTransferItem({ id: 'f1', status: 'failed' }), + }, + }); + + const store = useTransferStore.getState(); + const failedIds = store.getFailedTransfers().map((t) => t.id); + expect(failedIds).toContain('u1'); + expect(failedIds).toContain('f1'); + expect(failedIds).not.toContain('p1'); + expect(store.getQueueStats().failed).toBe(2); + }); + + test('retryAllFailed does not resurrect policy-cancelled rows', async () => { + webdavSelected(); + await initManager(); + useTransferStore.setState({ + transfers: { + p1: makeTransferItem({ id: 'p1', status: 'cancelled', cancelReason: 'policy' }), + }, + }); + + transferManager.retryAllFailed(); + await flushAsync(); + + expect(useTransferStore.getState().transfers['p1']?.status).toBe('cancelled'); + }); + + test('retryTransfer no-ops on a policy-cancelled row', async () => { + webdavSelected(); + await initManager(); + useTransferStore.setState({ + transfers: { + p1: makeTransferItem({ id: 'p1', status: 'cancelled', cancelReason: 'policy' }), + }, + }); + + transferManager.retryTransfer('p1'); + await flushAsync(); + + expect(useTransferStore.getState().transfers['p1']?.status).toBe('cancelled'); + }); + + test('user cancelTransfer records cancelReason user', async () => { + await initManager(); + useTransferStore.getState().pauseQueue(); + const id = transferManager.queueUpload(makeBook())!; + + transferManager.cancelTransfer(id); + + const transfer = useTransferStore.getState().transfers[id]; + expect(transfer?.status).toBe('cancelled'); + expect(transfer?.cancelReason).toBe('user'); + }); + + test('gate-off reconcile settles: a force-pended policy row is re-cancelled without looping', async () => { + webdavSelected(); + await initManager(); + useTransferStore.setState({ + transfers: { + p1: makeTransferItem({ id: 'p1', status: 'pending', cancelReason: 'policy' }), + }, + }); + + await (transferManager as unknown as { processQueue: () => Promise }).processQueue(); + await flushAsync(); + + const transfer = useTransferStore.getState().transfers['p1']; + expect(transfer?.status).toBe('cancelled'); + expect(useTransferStore.getState().getPendingTransfers()).toHaveLength(0); + }); +}); + +describe('quota failure handling', () => { + test('quota 403 fails immediately with zero retries', async () => { + const appService = makeAppService({ + uploadBook: vi.fn().mockRejectedValue(new Error('Insufficient storage quota')), + }); + const book = makeBook(); + await initManager(appService, [book]); + + transferManager.queueUpload(book); + await flushAsync(); + + const transfers = Object.values(useTransferStore.getState().transfers); + expect(transfers).toHaveLength(1); + expect(transfers[0]?.status).toBe('failed'); + expect(transfers[0]?.retryCount).toBe(0); + expect(appService['uploadBook']).toHaveBeenCalledTimes(1); + }); + + test('a batch of quota failures produces one summary toast, not one per book', async () => { + const appService = makeAppService({ + uploadBook: vi.fn().mockRejectedValue(new Error('Insufficient storage quota')), + }); + const books = [makeBook(), makeBook({ hash: 'hash2' }), makeBook({ hash: 'hash3' })]; + await initManager(appService, books); + + transferManager.queueBatchUploads(books); + await flushAsync(10000); + + const dispatched = vi.mocked(eventDispatcher.dispatch).mock.calls.filter( + ([event, payload]) => + event === 'toast' && + String((payload as { message?: string })?.message ?? '') + .toLowerCase() + .includes('quota'), + ); + expect(dispatched).toHaveLength(1); + expect(String((dispatched[0]![1] as { message: string }).message)).toContain('3'); + }); + + test('non-quota errors keep the existing retry behavior', async () => { + const appService = makeAppService({ + uploadBook: vi.fn().mockRejectedValue(new Error('network boom')), + }); + const book = makeBook(); + await initManager(appService, [book]); + + transferManager.queueUpload(book); + await flushAsync(60000); + + const transfer = Object.values(useTransferStore.getState().transfers)[0]; + expect(transfer?.status).toBe('failed'); + expect(transfer?.retryCount).toBe(3); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/transfer-manager.test.ts b/apps/readest-app/src/__tests__/services/transfer-manager.test.ts index d94c55e0..eb25e494 100644 --- a/apps/readest-app/src/__tests__/services/transfer-manager.test.ts +++ b/apps/readest-app/src/__tests__/services/transfer-manager.test.ts @@ -1,5 +1,7 @@ import { describe, test, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'; import { useTransferStore, TransferItem } from '@/store/transferStore'; +import { useSettingsStore } from '@/store/settingsStore'; +import type { SystemSettings } from '@/types/settings'; // ── Mocks ──────────────────────────────────────────────────────────── // The transferManager module is a singleton, so we need to mock its @@ -105,6 +107,16 @@ beforeEach(() => { resetTransferManager(); vi.clearAllMocks(); localStorage.clear(); + // Book uploads are gated on the selected cloud sync provider and + // deferred until settings hydrate; hydrate with Readest Cloud selected + // so the pre-gating behavior under test is preserved. + useSettingsStore.setState({ + settings: { + version: 1, + webdav: { enabled: false }, + googleDrive: { enabled: false }, + } as SystemSettings, + }); vi.spyOn(console, 'warn').mockImplementation(() => {}); vi.spyOn(console, 'error').mockImplementation(() => {}); }); diff --git a/apps/readest-app/src/app/library/components/TransferQueuePanel.tsx b/apps/readest-app/src/app/library/components/TransferQueuePanel.tsx index 0ddefff1..4f8e025e 100644 --- a/apps/readest-app/src/app/library/components/TransferQueuePanel.tsx +++ b/apps/readest-app/src/app/library/components/TransferQueuePanel.tsx @@ -17,7 +17,12 @@ import { useTranslation } from '@/hooks/useTranslation'; import { useResponsiveSize } from '@/hooks/useResponsiveSize'; import { useKeyDownActions } from '@/hooks/useKeyDownActions'; import { useLibraryStore } from '@/store/libraryStore'; -import { TransferItem, TransferStatus, useTransferStore } from '@/store/transferStore'; +import { + TransferItem, + TransferStatus, + isFailedLikeTransfer, + useTransferStore, +} from '@/store/transferStore'; const formatBytes = (bytes: number): string => { if (bytes === 0) return '0 B'; @@ -109,7 +114,10 @@ const TransferItemRow: React.FC<{ {transfer.error || _('Failed')} )} {transfer.status === 'completed' && (completedLabel[transfer.type] || _('Completed'))} - {transfer.status === 'cancelled' && _('Cancelled')} + {transfer.status === 'cancelled' && + (transfer.cancelReason === 'policy' + ? `${_('Cancelled')} · ${_('Cloud provider switched')}` + : _('Cancelled'))} {' · '} {formatDateTime(transfer.completedAt || transfer.startedAt || transfer.createdAt)} @@ -125,7 +133,7 @@ const TransferItemRow: React.FC<{
- {(transfer.status === 'failed' || transfer.status === 'cancelled') && ( + {isFailedLikeTransfer(transfer) && (