diff --git a/backend/src/common/cache/cache.service.spec.ts b/backend/src/common/cache/cache.service.spec.ts index 6e3b5e0..9a0cad2 100644 --- a/backend/src/common/cache/cache.service.spec.ts +++ b/backend/src/common/cache/cache.service.spec.ts @@ -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); }); diff --git a/backend/src/modules/locations/driver-location.service.spec.ts b/backend/src/modules/locations/driver-location.service.spec.ts index 9484f83..a952fc1 100644 --- a/backend/src/modules/locations/driver-location.service.spec.ts +++ b/backend/src/modules/locations/driver-location.service.spec.ts @@ -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; 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(); }); }); diff --git a/backend/src/modules/locations/driver-location.service.ts b/backend/src/modules/locations/driver-location.service.ts index 82ceac6..d86e84a 100644 --- a/backend/src/modules/locations/driver-location.service.ts +++ b/backend/src/modules/locations/driver-location.service.ts @@ -189,7 +189,8 @@ export class DriverLocationService { private async readHash(k: string): Promise { 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), diff --git a/backend/src/modules/ratings/rating-aggregate.service.spec.ts b/backend/src/modules/ratings/rating-aggregate.service.spec.ts index ed2af85..d2df4e6 100644 --- a/backend/src/modules/ratings/rating-aggregate.service.spec.ts +++ b/backend/src/modules/ratings/rating-aggregate.service.spec.ts @@ -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 () => { diff --git a/backend/src/modules/ratings/rating-aggregate.service.ts b/backend/src/modules/ratings/rating-aggregate.service.ts index 48e49d1..a4ae1f7 100644 --- a/backend/src/modules/ratings/rating-aggregate.service.ts +++ b/backend/src/modules/ratings/rating-aggregate.service.ts @@ -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 }; } // ---- داخلي ---- diff --git a/backend/src/modules/trips/trip-state.service.spec.ts b/backend/src/modules/trips/trip-state.service.spec.ts index 7b83f5b..548a998 100644 --- a/backend/src/modules/trips/trip-state.service.spec.ts +++ b/backend/src/modules/trips/trip-state.service.spec.ts @@ -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); });