// اختبار تسعير عبر 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); });