Files
TX/artifacts/tx-os/tests/notes-popup-checklist-collab.spec.mjs
T

187 lines
7.3 KiB
JavaScript
Raw Normal View History

// 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-<id>`.
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();
}
});