// e2e: a recipient toggles a checklist item directly from the // incoming-note popup, and the change is visible to the sender (and // persists server-side). 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"); 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, 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, displayEn }; } test.afterAll(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 notes WHERE user_id = ANY($1::int[])`, [createdUserIds]); 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(); }); 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("recipient ticks a checklist item in the popup; sender sees the update", async ({ browser, }) => { test.setTimeout(120_000); const sender = await createUser("clcollab_sender", "CL Collab Sender"); const recipient = await createUser("clcollab_recipient", "CL Collab Recipient"); const senderCtx = await browser.newContext(); const recipientCtx = await browser.newContext(); const senderPage = await senderCtx.newPage(); const recipientPage = await recipientCtx.newPage(); try { await loginViaUi(recipientPage, recipient.username); await recipientPage.goto("/"); await recipientPage.waitForTimeout(3500); await loginViaUi(senderPage, sender.username); await senderPage.goto("/notes"); await expect(senderPage.getByTestId("notes-page")).toBeVisible(); // Compose a checklist note with two items. await senderPage.getByTestId("notes-composer-open").click(); await senderPage.getByTestId("notes-composer-kind-toggle").click(); const list = senderPage.getByTestId("notes-composer-checklist-list"); await expect(list).toBeVisible(); await senderPage.getByRole("button", { name: "Add item" }).click(); const itemInputs = list.locator("input:not([type=checkbox])"); await itemInputs.first().fill("Buy milk"); await itemInputs.first().press("Enter"); await itemInputs.nth(1).fill("Walk dog"); await itemInputs.nth(1).blur(); const createPromise = senderPage.waitForResponse( (r) => new URL(r.url()).pathname === "/api/notes" && r.request().method() === "POST" && r.status() < 300, { timeout: 15_000 }, ); await senderPage.getByTestId("notes-composer-save").click(); const createResp = await createPromise; const created = await createResp.json(); createdNoteIds.push(created.id); // Send to the recipient. const card = senderPage.getByTestId(`note-card-${created.id}`); await card.hover(); await senderPage.getByTestId(`note-card-send-${created.id}`).click(); await senderPage.getByTestId("send-note-search").fill(recipient.displayEn); await senderPage.getByTestId(`send-recipient-option-${recipient.id}`).click(); const sendPromise = senderPage.waitForResponse( (r) => new URL(r.url()).pathname === `/api/notes/${created.id}/send` && r.status() < 300, { timeout: 15_000 }, ); await senderPage.getByTestId("send-note-submit").click(); await senderPage.getByTestId("send-note-confirm-submit").click(); await sendPromise; // Recipient sees the popup with the checklist. const popup = recipientPage.getByTestId("incoming-note-popup"); await expect(popup).toBeVisible({ timeout: 5_000 }); const checklist = recipientPage.getByTestId("incoming-note-popup-checklist"); await expect(checklist).toBeVisible(); // Tick the first item from the popup. The first checkbox in the // checklist has data-testid `incoming-note-popup-checklist-check-`. const firstCheckbox = checklist.locator('input[type="checkbox"]').first(); const togglePromise = recipientPage.waitForResponse( (r) => /\/api\/notes\/\d+\/checklist\/.+\/toggle$/.test( new URL(r.url()).pathname, ) && r.status() < 300, { timeout: 15_000 }, ); await firstCheckbox.check(); await togglePromise; // After the mutation settles the popup checkbox must remain // checked — the actor doesn't get a socket echo, so this verifies // the popup queue patch driven by the mutation response. await expect(firstCheckbox).toBeChecked({ timeout: 3_000 }); // Wait one paint frame and re-assert to guard against late state // resets that would only manifest after the mutation finishes. await recipientPage.waitForTimeout(400); await expect(firstCheckbox).toBeChecked(); // Server-side state confirms the toggle persisted (and was mirrored // to the recipient snapshot). const { rows } = await pool.query(`SELECT items FROM notes WHERE id = $1`, [ created.id, ]); expect(rows[0].items[0].done).toBe(true); // Sender's open notes page reflects the shared change after the // socket fanout invalidates the cache. We confirm both DB state // (recipient snapshot mirrored) AND the sender-rendered card on // their /notes page after a refresh — the latter is the actual UX // promise of "everyone sees the same shared state". await expect .poll( async () => ( await pool.query(`SELECT items FROM note_recipients WHERE note_id = $1`, [ created.id, ]) ).rows[0].items[0].done, { timeout: 5_000 }, ) .toBe(true); await senderPage.reload(); await expect(senderPage.getByTestId("notes-page")).toBeVisible(); const senderCard = senderPage.getByTestId(`note-card-${created.id}`); await expect(senderCard).toBeVisible(); const senderCheckboxes = senderCard.locator('input[type="checkbox"]'); await expect(senderCheckboxes.first()).toBeChecked({ timeout: 5_000 }); } finally { await senderCtx.close(); await recipientCtx.close(); } });