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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b7e91e5de6
commit
9d6b752ea8
@@ -0,0 +1,132 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user