Files
TX/artifacts/tx-os/tests/notes-inbox-bulk-delete.spec.mjs
T
riyadhafraa 0988585c65 Task #512: Per-recipient Delete in Notes Inbox
Adds a recipient-scoped delete that's distinct from Archive: a recipient
can remove a note from their own inbox without touching the underlying
note or other recipients' rows.

Backend (artifacts/api-server/src/routes/notes.ts):
- DELETE /notes/received/:id — recipient-only; 404 if no row.
- POST /notes/received/bulk-delete — body {ids:number[]}, max 500,
  single SQL DELETE, returns {ok, notFound} for partial-success UI.
- Both registered before DELETE /notes/:id so Express matches /received
  first.

Frontend (artifacts/tx-os):
- New hooks useDeleteReceivedNote / useBulkDeleteReceivedNotes in
  src/lib/notes-api.ts; both invalidate notes + folders queries.
- Inbox bulk bar gets a Delete button (rose) next to Archive plus a
  confirm AlertDialog with all/none/partial toasts (src/pages/notes.tsx).
- ThreadDialog gets a per-row Delete next to Archive plus a single
  confirm dialog that closes the thread on success.
- AR + EN locale strings added for all new copy.

Tests:
- artifacts/api-server/tests/notes-inbox-delete.test.mjs — recipient
  delete, non-recipient/sender 404, bulk mix of valid+missing ids, and
  DB-level checks that the note + other recipients survive.
- artifacts/tx-os/tests/notes-inbox-bulk-delete.spec.mjs — Playwright
  flow seeds three received notes, bulk-deletes two from the inbox,
  then deletes the third via ThreadDialog per-row.
2026-05-12 11:14:33 +00:00

210 lines
7.2 KiB
JavaScript

// Task #512 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 thread per-row", 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: open C in ThreadDialog and use the Delete button.
await cardC.click();
await expect(page.getByTestId("note-thread-dialog")).toBeVisible();
await page.getByTestId("thread-delete").click();
await expect(page.getByTestId("thread-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("thread-delete-confirm-button").click();
await singleDeleted;
await expect(page.getByTestId("note-thread-dialog")).toBeHidden();
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);
});