Files
Hamza-AyedandClaude Opus 5 fff76c949f chore: أرشفة باك إند NestJS إلى backend-archive
قرار المالك 2026-07-27: الباك إند المعتمد صار باك إند سيرو PHP، ويُنقل
إلى هذا المستودع كنسخة جديدة باسم «انطلق» على api.intaleqapp.com.

backend/ → backend-archive/ (305 ملفاً، إعادة تسمية بلا تعديل محتوى)
+ README-ARCHIVE.md يوضّح سبب الأرشفة وسبب عدم الحذف: scripts/e2e-test.mjs
  هو أدق توثيق سلوكي لدورة الرحلة كاملة، ويبقى مرجعاً عند بناء الوحدات
  الناقصة في باك إند PHP (المحفظتان · الاستحقاقات · محرّك التسعير · الوحدات).

مسارات backend/ في docs/*.md لم تُعدَّل عمداً — سجلّ تاريخي.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 04:54:35 +03:00

123 lines
5.4 KiB
JavaScript
Raw Permalink 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);
}
// تدفّق السحب صار خطوتين (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);
});