/** * تست دود گردش کامل سفارش مطابق روال کارفرما: * مشتری بدون دیدن قیمت سفارش می‌دهد، ادمین قیمت می‌گذارد و فاکتور می‌فرستد، * مشتری روش تسویه را انتخاب می‌کند و مسیر نقدی/چکی تا تایید نهایی پیش می‌رود. * اجرا: 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 = '09128081392'; const ADMIN_PASSWORD = '123456'; 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 isoInDays(days) { return new Date(Date.now() + days * 86400000).toISOString().slice(0, 10); } /** تصویر base64 بزرگ‌تر از سقف پیش‌فرض ۱۰۰KB اکسپرس */ function bigBase64(kb) { return `data:image/jpeg;base64,${'A'.repeat(kb * 1024)}`; } function checkPayload(amount, days, extra = {}) { return { amount, dueDate: isoInDays(days), sayadId: '1234567890123456', checkNumber: '123456', ownerNationalId: '0012345678', ownerFirstName: 'علی', ownerLastName: 'رضایی', bankName: 'ملی', branchName: 'مرکزی', mode: 'full', ...extra, }; } 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: String(code) === '0000' ? '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)); check( 'سقف متراژ موثر برگردانده می‌شود', typeof company?.maxMeteragePerInvoice === 'number' && company.maxMeteragePerInvoice > 0, `max=${company?.maxMeteragePerInvoice}`, ); 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)}`); const cartItem = addItem.data?.items?.[0]; check('سبد قیمت واحد ندارد', cartItem?.unitPrice === undefined, `unitPrice=${cartItem?.unitPrice}`); console.log('\n[4] ثبت سفارش بدون روش تسویه و بدون قیمت'); const cashOrder = await api('/orders', { method: 'POST', token: userToken, body: { companyId: company.id, items: [{ productId: product.id, meterage }] }, }); check('ثبت سفارش', cashOrder.status < 300, `status=${cashOrder.status} ${JSON.stringify(cashOrder.data).slice(0, 200)}`); const cashOrderId = cashOrder.data?.id; check('وضعیت اولیه در انتظار بررسی', cashOrder.data?.status === 'awaiting_review', `status=${cashOrder.data?.status}`); check('روش تسویه خالی است', cashOrder.data?.settlementType == null, `settlement=${cashOrder.data?.settlementType}`); check('مبلغ برای مشتری پنهان است', cashOrder.data?.finalAmount == null && cashOrder.data?.totalAmount == null); check('پرچم pricesVisible خاموش است', cashOrder.data?.pricesVisible === false, `flag=${cashOrder.data?.pricesVisible}`); check( 'قیمت واحد آیتم‌ها پنهان است', (cashOrder.data?.items ?? []).every((i) => i.unitPrice == null), ); console.log('\n[5] اعتبارسنجی ورودی'); const extraField = await api('/orders', { method: 'POST', token: userToken, body: { companyId: company.id, items: [{ productId: product.id, meterage }], bogusField: 'x' }, }); check('فیلد ناشناخته ۴۰۰ می‌دهد', extraField.status === 400, `status=${extraField.status}`); const earlySettlement = await api(`/orders/${cashOrderId}/settlement`, { method: 'POST', token: userToken, body: { settlementType: 'cash' }, }); check('انتخاب تسویه قبل از فاکتور رد می‌شود', earlySettlement.status === 400, `status=${earlySettlement.status}`); const earlyReceipt = await api(`/orders/${cashOrderId}/cash-receipt`, { method: 'POST', token: userToken, body: { amount: 1000, transferDate: isoInDays(0), transferTime: '12:00', trackingNumber: '1', destinationAccountId: 'x', }, }); check('رسید قبل از فاکتور رد می‌شود', earlyReceipt.status === 400, `status=${earlyReceipt.status}`); console.log('\n[6] سطل‌های وضعیت ادمین'); const adminOrders = await api('/admin/orders', { token: adminToken }); const adminIds = (adminOrders.data ?? []).map((o) => o.id); check('سفارش برای ادمین دیده می‌شود', adminIds.includes(cashOrderId)); const allowedTabs = new Set([ 'pending_review', 'awaiting_customer', 'pending_check', 'pending_payment', 'needs_correction', 'check_correction', 'final_approval', 'shipped', 'delivered', 'cancelled', 'rejected', 'expired', ]); const unknown = [...new Set((adminOrders.data ?? []).map((o) => o.adminStatus))].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[7] قیمت‌گذاری ادمین'); const detail = await api(`/admin/orders/${cashOrderId}`, { token: adminToken }); const items = detail.data?.items ?? []; check('سفارش آیتم دارد', items.length > 0); check( 'ادمین گذرهای مجاز را می‌بیند', Array.isArray(detail.data?.allowedTransitions) && detail.data.allowedTransitions.includes('pricing'), `transitions=${JSON.stringify(detail.data?.allowedTransitions)}`, ); const earlyInvoice = await api(`/admin/orders/${cashOrderId}/status`, { method: 'PATCH', token: adminToken, body: { status: 'awaiting_settlement' }, }); check('ارسال فاکتور بدون قیمت‌گذاری رد می‌شود', earlyInvoice.status === 400, `status=${earlyInvoice.status}`); 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; const expectedFinal = expectedTotal + 50000; 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}`); check('مبلغ نهایی = جمع + حمل', Math.abs((pricing.data?.finalAmount ?? 0) - expectedFinal) < 0.01, `final=${pricing.data?.finalAmount}`); const stillHidden = await api(`/orders/${cashOrderId}`, { token: userToken }); check('قیمت تا ارسال فاکتور برای مشتری پنهان است', stillHidden.data?.finalAmount == null, `final=${stillHidden.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[8] ارسال فاکتور برای مشتری'); 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 invoice = await api(`/admin/orders/${cashOrderId}/status`, { method: 'PATCH', token: adminToken, body: { status: 'awaiting_settlement', note: 'فاکتور نهایی شد' }, }); check('ارسال فاکتور', invoice.status < 300, `status=${invoice.status} ${JSON.stringify(invoice.data).slice(0, 150)}`); const priced = await api(`/orders/${cashOrderId}`, { token: userToken }); check('مشتری مبلغ فاکتور را می‌بیند', Math.abs((priced.data?.finalAmount ?? 0) - expectedFinal) < 0.01, `final=${priced.data?.finalAmount}`); check('pricesVisible روشن شد', priced.data?.pricesVisible !== false); const illegal = await api(`/admin/orders/${cashOrderId}/status`, { method: 'PATCH', token: adminToken, body: { status: 'delivered' }, }); check('گذر غیرمجاز بدون force رد می‌شود', illegal.status === 400, `status=${illegal.status}`); const forceNoReason = await api(`/admin/orders/${cashOrderId}/status`, { method: 'PATCH', token: adminToken, body: { status: 'delivered', force: true }, }); check('force بدون دلیل رد می‌شود', forceNoReason.status === 400, `status=${forceNoReason.status}`); console.log('\n[9] اعلان مشتری'); const notifs = await api('/notifications', { token: userToken }); const invoiceNotif = (notifs.data ?? []).find((n) => n.event === 'invoice_priced'); check('اعلان صدور فاکتور ساخته شد', !!invoiceNotif, JSON.stringify(notifs.data)?.slice(0, 200)); check('اعلان به سفارش لینک دارد', invoiceNotif?.link === `/orders/${cashOrderId}`, `link=${invoiceNotif?.link}`); const unread = await api('/notifications/unread-count', { token: userToken }); check('شمارنده خوانده‌نشده مثبت است', (unread.data?.count ?? 0) > 0, `count=${unread.data?.count}`); if (invoiceNotif) { const marked = await api(`/notifications/${invoiceNotif.id}/read`, { method: 'PATCH', token: userToken }); check('علامت خوانده‌شده', marked.status < 300 && marked.data?.count < (unread.data?.count ?? 0), JSON.stringify(marked.data)); } const readAll = await api('/notifications/read-all', { method: 'PATCH', token: userToken }); check('خواندن همه', readAll.status < 300 && (readAll.data?.count ?? -1) === 0, JSON.stringify(readAll.data)); console.log('\n[10] مسیر نقدی'); const chooseCash = await api(`/orders/${cashOrderId}/settlement`, { method: 'POST', token: userToken, body: { settlementType: 'cash' }, }); check('انتخاب تسویه نقدی', chooseCash.status < 300, `status=${chooseCash.status} ${JSON.stringify(chooseCash.data).slice(0, 150)}`); check('وضعیت در انتظار پرداخت', chooseCash.data?.status === 'awaiting_cash_payment', `status=${chooseCash.data?.status}`); const expiry = chooseCash.data?.cashInvoiceExpiresAt; check('انقضای فاکتور در آینده است', !!expiry && new Date(expiry) > new Date(), `expiry=${expiry}`); 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: expectedFinal, transferDate: isoInDays(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}`); const receipts = await api('/admin/cash-payments', { token: adminToken }); const pendingReceipt = (receipts.data ?? []).find((r) => r.orderId === cashOrderId); check('رسید در صف ادمین است', !!pendingReceipt, JSON.stringify(receipts.data)?.slice(0, 160)); const rejectReceipt = await api(`/admin/cash-payments/${pendingReceipt.id}/status`, { method: 'PATCH', token: adminToken, body: { status: 'rejected', note: 'مبلغ نامطابق' }, }); check('رد رسید', rejectReceipt.status < 300, `status=${rejectReceipt.status}`); const afterReject = await api(`/orders/${cashOrderId}`, { token: userToken }); check( 'رد رسید مشتری را به مرحله پرداخت برمی‌گرداند', afterReject.data?.status === 'awaiting_cash_payment', `status=${afterReject.data?.status}`, ); const approveReceipt = await api(`/admin/cash-payments/${pendingReceipt.id}/status`, { method: 'PATCH', token: adminToken, body: { status: 'approved' }, }); check('تأیید رسید', approveReceipt.status < 300, `status=${approveReceipt.status}`); const afterApprove = await api(`/orders/${cashOrderId}`, { token: userToken }); check('وضعیت پرداخت تأییدشده', afterApprove.data?.status === 'cash_confirmed', `status=${afterApprove.data?.status}`); const finalApprove = await api(`/admin/orders/${cashOrderId}/status`, { method: 'PATCH', token: adminToken, body: { status: 'final_approved' }, }); check('تأیید نهایی سفارش نقدی', finalApprove.status < 300, `status=${finalApprove.status}`); console.log('\n[11] مسیر چکی با حلقه اصلاح'); await api('/carts', { method: 'DELETE', token: userToken }); await api('/carts/items', { method: 'POST', token: userToken, body: { productId: product.id, meterage }, }); const checkOrder = await api('/orders', { method: 'POST', token: userToken, body: { companyId: company.id, items: [{ productId: product.id, meterage }] }, }); const checkOrderId = checkOrder.data?.id; check('ثبت سفارش دوم', checkOrder.status < 300, `status=${checkOrder.status}`); const checkDetail = await api(`/admin/orders/${checkOrderId}`, { token: adminToken }); const checkItem = checkDetail.data?.items?.[0]; await api(`/admin/orders/${checkOrderId}/pricing`, { method: 'PATCH', token: adminToken, body: { items: [{ id: checkItem.id, confirmedMeterage: meterage, unitPrice: 100000 }] }, }); await api(`/admin/orders/${checkOrderId}/status`, { method: 'PATCH', token: adminToken, body: { status: 'awaiting_settlement' }, }); const chooseCheck = await api(`/orders/${checkOrderId}/settlement`, { method: 'POST', token: userToken, body: { settlementType: 'check', checkMode: 'full' }, }); check('انتخاب تسویه چکی', chooseCheck.status < 300, `status=${chooseCheck.status} ${JSON.stringify(chooseCheck.data).slice(0, 150)}`); check('وضعیت در حال ثبت چک', chooseCheck.data?.status === 'check_in_progress', `status=${chooseCheck.data?.status}`); const submitChecks = await api(`/orders/${checkOrderId}/checks`, { method: 'PATCH', token: userToken, body: { checkMode: 'full', checks: [ checkPayload(expectedTotal, 30, { frontImage: bigBase64(300), backImage: bigBase64(300), }), ], }, }); check('ارسال چک‌ها (بدنه ~۶۰۰KB)', submitChecks.status < 300, `status=${submitChecks.status} ${JSON.stringify(submitChecks.data).slice(0, 200)}`); check('وضعیت بررسی چک', submitChecks.data?.status === 'awaiting_check_review', `status=${submitChecks.data?.status}`); check('راس چک محاسبه شد', submitChecks.data?.checkRassDays === 30, `rass=${submitChecks.data?.checkRassDays}`); const orderChecks = await api(`/admin/orders/${checkOrderId}/checks`, { token: adminToken }); const firstCheck = (orderChecks.data ?? [])[0]; check('چک برای ادمین دیده می‌شود', !!firstCheck, JSON.stringify(orderChecks.data)?.slice(0, 160)); const rejectCheck = await api(`/admin/checks/${firstCheck.id}/status`, { method: 'PATCH', token: adminToken, body: { status: 'rejected', note: 'تصویر ناخوانا' }, }); check('رد چک', rejectCheck.status < 300, `status=${rejectCheck.status}`); const afterCheckReject = await api(`/orders/${checkOrderId}`, { token: userToken }); check( 'رد چک به اصلاح می‌رود نه بن‌بست', afterCheckReject.data?.status === 'check_needs_fix', `status=${afterCheckReject.data?.status}`, ); const fixChecks = await api(`/orders/${checkOrderId}/checks`, { method: 'PATCH', token: userToken, body: { checkMode: 'full', checks: [checkPayload(expectedTotal, 45)] }, }); check('اصلاح چک‌ها', fixChecks.status < 300, `status=${fixChecks.status} ${JSON.stringify(fixChecks.data).slice(0, 200)}`); check('بازگشت به بررسی چک', fixChecks.data?.status === 'awaiting_check_review', `status=${fixChecks.data?.status}`); const fixedChecks = await api(`/admin/orders/${checkOrderId}/checks`, { token: adminToken }); const approveCheck = await api(`/admin/checks/${fixedChecks.data[0].id}/status`, { method: 'PATCH', token: adminToken, body: { status: 'approved' }, }); check('تأیید چک', approveCheck.status < 300, `status=${approveCheck.status}`); const afterCheckApprove = await api(`/orders/${checkOrderId}`, { token: userToken }); check('وضعیت تأیید چک‌ها', afterCheckApprove.data?.status === 'checks_approved', `status=${afterCheckApprove.data?.status}`); console.log('\n[12] تنظیمات سیستم'); const settings = await api('/admin/settings', { token: adminToken }); check('خواندن تنظیمات', settings.status < 300 && typeof settings.data?.maxMeteragePerInvoice === 'number', JSON.stringify(settings.data)?.slice(0, 160)); const savedSettings = await api('/admin/settings', { method: 'PATCH', token: adminToken, body: { smsPricingReady: false, maxMeteragePerInvoice: 6000 }, }); check('ذخیره تنظیمات', savedSettings.data?.maxMeteragePerInvoice === 6000 && savedSettings.data?.smsPricingReady === false, JSON.stringify(savedSettings.data)?.slice(0, 200)); const companyAfter = await api('/companies/public'); check( 'سقف متراژ شرکت از تنظیمات ارث می‌برد', companyAfter.data?.[0]?.maxMeteragePerInvoice === 6000, `max=${companyAfter.data?.[0]?.maxMeteragePerInvoice}`, ); await api('/admin/settings', { method: 'PATCH', token: adminToken, body: { smsPricingReady: true, maxMeteragePerInvoice: 5000 }, }); console.log('\n[13] دسترسی متقاطع بین مشتری‌ها'); 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 foreignSettlement = await api(`/orders/${checkOrderId}/settlement`, { method: 'POST', token: otherToken, body: { settlementType: 'cash' }, }); check('انتخاب تسویه روی سفارش دیگری رد می‌شود', foreignSettlement.status === 403, `status=${foreignSettlement.status}`); const foreignNotifs = await api('/notifications', { token: otherToken }); check('اعلان‌ها بین مشتری‌ها جدا است', (foreignNotifs.data ?? []).length === 0, `count=${(foreignNotifs.data ?? []).length}`); } console.log('\n[14] محافظت از ویرایش بعد از تحویل'); const forceDeliver = await api(`/admin/orders/${checkOrderId}/status`, { method: 'PATCH', token: adminToken, body: { status: 'delivered', force: true, reason: 'تحویل دستی برای تست' }, }); check('گذر دستی با دلیل پذیرفته می‌شود', forceDeliver.status < 300, `status=${forceDeliver.status} ${JSON.stringify(forceDeliver.data).slice(0, 150)}`); const lateEdit = await api(`/orders/${checkOrderId}/checks`, { method: 'PATCH', token: userToken, body: { checks: [checkPayload(1000, 10)] }, }); check('ویرایش چک بعد از تحویل رد می‌شود', lateEdit.status === 400, `status=${lateEdit.status}`); const lateReceipt = await api(`/orders/${checkOrderId}/cash-receipt`, { method: 'POST', token: userToken, body: { amount: 1000, transferDate: isoInDays(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); });