import RedisMock from 'ioredis-mock'; import { DriverLocationService } from './driver-location.service'; const TENANT = 't1'; const DRIVER = 'd1'; const CLASS = 'economy'; const BASE = { lat: 31.9539, lng: 35.9106 }; const metersToLat = (m: number) => m / 111_320; /** * MatchingService مزيّف: ioredis-mock لا ينفّذ GEOADD داخل multi() ولا * `redis.call` أصلاً، فلا يمكن اختبار فهرس GEO به. هذه الاختبارات تخصّ منطق * العتبات والتراكم؛ والمطابقة تُتحقَّق كتعاون (بماذا نُوديت). * سلوك GEO الحقيقي يُتحقَّق على السيرفر مقابل Redis فعلي. */ function fakeMatching() { return { setPosition: jest.fn().mockResolvedValue(undefined), removeDriver: jest.fn().mockResolvedValue(undefined), } as any; } /** الجيوفنس تعاونٌ جانبي لا يحجب النبضة — يكفي التأكّد أنه يُنادى. */ function fakeGeofence() { return { processLocation: jest.fn().mockResolvedValue(undefined), } as any; } describe('DriverLocationService', () => { let redis: any; let matching: ReturnType; let svc: DriverLocationService; let driversRepo: any; let geofence: ReturnType; beforeEach(async () => { redis = new RedisMock({ keyPrefix: 'tripz:' }); await redis.flushall(); // المخزن مشترك بين نسخ ioredis-mock matching = fakeMatching(); geofence = fakeGeofence(); driversRepo = { findOne: jest.fn().mockResolvedValue(null) }; svc = new DriverLocationService(redis, driversRepo, matching, geofence); }); it('أول نبضة ذات دلالة وتُخزَّن في Redis بلا قاعدة', async () => { const r = await svc.update(TENANT, DRIVER, CLASS, BASE); expect(r.significant).toBe(true); const pos = await svc.get(TENANT, DRIVER); expect(pos!.lat).toBeCloseTo(BASE.lat); expect(pos!.status).toBe('available'); expect(driversRepo.findOne).not.toHaveBeenCalled(); // Redis خط أول }); it('سائق واقف: نبضة بلا دلالة — لا مسار ولا تحديث فهرس', async () => { await svc.update(TENANT, DRIVER, CLASS, BASE); const tracksBefore = await redis.llen('loc:tracks'); matching.setPosition.mockClear(); // إزاحة مترين — تحت عتبة 10م const r = await svc.update(TENANT, DRIVER, CLASS, { lat: BASE.lat + metersToLat(2), lng: BASE.lng, }); expect(r.significant).toBe(false); expect(await redis.llen('loc:tracks')).toBe(tracksBefore); expect(matching.setPosition).not.toHaveBeenCalled(); expect(geofence.processLocation).not.toHaveBeenCalled(); }); it('نبضة ذات دلالة تُغذّي الجيوفنس ولا تُسقطها إن فشل', async () => { geofence.processLocation.mockRejectedValue(new Error('geofence down')); const r = await svc.update(TENANT, DRIVER, CLASS, BASE); expect(r.significant).toBe(true); // الجيوفنس تعاون جانبي لا يُفشل النبضة expect(geofence.processLocation).toHaveBeenCalledWith( TENANT, DRIVER, 'driver', BASE.lat, BASE.lng, ); }); it('تحرّك أكثر من 10م = دلالة + نقطة مسار + تحديث الفهرس', async () => { await svc.update(TENANT, DRIVER, CLASS, BASE); const tracksBefore = await redis.llen('loc:tracks'); matching.setPosition.mockClear(); const r = await svc.update(TENANT, DRIVER, CLASS, { lat: BASE.lat + metersToLat(25), lng: BASE.lng, }); expect(r.significant).toBe(true); expect(await redis.llen('loc:tracks')).toBe(tracksBefore + 1); expect(matching.setPosition).toHaveBeenCalledTimes(1); }); it('تغيّر السرعة وحده يكفي رغم الوقوف', async () => { await svc.update(TENANT, DRIVER, CLASS, { ...BASE, speed: 0 }); const r = await svc.update(TENANT, DRIVER, CLASS, { ...BASE, speed: 12 }); expect(r.significant).toBe(true); }); it('السائق «متّسخ» بعد نبضة ذات دلالة — ليكتبه الـworker لاحقاً', async () => { await svc.update(TENANT, DRIVER, CLASS, BASE); expect(await redis.smembers(DriverLocationService.DIRTY_KEY)).toContain(`${TENANT}|${DRIVER}`); }); it('نقطة المسار تحمل ما يحتاجه الـworker', async () => { await svc.update(TENANT, DRIVER, CLASS, { ...BASE, heading: 90, speed: 30 }); const raw = await redis.lrange(DriverLocationService.TRACKS_KEY, 0, -1); const point = JSON.parse(raw[0]); expect(point.tenant_id).toBe(TENANT); expect(point.driver_id).toBe(DRIVER); expect(point.lat).toBeCloseTo(BASE.lat); expect(point.heading).toBe(90); expect(typeof point.at).toBe('number'); }); it('busy ينقله بين الفهرسين، و off يزيله ويمسح Redis', async () => { await svc.update(TENANT, DRIVER, CLASS, BASE); await svc.setAvailability(TENANT, DRIVER, CLASS, 'busy'); expect(matching.setPosition).toHaveBeenLastCalledWith( TENANT, CLASS, DRIVER, expect.any(Number), expect.any(Number), 'busy', ); expect((await svc.get(TENANT, DRIVER))!.status).toBe('busy'); await svc.setAvailability(TENANT, DRIVER, CLASS, 'off'); expect(matching.removeDriver).toHaveBeenCalledWith(TENANT, CLASS, DRIVER); expect(await svc.get(TENANT, DRIVER)).toBeNull(); }); it('عند غياب المفتاح يرجع للقطة القاعدة (احتياط)', async () => { driversRepo.findOne.mockResolvedValue({ last_lat: 31.9, last_lng: 35.9, heading: 90, speed: 0, loc_status: 'available', service_class: CLASS, loc_updated_at: new Date(), }); const pos = await svc.get(TENANT, 'cold-driver'); expect(pos!.lat).toBe(31.9); expect(driversRepo.findOne).toHaveBeenCalled(); }); it('مفاتيح المستأجرين معزولة', async () => { await svc.update('tenant-a', DRIVER, CLASS, BASE); expect(await svc.get('tenant-b', DRIVER)).toBeNull(); }); });