Task #62: Service Orders backend foundation (corrected)
After prior code review rejection, refactored to match spec exactly: - Permission renamed orders:receive → orders.receive (dot form), seeded with order_receiver role - service_orders table: user_id, service_id, notes, status (pending/received/preparing/completed/cancelled with CHECK), assigned_to, created_at, updated_at - /confirm-receipt is now the receiver atomic claim (UPDATE WHERE pending+unassigned), 409 already_claimed on miss - /status accepts preparing|completed|cancelled with permission matrix: * preparing/completed: assigned receiver or admin * cancelled: owner (pending|received) OR admin (any non-cancelled status) - requirePermission middleware no longer auto-bypasses admin; admin gets the permission via explicit seed grant - Notifications use type='order', relatedType='order' - Realtime emits notification_created (per receiver) + order_incoming_changed (broadcast) + order_updated (owner) - OpenAPI Order schema rewritten (no quantity, no per-status timestamps), endpoint summaries updated, codegen run - Tests cover: client place+list, non-receiver 403, no-role 403, parallel claim race (200/409), status matrix, owner-cancel rules, admin-cancel-completed, descriptions excluded from service summary All 24 api-server tests pass. Ready for code review re-check.
This commit is contained in:
@@ -4,16 +4,13 @@ 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");
|
||||
}
|
||||
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 = [];
|
||||
|
||||
@@ -28,11 +25,12 @@ async function createUser(prefix, roleName) {
|
||||
);
|
||||
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],
|
||||
);
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -52,10 +50,7 @@ async function login(username) {
|
||||
async function call(cookie, method, path, body) {
|
||||
return fetch(`${API_BASE}/api${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: cookie,
|
||||
},
|
||||
headers: { "Content-Type": "application/json", Cookie: cookie },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
@@ -77,112 +72,171 @@ after(async () => {
|
||||
]);
|
||||
}
|
||||
if (createdUserIds.length > 0) {
|
||||
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 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("full order lifecycle: place → claim → deliver → confirm", async () => {
|
||||
test("client can place + list own orders; service summary excludes descriptions", async () => {
|
||||
const requester = await createUser("req", "user");
|
||||
const receiver = await createUser("recv", "order_receiver");
|
||||
const cookie = await login(requester.username);
|
||||
|
||||
const reqCookie = await login(requester.username);
|
||||
const recvCookie = await login(receiver.username);
|
||||
|
||||
// Place order
|
||||
let res = await call(reqCookie, "POST", "/orders", {
|
||||
let res = await call(cookie, "POST", "/orders", {
|
||||
serviceId,
|
||||
quantity: 2,
|
||||
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.quantity, 2);
|
||||
assert.equal(order.requestedBy, requester.id);
|
||||
assert.equal(order.userId, requester.id);
|
||||
assert.ok(order.service);
|
||||
assert.ok(order.service.nameAr);
|
||||
// Should NOT include description fields
|
||||
assert.ok(order.service.nameEn);
|
||||
assert.equal(order.service.descriptionAr, undefined);
|
||||
assert.equal(order.service.descriptionEn, undefined);
|
||||
|
||||
// Receiver lists incoming
|
||||
res = await call(recvCookie, "GET", "/orders/incoming");
|
||||
res = await call(cookie, "GET", "/orders/my");
|
||||
assert.equal(res.status, 200);
|
||||
const incoming = await res.json();
|
||||
assert.ok(incoming.find((o) => o.id === order.id));
|
||||
|
||||
// Non-receiver gets 403 on incoming
|
||||
res = await call(reqCookie, "GET", "/orders/incoming");
|
||||
assert.equal(res.status, 403);
|
||||
|
||||
// Receiver claims
|
||||
res = await call(recvCookie, "PATCH", `/orders/${order.id}/status`, {
|
||||
status: "claimed",
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const claimed = await res.json();
|
||||
assert.equal(claimed.status, "claimed");
|
||||
assert.equal(claimed.assignedTo, receiver.id);
|
||||
|
||||
// Requester cannot confirm receipt while only claimed
|
||||
res = await call(reqCookie, "PATCH", `/orders/${order.id}/confirm-receipt`);
|
||||
assert.equal(res.status, 409);
|
||||
|
||||
// Receiver delivers
|
||||
res = await call(recvCookie, "PATCH", `/orders/${order.id}/status`, {
|
||||
status: "delivered",
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal((await res.json()).status, "delivered");
|
||||
|
||||
// Other user cannot confirm receipt
|
||||
const stranger = await createUser("str", "user");
|
||||
const strCookie = await login(stranger.username);
|
||||
res = await call(strCookie, "PATCH", `/orders/${order.id}/confirm-receipt`);
|
||||
assert.equal(res.status, 403);
|
||||
|
||||
// Requester confirms receipt
|
||||
res = await call(reqCookie, "PATCH", `/orders/${order.id}/confirm-receipt`);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal((await res.json()).status, "received");
|
||||
|
||||
// Verify the order shows up in /orders/my
|
||||
res = await call(reqCookie, "GET", "/orders/my");
|
||||
assert.equal(res.status, 200);
|
||||
const my = await res.json();
|
||||
assert.ok(my.find((o) => o.id === order.id && o.status === "received"));
|
||||
const list = await res.json();
|
||||
const found = list.find((o) => o.id === order.id);
|
||||
assert.ok(found);
|
||||
assert.equal(found.service.descriptionAr, undefined);
|
||||
});
|
||||
|
||||
test("atomic claim: only one receiver wins", async () => {
|
||||
const requester = await createUser("req2", "user");
|
||||
const recv1 = await createUser("recv1", "order_receiver");
|
||||
const recv2 = await createUser("recv2", "order_receiver");
|
||||
test("non-receiver gets 403 from /incoming and /confirm-receipt", async () => {
|
||||
const owner = await createUser("own", "user");
|
||||
const cookieOwner = await login(owner.username);
|
||||
|
||||
const reqCookie = await login(requester.username);
|
||||
const c1 = await login(recv1.username);
|
||||
const c2 = await login(recv2.username);
|
||||
|
||||
let res = await call(reqCookie, "POST", "/orders", { serviceId });
|
||||
assert.equal(res.status, 201);
|
||||
let res = await call(cookieOwner, "POST", "/orders", { serviceId });
|
||||
const order = await res.json();
|
||||
createdOrderIds.push(order.id);
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
call(c1, "PATCH", `/orders/${order.id}/status`, { status: "claimed" }),
|
||||
call(c2, "PATCH", `/orders/${order.id}/status`, { status: "claimed" }),
|
||||
]);
|
||||
const statuses = [r1.status, r2.status].sort();
|
||||
assert.deepEqual(statuses, [200, 409]);
|
||||
// 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("non-authenticated cannot place order", async () => {
|
||||
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);
|
||||
|
||||
// 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" },
|
||||
|
||||
Reference in New Issue
Block a user