import { describe, expect, it, vi } from "vitest"; import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; import { defaultLogger, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, setGlobalLogFunction } from "octagonal-wheels/common/logger"; import { CENTRAL_COMPATIBILITY_REJECTION_REASONS, NO_INTERACTION, REPLICATION_PROGRESS_PRESENTATIONS, USER_INITIATED_REPLICATION_AUTHORITY, replicationFailed, } from "@vrtmrz/livesync-commonlib/replication"; const chunkMocks = vi.hoisted(() => ({ purgeUnreferencedChunks: vi.fn(async (_database: unknown, countOnly: boolean) => (countOnly ? 2 : 0)), balanceChunkPurgedDBs: vi.fn(async () => undefined), })); vi.mock("@vrtmrz/livesync-commonlib/compat/pouchdb/chunks", () => chunkMocks); vi.mock("@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator", () => ({ LiveSyncCouchDBReplicator: class {}, })); import { LiveSyncCouchDBReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/couchdb/LiveSyncReplicator"; import { createCentralCompatibilityRecovery } from "./centralCompatibilityRecovery"; describe("central compatibility recovery", () => { it("characterises unattended central failure handling as one INFO log without a NOTICE", async () => { const log = vi.fn((_message: unknown, _level?: number, _key?: string) => undefined); setGlobalLogFunction(log); try { const recovery = createCentralCompatibilityRecovery({ confirm: {}, getLocalDatabase: () => ({}), rebuilder: {}, services: { appLifecycle: {}, API: {}, replicator: {}, tweakValue: {}, }, } as never); await expect( recovery.handleReplicationFailure({ context: { provider: {}, replicator: {} }, setting: {}, outcome: replicationFailed(new Error("provider failed")), progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET, interaction: NO_INTERACTION, } as never) ).resolves.toBe(false); expect(log).toHaveBeenCalledOnce(); expect(log).toHaveBeenCalledWith("Replication failed on an unattended path.", LOG_LEVEL_INFO, undefined); expect(log.mock.calls.map(([, level]) => level)).not.toContain(LOG_LEVEL_NOTICE); } finally { setGlobalLogFunction(defaultLogger); } }); it("uses the exact failed outcome and permits dialogue only with recovery authority", async () => { const askResolvingMismatched = vi.fn(async (..._arguments: unknown[]) => undefined); const failedSetPreferred = vi.fn(async (_setting: unknown) => undefined); const failedReplicator = { setPreferredRemoteTweakSettings: failedSetPreferred }; const replacementSetPreferred = vi.fn(async (_setting: unknown) => undefined); const replacementReplicator = { tweakSettingsMismatched: true, preferredTweakValue: { customChunkSize: 99 }, setPreferredRemoteTweakSettings: replacementSetPreferred, }; const failedContext = { provider: {}, replicator: failedReplicator }; const replacementContext = { provider: {}, replicator: replacementReplicator }; const preferredTweakValue = { customChunkSize: 60 }; const outcome = replicationFailed(new Error("mismatched"), { reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH, preferredTweakValue, }); const recovery = createCentralCompatibilityRecovery({ confirm: {}, getLocalDatabase: () => ({}), rebuilder: {}, services: { appLifecycle: {}, API: {}, replicator: { runWithActiveReplicatorContext: vi.fn(async (task: (context: unknown) => unknown) => task(replacementContext) ), }, tweakValue: { askResolvingMismatched }, }, } as never); await recovery.handleReplicationFailure({ context: failedContext, setting: {}, outcome, progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET, interaction: NO_INTERACTION, } as never); expect(askResolvingMismatched).not.toHaveBeenCalled(); await recovery.handleReplicationFailure({ context: failedContext, setting: {}, outcome, progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET, interaction: { kind: "permitted", permissions: { ...USER_INITIATED_REPLICATION_AUTHORITY.permissions, failureRecovery: false }, }, } as never); expect(askResolvingMismatched).not.toHaveBeenCalled(); await recovery.handleReplicationFailure({ context: failedContext, setting: {}, outcome, progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.QUIET, interaction: USER_INITIATED_REPLICATION_AUTHORITY, } as never); expect(askResolvingMismatched).toHaveBeenCalledWith(preferredTweakValue, expect.any(Function)); const updatePreferredRemote = askResolvingMismatched.mock.calls[0][1] as ( setting: Record ) => Promise; await expect(updatePreferredRemote({ customChunkSize: 64 })).resolves.toBe(false); expect(failedSetPreferred).not.toHaveBeenCalled(); expect(replacementSetPreferred).not.toHaveBeenCalled(); }); it("writes a mismatch decision only through the still-active failed publication", async () => { const setPreferredRemoteTweakSettings = vi.fn(async (_setting: unknown) => undefined); const failedContext = { provider: {}, replicator: { setPreferredRemoteTweakSettings } }; let updatePreferredRemote: ((setting: Record) => Promise) | undefined; const askResolvingMismatched = vi.fn( async (_preferred: unknown, update: (setting: Record) => Promise) => { updatePreferredRemote = update; } ); const recovery = createCentralCompatibilityRecovery({ confirm: {}, getLocalDatabase: () => ({}), rebuilder: {}, services: { appLifecycle: {}, API: {}, replicator: { runWithActiveReplicatorContext: vi.fn(async (task: (context: unknown) => unknown) => task(failedContext) ), }, tweakValue: { askResolvingMismatched }, }, } as never); await recovery.handleReplicationFailure({ context: failedContext, setting: {}, outcome: replicationFailed(new Error("mismatched"), { reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.TWEAK_MISMATCH, preferredTweakValue: { customChunkSize: 60 }, }), progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE, interaction: USER_INITIATED_REPLICATION_AUTHORITY, } as never); const effectiveSetting = { customChunkSize: 64 }; await expect(updatePreferredRemote?.(effectiveSetting)).resolves.toBe(true); expect(setPreferredRemoteTweakSettings).toHaveBeenCalledWith(effectiveSetting); expect(setPreferredRemoteTweakSettings.mock.calls[0][0]).not.toBe(effectiveSetting); }); it("does not apply an unlock selected for a replaced failed publication", async () => { const failedMarkResolved = vi.fn(async () => undefined); const replacementMarkResolved = vi.fn(async () => undefined); const failedContext = { provider: {}, replicator: { markRemoteResolved: failedMarkResolved } }; const replacementContext = { provider: {}, replicator: { markRemoteResolved: replacementMarkResolved } }; const runWithActiveReplicatorContext = vi.fn(async (task: (context: unknown) => unknown) => task(replacementContext) ); const recovery = createCentralCompatibilityRecovery({ confirm: { askSelectStringDialogue: vi.fn(async (_message: string, choices: string[]) => choices[1]), }, getLocalDatabase: () => ({}), rebuilder: {}, services: { appLifecycle: { scheduleRestart: vi.fn() }, API: {}, replicator: { runWithActiveReplicatorContext }, tweakValue: {}, }, } as never); await recovery.handleReplicationFailure({ context: failedContext, setting: {}, outcome: replicationFailed(new Error("locked"), { reason: CENTRAL_COMPATIBILITY_REJECTION_REASONS.NODE_LOCKED, }), progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE, interaction: USER_INITIATED_REPLICATION_AUTHORITY, } as never); expect(runWithActiveReplicatorContext).toHaveBeenCalledOnce(); expect(failedMarkResolved).not.toHaveBeenCalled(); expect(replacementMarkResolved).not.toHaveBeenCalled(); }); it("keeps cleaned-remote replication and balancing inside the shared activity boundary", async () => { const activityFinished = vi.fn(); const runBoundedRemoteActivity = vi.fn(async (task: () => unknown) => { try { return await task(); } finally { activityFinished(); } }); const runFiniteReplicationActivity = vi.fn(async (task: () => unknown) => await task()); const openOneShotReplication = vi.fn(async () => true); const remoteDatabase = { close: vi.fn(async () => undefined) }; const close = vi.fn(async () => undefined); const activeReplicator = Object.assign(new LiveSyncCouchDBReplicator({} as never), { connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase, close })), openOneShotReplication, markRemoteResolved: vi.fn(async () => undefined), }); const expectedContext = { provider: {}, replicator: activeReplicator }; const runWithActiveReplicatorContext = vi.fn(async (task: (context: unknown) => unknown) => task(expectedContext) ); const localDatabase = { localDatabase: {}, clearCaches: vi.fn() }; const getLocalDatabase = vi.fn(() => localDatabase); const recovery = createCentralCompatibilityRecovery({ confirm: { confirmWithMessage: vi.fn(async () => "Cleanup") }, getLocalDatabase, rebuilder: {}, services: { appLifecycle: {}, API: { isMobile: vi.fn(() => false) }, replicator: { runBoundedRemoteActivity, runFiniteReplicationActivity, runWithActiveReplicatorContext, }, tweakValue: {}, }, } as never); await recovery.reconcileCleanedRemote(true, {} as ObsidianLiveSyncSettings, expectedContext as never); expect(runBoundedRemoteActivity).toHaveBeenCalledWith(expect.any(Function), { label: "database-cleanup", }); expect(runFiniteReplicationActivity).toHaveBeenCalledWith(expect.any(Function), { label: "replication", }); expect(runWithActiveReplicatorContext).toHaveBeenCalledOnce(); expect(openOneShotReplication).toHaveBeenCalledOnce(); expect(openOneShotReplication.mock.invocationCallOrder[0]).toBeLessThan( activityFinished.mock.invocationCallOrder[0] ); expect(chunkMocks.balanceChunkPurgedDBs).toHaveBeenCalledOnce(); expect(getLocalDatabase).toHaveBeenCalledTimes(2); expect(close).toHaveBeenCalledOnce(); expect(close.mock.invocationCallOrder[0]).toBeLessThan(activityFinished.mock.invocationCallOrder[0]); }); });