mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-25 18:43:20 +00:00
287 lines
12 KiB
TypeScript
287 lines
12 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import type { LoadedEntry, MetaEntry, UXFileInfoStub } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
|
import { checkIncompleteDocuments, type IncompleteDocumentsDependencies } from "./incompleteDocuments";
|
|
|
|
vi.mock("@/common/utils", () => ({
|
|
isValidPath: () => true,
|
|
}));
|
|
|
|
type DocumentFixture = {
|
|
meta: MetaEntry;
|
|
loaded: LoadedEntry;
|
|
storage: ArrayBuffer;
|
|
stub: UXFileInfoStub;
|
|
};
|
|
|
|
type FindAllNormalDocs = () => AsyncGenerator<MetaEntry>;
|
|
|
|
async function* noDocuments(): AsyncGenerator<MetaEntry> {
|
|
return;
|
|
}
|
|
|
|
async function* failedDocumentScan(): AsyncGenerator<MetaEntry> {
|
|
throw new Error("scan failed");
|
|
}
|
|
|
|
function documentFixture(
|
|
path: string,
|
|
options: { recordedSize?: number; storageContent?: string; conflicts?: string[] } = {}
|
|
): DocumentFixture {
|
|
const storageContent = options.storageContent ?? "hello";
|
|
const meta = {
|
|
_id: `f:${path}`,
|
|
_rev: "1-test",
|
|
path,
|
|
ctime: 1,
|
|
mtime: 2,
|
|
size: options.recordedSize ?? storageContent.length,
|
|
children: ["h:test"],
|
|
type: "plain",
|
|
eden: {},
|
|
...(options.conflicts ? { _conflicts: options.conflicts } : {}),
|
|
} as MetaEntry;
|
|
const loaded = {
|
|
...meta,
|
|
data: "abc",
|
|
datatype: "plain",
|
|
} as LoadedEntry;
|
|
const stub = {
|
|
name: path.split("/").pop() ?? path,
|
|
path,
|
|
stat: {
|
|
ctime: 1,
|
|
mtime: 2,
|
|
size: storageContent.length,
|
|
type: "file",
|
|
},
|
|
} as UXFileInfoStub;
|
|
return {
|
|
meta,
|
|
loaded,
|
|
storage: new TextEncoder().encode(storageContent).buffer as ArrayBuffer,
|
|
stub,
|
|
};
|
|
}
|
|
|
|
function documentsFrom(fixtures: DocumentFixture[]): FindAllNormalDocs {
|
|
return async function* () {
|
|
yield* fixtures.map((fixture) => fixture.meta);
|
|
};
|
|
}
|
|
|
|
function selectButton(index: number) {
|
|
return async (...args: unknown[]): Promise<string | false> => {
|
|
const buttons = args[1] as readonly string[];
|
|
return buttons[index] ?? false;
|
|
};
|
|
}
|
|
|
|
function createDependencies(findAllNormalDocs: FindAllNormalDocs = noDocuments, fixtures: DocumentFixture[] = []) {
|
|
const fixtureByPath = new Map<string, DocumentFixture>(
|
|
fixtures.map((fixture) => [fixture.meta.path as string, fixture])
|
|
);
|
|
const noticeGroups = {
|
|
setItem: vi.fn(),
|
|
finish: vi.fn(),
|
|
};
|
|
const getFixture = (path: string) => fixtureByPath.get(path);
|
|
const getDBEntryFromMeta = vi.fn(async (meta: { path: string }) => getFixture(meta.path)?.loaded);
|
|
const readHiddenFileBinary = vi.fn(async (path: string) => getFixture(path)?.storage ?? new ArrayBuffer(0));
|
|
const getFileStub = vi.fn(async (path: string) => getFixture(path)?.stub ?? null);
|
|
const storeFileToDB = vi.fn(async () => true);
|
|
const askSelectStringDialogue = vi.fn();
|
|
const dependencies = {
|
|
localDatabase: {
|
|
findAllNormalDocs,
|
|
getDBEntryFromMeta,
|
|
},
|
|
getPath: vi.fn((entry: { path: string }) => entry.path),
|
|
isTargetFile: vi.fn(async () => true),
|
|
storageAccess: {
|
|
readHiddenFileBinary,
|
|
statHidden: vi.fn(async (path: string) => getFixture(path)?.stub.stat ?? null),
|
|
getFileStub,
|
|
},
|
|
fileHandler: { storeFileToDB },
|
|
keyValueDB: {
|
|
get: vi.fn(async () => false),
|
|
set: vi.fn(async () => undefined),
|
|
},
|
|
noticeGroups,
|
|
confirm: { askSelectStringDialogue },
|
|
log: vi.fn(),
|
|
} as unknown as IncompleteDocumentsDependencies;
|
|
return {
|
|
askSelectStringDialogue,
|
|
dependencies,
|
|
getFileStub,
|
|
noticeGroups,
|
|
readHiddenFileBinary,
|
|
storeFileToDB,
|
|
};
|
|
}
|
|
|
|
describe("checkIncompleteDocuments", () => {
|
|
it("keeps the check and result in one persistent named group", async () => {
|
|
const { dependencies, noticeGroups } = createDependencies();
|
|
|
|
await expect(checkIncompleteDocuments(dependencies)).resolves.toBe(true);
|
|
|
|
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(1, "startup-integrity-check", "checking", {
|
|
message: "Checking for incomplete documents...",
|
|
});
|
|
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(2, "startup-integrity-check", "result", {
|
|
message: "No size mismatches found",
|
|
});
|
|
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
|
|
expect(dependencies.keyValueDB.set).toHaveBeenCalledWith("checkIncompleteDocs", true);
|
|
});
|
|
|
|
it("skips the non-forced check after a successful prior scan", async () => {
|
|
const { dependencies, noticeGroups } = createDependencies();
|
|
vi.mocked(dependencies.keyValueDB.get).mockResolvedValue(true);
|
|
|
|
await expect(checkIncompleteDocuments(dependencies)).resolves.toBe(true);
|
|
|
|
expect(noticeGroups.setItem).not.toHaveBeenCalled();
|
|
expect(noticeGroups.finish).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("finishes the group with a failure result when the scan throws", async () => {
|
|
const { dependencies, noticeGroups } = createDependencies(failedDocumentScan);
|
|
|
|
await expect(checkIncompleteDocuments(dependencies)).rejects.toThrow("scan failed");
|
|
|
|
expect(noticeGroups.setItem).toHaveBeenLastCalledWith("startup-integrity-check", "result", {
|
|
message: "The incomplete document check could not be completed.",
|
|
});
|
|
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
|
|
});
|
|
|
|
it("does not read healthy storage contents only to determine their size", async () => {
|
|
const healthy = documentFixture("healthy.md", { storageContent: "abc" });
|
|
const fixture = createDependencies(documentsFrom([healthy]), [healthy]);
|
|
|
|
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
|
|
|
expect(fixture.readHiddenFileBinary).not.toHaveBeenCalled();
|
|
expect(fixture.askSelectStringDialogue).not.toHaveBeenCalled();
|
|
expect(fixture.dependencies.keyValueDB.set).toHaveBeenCalledWith("checkIncompleteDocs", true);
|
|
});
|
|
|
|
it("does not mark a scan complete when storage changes during the size check", async () => {
|
|
const healthy = documentFixture("changing.md", { storageContent: "abc" });
|
|
const fixture = createDependencies(documentsFrom([healthy]), [healthy]);
|
|
vi.mocked(fixture.dependencies.storageAccess.statHidden)
|
|
.mockResolvedValueOnce(healthy.stub.stat)
|
|
.mockResolvedValueOnce({ ...healthy.stub.stat, mtime: 3 });
|
|
|
|
await expect(checkIncompleteDocuments(fixture.dependencies)).rejects.toThrow("Storage changed");
|
|
|
|
expect(fixture.readHiddenFileBinary).not.toHaveBeenCalled();
|
|
expect(fixture.dependencies.keyValueDB.set).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not offer repair when all sizes agree after reading storage", async () => {
|
|
const healthy = documentFixture("settled.md", { storageContent: "abc" });
|
|
const fixture = createDependencies(documentsFrom([healthy]), [healthy]);
|
|
vi.mocked(fixture.dependencies.storageAccess.statHidden).mockResolvedValueOnce({ ...healthy.stub.stat, size: 4 });
|
|
|
|
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
|
|
|
expect(fixture.readHiddenFileBinary).toHaveBeenCalledWith("settled.md");
|
|
expect(fixture.askSelectStringDialogue).not.toHaveBeenCalled();
|
|
expect(fixture.storeFileToDB).not.toHaveBeenCalled();
|
|
expect(fixture.dependencies.keyValueDB.set).toHaveBeenCalledWith("checkIncompleteDocs", true);
|
|
});
|
|
|
|
it("uses the bytes read from storage when a size mismatch remains", async () => {
|
|
const mismatched = documentFixture("changing.md", { recordedSize: 3, storageContent: "abcde" });
|
|
const fixture = createDependencies(documentsFrom([mismatched]), [mismatched]);
|
|
vi.mocked(fixture.dependencies.storageAccess.statHidden).mockResolvedValueOnce({
|
|
...mismatched.stub.stat,
|
|
size: 4,
|
|
});
|
|
fixture.askSelectStringDialogue.mockImplementation(selectButton(0));
|
|
|
|
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
|
|
|
expect(fixture.readHiddenFileBinary).toHaveBeenCalledWith("changing.md");
|
|
expect(fixture.askSelectStringDialogue).toHaveBeenCalledOnce();
|
|
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[0]).toEqual(
|
|
expect.stringContaining("changing.md (M: 3, A: 3, S: 5)")
|
|
);
|
|
expect(fixture.storeFileToDB).not.toHaveBeenCalled();
|
|
expect(fixture.dependencies.keyValueDB.set).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("still reads conflicted storage for the existing repair dialogue", async () => {
|
|
const conflicted = documentFixture("conflicted.md", { storageContent: "abc", conflicts: ["2-other"] });
|
|
const fixture = createDependencies(documentsFrom([conflicted]), [conflicted]);
|
|
fixture.askSelectStringDialogue.mockImplementation(selectButton(0));
|
|
|
|
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
|
|
|
expect(fixture.readHiddenFileBinary).toHaveBeenCalledWith("conflicted.md");
|
|
expect(fixture.dependencies.keyValueDB.set).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("repairs a recoverable document when FIX is selected", async () => {
|
|
const recoverable = documentFixture("recoverable.md");
|
|
const fixture = createDependencies(documentsFrom([recoverable]), [recoverable]);
|
|
fixture.askSelectStringDialogue.mockImplementation(selectButton(1));
|
|
|
|
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
|
|
|
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[1]).toHaveLength(3);
|
|
expect(fixture.getFileStub).toHaveBeenCalledWith("recoverable.md");
|
|
expect(fixture.storeFileToDB).toHaveBeenCalledWith(recoverable.stub, true, false);
|
|
expect(fixture.dependencies.keyValueDB.set).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("leaves recoverable documents unchanged when CHECK_IT_LATER is selected", async () => {
|
|
const recoverable = documentFixture("recoverable.md");
|
|
const fixture = createDependencies(documentsFrom([recoverable]), [recoverable]);
|
|
fixture.askSelectStringDialogue.mockImplementation(selectButton(0));
|
|
|
|
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
|
|
|
expect(fixture.getFileStub).not.toHaveBeenCalled();
|
|
expect(fixture.storeFileToDB).not.toHaveBeenCalled();
|
|
expect(fixture.dependencies.keyValueDB.set).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("records a permanent dismissal of recoverable document warnings", async () => {
|
|
const recoverable = documentFixture("recoverable.md");
|
|
const fixture = createDependencies(documentsFrom([recoverable]), [recoverable]);
|
|
fixture.askSelectStringDialogue.mockImplementation(selectButton(2));
|
|
|
|
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
|
|
|
expect(fixture.getFileStub).not.toHaveBeenCalled();
|
|
expect(fixture.storeFileToDB).not.toHaveBeenCalled();
|
|
expect(fixture.dependencies.keyValueDB.set).toHaveBeenCalledWith("checkIncompleteDocs", true);
|
|
});
|
|
|
|
it("stores only recoverable, non-conflicted documents from a mixed scan", async () => {
|
|
const recoverable = documentFixture("recoverable.md");
|
|
const unrecoverable = documentFixture("unrecoverable.md", { recordedSize: 4 });
|
|
const conflicted = documentFixture("conflicted.md", { conflicts: ["2-conflict"] });
|
|
const fixture = createDependencies(documentsFrom([recoverable, unrecoverable, conflicted]), [
|
|
recoverable,
|
|
unrecoverable,
|
|
conflicted,
|
|
]);
|
|
fixture.askSelectStringDialogue.mockImplementation(selectButton(1));
|
|
|
|
await expect(checkIncompleteDocuments(fixture.dependencies)).resolves.toBe(true);
|
|
|
|
expect(fixture.getFileStub).toHaveBeenCalledTimes(1);
|
|
expect(fixture.getFileStub).toHaveBeenCalledWith("recoverable.md");
|
|
expect(fixture.storeFileToDB).toHaveBeenCalledTimes(1);
|
|
expect(fixture.storeFileToDB).toHaveBeenCalledWith(recoverable.stub, true, false);
|
|
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[0]).toEqual(expect.stringContaining("unrecoverable.md"));
|
|
expect(fixture.askSelectStringDialogue.mock.calls[0]?.[0]).toEqual(expect.stringContaining("conflicted.md"));
|
|
});
|
|
});
|