mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-26 19:13:20 +00:00
Remediation mode prevents the reconciliation scan which readiness depends upon, so the application never becomes ready while the modification-time limit is set. Since the result processor began waiting for readiness, nothing was applied in that mode at all, not even changes older than the limit, which is what the mode exists for. Result application no longer requires readiness while the limit is configured, but it does require a usable local database, because a change applied while a fetch has reset the database cannot gather its chunks and is dropped. The limit itself is still enforced for every change. A scheduled fetch also no longer offers Simple Fetch in this mode. Simple Fetch reconciles the Vault with the local database after fetching, past the check which refuses that scan, so it could store the current files or write changes newer than the limit. The detailed flow states the restriction and offers to clear it first.
2001 lines
83 KiB
TypeScript
2001 lines
83 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import { createServiceContext } from "@vrtmrz/livesync-commonlib/context";
|
|
import type { LogFunction } from "@vrtmrz/livesync-commonlib/compat/services/lib/logUtils";
|
|
import {
|
|
FlagFilesHumanReadable,
|
|
FlagFilesOriginal,
|
|
} from "@vrtmrz/livesync-commonlib/compat/common/models/redflag.const";
|
|
import { REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
|
import {
|
|
createFetchAllFlagHandler,
|
|
createRebuildFlagHandler,
|
|
createSuspendFlagHandler,
|
|
isFlagFileExist,
|
|
deleteFlagFile,
|
|
adjustSettingToRemote,
|
|
adjustSettingToRemoteIfNeeded,
|
|
processVaultInitialisation,
|
|
verifyAndUnlockSuspension,
|
|
flagHandlerToEventHandler,
|
|
} from "./redFlag";
|
|
import {
|
|
DEFAULT_SETTINGS,
|
|
TweakValuesRecommendedTemplate,
|
|
TweakValuesShouldMatchedTemplate,
|
|
TweakValuesTemplate,
|
|
type TweakValues,
|
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
|
import {
|
|
ExtraOnLocal,
|
|
FullScanModes,
|
|
synchroniseAllFilesBetweenDBandStorage,
|
|
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
|
import {
|
|
SIMPLE_FETCH_STAGE1_DETAILED,
|
|
SIMPLE_FETCH_STAGE1_NEWER_WINS,
|
|
SIMPLE_FETCH_STAGE1_REMOTE_WINS,
|
|
SIMPLE_FETCH_STAGE2_NEWER_CLEANUP,
|
|
SIMPLE_FETCH_STAGE2_NEWER_SYNC_ALL,
|
|
SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE,
|
|
SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL,
|
|
STAGE2_ABORT,
|
|
askAndPerformFastSetupOnScheduledFetchAll,
|
|
askSimpleFetchMode,
|
|
} from "./redFlag.simpleFetch";
|
|
import { activateRemoteConfiguration } from "@vrtmrz/livesync-commonlib/remote-configurations";
|
|
//Mock synchroniseAllFilesBetweenDBandStorage
|
|
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner", async (importOriginal) => {
|
|
const originalModule = (await importOriginal()) as any;
|
|
return {
|
|
...originalModule,
|
|
synchroniseAllFilesBetweenDBandStorage: vi.fn(() => Promise.resolve(true)),
|
|
};
|
|
});
|
|
|
|
vi.mock("@vrtmrz/livesync-commonlib/compat/serviceFeatures/remoteConfig", () => {
|
|
return {
|
|
activateRemoteConfiguration: vi.fn((settings: any, configurationId: string) => {
|
|
if (!settings?.remoteConfigurations?.[configurationId]) return false;
|
|
return {
|
|
activeConfigurationId: configurationId,
|
|
remoteType: settings.remoteConfigurations[configurationId].remoteType ?? settings.remoteType,
|
|
remoteURI: settings.remoteConfigurations[configurationId].uri,
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
|
|
// Mock types and functions
|
|
const createLoggerMock = (): LogFunction => {
|
|
return vi.fn();
|
|
};
|
|
|
|
const availableRemoteTweaks = (values: Record<string, unknown>) => ({ status: "available", values }) as const;
|
|
|
|
const createStorageAccessMock = () => {
|
|
const files: Set<string> = new Set();
|
|
return {
|
|
files,
|
|
isExists: vi.fn((path: string) => Promise.resolve(files.has(path))),
|
|
normalisePath: vi.fn((path: string) => path),
|
|
delete: vi.fn((path: string, _recursive?: boolean) => {
|
|
files.delete(path);
|
|
return Promise.resolve();
|
|
}),
|
|
getFileNames: vi.fn(() => Array.from(files)),
|
|
};
|
|
};
|
|
|
|
const createSettingServiceMock = () => {
|
|
const settings: any = {
|
|
batchSave: true,
|
|
suspendFileWatching: false,
|
|
writeLogToTheFile: false,
|
|
remoteType: "CouchDB",
|
|
};
|
|
const smallConfig = new Map<string, string>();
|
|
return {
|
|
settings,
|
|
currentSettings: vi.fn(() => settings),
|
|
applyExternalSettings: vi.fn((partial: any, _feedback?: boolean) => {
|
|
Object.assign(settings, partial);
|
|
return Promise.resolve();
|
|
}),
|
|
applyPartial: vi.fn((partial: any, _feedback?: boolean) => {
|
|
Object.assign(settings, partial);
|
|
return Promise.resolve();
|
|
}),
|
|
suspendAllSync: vi.fn(() => Promise.resolve()),
|
|
suspendExtraSync: vi.fn(() => Promise.resolve()),
|
|
getSmallConfig: vi.fn((key: string) => smallConfig.get(key) ?? ""),
|
|
setSmallConfig: vi.fn((key: string, value: string) => {
|
|
smallConfig.set(key, value);
|
|
}),
|
|
deleteSmallConfig: vi.fn((key: string) => {
|
|
smallConfig.delete(key);
|
|
}),
|
|
};
|
|
};
|
|
|
|
const createAppLifecycleMock = () => {
|
|
return {
|
|
performRestart: vi.fn(),
|
|
onLayoutReady: {
|
|
addHandler: vi.fn(),
|
|
},
|
|
getUnresolvedMessages: {
|
|
addHandler: vi.fn(),
|
|
},
|
|
};
|
|
};
|
|
|
|
const createUIServiceMock = () => {
|
|
return {
|
|
dialogManager: {
|
|
openWithExplicitCancel: vi.fn(),
|
|
},
|
|
confirm: {
|
|
askSelectStringDialogue: vi.fn(),
|
|
askYesNoDialog: vi.fn(),
|
|
confirmWithMessage: vi.fn(),
|
|
},
|
|
};
|
|
};
|
|
|
|
const createRebuilderMock = () => {
|
|
return {
|
|
$fetchLocal: vi.fn(async () => {}),
|
|
$fetchLocalDBFast: vi.fn(async () => {}),
|
|
$rebuildEverything: vi.fn(async () => {}),
|
|
finishRebuild: vi.fn(async () => {}),
|
|
};
|
|
};
|
|
|
|
const createTweakValueMock = () => {
|
|
return {
|
|
fetchRemotePreferred: vi.fn(() =>
|
|
Promise.resolve<any>({ status: "unavailable", error: new Error("Remote unavailable") })
|
|
),
|
|
};
|
|
};
|
|
|
|
const createHostMock = () => {
|
|
const storageAccessMock = createStorageAccessMock();
|
|
const settingMock = createSettingServiceMock();
|
|
const appLifecycleMock = createAppLifecycleMock();
|
|
const uiMock = createUIServiceMock();
|
|
const rebuilderMock = createRebuilderMock();
|
|
const tweakValueMock = createTweakValueMock();
|
|
|
|
return {
|
|
services: {
|
|
context: createServiceContext(),
|
|
setting: settingMock,
|
|
appLifecycle: appLifecycleMock,
|
|
UI: uiMock,
|
|
tweakValue: tweakValueMock,
|
|
},
|
|
serviceModules: {
|
|
storageAccess: storageAccessMock,
|
|
rebuilder: rebuilderMock,
|
|
},
|
|
mocks: {
|
|
storageAccess: storageAccessMock,
|
|
setting: settingMock,
|
|
appLifecycle: appLifecycleMock,
|
|
ui: uiMock,
|
|
rebuilder: rebuilderMock,
|
|
tweakValue: tweakValueMock,
|
|
},
|
|
};
|
|
};
|
|
|
|
describe("Red Flag Feature", () => {
|
|
describe("isFlagFileExist", () => {
|
|
it("should return true if flag file exists", async () => {
|
|
const host = createHostMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
|
|
const result = await isFlagFileExist(host as any, FlagFilesOriginal.FETCH_ALL);
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should return false if flag file does not exist", async () => {
|
|
const host = createHostMock();
|
|
|
|
const result = await isFlagFileExist(host as any, FlagFilesOriginal.FETCH_ALL);
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("deleteFlagFile", () => {
|
|
it("should delete flag file if it exists", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
|
|
await deleteFlagFile(host as any, log, FlagFilesOriginal.FETCH_ALL);
|
|
|
|
const exists = await host.mocks.storageAccess.isExists(
|
|
host.mocks.storageAccess.normalisePath(FlagFilesOriginal.FETCH_ALL)
|
|
);
|
|
expect(exists).toBe(false);
|
|
});
|
|
|
|
it("should not throw error if file does not exist", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
await expect(deleteFlagFile(host as any, log, FlagFilesOriginal.FETCH_ALL)).resolves.not.toThrow();
|
|
});
|
|
|
|
it("should log error if deletion fails", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.delete.mockRejectedValueOnce(new Error("Delete failed"));
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
|
|
await deleteFlagFile(host as any, log, FlagFilesOriginal.FETCH_ALL);
|
|
|
|
expect(log).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("FlagFile Handler Priority", () => {
|
|
it("should handle suspend flag with priority 5", () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
const handler = createSuspendFlagHandler(host as any, log);
|
|
expect(handler.priority).toBe(5);
|
|
expect(typeof handler.check).toBe("function");
|
|
expect(typeof handler.handle).toBe("function");
|
|
});
|
|
|
|
it("should handle fetch all flag with priority 10", () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
expect(handler.priority).toBe(10);
|
|
expect(typeof handler.check).toBe("function");
|
|
expect(typeof handler.handle).toBe("function");
|
|
});
|
|
|
|
it("should handle rebuild all flag with priority 20", () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
const handler = createRebuildFlagHandler(host as any, log);
|
|
expect(handler.priority).toBe(20);
|
|
expect(typeof handler.check).toBe("function");
|
|
expect(typeof handler.handle).toBe("function");
|
|
});
|
|
});
|
|
|
|
describe("Setting adjustment during vault initialisation", () => {
|
|
it("should suspend file watching during initialisation", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
expect(host.mocks.setting.currentSettings().suspendFileWatching).toBe(false);
|
|
|
|
const result = await processVaultInitialisation(host as any, log, () => {
|
|
expect(host.mocks.setting.currentSettings().suspendFileWatching).toBe(true);
|
|
return Promise.resolve(true);
|
|
});
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should disable batch save during initialisation", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
expect(host.mocks.setting.currentSettings().batchSave).toBe(true);
|
|
|
|
const result = await processVaultInitialisation(host as any, log, () => {
|
|
expect(host.mocks.setting.currentSettings().batchSave).toBe(false);
|
|
return Promise.resolve(true);
|
|
});
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should suspend all sync operations", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
await processVaultInitialisation(host as any, log, () => {
|
|
return Promise.resolve(true);
|
|
});
|
|
|
|
expect(host.mocks.setting.suspendAllSync).toHaveBeenCalled();
|
|
expect(host.mocks.setting.suspendExtraSync).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should resume file watching after initialisation completes", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
await processVaultInitialisation(
|
|
host as any,
|
|
log,
|
|
() => {
|
|
return Promise.resolve(true);
|
|
},
|
|
"resume"
|
|
);
|
|
|
|
expect(host.mocks.setting.currentSettings().suspendFileWatching).toBe(false);
|
|
});
|
|
|
|
it("should keep suspending when the policy is keep", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
await processVaultInitialisation(
|
|
host as any,
|
|
log,
|
|
() => {
|
|
return Promise.resolve(true);
|
|
},
|
|
"keep"
|
|
);
|
|
|
|
expect(host.mocks.setting.currentSettings().suspendFileWatching).toBe(true);
|
|
});
|
|
|
|
it("should return false when process fails", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
const result = await processVaultInitialisation(
|
|
host as any,
|
|
log,
|
|
() => {
|
|
throw new Error("Process failed");
|
|
},
|
|
"resume"
|
|
);
|
|
|
|
expect(result).toBe(false);
|
|
expect(log).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Suspend Flag Handler", () => {
|
|
it("should write logs to file when suspend flag is detected", () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
const handler = createSuspendFlagHandler(host as any, log);
|
|
|
|
expect(handler.priority).toBe(5);
|
|
expect(typeof handler.check).toBe("function");
|
|
expect(typeof handler.handle).toBe("function");
|
|
});
|
|
|
|
it("should keep suspending after initialisation when suspend flag is active", async () => {
|
|
const host = createHostMock();
|
|
|
|
const handler = createSuspendFlagHandler(host as any, createLoggerMock());
|
|
|
|
const checkResult = await handler.check();
|
|
expect(typeof checkResult).toBe("boolean");
|
|
});
|
|
|
|
it("should apply writeLogToTheFile setting during suspension", async () => {
|
|
const host = createHostMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.SUSPEND_ALL);
|
|
|
|
const handler = createSuspendFlagHandler(host as any, createLoggerMock());
|
|
const checkResult = await handler.check();
|
|
|
|
expect(checkResult).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Fetch All Flag Handler", () => {
|
|
it("should detect fetch all flag using original filename", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
const exists = await handler.check();
|
|
|
|
expect(exists).toBe(true);
|
|
});
|
|
|
|
it("should detect fetch all flag using human-readable filename", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesHumanReadable.FETCH_ALL);
|
|
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
const exists = await handler.check();
|
|
|
|
expect(exists).toBe(true);
|
|
});
|
|
|
|
it("should clean up both original and human-readable fetch all flag files", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
host.mocks.storageAccess.files.add(FlagFilesHumanReadable.FETCH_ALL);
|
|
|
|
await deleteFlagFile(host as any, log, FlagFilesOriginal.FETCH_ALL);
|
|
await deleteFlagFile(host as any, log, FlagFilesHumanReadable.FETCH_ALL);
|
|
|
|
const originalExists = await host.mocks.storageAccess.isExists(
|
|
host.mocks.storageAccess.normalisePath(FlagFilesOriginal.FETCH_ALL)
|
|
);
|
|
const humanExists = await host.mocks.storageAccess.isExists(
|
|
host.mocks.storageAccess.normalisePath(FlagFilesHumanReadable.FETCH_ALL)
|
|
);
|
|
|
|
expect(originalExists).toBe(false);
|
|
expect(humanExists).toBe(false);
|
|
});
|
|
|
|
it("should have priority 10", () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
expect(handler.priority).toBe(10);
|
|
});
|
|
|
|
it("should use simplified remote-only mode and call performFullScan", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
// Stage 1: Overwrite all with remote files
|
|
// Stage 2: Delete local files if not on remote (Clean overwrite)
|
|
host.mocks.ui.confirm.confirmWithMessage
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL);
|
|
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
|
availableRemoteTweaks({ batchSave: false })
|
|
);
|
|
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
const result = await handler.handle();
|
|
|
|
expect(result).toBe(true);
|
|
expect(host.mocks.rebuilder.$fetchLocalDBFast).toHaveBeenCalled();
|
|
expect(synchroniseAllFilesBetweenDBandStorage).toHaveBeenCalled();
|
|
const firstPrompt = host.mocks.ui.confirm.confirmWithMessage.mock.calls[0]?.[1];
|
|
expect(firstPrompt).toContain("data retrieved from this remote source");
|
|
expect(firstPrompt).not.toContain("remote server");
|
|
// We can't easily check performFullScan call here because it's imported,
|
|
// but we can verify rebuilder was called.
|
|
});
|
|
|
|
it("opens the detailed Fetch flow when requested", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
|
|
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({
|
|
vault: "identical",
|
|
backup: "backup_skipped",
|
|
extra: { preventFetchingConfig: false },
|
|
});
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
|
availableRemoteTweaks({ batchSave: false })
|
|
);
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
const result = await handler.handle();
|
|
|
|
expect(result).toBe(true);
|
|
expect(host.mocks.ui.dialogManager.openWithExplicitCancel).toHaveBeenCalled();
|
|
expect(host.mocks.rebuilder.$fetchLocal).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should cancel fetch flow when first quick step is cancelled", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(false);
|
|
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
|
availableRemoteTweaks({ batchSave: false })
|
|
);
|
|
const result = await handler.handle();
|
|
|
|
expect(result).toBe(false);
|
|
expect(host.mocks.rebuilder.$fetchLocal).not.toHaveBeenCalled();
|
|
expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should use remote-authoritative quick mode for empty vault", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
host.mocks.ui.confirm.confirmWithMessage
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL);
|
|
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
|
availableRemoteTweaks({ batchSave: false })
|
|
);
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
const result = await handler.handle();
|
|
|
|
expect(result).toBe(true);
|
|
expect(host.mocks.rebuilder.$fetchLocalDBFast).toHaveBeenCalled();
|
|
expect(host.mocks.rebuilder.$fetchLocal).not.toHaveBeenCalledWith(false, true);
|
|
});
|
|
|
|
it("should keep current remote configuration when selected", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
Object.assign(host.mocks.setting.settings, {
|
|
remoteConfigurations: {
|
|
alpha: { name: "Alpha", uri: "sls+https://user:pass@example.com/db1" },
|
|
beta: { name: "Beta", uri: "sls+https://user:pass@example.com/db2" },
|
|
},
|
|
});
|
|
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Use active remote");
|
|
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(false);
|
|
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
const result = await handler.handle();
|
|
|
|
expect(result).toBe(false);
|
|
expect(host.mocks.setting.applyPartial).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ activeConfigurationId: expect.any(String) })
|
|
);
|
|
});
|
|
|
|
it("should stop when remote selection is cancelled", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
Object.assign(host.mocks.setting.settings, {
|
|
remoteConfigurations: {
|
|
alpha: { name: "Alpha", uri: "sls+https://user:pass@example.com/db1" },
|
|
beta: { name: "Beta", uri: "sls+https://user:pass@example.com/db2" },
|
|
},
|
|
});
|
|
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel");
|
|
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
const result = await handler.handle();
|
|
|
|
expect(result).toBe(false);
|
|
expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled();
|
|
expect(host.mocks.storageAccess.files.has(FlagFilesOriginal.FETCH_ALL)).toBe(false);
|
|
await expect(handler.check()).resolves.toBe(false);
|
|
expect(host.mocks.setting.applyPartial).toHaveBeenCalledWith(
|
|
{
|
|
suspendFileWatching: true,
|
|
suspendParseReplicationResult: true,
|
|
},
|
|
true
|
|
);
|
|
expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("should activate selected remote configuration", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
Object.assign(host.mocks.setting.settings, {
|
|
remoteConfigurations: {
|
|
alpha: {
|
|
name: "Alpha",
|
|
uri: "sls+https://user:pass@example.com/db1",
|
|
remoteType: "CouchDB",
|
|
},
|
|
beta: {
|
|
name: "Beta",
|
|
uri: "sls+https://user:pass@example.com/db2",
|
|
remoteType: "CouchDB",
|
|
},
|
|
},
|
|
});
|
|
host.mocks.ui.confirm.askSelectStringDialogue.mockImplementationOnce(
|
|
async (_message: string, selections: string[]) => selections.find((e) => e.startsWith("Beta -"))
|
|
);
|
|
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(false);
|
|
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
const result = await handler.handle();
|
|
|
|
expect(result).toBe(false);
|
|
expect(activateRemoteConfiguration).toHaveBeenCalledWith(host.mocks.setting.settings, "beta");
|
|
expect(host.mocks.setting.applyPartial).toHaveBeenCalledWith(
|
|
expect.objectContaining({ activeConfigurationId: "beta" })
|
|
);
|
|
});
|
|
|
|
it("should stop when selected remote name is unknown", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
Object.assign(host.mocks.setting.settings, {
|
|
remoteConfigurations: {
|
|
alpha: { name: "Alpha", uri: "sls+https://user:pass@example.com/db1" },
|
|
beta: { name: "Beta", uri: "sls+https://user:pass@example.com/db2" },
|
|
},
|
|
});
|
|
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Unknown option");
|
|
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
const result = await handler.handle();
|
|
|
|
expect(result).toBe(false);
|
|
expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should stop when remote activation fails", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
Object.assign(host.mocks.setting.settings, {
|
|
remoteConfigurations: {
|
|
alpha: { name: "Alpha", uri: "sls+https://user:pass@example.com/db1" },
|
|
beta: { name: "Beta", uri: "sls+https://user:pass@example.com/db2" },
|
|
},
|
|
});
|
|
host.mocks.ui.confirm.askSelectStringDialogue.mockImplementationOnce(
|
|
async (_message: string, selections: string[]) => selections.find((e) => e.startsWith("Beta -"))
|
|
);
|
|
(activateRemoteConfiguration as any).mockReturnValueOnce(false);
|
|
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
const result = await handler.handle();
|
|
|
|
expect(result).toBe(false);
|
|
expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("askSimpleFetchMode", () => {
|
|
it("should return cancelled when stage1 is cancelled", async () => {
|
|
const host = createHostMock();
|
|
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(false);
|
|
|
|
await expect(askSimpleFetchMode(host as any)).resolves.toBe("cancelled");
|
|
});
|
|
|
|
it("selects the detailed Fetch flow", async () => {
|
|
const host = createHostMock();
|
|
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
|
|
|
|
await expect(askSimpleFetchMode(host as any)).resolves.toEqual({ mode: "detailed", options: {} });
|
|
});
|
|
|
|
it("should return remote-only with keep-local option", async () => {
|
|
const host = createHostMock();
|
|
host.mocks.ui.confirm.confirmWithMessage
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE);
|
|
|
|
await expect(askSimpleFetchMode(host as any)).resolves.toEqual({
|
|
mode: "remote-only",
|
|
options: {
|
|
mode: FullScanModes.DB_APPLY,
|
|
extraOnRemote: undefined,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should return cancelled when remote-only stage2 is cancelled", async () => {
|
|
const host = createHostMock();
|
|
host.mocks.ui.confirm.confirmWithMessage
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
|
|
.mockResolvedValueOnce(false);
|
|
|
|
await expect(askSimpleFetchMode(host as any)).resolves.toBe("cancelled");
|
|
});
|
|
|
|
it("should return aborted when remote-only stage2 aborts", async () => {
|
|
const host = createHostMock();
|
|
host.mocks.ui.confirm.confirmWithMessage
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
|
|
.mockResolvedValueOnce(STAGE2_ABORT);
|
|
|
|
await expect(askSimpleFetchMode(host as any)).resolves.toBe("aborted");
|
|
});
|
|
|
|
it("should return newer-wins cleanup option", async () => {
|
|
const host = createHostMock();
|
|
host.mocks.ui.confirm.confirmWithMessage
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP);
|
|
|
|
await expect(askSimpleFetchMode(host as any)).resolves.toEqual({
|
|
mode: "newer-wins",
|
|
options: {
|
|
mode: FullScanModes.NEWER_WINS,
|
|
extraOnLocal: ExtraOnLocal.DELETE_DB_DELETED,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should return newer-wins keep-all option", async () => {
|
|
const host = createHostMock();
|
|
host.mocks.ui.confirm.confirmWithMessage
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_SYNC_ALL);
|
|
|
|
await expect(askSimpleFetchMode(host as any)).resolves.toEqual({
|
|
mode: "newer-wins",
|
|
options: {
|
|
mode: FullScanModes.NEWER_WINS,
|
|
extraOnLocal: ExtraOnLocal.APPEND_STORAGE_ONLY,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("should return cancelled when newer-wins stage2 is cancelled", async () => {
|
|
const host = createHostMock();
|
|
host.mocks.ui.confirm.confirmWithMessage
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
|
|
.mockResolvedValueOnce(false);
|
|
|
|
await expect(askSimpleFetchMode(host as any)).resolves.toBe("cancelled");
|
|
});
|
|
|
|
it("should return aborted when newer-wins stage2 aborts", async () => {
|
|
const host = createHostMock();
|
|
host.mocks.ui.confirm.confirmWithMessage
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
|
|
.mockResolvedValueOnce(STAGE2_ABORT);
|
|
|
|
await expect(askSimpleFetchMode(host as any)).resolves.toBe("aborted");
|
|
});
|
|
});
|
|
|
|
describe("askAndPerformFastSetupOnScheduledFetchAll", () => {
|
|
it("uses the detailed flow instead of Simple Fetch while remediation mode is active", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
|
|
|
|
Object.assign(host.mocks.setting.settings, {
|
|
maxMTimeForReflectEvents: Date.parse("2026-09-01T00:00:00Z"),
|
|
});
|
|
|
|
await expect(askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag)).resolves.toBe(
|
|
undefined
|
|
);
|
|
|
|
expect(host.mocks.ui.confirm.confirmWithMessage).not.toHaveBeenCalled();
|
|
expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode");
|
|
expect(cleanupFlag).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("releases both reflection suspensions after Fast Setup succeeds", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
|
|
|
|
Object.assign(host.mocks.setting.settings, {
|
|
doNotSuspendOnFetching: true,
|
|
suspendParseReplicationResult: true,
|
|
});
|
|
host.mocks.ui.confirm.confirmWithMessage
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP);
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ batchSave: false }));
|
|
|
|
await expect(askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag)).resolves.toBe(true);
|
|
|
|
expect(host.mocks.setting.currentSettings()).toMatchObject({
|
|
suspendFileWatching: false,
|
|
suspendParseReplicationResult: false,
|
|
});
|
|
});
|
|
|
|
it("keeps Vault reflection suspended and preserves recovery state when Fast Fetch fails", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
|
|
|
|
host.mocks.ui.confirm.confirmWithMessage
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP);
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ batchSave: false }));
|
|
host.mocks.rebuilder.$fetchLocalDBFast.mockRejectedValueOnce(new Error("cannot decrypt remote document"));
|
|
|
|
await expect(askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag)).resolves.toBe(false);
|
|
|
|
expect(host.mocks.setting.currentSettings()).toMatchObject({
|
|
suspendFileWatching: true,
|
|
suspendParseReplicationResult: true,
|
|
});
|
|
expect(host.mocks.rebuilder.finishRebuild).not.toHaveBeenCalled();
|
|
expect(cleanupFlag).not.toHaveBeenCalled();
|
|
expect(host.mocks.setting.deleteSmallConfig).not.toHaveBeenCalledWith("simple-fetch-mode");
|
|
});
|
|
|
|
it("re-suspends both reflection directions when finalisation fails after releasing them", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
|
|
|
|
host.mocks.ui.confirm.confirmWithMessage
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP);
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ batchSave: false }));
|
|
host.mocks.rebuilder.finishRebuild.mockImplementationOnce(async () => {
|
|
await host.mocks.setting.applyPartial(
|
|
{
|
|
suspendFileWatching: false,
|
|
suspendParseReplicationResult: false,
|
|
},
|
|
true
|
|
);
|
|
throw new Error("Vault scan failed after reflection resumed");
|
|
});
|
|
|
|
await expect(askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag)).resolves.toBe(false);
|
|
|
|
expect(host.mocks.setting.currentSettings()).toMatchObject({
|
|
suspendFileWatching: true,
|
|
suspendParseReplicationResult: true,
|
|
});
|
|
expect(cleanupFlag).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should remember quick flow choices while the scheduled fetch is pending", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
|
|
|
|
host.mocks.ui.confirm.confirmWithMessage
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP);
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ batchSave: false }));
|
|
host.mocks.rebuilder.$fetchLocalDBFast.mockRejectedValueOnce(new Error("offline"));
|
|
|
|
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
|
|
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
|
|
|
|
expect(host.mocks.ui.confirm.confirmWithMessage).toHaveBeenCalledTimes(2);
|
|
expect(host.mocks.rebuilder.$fetchLocalDBFast).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("should clear remembered quick flow choices after completion", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
|
|
|
|
host.mocks.ui.confirm.confirmWithMessage
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL);
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ batchSave: false }));
|
|
|
|
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
|
|
|
|
expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode");
|
|
});
|
|
|
|
it("should return false and cleanup when quick flow is cancelled", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
|
|
|
|
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(false);
|
|
|
|
const result = await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
|
|
|
|
expect(result).toBe(false);
|
|
expect(cleanupFlag).toHaveBeenCalled();
|
|
expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should return false without cleanup when quick flow is aborted", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
|
|
|
|
host.mocks.ui.confirm.confirmWithMessage
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
|
|
.mockResolvedValueOnce(STAGE2_ABORT);
|
|
|
|
const result = await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
|
|
|
|
expect(result).toBe(false);
|
|
expect(cleanupFlag).not.toHaveBeenCalled();
|
|
expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalled();
|
|
});
|
|
|
|
it("leaves the detailed Fetch flow to its existing handler", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
|
|
|
|
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
|
|
|
|
const result = await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
|
|
|
|
expect(result).toBeUndefined();
|
|
expect(host.mocks.rebuilder.$fetchLocalDBFast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should preserve automatic synchronisation choices and enter Scram when quick Fetch is cancelled", async () => {
|
|
const host = createHostMock();
|
|
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
|
|
Object.assign(host.mocks.setting.settings, {
|
|
liveSync: true,
|
|
periodicReplication: true,
|
|
syncOnSave: true,
|
|
syncOnEditorSave: true,
|
|
syncOnStart: true,
|
|
syncOnFileOpen: true,
|
|
syncAfterMerge: true,
|
|
suspendParseReplicationResult: false,
|
|
});
|
|
host.mocks.ui.confirm.confirmWithMessage
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL);
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
|
status: "not-configured",
|
|
reason: "milestone-missing",
|
|
});
|
|
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel");
|
|
|
|
const result = await askAndPerformFastSetupOnScheduledFetchAll(
|
|
host as any,
|
|
createLoggerMock(),
|
|
cleanupFlag
|
|
);
|
|
|
|
expect(result).toBe(false);
|
|
expect(host.mocks.rebuilder.$fetchLocalDBFast).not.toHaveBeenCalled();
|
|
expect(host.mocks.setting.suspendAllSync).not.toHaveBeenCalled();
|
|
expect(host.mocks.setting.applyPartial).toHaveBeenCalledWith(
|
|
{
|
|
suspendFileWatching: true,
|
|
suspendParseReplicationResult: true,
|
|
},
|
|
true
|
|
);
|
|
expect(host.mocks.setting.currentSettings()).toMatchObject({
|
|
liveSync: true,
|
|
periodicReplication: true,
|
|
syncOnSave: true,
|
|
syncOnEditorSave: true,
|
|
syncOnStart: true,
|
|
syncOnFileOpen: true,
|
|
syncAfterMerge: true,
|
|
suspendFileWatching: true,
|
|
suspendParseReplicationResult: true,
|
|
});
|
|
expect(cleanupFlag).toHaveBeenCalledOnce();
|
|
expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalledOnce();
|
|
expect(host.mocks.setting.applyPartial.mock.invocationCallOrder[0]).toBeLessThan(
|
|
cleanupFlag.mock.invocationCallOrder[0]
|
|
);
|
|
expect(cleanupFlag.mock.invocationCallOrder[0]).toBeLessThan(
|
|
host.mocks.appLifecycle.performRestart.mock.invocationCallOrder[0]
|
|
);
|
|
});
|
|
|
|
it("should reboot and return false when sync has failures and user chooses rerun", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
|
|
|
|
host.mocks.ui.confirm.confirmWithMessage
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL);
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
|
availableRemoteTweaks({ batchSave: false })
|
|
);
|
|
(synchroniseAllFilesBetweenDBandStorage as any).mockResolvedValueOnce(false);
|
|
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Reboot to re-run the process");
|
|
|
|
const result = await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
|
|
|
|
expect(result).toBe(false);
|
|
expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalled();
|
|
expect(cleanupFlag).not.toHaveBeenCalled();
|
|
expect(host.mocks.rebuilder.finishRebuild).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should continue and finalise when sync has failures but user releases flag", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
|
|
|
|
host.mocks.ui.confirm.confirmWithMessage
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
|
|
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL);
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
|
availableRemoteTweaks({ batchSave: false })
|
|
);
|
|
(synchroniseAllFilesBetweenDBandStorage as any).mockResolvedValueOnce(false);
|
|
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce(
|
|
"Finalise the process and resume normal operation"
|
|
);
|
|
|
|
const result = await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
|
|
|
|
expect(result).toBe(true);
|
|
expect(host.mocks.rebuilder.finishRebuild).toHaveBeenCalled();
|
|
expect(cleanupFlag).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Rebuild All Flag Handler", () => {
|
|
it("identifies P2P when opening the scheduled rebuild confirmation", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
host.mocks.setting.settings.remoteType = REMOTE_P2P;
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
|
|
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
|
|
|
|
await createRebuildFlagHandler(host as any, log).handle();
|
|
|
|
expect(host.mocks.ui.dialogManager.openWithExplicitCancel).toHaveBeenCalledWith(expect.anything(), {
|
|
isP2P: true,
|
|
});
|
|
});
|
|
|
|
it("should detect rebuild all flag using original filename", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
|
|
|
|
const handler = createRebuildFlagHandler(host as any, log);
|
|
const exists = await handler.check();
|
|
|
|
expect(exists).toBe(true);
|
|
});
|
|
|
|
it("should detect rebuild all flag using human-readable filename", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesHumanReadable.REBUILD_ALL);
|
|
|
|
const handler = createRebuildFlagHandler(host as any, log);
|
|
const exists = await handler.check();
|
|
|
|
expect(exists).toBe(true);
|
|
});
|
|
|
|
it("should clean up both original and human-readable rebuild all flag files", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
|
|
host.mocks.storageAccess.files.add(FlagFilesHumanReadable.REBUILD_ALL);
|
|
|
|
await deleteFlagFile(host as any, log, FlagFilesOriginal.REBUILD_ALL);
|
|
await deleteFlagFile(host as any, log, FlagFilesHumanReadable.REBUILD_ALL);
|
|
|
|
const originalExists = await host.mocks.storageAccess.isExists(
|
|
host.mocks.storageAccess.normalisePath(FlagFilesOriginal.REBUILD_ALL)
|
|
);
|
|
const humanExists = await host.mocks.storageAccess.isExists(
|
|
host.mocks.storageAccess.normalisePath(FlagFilesHumanReadable.REBUILD_ALL)
|
|
);
|
|
|
|
expect(originalExists).toBe(false);
|
|
expect(humanExists).toBe(false);
|
|
});
|
|
|
|
it("should have priority 20", () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
const handler = createRebuildFlagHandler(host as any, log);
|
|
expect(handler.priority).toBe(20);
|
|
});
|
|
});
|
|
|
|
describe("Flag file cleanup on error", () => {
|
|
it("should handle errors during flag file deletion gracefully", async () => {
|
|
const host = createHostMock();
|
|
|
|
// Simulate error in delete operation
|
|
host.mocks.storageAccess.delete.mockRejectedValueOnce(new Error("Delete failed"));
|
|
|
|
try {
|
|
await host.mocks.storageAccess.delete(FlagFilesOriginal.FETCH_ALL, true);
|
|
} catch {
|
|
// Error handled
|
|
}
|
|
|
|
expect(host.mocks.storageAccess.delete).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Integration: Handler registration on layout ready", () => {
|
|
it("should register handlers with correct priorities", () => {
|
|
const host = createHostMock();
|
|
|
|
expect(host.services.appLifecycle.onLayoutReady.addHandler).toBeDefined();
|
|
expect(typeof host.services.appLifecycle.onLayoutReady.addHandler).toBe("function");
|
|
});
|
|
});
|
|
|
|
describe("Dialog interaction scenarios", () => {
|
|
it("should handle fetch all dialog cancellation", async () => {
|
|
const host = createHostMock();
|
|
|
|
// Simulate user clicking cancel
|
|
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
|
|
|
|
// The dialog manager would return cancelled
|
|
const result = await host.mocks.ui.dialogManager.openWithExplicitCancel();
|
|
expect(result).toBe("cancelled");
|
|
});
|
|
|
|
it("should handle rebuild dialog cancellation", async () => {
|
|
const host = createHostMock();
|
|
|
|
// Simulate user clicking cancel
|
|
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
|
|
|
|
const result = await host.mocks.ui.dialogManager.openWithExplicitCancel();
|
|
expect(result).toBe("cancelled");
|
|
});
|
|
|
|
it("should handle confirm dialog for remote configuration mismatch", async () => {
|
|
const host = createHostMock();
|
|
|
|
await host.mocks.ui.confirm.askSelectStringDialogue("Your settings differed slightly.", ["OK"]);
|
|
expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Remote configuration adjustment", () => {
|
|
it("compatibility: preserves the local filename-case value when the remote omits it", async () => {
|
|
const host = createHostMock();
|
|
const config = {
|
|
...DEFAULT_SETTINGS,
|
|
...TweakValuesShouldMatchedTemplate,
|
|
handleFilenameCaseSensitive: false,
|
|
};
|
|
const remote: TweakValues = { ...TweakValuesShouldMatchedTemplate };
|
|
delete remote.handleFilenameCaseSensitive;
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(availableRemoteTweaks(remote));
|
|
|
|
await adjustSettingToRemote(host as any, createLoggerMock(), config);
|
|
|
|
expect(host.mocks.ui.confirm.askSelectStringDialogue).not.toHaveBeenCalled();
|
|
expect(host.mocks.setting.applyExternalSettings).toHaveBeenCalledWith(
|
|
expect.objectContaining({ handleFilenameCaseSensitive: false }),
|
|
true
|
|
);
|
|
});
|
|
|
|
it("keeps this device's E2EE settings when preparing to overwrite the remote", async () => {
|
|
const host = createHostMock();
|
|
Object.assign(host.mocks.setting.settings, TweakValuesShouldMatchedTemplate, {
|
|
encrypt: true,
|
|
passphrase: "local-encryption-passphrase",
|
|
});
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
|
availableRemoteTweaks({
|
|
...TweakValuesShouldMatchedTemplate,
|
|
encrypt: false,
|
|
})
|
|
);
|
|
|
|
const result = await adjustSettingToRemote(
|
|
host as any,
|
|
createLoggerMock(),
|
|
host.mocks.setting.currentSettings(),
|
|
"rebuild"
|
|
);
|
|
|
|
expect(result).toBe(true);
|
|
expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledOnce();
|
|
expect(host.mocks.setting.currentSettings().encrypt).toBe(true);
|
|
expect(host.mocks.setting.currentSettings().passphrase).toBe("local-encryption-passphrase");
|
|
expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should skip remote configuration fetch when preventFetchingConfig is true", async () => {
|
|
const host = createHostMock();
|
|
const config = { preventFetchingConfig: true } as any;
|
|
|
|
await adjustSettingToRemoteIfNeeded(
|
|
host as any,
|
|
createLoggerMock(),
|
|
{ preventFetchingConfig: true },
|
|
config
|
|
);
|
|
|
|
expect(host.mocks.tweakValue.fetchRemotePreferred).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should fetch remote configuration when preventFetchingConfig is false", async () => {
|
|
const host = createHostMock();
|
|
const config = { batchSave: true } as any;
|
|
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
|
availableRemoteTweaks({ batchSave: false })
|
|
);
|
|
|
|
await adjustSettingToRemoteIfNeeded(
|
|
host as any,
|
|
createLoggerMock(),
|
|
{ preventFetchingConfig: false },
|
|
config
|
|
);
|
|
|
|
expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalled();
|
|
});
|
|
|
|
const mismatchDetectionKeys = Object.keys(TweakValuesShouldMatchedTemplate);
|
|
it.each(mismatchDetectionKeys)(
|
|
"should apply remote configuration when available and different:%s",
|
|
async (key) => {
|
|
const host = createHostMock();
|
|
|
|
const config = { [key]: TweakValuesTemplate[key as keyof typeof TweakValuesTemplate] } as any;
|
|
const differentValue =
|
|
typeof config[key as keyof typeof config] === "boolean"
|
|
? !config[key as keyof typeof config]
|
|
: typeof config[key as keyof typeof config] === "number"
|
|
? (config[key as keyof typeof config] as number) + 1
|
|
: "different";
|
|
const differentConfig = {
|
|
[key]: differentValue,
|
|
};
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
|
availableRemoteTweaks(differentConfig)
|
|
);
|
|
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("OK");
|
|
|
|
await adjustSettingToRemote(host as any, createLoggerMock(), config);
|
|
expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalled();
|
|
expect(host.mocks.setting.applyExternalSettings).toHaveBeenCalled();
|
|
}
|
|
);
|
|
const mismatchAcceptedKeys = Object.keys(TweakValuesRecommendedTemplate).filter(
|
|
(key) => !mismatchDetectionKeys.includes(key)
|
|
);
|
|
|
|
it.each(mismatchAcceptedKeys)(
|
|
"should apply remote configuration when available and different but acceptable: %s",
|
|
async (key) => {
|
|
const host = createHostMock();
|
|
|
|
const config = { [key]: TweakValuesTemplate[key as keyof typeof TweakValuesTemplate] } as any;
|
|
const differentValue =
|
|
typeof config[key as keyof typeof config] === "boolean"
|
|
? !config[key as keyof typeof config]
|
|
: typeof config[key as keyof typeof config] === "number"
|
|
? (config[key as keyof typeof config] as number) + 1
|
|
: "different";
|
|
const differentConfig = {
|
|
[key]: differentValue,
|
|
};
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
|
availableRemoteTweaks(differentConfig)
|
|
);
|
|
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("OK");
|
|
|
|
await adjustSettingToRemote(host as any, createLoggerMock(), config);
|
|
|
|
expect(host.mocks.setting.applyExternalSettings).toHaveBeenCalled();
|
|
expect(host.mocks.ui.confirm.askSelectStringDialogue).not.toHaveBeenCalled();
|
|
}
|
|
);
|
|
|
|
it("should explain that missing remote settings are normal for a new database without offering retry", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
const config = { batchSave: true } as any;
|
|
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
|
status: "not-configured",
|
|
reason: "milestone-missing",
|
|
});
|
|
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Use this device's settings");
|
|
|
|
await expect(adjustSettingToRemote(host as any, log, config)).resolves.toBe(true);
|
|
|
|
expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalledWith(
|
|
"The selected remote has no saved synchronisation settings. This is normal for a new remote. Use this device's settings, or cancel if you expected existing settings.",
|
|
["Use this device's settings", "Cancel"],
|
|
{
|
|
defaultAction: "Use this device's settings",
|
|
timeout: 0,
|
|
title: "No Synchronisation Settings Found",
|
|
}
|
|
);
|
|
expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledOnce();
|
|
expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should retry only when remote settings are unavailable", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
const config = { batchSave: true } as any;
|
|
const failure = new Error("network failed");
|
|
|
|
host.mocks.tweakValue.fetchRemotePreferred
|
|
.mockResolvedValueOnce({ status: "unavailable", error: failure })
|
|
.mockResolvedValueOnce({ status: "available", values: { batchSave: false } } as any);
|
|
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Retry");
|
|
|
|
await expect(adjustSettingToRemote(host as any, log, config)).resolves.toBe(true);
|
|
|
|
expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledTimes(2);
|
|
expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalledWith(
|
|
"Could not read the remote's synchronisation settings. Check the connection and credentials, then retry.",
|
|
["Retry", "Cancel"],
|
|
{
|
|
defaultAction: "Retry",
|
|
timeout: 0,
|
|
title: "Could Not Read Synchronisation Settings",
|
|
}
|
|
);
|
|
});
|
|
|
|
it("should cancel initialisation instead of proceeding after an unavailable remote", async () => {
|
|
const host = createHostMock();
|
|
const config = { batchSave: true } as any;
|
|
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
|
status: "unavailable",
|
|
error: new Error("network failed"),
|
|
});
|
|
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel");
|
|
|
|
await expect(adjustSettingToRemote(host as any, createLoggerMock(), config)).resolves.toBe(false);
|
|
|
|
expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalledOnce();
|
|
expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should log when no changes needed", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
const config = { batchSave: false } as any;
|
|
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
|
availableRemoteTweaks({ batchSave: false })
|
|
);
|
|
|
|
await adjustSettingToRemote(host as any, log, config);
|
|
|
|
expect(log).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should handle null extra parameter in adjustSettingToRemoteIfNeeded", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
const config = { batchSave: true } as any;
|
|
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
|
status: "not-configured",
|
|
reason: "preferred-values-missing",
|
|
});
|
|
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Use this device's settings");
|
|
|
|
await adjustSettingToRemoteIfNeeded(host as any, log, null as any, config);
|
|
|
|
expect(host.mocks.tweakValue.fetchRemotePreferred).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("MinIO configuration handling", () => {
|
|
it("should not enable makeLocalChunkBeforeSync when remote is MinIO", () => {
|
|
const host = createHostMock();
|
|
host.mocks.setting.settings.remoteType = REMOTE_MINIO;
|
|
|
|
const settings = host.mocks.setting.currentSettings();
|
|
const isMinIO = settings.remoteType === REMOTE_MINIO;
|
|
|
|
expect(isMinIO).toBe(true);
|
|
});
|
|
|
|
it("should enable makeLocalChunkBeforeSync for non-MinIO remotes", () => {
|
|
const host = createHostMock();
|
|
host.mocks.setting.settings.remoteType = "CouchDB";
|
|
|
|
const settings = host.mocks.setting.currentSettings();
|
|
const isMinIO = settings.remoteType === REMOTE_MINIO;
|
|
|
|
expect(isMinIO).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Suspension unlock verification", () => {
|
|
it("should return true when suspension is not active", async () => {
|
|
const host = createHostMock();
|
|
|
|
const result = await verifyAndUnlockSuspension(host as any, createLoggerMock());
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should ask for confirmation when suspension is active", async () => {
|
|
const host = createHostMock();
|
|
|
|
await host.mocks.setting.applyPartial({ suspendFileWatching: true });
|
|
|
|
host.mocks.ui.confirm.askYesNoDialog.mockResolvedValueOnce("yes");
|
|
|
|
await verifyAndUnlockSuspension(host as any, createLoggerMock());
|
|
|
|
expect(host.mocks.ui.confirm.askYesNoDialog).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should return true when user declines suspension unlock", async () => {
|
|
const host = createHostMock();
|
|
|
|
await host.mocks.setting.applyPartial({ suspendFileWatching: true });
|
|
host.mocks.ui.confirm.askYesNoDialog.mockResolvedValueOnce("no");
|
|
|
|
const result = await verifyAndUnlockSuspension(host as any, createLoggerMock());
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should resume file watching and restart when user accepts", async () => {
|
|
const host = createHostMock();
|
|
|
|
await host.mocks.setting.applyPartial({ suspendFileWatching: true }, true);
|
|
host.mocks.ui.confirm.askYesNoDialog.mockResolvedValueOnce("yes");
|
|
|
|
await verifyAndUnlockSuspension(host as any, createLoggerMock());
|
|
|
|
expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("Error handling in vault initialization", () => {
|
|
it("should handle errors during initialisation gracefully", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
const result = await processVaultInitialisation(host as any, log, () => {
|
|
throw new Error("Initialization failed");
|
|
});
|
|
|
|
expect(result).toBe(false);
|
|
expect(log).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should track log calls during error conditions", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
await processVaultInitialisation(host as any, log, () => {
|
|
throw new Error("Test error");
|
|
});
|
|
|
|
expect(log).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should keep suspension state when error occurs during initialization", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
await processVaultInitialisation(
|
|
host as any,
|
|
log,
|
|
() => {
|
|
return Promise.resolve(false);
|
|
},
|
|
"keep"
|
|
);
|
|
|
|
expect(host.mocks.setting.currentSettings().suspendFileWatching).toBe(true);
|
|
});
|
|
|
|
it("should handle applySetting error in processVaultInitialisation", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.setting.applyPartial.mockRejectedValueOnce(new Error("Apply partial failed"));
|
|
|
|
const result = await processVaultInitialisation(host as any, log, () => {
|
|
return Promise.resolve(true);
|
|
});
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("Flag file detection with both formats", () => {
|
|
it("should detect either original or human-readable fetch all flag", async () => {
|
|
const host = createHostMock();
|
|
|
|
// Add only human-readable flag
|
|
host.mocks.storageAccess.files.add(FlagFilesHumanReadable.FETCH_ALL);
|
|
|
|
const humanExists = await host.mocks.storageAccess.isExists(
|
|
host.mocks.storageAccess.normalisePath(FlagFilesHumanReadable.FETCH_ALL)
|
|
);
|
|
|
|
expect(humanExists).toBe(true);
|
|
});
|
|
|
|
it("should detect either original or human-readable rebuild flag", async () => {
|
|
const host = createHostMock();
|
|
|
|
// Add only original flag
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
|
|
|
|
const originalExists = await host.mocks.storageAccess.isExists(
|
|
host.mocks.storageAccess.normalisePath(FlagFilesOriginal.REBUILD_ALL)
|
|
);
|
|
|
|
expect(originalExists).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Handler execution", () => {
|
|
it("should execute fetch all handler check method", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
const result = await handler.check();
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should execute rebuild handler check method", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
|
|
|
|
const handler = createRebuildFlagHandler(host as any, log);
|
|
const result = await handler.check();
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should execute suspend handler check method", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.SUSPEND_ALL);
|
|
|
|
const handler = createSuspendFlagHandler(host as any, log);
|
|
const result = await handler.check();
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should return false when flag does not exist", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
const result = await handler.check();
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it("should return correct priority for each handler", () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
const suspendHandler = createSuspendFlagHandler(host as any, log);
|
|
const fetchHandler = createFetchAllFlagHandler(host as any, log);
|
|
const rebuildHandler = createRebuildFlagHandler(host as any, log);
|
|
|
|
expect(suspendHandler.priority).toBe(5);
|
|
expect(fetchHandler.priority).toBe(10);
|
|
expect(rebuildHandler.priority).toBe(20);
|
|
});
|
|
|
|
it("should handle suspend flag and execute handler", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.SUSPEND_ALL);
|
|
|
|
const handler = createSuspendFlagHandler(host as any, log);
|
|
const checkResult = await handler.check();
|
|
|
|
expect(checkResult).toBe(true);
|
|
expect(typeof handler.handle).toBe("function");
|
|
});
|
|
|
|
it("should have handle method for all handlers", () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
const suspendHandler = createSuspendFlagHandler(host as any, log);
|
|
const fetchHandler = createFetchAllFlagHandler(host as any, log);
|
|
const rebuildHandler = createRebuildFlagHandler(host as any, log);
|
|
|
|
expect(typeof suspendHandler.handle).toBe("function");
|
|
expect(typeof fetchHandler.handle).toBe("function");
|
|
expect(typeof rebuildHandler.handle).toBe("function");
|
|
});
|
|
});
|
|
|
|
describe("Multiple concurrent operations", () => {
|
|
it("should handle multiple flag files existing simultaneously", async () => {
|
|
const host = createHostMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.SUSPEND_ALL);
|
|
|
|
const fetchExists = await host.mocks.storageAccess.isExists(
|
|
host.mocks.storageAccess.normalisePath(FlagFilesOriginal.FETCH_ALL)
|
|
);
|
|
const rebuildExists = await host.mocks.storageAccess.isExists(
|
|
host.mocks.storageAccess.normalisePath(FlagFilesOriginal.REBUILD_ALL)
|
|
);
|
|
const suspendExists = await host.mocks.storageAccess.isExists(
|
|
host.mocks.storageAccess.normalisePath(FlagFilesOriginal.SUSPEND_ALL)
|
|
);
|
|
|
|
expect(fetchExists).toBe(true);
|
|
expect(rebuildExists).toBe(true);
|
|
expect(suspendExists).toBe(true);
|
|
});
|
|
|
|
it("should cleanup all flags when processing completes", async () => {
|
|
const host = createHostMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesHumanReadable.FETCH_ALL);
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
|
|
await host.mocks.storageAccess.delete(FlagFilesHumanReadable.FETCH_ALL);
|
|
await host.mocks.storageAccess.delete(FlagFilesOriginal.FETCH_ALL);
|
|
|
|
expect(host.mocks.storageAccess.files.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("Setting state transitions", () => {
|
|
it("should apply complete state transition for initialization", async () => {
|
|
const host = createHostMock();
|
|
|
|
// Initial state
|
|
const initialState = {
|
|
batchSave: host.mocks.setting.currentSettings().batchSave,
|
|
suspendFileWatching: host.mocks.setting.currentSettings().suspendFileWatching,
|
|
};
|
|
|
|
// Initialization state
|
|
await host.mocks.setting.applyPartial(
|
|
{
|
|
batchSave: false,
|
|
suspendFileWatching: true,
|
|
},
|
|
true
|
|
);
|
|
|
|
const initState = {
|
|
batchSave: host.mocks.setting.currentSettings().batchSave,
|
|
suspendFileWatching: host.mocks.setting.currentSettings().suspendFileWatching,
|
|
};
|
|
|
|
// Post-initialization state
|
|
await host.mocks.setting.applyPartial(
|
|
{
|
|
batchSave: true,
|
|
suspendFileWatching: false,
|
|
},
|
|
true
|
|
);
|
|
|
|
const finalState = {
|
|
batchSave: host.mocks.setting.currentSettings().batchSave,
|
|
suspendFileWatching: host.mocks.setting.currentSettings().suspendFileWatching,
|
|
};
|
|
|
|
expect(initialState.batchSave).toBe(true);
|
|
expect(initialState.suspendFileWatching).toBe(false);
|
|
|
|
expect(initState.batchSave).toBe(false);
|
|
expect(initState.suspendFileWatching).toBe(true);
|
|
|
|
expect(finalState.batchSave).toBe(true);
|
|
expect(finalState.suspendFileWatching).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("flagHandlerToEventHandler integration", () => {
|
|
it("should stop a detailed Fetch when remote-setting initialisation is cancelled", async () => {
|
|
const host = createHostMock();
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
|
|
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({
|
|
vault: "independent",
|
|
extra: { preventFetchingConfig: false },
|
|
});
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
|
status: "not-configured",
|
|
reason: "milestone-missing",
|
|
});
|
|
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel");
|
|
|
|
const result = await createFetchAllFlagHandler(host as any, createLoggerMock()).handle();
|
|
|
|
expect(result).toBe(false);
|
|
expect(host.mocks.rebuilder.$fetchLocal).not.toHaveBeenCalled();
|
|
expect(host.mocks.setting.suspendAllSync).not.toHaveBeenCalled();
|
|
expect(host.mocks.setting.applyPartial).toHaveBeenCalledWith(
|
|
{
|
|
suspendFileWatching: true,
|
|
suspendParseReplicationResult: true,
|
|
},
|
|
true
|
|
);
|
|
expect(host.mocks.storageAccess.files.has(FlagFilesOriginal.FETCH_ALL)).toBe(false);
|
|
expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("should stop Rebuild before deleting local data when remote-setting initialisation is cancelled", async () => {
|
|
const host = createHostMock();
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
|
|
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({
|
|
extra: { preventFetchingConfig: false },
|
|
});
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
|
status: "not-configured",
|
|
reason: "milestone-missing",
|
|
});
|
|
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Cancel");
|
|
|
|
const result = await createRebuildFlagHandler(host as any, createLoggerMock()).handle();
|
|
|
|
expect(result).toBe(false);
|
|
expect(host.mocks.rebuilder.$rebuildEverything).not.toHaveBeenCalled();
|
|
expect(host.mocks.setting.suspendAllSync).not.toHaveBeenCalled();
|
|
expect(host.mocks.setting.applyPartial).toHaveBeenCalledWith(
|
|
{
|
|
suspendFileWatching: true,
|
|
suspendParseReplicationResult: true,
|
|
},
|
|
true
|
|
);
|
|
expect(host.mocks.storageAccess.files.has(FlagFilesOriginal.REBUILD_ALL)).toBe(false);
|
|
expect(host.mocks.appLifecycle.performRestart).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("should let Rebuild use this device's settings when remote settings are unavailable", async () => {
|
|
const host = createHostMock();
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
|
|
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({
|
|
extra: { preventFetchingConfig: false },
|
|
});
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce({
|
|
status: "unavailable",
|
|
error: new Error("network failed"),
|
|
});
|
|
host.mocks.ui.confirm.askSelectStringDialogue.mockResolvedValueOnce("Use this device's settings");
|
|
|
|
const result = await createRebuildFlagHandler(host as any, createLoggerMock()).handle();
|
|
|
|
expect(result).toBe(true);
|
|
expect(host.mocks.ui.confirm.askSelectStringDialogue).toHaveBeenCalledWith(
|
|
"Could not read the remote's synchronisation settings. Retry, or continue the overwrite with this device's settings. A working connection is still required.",
|
|
["Retry", "Use this device's settings", "Cancel"],
|
|
{
|
|
defaultAction: "Retry",
|
|
timeout: 0,
|
|
title: "Could Not Read Synchronisation Settings",
|
|
}
|
|
);
|
|
expect(host.mocks.rebuilder.$rebuildEverything).toHaveBeenCalledOnce();
|
|
expect(host.mocks.storageAccess.files.has(FlagFilesOriginal.REBUILD_ALL)).toBe(false);
|
|
expect(host.mocks.appLifecycle.performRestart).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should return true when flag does not exist", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
const eventHandler = flagHandlerToEventHandler(handler);
|
|
|
|
const result = await eventHandler();
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should execute handle when flag exists and check returns true", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(availableRemoteTweaks({}));
|
|
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
|
|
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
|
|
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
const eventHandler = flagHandlerToEventHandler(handler);
|
|
|
|
await eventHandler();
|
|
|
|
// When dialog is cancelled, handle returns false
|
|
expect(host.mocks.ui.dialogManager.openWithExplicitCancel).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should return handle result when flag exists", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.SUSPEND_ALL);
|
|
|
|
const handler = createSuspendFlagHandler(host as any, log);
|
|
const eventHandler = flagHandlerToEventHandler(handler);
|
|
|
|
const result = await eventHandler();
|
|
|
|
// Suspend handler execution results in false from processVaultInitialisation
|
|
expect(typeof result).toBe("boolean");
|
|
});
|
|
|
|
it("should not call handle when check returns false", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
const handler = createRebuildFlagHandler(host as any, log);
|
|
const handleSpy = vi.spyOn(handler, "handle");
|
|
const eventHandler = flagHandlerToEventHandler(handler);
|
|
|
|
const result = await eventHandler();
|
|
|
|
// Check returns false because no rebuild flag exists
|
|
expect(handleSpy).not.toHaveBeenCalled();
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it("should handle rebuild flag with flagHandlerToEventHandler", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
|
|
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce("cancelled");
|
|
|
|
const handler = createRebuildFlagHandler(host as any, log);
|
|
const eventHandler = flagHandlerToEventHandler(handler);
|
|
|
|
await eventHandler();
|
|
|
|
expect(host.mocks.ui.dialogManager.openWithExplicitCancel).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should propagate errors from handle method", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
host.mocks.ui.dialogManager.openWithExplicitCancel.mockRejectedValueOnce(new Error("Dialog failed"));
|
|
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
const eventHandler = flagHandlerToEventHandler(handler);
|
|
|
|
try {
|
|
await eventHandler();
|
|
} catch (error) {
|
|
expect((error as Error).message).toBe("Dialog failed");
|
|
}
|
|
});
|
|
|
|
it("should handle fetchAll flag with flagHandlerToEventHandler identical", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue(availableRemoteTweaks({ customChunkSize: 1 }));
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.FETCH_ALL);
|
|
host.mocks.ui.confirm.confirmWithMessage.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_DETAILED);
|
|
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({ vault: "identical", extra: {} });
|
|
host.mocks.rebuilder.$fetchLocal.mockResolvedValueOnce();
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
const eventHandler = flagHandlerToEventHandler(handler);
|
|
|
|
await Promise.resolve(eventHandler());
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(host.mocks.rebuilder.$fetchLocal).toHaveBeenCalled();
|
|
|
|
expect(host.mocks.ui.dialogManager.openWithExplicitCancel).toHaveBeenCalled();
|
|
});
|
|
it("should handle rebuildAll flag with flagHandlerToEventHandler", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
Object.assign(host.mocks.setting.settings, TweakValuesShouldMatchedTemplate, {
|
|
encrypt: true,
|
|
passphrase: "local-encryption-passphrase",
|
|
});
|
|
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValueOnce(
|
|
availableRemoteTweaks({
|
|
...TweakValuesShouldMatchedTemplate,
|
|
encrypt: false,
|
|
})
|
|
);
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.REBUILD_ALL);
|
|
host.mocks.ui.dialogManager.openWithExplicitCancel.mockResolvedValueOnce({ extra: {} });
|
|
host.mocks.rebuilder.$rebuildEverything.mockResolvedValueOnce();
|
|
const handler = createRebuildFlagHandler(host as any, log);
|
|
const eventHandler = flagHandlerToEventHandler(handler);
|
|
|
|
await Promise.resolve(eventHandler());
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(host.mocks.rebuilder.$rebuildEverything).toHaveBeenCalled();
|
|
expect(host.mocks.setting.currentSettings().encrypt).toBe(true);
|
|
expect(host.mocks.setting.applyExternalSettings).not.toHaveBeenCalled();
|
|
|
|
expect(host.mocks.ui.dialogManager.openWithExplicitCancel).toHaveBeenCalled();
|
|
});
|
|
|
|
it("should execute all handlers in sequence", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
const suspendHandler = createSuspendFlagHandler(host as any, log);
|
|
const fetchHandler = createFetchAllFlagHandler(host as any, log);
|
|
const rebuildHandler = createRebuildFlagHandler(host as any, log);
|
|
|
|
const suspendEvent = flagHandlerToEventHandler(suspendHandler);
|
|
const fetchEvent = flagHandlerToEventHandler(fetchHandler);
|
|
const rebuildEvent = flagHandlerToEventHandler(rebuildHandler);
|
|
|
|
// All should return true when flags don't exist
|
|
expect(await suspendEvent()).toBe(true);
|
|
expect(await fetchEvent()).toBe(true);
|
|
expect(await rebuildEvent()).toBe(true);
|
|
});
|
|
|
|
it("should return false from handle when suspending", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.files.add(FlagFilesOriginal.SUSPEND_ALL);
|
|
|
|
const handler = createSuspendFlagHandler(host as any, log);
|
|
const eventHandler = flagHandlerToEventHandler(handler);
|
|
|
|
const result = await eventHandler();
|
|
|
|
// Suspend handler returns false from its handle method
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it("should handle check error gracefully", async () => {
|
|
const host = createHostMock();
|
|
const log = createLoggerMock();
|
|
|
|
host.mocks.storageAccess.isExists.mockRejectedValueOnce(new Error("Check failed"));
|
|
|
|
const handler = createFetchAllFlagHandler(host as any, log);
|
|
const eventHandler = flagHandlerToEventHandler(handler);
|
|
|
|
try {
|
|
await eventHandler();
|
|
} catch (error) {
|
|
expect((error as Error).message).toBe("Check failed");
|
|
}
|
|
});
|
|
});
|
|
});
|