أول تشغيل فعلي للاختبارات على السيرفر كشف خطأين حقيقيين في الكود، لا في
الاختبارات:
- 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>
115 lines
3.9 KiB
TypeScript
115 lines
3.9 KiB
TypeScript
import RedisMock from 'ioredis-mock';
|
|
import { TripStateService } from './trip-state.service';
|
|
import { Trip } from './entities/trip.entity';
|
|
|
|
const TENANT = 't1';
|
|
|
|
function makeTrip(overrides: Partial<Trip> = {}): Trip {
|
|
return {
|
|
id: 'trip-1',
|
|
tenant_id: TENANT,
|
|
rider_id: 'rider-1',
|
|
driver_id: null,
|
|
service_class: 'economy',
|
|
is_round_trip: false,
|
|
city: 'amman',
|
|
origin_lat: 31.95,
|
|
origin_lng: 35.91,
|
|
dest_lat: 31.98,
|
|
dest_lng: 35.87,
|
|
status: 'searching',
|
|
distance_km: 4.2,
|
|
duration_min: 11,
|
|
quoted_fare: 3.75,
|
|
currency: 'JOD',
|
|
payment_method: 'cash',
|
|
assigned_at: null,
|
|
...overrides,
|
|
} as Trip;
|
|
}
|
|
|
|
describe('TripStateService', () => {
|
|
let redis: any;
|
|
let state: TripStateService;
|
|
|
|
// ioredis-mock يشارك مخزناً واحداً بين النسخ — flushall يعزل كل اختبار.
|
|
beforeEach(async () => {
|
|
redis = new RedisMock({ keyPrefix: 'tripz:' });
|
|
await redis.flushall();
|
|
state = new TripStateService(redis);
|
|
});
|
|
|
|
it('يحفظ الحالة ويقرأها بأنواعها الصحيحة', async () => {
|
|
await state.save(makeTrip(), 'driver-user-1');
|
|
const s = await state.get(TENANT, 'trip-1');
|
|
|
|
expect(s).not.toBeNull();
|
|
expect(s!.status).toBe('searching');
|
|
expect(s!.driver_user_id).toBe('driver-user-1');
|
|
expect(s!.origin_lat).toBeCloseTo(31.95);
|
|
expect(s!.quoted_fare).toBe(3.75);
|
|
expect(s!.assigned_at).toBeNull();
|
|
});
|
|
|
|
it('يرجع null لرحلة غير موجودة (فيرجع المُنادي للقاعدة)', async () => {
|
|
expect(await state.get(TENANT, 'ghost')).toBeNull();
|
|
});
|
|
|
|
describe('claim — القبول الذرّي', () => {
|
|
it('أول سائق يفوز والثاني يخسر', async () => {
|
|
await state.save(makeTrip());
|
|
const at = new Date();
|
|
|
|
expect(await state.claim(TENANT, 'trip-1', 'd1', 'u1', at)).toBe('won');
|
|
expect(await state.claim(TENANT, 'trip-1', 'd2', 'u2', at)).toBe('lost');
|
|
|
|
const s = await state.get(TENANT, 'trip-1');
|
|
expect(s!.status).toBe('assigned');
|
|
expect(s!.driver_id).toBe('d1');
|
|
expect(s!.driver_user_id).toBe('u1');
|
|
expect(s!.assigned_at?.toISOString()).toBe(at.toISOString());
|
|
});
|
|
|
|
it('فائز واحد فقط عند تزامن عدة سائقين', async () => {
|
|
await state.save(makeTrip());
|
|
const at = new Date();
|
|
|
|
const results = await Promise.all(
|
|
Array.from({ length: 20 }, (_, i) =>
|
|
state.claim(TENANT, 'trip-1', `d${i}`, `u${i}`, at),
|
|
),
|
|
);
|
|
|
|
expect(results.filter((r) => r === 'won')).toHaveLength(1);
|
|
expect(results.filter((r) => r === 'lost')).toHaveLength(19);
|
|
});
|
|
|
|
it('يرجع unknown عند غياب المفتاح ليحسم الأمر على القاعدة', async () => {
|
|
expect(await state.claim(TENANT, 'gone', 'd1', 'u1', new Date())).toBe('unknown');
|
|
});
|
|
});
|
|
|
|
it('setStatus لا يُنشئ مفتاحاً لرحلة غير جارية', async () => {
|
|
await state.setStatus(TENANT, 'ghost', 'completed');
|
|
expect(await state.get(TENANT, 'ghost')).toBeNull();
|
|
});
|
|
|
|
it('clear يمسح الحالة والعروض معاً', async () => {
|
|
await state.save(makeTrip());
|
|
await state.addOffers(TENANT, 'trip-1', ['u1', 'u2']);
|
|
await state.clear(TENANT, 'trip-1');
|
|
|
|
expect(await state.get(TENANT, 'trip-1')).toBeNull();
|
|
expect(await state.takeLosingOffers(TENANT, 'trip-1', 'u1')).toEqual([]);
|
|
});
|
|
|
|
it('takeLosingOffers يستثني الفائز ويستهلك المجموعة مرة واحدة', async () => {
|
|
await state.addOffers(TENANT, 'trip-1', ['u1', 'u2', 'u3']);
|
|
|
|
const losers = await state.takeLosingOffers(TENANT, 'trip-1', 'u2');
|
|
expect(losers.sort()).toEqual(['u1', 'u3']);
|
|
// الاستدعاء الثاني فارغ — لا إشعارات مكرّرة لبقية السائقين
|
|
expect(await state.takeLosingOffers(TENANT, 'trip-1', 'u2')).toEqual([]);
|
|
});
|
|
});
|