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);
});
+99
View File
@@ -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); });
+104
View File
@@ -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);
});
+395
View File
@@ -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);
});
+130
View File
@@ -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); });
+128
View File
@@ -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);
});