import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, writeFileSync, readdirSync, symlinkSync, 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"); 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 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("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", () => { 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}']) { test(`load rejects an invalid entry: ${entry}`, () => { const file = tempFile(); writeFileSync(file, `[${entry}]`); assert.throws(() => load(file), /entry 1 is not a valid todo/); }); }