feat: المجموعة H — المواقع في Redis، والقاعدة من الـworker فقط

كان كل نبضة موقع (كل 1-3 ثوانٍ لكل سائق متصل) تكلّف استعلاماً + كتابة صف
السائق في Postgres — أثقل حمل كان على القاعدة، وأثقل حتى من سيرو.

- H1/H2: نبضة الموقع = Redis فقط، بعتبات سيرو (10م / 1.0 سرعة / 5° اتجاه).
  سائق واقف = EXPIRE واحد: لا كتابة، لا نقطة مسار، ولا بثّ للراكب
- H3: لقطة آخر موقع على drivers (heading/speed/loc_status/loc_updated_at)
  يكتبها الـworker دورياً. لم نُنشئ جدولاً منفصلاً — drivers أصلاً صف واحد
  لكل سائق
- H4: driver_tracks — تتراكم في قائمة Redis ويُدرجها الـworker بعبارة واحدة
- H5: فهرسا available/busy — البحث يمسح المتاحين فقط. السائق يصير busy عند
  القبول ويعود available عند الإنهاء/الإلغاء
- H8: المطابقة والاحتيال و«الرحلات المتاحة» تقرأ الموقع الحيّ من Redis.
  كشف تزوير الوصول خاصةً يجب ألّا يحكم بلقطة دورية

الـworker صار حقيقياً (كان setInterval فارغاً): سياق Nest مستقل، تفريغ كل
5s، قفل يمنع تراكب الدورات، وتفريغة أخيرة عند SIGTERM.

مؤجَّل من H: H6 (سلوك السائق) و H7 (ساعات العمل) — تحليلات تُبنى من
driver_tracks لاحقاً.

هجرة: DriverLocations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-17 02:49:45 +03:00
co-authored by Claude Opus 4.8
parent a83dd7e5e5
commit 0ed3a3160a
19 changed files with 794 additions and 98 deletions
@@ -0,0 +1,112 @@
import RedisMock from 'ioredis-mock';
import { DriverLocationService } from './driver-location.service';
import { MatchingService } from '../matching/matching.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;
describe('DriverLocationService', () => {
let redis: any;
let matching: MatchingService;
let svc: DriverLocationService;
let driversRepo: any;
beforeEach(() => {
redis = new RedisMock({ keyPrefix: 'tripz:' });
matching = new MatchingService(redis);
driversRepo = { findOne: jest.fn().mockResolvedValue(null) };
svc = new DriverLocationService(redis, driversRepo, matching);
});
it('أول نبضة ذات دلالة وتُخزَّن', 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');
});
it('سائق واقف: النبضة التالية غير ذات دلالة ولا تضيف مساراً', async () => {
await svc.update(TENANT, DRIVER, CLASS, BASE);
const before = await redis.llen('loc:tracks');
// إزاحة مترين فقط — تحت عتبة 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(before);
});
it('تحرّك أكثر من 10م = نبضة ذات دلالة + نقطة مسار', async () => {
await svc.update(TENANT, DRIVER, CLASS, BASE);
const before = await redis.llen('loc:tracks');
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(before + 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('المتاح يظهر في المطابقة، والمشغول يختفي منها', async () => {
await svc.update(TENANT, DRIVER, CLASS, BASE);
expect(await matching.findNearby(TENANT, CLASS, BASE.lat, BASE.lng)).toHaveLength(1);
await svc.setAvailability(TENANT, DRIVER, CLASS, 'busy');
expect(await matching.findNearby(TENANT, CLASS, BASE.lat, BASE.lng)).toHaveLength(0);
await svc.setAvailability(TENANT, DRIVER, CLASS, 'available');
expect(await matching.findNearby(TENANT, CLASS, BASE.lat, BASE.lng)).toHaveLength(1);
});
it('الفصل (off) يزيله من الفهرس ومن Redis', async () => {
await svc.update(TENANT, DRIVER, CLASS, BASE);
await svc.setAvailability(TENANT, DRIVER, CLASS, 'off');
expect(await matching.findNearby(TENANT, CLASS, BASE.lat, BASE.lng)).toHaveLength(0);
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 matching.findNearby('tenant-b', CLASS, BASE.lat, BASE.lng)).toHaveLength(0);
});
});