Files
tripz-llc/backend/scripts/pricing-test.mjs
T
Hamza-AyedandClaude Opus 4.8 5838e92980 fix(test): read OTP from Redis instead of requiring OTP_DEV_MODE
نصيحتي السابقة بتفعيل `OTP_DEV_MODE=true` على السيرفر كانت خاطئة: حارس في
`main.ts` يرفض الإقلاع عند `OTP_DEV_MODE=true` مع `NODE_ENV=production`،
فسقطت حاوية الـapi في حلقة إعادة تشغيل وظهر الفشل كـ`EAI_AGAIN` في السكربت.

الحارس صحيح ولا يُضعَف لأجل اختبار: رمز ثابت على سيرفر متاح للإنترنت يعني
أن معرفة رقم هاتف تكفي لدخول أي حساب.

السكربت صار يقرأ الرمز الحقيقي من Redis (`send-otp` يخزّنه قبل محاولة
الإرسال، فيبقى محفوظاً حتى حين يفشل الإرسال لرقم وهمي). عميل RESP أدنى على
`node:net` بلا تبعيات — الحاوية عابرة بلا `node_modules`. المفتاح يستعمل
UUID المستأجر لا الـslug، فتُجلب خريطة الـslug→UUID من `/admin/tenants`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 01:01:51 +03:00

369 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// اختبار تسعير عبر 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);
}
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' },
];
// ---- عميل 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) يكون الرمز محفوظاً — وهو ما نقرأه.
const 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 = [];
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;
}
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;
process.exitCode = bad ? 1 : 0;
console.log(bad ? '\n❌ فشل الاختبار.' : `\n✅ الاختبار ناجح (${rows.length} طلباً).`);
}
main().catch((e) => {
console.error('خطأ غير متوقع:', e);
process.exit(1);
});