// e2e: per-recipient delete on the Inbox tab. // Seeds three received notes for a recipient, opens Notes (Inbox is // the default view), enters bulk-select mode, picks two cards, // confirms the bulk delete, and verifies that the deleted recipient // rows are gone from the inbox while the unselected one remains. // Then the third row is removed via the per-row Delete in // ThreadDialog. Direct-DB seed mirrors notes-sent-bulk-delete.spec.mjs. 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 }; } async function createNote(userId, title, content) { const { rows } = await pool.query( `INSERT INTO notes (user_id, title, content, color, kind) VALUES ($1, $2, $3, 'yellow', 'text') RETURNING id, title, content, color, kind`, [userId, title, content], ); const note = rows[0]; createdNoteIds.push(note.id); return note; } async function addRecipient(note, senderId, recipientId) { await pool.query( `INSERT INTO note_recipients (note_id, sender_user_id, recipient_user_id, title, content, color, kind, status) VALUES ($1, $2, $3, $4, $5, $6, $7, 'unread')`, [note.id, senderId, recipientId, note.title, note.content, note.color, note.kind], ); } 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(), ]); } test("Inbox tab: bulk delete two notes, third deleted via per-row card button", async ({ page, }) => { const sender = await createUser("inbox_del_sender", "Sender Del"); const recipient = await createUser("inbox_del_recipient", "Recipient Del"); // Seed three received notes for the recipient. const tag = uniq(); const noteA = await createNote(sender.id, `Inbox A ${tag}`, "alpha"); const noteB = await createNote(sender.id, `Inbox B ${tag}`, "beta"); const noteC = await createNote(sender.id, `Inbox C ${tag}`, "gamma"); await addRecipient(noteA, sender.id, recipient.id); await addRecipient(noteB, sender.id, recipient.id); await addRecipient(noteC, sender.id, recipient.id); await loginViaUi(page, recipient.username); // Switch to Inbox view. await page.goto("/notes"); await expect(page.getByTestId("notes-page")).toBeVisible(); await page.getByTestId("notes-overflow-menu-trigger").click(); await page.getByTestId("notes-overflow-received").click(); const cardA = page.getByTestId(`received-note-card-${noteA.id}`); const cardB = page.getByTestId(`received-note-card-${noteB.id}`); const cardC = page.getByTestId(`received-note-card-${noteC.id}`); await expect(cardA).toBeVisible(); await expect(cardB).toBeVisible(); await expect(cardC).toBeVisible(); // Enter bulk-select; pick A + B (leave C). await page.getByTestId("inbox-bulk-select-toggle").click(); await expect(page.getByTestId("inbox-bulk-bar")).toBeVisible(); await cardA.click(); await cardB.click(); await expect(page.getByTestId("inbox-bulk-count")).toContainText("2"); // Confirm bulk delete. await page.getByTestId("inbox-bulk-delete").click(); await expect(page.getByTestId("inbox-bulk-delete-confirm")).toBeVisible(); const bulkDeleted = page.waitForResponse( (r) => new URL(r.url()).pathname === "/api/notes/received/bulk-delete" && r.request().method() === "POST" && r.status() >= 200 && r.status() < 300, { timeout: 15_000 }, ); await page.getByTestId("inbox-bulk-delete-confirm-button").click(); await bulkDeleted; await expect(cardA).toBeHidden(); await expect(cardB).toBeHidden(); await expect(cardC).toBeVisible(); // The recipient rows for A + B are gone but the underlying notes remain // (sender still owns them). const { rows: recRows } = await pool.query( `SELECT note_id FROM note_recipients WHERE note_id = ANY($1::int[]) AND recipient_user_id = $2`, [[noteA.id, noteB.id, noteC.id], recipient.id], ); const remaining = recRows.map((r) => r.note_id); expect(remaining).toEqual([noteC.id]); const { rows: noteRows } = await pool.query( `SELECT id FROM notes WHERE id = ANY($1::int[])`, [[noteA.id, noteB.id, noteC.id]], ); expect(noteRows.length).toEqual(3); // Per-row delete from the inbox card itself (not via the thread). await page.getByTestId(`received-note-delete-${noteC.id}`).click(); await expect(page.getByTestId("inbox-row-delete-confirm")).toBeVisible(); const singleDeleted = page.waitForResponse( (r) => new URL(r.url()).pathname === `/api/notes/received/${noteC.id}` && r.request().method() === "DELETE" && r.status() >= 200 && r.status() < 300, { timeout: 15_000 }, ); await page.getByTestId("inbox-row-delete-confirm-button").click(); await singleDeleted; await expect(cardC).toBeHidden(); const { rows: recRows2 } = await pool.query( `SELECT note_id FROM note_recipients WHERE note_id = $1 AND recipient_user_id = $2`, [noteC.id, recipient.id], ); expect(recRows2.length).toEqual(0); });