Files
christianandClaude Opus 5.5 dab492da1e Lock during updates, repair duplicate ids, keep file identity on save
- CLI holds a lock file from load to save, so concurrent commands can't
  lose an update; stale locks from crashed runs are taken over
- load() renumbers duplicate ids from the old add() bug instead of
  refusing the file
- save() keeps owner, group and setgid on the temp file, and falls back to
  an in-place write for hard-linked files, files whose owner/group can't
  be kept, and directories that aren't writable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:49:47 +02:00

168 lines
5.6 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, readdirSync, symlinkSync, linkSync, lstatSync, statSync, chmodSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { load, save, add, has, complete, remove, format } from "../src/todo.js";
const tempFile = () => join(mkdtempSync(join(tmpdir(), "todo-")), "todos.json");
const isRoot = process.getuid?.() === 0;
test("add appends an open todo", () => {
const todos = add([], "buy milk");
assert.deepEqual(todos, [{ id: 1, text: "buy milk", done: false }]);
});
test("add after a remove does not reuse an existing id", () => {
const todos = add(remove(add(add([], "a"), "b"), 1), "c");
assert.deepEqual(todos.map((t) => t.id), [2, 3]);
});
test("has reports whether an id exists", () => {
const todos = add([], "a");
assert.equal(has(todos, 1), true);
assert.equal(has(todos, 2), false);
});
test("complete marks only the matching todo", () => {
const todos = complete(add(add([], "a"), "b"), 2);
assert.equal(todos[0].done, false);
assert.equal(todos[1].done, true);
});
test("remove drops the matching todo", () => {
const todos = remove(add(add([], "a"), "b"), 1);
assert.deepEqual(todos.map((t) => t.text), ["b"]);
});
test("format renders an empty list", () => {
assert.equal(format([]), "Nothing to do.");
});
test("load returns an empty list for a missing file", () => {
assert.deepEqual(load(tempFile()), []);
});
test("add refuses an id that load() would reject", () => {
assert.throws(() => add([{ id: Number.MAX_SAFE_INTEGER - 1, text: "a", done: false }], "b"), /No ids left/);
});
test("add handles a very large list", () => {
const todos = Array.from({ length: 200_000 }, (_, i) => ({ id: i + 1, text: "x", done: false }));
assert.equal(add(todos, "y").at(-1).id, 200_001);
});
test("save then load round-trips and leaves no temp file", () => {
const file = tempFile();
const todos = add([], "a");
save(file, todos);
assert.deepEqual(load(file), todos);
assert.deepEqual(readdirSync(dirname(file)), ["todos.json"]);
});
test("save writes through a symlink instead of replacing it", () => {
const real = tempFile();
const link = join(dirname(tempFile()), "link.json");
save(real, []);
symlinkSync(real, link);
save(link, add([], "a"));
assert.equal(lstatSync(link).isSymbolicLink(), true);
assert.deepEqual(load(real).map((t) => t.text), ["a"]);
});
test("load renumbers duplicate ids left by the old add() bug", () => {
const file = tempFile();
writeFileSync(file, JSON.stringify([
{ id: 2, text: "b", done: false },
{ id: 2, text: "c", done: true },
{ id: 3, text: "d", done: false },
]));
assert.deepEqual(load(file).map((t) => [t.id, t.text, t.done]), [[2, "b", false], [4, "c", true], [3, "d", false]]);
});
test("save writes in place when the file has other hard links", () => {
const file = tempFile();
const other = join(dirname(file), "other.json");
save(file, []);
linkSync(file, other);
save(file, add([], "a"));
assert.deepEqual(load(other).map((t) => t.text), ["a"]);
});
test("save writes in place when only the file, not its directory, is writable", { skip: isRoot && "root ignores file permissions" }, () => {
const file = tempFile();
save(file, []);
chmodSync(dirname(file), 0o500);
try {
save(file, add([], "a"));
assert.deepEqual(load(file).map((t) => t.text), ["a"]);
assert.deepEqual(readdirSync(dirname(file)), ["todos.json"]);
} finally {
chmodSync(dirname(file), 0o700);
}
});
test("save through a dangling symlink creates the target and keeps the link", () => {
const dir = dirname(tempFile());
const link = join(dir, "link.json");
symlinkSync("real.json", link);
save(link, add([], "a"));
assert.equal(lstatSync(link).isSymbolicLink(), true);
assert.deepEqual(load(join(dir, "real.json")).map((t) => t.text), ["a"]);
});
test("save refuses a read-only file", { skip: isRoot && "root ignores file permissions" }, () => {
const file = tempFile();
save(file, []);
chmodSync(file, 0o444);
assert.throws(() => save(file, add([], "a")), /Could not save .*todos\.json/);
assert.deepEqual(load(file), []);
});
test("save keeps the file's permissions", () => {
const file = tempFile();
save(file, []);
chmodSync(file, 0o600);
save(file, add([], "a"));
assert.equal(statSync(file).mode & 0o777, 0o600);
});
test("save reports a readable error and cleans up when it can't write", { skip: isRoot && "root ignores file permissions" }, () => {
const file = tempFile();
chmodSync(dirname(file), 0o500);
try {
assert.throws(() => save(file, []), /Could not save .*todos\.json/);
assert.deepEqual(readdirSync(dirname(file)), []);
} finally {
chmodSync(dirname(file), 0o700);
}
});
test("load rejects invalid JSON with a readable error", () => {
const file = tempFile();
writeFileSync(file, "{ not json");
assert.throws(() => load(file), /Could not read .*todos\.json/);
});
test("load rejects JSON that is not a list", () => {
const file = tempFile();
writeFileSync(file, "{}");
assert.throws(() => load(file), /expected a list of todos/);
});
for (const entry of [
"null",
"1",
'{"text":"a","done":false}',
'{"id":"1","text":"a","done":false}',
'{"id":0,"text":"a","done":false}',
'{"id":-1,"text":"a","done":false}',
`{"id":${Number.MAX_SAFE_INTEGER},"text":"a","done":false}`,
]) {
test(`load rejects an invalid entry: ${entry}`, () => {
const file = tempFile();
writeFileSync(file, `[${entry}]`);
assert.throws(() => load(file), /entry 1 is not a valid todo/);
});
}