Files
obsidian-livesync/src/serviceFeatures/redFlag.fetchState.unit.spec.ts
T

203 lines
8.8 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/settings";
import { REMOTE_MINIO, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
import {
FlagFilesHumanReadable,
FlagFilesOriginal,
} from "@vrtmrz/livesync-commonlib/compat/common/models/redflag.const";
import {
clearFetchSelection,
deleteFetchFlags,
describeFetchRemote,
readFetchSelection,
saveFetchSelection,
} from "./redFlag.fetchState";
import { readFetchFlagState, writeFetchFlagState } from "./redFlag.fetchStateMarkdown";
import {
askSimpleFetchMode,
SIMPLE_FETCH_STAGE1_REMOTE_WINS,
SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE,
} from "./redFlag.simpleFetch";
const modern = FlagFilesHumanReadable.FETCH_ALL;
const legacy = FlagFilesOriginal.FETCH_ALL;
const stage1 = SIMPLE_FETCH_STAGE1_REMOTE_WINS;
const stage2 = SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE;
function fixture() {
const files = new Map<string, string>([[modern, ""]]);
const settings = { ...DEFAULT_SETTINGS, couchDB_URI: "https://example.invalid", couchDB_DBNAME: "vault" };
const storage = {
normalisePath: (path: string) => path,
isExists: vi.fn(async (path: string) => files.has(path)),
readFileText: vi.fn(async (path: string) => {
if (!files.has(path)) throw new Error("missing file");
return files.get(path)!;
}),
writeFileAuto: vi.fn(async (path: string, value: string) => {
files.set(path, value);
return true;
}),
delete: vi.fn(async (path: string) => {
files.delete(path);
}),
};
const confirm = vi.fn(async (_title: string, _message: string, buttons: string[]) => buttons[1]);
const host = {
services: { setting: { currentSettings: () => settings }, UI: { confirm: { confirmWithMessage: confirm } } },
serviceModules: { storageAccess: storage },
} as any;
return { host, files, settings, storage, confirm };
}
describe("Fetch flag state", () => {
it("preserves Markdown and reuses the selection independently of the database suffix", async () => {
const { host, files, settings } = fixture();
await saveFetchSelection(host, stage1, stage2);
const prose = "# My recovery notes\n\nKeep this explanation.\n\n";
files.set(modern, prose + files.get(modern)!);
await saveFetchSelection(host, stage1, stage2);
expect(files.get(modern)).toContain(prose);
settings.additionalSuffixOfDatabaseName = "changed-by-rebuild";
await expect(readFetchSelection(host)).resolves.toMatchObject({ stage1, stage2 });
await clearFetchSelection(host);
expect(files.get(modern)).toContain(prose);
expect(readFetchFlagState(files.get(modern)!)).toBeUndefined();
});
it("reads and updates the legacy filename when it is the only flag", async () => {
const { host, files } = fixture();
files.delete(modern);
files.set(legacy, "");
await saveFetchSelection(host, stage1, stage2);
await expect(readFetchSelection(host)).resolves.toMatchObject({ stage1, stage2 });
expect(files.has(modern)).toBe(false);
await deleteFetchFlags(host);
expect(files.size).toBe(0);
});
it("treats a new empty canonical flag as a new operation even when a legacy selection remains", async () => {
const { host, files, confirm } = fixture();
await saveFetchSelection(host, stage1, stage2);
files.set(legacy, files.get(modern)!);
files.set(modern, "");
await askSimpleFetchMode(host);
expect(confirm).toHaveBeenCalledTimes(2);
await deleteFetchFlags(host);
expect(files.size).toBe(0);
});
it("does not fall back to the legacy flag when reading the canonical flag fails", async () => {
const { host, files, storage } = fixture();
files.set(legacy, "");
storage.readFileText.mockRejectedValueOnce(new Error("read failed"));
await expect(readFetchSelection(host)).rejects.toThrow("read failed");
expect(storage.readFileText).toHaveBeenCalledOnce();
});
it("does not create an unsolicited fetch flag when none exists", async () => {
const { host, files, storage } = fixture();
files.clear();
await expect(saveFetchSelection(host, stage1, stage2)).rejects.toThrow("flag is missing");
expect(storage.writeFileAuto).not.toHaveBeenCalled();
});
it("rejects a save when the storage silently retains different content", async () => {
const { host, storage } = fixture();
storage.writeFileAuto.mockResolvedValueOnce(true);
await expect(saveFetchSelection(host, stage1, stage2)).rejects.toThrow("could not be saved");
});
it("retains the authoritative flag when removing the legacy alias fails", async () => {
const { host, files, storage } = fixture();
files.set(legacy, "");
storage.delete.mockRejectedValueOnce(new Error("delete failed"));
await expect(deleteFetchFlags(host)).rejects.toThrow("delete failed");
expect(files.has(modern)).toBe(true);
expect(storage.delete).toHaveBeenCalledOnce();
});
it("detects a deletion which returned without removing the flag", async () => {
const { host, files, storage } = fixture();
storage.delete.mockResolvedValueOnce(undefined);
await expect(deleteFetchFlags(host)).rejects.toThrow("Could not remove");
expect(files.has(modern)).toBe(true);
});
it.each([
{ stage1: "unknown", stage2 },
{ stage1, stage2: "Delete local files if deleted on remote" },
{ stage1: "Use the detailed flow", stage2 },
])("asks again for an invalid combination: %j", async (choices) => {
const { host, files, storage, confirm } = fixture();
files.set(
modern,
writeFetchFlagState("", {
type: "livesync-fast-setup",
version: 1,
...choices,
})
);
const original = files.get(modern);
confirm.mockImplementationOnce(async (_title, _message, buttons) => buttons[0]);
await askSimpleFetchMode(host);
expect(confirm).toHaveBeenCalledTimes(3);
expect(files.get(modern)).toBe(original);
expect(storage.writeFileAuto).not.toHaveBeenCalled();
});
it.each([
{ remoteType: "" as const, couchDB_DBNAME: "different-vault" },
{ remoteType: REMOTE_MINIO, endpoint: "https://s3.invalid", bucket: "other-bucket" },
{ remoteType: REMOTE_P2P, P2P_roomID: "other-room" },
])("requires explicit confirmation for the currently selected remote: %j", async (remote) => {
const { host, settings, confirm } = fixture();
await saveFetchSelection(host, stage1, stage2);
Object.assign(settings, remote);
await expect(readFetchSelection(host)).resolves.toMatchObject({ stage1, stage2 });
confirm.mockImplementationOnce(async (_title, _message, buttons) => buttons[0]);
await askSimpleFetchMode(host);
expect(confirm).toHaveBeenCalledOnce();
expect(confirm.mock.calls[0][1]).toContain("Current remote:");
});
});
describe("Fetch destination description", () => {
it("omits URL credentials, query parameters, fragments, and connection secrets", () => {
const { settings } = fixture();
Object.assign(settings, {
couchDB_URI: "https://alice:secret@example.invalid/path?token=secret#secret",
couchDB_USER: "alice",
couchDB_PASSWORD: "secret",
jwtKey: "secret",
passphrase: "secret",
additionalSuffixOfDatabaseName: "another-device",
});
expect(describeFetchRemote(settings)).toBe("CouchDB / https://example.invalid/path / vault");
});
it("shows Object Storage prefixes and P2P rooms without relay credentials", () => {
const { settings } = fixture();
Object.assign(settings, {
remoteType: REMOTE_MINIO,
endpoint: "https://s3.invalid",
region: "region",
bucket: "vault",
bucketPrefix: "one/",
});
expect(describeFetchRemote(settings)).toContain("vault / one/");
Object.assign(settings, {
remoteType: REMOTE_P2P,
P2P_roomID: "group",
P2P_passphrase: "secret",
P2P_relays: "wss://alice:secret@relay.invalid?token=secret,wss://other.invalid",
});
const description = describeFetchRemote(settings);
expect(description).toContain("group");
expect(description).toContain("wss://relay.invalid");
expect(description).toContain("wss://other.invalid");
expect(description).not.toContain("secret");
expect(description).not.toContain("alice");
});
});