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:
co-authored by
Claude Opus 5
parent
75b80bd299
commit
fff76c949f
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// اختبار تحمّل: يشغّل دورة رحلة كاملة (طلب→قبول→حالات→دفع) بالتوازي ويقيس القدرة.
|
||||
// يستخدم fetch المدمج (Node 18+) — بلا تبعيات. يغطّي: تعرفة + خرائط + مطابقة GEO
|
||||
// + آلة الحالة + كتابة القاعدة + بث السوكت (خادمياً).
|
||||
//
|
||||
// التشغيل (حاوية node مؤقتة على شبكة tripz):
|
||||
// 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/loadtest.mjs 300 30
|
||||
// الوسائط: <إجمالي الرحلات> <التزامن>
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:4010/api';
|
||||
const TENANT = process.env.TENANT || 'siro';
|
||||
const TOTAL = parseInt(process.argv[2] || '200', 10);
|
||||
const CONC = parseInt(process.argv[3] || '20', 10);
|
||||
const CODE = '1234';
|
||||
const ORIGIN = { lat: 31.9539, lng: 35.9106 };
|
||||
|
||||
const H = (t) => ({ 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) });
|
||||
const jitter = (n) => n + (Math.random() - 0.5) * 0.01;
|
||||
|
||||
async function api(method, path, token, body) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers: { ...H(token), 'x-tenant-id': TENANT },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status} ${await res.text().catch(() => '')}`);
|
||||
return res.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
async function token(phone) {
|
||||
const r = await api('POST', '/auth/verify-otp', null, { phone, code: CODE });
|
||||
return r.access_token;
|
||||
}
|
||||
|
||||
async function setupDriver(i) {
|
||||
const t = await token(`0795${String(100000 + i).slice(-6)}`);
|
||||
const d = await api('POST', '/drivers/apply', t, { vehicle_make: 'LoadTest', service_class: 'economy' });
|
||||
await api('PATCH', `/drivers/${d.id}/approve`, t, {});
|
||||
await api('PATCH', '/drivers/status', t, { online: true });
|
||||
await api('POST', '/drivers/location', t, { lat: jitter(ORIGIN.lat), lng: jitter(ORIGIN.lng) });
|
||||
return t;
|
||||
}
|
||||
|
||||
async function oneTrip(riderTok, driverTok) {
|
||||
const start = Date.now();
|
||||
const { trip } = await api('POST', '/trips', riderTok, {
|
||||
origin: { lat: jitter(ORIGIN.lat), lng: jitter(ORIGIN.lng) },
|
||||
destination: { lat: jitter(ORIGIN.lat + 0.03), lng: jitter(ORIGIN.lng + 0.03) },
|
||||
service_class: 'economy',
|
||||
});
|
||||
await api('POST', `/trips/${trip.id}/accept`, driverTok, {});
|
||||
for (const s of ['driver_arriving', 'driver_arrived', 'in_progress', 'completed', 'paid']) {
|
||||
await api('PATCH', `/trips/${trip.id}/status`, driverTok, { status: s });
|
||||
}
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Setup ${CONC} drivers+riders...`);
|
||||
const drivers = [], riders = [];
|
||||
for (let i = 0; i < CONC; i++) {
|
||||
drivers.push(await setupDriver(i));
|
||||
riders.push(await token(`0796${String(100000 + i).slice(-6)}`));
|
||||
}
|
||||
|
||||
console.log(`Running ${TOTAL} trips @ concurrency ${CONC}...`);
|
||||
let done = 0, errors = 0;
|
||||
const lats = [];
|
||||
const t0 = Date.now();
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: CONC }, (_, w) =>
|
||||
(async () => {
|
||||
while (true) {
|
||||
const idx = done++;
|
||||
if (idx >= TOTAL) break;
|
||||
try {
|
||||
lats.push(await oneTrip(riders[w], drivers[w]));
|
||||
} catch (e) {
|
||||
errors++;
|
||||
if (errors <= 5) console.error('ERR:', e.message);
|
||||
}
|
||||
}
|
||||
})(),
|
||||
),
|
||||
);
|
||||
|
||||
const secs = (Date.now() - t0) / 1000;
|
||||
lats.sort((a, b) => a - b);
|
||||
const pct = (p) => lats[Math.min(lats.length - 1, Math.floor((p / 100) * lats.length))] || 0;
|
||||
const ok = lats.length;
|
||||
console.log('\n===== النتائج =====');
|
||||
console.log(`رحلات ناجحة: ${ok} / ${TOTAL} · أخطاء: ${errors}`);
|
||||
console.log(`الزمن: ${secs.toFixed(1)}s · الإنتاجية: ${(ok / secs).toFixed(1)} رحلة/ث (~${Math.round((ok / secs) * 86400).toLocaleString()} رحلة/يوم)`);
|
||||
console.log(`زمن الرحلة الكاملة (ms): p50=${pct(50)} · p95=${pct(95)} · max=${pct(100)}`);
|
||||
console.log('ملاحظة: كل رحلة = 7 نداءات API (طلب+قبول+5 حالات).');
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,104 @@
|
||||
// اختبار مسار المواقع (docs/17 — H): يثبت أن العتبات تعمل على Redis حقيقي،
|
||||
// وأن سائقاً واقفاً لا يكلّف شيئاً. يستخدم fetch المدمج — بلا تبعيات.
|
||||
//
|
||||
// يعتمد أن POST /drivers/location يرجع { ok, significant } — فالعتبة تُتحقَّق
|
||||
// عبر الـAPI الحقيقي بلا حاجة للنظر في القاعدة.
|
||||
//
|
||||
// التشغيل:
|
||||
// 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/location-test.mjs
|
||||
//
|
||||
// بعده تحقَّق من كتابة الـworker (بعد ~6 ثوانٍ):
|
||||
// SELECT count(*) FROM tripz_driver_tracks; -- يجب أن يساوي عدد «ذات الدلالة»
|
||||
// SELECT loc_status, speed, loc_updated_at FROM tripz_drivers WHERE loc_updated_at IS NOT NULL;
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:4010/api';
|
||||
const TENANT = process.env.TENANT || 'siro';
|
||||
const CODE = '1234';
|
||||
|
||||
const STATIONARY_PINGS = 10;
|
||||
const MOVING_PINGS = 8;
|
||||
const ORIGIN = { lat: 31.9539, lng: 35.9106 };
|
||||
const metersToLat = (m) => m / 111_320;
|
||||
|
||||
const H = (t) => ({ 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) });
|
||||
|
||||
async function api(method, path, token, body) {
|
||||
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) throw new Error(`${method} ${path} -> ${res.status} ${text.slice(0, 200)}`);
|
||||
return json;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const phone = `0797${String(Date.now()).slice(-6)}`;
|
||||
const { access_token: t } = await api('POST', '/auth/verify-otp', null, { phone, code: CODE });
|
||||
|
||||
const driver = await api('POST', '/drivers/apply', t, {
|
||||
vehicle_make: 'LocationTest',
|
||||
service_class: 'economy',
|
||||
});
|
||||
await api('PATCH', `/drivers/${driver.id}/approve`, t, {});
|
||||
await api('PATCH', '/drivers/status', t, { online: true });
|
||||
console.log(`سائق: ${driver.id} (${phone}) — متصل\n`);
|
||||
|
||||
// 1) واقف مكانه: الأولى ذات دلالة، والباقي يجب أن تُهمَل كلها.
|
||||
let stationarySignificant = 0;
|
||||
for (let i = 0; i < STATIONARY_PINGS; i++) {
|
||||
const r = await api('POST', '/drivers/location', t, { ...ORIGIN, speed: 0, heading: 0 });
|
||||
if (r.significant) stationarySignificant++;
|
||||
}
|
||||
console.log(`واقف : ${STATIONARY_PINGS} نبضة → ${stationarySignificant} ذات دلالة (المتوقع 1: الأولى فقط)`);
|
||||
|
||||
// 2) متحرّك 30م كل نبضة: كلها يجب أن تكون ذات دلالة.
|
||||
let movingSignificant = 0;
|
||||
for (let i = 1; i <= MOVING_PINGS; i++) {
|
||||
const r = await api('POST', '/drivers/location', t, {
|
||||
lat: ORIGIN.lat + metersToLat(30 * i),
|
||||
lng: ORIGIN.lng,
|
||||
speed: 40,
|
||||
heading: 0,
|
||||
});
|
||||
if (r.significant) movingSignificant++;
|
||||
}
|
||||
console.log(`متحرّك : ${MOVING_PINGS} نبضة × 30م → ${movingSignificant} ذات دلالة (المتوقع ${MOVING_PINGS})`);
|
||||
|
||||
const totalPings = STATIONARY_PINGS + MOVING_PINGS;
|
||||
const totalSignificant = stationarySignificant + movingSignificant;
|
||||
const saved = Math.round(((totalPings - totalSignificant) / totalPings) * 100);
|
||||
|
||||
console.log('');
|
||||
console.log(`الإجمالي: ${totalPings} نبضة → ${totalSignificant} فقط لمست Redis/الفهرس`);
|
||||
console.log(`أي أن ${saved}% من النبضات كلّفت أمر EXPIRE واحداً لا أكثر.`);
|
||||
console.log('');
|
||||
|
||||
const checks = [
|
||||
['النبضة الأولى ذات دلالة', stationarySignificant >= 1],
|
||||
['الوقوف لا يُنتج نبضات ذات دلالة', stationarySignificant === 1],
|
||||
['كل تحرّك 30م ذو دلالة', movingSignificant === MOVING_PINGS],
|
||||
];
|
||||
let failed = 0;
|
||||
for (const [name, pass] of checks) {
|
||||
console.log(`${pass ? '✅' : '❌'} ${name}`);
|
||||
if (!pass) failed++;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(`بعد ~6 ثوانٍ يجب أن يكون الـworker كتب ${totalSignificant} نقطة مسار. تحقَّق:`);
|
||||
console.log(` SELECT count(*) FROM tripz_driver_tracks WHERE driver_id = '${driver.id}';`);
|
||||
console.log(` SELECT loc_status, speed, loc_updated_at FROM tripz_drivers WHERE id = '${driver.id}';`);
|
||||
console.log('');
|
||||
console.log(failed === 0 ? '✅ العتبات تعمل.' : `❌ ${failed} محكّ فشل.`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('خطأ:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
// اختبار تسعير عبر API حقيقي — يطلب رحلة لكل فئة خدمة في كل بلد، ويقرأ
|
||||
// السعر والمسافة والزمن اللذين رجعهما محرّك التعرفة الفعلي (لا محاكاة).
|
||||
//
|
||||
// ينشئ مستأجرَي اختبار لمصر وسوريا إن لم يكونا موجودين (الأردن = "siro"
|
||||
// القائم أصلاً)، ثم يطلب 3 رحلات نموذجية (قصيرة/متوسطة/طويلة) لكل فئة
|
||||
// خدمة في كل بلد، ويطبع جدول: البلد × الفئة × الرحلة → السعر.
|
||||
//
|
||||
// التشغيل (من داخل شبكة الحاوية، بعد نجاح البناء):
|
||||
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
|
||||
// -e PLATFORM_SECRET="$(grep -E '^PLATFORM_SECRET=' .env | cut -d= -f2-)" \
|
||||
// -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/pricing-test.mjs
|
||||
//
|
||||
// يتطلّب `PLATFORM_SECRET` فقط — **لا يحتاج `OTP_DEV_MODE`**: يقرأ رمز
|
||||
// التحقّق من Redis مباشرةً. الوضع التطويري ممنوع على الإنتاج بحارس في
|
||||
// `main.ts`، وإضعاف ذلك الحارس لأجل اختبار مقايضة خاسرة.
|
||||
|
||||
import net from 'node:net';
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:4010/api';
|
||||
const PLATFORM_SECRET = process.env.PLATFORM_SECRET || '';
|
||||
|
||||
// يقرأ رمز OTP من Redis مباشرةً بدل `OTP_DEV_MODE`. الوضع التطويري ممنوع
|
||||
// على الإنتاج بحارس في `main.ts` (`OTP_DEV_MODE=true` + `NODE_ENV=production`
|
||||
// = رفض الإقلاع)، وهو حارس صحيح: رمز ثابت على سيرفر متاح للإنترنت يعني أن
|
||||
// معرفة رقم هاتف تكفي لدخول أي حساب. فنقرأ الرمز الحقيقي بدل إضعاف الحارس.
|
||||
const REDIS_HOST = process.env.REDIS_HOST || 'tripz-redis';
|
||||
const REDIS_PORT = parseInt(process.env.REDIS_PORT || '6379', 10);
|
||||
const REDIS_DB = parseInt(process.env.REDIS_DB || '3', 10);
|
||||
const REDIS_KEY_PREFIX = process.env.REDIS_KEY_PREFIX || 'tripz:';
|
||||
|
||||
if (!PLATFORM_SECRET) {
|
||||
console.error('❌ PLATFORM_SECRET غير مضبوط — لا يمكن إنشاء مستأجرَي الاختبار.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// الفئات المتوقَّع أن تُسعَّر. **`fixed` ليست منها**: أُخرجت من الزرع عمداً
|
||||
// (سعر ثابت بلا جدول خطوط = رقم واحد لكل الوجهات)، فالمتوقَّع منها رفض 400
|
||||
// لا سعر — ويُتحقَّق من ذلك صراحةً في `REJECTED_CLASSES`.
|
||||
const SERVICE_CLASSES = [
|
||||
'saver', 'delivery', 'economy', 'electric', 'lady', 'comfort', 'vip', 'van',
|
||||
];
|
||||
|
||||
/** فئات يجب أن يرفضها الخادم — الرفض هنا نجاحٌ لا فشل. */
|
||||
const REJECTED_CLASSES = ['fixed'];
|
||||
|
||||
/**
|
||||
* رحلات نموذجية داخل الزرقاء/عمّان (إحداثيات قابلة لإعادة الاستعمال في
|
||||
* أي بلد — الفارق بين البلدان هو التعرفة لا الجغرافيا؛ المسافة والزمن
|
||||
* يأتيان من محرّك المسارات الحقيقي `MapsService.route`، لا من افتراض).
|
||||
*
|
||||
* النافذة الزمنية الفعلية تُحدَّد بلحظة تشغيل السكربت فعلاً — لا حقلاً في
|
||||
* الطلب (`TariffEngine.quote` يستعمل `new Date()` الحقيقي). اختبار الذروة
|
||||
* مقابل النافذة العادية مغطّى بوحدات على `tariff-seed.spec.ts`؛ هنا الهدف
|
||||
* إثبات أن **الـAPI الحي** يرجع سعراً معقولاً بمسافة/زمن حقيقيَّين من انطلق.
|
||||
*/
|
||||
const RIDES = [
|
||||
{
|
||||
label: 'قصيرة (داخل الزرقاء)',
|
||||
origin: { lat: 32.0728, lng: 36.0876 },
|
||||
destination: { lat: 32.0895, lng: 36.1046 },
|
||||
},
|
||||
{
|
||||
label: 'متوسطة (الزرقاء → عمّان)',
|
||||
origin: { lat: 32.0728, lng: 36.0876 },
|
||||
destination: { lat: 31.9539, lng: 35.9106 },
|
||||
},
|
||||
{
|
||||
label: 'طويلة (الزرقاء → المطار)',
|
||||
origin: { lat: 32.0728, lng: 36.0876 },
|
||||
destination: { lat: 31.7226, lng: 35.9932 },
|
||||
},
|
||||
];
|
||||
|
||||
const TENANTS = [
|
||||
{ slug: 'siro', countryPack: 'jo', currency: 'JOD', create: false }, // موجود أصلاً
|
||||
{ slug: 'tripz-test-eg', countryPack: 'eg', currency: 'EGP', create: true, name: 'Tripz Test EG' },
|
||||
{ slug: 'tripz-test-sy', countryPack: 'sy', currency: 'SYP', create: true, name: 'Tripz Test SY' },
|
||||
];
|
||||
|
||||
// ---- عميل Redis أدنى (RESP على TCP خام) ----
|
||||
// بلا `ioredis`: حاوية `node:22-alpine` عابرة بلا `node_modules`، وتثبيت
|
||||
// حزمة لكل تشغيلة تبعية شبكة لا داعي لها لأمرَين اثنين (SELECT ثم GET).
|
||||
|
||||
const respCmd = (...args) =>
|
||||
`*${args.length}\r\n` +
|
||||
args.map((a) => `$${Buffer.byteLength(a)}\r\n${a}\r\n`).join('');
|
||||
|
||||
/** يفكّ ردّاً واحداً بدءاً من `i`؛ يعيد null إن لم يكتمل بعد. */
|
||||
function parseReply(s, i) {
|
||||
const type = s[i];
|
||||
const end = s.indexOf('\r\n', i);
|
||||
if (end === -1) return null;
|
||||
const head = s.slice(i + 1, end);
|
||||
if (type === '+' || type === '-' || type === ':') {
|
||||
return { value: head, next: end + 2, error: type === '-' };
|
||||
}
|
||||
if (type === '$') {
|
||||
const len = parseInt(head, 10);
|
||||
if (len === -1) return { value: null, next: end + 2 }; // مفتاح غير موجود
|
||||
const start = end + 2;
|
||||
if (s.length < start + len + 2) return null;
|
||||
return { value: s.slice(start, start + len), next: start + len + 2 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function redisGet(key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sock = net.createConnection({ host: REDIS_HOST, port: REDIS_PORT });
|
||||
sock.setEncoding('utf8');
|
||||
const timer = setTimeout(() => {
|
||||
sock.destroy();
|
||||
reject(new Error(`redis timeout (${REDIS_HOST}:${REDIS_PORT})`));
|
||||
}, 5000);
|
||||
|
||||
sock.on('connect', () => {
|
||||
sock.write(respCmd('SELECT', String(REDIS_DB)));
|
||||
sock.write(respCmd('GET', key));
|
||||
});
|
||||
|
||||
let buf = '';
|
||||
sock.on('data', (chunk) => {
|
||||
buf += chunk;
|
||||
const first = parseReply(buf, 0); // ردّ SELECT
|
||||
if (!first) return;
|
||||
if (first.error) {
|
||||
clearTimeout(timer);
|
||||
sock.end();
|
||||
return reject(new Error(`redis SELECT failed: ${first.value}`));
|
||||
}
|
||||
const second = parseReply(buf, first.next); // ردّ GET
|
||||
if (!second) return;
|
||||
clearTimeout(timer);
|
||||
sock.end();
|
||||
resolve(second.value);
|
||||
});
|
||||
|
||||
sock.on('error', (e) => {
|
||||
clearTimeout(timer);
|
||||
reject(e);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function api(method, path, { token, tenant, body, platform } = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
if (tenant) headers['x-tenant-id'] = tenant;
|
||||
if (platform) headers['x-platform-secret'] = PLATFORM_SECRET;
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
let json = null;
|
||||
try { json = await res.json(); } catch {}
|
||||
return { status: res.status, body: json };
|
||||
}
|
||||
|
||||
async function ensureTenant(t) {
|
||||
if (!t.create) return;
|
||||
const provisioned = await api('POST', '/admin/tenants/provision', {
|
||||
platform: true,
|
||||
body: {
|
||||
name: t.name,
|
||||
slug: t.slug,
|
||||
countryPack: t.countryPack,
|
||||
plan: 'launch',
|
||||
},
|
||||
});
|
||||
if (provisioned.status >= 200 && provisioned.status < 300) {
|
||||
console.log(`✅ أُنشئ مستأجر ${t.slug} (${t.countryPack})`);
|
||||
} else if (/already taken|already exists|موجود/i.test(JSON.stringify(provisioned.body))) {
|
||||
console.log(`ℹ️ ${t.slug} موجود أصلاً`);
|
||||
} else {
|
||||
console.error(`❌ فشل إنشاء ${t.slug}:`, provisioned.status, JSON.stringify(provisioned.body));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* رقم دولي كامل صالح لكل بلد — **بلا "+"**، تماماً كما يخزّنه
|
||||
* `PhoneService.normalize` (docs/17 — D1). الطول يجب أن يطابق
|
||||
* `callingCode + nationalLength` بالضبط وإلا رُفض الرقم:
|
||||
* الأردن/سوريا 962/963 + 9 أرقام محلية، مصر 20 + 10 أرقام محلية.
|
||||
*/
|
||||
function testPhone(countryPack, seed) {
|
||||
const plans = {
|
||||
jo: { code: '962', national: 9, prefix: '79' },
|
||||
sy: { code: '963', national: 9, prefix: '93' },
|
||||
eg: { code: '20', national: 10, prefix: '10' },
|
||||
};
|
||||
const plan = plans[countryPack] ?? plans.jo;
|
||||
const remaining = plan.national - plan.prefix.length;
|
||||
const digits = String(1_000_000_000 + seed).slice(-remaining);
|
||||
return `${plan.code}${plan.prefix}${digits}`;
|
||||
}
|
||||
|
||||
async function loginRider(tenantSlug, tenantId, countryPack, seed) {
|
||||
const phone = testPhone(countryPack, seed);
|
||||
|
||||
// `send-otp` يخزّن الرمز في Redis **قبل** محاولة الإرسال، فحتى لو فشل
|
||||
// الإرسال لرقم وهمي (503) يكون الرمز محفوظاً — وهو ما نقرأه.
|
||||
let sent = await api('POST', '/auth/send-otp', { tenant: tenantSlug, body: { phone } });
|
||||
// 429: `send-otp` محدود المعدّل (10 لكل 5 دقائق). تشغيلتان متتاليتان
|
||||
// تصطدمان به، فننتظر بدل أن نُبلغ عن فشل ليس في النظام.
|
||||
for (let wait = 0; sent.status === 429 && wait < 3; wait++) {
|
||||
console.log(` ⏳ حدّ المعدّل على send-otp — انتظار 20 ثانية…`);
|
||||
await new Promise((r) => setTimeout(r, 20000));
|
||||
sent = await api('POST', '/auth/send-otp', { tenant: tenantSlug, body: { phone } });
|
||||
}
|
||||
if (sent.body?.dev_code) {
|
||||
// وضع تطويري مفعّل (بيئة غير إنتاجية) — الرمز في الاستجابة مباشرةً.
|
||||
return (await verify(tenantSlug, phone, sent.body.dev_code))?.access_token;
|
||||
}
|
||||
|
||||
// المفتاح كما يبنيه `AuthService.otpKey` + `keyPrefix` من إعداد Redis،
|
||||
// والمعرّف هو **UUID المستأجر** لا الـslug.
|
||||
const key = `${REDIS_KEY_PREFIX}otp:${tenantId}:${phone}`;
|
||||
let code;
|
||||
try {
|
||||
code = await redisGet(key);
|
||||
} catch (e) {
|
||||
console.error(` ❌ تعذّرت قراءة الرمز من Redis: ${e.message}`);
|
||||
return undefined;
|
||||
}
|
||||
if (!code) {
|
||||
console.error(
|
||||
` ❌ لا رمز في Redis للمفتاح ${key} — ` +
|
||||
`استجابة send-otp كانت ${sent.status} ${JSON.stringify(sent.body)}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const r = await verify(tenantSlug, phone, code);
|
||||
if (!r?.access_token) {
|
||||
console.error(` ❌ فشل تسجيل الدخول (${phone}): ${JSON.stringify(r)}`);
|
||||
}
|
||||
return r?.access_token;
|
||||
}
|
||||
|
||||
async function verify(tenantSlug, phone, code) {
|
||||
const r = await api('POST', '/auth/verify-otp', { tenant: tenantSlug, body: { phone, code } });
|
||||
return r.body;
|
||||
}
|
||||
|
||||
async function quoteFor(tenantSlug, token, serviceClass, ride) {
|
||||
const res = await api('POST', '/trips', {
|
||||
tenant: tenantSlug,
|
||||
token,
|
||||
body: {
|
||||
origin: ride.origin,
|
||||
destination: ride.destination,
|
||||
service_class: serviceClass,
|
||||
payment_method: 'cash',
|
||||
},
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\n=== تسعير التعرفة الافتراضية — API حقيقي (${BASE}) ===`);
|
||||
console.log(`وقت التشغيل: ${new Date().toISOString()} (UTC) — يحدّد أي نافذة تعرفة ستُختار فعلياً.\n`);
|
||||
|
||||
for (const t of TENANTS) await ensureTenant(t);
|
||||
|
||||
// مفتاح OTP في Redis يستعمل **UUID المستأجر** لا الـslug، فنجلب الخريطة مرة.
|
||||
const all = await api('GET', '/admin/tenants', { platform: true });
|
||||
const idBySlug = new Map((all.body ?? []).map((x) => [x.slug, x.id]));
|
||||
if (!idBySlug.size) {
|
||||
console.error(`❌ تعذّر جلب المستأجرين: ${all.status} ${JSON.stringify(all.body)}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
let rejectionsOk = true;
|
||||
|
||||
for (const t of TENANTS) {
|
||||
console.log(`\n--- ${t.slug} (${t.countryPack} / ${t.currency}) ---`);
|
||||
const tenantId = idBySlug.get(t.slug);
|
||||
if (!tenantId) {
|
||||
console.error(`❌ لا UUID للمستأجر ${t.slug} — تخطّي`);
|
||||
continue;
|
||||
}
|
||||
let seed = 500 + TENANTS.indexOf(t) * 100;
|
||||
const token = await loginRider(t.slug, tenantId, t.countryPack, seed++);
|
||||
if (!token) {
|
||||
console.error(`❌ تعذّر تسجيل دخول راكب اختبار لـ${t.slug} — تخطّي`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// الفئات المسحوبة يجب أن تُرفض — تسريب الـ2.5 دينار كان هنا.
|
||||
for (const serviceClass of REJECTED_CLASSES) {
|
||||
const res = await quoteFor(t.slug, token, serviceClass, RIDES[2]); // الأطول
|
||||
if (res.status === 400) {
|
||||
console.log(` ✅ ${serviceClass} مرفوضة كما يجب (400)`);
|
||||
} else {
|
||||
console.error(
|
||||
` ❌ ${serviceClass} لم تُرفض: ${res.status} — رحلة ${RIDES[2].label} بسعر ثابت تسريب.`,
|
||||
);
|
||||
rejectionsOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const ride of RIDES) {
|
||||
for (const serviceClass of SERVICE_CLASSES) {
|
||||
const res = await quoteFor(t.slug, token, serviceClass, ride);
|
||||
const trip = res.body?.trip;
|
||||
if (!trip) {
|
||||
console.error(` ❌ ${serviceClass} / ${ride.label}: ${res.status} ${JSON.stringify(res.body)}`);
|
||||
continue;
|
||||
}
|
||||
rows.push({
|
||||
tenant: t.slug,
|
||||
country: t.countryPack,
|
||||
currency: t.currency,
|
||||
ride: ride.label,
|
||||
serviceClass,
|
||||
distanceKm: trip.distance_km,
|
||||
durationMin: trip.duration_min,
|
||||
fare: trip.quoted_fare,
|
||||
tariffFound: trip.quoted_fare != null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== النتائج ===\n');
|
||||
|
||||
// **حارس العدم**: النسخة الأولى طبعت «الاختبار ناجح» بعد أن فشل تسجيل
|
||||
// الدخول وجُمع صفر صفّ — لأن كل تحقّق كان على مجموعة فارغة فمرّ فراغاً.
|
||||
// اختبار يعلن النجاح وهو لم ينفّذ شيئاً أسوأ من اختبار يفشل: الفشل يُرى.
|
||||
const expected = TENANTS.length * RIDES.length * SERVICE_CLASSES.length;
|
||||
if (rows.length === 0) {
|
||||
console.error('❌ لم يُنفَّذ ولا طلب واحد — لا نتيجة تُقرأ. راجع أخطاء تسجيل الدخول أعلاه.');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (rows.length < expected) {
|
||||
console.error(`⚠️ نُفِّذ ${rows.length} من ${expected} طلباً — التغطية ناقصة.`);
|
||||
}
|
||||
|
||||
const noQuote = rows.filter((r) => !r.tariffFound);
|
||||
if (noQuote.length) {
|
||||
console.error(`❌ ${noQuote.length} طلب بلا سعر (تعرفة مفقودة) — هذا هو العطل الذي أصلحناه، فوجوده الآن خطأ حرج:`);
|
||||
for (const r of noQuote) console.error(` ${r.tenant} / ${r.serviceClass} / ${r.ride}`);
|
||||
} else {
|
||||
console.log(`✅ كل الطلبات (${rows.length}) رجعت سعراً — لا رحلة مجانية.`);
|
||||
}
|
||||
|
||||
for (const t of TENANTS) {
|
||||
const forTenant = rows.filter((r) => r.tenant === t.slug);
|
||||
if (!forTenant.length) continue;
|
||||
console.log(`\n### ${t.slug} (${t.currency}) ###`);
|
||||
for (const ride of RIDES) {
|
||||
console.log(`\n ${ride.label}:`);
|
||||
const forRide = forTenant.filter((r) => r.ride === ride.label);
|
||||
if (forRide.length) {
|
||||
console.log(` مسافة: ${forRide[0].distanceKm} كم · زمن: ${forRide[0].durationMin} دقيقة`);
|
||||
}
|
||||
for (const cls of SERVICE_CLASSES) {
|
||||
const r = forRide.find((x) => x.serviceClass === cls);
|
||||
console.log(` ${cls.padEnd(10)} ${r ? r.fare + ' ' + t.currency : '—'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n=== التحقق: ترتيب الفئات لا ينعكس ===\n`);
|
||||
let orderOk = true;
|
||||
for (const t of TENANTS) {
|
||||
for (const ride of RIDES) {
|
||||
const forRide = rows.filter((r) => r.tenant === t.slug && r.ride === ride.label);
|
||||
const economy = forRide.find((r) => r.serviceClass === 'economy')?.fare;
|
||||
const van = forRide.find((r) => r.serviceClass === 'van')?.fare;
|
||||
if (economy != null && van != null && van <= economy) {
|
||||
orderOk = false;
|
||||
console.error(`❌ ${t.slug} / ${ride.label}: الفان (${van}) ≤ الاقتصادي (${economy})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (orderOk) console.log('✅ الفان أغلى من الاقتصادي في كل رحلة وكل بلد.');
|
||||
|
||||
// المقارنة تحتاج الفئتين معاً؛ غيابهما يعني تحقّقاً لم يقع لا تحقّقاً نجح.
|
||||
const comparable = rows.filter((r) => ['economy', 'van'].includes(r.serviceClass)).length;
|
||||
if (comparable === 0) console.error('⚠️ لا بيانات كافية للمقارنة — التحقّق أعلاه لم يقع فعلياً.');
|
||||
|
||||
const bad = noQuote.length > 0 || !orderOk || rows.length < expected || !rejectionsOk;
|
||||
process.exitCode = bad ? 1 : 0;
|
||||
console.log(bad ? '\n❌ فشل الاختبار.' : `\n✅ الاختبار ناجح (${rows.length} طلباً).`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('خطأ غير متوقع:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
// اختبار طبقة التزويد (docs/22 — N): السوبر-أدمن ينشئ مستأجراً كاملاً ويقرأ
|
||||
// مانيفست تطبيقه. يتطلّب PLATFORM_SECRET (نفس سرّ السيرفر).
|
||||
//
|
||||
// ⚠️ يتطلّب أيضاً OTP_DEV_MODE=true: قسم التعليق (10) ينادي send-otp مرّتين،
|
||||
// وبلا وضع التطوير تصير كل دورة اختبار رسالتَي واتساب مدفوعتين فعلياً.
|
||||
//
|
||||
// التشغيل:
|
||||
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
|
||||
// -e SECRET=$PLATFORM_SECRET -v /home/tripz-llc/backend/scripts:/s \
|
||||
// node:22-alpine node /s/provision-test.mjs
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:4010/api';
|
||||
const SECRET = process.env.SECRET || process.env.PLATFORM_SECRET || '';
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
const check = (n, c, d) => { c ? (passed++, console.log(` ✅ ${n}`)) : (failed++, console.log(` ❌ ${n}${d ? ` — ${d}` : ''}`)); };
|
||||
|
||||
async function api(method, path, body, secret = SECRET) {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...(secret ? { 'x-platform-secret': secret } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text().catch(() => '');
|
||||
let json = {}; try { json = text ? JSON.parse(text) : {}; } catch {}
|
||||
return { status: res.status, ok: res.ok, body: json };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\n🧪 اختبار تزويد السوبر-أدمن على ${BASE}\n`);
|
||||
if (!SECRET) { console.log(' ❌ لا PLATFORM_SECRET — مرّره عبر -e SECRET=…'); process.exit(1); }
|
||||
|
||||
// 1) الحارس: بلا سرّ يُرفض
|
||||
const noSecret = await api('GET', '/admin/tenants', null, '');
|
||||
check('نقاط السوبر-أدمن محميّة (بلا سرّ = 401)', noSecret.status === 401, `status=${noSecret.status}`);
|
||||
|
||||
const wrong = await api('GET', '/admin/tenants', null, 'wrong-secret');
|
||||
check('سرّ خاطئ يُرفض', wrong.status === 401);
|
||||
|
||||
// 2) الكتالوج
|
||||
const cat = await api('GET', '/admin/features');
|
||||
check('كتالوج الميزات ووسائل الدفع متاح', Array.isArray(cat.body.features) && Array.isArray(cat.body.payment_methods));
|
||||
|
||||
// 3) تزويد مستأجر كامل
|
||||
const slug = `test${Date.now().toString().slice(-8)}`;
|
||||
const prov = await api('POST', '/admin/tenants/provision', {
|
||||
name: 'مستأجر اختبار', slug, countryPack: 'eg', plan: 'brand',
|
||||
features: { bots: true }, paymentMethods: ['cash', 'paymob'],
|
||||
branding: { app_name: 'تطبيق الاختبار', bundle_id_android: 'com.test.rider', bundle_id_ios: 'com.test.rider' },
|
||||
});
|
||||
check('تزويد مستأجر جديد نجح', prov.ok, JSON.stringify(prov.body).slice(0, 120));
|
||||
const tenantId = prov.body.id;
|
||||
|
||||
// 4) وسائل الدفع صُفّيت للكتالوج
|
||||
const summary = await api('GET', `/admin/tenants/${tenantId}/summary`);
|
||||
check('وسائل الدفع محفوظة', Array.isArray(summary.body.payment_methods) &&
|
||||
summary.body.payment_methods.includes('paymob'), JSON.stringify(summary.body.payment_methods));
|
||||
|
||||
// 5) الاستحقاقات: الميزة المشتراة مفعّلة + الافتراض منع
|
||||
const ent = await api('GET', `/admin/tenants/${tenantId}/entitlements`);
|
||||
check('ميزة bots المشتراة مفعّلة', ent.body.features?.bots === true);
|
||||
check('ميزة غير مشتراة (transit) محجوبة افتراضاً', ent.body.features?.transit === false);
|
||||
|
||||
// 6) المانيفست جاهز لسكربت البناء
|
||||
const manifest = await api('GET', `/admin/tenants/${tenantId}/app-manifest`);
|
||||
check('المانيفست يحمل bundle ID', manifest.body.bundle_id_android === 'com.test.rider');
|
||||
check('المانيفست يحمل اسم التطبيق', manifest.body.app_name === 'تطبيق الاختبار');
|
||||
check('المانيفست يحمل الدولة والدفع', manifest.body.country_pack === 'eg' &&
|
||||
manifest.body.payment_methods.includes('paymob'));
|
||||
|
||||
// 7) slug مكرّر يُرفض
|
||||
const dup = await api('POST', '/admin/tenants/provision', { name: 'x', slug });
|
||||
check('slug مكرّر يُرفض', dup.status === 400);
|
||||
|
||||
// 8) تعديل وسائل الدفع (تصفية المخترع)
|
||||
const setPay = await api('PATCH', `/admin/tenants/${tenantId}/payment-methods`, {
|
||||
methods: ['cash', 'made_up_gateway', 'cliq'],
|
||||
});
|
||||
check('وسيلة مخترعة تُصفّى، الصحيحة تبقى',
|
||||
setPay.body.settings?.payment_methods?.includes('cliq') &&
|
||||
!setPay.body.settings?.payment_methods?.includes('made_up_gateway'));
|
||||
|
||||
// 9) النظرة الشاملة (docs/22 — N1b)
|
||||
const ov = await api('GET', '/admin/overview?days=30');
|
||||
check('النظرة الشاملة تعمل', ov.ok && typeof ov.body.totals?.tenants === 'number');
|
||||
check('المستأجر الجديد يظهر فيها', (ov.body.tenants || []).some((t) => t.slug === slug));
|
||||
check('أرقام المال أعداد لا نصوص', typeof (ov.body.totals?.gmv) === 'number' &&
|
||||
typeof (ov.body.totals?.revenue) === 'number');
|
||||
// P5: الإيراد صار من دفتر الإيراد، وعمولة الرحلات حقل منفصل عنه.
|
||||
check('الإيراد وعمولة الرحلات حقلان منفصلان',
|
||||
typeof (ov.body.totals?.trip_commission) === 'number');
|
||||
const badWindow = await api('GET', '/admin/overview?days=abc');
|
||||
check('نافذة غير صالحة تسقط للافتراضي لا تنكسر', badWindow.ok && badWindow.body.window_days === 30);
|
||||
|
||||
// 10) التعليق يسري فعلياً (docs/22 — N1a) — الحقل كان زخرفة بلا فرض
|
||||
const badStatus = await api('PATCH', `/admin/tenants/${tenantId}/status`, { status: 'whatever' });
|
||||
check('حالة مخترعة تُرفض', badStatus.status === 400);
|
||||
|
||||
const susp = await api('PATCH', `/admin/tenants/${tenantId}/status`, { status: 'suspended' });
|
||||
check('تعليق المستأجر نجح', susp.ok && susp.body.status === 'suspended');
|
||||
|
||||
// الإثبات الحقيقي: الدخول نفسه يُقطع — لا مجرّد تغيّر الحقل.
|
||||
const otpWhileSuspended = await fetch(`${BASE}/auth/send-otp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-tenant-id': slug },
|
||||
body: JSON.stringify({ phone: '01000000000' }),
|
||||
});
|
||||
check('المستأجر المعلَّق لا يستطيع حتى طلب OTP (401)', otpWhileSuspended.status === 401,
|
||||
`status=${otpWhileSuspended.status}`);
|
||||
|
||||
const back = await api('PATCH', `/admin/tenants/${tenantId}/status`, { status: 'active' });
|
||||
check('إعادة التفعيل تعمل', back.ok && back.body.status === 'active');
|
||||
|
||||
const otpAfter = await fetch(`${BASE}/auth/send-otp`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-tenant-id': slug },
|
||||
body: JSON.stringify({ phone: '01000000000' }),
|
||||
});
|
||||
// نتحقّق من 2xx تحديداً لا من «ليس 401»: لو صادف حدّ الطلبات (429) لمرّ
|
||||
// الاختبار وهو لا يثبت شيئاً عن إبطال الكاش.
|
||||
check('بعد إعادة التفعيل يعود الدخول فوراً (الكاش أُبطل)',
|
||||
otpAfter.status >= 200 && otpAfter.status < 300, `status=${otpAfter.status}`);
|
||||
|
||||
console.log('\n' + '═'.repeat(48));
|
||||
console.log(`النتيجة: ${passed} نجح · ${failed} فشل`);
|
||||
console.log('═'.repeat(48) + '\n');
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('💥', e.message); process.exit(1); });
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# دورة تحقّق واحدة: افحص البيئة → أقلِع → **انتظر الصحة فعلاً** → اختبر.
|
||||
#
|
||||
# سبب وجوده: تسلسل `up -d --build && restart && <اختبار>` كان يشغّل الاختبار
|
||||
# بينما الـapi ساقطة، فيظهر الفشل كـ`EAI_AGAIN` في الاختبار — أي في المكان
|
||||
# الخطأ تماماً. ضاعت دورتان كاملتان على تشخيص خاطئ لهذا السبب وحده.
|
||||
# البوابة هنا تمنع تكراره: لا اختبار قبل ردّ /health الفعلي.
|
||||
#
|
||||
# التشغيل من backend/: bash scripts/verify-and-test.sh
|
||||
# بلا اختبار (نشر فقط): bash scripts/verify-and-test.sh --deploy-only
|
||||
# مع إعادة بناء: bash scripts/verify-and-test.sh --build
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
BUILD=0
|
||||
DEPLOY_ONLY=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--build) BUILD=1 ;;
|
||||
--deploy-only) DEPLOY_ONLY=1 ;;
|
||||
*) echo "خيار غير معروف: $arg"; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
ENV_FILE=".env"
|
||||
FAILED=0
|
||||
|
||||
hr() { printf '%s\n' "────────────────────────────────────────────────"; }
|
||||
|
||||
# ── 1) فحص البيئة قبل أي إقلاع ───────────────────────────────────────
|
||||
hr; echo "1) فحص البيئة"
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "❌ لا يوجد $ENV_FILE"; exit 1
|
||||
fi
|
||||
|
||||
NODE_ENV_VAL=$(grep -E '^NODE_ENV=' "$ENV_FILE" | cut -d= -f2- | tr -d '"'"'"'\r ' || true)
|
||||
OTP_DEV_VAL=$(grep -E '^OTP_DEV_MODE=' "$ENV_FILE" | cut -d= -f2- | tr -d '"'"'"'\r ' || true)
|
||||
echo " NODE_ENV=${NODE_ENV_VAL:-<غير مضبوط>} OTP_DEV_MODE=${OTP_DEV_VAL:-<غير مضبوط>}"
|
||||
|
||||
# التعارض القاتل: الحارس في main.ts ينهي العملية (exit 1) لا يحذّر فقط،
|
||||
# فالحاوية تدخل حلقة إعادة تشغيل ويظهر العطل لاحقاً كفشل شبكة مضلِّل.
|
||||
if [[ "$NODE_ENV_VAL" == "production" && "$OTP_DEV_VAL" == "true" ]]; then
|
||||
echo " ⚠️ تعارض قاتل: OTP_DEV_MODE=true مع NODE_ENV=production → الـapi لن تقلع."
|
||||
echo " ↪︎ أطفئ OTP_DEV_MODE تلقائياً (الاختبار لا يحتاجه — يقرأ الرمز من Redis)."
|
||||
sed -i.bak 's/^OTP_DEV_MODE=.*/OTP_DEV_MODE=false/' "$ENV_FILE"
|
||||
echo " ✅ OTP_DEV_MODE=false (نسخة احتياطية: ${ENV_FILE}.bak)"
|
||||
fi
|
||||
|
||||
if ! grep -qE '^PLATFORM_SECRET=.+' "$ENV_FILE"; then
|
||||
echo " ❌ PLATFORM_SECRET غير مضبوط — نقاط السوبر-أدمن مغلقة والاختبار يحتاجها."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── 2) الإقلاع ───────────────────────────────────────────────────────
|
||||
hr; echo "2) الإقلاع"
|
||||
if [[ "$BUILD" == "1" ]]; then
|
||||
echo " بناء (الاختبارات تعمل داخل مرحلة البناء وتحجب النشر عند الفشل)…"
|
||||
docker compose up -d --build || { echo "❌ فشل البناء"; exit 1; }
|
||||
else
|
||||
docker compose up -d || { echo "❌ فشل الإقلاع"; exit 1; }
|
||||
fi
|
||||
|
||||
# ── 3) بوابة الصحة — لا اختبار قبلها ─────────────────────────────────
|
||||
hr; echo "3) انتظار /health"
|
||||
HEALTH_OK=0
|
||||
for i in $(seq 1 30); do
|
||||
STATUS=$(docker compose ps --format '{{.Name}} {{.Status}}' | grep '^tripz-api' || true)
|
||||
# الحاوية في حلقة إعادة تشغيل = خطأ إقلاع، والانتظار لن يصلحه.
|
||||
if [[ "$STATUS" == *Restarting* ]]; then
|
||||
echo " ❌ tripz-api في حلقة إعادة تشغيل — خطأ إقلاع، لا مشكلة شبكة."
|
||||
hr; echo "سبب السقوط (آخر أخطاء):"
|
||||
docker compose logs api --tail=200 2>/dev/null | grep -E 'ERROR|⛔|Error:' | tail -15
|
||||
exit 1
|
||||
fi
|
||||
if docker run --rm --network tripz-net curlimages/curl:latest \
|
||||
-fsS --max-time 3 http://tripz-api:4010/api/health >/dev/null 2>&1; then
|
||||
HEALTH_OK=1
|
||||
echo " ✅ الـapi تستجيب (بعد ${i} محاولة)"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [[ "$HEALTH_OK" != "1" ]]; then
|
||||
echo " ❌ لم تستجب /health خلال 60 ثانية."
|
||||
hr; docker compose ps
|
||||
hr; docker compose logs api --tail=40
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker compose ps --format 'table {{.Name}}\t{{.Status}}'
|
||||
|
||||
if [[ "$DEPLOY_ONLY" == "1" ]]; then
|
||||
hr; echo "✅ النشر سليم (بلا اختبار)."; exit 0
|
||||
fi
|
||||
|
||||
# ── 4) إبطال كاش التعرفة ─────────────────────────────────────────────
|
||||
# `getActive` تكاشي في Redis 900 ثانية، و**Redis حاوية منفصلة لا تتأثر
|
||||
# بإعادة تشغيل الـapi**. تطبيق `tariffs.sql` مباشرةً على القاعدة يتجاوز
|
||||
# التطبيق فلا يُبطل شيئاً — فبقي النظام يخدم أسعاراً قديمة عبر دورتَي نشر
|
||||
# كاملتين وهو يعلن النجاح. المسح هنا يجعل الطزاجة مضمونة بالبناء لا
|
||||
# بالتذكّر.
|
||||
hr; echo "4) إبطال كاش التعرفة"
|
||||
KEYS=$(docker compose exec -T redis redis-cli -n "${REDIS_DB:-3}" --scan --pattern 'tripz:tariff:*' 2>/dev/null | tr -d '\r')
|
||||
if [[ -n "$KEYS" ]]; then
|
||||
COUNT=$(printf '%s\n' "$KEYS" | grep -c . || true)
|
||||
printf '%s\n' "$KEYS" | xargs -r docker compose exec -T redis redis-cli -n "${REDIS_DB:-3}" DEL >/dev/null
|
||||
echo " ✅ مُسح $COUNT مفتاح تعرفة"
|
||||
else
|
||||
echo " ℹ️ لا مفاتيح تعرفة مكاشية"
|
||||
fi
|
||||
|
||||
# ── 5) اختبار التسعير ────────────────────────────────────────────────
|
||||
hr; echo "5) اختبار التسعير"
|
||||
SECRET=$(grep -E '^PLATFORM_SECRET=' "$ENV_FILE" | cut -d= -f2- | tr -d '"'"'"'\r ')
|
||||
docker run --rm --network tripz-net \
|
||||
-e BASE=http://tripz-api:4010/api \
|
||||
-e PLATFORM_SECRET="$SECRET" \
|
||||
-v "$(pwd)/scripts:/s" \
|
||||
node:22-alpine node /s/pricing-test.mjs
|
||||
FAILED=$?
|
||||
|
||||
hr
|
||||
if [[ "$FAILED" == "0" ]]; then echo "✅ تمّت الدورة بنجاح."; else echo "❌ فشل الاختبار (رمز $FAILED)."; fi
|
||||
exit $FAILED
|
||||
@@ -0,0 +1,122 @@
|
||||
// اختبار تسابق المحفظة (docs/17 — I1): يثبت أن المال لا يُفقد ولا يُخلق تحت التزامن.
|
||||
// يستخدم fetch المدمج (Node 18+) — بلا تبعيات.
|
||||
//
|
||||
// لماذا سكربت منفصل: pg-mem في اختبارات jest أحادي الخيط فيثبت صحة الـSQL فقط،
|
||||
// أما التزامن الحقيقي على نفس الصف فيحتاج Postgres حقيقياً.
|
||||
//
|
||||
// التشغيل (حاوية node مؤقتة على شبكة tripz):
|
||||
// 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/wallet-race-test.mjs 100 5
|
||||
// الوسائط: <عدد محاولات الخصم المتزامنة> <مبلغ كل خصم>
|
||||
|
||||
const BASE = process.env.BASE || 'http://localhost:4010/api';
|
||||
const TENANT = process.env.TENANT || 'siro';
|
||||
const ATTEMPTS = parseInt(process.argv[2] || '100', 10);
|
||||
const AMOUNT = parseFloat(process.argv[3] || '5');
|
||||
const CODE = '1234';
|
||||
|
||||
const H = (t) => ({ 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) });
|
||||
|
||||
async function api(method, path, token, body) {
|
||||
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 */ }
|
||||
return { ok: res.ok, status: res.status, body: json };
|
||||
}
|
||||
|
||||
async function token(phone) {
|
||||
const r = await api('POST', '/auth/verify-otp', null, { phone, code: CODE });
|
||||
if (!r.ok) throw new Error(`auth failed: ${r.status} ${JSON.stringify(r.body)}`);
|
||||
return r.body.access_token;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// مبلغ يكفي نصف المحاولات فقط — النصف الآخر *يجب* أن يُرفض.
|
||||
const expectedSuccesses = Math.floor(ATTEMPTS / 2);
|
||||
const funded = expectedSuccesses * AMOUNT;
|
||||
|
||||
const phone = `0796${String(Date.now()).slice(-6)}`;
|
||||
const t = await token(phone);
|
||||
|
||||
await api('POST', '/wallet/topup', t, { amount: funded });
|
||||
const before = await api('GET', '/wallet', t);
|
||||
const startBalance = Number(before.body.balance);
|
||||
console.log(`المحفظة: ${phone} | الرصيد الابتدائي = ${startBalance}`);
|
||||
if (startBalance !== funded) {
|
||||
console.log(`⚠️ الشحن لم يطابق المتوقع (${funded}) — أوقفت الاختبار.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// تدفّق السحب صار خطوتين (docs/17 — I4): الطلب يرسل رمزاً ولا يخصم شيئاً،
|
||||
// والخصم يقع عند التأكيد. فالسباق هنا على `confirm` لا على `request`.
|
||||
// يعتمد dev_code الذي ترجعه النقطة في وضع التطوير (OTP_DEV_MODE=true).
|
||||
console.log(`تحضير ${ATTEMPTS} طلب سحب (بلا خصم)…`);
|
||||
const requested = await Promise.all(
|
||||
Array.from({ length: ATTEMPTS }, () =>
|
||||
api('POST', '/payouts/request', t, { amount: AMOUNT, channel: 'cliq' }).catch(() => null),
|
||||
),
|
||||
);
|
||||
const pending = requested.filter((r) => r?.ok && r.body?.payout?.id && r.body?.dev_code);
|
||||
if (pending.length !== ATTEMPTS) {
|
||||
console.log(`⚠️ حُضّر ${pending.length}/${ATTEMPTS} فقط.`);
|
||||
if (pending.length === 0) {
|
||||
console.log('لا شيء لتأكيده — تأكّد أن OTP_DEV_MODE=true (يُرجع dev_code).');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// فحص وسيط: الطلب وحده يجب ألّا يمسّ الرصيد إطلاقاً.
|
||||
const mid = await api('GET', '/wallet', t);
|
||||
const midBalance = Number(mid.body.balance);
|
||||
|
||||
console.log(`إطلاق ${pending.length} تأكيد متزامن × ${AMOUNT} (يكفي ${expectedSuccesses} فقط)…`);
|
||||
const t0 = Date.now();
|
||||
const results = await Promise.all(
|
||||
pending.map((r) =>
|
||||
api('POST', `/payouts/${r.body.payout.id}/confirm`, t, {
|
||||
code: String(r.body.dev_code),
|
||||
}).catch((e) => ({ ok: false, status: 0, body: { message: String(e) } })),
|
||||
),
|
||||
);
|
||||
const elapsed = Date.now() - t0;
|
||||
|
||||
const ok = results.filter((r) => r.ok).length;
|
||||
const rejected = results.filter((r) => !r.ok).length;
|
||||
|
||||
const after = await api('GET', '/wallet', t);
|
||||
const endBalance = Number(after.body.balance);
|
||||
const spent = startBalance - endBalance;
|
||||
|
||||
console.log('');
|
||||
console.log(`نجح : ${ok} (المتوقع ${expectedSuccesses})`);
|
||||
console.log(`رُفض : ${rejected}`);
|
||||
console.log(`الرصيد : ${startBalance} → ${endBalance} (خُصم ${spent})`);
|
||||
console.log(`الزمن : ${elapsed}ms`);
|
||||
console.log('');
|
||||
|
||||
const checks = [
|
||||
['الطلب وحده لا يمسّ الرصيد (لا مال قبل إثبات الهوية)', midBalance === startBalance],
|
||||
['الرصيد لم يصبح سالباً', endBalance >= 0],
|
||||
['عدد النجاحات = ما يسمح به الرصيد', ok === expectedSuccesses],
|
||||
['المخصوم = النجاحات × المبلغ (لا مال ضائع/مخلوق)', Math.abs(spent - ok * AMOUNT) < 1e-6],
|
||||
];
|
||||
let failed = 0;
|
||||
for (const [name, pass] of checks) {
|
||||
console.log(`${pass ? '✅' : '❌'} ${name}`);
|
||||
if (!pass) failed++;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(failed === 0 ? '✅ لا سباق: المحفظة ذرّية.' : `❌ ${failed} محكّ فشل — يوجد سباق.`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('خطأ:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user