Task #188: Add browser test for reorder rollback when the API rejects a row drag

Added a Playwright e2e scenario in
`artifacts/tx-os/tests/executive-meetings-schedule-features.spec.mjs`
that exercises the optimistic-update + onError rollback path in
`reorderRows()` (artifacts/tx-os/src/pages/executive-meetings.tsx ~L1484).

What the test does:
- Seeds two meetings (A, B) on a unique future date via direct DB inserts.
- Logs in as admin, navigates to /executive-meetings, jumps to that date,
  enables edit mode.
- Captures the original DOM order [A, B].
- Installs a `page.route("**/api/executive-meetings/reorder")` override
  that responds with 500 + `{ error: "Simulated reorder failure" }`. The
  `error` field (not `message`) is intentional so apiJson() throws that
  exact string into the destructive toast description.
- Drags row B above row A using the same mechanic as the success-path
  reorder test (warm-up move past dnd-kit's 6px PointerSensor activation
  distance, then a stepped move targeting just above the row's vertical
  center).
- Waits for the intercepted 500, then asserts the toast description
  ("Simulated reorder failure") and a localized title (en or ar) is
  visible — proving the user-facing error surfaced.
- Polls the DOM to confirm the row order rolled back to [A, B].
- Defense-in-depth: queries the DB to confirm daily_number / start_time /
  end_time for both rows are unchanged, so a future regression that
  somehow bypasses the route override can't hide.
- Cleans up: page.unroute, and the existing afterAll deletes the seeded
  rows.

Test seeding:
- Uses uniqueFutureDate(6); offsets 1..5 are already claimed by other
  tests in this file, and the file-level afterAll cleanup means two
  tests sharing date + daily_number would collide on the
  UNIQUE (meeting_date, daily_number) index.

Verified by running:
  npx playwright test --grep "rolls back to the original order"
and the prior success-path drag test together — both pass.

No production code changes; this is a pure test addition.

Replit-Task-Id: 02cfe898-1db8-40e9-ba6b-cd5df9f0a3f4
This commit is contained in:
riyadhafraa
2026-05-01 12:53:59 +00:00
parent b92903d0d3
commit f0888b6b82
2 changed files with 173 additions and 0 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

@@ -822,3 +822,176 @@ test("Schedule: dragging the attendees column header above the meeting column re
expect(await readHeaderOrder()).toEqual(expectedOrder);
});
test("Schedule: when the reorder API rejects a row drag, the UI rolls back to the original order and surfaces an error toast", async ({
page,
}) => {
await setLangEn(page);
// Seed two meetings on a unique future date so we own the order
// entirely. The optimistic UI flow we exercise here:
// 1. Drag row B above row A → setOptimisticOrder([B, A])
// 2. POST /api/executive-meetings/reorder
// 3. Server responds 500 → setOptimisticOrder(null) + error toast
// 4. DOM order returns to [A, B]
// We assert both the rollback and the user-facing error toast.
// Use offset 6 — offsets 1..5 are already claimed by other tests in
// this file. The afterAll cleanup is file-level, so two tests sharing
// a date + daily_number would hit the UNIQUE (meeting_date,
// daily_number) index. Picking a free offset keeps the seed
// collision-proof without needing duplicate-retry logic.
const date = uniqueFutureDate(6);
const uniq = `${Date.now().toString(36)}_${Math.random()
.toString(36)
.slice(2, 6)}`;
const idA = await insertMeeting({
meetingDate: date,
dailyNumber: 1,
titleEn: `Rollback A ${uniq}`,
titleAr: `تراجع أ ${uniq}`,
startTime: "09:00:00",
endTime: "10:00:00",
});
const idB = await insertMeeting({
meetingDate: date,
dailyNumber: 2,
titleEn: `Rollback B ${uniq}`,
titleAr: `تراجع ب ${uniq}`,
startTime: "11:00:00",
endTime: "12:00:00",
});
await loginViaUi(page, "admin", "admin123");
await page.goto("/executive-meetings");
await resetSchedulePrefs(page);
await page.reload();
await page.locator('input[type="date"]').first().fill(date);
await expect(page.getByTestId(`em-row-${idA}`)).toBeVisible({
timeout: 10_000,
});
await expect(page.getByTestId(`em-row-${idB}`)).toBeVisible();
// Capture the DOM order BEFORE we install the route override. This
// is the order we expect to see again after the rollback.
const readDomOrder = async () => {
const rowsInOrder = await page
.locator('tr[data-testid^="em-row-"]')
.evaluateAll((els) => els.map((el) => el.getAttribute("data-testid")));
return rowsInOrder
.map((tid) => Number(tid?.replace("em-row-", "")))
.filter((n) => n === idA || n === idB);
};
expect(await readDomOrder()).toEqual([idA, idB]);
// Edit mode must be on for the grip handle to render.
const toggle = page.getByTestId("em-edit-mode-toggle");
await toggle.click();
await expect(toggle).toHaveAttribute("aria-pressed", "true");
// Intercept the reorder PATCH/POST and respond with a 500 so the
// mutation in reorderRows() throws and triggers the onError rollback
// branch (setOptimisticOrder(null) + destructive toast). We track the
// hits so the assertion below can prove the request really fired
// (otherwise a missed drag would silently pass the rollback check).
let reorderHits = 0;
await page.route("**/api/executive-meetings/reorder", async (route) => {
reorderHits += 1;
await route.fulfill({
status: 500,
contentType: "application/json",
// apiJson() reads `data.error` for the thrown message, so use
// `error` (not `message`) to make the description deterministic.
body: JSON.stringify({ error: "Simulated reorder failure" }),
});
});
const gripA = page.getByTestId(`em-row-grip-${idA}`);
const gripB = page.getByTestId(`em-row-grip-${idB}`);
await expect(gripA).toBeVisible();
await expect(gripB).toBeVisible();
const fromBox = await gripB.boundingBox();
const toBox = await gripA.boundingBox();
expect(fromBox).not.toBeNull();
expect(toBox).not.toBeNull();
// Same drag mechanics as the success-path reorder test above:
// dnd-kit's PointerSensor needs a small warm-up move past its 6px
// activation distance, then a stepped move to the target. Aim
// slightly above the target row's vertical center so dnd-kit
// inserts B before A.
const startX = fromBox.x + fromBox.width / 2;
const startY = fromBox.y + fromBox.height / 2;
const endX = toBox.x + toBox.width / 2;
const endY = toBox.y + 4;
const failedReorderPromise = page.waitForResponse(
(resp) => {
const u = new URL(resp.url());
return (
u.pathname === "/api/executive-meetings/reorder" &&
resp.status() === 500
);
},
{ timeout: 15_000 },
);
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX, startY + 12, { steps: 5 });
await page.mouse.move(endX, endY, { steps: 15 });
await page.mouse.up();
await failedReorderPromise;
expect(reorderHits).toBeGreaterThanOrEqual(1);
// The page renders a destructive toast with title = t("common.error")
// ("An error occurred" / "حدث خطأ" depending on the signed-in user's
// preferred language) and description = the thrown error message.
// We assert against the description because it comes straight from
// our route override's response body and is therefore independent of
// the UI language. Wait for it before asserting the rollback, since
// the toast and the optimistic-order reset both happen synchronously
// in the onError branch — once the toast is visible we know the
// rollback has already run.
const errorToastDesc = page.getByText("Simulated reorder failure", {
exact: true,
});
await expect(errorToastDesc).toBeVisible({ timeout: 5_000 });
// Also verify the title is one of the localized "error" strings so
// we catch a regression that drops the title entirely.
const errorToastTitle = page.getByText(/^(An error occurred|حدث خطأ)$/);
await expect(errorToastTitle.first()).toBeVisible();
// The DOM order must have returned to the original [A, B]. Use
// expect.poll so we tolerate a render frame between the
// setOptimisticOrder(null) call and React flushing the new order.
await expect
.poll(readDomOrder, { timeout: 5_000 })
.toEqual([idA, idB]);
// Defense in depth: the database must be untouched — neither row's
// daily_number, start_time, nor end_time should have moved, since
// the server "rejected" the request and the optimistic UI was rolled
// back. (The route override prevented any real PATCH from running,
// but we assert against the DB anyway so a future regression that
// somehow fires the request through a different code path can't
// sneak past.)
const { rows } = await pool.query(
`SELECT id, daily_number, start_time, end_time
FROM executive_meetings WHERE id = ANY($1::int[]) ORDER BY id`,
[[idA, idB]],
);
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
expect(byId[idA].daily_number).toBe(1);
expect(String(byId[idA].start_time)).toBe("09:00:00");
expect(String(byId[idA].end_time)).toBe("10:00:00");
expect(byId[idB].daily_number).toBe(2);
expect(String(byId[idB].start_time)).toBe("11:00:00");
expect(String(byId[idB].end_time)).toBe("12:00:00");
await page.unroute("**/api/executive-meetings/reorder");
});