chore: أرشفة باك إند NestJS إلى backend-archive

قرار المالك 2026-07-27: الباك إند المعتمد صار باك إند سيرو PHP، ويُنقل
إلى هذا المستودع كنسخة جديدة باسم «انطلق» على api.intaleqapp.com.

backend/ → backend-archive/ (305 ملفاً، إعادة تسمية بلا تعديل محتوى)
+ README-ARCHIVE.md يوضّح سبب الأرشفة وسبب عدم الحذف: scripts/e2e-test.mjs
  هو أدق توثيق سلوكي لدورة الرحلة كاملة، ويبقى مرجعاً عند بناء الوحدات
  الناقصة في باك إند PHP (المحفظتان · الاستحقاقات · محرّك التسعير · الوحدات).

مسارات backend/ في docs/*.md لم تُعدَّل عمداً — سجلّ تاريخي.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-27 04:54:35 +03:00
co-authored by Claude Opus 5
parent 75b80bd299
commit fff76c949f
307 changed files with 47 additions and 0 deletions
+314
View File
@@ -0,0 +1,314 @@
// اختبار شامل متكامل لكل وظائف الباك إند (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);
});