الأساس المالي حسب docs/24: فصل ما نملكه عمّا نحتفظ به لغيرنا. - جدولان منفصلان (`tenant_revenue_ledger` · `tenant_pending_ledger`) لا جدول واحد بعمود نوع: استعلامٌ ينسى الشرط كان يجعل المالك يسحب من مال الركّاب. مضافان فقط، والرصيد مشتقّ لا حقل يُحدَّث. - فهرس فريد (tenant_id, ref) = حارس التسوية المزدوجة. التصادم يُلتقط من القاعدة لا بالفحص المسبق وحده — نداءان متزامنان يمرّان معاً قبل أي كتابة. - رسم العملية من مصدر واحد (35 ل.س · 5 ج.م · 0.20 د.أ) قابل للتجاوز من إعداد المستأجر، مع قصّه عند المبلغ حتى لا يخرج المستخدم بصافٍ سالب. - توجيه الدفع: شحن السائق ← إيراد + رصيده التشغيلي · شحن الراكب ← أمانة · الرسم ← إيراد. وفُتح مسار شحن السائق الذي لم يكن له مدخل إطلاقاً. - ثغرة سُدّت: `purpose` يصل من الجسم، فراكب كان يستطيع إرسال `credit_topup` فيُسجَّل مالُه إيراداً ويُشحن حساب سائق لا يملكه. الدور الآن من التوكن. - `/admin/overview`: الإيراد من الدفتر، وعمولة الرحلات حقل منفصل عنه. - `/admin/wallet/summary` و`/revenue-by-reason` لأدمن المستأجر. 25 اختباراً جديداً (188 إجمالاً، كلها خضراء). أحدها أمسك عطلاً فعلياً: صافٍ صفري كان ينادي الدفتر بصفر فيرمي خطأً بعد قيد الرسم — عملية نصف مطبَّقة. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
131 lines
7.5 KiB
JavaScript
131 lines
7.5 KiB
JavaScript
// اختبار طبقة التزويد (docs/22 — N): السوبر-أدمن ينشئ مستأجراً كاملاً ويقرأ
|
|
// مانيفست تطبيقه. يتطلّب PLATFORM_SECRET (نفس سرّ السيرفر).
|
|
//
|
|
// ⚠️ يتطلّب أيضاً OTP_DEV_MODE=true: قسم التعليق (10) ينادي send-otp مرّتين،
|
|
// وبلا وضع التطوير تصير كل دورة اختبار رسالتَي واتساب مدفوعتين فعلياً.
|
|
//
|
|
// التشغيل:
|
|
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
|
|
// -e SECRET=$PLATFORM_SECRET -v /home/tripz-llc/backend/scripts:/s \
|
|
// node:22-alpine node /s/provision-test.mjs
|
|
|
|
const BASE = process.env.BASE || 'http://localhost:4010/api';
|
|
const SECRET = process.env.SECRET || process.env.PLATFORM_SECRET || '';
|
|
|
|
let passed = 0, failed = 0;
|
|
const check = (n, c, d) => { c ? (passed++, console.log(` ✅ ${n}`)) : (failed++, console.log(` ❌ ${n}${d ? ` — ${d}` : ''}`)); };
|
|
|
|
async function api(method, path, body, secret = SECRET) {
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json', ...(secret ? { 'x-platform-secret': secret } : {}) },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
const text = await res.text().catch(() => '');
|
|
let json = {}; try { json = text ? JSON.parse(text) : {}; } catch {}
|
|
return { status: res.status, ok: res.ok, body: json };
|
|
}
|
|
|
|
async function main() {
|
|
console.log(`\n🧪 اختبار تزويد السوبر-أدمن على ${BASE}\n`);
|
|
if (!SECRET) { console.log(' ❌ لا PLATFORM_SECRET — مرّره عبر -e SECRET=…'); process.exit(1); }
|
|
|
|
// 1) الحارس: بلا سرّ يُرفض
|
|
const noSecret = await api('GET', '/admin/tenants', null, '');
|
|
check('نقاط السوبر-أدمن محميّة (بلا سرّ = 401)', noSecret.status === 401, `status=${noSecret.status}`);
|
|
|
|
const wrong = await api('GET', '/admin/tenants', null, 'wrong-secret');
|
|
check('سرّ خاطئ يُرفض', wrong.status === 401);
|
|
|
|
// 2) الكتالوج
|
|
const cat = await api('GET', '/admin/features');
|
|
check('كتالوج الميزات ووسائل الدفع متاح', Array.isArray(cat.body.features) && Array.isArray(cat.body.payment_methods));
|
|
|
|
// 3) تزويد مستأجر كامل
|
|
const slug = `test${Date.now().toString().slice(-8)}`;
|
|
const prov = await api('POST', '/admin/tenants/provision', {
|
|
name: 'مستأجر اختبار', slug, countryPack: 'eg', plan: 'brand',
|
|
features: { bots: true }, paymentMethods: ['cash', 'paymob'],
|
|
branding: { app_name: 'تطبيق الاختبار', bundle_id_android: 'com.test.rider', bundle_id_ios: 'com.test.rider' },
|
|
});
|
|
check('تزويد مستأجر جديد نجح', prov.ok, JSON.stringify(prov.body).slice(0, 120));
|
|
const tenantId = prov.body.id;
|
|
|
|
// 4) وسائل الدفع صُفّيت للكتالوج
|
|
const summary = await api('GET', `/admin/tenants/${tenantId}/summary`);
|
|
check('وسائل الدفع محفوظة', Array.isArray(summary.body.payment_methods) &&
|
|
summary.body.payment_methods.includes('paymob'), JSON.stringify(summary.body.payment_methods));
|
|
|
|
// 5) الاستحقاقات: الميزة المشتراة مفعّلة + الافتراض منع
|
|
const ent = await api('GET', `/admin/tenants/${tenantId}/entitlements`);
|
|
check('ميزة bots المشتراة مفعّلة', ent.body.features?.bots === true);
|
|
check('ميزة غير مشتراة (transit) محجوبة افتراضاً', ent.body.features?.transit === false);
|
|
|
|
// 6) المانيفست جاهز لسكربت البناء
|
|
const manifest = await api('GET', `/admin/tenants/${tenantId}/app-manifest`);
|
|
check('المانيفست يحمل bundle ID', manifest.body.bundle_id_android === 'com.test.rider');
|
|
check('المانيفست يحمل اسم التطبيق', manifest.body.app_name === 'تطبيق الاختبار');
|
|
check('المانيفست يحمل الدولة والدفع', manifest.body.country_pack === 'eg' &&
|
|
manifest.body.payment_methods.includes('paymob'));
|
|
|
|
// 7) slug مكرّر يُرفض
|
|
const dup = await api('POST', '/admin/tenants/provision', { name: 'x', slug });
|
|
check('slug مكرّر يُرفض', dup.status === 400);
|
|
|
|
// 8) تعديل وسائل الدفع (تصفية المخترع)
|
|
const setPay = await api('PATCH', `/admin/tenants/${tenantId}/payment-methods`, {
|
|
methods: ['cash', 'made_up_gateway', 'cliq'],
|
|
});
|
|
check('وسيلة مخترعة تُصفّى، الصحيحة تبقى',
|
|
setPay.body.settings?.payment_methods?.includes('cliq') &&
|
|
!setPay.body.settings?.payment_methods?.includes('made_up_gateway'));
|
|
|
|
// 9) النظرة الشاملة (docs/22 — N1b)
|
|
const ov = await api('GET', '/admin/overview?days=30');
|
|
check('النظرة الشاملة تعمل', ov.ok && typeof ov.body.totals?.tenants === 'number');
|
|
check('المستأجر الجديد يظهر فيها', (ov.body.tenants || []).some((t) => t.slug === slug));
|
|
check('أرقام المال أعداد لا نصوص', typeof (ov.body.totals?.gmv) === 'number' &&
|
|
typeof (ov.body.totals?.revenue) === 'number');
|
|
// P5: الإيراد صار من دفتر الإيراد، وعمولة الرحلات حقل منفصل عنه.
|
|
check('الإيراد وعمولة الرحلات حقلان منفصلان',
|
|
typeof (ov.body.totals?.trip_commission) === 'number');
|
|
const badWindow = await api('GET', '/admin/overview?days=abc');
|
|
check('نافذة غير صالحة تسقط للافتراضي لا تنكسر', badWindow.ok && badWindow.body.window_days === 30);
|
|
|
|
// 10) التعليق يسري فعلياً (docs/22 — N1a) — الحقل كان زخرفة بلا فرض
|
|
const badStatus = await api('PATCH', `/admin/tenants/${tenantId}/status`, { status: 'whatever' });
|
|
check('حالة مخترعة تُرفض', badStatus.status === 400);
|
|
|
|
const susp = await api('PATCH', `/admin/tenants/${tenantId}/status`, { status: 'suspended' });
|
|
check('تعليق المستأجر نجح', susp.ok && susp.body.status === 'suspended');
|
|
|
|
// الإثبات الحقيقي: الدخول نفسه يُقطع — لا مجرّد تغيّر الحقل.
|
|
const otpWhileSuspended = await fetch(`${BASE}/auth/send-otp`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'x-tenant-id': slug },
|
|
body: JSON.stringify({ phone: '01000000000' }),
|
|
});
|
|
check('المستأجر المعلَّق لا يستطيع حتى طلب OTP (401)', otpWhileSuspended.status === 401,
|
|
`status=${otpWhileSuspended.status}`);
|
|
|
|
const back = await api('PATCH', `/admin/tenants/${tenantId}/status`, { status: 'active' });
|
|
check('إعادة التفعيل تعمل', back.ok && back.body.status === 'active');
|
|
|
|
const otpAfter = await fetch(`${BASE}/auth/send-otp`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'x-tenant-id': slug },
|
|
body: JSON.stringify({ phone: '01000000000' }),
|
|
});
|
|
// نتحقّق من 2xx تحديداً لا من «ليس 401»: لو صادف حدّ الطلبات (429) لمرّ
|
|
// الاختبار وهو لا يثبت شيئاً عن إبطال الكاش.
|
|
check('بعد إعادة التفعيل يعود الدخول فوراً (الكاش أُبطل)',
|
|
otpAfter.status >= 200 && otpAfter.status < 300, `status=${otpAfter.status}`);
|
|
|
|
console.log('\n' + '═'.repeat(48));
|
|
console.log(`النتيجة: ${passed} نجح · ${failed} فشل`);
|
|
console.log('═'.repeat(48) + '\n');
|
|
process.exit(failed === 0 ? 0 : 1);
|
|
}
|
|
|
|
main().catch((e) => { console.error('💥', e.message); process.exit(1); });
|