7ebf59d4b7
Backend (artifacts/api-server)
- Add admin-only role-toggle endpoints:
- POST /users/:id/roles { roleName } — idempotent, returns UserProfile
- DELETE /users/:id/roles/:roleName — idempotent, returns UserProfile
- Allow assigned receiver to cancel their own claimed order
(received/preparing) in PATCH /orders/:id/status. Owner & admin
rules unchanged.
- New backend test cases: receiver can cancel own assigned order;
another receiver gets 403.
API spec / codegen
- Add AddUserRoleBody schema and the two new role endpoints
under a new "roles" tag in lib/api-spec/openapi.yaml.
- Regenerated api-zod and api-client-react.
Frontend (artifacts/teaboy-os)
- New page src/pages/orders-incoming.tsx at /orders/incoming:
RBAC-gated (admin || order_receiver), shows "My active orders"
and "Awaiting receiver" sections, with claim, mark preparing,
mark completed and cancel buttons. Handles 409 already_claimed.
- Add Inbox button to home top bar, conditional on the same roles.
- Admin users table now has an "Order Receiver" Switch wired to
the new role-toggle hooks.
- Extend use-notifications-socket to invalidate the incoming-orders
query on order_incoming_changed and on notification_created with
type === "order".
- Bilingual locale keys (ar/en) for the new page and admin label.
Tests
- All 25 api-server tests pass (24 existing + 1 new receiver-cancel
case). All 3 teaboy-os e2e tests pass.
Follow-up filed: #67 (notify requester when receiver cancels).
270 lines
9.6 KiB
JavaScript
270 lines
9.6 KiB
JavaScript
import { test, before, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import pg from "pg";
|
|
|
|
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
|
const DATABASE_URL = process.env.DATABASE_URL;
|
|
if (!DATABASE_URL) throw new Error("DATABASE_URL must be set to run these tests");
|
|
|
|
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 = [];
|
|
|
|
async function createUser(prefix, roleName) {
|
|
const username = `${prefix}_${Date.now().toString(36)}_${Math.random()
|
|
.toString(36)
|
|
.slice(2, 8)}`;
|
|
const { rows } = await pool.query(
|
|
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
|
VALUES ($1, $2, $3, 'SO Test', 'en', true) RETURNING id`,
|
|
[username, `${username}@example.com`, TEST_PASSWORD_HASH],
|
|
);
|
|
const id = rows[0].id;
|
|
createdUserIds.push(id);
|
|
if (roleName) {
|
|
await pool.query(
|
|
`INSERT INTO user_roles (user_id, role_id) SELECT $1, id FROM roles WHERE name = $2`,
|
|
[id, roleName],
|
|
);
|
|
}
|
|
return { id, username };
|
|
}
|
|
|
|
async function login(username) {
|
|
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username, password: TEST_PASSWORD }),
|
|
});
|
|
assert.equal(res.status, 200);
|
|
const sc = res.headers.get("set-cookie");
|
|
return sc.split(",").map((c) => c.split(";")[0].trim()).find((c) =>
|
|
c.startsWith("connect.sid="),
|
|
);
|
|
}
|
|
|
|
async function call(cookie, method, path, body) {
|
|
return fetch(`${API_BASE}/api${path}`, {
|
|
method,
|
|
headers: { "Content-Type": "application/json", Cookie: cookie },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
}
|
|
|
|
let serviceId;
|
|
|
|
before(async () => {
|
|
const { rows } = await pool.query(
|
|
`SELECT id FROM services WHERE is_available = true ORDER BY id LIMIT 1`,
|
|
);
|
|
if (rows.length === 0) throw new Error("No available service to test with");
|
|
serviceId = rows[0].id;
|
|
});
|
|
|
|
after(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();
|
|
});
|
|
|
|
test("client can place + list own orders; service summary excludes descriptions", async () => {
|
|
const requester = await createUser("req", "user");
|
|
const cookie = await login(requester.username);
|
|
|
|
let res = await call(cookie, "POST", "/orders", {
|
|
serviceId,
|
|
notes: "extra hot",
|
|
});
|
|
assert.equal(res.status, 201);
|
|
const order = await res.json();
|
|
createdOrderIds.push(order.id);
|
|
assert.equal(order.status, "pending");
|
|
assert.equal(order.userId, requester.id);
|
|
assert.ok(order.service);
|
|
assert.ok(order.service.nameAr);
|
|
assert.ok(order.service.nameEn);
|
|
assert.equal(order.service.descriptionAr, undefined);
|
|
assert.equal(order.service.descriptionEn, undefined);
|
|
|
|
res = await call(cookie, "GET", "/orders/my");
|
|
assert.equal(res.status, 200);
|
|
const list = await res.json();
|
|
const found = list.find((o) => o.id === order.id);
|
|
assert.ok(found);
|
|
assert.equal(found.service.descriptionAr, undefined);
|
|
});
|
|
|
|
test("non-receiver gets 403 from /incoming and /confirm-receipt", async () => {
|
|
const owner = await createUser("own", "user");
|
|
const cookieOwner = await login(owner.username);
|
|
|
|
let res = await call(cookieOwner, "POST", "/orders", { serviceId });
|
|
const order = await res.json();
|
|
createdOrderIds.push(order.id);
|
|
|
|
// Non-receiver listing
|
|
res = await call(cookieOwner, "GET", "/orders/incoming");
|
|
assert.equal(res.status, 403);
|
|
|
|
// Non-receiver tries to claim
|
|
res = await call(cookieOwner, "PATCH", `/orders/${order.id}/confirm-receipt`);
|
|
assert.equal(res.status, 403);
|
|
});
|
|
|
|
test("user with no roles is 403 on /incoming (middleware has no auto-bypass)", async () => {
|
|
const noRole = await createUser("noro", null);
|
|
const cookie = await login(noRole.username);
|
|
const res = await call(cookie, "GET", "/orders/incoming");
|
|
assert.equal(res.status, 403);
|
|
});
|
|
|
|
test("two parallel confirm-receipt: one wins (200), other 409 already_claimed", async () => {
|
|
const owner = await createUser("own2", "user");
|
|
const r1 = await createUser("r1", "order_receiver");
|
|
const r2 = await createUser("r2", "order_receiver");
|
|
const co = await login(owner.username);
|
|
const c1 = await login(r1.username);
|
|
const c2 = await login(r2.username);
|
|
|
|
let res = await call(co, "POST", "/orders", { serviceId });
|
|
const order = await res.json();
|
|
createdOrderIds.push(order.id);
|
|
|
|
const [a, b] = await Promise.all([
|
|
call(c1, "PATCH", `/orders/${order.id}/confirm-receipt`),
|
|
call(c2, "PATCH", `/orders/${order.id}/confirm-receipt`),
|
|
]);
|
|
const codes = [a.status, b.status].sort();
|
|
assert.deepEqual(codes, [200, 409]);
|
|
|
|
const losing = a.status === 409 ? a : b;
|
|
const body = await losing.json();
|
|
assert.equal(body.error, "already_claimed");
|
|
});
|
|
|
|
test("status transitions matrix: receiver flows + owner/admin cancel rules", async () => {
|
|
const owner = await createUser("own3", "user");
|
|
const recv = await createUser("recv3", "order_receiver");
|
|
const stranger = await createUser("strg3", "user");
|
|
const adminUser = await createUser("admn3", "admin");
|
|
const co = await login(owner.username);
|
|
const cr = await login(recv.username);
|
|
const cs = await login(stranger.username);
|
|
const ca = await login(adminUser.username);
|
|
|
|
// Order 1: receiver flow pending → received → preparing → completed
|
|
let res = await call(co, "POST", "/orders", { serviceId });
|
|
let order = await res.json();
|
|
createdOrderIds.push(order.id);
|
|
|
|
// Owner CAN cancel while pending
|
|
res = await call(co, "POST", "/orders", { serviceId });
|
|
const cancelOrder = await res.json();
|
|
createdOrderIds.push(cancelOrder.id);
|
|
res = await call(co, "PATCH", `/orders/${cancelOrder.id}/status`, {
|
|
status: "cancelled",
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.equal((await res.json()).status, "cancelled");
|
|
|
|
// Stranger cannot move status to preparing
|
|
res = await call(cs, "PATCH", `/orders/${order.id}/status`, {
|
|
status: "preparing",
|
|
});
|
|
assert.equal(res.status, 403);
|
|
|
|
// Receiver claims first
|
|
res = await call(cr, "PATCH", `/orders/${order.id}/confirm-receipt`);
|
|
assert.equal(res.status, 200);
|
|
|
|
// Receiver moves to preparing (allowed from received)
|
|
res = await call(cr, "PATCH", `/orders/${order.id}/status`, {
|
|
status: "preparing",
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.equal((await res.json()).status, "preparing");
|
|
|
|
// Owner cannot cancel once preparing (only pending/received)
|
|
res = await call(co, "PATCH", `/orders/${order.id}/status`, {
|
|
status: "cancelled",
|
|
});
|
|
assert.equal(res.status, 403);
|
|
|
|
// Admin can cancel anytime
|
|
// (use a NEW preparing order)
|
|
res = await call(co, "POST", "/orders", { serviceId });
|
|
const o2 = await res.json();
|
|
createdOrderIds.push(o2.id);
|
|
res = await call(cr, "PATCH", `/orders/${o2.id}/confirm-receipt`);
|
|
assert.equal(res.status, 200);
|
|
res = await call(cr, "PATCH", `/orders/${o2.id}/status`, { status: "preparing" });
|
|
assert.equal(res.status, 200);
|
|
res = await call(ca, "PATCH", `/orders/${o2.id}/status`, { status: "cancelled" });
|
|
assert.equal(res.status, 200);
|
|
|
|
// Assigned receiver CAN cancel their own assigned order while preparing
|
|
res = await call(co, "POST", "/orders", { serviceId });
|
|
const o3 = await res.json();
|
|
createdOrderIds.push(o3.id);
|
|
res = await call(cr, "PATCH", `/orders/${o3.id}/confirm-receipt`);
|
|
assert.equal(res.status, 200);
|
|
res = await call(cr, "PATCH", `/orders/${o3.id}/status`, { status: "preparing" });
|
|
assert.equal(res.status, 200);
|
|
res = await call(cr, "PATCH", `/orders/${o3.id}/status`, { status: "cancelled" });
|
|
assert.equal(res.status, 200);
|
|
assert.equal((await res.json()).status, "cancelled");
|
|
|
|
// A different receiver (not the assignee) cannot cancel
|
|
res = await call(co, "POST", "/orders", { serviceId });
|
|
const o4 = await res.json();
|
|
createdOrderIds.push(o4.id);
|
|
res = await call(cr, "PATCH", `/orders/${o4.id}/confirm-receipt`);
|
|
assert.equal(res.status, 200);
|
|
const otherRecv = await createUser("recv4", "order_receiver");
|
|
const cor = await login(otherRecv.username);
|
|
res = await call(cor, "PATCH", `/orders/${o4.id}/status`, { status: "cancelled" });
|
|
assert.equal(res.status, 403);
|
|
|
|
// Receiver completes original order
|
|
res = await call(cr, "PATCH", `/orders/${order.id}/status`, {
|
|
status: "completed",
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.equal((await res.json()).status, "completed");
|
|
|
|
// Cannot transition completed → anything (non-admin)
|
|
res = await call(cr, "PATCH", `/orders/${order.id}/status`, {
|
|
status: "preparing",
|
|
});
|
|
assert.equal(res.status, 400);
|
|
|
|
// Admin CAN cancel a completed order
|
|
res = await call(ca, "PATCH", `/orders/${order.id}/status`, {
|
|
status: "cancelled",
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.equal((await res.json()).status, "cancelled");
|
|
});
|
|
|
|
test("unauthenticated cannot place order", async () => {
|
|
const res = await fetch(`${API_BASE}/api/orders`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ serviceId }),
|
|
});
|
|
assert.equal(res.status, 401);
|
|
});
|