167 lines
5.0 KiB
TypeScript
167 lines
5.0 KiB
TypeScript
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);
|
|
});
|