// API tests for the collaborative checklist toggle endpoint // `POST /notes/:id/checklist/:itemId/toggle`. Owner + active recipients // can flip an item; the change is mirrored to every recipient snapshot. import { test, after } from "node:test"; import assert from "node:assert/strict"; import pg from "pg"; const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error("DATABASE_URL must be set to run these tests"); } const TEST_PASSWORD = "TestPass123!"; const TEST_PASSWORD_HASH = "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; const pool = new pg.Pool({ connectionString: DATABASE_URL }); const createdUserIds = []; const createdNoteIds = []; function uniq() { return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } async function createUser(prefix) { const username = `${prefix}_${uniq()}`; const { rows } = await pool.query( `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`, [username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix], ); const id = rows[0].id; createdUserIds.push(id); await pool.query( `INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = 'user'`, [id], ); return { id, username }; } async function login(username) { const res = await fetch(`${API_BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password: TEST_PASSWORD }), }); assert.equal(res.status, 200); const sid = res.headers .get("set-cookie") .split(",") .map((c) => c.split(";")[0].trim()) .find((c) => c.startsWith("connect.sid=")); return sid; } async function api(path, cookie, init = {}) { return fetch(`${API_BASE}/api${path}`, { ...init, headers: { Cookie: cookie, "Content-Type": "application/json", ...(init.headers ?? {}), }, }); } async function createChecklistNote(cookie, items) { const res = await api("/notes", cookie, { method: "POST", body: JSON.stringify({ title: "Shared list", kind: "checklist", items, color: "blue", }), }); assert.ok(res.status === 200 || res.status === 201); const json = await res.json(); createdNoteIds.push(json.id); return json; } after(async () => { if (createdNoteIds.length > 0) { await pool.query(`DELETE FROM note_recipients WHERE note_id = ANY($1::int[])`, [createdNoteIds]); await pool.query(`DELETE FROM notes WHERE id = ANY($1::int[])`, [createdNoteIds]); } if (createdUserIds.length > 0) { await pool.query(`DELETE FROM notifications WHERE user_id = ANY($1::int[])`, [createdUserIds]); await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [createdUserIds]); await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [createdUserIds]); } await pool.end(); }); test("owner can toggle a checklist item; live note + recipient snapshot both update", async () => { const owner = await createUser("cl_owner"); const recipient = await createUser("cl_recipient"); const oCookie = await login(owner.username); const note = await createChecklistNote(oCookie, [ { id: "a", text: "Buy milk", done: false }, { id: "b", text: "Walk dog", done: false }, ]); await api(`/notes/${note.id}/send`, oCookie, { method: "POST", body: JSON.stringify({ recipientUserIds: [recipient.id] }), }); const res = await api(`/notes/${note.id}/checklist/a/toggle`, oCookie, { method: "POST", body: JSON.stringify({ done: true }), }); assert.equal(res.status, 200); const body = await res.json(); assert.equal(body.success, true); assert.equal(body.items.find((i) => i.id === "a").done, true); // Live note row updated. const { rows: liveRows } = await pool.query( `SELECT items FROM notes WHERE id = $1`, [note.id], ); assert.equal(liveRows[0].items.find((i) => i.id === "a").done, true); // Recipient snapshot mirrored. const { rows: snapRows } = await pool.query( `SELECT items FROM note_recipients WHERE note_id = $1`, [note.id], ); assert.equal(snapRows[0].items.find((i) => i.id === "a").done, true); }); test("recipient can toggle a checklist item collaboratively", async () => { const owner = await createUser("cl_owner2"); const recipient = await createUser("cl_recipient2"); const oCookie = await login(owner.username); const rCookie = await login(recipient.username); const note = await createChecklistNote(oCookie, [ { id: "x", text: "Task X", done: false }, ]); await api(`/notes/${note.id}/send`, oCookie, { method: "POST", body: JSON.stringify({ recipientUserIds: [recipient.id] }), }); const res = await api(`/notes/${note.id}/checklist/x/toggle`, rCookie, { method: "POST", body: JSON.stringify({ done: true }), }); assert.equal(res.status, 200, "active recipient must be able to toggle"); // Owner sees the change in their live note. const { rows } = await pool.query(`SELECT items FROM notes WHERE id = $1`, [note.id]); assert.equal(rows[0].items[0].done, true); }); test("non-recipient gets 403 when toggling", async () => { const owner = await createUser("cl_owner3"); const recipient = await createUser("cl_recipient3"); const stranger = await createUser("cl_stranger3"); const oCookie = await login(owner.username); const sCookie = await login(stranger.username); const note = await createChecklistNote(oCookie, [ { id: "y", text: "Y", done: false }, ]); await api(`/notes/${note.id}/send`, oCookie, { method: "POST", body: JSON.stringify({ recipientUserIds: [recipient.id] }), }); const res = await api(`/notes/${note.id}/checklist/y/toggle`, sCookie, { method: "POST", body: JSON.stringify({ done: true }), }); assert.equal(res.status, 403); }); test("archived recipient gets 403 when toggling", async () => { const owner = await createUser("cl_owner4"); const recipient = await createUser("cl_recipient4"); const oCookie = await login(owner.username); const rCookie = await login(recipient.username); const note = await createChecklistNote(oCookie, [ { id: "z", text: "Z", done: false }, ]); await api(`/notes/${note.id}/send`, oCookie, { method: "POST", body: JSON.stringify({ recipientUserIds: [recipient.id] }), }); await api(`/notes/${note.id}/archive`, rCookie, { method: "POST", body: JSON.stringify({ archived: true }), }); const res = await api(`/notes/${note.id}/checklist/z/toggle`, rCookie, { method: "POST", body: JSON.stringify({ done: true }), }); assert.equal(res.status, 403); }); test("non-checklist note rejects the toggle with 400", async () => { const owner = await createUser("cl_owner5"); const oCookie = await login(owner.username); const res = await api("/notes", oCookie, { method: "POST", body: JSON.stringify({ title: "Plain", content: "hi", kind: "text" }), }); const note = await res.json(); createdNoteIds.push(note.id); const tog = await api(`/notes/${note.id}/checklist/a/toggle`, oCookie, { method: "POST", body: JSON.stringify({ done: true }), }); assert.equal(tog.status, 400); }); test("unknown itemId yields 404", async () => { const owner = await createUser("cl_owner6"); const oCookie = await login(owner.username); const note = await createChecklistNote(oCookie, [ { id: "real", text: "T", done: false }, ]); const res = await api(`/notes/${note.id}/checklist/missing/toggle`, oCookie, { method: "POST", body: JSON.stringify({ done: true }), }); assert.equal(res.status, 404); }); test("invalid body (missing done boolean) yields 400", async () => { const owner = await createUser("cl_owner7"); const oCookie = await login(owner.username); const note = await createChecklistNote(oCookie, [ { id: "a", text: "A", done: false }, ]); const res = await api(`/notes/${note.id}/checklist/a/toggle`, oCookie, { method: "POST", body: JSON.stringify({}), }); assert.equal(res.status, 400); });