// E2E test for the in-app messaging flow built on the Notes page: // - Sender (user A) creates a note via the composer at /notes // - Sender clicks "Send" on the card and picks user B as recipient // - Recipient (user B) signs in, opens /notes → "Inbox" tab // - Sees the note from A with an "Unread" badge → opens it (which marks read) // - Replies inline; reply persists in the thread dialog // - Sender re-opens /notes → "Sent" tab and sees the recipient with the // "Replied" status pill. 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 = []; 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_replies WHERE note_id = ANY($1::int[])`, [createdNoteIds], ); 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(), ]); } async function logout(page) { await page.context().clearCookies(); } test("sender → recipient → reply flow updates Inbox + Sent tabs", async ({ page, }) => { const sender = await createUser("notes_e2e_sender", "Sender Person"); const recipient = await createUser("notes_e2e_recipient", "Recipient Person"); // ---- Sender composes + sends a note ---- await loginViaUi(page, sender.username); await page.goto("/notes"); await expect(page.getByTestId("notes-page")).toBeVisible(); const noteContent = `Please review attached. ${uniq()}`; await page.getByTestId("notes-composer-open").click(); await page.getByTestId("notes-composer-content").fill(noteContent); const createPromise = page.waitForResponse( (r) => new URL(r.url()).pathname === "/api/notes" && r.request().method() === "POST" && r.status() >= 200 && r.status() < 300, { timeout: 15_000 }, ); await page.getByTestId("notes-composer-save").click(); const createResp = await createPromise; const createdNote = await createResp.json(); createdNoteIds.push(createdNote.id); // The new card should appear with our content; click its Send button. const card = page.getByTestId(`note-card-${createdNote.id}`); await expect(card).toBeVisible(); await card.hover(); await page.getByTestId(`note-card-send-${createdNote.id}`).click(); const sendDialog = page.getByTestId("send-note-dialog"); await expect(sendDialog).toBeVisible(); await page.getByTestId("send-note-search").fill(recipient.displayEn); await page .getByTestId(`send-recipient-option-${recipient.id}`) .click(); const sendPromise = page.waitForResponse( (r) => new URL(r.url()).pathname === `/api/notes/${createdNote.id}/send` && r.request().method() === "POST" && r.status() >= 200 && r.status() < 300, { timeout: 15_000 }, ); await page.getByTestId("send-note-submit").click(); await sendPromise; await expect(page.getByTestId("send-note-confirm")).toBeHidden(); await expect(sendDialog).toBeHidden(); // ---- Switch to recipient session ---- await logout(page); await loginViaUi(page, recipient.username); await page.goto("/notes"); await page.getByTestId("notes-overflow-menu-trigger").click(); await page.getByTestId("notes-overflow-received").click(); const inboxCard = page.getByTestId(`received-note-card-${createdNote.id}`); await expect(inboxCard).toBeVisible(); await expect(inboxCard).toContainText(noteContent); await expect(inboxCard).toContainText(sender.displayEn); await expect( inboxCard.getByTestId("status-badge-unread"), ).toBeVisible(); // Open the thread (this auto-marks read). const readPromise = page.waitForResponse( (r) => new URL(r.url()).pathname === `/api/notes/${createdNote.id}/read` && r.request().method() === "POST" && r.status() >= 200 && r.status() < 300, { timeout: 15_000 }, ); await inboxCard.click(); const threadDialog = page.getByTestId("note-thread-dialog"); await expect(threadDialog).toBeVisible(); await readPromise; // Reply inline. const replyText = "Acknowledged, thanks."; await page.getByTestId("thread-reply-input").fill(replyText); const replyPromise = page.waitForResponse( (r) => new URL(r.url()).pathname === `/api/notes/${createdNote.id}/reply` && r.request().method() === "POST" && r.status() >= 200 && r.status() < 300, { timeout: 15_000 }, ); await page.getByTestId("thread-reply-submit").click(); await replyPromise; // Reply now appears in the thread. await expect(threadDialog).toContainText(replyText); await page.keyboard.press("Escape"); // ---- Back to sender → Sent tab shows replied status ---- await logout(page); await loginViaUi(page, sender.username); await page.goto("/notes"); await page.getByTestId("notes-overflow-menu-trigger").click(); await page.getByTestId("notes-overflow-sent").click(); const sentCard = page.getByTestId(`sent-note-card-${createdNote.id}`); await expect(sentCard).toBeVisible(); await expect(sentCard).toContainText(noteContent); await expect(sentCard).toContainText(recipient.displayEn); // Per-recipient status pill flipped to "replied". const recipientPill = page.getByTestId( `sent-recipient-status-${createdNote.id}-${recipient.id}`, ); await expect(recipientPill).toBeVisible(); // Open the thread from the sender side and confirm the reply is visible. await sentCard.click(); const senderThread = page.getByTestId("note-thread-dialog"); await expect(senderThread).toBeVisible(); await expect(senderThread).toContainText(replyText); });