المجموعة 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>
133 lines
5.0 KiB
TypeScript
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);
|
|
});
|
|
});
|