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,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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user