Files
tripz-llc/backend/src/modules/wallet/wallet.service.spec.ts
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

133 lines
5.0 KiB
TypeScript

import { randomUUID } from 'crypto';
import { newDb } from 'pg-mem';
import { DataSource } from 'typeorm';
import { BadRequestException } from '@nestjs/common';
import { Wallet } from './entities/wallet.entity';
import { WalletTxn } from './entities/wallet-txn.entity';
import { WalletService } from './wallet.service';
/**
* يعمل على Postgres في الذاكرة (pg-mem) — يثبت أن SQL المولَّد صحيح وأن دلالة
* الخصم المشروط تعمل. التزامن الحقيقي (عمليات متوازية على نفس الصف) لا يمكن
* إثباته هنا لأن pg-mem أحادي الخيط — لذلك scripts/wallet-race-test.mjs
* يُشغَّل على السيرفر مقابل Postgres حقيقي.
*/
const TENANT = '11111111-1111-1111-1111-111111111111';
const USER = '22222222-2222-2222-2222-222222222222';
describe('WalletService', () => {
let ds: DataSource;
let wallet: WalletService;
beforeEach(async () => {
const db = newDb({ autoCreateForeignKeyIndices: true });
db.public.registerFunction({
name: 'version',
returns: 'text' as any,
implementation: () => 'pg-mem',
});
db.public.registerFunction({
name: 'current_database',
returns: 'text' as any,
implementation: () => 'tripz',
});
db.registerExtension('uuid-ossp', (schema) =>
schema.registerFunction({
name: 'uuid_generate_v4',
returns: 'uuid' as any,
implementation: () => randomUUID(),
impure: true,
}),
);
await db.public.none(`CREATE EXTENSION "uuid-ossp"`);
ds = (await db.adapters.createTypeormDataSource({
type: 'postgres',
entities: [Wallet, WalletTxn],
entityPrefix: 'tripz_',
})) as DataSource;
await ds.initialize();
await ds.synchronize();
// شبكة الأمان التي تضيفها الهجرة WalletLedger
await ds.query(
`ALTER TABLE tripz_wallets ADD CONSTRAINT tripz_wallets_balance_non_negative CHECK (balance >= 0)`,
);
wallet = new WalletService(ds.getRepository(Wallet), ds.getRepository(WalletTxn));
});
afterEach(async () => {
if (ds?.isInitialized) await ds.destroy();
});
it('ينشئ المحفظة عند أول إيداع ويضبط الرصيد', async () => {
const w = await wallet.credit(TENANT, USER, 10, 'topup');
expect(w.balance).toBe(10);
});
it('يجمع الإيداعات المتتالية بلا فقدان', async () => {
await wallet.credit(TENANT, USER, 10, 'topup');
await wallet.credit(TENANT, USER, 5.5, 'topup');
const w = await wallet.getOrCreate(TENANT, USER);
expect(Number(w.balance)).toBe(15.5);
});
it('يخصم عند توفّر الرصيد', async () => {
await wallet.credit(TENANT, USER, 20, 'topup');
const w = await wallet.debit(TENANT, USER, 8, 'trip_fare');
expect(w.balance).toBe(12);
});
it('يرفض الخصم عند نقص الرصيد ولا يغيّر شيئاً', async () => {
await wallet.credit(TENANT, USER, 5, 'topup');
await expect(wallet.debit(TENANT, USER, 9, 'trip_fare')).rejects.toThrow(BadRequestException);
const w = await wallet.getOrCreate(TENANT, USER);
expect(Number(w.balance)).toBe(5);
// القيد المرفوض لا يُسجَّل في الدفتر
const txns = await wallet.history(TENANT, w.id);
expect(txns.filter((t) => t.type === 'debit')).toHaveLength(0);
});
it('يرفض الخصم من محفظة غير موجودة', async () => {
await expect(wallet.debit(TENANT, USER, 1, 'trip_fare')).rejects.toThrow(BadRequestException);
});
it('يرفض المبالغ غير الصالحة', async () => {
for (const bad of [0, -5, NaN, Infinity]) {
await expect(wallet.credit(TENANT, USER, bad, 'topup')).rejects.toThrow(BadRequestException);
await expect(wallet.debit(TENANT, USER, bad, 'payout')).rejects.toThrow(BadRequestException);
}
});
it('الدفتر يطابق الرصيد ويسجّل balance_after', async () => {
await wallet.credit(TENANT, USER, 30, 'topup');
await wallet.debit(TENANT, USER, 12, 'trip_fare');
await wallet.credit(TENANT, USER, 2, 'refund');
const w = await wallet.getOrCreate(TENANT, USER);
const txns = await wallet.history(TENANT, w.id);
expect(txns).toHaveLength(3);
const sum = txns.reduce(
(acc, t) => acc + (t.type === 'credit' ? 1 : -1) * Number(t.amount),
0,
);
expect(sum).toBe(Number(w.balance));
expect(sum).toBe(20);
const last = txns.find((t) => t.reason === 'refund')!;
expect(Number(last.balance_after)).toBe(20);
});
it('محافظ مستأجرين مختلفين معزولة رغم نفس user_id', async () => {
const other = '33333333-3333-3333-3333-333333333333';
await wallet.credit(TENANT, USER, 10, 'topup');
await wallet.credit(other, USER, 7, 'topup');
expect(Number((await wallet.getOrCreate(TENANT, USER)).balance)).toBe(10);
expect(Number((await wallet.getOrCreate(other, USER)).balance)).toBe(7);
});
});