fix: أخطاء «الصفر الصادق» + عزل حالة ioredis-mock في الاختبارات

أول تشغيل فعلي للاختبارات على السيرفر كشف خطأين حقيقيين في الكود، لا في
الاختبارات:

- RatingAggregateService.get: البذر يكتب count='0' لهدف بلا تقييمات، و'0'
  نصٌّ صادق في JS — ففحص !h.count لا يمسكه وكانت ترجع {avg:0,count:0} بدل
  null. صارت مقارنة رقمية.
- DriverLocationService.readHash: نفس الفخ — خط عرض '0' إحداثي صالح لكنه
  نصٌّ صادق؛ صار الفحص == null.

الاختبارات:
- ioredis-mock يشارك مخزناً واحداً بين النسخ رغم new RedisMock() — أُضيف
  flushall في كل beforeEach (تسرّبت مفاتيح بين الاختبارات).
- ioredis-mock لا ينفّذ geoadd داخل multi() ولا redis.call، فلا يمكن اختبار
  فهرس GEO به: صار MatchingService ضعفاً مزيّفاً في اختبارات المواقع،
  والتحقق ينصبّ على منطق العتبات. سلوك GEO الحقيقي يُتحقَّق على السيرفر.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-17 02:56:50 +03:00
co-authored by Claude Opus 4.8
parent 17c111a8f9
commit da37dffa44
6 changed files with 69 additions and 34 deletions
+4 -1
View File
@@ -5,8 +5,11 @@ describe('CacheService', () => {
let redis: any;
let cache: CacheService;
beforeEach(() => {
// ioredis-mock يشارك مخزناً واحداً بين النسخ ذات الإعدادات نفسها —
// بلا flushall تتسرّب مفاتيح اختبار إلى الذي يليه.
beforeEach(async () => {
redis = new RedisMock({ keyPrefix: 'tripz:' });
await redis.flushall();
cache = new CacheService(redis);
});
@@ -1,54 +1,70 @@
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;
/**
* 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;
}
describe('DriverLocationService', () => {
let redis: any;
let matching: MatchingService;
let matching: ReturnType<typeof fakeMatching>;
let svc: DriverLocationService;
let driversRepo: any;
beforeEach(() => {
beforeEach(async () => {
redis = new RedisMock({ keyPrefix: 'tripz:' });
matching = new MatchingService(redis);
await redis.flushall(); // المخزن مشترك بين نسخ ioredis-mock
matching = fakeMatching();
driversRepo = { findOne: jest.fn().mockResolvedValue(null) };
svc = new DriverLocationService(redis, driversRepo, matching);
});
it('أول نبضة ذات دلالة وتُخزَّن', async () => {
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 () => {
it('سائق واقف: نبضة بلا دلالة — لا مسار ولا تحديث فهرس', async () => {
await svc.update(TENANT, DRIVER, CLASS, BASE);
const before = await redis.llen('loc:tracks');
const tracksBefore = await redis.llen('loc:tracks');
matching.setPosition.mockClear();
// إزاحة مترين فقط — تحت عتبة 10م
// إزاحة مترين — تحت عتبة 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);
expect(await redis.llen('loc:tracks')).toBe(tracksBefore);
expect(matching.setPosition).not.toHaveBeenCalled();
});
it('تحرّك أكثر من 10م = نبضة ذات دلالة + نقطة مسار', async () => {
it('تحرّك أكثر من 10م = دلالة + نقطة مسار + تحديث الفهرس', async () => {
await svc.update(TENANT, DRIVER, CLASS, BASE);
const before = await redis.llen('loc:tracks');
const tracksBefore = await redis.llen('loc:tracks');
matching.setPosition.mockClear();
const r = await svc.update(TENANT, DRIVER, CLASS, {
lat: BASE.lat + metersToLat(25),
@@ -56,7 +72,8 @@ describe('DriverLocationService', () => {
});
expect(r.significant).toBe(true);
expect(await redis.llen('loc:tracks')).toBe(before + 1);
expect(await redis.llen('loc:tracks')).toBe(tracksBefore + 1);
expect(matching.setPosition).toHaveBeenCalledTimes(1);
});
it('تغيّر السرعة وحده يكفي رغم الوقوف', async () => {
@@ -70,26 +87,33 @@ describe('DriverLocationService', () => {
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);
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]);
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);
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('الفصل (off) يزيله من الفهرس ومن Redis', async () => {
it('busy ينقله بين الفهرسين، و 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);
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 () => {
it('عند غياب المفتاح يرجع للقطة القاعدة (احتياط)', async () => {
driversRepo.findOne.mockResolvedValue({
last_lat: 31.9,
last_lng: 35.9,
@@ -105,8 +129,8 @@ describe('DriverLocationService', () => {
expect(driversRepo.findOne).toHaveBeenCalled();
});
it('المستأجرون معزولون في المطابقة', async () => {
it('مفاتيح المستأجرين معزولة', async () => {
await svc.update('tenant-a', DRIVER, CLASS, BASE);
expect(await matching.findNearby('tenant-b', CLASS, BASE.lat, BASE.lng)).toHaveLength(0);
expect(await svc.get('tenant-b', DRIVER)).toBeNull();
});
});
@@ -189,7 +189,8 @@ export class DriverLocationService {
private async readHash(k: string): Promise<LivePosition | null> {
const h = await this.redis.hgetall(k);
if (!h?.lat) return null;
// `== null` لا `!h.lat` — خط عرض '0' نصٌّ صادق لكنه إحداثي صالح.
if (h?.lat == null) return null;
return {
lat: Number(h.lat),
lng: Number(h.lng),
@@ -18,8 +18,11 @@ function fakeRepo(sum = 0, count = 0) {
describe('RatingAggregateService', () => {
let redis: any;
beforeEach(() => {
// ioredis-mock يشارك مخزناً واحداً بين النسخ — بلا flushall تتراكم
// التجميعات من اختبار لآخر فتفسد الأعداد وحجز الكتابة اليومية.
beforeEach(async () => {
redis = new RedisMock({ keyPrefix: 'tripz:' });
await redis.flushall();
});
it('يبني التجميعة من القاعدة عند أول تقييم ثم يراكم في Redis', async () => {
@@ -79,9 +79,11 @@ export class RatingAggregateService {
const k = this.key(tenantId, target, targetId);
await this.seed(tenantId, target, targetId, k);
const h = await this.redis.hgetall(k);
if (!h?.count) return null;
const count = Number(h.count);
return { avg: count > 0 ? Number(h.sum) / count : 0, count };
// البذر يكتب count='0' لهدف بلا تقييمات، و'0' نصٌّ **صادق** في JS —
// فالمقارنة رقمية لا بالصدق، وإلا رجعنا {avg:0} بدل null.
const count = Number(h?.count ?? 0);
if (!(count > 0)) return null;
return { avg: Number(h.sum) / count, count };
}
// ---- داخلي ----
@@ -32,8 +32,10 @@ describe('TripStateService', () => {
let redis: any;
let state: TripStateService;
beforeEach(() => {
// ioredis-mock يشارك مخزناً واحداً بين النسخ — flushall يعزل كل اختبار.
beforeEach(async () => {
redis = new RedisMock({ keyPrefix: 'tripz:' });
await redis.flushall();
state = new TripStateService(redis);
});