الدورة السابقة أعلنت الفشل والنظام سليم — العطل كان في السكربت: 1. **`fixed` تُرفض بـ400 وهذا هو المطلوب** (أُخرجت من الزرع عمداً)، لكن السكربت كان يعدّها فئة متوقَّعة فأسقط التغطية إلى 72/81. صارت في `REJECTED_CLASSES`: الرفض يُتحقَّق منه صراحةً، وغيابه هو الفشل. 2. **429 على `send-otp`** (10 لكل 5 دقائق) عند تشغيلتين متتاليتين — يُنتظر ويُعاد بدل الإبلاغ عن فشل ليس في النظام. 3. **إبطال كاش التعرفة صار خطوة في الدورة.** `getActive` تكاشي 900 ثانية، و**Redis حاوية منفصلة لا تتأثر بإعادة تشغيل الـapi** — وتطبيق `tariffs.sql` مباشرةً على القاعدة يتجاوز التطبيق فلا يُبطل شيئاً. النتيجة أن النظام خدم أسعاراً قديمة عبر دورتَي نشر كاملتين وهو يعلن النجاح. المسح الآن مبنيّ في السكربت لا متروك للتذكّر. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
396 lines
17 KiB
JavaScript
396 lines
17 KiB
JavaScript
// اختبار تسعير عبر 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);
|
||
});
|