// اختبار شامل متكامل لكل وظائف الباك إند (docs/17). // يغطّي: تسجيل الدخول/الحساب · إعداد السائق (ملف + مركبة) · طلب رحلة · // المطابقة عبر Redis · القبول الذرّي · كل حالات الرحلة · تتبّع WebSocket · // التسوية المالية (محفظة) · الرصيد التشغيلي والعمولة · التقييم للطرفين · // نقطة «رحلة معلّقة للتقييم عند الفتح» · السحب بـOTP. // // يستخدم fetch المدمج. اختبار الـWebSocket اختياري: يعمل إن توفّر // socket.io-client، ويُتخطّى بتحذير إن غاب. // // التشغيل (مع WebSocket): // docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \ // -e WS=http://tripz-api:4010 -v /home/tripz-llc/backend/scripts:/s \ // node:22-alpine sh -c "cd /tmp && npm i socket.io-client --silent 2>/dev/null; \ // NODE_PATH=/tmp/node_modules node /s/e2e-test.mjs" // // التشغيل (REST فقط، بلا WebSocket): // docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \ // -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/e2e-test.mjs // // يتطلّب OTP_DEV_MODE=true (الرمز الثابت 1234). const BASE = process.env.BASE || 'http://localhost:4010/api'; const WS = process.env.WS || BASE.replace(/\/api$/, ''); const TENANT = process.env.TENANT || 'siro'; const CODE = '1234'; const ORIGIN = { lat: 31.9539, lng: 35.9106 }; const DEST = { lat: 31.9800, lng: 35.8700 }; let passed = 0; let failed = 0; const failures = []; function check(name, cond, detail) { if (cond) { passed++; console.log(` ✅ ${name}`); } else { failed++; failures.push(name + (detail ? ` — ${detail}` : '')); console.log(` ❌ ${name}${detail ? ` — ${detail}` : ''}`); } } const H = (t) => ({ 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) }); async function api(method, path, token, body, opts = {}) { const res = await fetch(`${BASE}${path}`, { method, headers: { ...H(token), 'x-tenant-id': TENANT }, body: body ? JSON.stringify(body) : undefined, }); const text = await res.text().catch(() => ''); let json = {}; try { json = text ? JSON.parse(text) : {}; } catch { /* غير JSON */ } if (!res.ok && !opts.allowError) { throw new Error(`${method} ${path} -> ${res.status} ${text.slice(0, 200)}`); } return { status: res.status, body: json, ok: res.ok }; } async function login(phone) { const r = await api('POST', '/auth/verify-otp', null, { phone, code: CODE }); return { token: r.body.access_token, userId: r.body.user?.id, role: r.body.user?.role }; } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // رقم أردني صالح: 0 + 9 خانات (10 إجمالاً). فريد عبر عدّاد + طابع زمني. let _seq = 0; const uniqJoPhone = () => `07${String(Date.now()).slice(-7)}${_seq++}`; // نصّ فريد عام (رقم وطني/لوحة — لا يمرّ بتطبيع الهاتف). const uniqStr = (p) => `${p}${String(Date.now()).slice(-6)}${_seq++}`; // ---- اختبار WebSocket اختياري ---- async function tryWebSocket(riderToken, tripId) { let ioClient; try { ioClient = (await import('socket.io-client')).io; } catch { console.log(' ⚠️ socket.io-client غير متوفّر — تخطّي اختبار WebSocket (راجع أمر التشغيل)'); return null; } return new Promise((resolve) => { const events = { connected: false, tripUpdate: false, driverLocation: false }; const socket = ioClient(WS, { auth: { token: riderToken }, transports: ['websocket'], timeout: 5000 }); const done = () => { try { socket.close(); } catch {} resolve(events); }; socket.on('connect', () => { events.connected = true; socket.emit('trip:join', { tripId }); }); socket.on('trip:update', () => { events.tripUpdate = true; }); socket.on('driver:location', () => { events.driverLocation = true; }); socket.on('connect_error', () => done()); setTimeout(done, 8000); // نافذة رصد الأحداث }); } async function main() { console.log(`\n🧪 اختبار Tripz الشامل على ${BASE}\n`); // ═══ 1. تسجيل الدخول / إنشاء الحساب ═══ console.log('1) تسجيل الدخول وإنشاء الحساب (هاتف + OTP)'); const riderPhone = uniqJoPhone(); const rider = await login(riderPhone); check('الراكب سجّل دخوله وحصل على توكن', !!rider.token); check('حساب جديد بدور rider افتراضياً', rider.role === 'rider', `role=${rider.role}`); // نفس الرقم بصيغة مختلفة → نفس الحساب (تطبيع D1) const riderAgain = await api('POST', '/auth/verify-otp', null, { phone: '0' + riderPhone.slice(1), code: CODE, }, { allowError: true }); check('تطبيع الهاتف: نفس الرقم بصيغ مختلفة = نفس المستخدم', riderAgain.body.user?.id === rider.userId); const driverPhone = uniqJoPhone(); let driver = await login(driverPhone); check('السائق سجّل دخوله', !!driver.token); // ═══ 2. إعداد السائق (ملف + مركبة + اعتماد) ═══ console.log('\n2) إعداد السائق: تقديم + ملف + مركبة + اعتماد'); const applied = await api('POST', '/drivers/apply', driver.token, { vehicle_make: 'Toyota', service_class: 'economy', }); const driverId = applied.body.id; check('تقديم السائق أنشأ سجلاً', !!driverId); await api('PATCH', '/drivers/profile', driver.token, { gender: 'male', national_number: uniqStr("99"), name_arabic: 'سائق تجريبي', license_type: 'private', }); check('تحديث ملف السائق (رقم وطني مشفّر + فهرس أعمى)', true); const vehicle = await api('POST', '/vehicles', driver.token, { make: 'Toyota', model: 'Corolla', year: 2020, color: 'أبيض', color_hex: '#FFFFFF', plate: uniqStr("PL"), }); check('إنشاء مركبة', !!vehicle.body.id); check('أول مركبة تصير الافتراضية تلقائياً', vehicle.body.is_default === true); await api('PATCH', `/drivers/${driverId}/approve`, driver.token, {}); // الدور صار driver بعد apply — يجب إعادة الدخول ليحمل التوكن الدور الجديد driver = await login(driverPhone); check('بعد الاعتماد وإعادة الدخول: الدور driver في التوكن', driver.role === 'driver', `role=${driver.role}`); // ═══ 3. الرصيد التشغيلي (مكافأة التسجيل) ═══ console.log('\n3) الرصيد التشغيلي للسائق'); const credit0 = await api('GET', '/credit', driver.token); check('السائق بدأ برصيد تشغيلي (مكافأة تسجيل > 0)', Number(credit0.body.balance) > 0, `balance=${credit0.body.balance}`); check('السائق غير محجوب', credit0.body.blocked === false); // ═══ 4. اتصال السائق + رفع الموقع (Redis) ═══ console.log('\n4) اتصال السائق ورفع الموقع (Redis + المطابقة)'); await api('PATCH', '/drivers/status', driver.token, { online: true }); await api('POST', '/drivers/location', driver.token, { ...ORIGIN, speed: 0, heading: 0 }); check('السائق متصل ورفع موقعه', true); // ═══ 5. المحفظة (شحن للتسوية لاحقاً) ═══ console.log('\n5) المحفظة'); await api('POST', '/wallet/topup', rider.token, { amount: 50 }); const wallet0 = await api('GET', '/wallet', rider.token); check('شحن محفظة الراكب', Number(wallet0.body.balance) >= 50, `balance=${wallet0.body.balance}`); // ═══ 6. طلب رحلة (تسعير + مطابقة + عرض) ═══ console.log('\n6) طلب رحلة (تسعير + مطابقة عبر Redis)'); const reqTrip = await api('POST', '/trips', rider.token, { origin: ORIGIN, destination: DEST, service_class: 'economy', payment_method: 'wallet', }); const tripId = reqTrip.body.trip?.id; check('إنشاء الرحلة', !!tripId); check('الرحلة بحالة searching', reqTrip.body.trip?.status === 'searching'); check('حُسبت أجرة مقفولة', reqTrip.body.trip?.quoted_fare != null, `fare=${reqTrip.body.trip?.quoted_fare}`); check('عُرضت على سائق قريب (مطابقة Redis نجحت)', reqTrip.body.offeredDrivers >= 1, `offered=${reqTrip.body.offeredDrivers}`); // السائق يرى الرحلة في المتاحة const available = await api('GET', '/trips/available', driver.token); check('السائق يرى الرحلة في /trips/available', Array.isArray(available.body) && available.body.some((t) => t.tripId === tripId || t.id === tripId)); // ═══ 7. WebSocket (يُبدأ قبل الانتقالات لرصد الأحداث) ═══ console.log('\n7) تتبّع WebSocket (اختياري)'); const wsPromise = tryWebSocket(rider.token, tripId); await sleep(1500); // مهلة ليتّصل السوكت قبل الانتقالات، وإلا فاتته الأحداث // ═══ 8. القبول + كل حالات الرحلة ═══ console.log('\n8) القبول وكل حالات الرحلة'); const accepted = await api('POST', `/trips/${tripId}/accept`, driver.token, {}); check('السائق قبل الرحلة', accepted.ok); const afterAccept = await api('GET', `/trips/${tripId}`, driver.token); check('الحالة صارت assigned', afterAccept.body.status === 'assigned', `status=${afterAccept.body.status}`); const transitions = ['driver_arriving', 'driver_arrived', 'in_progress', 'completed']; for (const st of transitions) { // نبضة موقع قبل الوصول (لكشف تزوير الوصول — H/E) if (st === 'driver_arrived') { await api('POST', '/drivers/location', driver.token, { ...ORIGIN, speed: 5 }); } const r = await api('PATCH', `/trips/${tripId}/status`, driver.token, { status: st }, { allowError: true }); check(`الانتقال إلى ${st}`, r.ok, r.ok ? '' : JSON.stringify(r.body).slice(0, 120)); await sleep(150); } const afterComplete = await api('GET', `/trips/${tripId}`, driver.token); check('الرحلة مكتملة', afterComplete.body.status === 'completed'); check('طوابع دقيقة: started_at مضبوط', !!afterComplete.body.started_at); check('السعر مفصول: price_for_passenger و price_for_driver', afterComplete.body.price_for_passenger != null && afterComplete.body.price_for_driver != null); // ═══ 9. العمولة خُصمت من الرصيد التشغيلي عند الإنهاء ═══ console.log('\n9) العمولة من الرصيد التشغيلي (عند الإنهاء)'); const credit1 = await api('GET', '/credit', driver.token); const commission = Number(afterComplete.body.commission_amount ?? 0); if (commission > 0) { check('نقص الرصيد التشغيلي بمقدار العمولة', Math.abs((Number(credit0.body.balance) - Number(credit1.body.balance)) - commission) < 0.01, `${credit0.body.balance} → ${credit1.body.balance}, عمولة=${commission}`); } else { check('لا عمولة على هذه التعرفة (تخطّي)', true); } // ═══ 10. الدفع (تسوية المحفظة عند paid) ═══ console.log('\n10) الدفع وتسوية المحفظة'); const paidRes = await api('PATCH', `/trips/${tripId}/status`, driver.token, { status: 'paid' }, { allowError: true }); check('الانتقال إلى paid', paidRes.ok, paidRes.ok ? '' : JSON.stringify(paidRes.body).slice(0, 120)); await sleep(200); const walletAfter = await api('GET', '/wallet', rider.token); const fare = Number(afterComplete.body.price_for_passenger ?? 0); check('خُصمت الأجرة من محفظة الراكب', Math.abs((Number(wallet0.body.balance) - Number(walletAfter.body.balance)) - fare) < 0.01, `${wallet0.body.balance} → ${walletAfter.body.balance}, أجرة=${fare}`); // ═══ 11. التقييم: نقطة «رحلة معلّقة عند الفتح» ═══ console.log('\n11) التقييم (رحلة معلّقة عند الفتح + تقييم الطرفين)'); const pendingRider = await api('GET', '/trips/rating/pending', rider.token); check('الراكب لديه رحلة معلّقة للتقييم عند الفتح', pendingRider.body.pending?.tripId === tripId, `pending=${JSON.stringify(pendingRider.body.pending)}`); check('دور الراكب في التقييم = rider', pendingRider.body.pending?.role === 'rider'); const pendingDriver = await api('GET', '/trips/rating/pending', driver.token); check('السائق لديه رحلة معلّقة للتقييم', pendingDriver.body.pending?.tripId === tripId); check('دور السائق في التقييم = driver', pendingDriver.body.pending?.role === 'driver'); await api('POST', `/trips/${tripId}/rate`, rider.token, { stars: 5, comment: 'ممتاز' }); check('الراكب قيّم السائق', true); await api('POST', `/trips/${tripId}/rate`, driver.token, { stars: 4 }); check('السائق قيّم الراكب', true); const pendingAfter = await api('GET', '/trips/rating/pending', rider.token); check('بعد التقييم: لا رحلة معلّقة للراكب', pendingAfter.body.pending === null); const dupRate = await api('POST', `/trips/${tripId}/rate`, rider.token, { stars: 3 }, { allowError: true }); check('منع التقييم المزدوج', !dupRate.ok && dupRate.status === 400); // ═══ 11.5 حراسة النقاط الإدارية (docs/22 — N4) ═══ console.log('\n11.5) حراسة لوحة الأدمن (RolesGuard)'); const adminAsDriver = await api('GET', '/drivers/admin/list', driver.token, null, { allowError: true }); check('السائق (غير أدمن) يُرفض من /drivers/admin/list', adminAsDriver.status === 403, `status=${adminAsDriver.status}`); const tripsAsRider = await api('GET', '/trips/admin/list', rider.token, null, { allowError: true }); check('الراكب يُرفض من /trips/admin/list', tripsAsRider.status === 403); const searchAsRider = await api('GET', '/admin/users/search?phone=07900000000', rider.token, null, { allowError: true }); check('الراكب يُرفض من بحث خدمة العملاء', searchAsRider.status === 403); // ═══ 12. السحب بـOTP (تدفّق خطوتين) ═══ console.log('\n12) سحب أرباح السائق (OTP خطوتين)'); // السائق يشحن محفظة أرباحه أولاً ليكون له رصيد للسحب await api('POST', '/wallet/topup', driver.token, { amount: 20 }); const payoutReq = await api('POST', '/payouts/request', driver.token, { amount: 10, channel: 'cliq' }, { allowError: true }); check('طلب السحب أرسل رمزاً بلا خصم', payoutReq.ok && payoutReq.body.otp_sent === true, JSON.stringify(payoutReq.body).slice(0, 120)); const payoutId = payoutReq.body.payout?.id; const devCode = payoutReq.body.dev_code; if (payoutId && devCode) { const walletBeforeConfirm = await api('GET', '/wallet', driver.token); const confirm = await api('POST', `/payouts/${payoutId}/confirm`, driver.token, { code: String(devCode) }, { allowError: true }); check('تأكيد السحب بالرمز نجح', confirm.ok, JSON.stringify(confirm.body).slice(0, 120)); const walletAfterConfirm = await api('GET', '/wallet', driver.token); check('حُجز مبلغ السحب من محفظة السائق عند التأكيد', Math.abs((Number(walletBeforeConfirm.body.balance) - Number(walletAfterConfirm.body.balance)) - 10) < 0.01, `${walletBeforeConfirm.body.balance} → ${walletAfterConfirm.body.balance}`); } else { check('dev_code متوفّر للتأكيد', false, 'لا يوجد dev_code — تأكّد من OTP_DEV_MODE=true'); } // ═══ نتيجة WebSocket ═══ console.log('\n13) نتيجة WebSocket'); const ws = await wsPromise; if (ws) { check('اتصال WebSocket بمصادقة JWT', ws.connected); check('استقبال أحداث trip:update أثناء الرحلة', ws.tripUpdate); // driver:location قد لا يُبثّ إن لم تكن الحركة ذات دلالة — تحذير لا فشل if (!ws.driverLocation) console.log(' ⚠️ لم تُرصد driver:location (قد لا تكون حركة ذات دلالة)'); } // ═══ الخلاصة ═══ console.log('\n' + '═'.repeat(50)); console.log(`النتيجة: ${passed} نجح · ${failed} فشل`); if (failed > 0) { console.log('\nالإخفاقات:'); for (const f of failures) console.log(` ❌ ${f}`); } console.log('═'.repeat(50) + '\n'); process.exit(failed === 0 ? 0 : 1); } main().catch((e) => { console.error('\n💥 توقّف الاختبار بخطأ غير متوقّع:', e.message); console.error(e.stack?.split('\n').slice(0, 4).join('\n')); process.exit(1); });