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:
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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user