From 90c3f7143043116535044a48b4fa77d7e1eb1813 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sun, 19 Jul 2026 00:40:30 +0300 Subject: [PATCH] test: add live pricing script across all service classes and countries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit يطلب رحلة حقيقية لكل فئة خدمة (9) × 3 مسافات × 3 بلدان عبر الـAPI الحي، فالمسافة والزمن يأتيان من محرّك المسارات (انطلق) لا من افتراض. يتحقّق آلياً من أمرين كانا عطلين حقيقيين قبل المجموعة M: - ولا طلب يرجع بلا سعر (التعرفة المفقودة كانت تنتج رحلة مجانية بعمولة صفر). - الفان أغلى من الاقتصادي في كل بلد (الأرقام الخام السابقة عكست الترتيب). Co-Authored-By: Claude Opus 4.8 --- backend/scripts/pricing-test.mjs | 233 +++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 backend/scripts/pricing-test.mjs diff --git a/backend/scripts/pricing-test.mjs b/backend/scripts/pricing-test.mjs new file mode 100644 index 0000000..dda1e0d --- /dev/null +++ b/backend/scripts/pricing-test.mjs @@ -0,0 +1,233 @@ +// اختبار تسعير عبر API حقيقي — يطلب رحلة لكل فئة خدمة في كل بلد، ويقرأ +// السعر والمسافة والزمن اللذين رجعهما محرّك التعرفة الفعلي (لا محاكاة). +// +// ينشئ مستأجرَي اختبار لمصر وسوريا إن لم يكونا موجودين (الأردن = "siro" +// القائم أصلاً)، ثم يطلب 3 رحلات نموذجية (قصيرة/متوسطة/طويلة) لكل فئة +// خدمة في كل بلد، ويطبع جدول: البلد × الفئة × الرحلة → السعر. +// +// التشغيل (من داخل شبكة الحاوية، بعد نجاح البناء): +// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \ +// -e PLATFORM_SECRET=<القيمة من .env> \ +// -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/pricing-test.mjs +// +// يتطلّب OTP_DEV_MODE=true (الرمز الثابت 1234) و PLATFORM_SECRET مضبوطاً. + +const BASE = process.env.BASE || 'http://localhost:4010/api'; +const CODE = '1234'; +const PLATFORM_SECRET = process.env.PLATFORM_SECRET || ''; + +if (!PLATFORM_SECRET) { + console.error('❌ PLATFORM_SECRET غير مضبوط — لا يمكن إنشاء مستأجرَي الاختبار.'); + process.exit(1); +} + +const SERVICE_CLASSES = [ + 'saver', 'delivery', 'economy', 'fixed', 'electric', 'lady', 'comfort', 'van', 'vip', +]; + +/** + * رحلات نموذجية داخل الزرقاء/عمّان (إحداثيات قابلة لإعادة الاستعمال في + * أي بلد — الفارق بين البلدان هو التعرفة لا الجغرافيا؛ المسافة والزمن + * يأتيان من محرّك المسارات الحقيقي `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' }, +]; + +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, countryPack, seed) { + const phone = testPhone(countryPack, seed); + // وضع OTP التطويري: رمز ثابت 1234 يُنشئ الحساب ويسجّل الدخول بنداء واحد + // (بلا حاجة لـ/auth/send-otp أولاً) — نفس نمط e2e-test.mjs. + const r = await api('POST', '/auth/verify-otp', { tenant: tenantSlug, body: { phone, code: CODE } }); + if (!r.body?.access_token) { + console.error(` ❌ فشل تسجيل الدخول (${phone}): ${r.status} ${JSON.stringify(r.body)}`); + } + return r.body?.access_token; +} + +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); + + const rows = []; + + for (const t of TENANTS) { + console.log(`\n--- ${t.slug} (${t.countryPack} / ${t.currency}) ---`); + let seed = 500 + TENANTS.indexOf(t) * 100; + const token = await loginRider(t.slug, t.countryPack, seed++); + if (!token) { + console.error(`❌ تعذّر تسجيل دخول راكب اختبار لـ${t.slug} — تخطّي`); + continue; + } + + 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 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('✅ الفان أغلى من الاقتصادي في كل رحلة وكل بلد.'); + + console.log(failedExitCode(noQuote.length, orderOk)); +} + +function failedExitCode(missing, orderOk) { + const bad = missing > 0 || !orderOk; + process.exitCode = bad ? 1 : 0; + return bad ? '\n❌ فشل الاختبار.' : '\n✅ الاختبار ناجح.'; +} + +main().catch((e) => { + console.error('خطأ غير متوقع:', e); + process.exit(1); +});