feat: المجموعة G — Redis خط أول والقاعدة خط احتياط

الأساس: common/cache — كاش-جانبي فوق Redis. مبدأ صارم: فشل Redis لا يُسقط
الطلب (يُسجَّل ويُرجَع للقاعدة) — الكاش تحسين أداء لا مصدر حقيقة.

- G1: لغة المستخدم في Redis (كانت استعلاماً قبل كل إشعار)؛ تُكتب عند
  PATCH /users/me فيصير الإصابة دائمة
- G2/G3: توكنات FCM في Redis — يُبطَل عند register وعند اكتشاف توكن ميت.
  البث الجماعي صار بلا استعلامات قاعدة
- G4: كاش tenant resolve/config + إبطال عند الإنشاء
- G5: كاش التعرفة و ride-types + إبطال صريح عند أي إنشاء
- G6: التقييم يتراكم في Redis، والقاعدة تُكتب مرة واحدة يومياً لكل طرف
  (حجز الكتابة ذرّي بـLua). التأخير مقصود ليبعد الاحتكاك بين الطرفين.
  أُضيف ratings.target_user_id و users.rating — تقييم السائق للراكب كان
  يُخزَّن بلا هدف فلا يُجمَّع أبداً
- كاش الخرائط (route/reverse/geocode) بإحداثيات مقرَّبة 4 خانات (~11م):
  نداء انطلق كان يهيمن على p50 (2.9s). لا يُخزَّن الرجوع لخط مستقيم ولا
  استجابة فاشلة

تصحيح: ادّعيت سابقاً أن tenants.resolve يعمل على كل request — خطأ.
tenant_id يأتي من الـJWT؛ resolve يُستدعى عند الدخول و3 كنترولرات فقط.
الحمل الأثقل فعلياً هو رفع موقع السائق (المجموعة H).

هجرة: RatingsBothParties.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-07-17 02:39:34 +03:00
co-authored by Claude Opus 4.8
parent 857652404b
commit a83dd7e5e5
18 changed files with 694 additions and 85 deletions
+68
View File
@@ -0,0 +1,68 @@
import RedisMock from 'ioredis-mock';
import { CacheService, CacheKeys } from './cache.service';
describe('CacheService', () => {
let redis: any;
let cache: CacheService;
beforeEach(() => {
redis = new RedisMock({ keyPrefix: 'tripz:' });
cache = new CacheService(redis);
});
it('يخزّن ويقرأ قيمة مركّبة', async () => {
await cache.set('k', { a: 1, b: ['x'] }, 60);
expect(await cache.get<{ a: number; b: string[] }>('k')).toEqual({ a: 1, b: ['x'] });
});
it('يرجع null لمفتاح غائب', async () => {
expect(await cache.get('nope')).toBeNull();
});
it('wrap: يحمّل من المصدر مرة واحدة ثم يخدم من الكاش', async () => {
const loader = jest.fn().mockResolvedValue({ id: 't1' });
expect(await cache.wrap('k', 60, loader)).toEqual({ id: 't1' });
expect(await cache.wrap('k', 60, loader)).toEqual({ id: 't1' });
expect(loader).toHaveBeenCalledTimes(1);
});
it('wrap: لا يخزّن null — الغياب المؤقّت لا يُثبَّت', async () => {
const loader = jest.fn().mockResolvedValue(null);
expect(await cache.wrap('k', 60, loader)).toBeNull();
expect(await cache.wrap('k', 60, loader)).toBeNull();
expect(loader).toHaveBeenCalledTimes(2);
});
it('del يُبطل المفتاح فيعود المصدر', async () => {
const loader = jest.fn().mockResolvedValue('v1');
await cache.wrap('k', 60, loader);
await cache.del('k');
await cache.wrap('k', 60, loader);
expect(loader).toHaveBeenCalledTimes(2);
});
it('mget يقرأ عدة مفاتيح ويعطي null للغائب', async () => {
await cache.set('a', 1, 60);
await cache.set('c', 3, 60);
expect(await cache.mget<number>(['a', 'b', 'c'])).toEqual([1, null, 3]);
});
it('فشل Redis لا يُسقط الطلب — يرجع للمصدر', async () => {
const broken = {
get: jest.fn().mockRejectedValue(new Error('redis down')),
set: jest.fn().mockRejectedValue(new Error('redis down')),
} as any;
const c = new CacheService(broken);
const loader = jest.fn().mockResolvedValue('from-db');
expect(await c.wrap('k', 60, loader)).toBe('from-db');
expect(loader).toHaveBeenCalledTimes(1);
});
it('المفاتيح معزولة بالمستأجر', () => {
expect(CacheKeys.userLang('t1', 'u1')).not.toBe(CacheKeys.userLang('t2', 'u1'));
expect(CacheKeys.tariff('t1', 'amman', 'economy')).toBe('tariff:t1:amman:economy');
});
});