// E2E regression for the per-note checklist (to-do list) option. // 1) Composer can switch to checklist mode, items can be added and saved. // 2) After reload, the saved checklist persists with its items + done state. // 3) Sending a checklist note delivers the items to the recipient snapshot. import { test, expect } from "@playwright/test"; import pg from "pg"; const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error("DATABASE_URL must be set to run this UI test"); } const TEST_PASSWORD = "TestPass123!"; const TEST_PASSWORD_HASH = "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; const pool = new pg.Pool({ connectionString: DATABASE_URL }); const createdUserIds = []; function uniq() { return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } async function createUser(prefix, displayEn) { 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, displayEn], ); 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 }; } test.afterAll(async () => { if (createdUserIds.length > 0) { await pool.query( `DELETE FROM note_recipients WHERE sender_user_id = ANY($1::int[]) OR recipient_user_id = ANY($1::int[])`, [createdUserIds], ); await pool.query(`DELETE FROM notes 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(); }); async function loginViaUi(page, username) { await page.goto("/login"); await page.locator("#username").fill(username); await page.locator("#password").fill(TEST_PASSWORD); await Promise.all([ page.waitForURL((url) => !url.pathname.endsWith("/login"), { timeout: 15_000, }), page.locator('form button[type="submit"]').click(), ]); } test("composer creates a checklist note that persists across reloads", async ({ page, }) => { const user = await createUser("notes_checklist_e2e", "Checklist Tester"); await loginViaUi(page, user.username); await page.goto("/notes"); await expect(page.getByTestId("notes-page")).toBeVisible(); // Open composer and switch to checklist. await page.getByTestId("notes-composer").click(); await page.getByTestId("notes-composer-kind-toggle").click(); // Add two items via the "Add item" button. const composer = page.getByTestId("notes-composer"); await composer.getByTestId("notes-composer-checklist-add").click(); // The newly inserted row gets focus; type into it. await page.keyboard.type("Buy milk"); await composer.getByTestId("notes-composer-checklist-add").click(); await page.keyboard.type("Walk the dog"); // Save via the explicit Save button (synchronous, no portal-click risk). const savePromise = page.waitForResponse( (r) => new URL(r.url()).pathname === "/api/notes" && r.request().method() === "POST" && r.status() >= 200 && r.status() < 300, ); await page.getByTestId("notes-composer-save").click(); const resp = await savePromise; const note = await resp.json(); expect(note.kind).toBe("checklist"); expect(Array.isArray(note.items)).toBe(true); expect(note.items.map((i) => i.text)).toEqual(["Buy milk", "Walk the dog"]); // The note card should render the checklist with both rows visible. const card = page.getByTestId(`note-card-${note.id}`); await expect(card).toBeVisible(); await expect(card.getByText("Buy milk")).toBeVisible(); await expect(card.getByText("Walk the dog")).toBeVisible(); // Tick the first item from the card, expect a PATCH that flips done. const firstItemId = note.items[0].id; const togglePromise = page.waitForResponse( (r) => new URL(r.url()).pathname === `/api/notes/${note.id}` && r.request().method() === "PATCH" && r.status() >= 200 && r.status() < 300, ); await card .getByTestId(`note-card-checklist-${note.id}-check-${firstItemId}`) .click(); const patched = await togglePromise; const patchedNote = await patched.json(); expect(patchedNote.kind).toBe("checklist"); const patchedFirst = patchedNote.items.find((i) => i.id === firstItemId); expect(patchedFirst?.done).toBe(true); // Reload — the persisted checklist with the toggled state must come back. await page.reload(); await expect(page.getByTestId(`note-card-${note.id}`)).toBeVisible(); const reloadedFirst = page.getByTestId( `note-card-checklist-${note.id}-check-${firstItemId}`, ); await expect(reloadedFirst).toBeChecked(); }); test("PATCH items without kind normalizes against the note's current kind", async ({ page, }) => { const user = await createUser("notes_checklist_norm", "Norm Tester"); await loginViaUi(page, user.username); // Create a plain text note (kind defaults to "text"). const textResp = await page.request.post("/api/notes", { data: { title: `text-note ${uniq()}`, content: "hello" }, }); expect(textResp.ok()).toBe(true); const textNote = await textResp.json(); expect(textNote.kind).toBe("text"); // Patch only `items` (no kind). Server must coerce items back to null // because the note's current kind is "text". const patchTextResp = await page.request.patch(`/api/notes/${textNote.id}`, { data: { items: [{ id: "x", text: "should be dropped", done: false }] }, }); expect(patchTextResp.ok()).toBe(true); const patchedText = await patchTextResp.json(); expect(patchedText.kind).toBe("text"); expect(patchedText.items).toBeNull(); // Now create a checklist note and patch only `items` (no kind). Items // must persist because the note's current kind is "checklist". const clResp = await page.request.post("/api/notes", { data: { title: `cl-note ${uniq()}`, kind: "checklist", items: [{ id: "a", text: "first", done: false }], }, }); const cl = await clResp.json(); const patchClResp = await page.request.patch(`/api/notes/${cl.id}`, { data: { items: [{ id: "a", text: "first", done: true }] }, }); expect(patchClResp.ok()).toBe(true); const patchedCl = await patchClResp.json(); expect(patchedCl.kind).toBe("checklist"); expect(patchedCl.items[0].done).toBe(true); }); test("checklist notes are delivered to recipients with their items", async ({ page, }) => { const sender = await createUser("notes_checklist_send_s", "Sender"); const recipient = await createUser("notes_checklist_send_r", "Recipient"); await loginViaUi(page, sender.username); // Create the checklist note directly via the API (we already cover the // composer flow above; this test focuses on the send/snapshot path). const createResp = await page.request.post("/api/notes", { data: { title: `send-checklist ${uniq()}`, kind: "checklist", items: [ { id: "a", text: "Item A", done: false }, { id: "b", text: "Item B", done: true }, ], }, }); expect(createResp.ok()).toBe(true); const note = await createResp.json(); const sendResp = await page.request.post(`/api/notes/${note.id}/send`, { data: { recipientUserIds: [recipient.id] }, }); expect(sendResp.ok()).toBe(true); // Log out and back in as the recipient, then read the inbox. await page.request.post("/api/auth/logout").catch(() => {}); await loginViaUi(page, recipient.username); const receivedResp = await page.request.get("/api/notes/received?archived=false"); expect(receivedResp.ok()).toBe(true); const received = await receivedResp.json(); const delivered = received.find((r) => r.id === note.id); expect(delivered, "checklist note should appear in recipient inbox").toBeTruthy(); expect(delivered.kind).toBe("checklist"); expect(delivered.items.map((i) => i.text)).toEqual(["Item A", "Item B"]); expect(delivered.items[1].done).toBe(true); });