Files
pikasTech-HWLAB/internal/tasktree/backup-contracts.test.ts
T

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);
});