Files
tripz-llc/backend/src/modules/locations/driver-location.service.spec.ts
T
Hamza-AyedandClaude Opus 4.8 4db7af9301 fix(tests): inject GeofenceService into DriverLocationService spec
الاختبار بقي على constructor بثلاثة وسائط بعد إضافة الجيوفنس، فأسقط
`npm test` وأوقف بناء الصورة — فلم يقلع الـAPI أصلاً على 4010.

يضيف أيضاً تغطية للتعاون الجديد: الجيوفنس لا يُنادى على نبضة بلا دلالة،
ولا يُفشل النبضة إذا سقط هو.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 19:25:57 +03:00

158 lines
6.0 KiB
TypeScript

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<typeof fakeMatching>;
let svc: DriverLocationService;
let driversRepo: any;
let geofence: ReturnType<typeof fakeGeofence>;
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();
});
});