2026-05-14 06:23:49 +00:00
|
|
|
// e2e: a recipient toggles a checklist item directly from the
|
2026-05-07 11:24:57 +00:00
|
|
|
// 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;
|
|
|
|
|
|
2026-05-07 11:42:38 +00:00
|
|
|
// 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();
|
|
|
|
|
|
2026-05-07 11:24:57 +00:00
|
|
|
// 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
|
2026-05-07 11:37:05 +00:00
|
|
|
// 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".
|
2026-05-07 11:24:57 +00:00
|
|
|
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);
|
2026-05-07 11:37:05 +00:00
|
|
|
|
|
|
|
|
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 });
|
2026-05-07 11:24:57 +00:00
|
|
|
} finally {
|
|
|
|
|
await senderCtx.close();
|
|
|
|
|
await recipientCtx.close();
|
|
|
|
|
}
|
|
|
|
|
});
|