mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-26 13:27:05 +00:00
247 lines
8.9 KiB
TypeScript
247 lines
8.9 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import type {
|
|
FilePath,
|
|
FilePathWithPrefix,
|
|
LoadedEntry,
|
|
MetaEntry,
|
|
UXFileInfo,
|
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
|
import { restoreDocumentHistoryRevision, type DocumentHistoryRestorationCore } from "./documentHistoryRestoration";
|
|
|
|
const path = "history.md" as FilePathWithPrefix;
|
|
const currentPath = "History.md" as FilePathWithPrefix;
|
|
|
|
function createSource(): LoadedEntry {
|
|
return {
|
|
_id: "f:history",
|
|
_rev: "2-source",
|
|
path,
|
|
ctime: 10,
|
|
mtime: 20,
|
|
size: 18,
|
|
type: "plain",
|
|
datatype: "plain",
|
|
children: ["h:source"],
|
|
data: ["historical content"],
|
|
eden: {},
|
|
} as LoadedEntry;
|
|
}
|
|
|
|
function createCurrent(): MetaEntry {
|
|
return {
|
|
_id: "f:history",
|
|
_rev: "4-deleted",
|
|
path: currentPath,
|
|
ctime: 10,
|
|
mtime: 40,
|
|
size: 18,
|
|
type: "plain",
|
|
children: ["h:current"],
|
|
deleted: true,
|
|
eden: {},
|
|
} as MetaEntry;
|
|
}
|
|
|
|
function createCore(
|
|
overrides: {
|
|
source?: LoadedEntry | false;
|
|
current?: MetaEntry | false;
|
|
storedRevision?: string | false;
|
|
reflected?: boolean;
|
|
reflectionError?: unknown;
|
|
conflicts?: string[];
|
|
conflictCheckError?: unknown;
|
|
} = {}
|
|
) {
|
|
const calls: string[] = [];
|
|
const fetchEntry = vi.fn(async () => {
|
|
calls.push("read-source");
|
|
return overrides.source === undefined ? createSource() : overrides.source;
|
|
});
|
|
const fetchEntryMeta = vi.fn(async () => {
|
|
calls.push("read-current");
|
|
return overrides.current === undefined ? createCurrent() : overrides.current;
|
|
});
|
|
const storeWithLiveBaseRevision = vi.fn(async (_file: UXFileInfo) => {
|
|
calls.push("store");
|
|
return overrides.storedRevision === undefined ? "5-restored" : overrides.storedRevision;
|
|
});
|
|
const getConflictedRevs = vi.fn(async () => {
|
|
calls.push("check-conflicts");
|
|
if (overrides.conflictCheckError !== undefined) {
|
|
throw overrides.conflictCheckError;
|
|
}
|
|
return overrides.conflicts ?? [];
|
|
});
|
|
const dbToStorageWithSpecificRev = vi.fn(async () => {
|
|
calls.push("reflect");
|
|
if (overrides.reflectionError !== undefined) {
|
|
throw overrides.reflectionError;
|
|
}
|
|
return overrides.reflected ?? true;
|
|
});
|
|
const core: DocumentHistoryRestorationCore = {
|
|
databaseFileAccess: {
|
|
fetchEntry,
|
|
fetchEntryMeta,
|
|
getConflictedRevs,
|
|
storeWithLiveBaseRevision,
|
|
},
|
|
fileHandler: {
|
|
dbToStorageWithSpecificRev,
|
|
},
|
|
};
|
|
return {
|
|
calls,
|
|
core,
|
|
dbToStorageWithSpecificRev,
|
|
fetchEntry,
|
|
fetchEntryMeta,
|
|
getConflictedRevs,
|
|
storeWithLiveBaseRevision,
|
|
};
|
|
}
|
|
|
|
describe("restoreDocumentHistoryRevision", () => {
|
|
it("stores historical bytes below the current deleted winner before reflecting the exact new revision", async () => {
|
|
const { calls, core, dbToStorageWithSpecificRev, fetchEntry, fetchEntryMeta, storeWithLiveBaseRevision } =
|
|
createCore();
|
|
|
|
await expect(restoreDocumentHistoryRevision(core, path, "2-source", { now: () => 50 })).resolves.toEqual({
|
|
status: "restored",
|
|
path: "History.md" as FilePath,
|
|
revision: "5-restored",
|
|
conflictsRemain: false,
|
|
});
|
|
|
|
expect(calls).toEqual(["read-source", "read-current", "store", "reflect", "check-conflicts"]);
|
|
expect(fetchEntry).toHaveBeenCalledWith(path, "2-source", true, true);
|
|
expect(fetchEntryMeta).toHaveBeenCalledWith(path, undefined, true);
|
|
expect(storeWithLiveBaseRevision).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
name: "History.md",
|
|
path: "History.md",
|
|
stat: {
|
|
ctime: 10,
|
|
mtime: 50,
|
|
size: 18,
|
|
type: "file",
|
|
},
|
|
body: expect.any(Blob),
|
|
}),
|
|
"4-deleted",
|
|
true
|
|
);
|
|
const storedFile = storeWithLiveBaseRevision.mock.calls[0][0];
|
|
await expect(storedFile.body.text()).resolves.toBe("historical content");
|
|
expect(storedFile.deleted).toBeUndefined();
|
|
expect(dbToStorageWithSpecificRev).toHaveBeenCalledWith("History.md", "5-restored", true);
|
|
});
|
|
|
|
it("leaves the Vault unchanged when the conditional database write is refused", async () => {
|
|
const { calls, core, dbToStorageWithSpecificRev } = createCore({ storedRevision: false });
|
|
|
|
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
|
|
status: "database-write-refused",
|
|
});
|
|
|
|
expect(calls).toEqual(["read-source", "read-current", "store"]);
|
|
expect(dbToStorageWithSpecificRev).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports a stored revision when its subsequent Vault reflection fails", async () => {
|
|
const { core, storeWithLiveBaseRevision } = createCore({ reflected: false });
|
|
|
|
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
|
|
status: "stored-not-reflected",
|
|
path: "History.md" as FilePath,
|
|
revision: "5-restored",
|
|
});
|
|
|
|
expect(storeWithLiveBaseRevision).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("reports a stored revision when its subsequent Vault reflection throws", async () => {
|
|
const reflectionError = new Error("adapter unavailable");
|
|
const { core, getConflictedRevs } = createCore({ reflectionError });
|
|
|
|
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
|
|
status: "stored-not-reflected",
|
|
path: "History.md" as FilePath,
|
|
revision: "5-restored",
|
|
cause: reflectionError,
|
|
});
|
|
|
|
expect(getConflictedRevs).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports remaining conflict leaves after restoration", async () => {
|
|
const { core, getConflictedRevs } = createCore({ conflicts: ["3-other"] });
|
|
|
|
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
|
|
status: "restored",
|
|
path: "History.md" as FilePath,
|
|
revision: "5-restored",
|
|
conflictsRemain: true,
|
|
});
|
|
|
|
expect(getConflictedRevs).toHaveBeenCalledWith("History.md");
|
|
});
|
|
|
|
it("does not turn a completed restoration into a failure when conflict inspection fails", async () => {
|
|
const conflictCheckError = new Error("inspection unavailable");
|
|
const { core } = createCore({ conflictCheckError });
|
|
|
|
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
|
|
status: "restored",
|
|
path: "History.md" as FilePath,
|
|
revision: "5-restored",
|
|
conflictsRemain: undefined,
|
|
conflictCheckError,
|
|
});
|
|
});
|
|
|
|
it("does not read or mutate the current tree when the historical source is unavailable", async () => {
|
|
const { core, fetchEntryMeta, storeWithLiveBaseRevision } = createCore({ source: false });
|
|
|
|
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
|
|
status: "source-unavailable",
|
|
});
|
|
|
|
expect(fetchEntryMeta).not.toHaveBeenCalled();
|
|
expect(storeWithLiveBaseRevision).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not write when the current winner cannot supply an exact base revision", async () => {
|
|
const current = { ...createCurrent(), _rev: undefined } as MetaEntry;
|
|
const { core, storeWithLiveBaseRevision } = createCore({ current });
|
|
|
|
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
|
|
status: "current-unavailable",
|
|
});
|
|
|
|
expect(storeWithLiveBaseRevision).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses prefixed internal Metadata before creating a database revision", async () => {
|
|
const current = { ...createCurrent(), path: "i:.obsidian/config.json" as FilePathWithPrefix } as MetaEntry;
|
|
const { core, storeWithLiveBaseRevision } = createCore({ current });
|
|
|
|
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
|
|
status: "unsupported-path",
|
|
});
|
|
|
|
expect(storeWithLiveBaseRevision).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses the host path validator before creating a database revision", async () => {
|
|
const { core, storeWithLiveBaseRevision } = createCore();
|
|
|
|
await expect(
|
|
restoreDocumentHistoryRevision(core, path, "2-source", { isPathValid: () => false })
|
|
).resolves.toEqual({ status: "unsupported-path" });
|
|
|
|
expect(storeWithLiveBaseRevision).not.toHaveBeenCalled();
|
|
});
|
|
});
|