mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-03 01:07:07 +00:00
268 lines
12 KiB
TypeScript
268 lines
12 KiB
TypeScript
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<string, unknown>
|
|
) => Promise<boolean>;
|
|
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<string, unknown>) => Promise<boolean>) | undefined;
|
|
const askResolvingMismatched = vi.fn(
|
|
async (_preferred: unknown, update: (setting: Record<string, unknown>) => Promise<boolean>) => {
|
|
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]);
|
|
});
|
|
});
|