Files

309 lines
13 KiB
JavaScript
Raw Permalink Normal View History

/**
* Delivery smoke test — the admin flows the owner listed (D19 step 3):
*
* login → create a customer → create and edit a sales expert
* → create and edit a product with variants
* → view an order and change its status
*
* This is a WORKING check, not a parity audit: each step asserts that the
* operation actually took effect in the database, not merely that the HTTP
* call returned 200. A 200 with a silently-dropped field is exactly the class
* of bug this is meant to catch.
*
* Every Persian string is a literal in this file and travels as UTF-8 JSON —
* never as a shell argument (D15).
*
* Usage: node scripts/smoke-admin.cjs [apiBaseUrl]
*/
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, '');
let pass = 0;
let fail = 0;
const failures = [];
const chk = (cond, msg) => {
if (cond) {
pass++;
console.log(' PASS ' + msg);
} else {
fail++;
failures.push(msg);
console.log(' FAIL ' + msg);
}
};
const step = (t) => console.log('\n' + t);
/** Records created by this run, removed at the end. */
const created = { customers: [], adminUsers: [], products: [] };
let H = {};
const call = async (method, path, body) => {
const res = await fetch(`${API}/${path}`, {
method,
headers: H,
body: body === undefined ? undefined : JSON.stringify(body),
});
let payload = null;
try {
payload = await res.json();
} catch {
/* empty body */
}
return { status: res.status, ok: res.ok, body: payload };
};
(async () => {
// ── 1. LOGIN ────────────────────────────────────────────────────────────
step('1 — ADMIN LOGIN');
const login = await fetch(`${API}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: '09128081392', password: '09128081392' }),
});
const auth = await login.json();
chk(login.status === 200 || login.status === 201, `POST admin/auth/login -> ${login.status}`);
chk(typeof auth.api_token === 'string', 'a token came back');
if (!auth.api_token) {
console.log('\nCannot continue without a token.');
process.exit(1);
}
H = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + auth.api_token };
const unique = String(Date.now()).slice(-7);
// ── 2. CREATE A CUSTOMER ────────────────────────────────────────────────
step('2 — CREATE A WHOLESALE CUSTOMER');
const custMobile = '0912' + unique;
const cust = await call('POST', 'admin/customers-omde/create', {
fname: 'سمیرا',
lname: 'کریمی',
name: 'سمیرا کریمی',
mobile: custMobile,
percent: 3,
type: 2,
store_name: 'گالری سمیرا',
gram_level: 150,
});
chk(cust.ok, `POST customers-omde/create -> ${cust.status}`);
const custId = cust.body?.id;
if (custId) created.customers.push(custId);
chk(Boolean(custId), 'an id came back');
if (custId) {
const row = await prisma.customer.findUnique({ where: { id: custId } });
chk(row?.firstName === 'سمیرا' && row?.lastName === 'کریمی', 'the name parts were stored separately');
chk(row?.fullName === 'سمیرا کریمی', 'fullName was rebuilt by the trigger');
chk(String(row?.makingChargeOffsetPercent) === '3', 'percent persisted as 3');
chk(row?.storeName === 'گالری سمیرا', 'store_name persisted');
}
step('2b — Persian digits in the mobile are normalised (D19 gap 2)');
const faMobile = '۰۹۱۳' + unique;
const faCust = await call('POST', 'admin/customers-omde/create', {
fname: 'نگین',
lname: 'مرادی',
mobile: faMobile,
percent: 3,
type: 2,
});
chk(faCust.ok, `create with Persian digits -> ${faCust.status}`);
if (faCust.body?.id) {
created.customers.push(faCust.body.id);
const row = await prisma.customer.findUnique({ where: { id: faCust.body.id } });
chk(row?.mobile === '0913' + unique, `stored as ASCII "${row?.mobile}" (not Persian digits)`);
}
// ── 3. SALES EXPERT: CREATE, READ BACK, EDIT ────────────────────────────
step('3 — CREATE A SALES EXPERT');
const expMobile = '0914' + unique;
const exp = await call('POST', 'admin/users', {
fname: 'محمد علی',
lname: 'احمدی',
name: 'محمد علی احمدی',
mobile: expMobile,
password: 'Test12345',
role: 'SALES_EXPERT',
percent: 4.5,
gram_level: 250,
});
chk(exp.ok, `POST admin/users -> ${exp.status}`);
const expId = exp.body?.id;
if (expId) created.adminUsers.push(expId);
chk(Boolean(expId), 'an id came back');
if (expId) {
const row = await prisma.adminUser.findUnique({
where: { id: expId },
include: { salesExpertProfile: true },
});
chk(row?.firstName === 'محمد علی', 'a two-token first name was NOT re-split');
chk(row?.lastName === 'احمدی', 'lastName stored');
chk(Boolean(row?.salesExpertProfile), 'the SalesExpertProfile was auto-created');
step('3b — EDIT the sales expert (one field only)');
const edit = await call('PUT', `admin/users/${expId}`, { gram_level: 999 });
chk(edit.ok, `PUT admin/users/:id -> ${edit.status}`);
const after = await prisma.adminUser.findUnique({
where: { id: expId },
include: { salesExpertProfile: true },
});
chk(String(after?.salesExpertProfile?.gramLevel) === '999', 'gram_level was persisted');
chk(after?.firstName === 'محمد علی' && after?.lastName === 'احمدی', 'the name was untouched');
const grid = await call('POST', 'admin/customers-omde/reagent/list', { page: 1, limit: 200 });
chk(grid.ok, `POST reagent/list -> ${grid.status}`);
const mine = (grid.body?.rows ?? []).find((r) => r.id === expId);
chk(Boolean(mine), 'the expert appears in the grid');
chk(mine?.fname === 'محمد علی' && mine?.lname === 'احمدی', 'the grid shows the real name parts');
}
// ── 4. PRODUCT + VARIANTS: CREATE, READ BACK, EDIT ──────────────────────
step('4 — CREATE A PRODUCT WITH VARIANTS');
const prod = await call('POST', 'admin/products/create', {
title: 'انگشتر طلا ' + unique,
body_html: 'توضیح آزمایشی',
product_code: 'SMK-' + unique,
show_type: 3,
is_active: true,
variants: [
{ title: 'سایز ۱', grams: 2.5, percentage_pay: 7, qty: 4 },
{ title: 'سایز ۲', grams: 3.25, percentage_pay: 7, qty: 0, is_pre_order: true },
],
});
chk(prod.ok, `POST admin/products/create -> ${prod.status}`);
const prodId = prod.body?.id;
if (prodId) created.products.push(prodId);
chk(Boolean(prodId), 'an id came back');
if (prodId) {
const variants = await prisma.productVariant.findMany({
where: { productId: prodId },
orderBy: { title: 'asc' },
});
chk(variants.length === 2, `both variants were created (got ${variants.length})`);
chk(
variants.some((v) => String(v.rashWeightGrams) === '2.5'),
'the 2.5g weight round-tripped exactly (no float drift)',
);
chk(
variants.some((v) => String(v.defaultWagePercent) === '7'),
'the making charge (اجرت) persisted as 7',
);
chk(variants.every((v) => Boolean(v.sku)), 'every variant got a SKU');
chk(
variants.some((v) => v.isPreOrder === true),
'the pre-order flag persisted',
);
step('4b — OPEN the product for edit');
const show = await call('GET', `admin/products/${prodId}`);
chk(show.ok, `GET admin/products/:id -> ${show.status}`);
chk(show.body?.title === 'انگشتر طلا ' + unique, 'the title reads back intact');
// The form reads `variants_all` (AddProduct.vue:488), not `variants`.
const shownVariants = show.body?.variants_all ?? [];
chk(shownVariants.length === 2, `the edit form receives both variants (got ${shownVariants.length})`);
step('4c — EDIT the product');
const upd = await call('PUT', `admin/products/${prodId}`, { title: 'انگشتر طلا ویرایش‌شده' });
chk(upd.ok, `PUT admin/products/:id -> ${upd.status}`);
const afterP = await prisma.product.findUnique({ where: { id: prodId } });
chk(afterP?.title === 'انگشتر طلا ویرایش‌شده', 'the new title persisted');
chk(afterP?.productCode === 'SMK-' + unique, 'product_code was NOT wiped by the partial edit');
const stillThere = await prisma.productVariant.count({ where: { productId: prodId } });
chk(stillThere === 2, `the variants survived the edit (${stillThere} of 2)`);
}
// ── 5. ORDERS: LIST, VIEW, CHANGE STATUS ────────────────────────────────
step('5 — ORDER LIST / DETAIL / STATUS');
const orders = await call('POST', 'admin/orders/list', { page: 1, limit: 5 });
chk(orders.ok, `POST admin/orders/list -> ${orders.status}`);
const wholesale = await call('POST', 'admin/orders/wholesale/list', { page: 1, limit: 5 });
chk(wholesale.ok, `POST admin/orders/wholesale/list -> ${wholesale.status}`);
const statusOpts = await call('GET', 'admin/orders/status-options');
chk(statusOpts.ok, `GET admin/orders/status-options -> ${statusOpts.status}`);
const anyOrder =
(orders.body?.rows ?? [])[0] ?? (wholesale.body?.rows ?? [])[0] ?? null;
if (!anyOrder) {
console.log(' (no order exists in this database — detail/status checks skipped)');
} else {
const detail = await call('GET', `admin/orders/${anyOrder.id}`);
chk(detail.ok, `GET admin/orders/:id -> ${detail.status}`);
chk(Boolean(detail.body?.id), 'the order detail came back');
const before = await prisma.order.findUnique({ where: { id: anyOrder.id } });
const options = Array.isArray(statusOpts.body) ? statusOpts.body : [];
// Pick a status that is NOT the current one, so the write is observable.
const targetOpt = options.find(
(o) => String(o.id ?? o.value) !== String(before?.status),
);
if (!targetOpt || !before) {
console.log(' (no alternative status available — status change skipped)');
} else {
const target = String(targetOpt.id ?? targetOpt.value);
// UpdateOrderStatusDto requires `orderId` — the exact field the retail
// List.vue screen was failing to send (fixed in this batch).
const res = await call('POST', 'admin/orders/status', {
orderId: anyOrder.id,
status: target,
});
chk(res.ok, `POST admin/orders/status -> ${res.status}`);
const after = await prisma.order.findUnique({ where: { id: anyOrder.id } });
chk(String(after?.status) === target, `the status actually changed to ${target}`);
// Put it back exactly as it was — this is live data, not a fixture.
await prisma.order.update({
where: { id: anyOrder.id },
data: { status: before.status },
});
const restored = await prisma.order.findUnique({ where: { id: anyOrder.id } });
chk(restored?.status === before.status, 'the original status was restored');
}
}
// ── CLEANUP ─────────────────────────────────────────────────────────────
step('CLEANUP — removing only the rows this run created');
if (created.products.length) {
await prisma.productVariant.deleteMany({ where: { productId: { in: created.products } } });
await prisma.productImage.deleteMany({ where: { productId: { in: created.products } } });
await prisma.product.deleteMany({ where: { id: { in: created.products } } });
}
if (created.adminUsers.length) {
await prisma.salesExpertProfile.deleteMany({
where: { adminUserId: { in: created.adminUsers } },
});
await prisma.adminUser.deleteMany({ where: { id: { in: created.adminUsers } } });
}
if (created.customers.length) {
await prisma.customer.deleteMany({ where: { id: { in: created.customers } } });
}
console.log(
` deleted: ${created.products.length} product(s) + their variants/images, ` +
`${created.adminUsers.length} admin user(s) + profiles, ` +
`${created.customers.length} customer(s). No pre-existing data was removed.`,
);
console.log(`\n${'='.repeat(60)}`);
console.log(`ADMIN SMOKE: passed ${pass} failed ${fail}`);
if (fail) {
console.log('\nFailures:');
failures.forEach((f) => console.log(' - ' + f));
}
console.log('='.repeat(60));
await prisma.$disconnect();
process.exit(fail ? 1 : 0);
})().catch(async (e) => {
console.error('\nSMOKE ABORTED:', e);
await prisma.$disconnect();
process.exit(1);
});