bfbf59cb20
The send-note composer always showed an AlertDialog before posting, even for the common one-on-one case. The dialog now only appears when two or more recipients are picked. Implementation (artifacts/tx-os/src/pages/notes.tsx): - Extracted shared `performSend()` from `confirmSubmit` so both the direct-send and confirm-then-send paths reuse the same mutation, success toast + composer close, and error toast. - `requestSubmit()` now branches: 0 picked = no-op, 1 picked = call performSend() directly, 2+ picked = open the existing AlertDialog. - `confirmSubmit()` (the AlertDialog action) delegates to performSend. - No changes to API, recipient picker, AR/EN strings, or any other AlertDialog (bulk archive/delete left untouched). Tests: - Updated artifacts/tx-os/tests/notes-inbox.spec.mjs (sends to a single recipient): no longer expects the confirm dialog; asserts /api/notes/:id/send fires directly and the composer closes. - New artifacts/tx-os/tests/notes-send-confirm-threshold.spec.mjs: - "single-recipient send skips the confirm dialog" (passes) - "multi-recipient send still requires the confirm dialog" (passes) Validation: tx-os tsc clean; both threshold specs pass locally; architect review APPROVED.
222 lines
7.5 KiB
JavaScript
222 lines
7.5 KiB
JavaScript
// 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);
|
|
});
|