227 lines
11 KiB
JavaScript
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);
|
||
|
|
});
|