الاختبار بقي على constructor بثلاثة وسائط بعد إضافة الجيوفنس، فأسقط `npm test` وأوقف بناء الصورة — فلم يقلع الـAPI أصلاً على 4010. يضيف أيضاً تغطية للتعاون الجديد: الجيوفنس لا يُنادى على نبضة بلا دلالة، ولا يُفشل النبضة إذا سقط هو. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
158 lines
6.0 KiB
TypeScript
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();
|
|
});
|
|
});
|