Files
tripz-llc/backend/scripts/wallet-race-test.mjs
T
Hamza-AyedandClaude Opus 4.8 9d6b752ea8 feat: المجموعة A (زمن حقيقي + FCM + Redis) + إصلاح سباق المحفظة
المجموعة A (docs/17):
- A1: FCM على كل انتقال حالة (priority high) + حذف التوكنات الميتة
- A2: common/i18n (ar/en) + عمود users.language — الإشعارات بلغة المستخدم
- A3: TripStateService — حالة الرحلة الجارية في Redis hash (TTL 6س)؛
  الانتقال صار UPDATE شرطي + قراءة واحدة بدل ~5 استعلامات
- A4: قبول ذرّي — CAS بـLua في Redis + UPDATE ... WHERE status='searching'
  كحَكَم نهائي؛ أول سائق يفوز والباقي يُرفضون بلا لمس القاعدة
- A5: مجموعة العروض في Redis + بث trip:offer_taken و FCM لبقية السائقين
- A6: GET /trips/available — السائق يسحب الطلبات القريبة
- A7: FCM data-only بحمولة كاملة للـoverlay

I1 — إصلاح سباق المحفظة (ثغرة مالية):
- credit/debit كانا read-modify-write على balance بلا قفل → خصمان متزامنان
  يكتبان فوق بعضهما. صارا UPDATE ذرّي واحد بشرط balance >= :amount،
  والقيد+الرصيد في معاملة واحدة
- wallet_txns.balance_after للتدقيق + CHECK (balance >= 0) كشبكة أمان
- إنشاء المحفظة عبر ON CONFLICT DO NOTHING (سباق ثانٍ كان كامناً)

الاختبارات تعمل على السيرفر (docs/15):
- npm test صار جزءاً من مرحلة builder — فشل اختبار = فشل بناء = لا نشر
- pg-mem + ioredis-mock: بلا شبكة وبلا قاعدة حقيقية
- scripts/wallet-race-test.mjs للتزامن الحقيقي على السيرفر

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 02:23:00 +03:00

104 lines
4.3 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.
// اختبار تسابق المحفظة (docs/17 — I1): يثبت أن المال لا يُفقد ولا يُخلق تحت التزامن.
// يستخدم fetch المدمج (Node 18+) — بلا تبعيات.
//
// لماذا سكربت منفصل: pg-mem في اختبارات jest أحادي الخيط فيثبت صحة الـSQL فقط،
// أما التزامن الحقيقي على نفس الصف فيحتاج Postgres حقيقياً.
//
// التشغيل (حاوية node مؤقتة على شبكة tripz):
// docker run --rm --network tripz-net -e BASE=http://tripz-api:4010/api \
// -v /home/tripz-llc/backend/scripts:/s node:22-alpine node /s/wallet-race-test.mjs 100 5
// الوسائط: <عدد محاولات الخصم المتزامنة> <مبلغ كل خصم>
const BASE = process.env.BASE || 'http://localhost:4010/api';
const TENANT = process.env.TENANT || 'siro';
const ATTEMPTS = parseInt(process.argv[2] || '100', 10);
const AMOUNT = parseFloat(process.argv[3] || '5');
const CODE = '1234';
const H = (t) => ({ 'Content-Type': 'application/json', ...(t ? { Authorization: `Bearer ${t}` } : {}) });
async function api(method, path, token, body) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: { ...H(token), 'x-tenant-id': TENANT },
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text().catch(() => '');
let json = {};
try { json = text ? JSON.parse(text) : {}; } catch { /* نص غير JSON */ }
return { ok: res.ok, status: res.status, body: json };
}
async function token(phone) {
const r = await api('POST', '/auth/verify-otp', null, { phone, code: CODE });
if (!r.ok) throw new Error(`auth failed: ${r.status} ${JSON.stringify(r.body)}`);
return r.body.access_token;
}
async function main() {
// مبلغ يكفي نصف المحاولات فقط — النصف الآخر *يجب* أن يُرفض.
const expectedSuccesses = Math.floor(ATTEMPTS / 2);
const funded = expectedSuccesses * AMOUNT;
const phone = `0796${String(Date.now()).slice(-6)}`;
const t = await token(phone);
await api('POST', '/wallet/topup', t, { amount: funded });
const before = await api('GET', '/wallet', t);
const startBalance = Number(before.body.balance);
console.log(`المحفظة: ${phone} | الرصيد الابتدائي = ${startBalance}`);
if (startBalance !== funded) {
console.log(`⚠️ الشحن لم يطابق المتوقع (${funded}) — أوقفت الاختبار.`);
process.exit(1);
}
// كل الطلبات تنطلق دفعة واحدة على نفس الصف.
console.log(`إطلاق ${ATTEMPTS} خصم متزامن × ${AMOUNT} (يكفي ${expectedSuccesses} فقط)…`);
const t0 = Date.now();
const results = await Promise.all(
Array.from({ length: ATTEMPTS }, () =>
api('POST', '/payouts/request', t, { amount: AMOUNT, channel: 'cliq' }).catch((e) => ({
ok: false,
status: 0,
body: { message: String(e) },
})),
),
);
const elapsed = Date.now() - t0;
const ok = results.filter((r) => r.ok).length;
const rejected = results.filter((r) => !r.ok).length;
const after = await api('GET', '/wallet', t);
const endBalance = Number(after.body.balance);
const spent = startBalance - endBalance;
console.log('');
console.log(`نجح : ${ok} (المتوقع ${expectedSuccesses})`);
console.log(`رُفض : ${rejected}`);
console.log(`الرصيد : ${startBalance} → ${endBalance} (خُصم ${spent})`);
console.log(`الزمن : ${elapsed}ms`);
console.log('');
// المحكّات الثلاثة
const checks = [
['الرصيد لم يصبح سالباً', endBalance >= 0],
['عدد النجاحات = ما يسمح به الرصيد', ok === expectedSuccesses],
['المخصوم = النجاحات × المبلغ (لا مال ضائع/مخلوق)', Math.abs(spent - ok * AMOUNT) < 1e-6],
];
let failed = 0;
for (const [name, pass] of checks) {
console.log(`${pass ? '✅' : '❌'} ${name}`);
if (!pass) failed++;
}
console.log('');
console.log(failed === 0 ? '✅ لا سباق: المحفظة ذرّية.' : `❌ ${failed} محكّ فشل — يوجد سباق.`);
process.exit(failed === 0 ? 0 : 1);
}
main().catch((e) => {
console.error('خطأ:', e.message);
process.exit(1);
});