Add SMS OTP, harden auth, and fix order/checkout server bugs.

Ship 4-digit OTP with rate-limit attempts, Tehran-safe expiry/rass math, larger JSON body limit for check images, and safer order pricing/status guards. Include rebuilt dist for prebuilt deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-08 14:55:26 +03:30
co-authored by Cursor
parent b121797263
commit 345a4a4708
100 changed files with 1476 additions and 353 deletions
+335
View File
@@ -0,0 +1,335 @@
/**
* تست دود جریان کامل سفارش: ورود مشتری، سبد، ثبت چکی و نقدی،
* قیمت‌گذاری ادمین، رسید نقدی و بررسی وضعیت‌ها.
* اجرا: node scripts/smoke-order-flow.mjs
*/
const BASE = process.env.SMOKE_BASE_URL || 'http://127.0.0.1:3999/api';
const CUSTOMER_PHONE = '09121234567';
const ADMIN_PHONE = '09121112222';
const ADMIN_PASSWORD = 'admin123';
let passed = 0;
let failed = 0;
function check(name, condition, detail) {
if (condition) {
passed++;
console.log(` PASS ${name}`);
} else {
failed++;
console.log(` FAIL ${name}${detail ? ` → ${detail}` : ''}`);
}
}
async function api(path, { method = 'GET', body, token } = {}) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
return { status: res.status, data };
}
function jalaliIsoInDays(days) {
const d = new Date(Date.now() + days * 86400000);
return d.toISOString().slice(0, 10);
}
/** تصویر base64 بزرگ‌تر از سقف پیش‌فرض ۱۰۰KB اکسپرس */
function bigBase64(kb) {
return `data:image/jpeg;base64,${'A'.repeat(kb * 1024)}`;
}
async function main() {
console.log(`\n== Smoke test @ ${BASE} ==\n`);
console.log('[1] احراز هویت');
const otp = await api('/auth/otp/request', {
method: 'POST',
body: { phone: CUSTOMER_PHONE },
});
check('درخواست OTP', otp.status === 201 || otp.status === 200, `status=${otp.status}`);
const code = otp.data?.debugCode ?? '1234';
check('کد OTP چهار رقمی است', /^\d{4}$/.test(String(code)), `code=${code}`);
const badVerify = await api('/auth/otp/verify', {
method: 'POST',
body: { phone: CUSTOMER_PHONE, code: '0000' === code ? '1111' : '0000' },
});
check('کد اشتباه رد می‌شود', badVerify.status === 401, `status=${badVerify.status}`);
const verify = await api('/auth/otp/verify', {
method: 'POST',
body: { phone: CUSTOMER_PHONE, code: String(code) },
});
check('ورود مشتری', !!verify.data?.accessToken, JSON.stringify(verify.data).slice(0, 120));
const userToken = verify.data?.accessToken;
const adminLogin = await api('/admin/auth/login', {
method: 'POST',
body: { phone: ADMIN_PHONE, password: ADMIN_PASSWORD },
});
check('ورود ادمین', !!adminLogin.data?.accessToken, JSON.stringify(adminLogin.data).slice(0, 120));
const adminToken = adminLogin.data?.accessToken;
console.log('\n[2] جداسازی دسترسی');
const crossAccess = await api('/admin/orders', { token: userToken });
check('توکن مشتری به مسیر ادمین دسترسی ندارد', crossAccess.status === 403, `status=${crossAccess.status}`);
const custWithAdmin = await api('/orders', { token: adminToken });
check('توکن ادمین به مسیر مشتری دسترسی ندارد', custWithAdmin.status === 403, `status=${custWithAdmin.status}`);
const noToken = await api('/orders');
check('بدون توکن ۴۰۱', noToken.status === 401, `status=${noToken.status}`);
console.log('\n[3] سبد خرید');
const companies = await api('/companies/public');
const company = companies.data?.[0];
check('لیست شرکت‌ها', !!company, JSON.stringify(companies.data).slice(0, 120));
const products = await api(`/products?companyId=${company.id}`, { token: userToken });
const product = (Array.isArray(products.data) ? products.data : products.data?.items)?.find(
(p) => p.companyId === company.id,
);
check('لیست محصولات', !!product, JSON.stringify(products.data).slice(0, 160));
const meterage = Math.max(product.minMeterage ?? 1, product.orderMultiplier ?? 1);
await api('/carts', { method: 'DELETE', token: userToken });
const addItem = await api('/carts/items', {
method: 'POST',
token: userToken,
body: { productId: product.id, meterage },
});
check('افزودن به سبد', addItem.status < 300, `status=${addItem.status} ${JSON.stringify(addItem.data).slice(0, 120)}`);
console.log('\n[4] ثبت سفارش چکی با تصویر بزرگ');
const checkOrder = await api('/orders', {
method: 'POST',
token: userToken,
body: {
companyId: company.id,
settlementType: 'check',
checkMode: 'full',
items: [{ productId: product.id, meterage }],
checks: [
{
amount: 2000000,
dueDate: jalaliIsoInDays(30),
sayadId: '1234567890123456',
checkNumber: '123456',
ownerNationalId: '0012345678',
ownerFirstName: 'علی',
ownerLastName: 'رضایی',
bankName: 'ملی',
branchName: 'مرکزی',
frontImage: bigBase64(300),
backImage: bigBase64(300),
mode: 'full',
},
],
},
});
check(
'ثبت سفارش چکی (بدنه ~۶۰۰KB)',
checkOrder.status < 300,
`status=${checkOrder.status} ${JSON.stringify(checkOrder.data).slice(0, 200)}`,
);
const checkOrderId = checkOrder.data?.id;
check('راس چک محاسبه شد', checkOrder.data?.checkRassDays === 30, `rass=${checkOrder.data?.checkRassDays}`);
console.log('\n[5] فیلدهای اضافه باید رد شوند');
const extraField = await api('/orders', {
method: 'POST',
token: userToken,
body: {
companyId: company.id,
settlementType: 'cash',
items: [{ productId: product.id, meterage }],
bogusField: 'x',
},
});
check('فیلد ناشناخته ۴۰۰ می‌دهد', extraField.status === 400, `status=${extraField.status}`);
console.log('\n[6] ثبت سفارش نقدی');
await api('/carts/items', {
method: 'POST',
token: userToken,
body: { productId: product.id, meterage },
});
const cashOrder = await api('/orders', {
method: 'POST',
token: userToken,
body: {
companyId: company.id,
settlementType: 'cash',
items: [{ productId: product.id, meterage }],
},
});
check('ثبت سفارش نقدی', cashOrder.status < 300, `status=${cashOrder.status} ${JSON.stringify(cashOrder.data).slice(0, 200)}`);
const cashOrderId = cashOrder.data?.id;
const expiry = cashOrder.data?.cashInvoiceExpiresAt;
check('انقضای فاکتور در آینده است', !!expiry && new Date(expiry) > new Date(), `expiry=${expiry}`);
console.log('\n[7] سفارش در لیست مشتری دیده می‌شود');
const myOrders = await api('/orders', { token: userToken });
const ids = (myOrders.data ?? []).map((o) => o.id);
check('سفارش چکی در لیست', ids.includes(checkOrderId));
check('سفارش نقدی در لیست', ids.includes(cashOrderId));
console.log('\n[8] لیست و فیلتر ادمین');
const adminOrders = await api('/admin/orders', { token: adminToken });
const adminIds = (adminOrders.data ?? []).map((o) => o.id);
check('هر دو سفارش برای ادمین', adminIds.includes(checkOrderId) && adminIds.includes(cashOrderId));
const adminStatuses = new Set((adminOrders.data ?? []).map((o) => o.adminStatus));
const allowedTabs = new Set([
'pending_review',
'pending_check',
'needs_correction',
'check_correction',
'final_approval',
'shipped',
'delivered',
'cancelled',
'rejected',
'expired',
]);
const unknown = [...adminStatuses].filter((s) => !allowedTabs.has(s));
check('همه وضعیت‌ها به تب معتبر نگاشت شده‌اند', unknown.length === 0, `unknown=${unknown.join(',')}`);
const filtered = await api('/admin/orders?status=pending_review', { token: adminToken });
check(
'فیلتر «در انتظار بررسی» سفارش‌ها را برمی‌گرداند',
(filtered.data ?? []).some((o) => o.id === cashOrderId),
`count=${(filtered.data ?? []).length}`,
);
console.log('\n[9] قیمت‌گذاری ادمین');
const detail = await api(`/admin/orders/${cashOrderId}`, { token: adminToken });
const items = detail.data?.items ?? [];
check('سفارش آیتم دارد', items.length > 0);
const pricing = await api(`/admin/orders/${cashOrderId}/pricing`, {
method: 'PATCH',
token: adminToken,
body: {
items: [{ id: items[0].id, confirmedMeterage: meterage, unitPrice: 100000, unitDiscount: 0 }],
shippingCost: 50000,
},
});
const expectedTotal = 100000 * meterage;
check('قیمت‌گذاری موفق', pricing.status < 300, `status=${pricing.status} ${JSON.stringify(pricing.data).slice(0, 150)}`);
check(
'جمع کل درست است',
Math.abs((pricing.data?.totalAmount ?? 0) - expectedTotal) < 0.01,
`total=${pricing.data?.totalAmount} expected=${expectedTotal}`,
);
check(
'مبلغ نهایی = جمع + حمل',
Math.abs((pricing.data?.finalAmount ?? 0) - (expectedTotal + 50000)) < 0.01,
`final=${pricing.data?.finalAmount}`,
);
const badDiscount = await api(`/admin/orders/${cashOrderId}/pricing`, {
method: 'PATCH',
token: adminToken,
body: { items: [{ id: items[0].id, unitPrice: 1000, unitDiscount: 5000 }] },
});
check('تخفیف بیشتر از قیمت رد می‌شود', badDiscount.status === 400, `status=${badDiscount.status}`);
console.log('\n[10] رسید پرداخت نقدی');
const accounts = await api(`/companies/${company.id}/bank-accounts`, { token: userToken });
const account = (accounts.data ?? [])[0];
check('حساب بانکی موجود', !!account, JSON.stringify(accounts.data).slice(0, 120));
const receipt = await api(`/orders/${cashOrderId}/cash-receipt`, {
method: 'POST',
token: userToken,
body: {
amount: expectedTotal + 50000,
transferDate: jalaliIsoInDays(0),
transferTime: '12:00',
trackingNumber: '987654321',
destinationAccountId: account.id,
receiptImage: bigBase64(400),
},
});
check('ثبت رسید (تصویر ۴۰۰KB)', receipt.status < 300, `status=${receipt.status} ${JSON.stringify(receipt.data).slice(0, 200)}`);
const afterReceipt = await api(`/orders/${cashOrderId}`, { token: userToken });
check(
'وضعیت به تأیید پرداخت رفت',
afterReceipt.data?.status === 'awaiting_cash_confirmation',
`status=${afterReceipt.data?.status}`,
);
console.log('\n[11] دسترسی متقاطع بین مشتری‌ها');
const otherPhone = '09129999001';
await api('/auth/otp/request', {
method: 'POST',
body: { phone: otherPhone, firstName: 'تست', lastName: 'دوم', isRegister: true },
});
const otherVerify = await api('/auth/otp/verify', {
method: 'POST',
body: { phone: otherPhone, code: String(code) },
});
const otherToken = otherVerify.data?.accessToken;
check('ثبت‌نام مشتری دوم', !!otherToken, JSON.stringify(otherVerify.data).slice(0, 120));
if (otherToken) {
const foreign = await api(`/orders/${cashOrderId}`, { token: otherToken });
check('سفارش دیگری قابل خواندن نیست', foreign.status === 403, `status=${foreign.status}`);
const foreignReceipt = await api(`/orders/${cashOrderId}/cash-receipt`, {
method: 'POST',
token: otherToken,
body: {
amount: 1000,
transferDate: jalaliIsoInDays(0),
transferTime: '12:00',
trackingNumber: '1',
destinationAccountId: account.id,
},
});
check('رسید روی سفارش دیگری رد می‌شود', foreignReceipt.status === 403, `status=${foreignReceipt.status}`);
}
console.log('\n[12] محافظت از ویرایش چک بعد از تأیید');
const approve = await api(`/admin/orders/${checkOrderId}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'delivered' },
});
check('تغییر وضعیت توسط ادمین', approve.status < 300, `status=${approve.status}`);
const lateEdit = await api(`/orders/${checkOrderId}/checks`, {
method: 'PATCH',
token: userToken,
body: { checks: [{ amount: 1, dueDate: jalaliIsoInDays(10), mode: 'brief' }] },
});
check('ویرایش چک بعد از تحویل رد می‌شود', lateEdit.status === 400, `status=${lateEdit.status}`);
const lateReceipt = await api(`/orders/${checkOrderId}/cash-receipt`, {
method: 'POST',
token: userToken,
body: {
amount: 1000,
transferDate: jalaliIsoInDays(0),
transferTime: '12:00',
trackingNumber: '1',
destinationAccountId: account.id,
},
});
check('رسید نقدی روی سفارش چکی رد می‌شود', lateReceipt.status === 400, `status=${lateReceipt.status}`);
console.log(`\n== نتیجه: ${passed} موفق، ${failed} ناموفق ==\n`);
process.exit(failed === 0 ? 0 : 1);
}
main().catch((e) => {
console.error('اجرای تست شکست خورد:', e);
process.exit(1);
});