cdf5bf4d33
- New `service_orders` table (status pending|claimed|delivered|received|cancelled) - New `orders:receive` permission + `order_receiver` role; admins implicitly allowed - Added `requirePermission(name)` and `userHasPermission(userId, name)` middleware helpers - New routes: - POST /api/orders place order (authenticated) - GET /api/orders/my list current user's orders - GET /api/orders/incoming receivers see pending+active visible orders - PATCH /api/orders/:id/status claim (atomic), deliver, cancel - PATCH /api/orders/:id/confirm-receipt requester confirms a delivered order - Atomic claim via UPDATE ... WHERE status='pending' AND assigned_to IS NULL (returns 409 already_claimed on race) - Realtime: emits `notification_created` to receivers/requester, `order_incoming_changed` to all receivers, `order_updated` to requester - Service shape in order responses limited to id/nameAr/nameEn/imageUrl (no description fields), per spec - OpenAPI updated with new paths and schemas; codegen run - Seed updated idempotently (permission, role, role_permissions) - New tests in artifacts/api-server/tests/service-orders.test.mjs (full lifecycle, atomic claim race, unauth rejection) — all 21 api tests pass No deviations from the planned scope. Tasks #63 (client UI) and #64 (receiver page + admin role toggle) remain blocked-by #62 and are next.
193 lines
6.2 KiB
JavaScript
193 lines
6.2 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);
|
|
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 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 () => {
|
|
const requester = await createUser("req", "user");
|
|
const receiver = await createUser("recv", "order_receiver");
|
|
|
|
const reqCookie = await login(requester.username);
|
|
const recvCookie = await login(receiver.username);
|
|
|
|
// Place order
|
|
let res = await call(reqCookie, "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.ok(order.service);
|
|
assert.ok(order.service.nameAr);
|
|
// Should NOT include description fields
|
|
assert.equal(order.service.descriptionAr, undefined);
|
|
|
|
// Receiver lists incoming
|
|
res = await call(recvCookie, "GET", "/orders/incoming");
|
|
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"));
|
|
});
|
|
|
|
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");
|
|
|
|
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);
|
|
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]);
|
|
});
|
|
|
|
test("non-authenticated 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);
|
|
});
|