505 lines
18 KiB
TypeScript
505 lines
18 KiB
TypeScript
import "../../open-sse/utils/setupPolyfill.ts";
|
|
import test, { describe, beforeEach, mock } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
// ── Pure logic extracted from server for unit testing ──────────────────────
|
|
|
|
interface ManifestEntry {
|
|
path: string;
|
|
mtime: number;
|
|
size: number;
|
|
}
|
|
|
|
interface TombstoneEntry {
|
|
path: string;
|
|
deletedAt: number;
|
|
deletedBy: string;
|
|
}
|
|
|
|
interface SyncPullResult {
|
|
filesToPull: ManifestEntry[];
|
|
folders: string[];
|
|
tombstones: TombstoneEntry[];
|
|
}
|
|
|
|
interface SyncPushResult {
|
|
conflict: boolean;
|
|
}
|
|
|
|
const MAX_TOMBSTONES = 1000;
|
|
|
|
function buildPullResult(
|
|
serverFiles: ManifestEntry[],
|
|
serverFolders: string[],
|
|
serverTombstones: TombstoneEntry[],
|
|
localMap: Map<string, ManifestEntry>,
|
|
since: number
|
|
): SyncPullResult {
|
|
const filesToPull: ManifestEntry[] = [];
|
|
for (const file of serverFiles) {
|
|
const local = localMap.get(file.path);
|
|
if (!local || file.mtime > local.mtime || file.size !== local.size) {
|
|
filesToPull.push(file);
|
|
}
|
|
}
|
|
const folders = serverFolders.filter((f) => !localMap.has(f));
|
|
const tombstones = serverTombstones.filter((t) => t.deletedAt > since);
|
|
return { filesToPull, folders, tombstones };
|
|
}
|
|
|
|
function checkConflict(serverMtime: number, pushMtime: number): SyncPushResult {
|
|
return { conflict: serverMtime >= pushMtime };
|
|
}
|
|
|
|
function addTombstone(
|
|
tombstones: TombstoneEntry[],
|
|
path: string,
|
|
deviceId = "desktop"
|
|
): TombstoneEntry[] {
|
|
const next = [...tombstones, { path, deletedAt: Date.now(), deletedBy: deviceId }];
|
|
return next.length > MAX_TOMBSTONES ? next.slice(-MAX_TOMBSTONES) : next;
|
|
}
|
|
|
|
function filterTombstones(tombstones: TombstoneEntry[], since: number): TombstoneEntry[] {
|
|
return tombstones.filter((t) => t.deletedAt > since);
|
|
}
|
|
|
|
interface DiscoveryCandidateInput {
|
|
host: string;
|
|
port: number;
|
|
}
|
|
|
|
function buildDiscoveryCandidates(input: DiscoveryCandidateInput): string[] {
|
|
const { host, port } = input;
|
|
const candidates: string[] = [];
|
|
const trimmed = host.trim();
|
|
if (!trimmed) return candidates;
|
|
|
|
if (trimmed.includes("://")) {
|
|
candidates.push(trimmed);
|
|
} else if (trimmed.includes(".") && !trimmed.startsWith("100.")) {
|
|
candidates.push(`http://${trimmed}:${port}`);
|
|
candidates.push(`http://${trimmed}.ts.net:${port}`);
|
|
} else {
|
|
candidates.push(`http://${trimmed}:${port}`);
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
function findConflictFiles(allFilePaths: string[], basePath: string): string[] {
|
|
const pattern = `${basePath}.conflict-`;
|
|
return allFilePaths.filter((p) => p.startsWith(pattern));
|
|
}
|
|
|
|
function resolveLocal(
|
|
conflictFileContent: string,
|
|
existingContent: string | null
|
|
): { content: string; action: string } {
|
|
return { content: conflictFileContent, action: "kept-local" };
|
|
}
|
|
|
|
function resolveRemote(): { action: string } {
|
|
return { action: "kept-remote" };
|
|
}
|
|
|
|
function resolveKeepBoth(conflictPaths: string[]): { action: string; conflictPaths: string[] } {
|
|
return { action: "kept-both", conflictPaths };
|
|
}
|
|
|
|
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
|
|
describe("Sync Pull — Manifest Diff", () => {
|
|
test("returns all files when local manifest is empty", () => {
|
|
const serverFiles: ManifestEntry[] = [
|
|
{ path: "a.md", mtime: 1000, size: 100 },
|
|
{ path: "b.md", mtime: 2000, size: 200 },
|
|
];
|
|
const result = buildPullResult(serverFiles, [], [], new Map(), 0);
|
|
assert.equal(result.filesToPull.length, 2);
|
|
});
|
|
|
|
test("skips files where both mtime and size match", () => {
|
|
const serverFiles: ManifestEntry[] = [
|
|
{ path: "a.md", mtime: 1000, size: 100 },
|
|
];
|
|
const localMap = new Map([["a.md", { path: "a.md", mtime: 1000, size: 100 }]]);
|
|
const result = buildPullResult(serverFiles, [], [], localMap, 0);
|
|
assert.equal(result.filesToPull.length, 0);
|
|
});
|
|
|
|
test("returns file when mtime differs even if size matches", () => {
|
|
const serverFiles: ManifestEntry[] = [
|
|
{ path: "a.md", mtime: 2000, size: 100 },
|
|
];
|
|
const localMap = new Map([["a.md", { path: "a.md", mtime: 1000, size: 100 }]]);
|
|
const result = buildPullResult(serverFiles, [], [], localMap, 0);
|
|
assert.equal(result.filesToPull.length, 1);
|
|
assert.equal(result.filesToPull[0].path, "a.md");
|
|
});
|
|
|
|
test("returns file when size differs even if mtime matches", () => {
|
|
const serverFiles: ManifestEntry[] = [
|
|
{ path: "a.md", mtime: 1000, size: 200 },
|
|
];
|
|
const localMap = new Map([["a.md", { path: "a.md", mtime: 1000, size: 100 }]]);
|
|
const result = buildPullResult(serverFiles, [], [], localMap, 0);
|
|
assert.equal(result.filesToPull.length, 1);
|
|
});
|
|
|
|
test("returns file when local has no entry for it", () => {
|
|
const serverFiles: ManifestEntry[] = [
|
|
{ path: "new.md", mtime: 1000, size: 50 },
|
|
];
|
|
const localMap = new Map([["other.md", { path: "other.md", mtime: 1000, size: 100 }]]);
|
|
const result = buildPullResult(serverFiles, [], [], localMap, 0);
|
|
assert.equal(result.filesToPull.length, 1);
|
|
assert.equal(result.filesToPull[0].path, "new.md");
|
|
});
|
|
|
|
test("returns only folders not tracked in local manifest", () => {
|
|
const localMap = new Map([["existing-folder/note.md", { path: "existing-folder/note.md", mtime: 1000, size: 100 }], ["existing-folder", { path: "existing-folder", mtime: 0, size: 0 }]]);
|
|
const result = buildPullResult([], ["existing-folder", "new-folder"], [], localMap, 0);
|
|
assert.deepEqual(result.folders, ["new-folder"]);
|
|
});
|
|
|
|
test("returns all folders when local manifest has none", () => {
|
|
const result = buildPullResult([], ["folder-a", "folder-b"], [], new Map(), 0);
|
|
assert.deepEqual(result.folders, ["folder-a", "folder-b"]);
|
|
});
|
|
|
|
test("filters tombstones by since timestamp", () => {
|
|
const tombstones: TombstoneEntry[] = [
|
|
{ path: "old.md", deletedAt: 1000, deletedBy: "desktop" },
|
|
{ path: "new.md", deletedAt: 2000, deletedBy: "mobile" },
|
|
];
|
|
const result = buildPullResult([], [], tombstones, new Map(), 1500);
|
|
assert.equal(result.tombstones.length, 1);
|
|
assert.equal(result.tombstones[0].path, "new.md");
|
|
});
|
|
|
|
test("returns empty when all tombstones are older than since", () => {
|
|
const tombstones: TombstoneEntry[] = [
|
|
{ path: "a.md", deletedAt: 100, deletedBy: "desktop" },
|
|
];
|
|
const result = buildPullResult([], [], tombstones, new Map(), 9999);
|
|
assert.equal(result.tombstones.length, 0);
|
|
});
|
|
|
|
test("empty server and empty local returns nothing", () => {
|
|
const result = buildPullResult([], [], [], new Map(), 0);
|
|
assert.equal(result.filesToPull.length, 0);
|
|
assert.equal(result.folders.length, 0);
|
|
assert.equal(result.tombstones.length, 0);
|
|
});
|
|
});
|
|
|
|
describe("Sync Push — Conflict Detection", () => {
|
|
test("conflict when server mtime equals push mtime (same-second edit)", () => {
|
|
const result = checkConflict(1000, 1000);
|
|
assert.equal(result.conflict, true);
|
|
});
|
|
|
|
test("no conflict when push is newer than server", () => {
|
|
const result = checkConflict(1000, 2000);
|
|
assert.equal(result.conflict, false);
|
|
});
|
|
|
|
test("conflict when server is newer than push", () => {
|
|
const result = checkConflict(2000, 1000);
|
|
assert.equal(result.conflict, true);
|
|
});
|
|
|
|
test("no conflict when server file does not exist (mtime 0)", () => {
|
|
const result = checkConflict(0, 1000);
|
|
assert.equal(result.conflict, false);
|
|
});
|
|
|
|
test("conflict with 1ms difference", () => {
|
|
const result = checkConflict(1001, 1000);
|
|
assert.equal(result.conflict, true);
|
|
});
|
|
|
|
test("no conflict with 1ms newer push", () => {
|
|
const result = checkConflict(1000, 1001);
|
|
assert.equal(result.conflict, false);
|
|
});
|
|
});
|
|
|
|
describe("Tombstone Log", () => {
|
|
test("appends tombstone", () => {
|
|
const result = addTombstone([], "deleted.md", "mobile");
|
|
assert.equal(result.length, 1);
|
|
assert.equal(result[0].path, "deleted.md");
|
|
assert.equal(result[0].deletedBy, "mobile");
|
|
});
|
|
|
|
test("defaults deviceId to desktop", () => {
|
|
const result = addTombstone([], "file.md");
|
|
assert.equal(result[0].deletedBy, "desktop");
|
|
});
|
|
|
|
test("caps at MAX_TOMBSTONES (1000) — drops oldest", () => {
|
|
let tombstones: TombstoneEntry[] = [];
|
|
for (let i = 0; i < 1001; i++) {
|
|
tombstones = addTombstone(tombstones, `file-${i}.md`);
|
|
}
|
|
assert.equal(tombstones.length, 1000);
|
|
assert.equal(tombstones[0].path, "file-1.md");
|
|
assert.equal(tombstones[999].path, "file-1000.md");
|
|
});
|
|
|
|
test("preserves exactly 1000 when at cap", () => {
|
|
let tombstones: TombstoneEntry[] = [];
|
|
for (let i = 0; i < 1000; i++) {
|
|
tombstones = addTombstone(tombstones, `file-${i}.md`);
|
|
}
|
|
assert.equal(tombstones.length, 1000);
|
|
tombstones = addTombstone(tombstones, "overflow.md");
|
|
assert.equal(tombstones.length, 1000);
|
|
assert.equal(tombstones[999].path, "overflow.md");
|
|
});
|
|
|
|
test("filterTombstones returns only entries after since", () => {
|
|
const tombstones: TombstoneEntry[] = [
|
|
{ path: "a.md", deletedAt: 100, deletedBy: "d" },
|
|
{ path: "b.md", deletedAt: 200, deletedBy: "d" },
|
|
{ path: "c.md", deletedAt: 300, deletedBy: "d" },
|
|
];
|
|
const filtered = filterTombstones(tombstones, 150);
|
|
assert.equal(filtered.length, 2);
|
|
assert.deepEqual(filtered.map((t) => t.path), ["b.md", "c.md"]);
|
|
});
|
|
|
|
test("filterTombstones returns empty when since is after all", () => {
|
|
const tombstones: TombstoneEntry[] = [
|
|
{ path: "a.md", deletedAt: 100, deletedBy: "d" },
|
|
];
|
|
const filtered = filterTombstones(tombstones, 999999);
|
|
assert.equal(filtered.length, 0);
|
|
});
|
|
|
|
test("filterTombstones returns all when since is 0", () => {
|
|
const tombstones: TombstoneEntry[] = [
|
|
{ path: "a.md", deletedAt: 100, deletedBy: "d" },
|
|
{ path: "b.md", deletedAt: 200, deletedBy: "d" },
|
|
];
|
|
const filtered = filterTombstones(tombstones, 0);
|
|
assert.equal(filtered.length, 2);
|
|
});
|
|
});
|
|
|
|
describe("Discovery Candidates", () => {
|
|
test("returns empty for empty host", () => {
|
|
const result = buildDiscoveryCandidates({ host: "", port: 27781 });
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test("returns empty for whitespace-only host", () => {
|
|
const result = buildDiscoveryCandidates({ host: " ", port: 27781 });
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test("passes through full URL as-is", () => {
|
|
const result = buildDiscoveryCandidates({ host: "http://my-mac:27781", port: 27781 });
|
|
assert.deepEqual(result, ["http://my-mac:27781"]);
|
|
});
|
|
|
|
test("passes through https URL as-is", () => {
|
|
const result = buildDiscoveryCandidates({ host: "https://desktop.local", port: 27781 });
|
|
assert.deepEqual(result, ["https://desktop.local"]);
|
|
});
|
|
|
|
test("generates MagicDNS candidate for dotted name", () => {
|
|
const result = buildDiscoveryCandidates({ host: "my-macbook.local", port: 27781 });
|
|
assert.ok(result.includes("http://my-macbook.local:27781"));
|
|
assert.ok(result.includes("http://my-macbook.local.ts.net:27781"));
|
|
assert.equal(result.length, 2);
|
|
});
|
|
|
|
test("single bare name gets only direct URL (no .ts.net)", () => {
|
|
const result = buildDiscoveryCandidates({ host: "my-macbook", port: 27781 });
|
|
assert.deepEqual(result, ["http://my-macbook:27781"]);
|
|
assert.equal(result.length, 1);
|
|
});
|
|
|
|
test("does NOT add .ts.net for 100.x.y.z addresses", () => {
|
|
const result = buildDiscoveryCandidates({ host: "100.64.0.42", port: 27781 });
|
|
assert.deepEqual(result, ["http://100.64.0.42:27781"]);
|
|
assert.equal(result.length, 1);
|
|
});
|
|
|
|
test("handles host with leading/trailing whitespace", () => {
|
|
const result = buildDiscoveryCandidates({ host: " my-mac ", port: 27781 });
|
|
assert.ok(result.includes("http://my-mac:27781"));
|
|
});
|
|
|
|
test("handles .local domain (dotted, not 100.x)", () => {
|
|
const result = buildDiscoveryCandidates({ host: "desktop.local", port: 27781 });
|
|
assert.ok(result.includes("http://desktop.local:27781"));
|
|
assert.ok(result.includes("http://desktop.local.ts.net:27781"));
|
|
});
|
|
});
|
|
|
|
describe("Conflict File Detection", () => {
|
|
test("finds conflict files for a given path", () => {
|
|
const allFiles = [
|
|
"notes/daily.md",
|
|
"notes/daily.md.conflict-1717000000000.md",
|
|
"notes/daily.md.conflict-1717000001000.md",
|
|
"other.md",
|
|
];
|
|
const conflicts = findConflictFiles(allFiles, "notes/daily.md");
|
|
assert.equal(conflicts.length, 2);
|
|
assert.ok(conflicts.every((c) => c.includes(".conflict-")));
|
|
});
|
|
|
|
test("returns empty when no conflicts exist", () => {
|
|
const allFiles = ["a.md", "b.md"];
|
|
const conflicts = findConflictFiles(allFiles, "a.md");
|
|
assert.equal(conflicts.length, 0);
|
|
});
|
|
|
|
test("does not match partial path names", () => {
|
|
const allFiles = [
|
|
"daily.md",
|
|
"daily.md.conflict-1717000000000.md",
|
|
"daily.md.backup",
|
|
];
|
|
const conflicts = findConflictFiles(allFiles, "daily.md");
|
|
assert.equal(conflicts.length, 1);
|
|
assert.equal(conflicts[0], "daily.md.conflict-1717000000000.md");
|
|
});
|
|
|
|
test("handles path with .conflict- in directory name (edge case)", () => {
|
|
const allFiles = [
|
|
"my.conflict-folder/note.md",
|
|
"my.conflict-folder/note.md.conflict-1717000000000.md",
|
|
];
|
|
const conflicts = findConflictFiles(allFiles, "my.conflict-folder/note.md");
|
|
assert.equal(conflicts.length, 1);
|
|
assert.equal(conflicts[0], "my.conflict-folder/note.md.conflict-1717000000000.md");
|
|
});
|
|
|
|
test("does not match non-.conflict- files with similar prefix", () => {
|
|
const allFiles = [
|
|
"note.md",
|
|
"note.md.conflict-1000.md",
|
|
"note.md.conflicting.md",
|
|
];
|
|
const conflicts = findConflictFiles(allFiles, "note.md");
|
|
assert.equal(conflicts.length, 1);
|
|
assert.equal(conflicts[0], "note.md.conflict-1000.md");
|
|
});
|
|
});
|
|
|
|
describe("Conflict Resolution", () => {
|
|
test("resolveLocal returns conflict file content", () => {
|
|
const result = resolveLocal("conflict content", "server content");
|
|
assert.equal(result.content, "conflict content");
|
|
assert.equal(result.action, "kept-local");
|
|
});
|
|
|
|
test("resolveLocal works even when existing is null (file deleted on server)", () => {
|
|
const result = resolveLocal("local save", null);
|
|
assert.equal(result.content, "local save");
|
|
assert.equal(result.action, "kept-local");
|
|
});
|
|
|
|
test("resolveRemote returns kept-remote action", () => {
|
|
const result = resolveRemote();
|
|
assert.equal(result.action, "kept-remote");
|
|
});
|
|
|
|
test("resolveKeepBoth returns all conflict paths", () => {
|
|
const paths = ["a.md.conflict-1000.md", "a.md.conflict-2000.md"];
|
|
const result = resolveKeepBoth(paths);
|
|
assert.equal(result.action, "kept-both");
|
|
assert.deepEqual(result.conflictPaths, paths);
|
|
});
|
|
|
|
test("resolveKeepBoth with empty array", () => {
|
|
const result = resolveKeepBoth([]);
|
|
assert.equal(result.action, "kept-both");
|
|
assert.deepEqual(result.conflictPaths, []);
|
|
});
|
|
});
|
|
|
|
describe("End-to-End Sync Scenarios", () => {
|
|
test("full pull: server has files local lacks", () => {
|
|
const serverFiles: ManifestEntry[] = [
|
|
{ path: "a.md", mtime: 2000, size: 100 },
|
|
{ path: "b.md", mtime: 1000, size: 200 },
|
|
];
|
|
const localMap = new Map<string, ManifestEntry>([
|
|
["a.md", { path: "a.md", mtime: 1000, size: 100 }],
|
|
]);
|
|
|
|
const pullResult = buildPullResult(serverFiles, [], [], localMap, 0);
|
|
assert.equal(pullResult.filesToPull.length, 2);
|
|
const paths = pullResult.filesToPull.map((f) => f.path).sort();
|
|
assert.deepEqual(paths, ["a.md", "b.md"]);
|
|
});
|
|
|
|
test("push to server with newer version creates conflict", () => {
|
|
const serverMtime = 3000;
|
|
const pushMtime = 2000;
|
|
const result = checkConflict(serverMtime, pushMtime);
|
|
assert.equal(result.conflict, true);
|
|
});
|
|
|
|
test("push with newer mtime does not conflict", () => {
|
|
const result = checkConflict(1000, 2000);
|
|
assert.equal(result.conflict, false);
|
|
});
|
|
|
|
test("tombstone propagation: delete on desktop, pull on mobile", () => {
|
|
const tombstones: TombstoneEntry[] = [
|
|
{ path: "deleted.md", deletedAt: 5000, deletedBy: "desktop" },
|
|
];
|
|
const localMap = new Map([["deleted.md", { path: "deleted.md", mtime: 1000, size: 100 }]]);
|
|
|
|
const pullResult = buildPullResult([], [], tombstones, localMap, 0);
|
|
assert.equal(pullResult.tombstones.length, 1);
|
|
assert.equal(pullResult.tombstones[0].path, "deleted.md");
|
|
});
|
|
|
|
test("rename produces tombstone for old path", () => {
|
|
const tombstones: TombstoneEntry[] = [];
|
|
const afterRename = addTombstone(tombstones, "old-name.md", "desktop");
|
|
assert.equal(afterRename.length, 1);
|
|
assert.equal(afterRename[0].path, "old-name.md");
|
|
});
|
|
|
|
test("large manifest: 5000 files, only 3 changed", () => {
|
|
const serverFiles: ManifestEntry[] = [];
|
|
const localMap = new Map<string, ManifestEntry>();
|
|
|
|
for (let i = 0; i < 5000; i++) {
|
|
const entry = { path: `file-${i}.md`, mtime: 1000, size: 100 };
|
|
serverFiles.push(entry);
|
|
if (i !== 42 && i !== 99 && i !== 4999) {
|
|
localMap.set(entry.path, { ...entry });
|
|
}
|
|
}
|
|
|
|
const result = buildPullResult(serverFiles, [], [], localMap, 0);
|
|
assert.equal(result.filesToPull.length, 3);
|
|
const paths = result.filesToPull.map((f) => f.path).sort();
|
|
assert.deepEqual(paths, ["file-42.md", "file-4999.md", "file-99.md"]);
|
|
});
|
|
|
|
test("identical manifests produce zero diff", () => {
|
|
const serverFiles: ManifestEntry[] = [];
|
|
const localMap = new Map<string, ManifestEntry>();
|
|
|
|
for (let i = 0; i < 100; i++) {
|
|
const entry = { path: `file-${i}.md`, mtime: 5000, size: 50 };
|
|
serverFiles.push(entry);
|
|
localMap.set(entry.path, { ...entry });
|
|
}
|
|
|
|
const result = buildPullResult(serverFiles, [], [], localMap, 0);
|
|
assert.equal(result.filesToPull.length, 0);
|
|
});
|
|
});
|