// اختبار تسابق المحفظة (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); } // تدفّق السحب صار خطوتين (docs/17 — I4): الطلب يرسل رمزاً ولا يخصم شيئاً، // والخصم يقع عند التأكيد. فالسباق هنا على `confirm` لا على `request`. // يعتمد dev_code الذي ترجعه النقطة في وضع التطوير (OTP_DEV_MODE=true). console.log(`تحضير ${ATTEMPTS} طلب سحب (بلا خصم)…`); const requested = await Promise.all( Array.from({ length: ATTEMPTS }, () => api('POST', '/payouts/request', t, { amount: AMOUNT, channel: 'cliq' }).catch(() => null), ), ); const pending = requested.filter((r) => r?.ok && r.body?.payout?.id && r.body?.dev_code); if (pending.length !== ATTEMPTS) { console.log(`⚠️ حُضّر ${pending.length}/${ATTEMPTS} فقط.`); if (pending.length === 0) { console.log('لا شيء لتأكيده — تأكّد أن OTP_DEV_MODE=true (يُرجع dev_code).'); process.exit(1); } } // فحص وسيط: الطلب وحده يجب ألّا يمسّ الرصيد إطلاقاً. const mid = await api('GET', '/wallet', t); const midBalance = Number(mid.body.balance); console.log(`إطلاق ${pending.length} تأكيد متزامن × ${AMOUNT} (يكفي ${expectedSuccesses} فقط)…`); const t0 = Date.now(); const results = await Promise.all( pending.map((r) => api('POST', `/payouts/${r.body.payout.id}/confirm`, t, { code: String(r.body.dev_code), }).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 = [ ['الطلب وحده لا يمسّ الرصيد (لا مال قبل إثبات الهوية)', midBalance === startBalance], ['الرصيد لم يصبح سالباً', 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); });