e085801f07
Backend (artifacts/api-server):
- Added hasReceivePermission() and refactored DELETE /orders/:id into a
shared authorizeAndDeleteOrder() helper so single and bulk paths use
the same authorization rules.
- Added POST /orders/bulk-delete returning { deletedIds, failedIds } with
per-id authorization, dedup, and best-effort partial success.
- Tightened receiver authorization: receivers may only delete orders
currently in the incoming queue (pending/received/preparing). Terminal
orders remain the owner's cleanup responsibility. Code, comments and
OpenAPI description now agree.
API spec (lib/api-spec/openapi.yaml):
- New /orders/bulk-delete operation with BulkDeleteServiceOrdersBody and
BulkDeleteServiceOrdersResponse schemas. Updated DELETE /orders/{id}
description. Regenerated react-query hooks via codegen — both
useDeleteServiceOrder and useBulkDeleteServiceOrders are used by the UI.
UI (artifacts/tx-os/src/pages/orders-incoming.tsx):
- Per-card checkbox visible at all times (RTL-safe leading edge).
- Per-card trash icon for single delete.
- Section-scoped Select-all (مين / unclaimed) with tri-state
all/some(indeterminate)/none and shadcn Checkbox.
- Sticky bottom bulk action bar shows selected count + Clear selection +
destructive Delete.
- Single AlertDialog used by both per-card and bulk paths, with
pluralized confirmation copy. Single delete calls DELETE /orders/:id;
multi delete calls POST /orders/bulk-delete; partial-failure surfaces
via toast.
i18n: New incomingOrders keys in ar.json and en.json (select, selectAll,
clearSelection, selectedCount, delete, deleteConfirmTitle/Body, deleted,
deleteFailed, deletePartial) with pluralization in both languages.
Tests:
- artifacts/api-server/tests/service-orders.test.mjs: receivers can
delete pending/received/preparing; receivers cannot delete terminal
(completed/cancelled); bulk-delete with mixed authorized / unknown /
dedup / unauthenticated cases.
- artifacts/tx-os/tests/order-incoming-delete.spec.mjs (new Playwright):
bulk-delete two unclaimed orders shrinks the list by 2; per-card
trash on a claimed order deletes one. Both pass.
Pre-existing executive-meetings PDF/font test failures are unrelated.
Follow-ups proposed: #398 (Undo for incoming-order deletes), #399
(safer notification handling in bulk-delete).
291 lines
9.0 KiB
JavaScript
291 lines
9.0 KiB
JavaScript
// Playwright e2e for the per-card and bulk delete affordances on
|
|
// /orders/incoming added in task #397.
|
|
//
|
|
// Covers:
|
|
// - Bulk delete: receiver selects 2 unclaimed orders via per-card
|
|
// checkboxes, opens the confirm dialog from the sticky bulk action
|
|
// bar, confirms, and the list shrinks by 2 (the rest stay).
|
|
// - Per-card delete: receiver claims a third order, then deletes it
|
|
// from the "my claimed" section using the per-card trash icon and
|
|
// the same confirm dialog. The list shrinks by 1.
|
|
//
|
|
// Mirrors the existing order-receiver-flow.spec.mjs harness (same
|
|
// password hash, same DB cleanup pattern).
|
|
//
|
|
// Run with:
|
|
// DATABASE_URL=... pnpm --filter @workspace/tx-os test:e2e
|
|
|
|
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 the incoming-orders delete 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 createdOrderIds = [];
|
|
|
|
function uniqueSuffix() {
|
|
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
async function createUser(prefix, roleName, displayNameEn) {
|
|
const username = `${prefix}_${uniqueSuffix()}`;
|
|
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,
|
|
displayNameEn ?? username,
|
|
],
|
|
);
|
|
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 = $2`,
|
|
[id, roleName],
|
|
);
|
|
return { id, username, displayName: displayNameEn ?? username };
|
|
}
|
|
|
|
async function getAvailableService() {
|
|
const { rows } = await pool.query(
|
|
`SELECT id, name_en AS "nameEn", name_ar AS "nameAr"
|
|
FROM services WHERE is_available = true
|
|
ORDER BY id ASC LIMIT 1`,
|
|
);
|
|
if (rows.length === 0) {
|
|
throw new Error("No available service found in DB");
|
|
}
|
|
return rows[0];
|
|
}
|
|
|
|
async function placeOrderDirect(userId, serviceId, notes) {
|
|
const { rows } = await pool.query(
|
|
`INSERT INTO service_orders (user_id, service_id, status, notes)
|
|
VALUES ($1, $2, 'pending', $3) RETURNING id`,
|
|
[userId, serviceId, notes],
|
|
);
|
|
const id = rows[0].id;
|
|
createdOrderIds.push(id);
|
|
return id;
|
|
}
|
|
|
|
test.afterAll(async () => {
|
|
if (createdOrderIds.length > 0) {
|
|
await pool.query(`DELETE FROM service_orders WHERE id = ANY($1::int[])`, [
|
|
createdOrderIds,
|
|
]);
|
|
}
|
|
if (createdUserIds.length > 0) {
|
|
await pool.query(
|
|
`DELETE FROM service_orders
|
|
WHERE user_id = ANY($1::int[]) OR assigned_to = 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 setLanguage(page, lang) {
|
|
await page.addInitScript((l) => {
|
|
try {
|
|
window.localStorage.setItem("tx-lang", l);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, lang);
|
|
}
|
|
|
|
async function loginViaUi(page, username, password) {
|
|
await page.goto("/login");
|
|
await page.locator("#username").fill(username);
|
|
await page.locator("#password").fill(password);
|
|
await Promise.all([
|
|
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
|
|
timeout: 15_000,
|
|
}),
|
|
page.locator('form button[type="submit"]').click(),
|
|
]);
|
|
}
|
|
|
|
async function countCards(page) {
|
|
return page.locator('[data-testid^="incoming-order-card-"]').count();
|
|
}
|
|
|
|
test.describe("Incoming orders — delete affordances", () => {
|
|
test("bulk delete two unclaimed orders shrinks the list by 2", async ({
|
|
browser,
|
|
}) => {
|
|
const requester = await createUser(
|
|
"ord_del_req",
|
|
"user",
|
|
`DelReq ${uniqueSuffix()}`,
|
|
);
|
|
const receiver = await createUser(
|
|
"ord_del_recv",
|
|
"order_receiver",
|
|
`DelRecv ${uniqueSuffix()}`,
|
|
);
|
|
const service = await getAvailableService();
|
|
|
|
// Three pending unclaimed orders so we can assert the post-delete
|
|
// count is exactly one.
|
|
const o1 = await placeOrderDirect(requester.id, service.id, "delete-me-1");
|
|
const o2 = await placeOrderDirect(requester.id, service.id, "delete-me-2");
|
|
const o3 = await placeOrderDirect(requester.id, service.id, "keep-me");
|
|
|
|
const ctx = await browser.newContext();
|
|
try {
|
|
const page = await ctx.newPage();
|
|
await setLanguage(page, "en");
|
|
await loginViaUi(page, receiver.username, TEST_PASSWORD);
|
|
|
|
await page.goto("/orders/incoming");
|
|
await expect(
|
|
page.locator(`[data-testid="incoming-order-card-${o1}"]`),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.locator(`[data-testid="incoming-order-card-${o2}"]`),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.locator(`[data-testid="incoming-order-card-${o3}"]`),
|
|
).toBeVisible();
|
|
|
|
const initial = await countCards(page);
|
|
expect(initial).toBeGreaterThanOrEqual(3);
|
|
|
|
// Select two via per-card checkboxes (always visible).
|
|
await page.locator(`[data-testid="order-select-${o1}"]`).click();
|
|
await page.locator(`[data-testid="order-select-${o2}"]`).click();
|
|
|
|
const bar = page.locator('[data-testid="bulk-action-bar"]');
|
|
await expect(bar).toBeVisible();
|
|
await expect(bar).toContainText("2 orders selected");
|
|
|
|
const bulkPromise = page.waitForResponse(
|
|
(resp) => {
|
|
const path = new URL(resp.url()).pathname;
|
|
return (
|
|
path === "/api/orders/bulk-delete" &&
|
|
resp.request().method() === "POST"
|
|
);
|
|
},
|
|
{ timeout: 15_000 },
|
|
);
|
|
await page.locator('[data-testid="bulk-delete"]').click();
|
|
await page.locator('[data-testid="confirm-delete"]').click();
|
|
const bulkResp = await bulkPromise;
|
|
expect(bulkResp.status()).toBe(200);
|
|
const body = await bulkResp.json();
|
|
expect(body.deletedIds.sort()).toEqual([o1, o2].sort());
|
|
expect(body.failedIds).toEqual([]);
|
|
|
|
await expect(
|
|
page.locator(`[data-testid="incoming-order-card-${o1}"]`),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
page.locator(`[data-testid="incoming-order-card-${o2}"]`),
|
|
).toHaveCount(0);
|
|
await expect(
|
|
page.locator(`[data-testid="incoming-order-card-${o3}"]`),
|
|
).toBeVisible();
|
|
|
|
const after = await countCards(page);
|
|
expect(after).toBe(initial - 2);
|
|
} finally {
|
|
await ctx.close();
|
|
}
|
|
});
|
|
|
|
test("per-card trash deletes a single claimed order", async ({ browser }) => {
|
|
const requester = await createUser(
|
|
"ord_del_req2",
|
|
"user",
|
|
`DelReq2 ${uniqueSuffix()}`,
|
|
);
|
|
const receiver = await createUser(
|
|
"ord_del_recv2",
|
|
"order_receiver",
|
|
`DelRecv2 ${uniqueSuffix()}`,
|
|
);
|
|
const service = await getAvailableService();
|
|
|
|
const o1 = await placeOrderDirect(requester.id, service.id, "claim-me");
|
|
|
|
const ctx = await browser.newContext();
|
|
try {
|
|
const page = await ctx.newPage();
|
|
await setLanguage(page, "en");
|
|
await loginViaUi(page, receiver.username, TEST_PASSWORD);
|
|
|
|
await page.goto("/orders/incoming");
|
|
const card = page.locator(
|
|
`[data-testid="incoming-order-card-${o1}"]`,
|
|
);
|
|
await expect(card).toBeVisible();
|
|
|
|
// Claim the order so it moves to "My active orders".
|
|
const claimPromise = page.waitForResponse(
|
|
(resp) => {
|
|
const path = new URL(resp.url()).pathname;
|
|
return (
|
|
path === `/api/orders/${o1}/confirm-receipt` &&
|
|
resp.request().method() === "PATCH"
|
|
);
|
|
},
|
|
{ timeout: 15_000 },
|
|
);
|
|
await card.getByRole("button", { name: "Confirm Receipt", exact: true }).click();
|
|
const claimResp = await claimPromise;
|
|
expect(claimResp.status()).toBe(200);
|
|
|
|
// Now delete via per-card trash.
|
|
const before = await countCards(page);
|
|
const deletePromise = page.waitForResponse(
|
|
(resp) => {
|
|
const path = new URL(resp.url()).pathname;
|
|
return (
|
|
path === `/api/orders/${o1}` &&
|
|
resp.request().method() === "DELETE"
|
|
);
|
|
},
|
|
{ timeout: 15_000 },
|
|
);
|
|
await page.locator(`[data-testid="order-delete-${o1}"]`).click();
|
|
await page.locator('[data-testid="confirm-delete"]').click();
|
|
const delResp = await deletePromise;
|
|
expect(delResp.status()).toBe(204);
|
|
|
|
await expect(card).toHaveCount(0);
|
|
const after = await countCards(page);
|
|
expect(after).toBe(before - 1);
|
|
} finally {
|
|
await ctx.close();
|
|
}
|
|
});
|
|
});
|