import assert from "node:assert/strict"; import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "bun:test"; import { canonicalSnapshot, parseManifest, parseSnapshot, restoreDiff, snapshotManifest, snapshotSha256, verifySnapshotManifest, type TaskTreeSnapshot, } from "./backup-contracts.ts"; import { TaskTreeGitBackup } from "./backup-git.ts"; import { startTaskTreeBackupScheduler, type TaskTreeBackupService } from "./backup.ts"; const snapshot: TaskTreeSnapshot = { schemaVersion: 1, groups: [ { id: "tg_1", name: "Backup", description: "", createdAt: "2026-07-18T00:00:00.000Z", updatedAt: "2026-07-18T00:00:00.000Z", }, ], tasks: [ { id: "tt_2", groupId: "tg_1", parentId: "tt_1", kind: "subtask", title: "Child", description: "", status: "pending", startAt: null, dueAt: null, sortOrder: 1, createdAt: "2026-07-18T00:00:00.000Z", updatedAt: "2026-07-18T00:00:00.000Z", }, { id: "tt_1", groupId: "tg_1", parentId: null, kind: "task", title: "Root", description: "", status: "in_progress", startAt: "2026-07-18T00:00:00.000Z", dueAt: "2026-07-19T00:00:00.000Z", sortOrder: 0, createdAt: "2026-07-18T00:00:00.000Z", updatedAt: "2026-07-18T00:00:00.000Z", }, ], milestones: [], reports: [ { id: "tr_1", taskId: "tt_1", title: "Execution", body: "Passed.", status: "succeeded", createdAt: "2026-07-18T00:00:00.000Z", }, ], }; test("TaskTree backup canonical JSON and SHA ignore entity input order", () => { const reversed = { ...snapshot, tasks: [...snapshot.tasks].reverse() }; assert.equal(canonicalSnapshot(snapshot), canonicalSnapshot(reversed)); assert.equal(snapshotSha256(snapshot), snapshotSha256(reversed)); assert.deepEqual(parseSnapshot(canonicalSnapshot(snapshot)), reversed); }); test("TaskTree backup manifest rejects changed snapshot content", () => { const manifest = snapshotManifest(snapshot, { target: "development", generatedAt: "2026-07-18T01:00:00.000Z", }); assert.doesNotThrow(() => verifySnapshotManifest(snapshot, parseManifest(JSON.stringify(manifest)))); const changed = { ...snapshot, tasks: snapshot.tasks.map((task) => task.id === "tt_1" ? { ...task, title: "Changed" } : task), }; assert.throws( () => verifySnapshotManifest(changed, manifest), (error: any) => error?.code === "backup_content_sha_mismatch", ); }); test("TaskTree restore diff reports create, update, and delete independently", () => { const incoming: TaskTreeSnapshot = { ...snapshot, tasks: [ { ...snapshot.tasks[0], title: "Updated child" }, { ...snapshot.tasks[1], id: "tt_3", parentId: null, title: "New root", }, ], }; assert.deepEqual(restoreDiff(snapshot, incoming).tasks, { create: 1, update: 1, delete: 1 }); }); test("TaskTree Git backup publishes once and deduplicates identical content", async () => { const root = mkdtempSync(join(tmpdir(), "tasktree-backup-test-")); const remote = join(root, "remote.git"); const worktree = join(root, "worktree"); try { const init = Bun.spawnSync(["git", "init", "--bare", remote], { stdout: "pipe", stderr: "pipe" }); assert.equal(init.exitCode, 0); const backup = new TaskTreeGitBackup({ repoSshUrl: remote, branch: "main", basePath: "tasktree/test", worktreePath: worktree, target: "test", sshCommand: "", authorName: "TaskTree Test", authorEmail: "tasktree-test@example.invalid", commitMessagePrefix: "tasktree:", }); const first = await backup.publish(snapshot, "test snapshot"); const second = await backup.publish(snapshot, "duplicate snapshot"); assert.equal(first.changed, true); assert.equal(second.changed, false); assert.equal(second.gitCommit, first.gitCommit); assert.equal(backup.readCurrent()?.manifest.contentSha256, snapshotSha256(snapshot)); assert.equal(JSON.parse(readFileSync(join(worktree, "tasktree/test/manifest.json"), "utf8")).target, "test"); } finally { rmSync(root, { recursive: true, force: true }); } }); test("TaskTree backup scheduler waits for an in-flight snapshot before stopping", async () => { let finish!: () => void; const createFinished = new Promise((resolve) => { finish = resolve; }); const service = { config: { enabled: true, intervalMs: 60_000 }, async create() { await createFinished; return { changed: false }; }, } as unknown as TaskTreeBackupService; const scheduler = startTaskTreeBackupScheduler(service); await Bun.sleep(10); let stopped = false; const stopping = scheduler.stop().then(() => { stopped = true; }); await Bun.sleep(10); assert.equal(stopped, false); finish(); await stopping; assert.equal(stopped, true); });