Files
s.esmaeiliandClaude Opus 5 24b7a4c395 fix(api): treat blank env vars as unset; verify the whole flow in Docker Compose
Found by running the delivery flow against the actual built container rather
than a local dev process.

DEPLOY-02 (Critical) — ADMIN LOGIN RETURNED 500 FOR EVERY USER IN DOCKER.
docker-compose.yml passes each optional variable as ${X:-}, so an unconfigured
one arrives as "" rather than undefined. The code read them with
`process.env.X ?? 'default'`, which only falls back on undefined, so the empty
string reached jsonwebtoken and it threw

  "expiresIn" should be a number of seconds or string representing a timespan

on every sign. Invisible to every existing suite, all of which run against a
local process where the variable is genuinely absent.

Added src/common/env.ts (envOr / envOrNull), which treat blank as unset, and
applied it to every auth, port, upload and SMS read. Three were not cosmetic:
a blank UPLOAD_DIR would have served static files from the FILESYSTEM ROOT, a
blank PORT would have bound a random ephemeral port (Number('') === 0), and a
blank JWT_SECRET would have become the verifying key. Recorded as D25, with a
standing rule to use these readers instead of `??`.

Pinned by src/common/env.spec.ts (9 tests), which reproduces the original throw
against the real jsonwebtoken and asserts the reader can no longer produce it.

DEPLOY-03 (process, documented) — the API image is `COPY release/ ./`, so it
ships a bundle pre-built on the developer machine. `docker compose build` will
happily produce an image from a STALE release/app.js and report success; at the
start of this run that bundle was four days old and the rebuilt container was
missing every route added since. DELIVERY.md's deploy section now leads with
`pnpm build:release`.

New: scripts/test-compose-e2e.cjs — the whole delivery flow against the built
container, over HTTP only, as a black box: a sales expert and an ordinary
customer both sign in with the master code, both place an order, both orders
appear in the admin grid and open there, and the three token boundaries still
hold in the deployed image. 32/32.

Verified: compose e2e 32/32 · jest 54/54 · build 5/5 ·
against the CONTAINER: complete-profile 38/38, report 40/40, expert login 36/36 ·
locally: apps smoke 43/43, admin smoke 43/43, B2B scan 34 ok/0 broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 02:27:30 +03:30

227 lines
11 KiB
JavaScript

/**
* The whole delivery flow, against the DOCKER COMPOSE stack.
*
* What the owner asked to see proven inside compose:
* 1. a sales expert signs into the app with the master code
* 2. an ordinary customer signs into the app with the master code
* 3. BOTH place an order
* 4. an ADMIN sees both of those orders in the admin panel
*
* Unlike the other scripts this one talks ONLY over HTTP — it never opens a
* Prisma client — because the point is to exercise the deployed container as a
* black box, including its own boot-time migration run. Setup and teardown go
* through the admin API, the same way an operator would.
*
* Usage: node scripts/test-compose-e2e.cjs [apiBaseUrl] [masterCode]
* e.g. node scripts/test-compose-e2e.cjs http://localhost:3100/api 1379
*/
const API = (process.argv[2] || 'http://localhost:3100/api').replace(/\/$/, '');
const MASTER = process.argv[3] || '1379';
const ADMIN_USER = process.argv[4] || '09128081392';
const ADMIN_PASS = process.argv[5] || '09128081392';
let pass = 0, fail = 0;
const failures = [];
const chk = (c, m) => {
if (c) { pass++; console.log(' PASS ' + m); }
else { fail++; failures.push(m); console.log(' FAIL ' + m); }
};
const step = (t) => console.log('\n' + t);
const call = async (method, p, body, token) => {
const res = await fetch(`${API}/${p}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await res.text();
let payload = null;
try { payload = JSON.parse(text); } catch { /* empty */ }
return { status: res.status, ok: res.ok, body: payload, raw: text };
};
const signIn = (mobile) =>
call('POST', 'customer/auth/verify-otp', { username: mobile, password: MASTER });
const uniq = String(Date.now()).slice(-7);
const made = { customers: [], experts: [], orders: [] };
(async () => {
// ── ADMIN ───────────────────────────────────────────────────────────────
step('0 — ADMIN signs into the panel');
const adminLogin = await call('POST', 'admin/auth/login', {
phone: ADMIN_USER,
password: ADMIN_PASS,
});
chk(adminLogin.ok, `POST admin/auth/login -> ${adminLogin.status}`);
const adminToken = adminLogin.body?.api_token;
if (!adminToken) {
console.log('\n Could not sign in as admin; pass the credentials as argv[4]/argv[5].');
console.log(' Response: ' + adminLogin.raw.slice(0, 300));
process.exit(1);
}
chk(Boolean(adminToken), 'an admin token came back');
// ── SETUP through the ADMIN API, as an operator would ───────────────────
step('SETUP — create a sales expert and a customer through the admin panel');
const expertMobile = `0913${uniq}`;
const expert = await call('POST', 'admin/users', {
fname: 'کارشناس', lname: 'کامپوز',
mobile: expertMobile, password: 'Test12345',
role: 'SALES_EXPERT', percent: 3.5, gram_level: 150,
}, adminToken);
chk(expert.ok, `create the sales expert -> ${expert.status}` + (expert.ok ? '' : ' ' + expert.raw.slice(0, 160)));
if (expert.body?.id) made.experts.push(expert.body.id);
const custMobile = `0914${uniq}`;
const cust = await call('POST', 'admin/customers-omde/create', {
fname: 'مشتری', lname: 'کامپوز', name: 'مشتری کامپوز',
mobile: custMobile, percent: 3, type: 2,
store_name: 'گالری کامپوز', gram_level: 200,
}, adminToken);
chk(cust.ok, `create the customer -> ${cust.status}` + (cust.ok ? '' : ' ' + cust.raw.slice(0, 160)));
const custId = cust.body?.id;
if (custId) made.customers.push(custId);
// A product to order.
step('SETUP — find an orderable product variant');
const products = await call('GET', 'catalog/products');
chk(products.ok, `GET catalog/products -> ${products.status}`);
const variant = (products.body ?? [])
.flatMap((p) => p.variants ?? [])
.find((v) => v && v.id);
chk(Boolean(variant), `found a variant to order (${variant?.id ?? 'none'})`);
if (!variant) {
console.log(' No product variants in this database — cannot place orders.');
process.exit(1);
}
// ── 1. THE EXPERT SIGNS IN ──────────────────────────────────────────────
step('1 — THE SALES EXPERT signs into the app with the master code');
const expReq = await call('POST', 'customer/auth/request-otp', { username: expertMobile });
chk(expReq.ok, `request-otp -> ${expReq.status}`);
const expLogin = await signIn(expertMobile);
chk(expLogin.ok, `verify-otp with the master code -> ${expLogin.status}`);
const expToken = expLogin.body?.token;
chk(Boolean(expToken), 'an app token came back');
const whoami = await call('GET', 'expert/whoami', undefined, expToken);
chk(whoami.body?.isSalesExpert === true,
`the app is told this session is an expert (${whoami.body?.isSalesExpert})`);
// ── 2. THE CUSTOMER SIGNS IN ────────────────────────────────────────────
step('2 — AN ORDINARY CUSTOMER signs into the app with the master code');
await call('POST', 'customer/auth/request-otp', { username: custMobile });
const custLogin = await signIn(custMobile);
chk(custLogin.ok, `verify-otp with the master code -> ${custLogin.status}`);
const custToken = custLogin.body?.token;
chk(Boolean(custToken), 'an app token came back');
const custWho = await call('GET', 'expert/whoami', undefined, custToken);
chk(custWho.body?.isSalesExpert === false,
`and is NOT flagged as an expert (${custWho.body?.isSalesExpert})`);
// ── 3. BOTH PLACE AN ORDER ──────────────────────────────────────────────
step('3 — BOTH place an order from the app');
// The exact payload both apps send from the cart (CartView.vue:200-211):
// wagePercent comes from the variant the cart pinned. No customerId — the
// server takes the subject from the token (D21).
const placeOrder = async (token, who) => {
const r = await call('POST', 'orders', {
items: [
{
productVariantId: variant.id,
quantity: 1,
wagePercent: parseFloat(variant.defaultWagePercent ?? '0'),
},
],
}, token);
chk(r.ok, `${who} places an order -> ${r.status}${r.ok ? ' (' + r.body?.orderNumber + ')' : ' ' + r.raw.slice(0, 160)}`);
if (r.body?.id) made.orders.push({ id: r.body.id, number: r.body.orderNumber, who });
return r.body;
};
const expOrder = await placeOrder(expToken, 'the EXPERT');
const custOrder = await placeOrder(custToken, 'the CUSTOMER');
chk(Boolean(expOrder?.orderNumber && custOrder?.orderNumber),
'both orders came back with an order number');
chk(expOrder?.orderNumber !== custOrder?.orderNumber,
'and they are two distinct orders');
// Each sees their own order in their own history, and only their own.
const expHistory = await call('GET', 'customer/me/orders', undefined, expToken);
chk(expHistory.body?.some((o) => o.id === expOrder?.id),
"the expert's own order is in the expert's history");
chk(!expHistory.body?.some((o) => o.id === custOrder?.id),
"and the customer's order is NOT");
const custHistory = await call('GET', 'customer/me/orders', undefined, custToken);
chk(custHistory.body?.some((o) => o.id === custOrder?.id),
"the customer's own order is in the customer's history");
chk(!custHistory.body?.some((o) => o.id === expOrder?.id),
"and the expert's order is NOT");
// ── 4. THE ADMIN SEES BOTH ──────────────────────────────────────────────
step('4 — THE ADMIN sees both orders in the panel');
const list = await call('POST', 'admin/orders/list', { page: 1, limit: 100 }, adminToken);
chk(list.ok, `POST admin/orders/list -> ${list.status}`);
const rows = list.body?.rows ?? list.body?.data ?? list.body?.result ?? [];
chk(Array.isArray(rows) && rows.length > 0, `the grid returned ${rows.length} row(s)`);
const findRow = (id, number) =>
rows.find((r) => r.id === id || r.orderNumber === number);
chk(Boolean(findRow(expOrder?.id, expOrder?.orderNumber)),
`the EXPERT's order ${expOrder?.orderNumber} appears in the admin grid`);
chk(Boolean(findRow(custOrder?.id, custOrder?.orderNumber)),
`the CUSTOMER's order ${custOrder?.orderNumber} appears in the admin grid`);
// And the admin can open each one.
for (const o of made.orders) {
const detail = await call('GET', `orders/admin/${o.id}`, undefined, adminToken);
chk(detail.ok, `the admin can open ${o.who}'s order ${o.number} -> ${detail.status}`);
chk(detail.body?.items?.length > 0, `and it carries its items (${detail.body?.items?.length})`);
}
// ── 5. TOKEN BOUNDARIES still hold inside compose ───────────────────────
step('5 — the token boundaries hold in the deployed container too');
const appOnAdmin = await call('POST', 'admin/orders/list', { page: 1 }, expToken);
chk(appOnAdmin.status === 401 || appOnAdmin.status === 403,
`an expert's APP token on an admin route -> ${appOnAdmin.status}`);
const custOnExpert = await call('POST', 'expert/my-customers', { search: null }, custToken);
chk(custOnExpert.status === 403 || custOnExpert.status === 401,
`a customer's token on an expert route -> ${custOnExpert.status}`);
const adminOnApp = await call('GET', 'customer/me', undefined, adminToken);
chk(adminOnApp.status === 401, `an admin token on an app route -> ${adminOnApp.status}`);
// ── CLEANUP ─────────────────────────────────────────────────────────────
step('CLEANUP');
console.log(' This run created, through the admin API:');
for (const o of made.orders) console.log(` order ${o.number} (${o.who})`);
console.log(` customer ${custMobile}, sales expert ${expertMobile}`);
console.log(' They are LEFT IN PLACE deliberately: this is the compose stack, its');
console.log(' database is separate from development, and the rows are what the');
console.log(' owner will look at in the panel. Delete them from the admin panel');
console.log(' after the demo if they are not wanted.');
console.log(`\n${'='.repeat(60)}`);
console.log(`COMPOSE END-TO-END: passed ${pass} failed ${fail}`);
if (fail) { console.log('\nFailures:'); failures.forEach((f) => console.log(' - ' + f)); }
console.log('='.repeat(60));
process.exit(fail ? 1 : 0);
})().catch((e) => {
console.error('\nABORTED:', e);
process.exit(1);
});