test: add live pricing script across all service classes and countries
يطلب رحلة حقيقية لكل فئة خدمة (9) × 3 مسافات × 3 بلدان عبر الـAPI الحي، فالمسافة والزمن يأتيان من محرّك المسارات (انطلق) لا من افتراض. يتحقّق آلياً من أمرين كانا عطلين حقيقيين قبل المجموعة M: - ولا طلب يرجع بلا سعر (التعرفة المفقودة كانت تنتج رحلة مجانية بعمولة صفر). - الفان أغلى من الاقتصادي في كل بلد (الأرقام الخام السابقة عكست الترتيب). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
92f1eeb48c
commit
90c3f71430
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user