feat: نقطة الرحلة المعلّقة للتقييم + اختبار E2E شامل
نقطة التقييم عند الفتح (طلب المالك): GET /trips/rating/pending يرجع أحدث رحلة منتهية لم يقيّمها المستخدم (سائقاً أو راكباً) + العدد. التطبيق يناديها عند كل فتح ليفرض شاشة التقييم قبل رحلة جديدة. TripsService. finishedForParticipant يجلب المنتهية، و RatingsService.pendingRating يطرح المقيَّمة باستعلام واحد. scripts/e2e-test.mjs: اختبار شامل متكامل لكل وظائف الباك إند — - تسجيل الدخول/الحساب + تطبيع الهاتف (نفس الرقم بصيغتين = حساب واحد) - إعداد السائق: تقديم + ملف (رقم وطني مشفّر) + مركبة + اعتماد + إعادة دخول للحصول على دور driver في التوكن - الرصيد التشغيلي (مكافأة التسجيل) + عدم الحجب - اتصال السائق ورفع الموقع (Redis) + المطابقة + /trips/available - طلب رحلة (تسعير + عرض) + كل الحالات (assigned→...→completed→paid) - WebSocket: اتصال JWT + رصد trip:update (اختياري، يتخطّى إن غاب socket.io-client) - خصم العمولة من الرصيد التشغيلي عند الإنهاء - تسوية المحفظة عند paid (خصم الأجرة من الراكب) - التقييم: رحلة معلّقة → تقييم الطرفين → تُمسح المعلّقة + منع التكرار - السحب بـOTP خطوتين (طلب بلا خصم → تأكيد بالرمز → حجز) أوامر التشغيل في docs/15 (مع/بلا WebSocket). يتطلّب OTP_DEV_MODE=true. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a58f611063
commit
feb2a9663d
@@ -0,0 +1,300 @@
|
||||
// اختبار شامل متكامل لكل وظائف الباك إند (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));
|
||||
const uniqPhone = (p) => `${p}${String(Date.now()).slice(-6)}`;
|
||||
|
||||
// ---- اختبار 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 = uniqPhone('07900');
|
||||
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 = uniqPhone('07955');
|
||||
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: uniqPhone('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: uniqPhone('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);
|
||||
|
||||
// ═══ 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);
|
||||
});
|
||||
Reference in New Issue
Block a user