Files
obsidian-livesync/src/features/LocalDatabaseMainte/CmdLocalDatabaseMainte.unit.spec.ts
T
2026-07-24 17:53:22 +00:00

422 lines
16 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
vi.mock("octagonal-wheels/number", () => ({
sizeToHumanReadable: vi.fn((value: number) => `${value} B`),
}));
vi.mock("octagonal-wheels/concurrency/lock_v2", () => ({
serialized: vi.fn((_key: string, task: () => unknown) => task()),
}));
vi.mock("octagonal-wheels/collection", () => ({
arrayToChunkedArray: vi.fn((values: unknown[]) => [values]),
}));
vi.mock("@vrtmrz/livesync-commonlib/compat/common/utils", async (importOriginal) => {
const actual = await importOriginal<typeof import("@vrtmrz/livesync-commonlib/compat/common/utils")>();
return {
...actual,
delay: vi.fn(async () => undefined),
};
});
vi.mock("@/features/LiveSyncCommands", () => ({
LiveSyncCommands: class LiveSyncCommands {
core!: { settings: unknown };
get settings() {
return this.core.settings;
}
},
}));
vi.mock("@/common/events", () => ({
EVENT_ANALYSE_DB_USAGE: "analyse",
EVENT_REQUEST_PERFORM_GC_V3: "gc",
eventHub: {
onEvent: vi.fn(),
},
}));
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
REMOTE_MINIO,
REMOTE_P2P,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
import { LocalDatabaseMaintenance } from "./CmdLocalDatabaseMainte";
import { ensureLocalDatabaseMaintenancePrerequisites } from "./maintenancePrerequisites";
function createPrerequisites(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
const askSelectStringDialogue = vi.fn(
async (
_message: string,
_buttons: readonly ["Apply and continue", "Cancel"],
_options: { title: string; defaultAction: "Cancel" }
): Promise<"Apply and continue" | "Cancel" | false | undefined> => "Apply and continue"
);
const applyPartial = vi.fn(async () => undefined);
const settings = {
...DEFAULT_SETTINGS,
doNotUseFixedRevisionForChunks: false,
readChunksOnline: true,
...settingsOverride,
};
return { settings, askSelectStringDialogue, applyPartial };
}
describe("LocalDatabaseMaintenance prerequisites", () => {
it("shows database analysis in Advanced mode and Garbage Collection only in applicable Edge Case mode", () => {
const commands: Array<{
id: string;
checkCallback?: (checking: boolean) => boolean | void;
}> = [];
const settings: {
useAdvancedMode: boolean;
useEdgeCaseMode: boolean;
remoteType: string;
} = {
useAdvancedMode: false,
useEdgeCaseMode: false,
remoteType: REMOTE_COUCHDB,
};
const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
Object.assign(maintenance, {
plugin: {
addCommand: vi.fn((command) => commands.push(command)),
},
core: {
settings,
},
_isDatabaseReady: vi.fn(() => true),
});
maintenance.onload();
const analyse = commands.find(({ id }) => id === "analyse-database");
const garbageCollect = commands.find(({ id }) => id === "gc-v3");
expect(analyse?.checkCallback?.(true)).toBe(false);
expect(garbageCollect?.checkCallback?.(true)).toBe(false);
settings.useAdvancedMode = true;
expect(analyse?.checkCallback?.(true)).toBe(true);
expect(garbageCollect?.checkCallback?.(true)).toBe(false);
settings.useEdgeCaseMode = true;
expect(garbageCollect?.checkCallback?.(true)).toBe(true);
settings.remoteType = REMOTE_P2P;
expect(garbageCollect?.checkCallback?.(true)).toBe(false);
settings.remoteType = REMOTE_MINIO;
expect(garbageCollect?.checkCallback?.(true)).toBe(false);
});
it("asks to disable on-demand chunk fetching before maintenance actions", async () => {
const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites();
const result = await ensureLocalDatabaseMaintenancePrerequisites({
operationName: "Garbage Collection",
settings: {
readChunksOnline: settings.readChunksOnline,
},
askSelectStringDialogue,
applyPartial,
});
expect(result).toBe(true);
expect(askSelectStringDialogue).toHaveBeenCalledWith(
expect.stringContaining("Garbage Collection requires the following settings"),
["Apply and continue", "Cancel"],
{
title: "Garbage Collection prerequisites",
defaultAction: "Cancel",
}
);
expect(applyPartial).toHaveBeenCalledWith(
{
readChunksOnline: false,
},
true
);
expect(vi.mocked(askSelectStringDialogue).mock.calls[0]?.[0]).not.toContain("Compute revisions for chunks");
});
it("cancels maintenance actions when prerequisite changes are rejected", async () => {
const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites();
askSelectStringDialogue.mockResolvedValueOnce("Cancel");
const result = await ensureLocalDatabaseMaintenancePrerequisites({
operationName: "Garbage Collection",
settings: {
readChunksOnline: settings.readChunksOnline,
},
askSelectStringDialogue,
applyPartial,
});
expect(result).toBe(false);
expect(applyPartial).not.toHaveBeenCalled();
});
it("continues without asking when prerequisite settings already match", async () => {
const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites({
doNotUseFixedRevisionForChunks: true,
readChunksOnline: false,
});
const result = await ensureLocalDatabaseMaintenancePrerequisites({
operationName: "Garbage Collection",
settings: {
readChunksOnline: settings.readChunksOnline,
},
askSelectStringDialogue,
applyPartial,
});
expect(askSelectStringDialogue).not.toHaveBeenCalled();
expect(applyPartial).not.toHaveBeenCalled();
});
it("retirement guard: ignores the obsolete fixed-revision key as a maintenance prerequisite", async () => {
const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites({
doNotUseFixedRevisionForChunks: false,
readChunksOnline: false,
});
const result = await ensureLocalDatabaseMaintenancePrerequisites({
operationName: "Garbage Collection",
settings: {
readChunksOnline: settings.readChunksOnline,
},
askSelectStringDialogue,
applyPartial,
});
expect(result).toBe(true);
expect(askSelectStringDialogue).not.toHaveBeenCalled();
expect(applyPartial).not.toHaveBeenCalled();
});
it("describes the current chunk-recreation action without promising historical recovery", async () => {
const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
const askSelectStringDialogue = vi.fn().mockResolvedValue("Cancel");
Object.assign(maintenance, {
core: {
confirm: {
askSelectStringDialogue,
},
},
_log: vi.fn(),
});
vi.spyOn(maintenance, "ensureAvailable").mockResolvedValue(true);
vi.spyOn(maintenance, "trackChanges").mockResolvedValue(undefined);
await maintenance.performGC();
const message = vi.mocked(askSelectStringDialogue).mock.calls[0]?.[0] as string;
expect(message).toContain("Hatch -> Recreate chunks for current Vault files");
expect(message).toContain("only from files currently present in the Vault");
expect(message).not.toContain("Recreate missing chunks for all files");
});
});
describe("LocalDatabaseMaintenance Garbage Collection V3", () => {
it("does not report remote compaction as successful after its completion wait times out", async () => {
const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
const notice = vi.fn();
const remoteDatabase = {
compact: vi.fn(async () => ({ ok: true })),
info: vi.fn(async () => ({ compact_running: true })),
};
Object.assign(maintenance, {
core: {
replicator: {
connectRemoteCouchDBWithSetting: vi.fn(async () => ({ db: remoteDatabase })),
},
settings: {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
},
},
_notice: notice,
});
await maintenance.compactDatabase();
expect(notice).toHaveBeenCalledWith("Compaction on remote database timed out.", "gc-compact");
expect(notice).not.toHaveBeenCalledWith(
"Compaction on remote database completed successfully.",
"gc-compact"
);
});
it.each([
["no device progress entries", {}],
[
"an unparseable device progress entry",
{
"device-a": {
progress: "",
device_name: "Device A",
app_version: "1.12.7",
plugin_version: "1.0.0-beta.0",
},
},
],
[
"a missing device progress entry",
{
"device-a": {
device_name: "Device A",
app_version: "1.12.7",
plugin_version: "1.0.0-beta.0",
},
},
],
] as const)("cancels before collection when the milestone has %s", async (_case, nodeInfo) => {
const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
const pushModes: string[] = [];
const allChunks = vi.fn(async () => ({
used: new Set<string>(),
existing: new Map(),
}));
const replicator = {
openOneShotReplication: vi.fn(async (...args: unknown[]) => {
pushModes.push(String(args[3]));
return true;
}),
getConnectedDeviceList: vi.fn(async () => ({
accepted_nodes: Object.keys(nodeInfo),
node_info: nodeInfo,
})),
};
const notice = vi.fn();
Object.assign(maintenance, {
core: {
settings: {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
},
replicator,
confirm: {
askSelectStringDialogue: vi.fn(async () => "Proceed Garbage Collection"),
},
},
localDatabase: {
allChunks,
localDatabase: {
bulkDocs: vi.fn(async () => []),
},
},
_notice: notice,
});
vi.spyOn(maintenance, "ensureAvailable").mockResolvedValue(true);
vi.spyOn(maintenance, "compactDatabase").mockResolvedValue(undefined);
vi.spyOn(maintenance, "clearHash").mockImplementation(() => undefined);
await maintenance.gcv3();
expect(allChunks).not.toHaveBeenCalled();
expect(pushModes).toEqual(["sync"]);
expect(notice).toHaveBeenCalledWith(
"No connected device information found. Cancelling Garbage Collection."
);
});
it("keeps chunks referenced by a live conflict revision and deletes only unreachable chunks", async () => {
const maintenance = Object.create(LocalDatabaseMaintenance.prototype) as LocalDatabaseMaintenance;
const pushModes: string[] = [];
const deletedChunks: Array<{ _id: string; _rev?: string; _deleted?: boolean }> = [];
const allChunks = vi.fn(async () => ({
used: new Set(["h:winner", "h:conflict"]),
existing: new Map([
["h:winner", { _id: "h:winner", _rev: "1-winner", type: "leaf", data: "winner" }],
["h:conflict", { _id: "h:conflict", _rev: "1-conflict", type: "leaf", data: "conflict" }],
["h:obsolete", { _id: "h:obsolete", _rev: "1-obsolete", type: "leaf", data: "obsolete" }],
]),
}));
const rawDocuments = new Map<string, object>([
[
"note.md",
{
_id: "note.md",
_rev: "2-winner",
_conflicts: ["2-conflict"],
type: "plain",
children: ["h:winner"],
},
],
["h:winner", { _id: "h:winner", _rev: "1-winner", type: "leaf", data: "winner" }],
["h:conflict", { _id: "h:conflict", _rev: "1-conflict", type: "leaf", data: "conflict" }],
["h:obsolete", { _id: "h:obsolete", _rev: "1-obsolete", type: "leaf", data: "obsolete" }],
]);
const findEntryNames = vi.fn(async function* () {
yield* rawDocuments.keys();
});
const getRaw = vi.fn(async (id: string) => rawDocuments.get(id));
const localDatabase = {
allChunks,
localDatabase: {
info: vi.fn(async () => ({ doc_count: rawDocuments.size })),
bulkDocs: vi.fn(async (docs: Array<{ _id: string; _rev?: string; _deleted?: boolean }>) => {
deletedChunks.push(...docs);
return docs.map(({ _id }) => ({ ok: true, id: _id, rev: "2-deleted" }));
}),
},
findEntryNames,
getRaw,
};
const replicator = {
openOneShotReplication: vi.fn(
async (
_settings: typeof DEFAULT_SETTINGS,
_showResult: boolean,
_ignoreCleanLock: boolean,
mode: string
) => {
pushModes.push(mode);
return true;
}
),
getConnectedDeviceList: vi.fn(async () => ({
accepted_nodes: ["device-a"],
node_info: {
"device-a": {
progress: "10-local",
device_name: "Device A",
app_version: "1.12.7",
plugin_version: "1.0.0-beta.0",
},
},
})),
};
Object.assign(maintenance, {
core: {
settings: {
...DEFAULT_SETTINGS,
remoteType: REMOTE_COUCHDB,
},
replicator,
confirm: {
askSelectStringDialogue: vi.fn(async () => "Proceed Garbage Collection"),
},
},
localDatabase,
_notice: vi.fn(),
});
vi.spyOn(maintenance, "ensureAvailable").mockResolvedValue(true);
vi.spyOn(maintenance, "compactDatabase").mockResolvedValue(undefined);
vi.spyOn(maintenance, "clearHash").mockImplementation(() => undefined);
await maintenance.gcv3();
expect(allChunks).toHaveBeenCalledOnce();
expect(findEntryNames).not.toHaveBeenCalled();
expect(getRaw).not.toHaveBeenCalled();
expect(deletedChunks).toEqual([
{
_id: "h:obsolete",
_rev: "1-obsolete",
_deleted: true,
},
]);
expect(pushModes).toEqual(["sync", "pushOnly"]);
expect(maintenance.compactDatabase).toHaveBeenCalledOnce();
});
});