feat(tasktree): add GitHub disaster recovery backups
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
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<void>((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);
|
||||
});
|
||||
Reference in New Issue
Block a user