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