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 { 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; // ioredis-mock يشارك مخزناً واحداً بين النسخ — flushall يعزل كل اختبار. beforeEach(async () => { redis = new RedisMock({ keyPrefix: 'tripz:' }); await redis.flushall(); 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([]); }); });