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:
Hamza-Ayed
2026-07-17 02:23:00 +03:00
co-authored by Claude Opus 4.8
parent b7e91e5de6
commit 9d6b752ea8
28 changed files with 1372 additions and 166 deletions
@@ -0,0 +1,112 @@
import RedisMock from 'ioredis-mock';
import { TripStateService } from './trip-state.service';
import { Trip } from './entities/trip.entity';
const TENANT = 't1';
function makeTrip(overrides: Partial<Trip> = {}): Trip {
return {
id: 'trip-1',
tenant_id: TENANT,
rider_id: 'rider-1',
driver_id: null,
service_class: 'economy',
is_round_trip: false,
city: 'amman',
origin_lat: 31.95,
origin_lng: 35.91,
dest_lat: 31.98,
dest_lng: 35.87,
status: 'searching',
distance_km: 4.2,
duration_min: 11,
quoted_fare: 3.75,
currency: 'JOD',
payment_method: 'cash',
assigned_at: null,
...overrides,
} as Trip;
}
describe('TripStateService', () => {
let redis: any;
let state: TripStateService;
beforeEach(() => {
redis = new RedisMock({ keyPrefix: 'tripz:' });
state = new TripStateService(redis);
});
it('يحفظ الحالة ويقرأها بأنواعها الصحيحة', async () => {
await state.save(makeTrip(), 'driver-user-1');
const s = await state.get(TENANT, 'trip-1');
expect(s).not.toBeNull();
expect(s!.status).toBe('searching');
expect(s!.driver_user_id).toBe('driver-user-1');
expect(s!.origin_lat).toBeCloseTo(31.95);
expect(s!.quoted_fare).toBe(3.75);
expect(s!.assigned_at).toBeNull();
});
it('يرجع null لرحلة غير موجودة (فيرجع المُنادي للقاعدة)', async () => {
expect(await state.get(TENANT, 'ghost')).toBeNull();
});
describe('claim — القبول الذرّي', () => {
it('أول سائق يفوز والثاني يخسر', async () => {
await state.save(makeTrip());
const at = new Date();
expect(await state.claim(TENANT, 'trip-1', 'd1', 'u1', at)).toBe('won');
expect(await state.claim(TENANT, 'trip-1', 'd2', 'u2', at)).toBe('lost');
const s = await state.get(TENANT, 'trip-1');
expect(s!.status).toBe('assigned');
expect(s!.driver_id).toBe('d1');
expect(s!.driver_user_id).toBe('u1');
expect(s!.assigned_at?.toISOString()).toBe(at.toISOString());
});
it('فائز واحد فقط عند تزامن عدة سائقين', async () => {
await state.save(makeTrip());
const at = new Date();
const results = await Promise.all(
Array.from({ length: 20 }, (_, i) =>
state.claim(TENANT, 'trip-1', `d${i}`, `u${i}`, at),
),
);
expect(results.filter((r) => r === 'won')).toHaveLength(1);
expect(results.filter((r) => r === 'lost')).toHaveLength(19);
});
it('يرجع unknown عند غياب المفتاح ليحسم الأمر على القاعدة', async () => {
expect(await state.claim(TENANT, 'gone', 'd1', 'u1', new Date())).toBe('unknown');
});
});
it('setStatus لا يُنشئ مفتاحاً لرحلة غير جارية', async () => {
await state.setStatus(TENANT, 'ghost', 'completed');
expect(await state.get(TENANT, 'ghost')).toBeNull();
});
it('clear يمسح الحالة والعروض معاً', async () => {
await state.save(makeTrip());
await state.addOffers(TENANT, 'trip-1', ['u1', 'u2']);
await state.clear(TENANT, 'trip-1');
expect(await state.get(TENANT, 'trip-1')).toBeNull();
expect(await state.takeLosingOffers(TENANT, 'trip-1', 'u1')).toEqual([]);
});
it('takeLosingOffers يستثني الفائز ويستهلك المجموعة مرة واحدة', async () => {
await state.addOffers(TENANT, 'trip-1', ['u1', 'u2', 'u3']);
const losers = await state.takeLosingOffers(TENANT, 'trip-1', 'u2');
expect(losers.sort()).toEqual(['u1', 'u3']);
// الاستدعاء الثاني فارغ — لا إشعارات مكرّرة لبقية السائقين
expect(await state.takeLosingOffers(TENANT, 'trip-1', 'u2')).toEqual([]);
});
});