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); }); });