FRE-4521 Implement Redis integration for rate limiting and deduplication
- Add ioredis dependency for Redis connection pooling - Create RedisService singleton with connection management - Add Redis config (url, dedupWindowSeconds) to notification.config.ts - Implement NotificationService.checkRateLimit using Redis INCR+EXPIRE - Implement NotificationService.deduplicateNotification using Redis SET/NX - Add configurable rate limit windows and thresholds via env vars - Add 29 unit tests covering Redis operations, rate limiting, and dedup - All tests pass, TypeScript compiles cleanly for new files
This commit is contained in:
@@ -14,7 +14,8 @@
|
|||||||
"firebase-admin": "^12.0.0",
|
"firebase-admin": "^12.0.0",
|
||||||
"twilio": "^4.0.0",
|
"twilio": "^4.0.0",
|
||||||
"zod": "^3.22.0",
|
"zod": "^3.22.0",
|
||||||
"express": "^4.18.0"
|
"express": "^4.18.0",
|
||||||
|
"ioredis": "^5.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.0",
|
"@types/express": "^4.17.0",
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ export const NotificationConfigSchema = z.object({
|
|||||||
emailPerMinute: z.number().default(60),
|
emailPerMinute: z.number().default(60),
|
||||||
smsPerMinute: z.number().default(30),
|
smsPerMinute: z.number().default(30),
|
||||||
pushPerMinute: z.number().default(100),
|
pushPerMinute: z.number().default(100),
|
||||||
|
windowSeconds: z.number().default(60),
|
||||||
|
}),
|
||||||
|
redis: z.object({
|
||||||
|
url: z.string().default('redis://localhost:6379'),
|
||||||
|
dedupWindowSeconds: z.number().default(300),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,5 +60,10 @@ export const loadNotificationConfig = (): NotificationConfig => ({
|
|||||||
emailPerMinute: parseInt(process.env.EMAIL_RATE_LIMIT || '60', 10),
|
emailPerMinute: parseInt(process.env.EMAIL_RATE_LIMIT || '60', 10),
|
||||||
smsPerMinute: parseInt(process.env.SMS_RATE_LIMIT || '30', 10),
|
smsPerMinute: parseInt(process.env.SMS_RATE_LIMIT || '30', 10),
|
||||||
pushPerMinute: parseInt(process.env.PUSH_RATE_LIMIT || '100', 10),
|
pushPerMinute: parseInt(process.env.PUSH_RATE_LIMIT || '100', 10),
|
||||||
|
windowSeconds: parseInt(process.env.RATE_LIMIT_WINDOW_SECONDS || '60', 10),
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
url: process.env.REDIS_URL || 'redis://localhost:6379',
|
||||||
|
dedupWindowSeconds: parseInt(process.env.DEDUP_WINDOW_SECONDS || '300', 10),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
export { EmailService } from './services/email.service';
|
export { EmailService } from './services/email.service';
|
||||||
export { SMSService } from './services/sms.service';
|
export { SMSService } from './services/sms.service';
|
||||||
export { PushService } from './services/push.service';
|
export { PushService } from './services/push.service';
|
||||||
export { NotificationService } from './services/notification.service';
|
export { NotificationService, RateLimitResult } from './services/notification.service';
|
||||||
|
export { RedisService } from './services/redis.service';
|
||||||
export { TemplateService } from './services/template.service';
|
export { TemplateService } from './services/template.service';
|
||||||
export { loadNotificationConfig, NotificationConfigSchema } from './config/notification.config';
|
export { loadNotificationConfig, NotificationConfigSchema } from './config/notification.config';
|
||||||
export { notificationRoutes } from './routes/notification.routes';
|
export { notificationRoutes } from './routes/notification.routes';
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
import { NotificationService } from '../services/notification.service';
|
||||||
|
import { RedisService } from '../services/redis.service';
|
||||||
|
|
||||||
|
vi.mock('../services/email.service', () => ({
|
||||||
|
EmailService: {
|
||||||
|
getInstance: vi.fn(() => ({
|
||||||
|
send: vi.fn(async () => ({
|
||||||
|
notificationId: 'email-123',
|
||||||
|
channel: 'email',
|
||||||
|
status: 'sent',
|
||||||
|
externalId: 'ext-123',
|
||||||
|
})),
|
||||||
|
getRateLimitStatus: vi.fn(() => ({ remaining: 50, limit: 60 })),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/sms.service', () => ({
|
||||||
|
SMSService: {
|
||||||
|
getInstance: vi.fn(() => ({
|
||||||
|
send: vi.fn(async () => ({
|
||||||
|
notificationId: 'sms-123',
|
||||||
|
channel: 'sms',
|
||||||
|
status: 'sent',
|
||||||
|
externalId: 'ext-456',
|
||||||
|
})),
|
||||||
|
getRateLimitStatus: vi.fn(() => ({ remaining: 25, limit: 30 })),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../services/push.service', () => ({
|
||||||
|
PushService: {
|
||||||
|
getInstance: vi.fn(() => ({
|
||||||
|
send: vi.fn(async () => ({
|
||||||
|
notificationId: 'push-123',
|
||||||
|
channel: 'push',
|
||||||
|
status: 'sent',
|
||||||
|
externalId: 'ext-789',
|
||||||
|
})),
|
||||||
|
getRateLimitStatus: vi.fn(() => ({ remaining: 90, limit: 100 })),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../config/notification.config', () => ({
|
||||||
|
loadNotificationConfig: vi.fn(() => ({
|
||||||
|
resend: { apiKey: 'test', baseUrl: 'https://api.resend.com' },
|
||||||
|
fcm: { privateKey: 'test', projectId: 'test', clientEmail: 'test@test.com' },
|
||||||
|
apns: { key: 'test', keyId: 'test', teamId: 'test', bundleId: 'test' },
|
||||||
|
twilio: { accountSid: 'test', authToken: 'test', messagingServiceSid: 'test' },
|
||||||
|
rateLimits: {
|
||||||
|
emailPerMinute: 60,
|
||||||
|
smsPerMinute: 30,
|
||||||
|
pushPerMinute: 100,
|
||||||
|
windowSeconds: 60,
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
url: 'redis://localhost:6379',
|
||||||
|
dedupWindowSeconds: 300,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('NotificationService - Rate Limiting', () => {
|
||||||
|
let notificationService: NotificationService;
|
||||||
|
let redis: RedisService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
process.env.EMAIL_RATE_LIMIT = '60';
|
||||||
|
process.env.SMS_RATE_LIMIT = '30';
|
||||||
|
process.env.PUSH_RATE_LIMIT = '100';
|
||||||
|
process.env.RATE_LIMIT_WINDOW_SECONDS = '60';
|
||||||
|
process.env.DEDUP_WINDOW_SECONDS = '300';
|
||||||
|
|
||||||
|
redis = RedisService.getInstance({ url: 'redis://localhost:6379' });
|
||||||
|
await redis.getClient().flushdb();
|
||||||
|
|
||||||
|
notificationService = NotificationService.getInstance();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await redis.getClient().flushdb();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkRateLimit', () => {
|
||||||
|
it('should allow request within limit', async () => {
|
||||||
|
const result = await notificationService.checkRateLimit('user-1', 'email');
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.currentCount).toBe(1);
|
||||||
|
expect(result.limit).toBe(60);
|
||||||
|
expect(result.remaining).toBe(59);
|
||||||
|
expect(result.resetInSeconds).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow multiple requests within limit', async () => {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const result = await notificationService.checkRateLimit('user-2', 'email');
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.currentCount).toBe(i + 1);
|
||||||
|
expect(result.remaining).toBe(60 - (i + 1));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny request when limit is exceeded', async () => {
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
await notificationService.checkRateLimit('user-3', 'email');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await notificationService.checkRateLimit('user-3', 'email');
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.currentCount).toBe(61);
|
||||||
|
expect(result.remaining).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom limit when provided', async () => {
|
||||||
|
const result = await notificationService.checkRateLimit('user-4', 'email', 5);
|
||||||
|
expect(result.limit).toBe(5);
|
||||||
|
expect(result.currentCount).toBe(1);
|
||||||
|
expect(result.remaining).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom window when provided', async () => {
|
||||||
|
const result = await notificationService.checkRateLimit('user-5', 'email', 10, 120);
|
||||||
|
expect(result.resetInSeconds).toBeLessThanOrEqual(120);
|
||||||
|
expect(result.resetInSeconds).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use SMS default limit', async () => {
|
||||||
|
const result = await notificationService.checkRateLimit('user-6', 'sms');
|
||||||
|
expect(result.limit).toBe(30);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use push default limit', async () => {
|
||||||
|
const result = await notificationService.checkRateLimit('user-7', 'push');
|
||||||
|
expect(result.limit).toBe(100);
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track different identifiers independently', async () => {
|
||||||
|
await notificationService.checkRateLimit('user-a', 'email');
|
||||||
|
await notificationService.checkRateLimit('user-a', 'email');
|
||||||
|
|
||||||
|
const resultA = await notificationService.checkRateLimit('user-a', 'email');
|
||||||
|
expect(resultA.currentCount).toBe(3);
|
||||||
|
|
||||||
|
const resultB = await notificationService.checkRateLimit('user-b', 'email');
|
||||||
|
expect(resultB.currentCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track different channels independently', async () => {
|
||||||
|
await notificationService.checkRateLimit('user-8', 'email');
|
||||||
|
await notificationService.checkRateLimit('user-8', 'email');
|
||||||
|
|
||||||
|
const emailResult = await notificationService.checkRateLimit('user-8', 'email');
|
||||||
|
expect(emailResult.currentCount).toBe(3);
|
||||||
|
|
||||||
|
const smsResult = await notificationService.checkRateLimit('user-8', 'sms');
|
||||||
|
expect(smsResult.currentCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NotificationService - Deduplication', () => {
|
||||||
|
let notificationService: NotificationService;
|
||||||
|
let redis: RedisService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
process.env.EMAIL_RATE_LIMIT = '60';
|
||||||
|
process.env.SMS_RATE_LIMIT = '30';
|
||||||
|
process.env.PUSH_RATE_LIMIT = '100';
|
||||||
|
process.env.RATE_LIMIT_WINDOW_SECONDS = '60';
|
||||||
|
process.env.DEDUP_WINDOW_SECONDS = '300';
|
||||||
|
|
||||||
|
redis = RedisService.getInstance({ url: 'redis://localhost:6379' });
|
||||||
|
await redis.getClient().flushdb();
|
||||||
|
|
||||||
|
notificationService = NotificationService.getInstance();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await redis.getClient().flushdb();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deduplicateNotification', () => {
|
||||||
|
it('should return true for first notification', async () => {
|
||||||
|
const result = await notificationService.deduplicateNotification({
|
||||||
|
userId: 'user-1',
|
||||||
|
templateId: 'welcome-email',
|
||||||
|
key: 'initial',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for duplicate notification', async () => {
|
||||||
|
await notificationService.deduplicateNotification({
|
||||||
|
userId: 'user-2',
|
||||||
|
templateId: 'welcome-email',
|
||||||
|
key: 'initial',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await notificationService.deduplicateNotification({
|
||||||
|
userId: 'user-2',
|
||||||
|
templateId: 'welcome-email',
|
||||||
|
key: 'initial',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow different keys for same user', async () => {
|
||||||
|
await notificationService.deduplicateNotification({
|
||||||
|
userId: 'user-3',
|
||||||
|
templateId: 'welcome-email',
|
||||||
|
key: 'initial',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await notificationService.deduplicateNotification({
|
||||||
|
userId: 'user-3',
|
||||||
|
templateId: 'welcome-email',
|
||||||
|
key: 'followup',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow different users with same template', async () => {
|
||||||
|
await notificationService.deduplicateNotification({
|
||||||
|
userId: 'user-4',
|
||||||
|
templateId: 'welcome-email',
|
||||||
|
key: 'initial',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await notificationService.deduplicateNotification({
|
||||||
|
userId: 'user-5',
|
||||||
|
templateId: 'welcome-email',
|
||||||
|
key: 'initial',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom window when provided', async () => {
|
||||||
|
const result = await notificationService.deduplicateNotification(
|
||||||
|
{
|
||||||
|
userId: 'user-6',
|
||||||
|
templateId: 'test',
|
||||||
|
key: 'custom-window',
|
||||||
|
},
|
||||||
|
60
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
const ttl = await redis.getTTL('dedup:user-6:test:custom-window');
|
||||||
|
expect(ttl).toBeLessThanOrEqual(60);
|
||||||
|
expect(ttl).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use windowSeconds from dedupKey', async () => {
|
||||||
|
const result = await notificationService.deduplicateNotification({
|
||||||
|
userId: 'user-7',
|
||||||
|
templateId: 'test',
|
||||||
|
key: 'key-window',
|
||||||
|
windowSeconds: 120,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
const ttl = await redis.getTTL('dedup:user-7:test:key-window');
|
||||||
|
expect(ttl).toBeLessThanOrEqual(120);
|
||||||
|
expect(ttl).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default dedup window from config', async () => {
|
||||||
|
const result = await notificationService.deduplicateNotification({
|
||||||
|
userId: 'user-8',
|
||||||
|
templateId: 'test',
|
||||||
|
key: 'default-window',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
const ttl = await redis.getTTL('dedup:user-8:test:default-window');
|
||||||
|
expect(ttl).toBeLessThanOrEqual(300);
|
||||||
|
expect(ttl).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NotificationService - Rate Limit Config', () => {
|
||||||
|
let notificationService: NotificationService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
process.env.EMAIL_RATE_LIMIT = '60';
|
||||||
|
process.env.SMS_RATE_LIMIT = '30';
|
||||||
|
process.env.PUSH_RATE_LIMIT = '100';
|
||||||
|
process.env.RATE_LIMIT_WINDOW_SECONDS = '60';
|
||||||
|
process.env.DEDUP_WINDOW_SECONDS = '300';
|
||||||
|
|
||||||
|
notificationService = NotificationService.getInstance();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRateLimitConfig', () => {
|
||||||
|
it('should return configured rate limits', () => {
|
||||||
|
const config = notificationService.getRateLimitConfig();
|
||||||
|
|
||||||
|
expect(config.emailPerMinute).toBe(60);
|
||||||
|
expect(config.smsPerMinute).toBe(30);
|
||||||
|
expect(config.pushPerMinute).toBe(100);
|
||||||
|
expect(config.windowSeconds).toBe(60);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,8 @@ import { EmailService } from './email.service';
|
|||||||
import { SMSService } from './sms.service';
|
import { SMSService } from './sms.service';
|
||||||
import { PushService } from './push.service';
|
import { PushService } from './push.service';
|
||||||
import { TemplateService } from './template.service';
|
import { TemplateService } from './template.service';
|
||||||
|
import { RedisService } from './redis.service';
|
||||||
|
import { loadNotificationConfig } from '../config/notification.config';
|
||||||
import type {
|
import type {
|
||||||
Notification,
|
Notification,
|
||||||
NotificationChannel,
|
NotificationChannel,
|
||||||
@@ -11,11 +13,21 @@ import type {
|
|||||||
} from '../types/notification.types';
|
} from '../types/notification.types';
|
||||||
import type { TemplateResolutionOptions } from '../types/template.types';
|
import type { TemplateResolutionOptions } from '../types/template.types';
|
||||||
|
|
||||||
|
export interface RateLimitResult {
|
||||||
|
allowed: boolean;
|
||||||
|
currentCount: number;
|
||||||
|
limit: number;
|
||||||
|
remaining: number;
|
||||||
|
resetInSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class NotificationService {
|
export class NotificationService {
|
||||||
private static instance: NotificationService;
|
private static instance: NotificationService;
|
||||||
private emailService: EmailService;
|
private emailService: EmailService;
|
||||||
private smsService: SMSService;
|
private smsService: SMSService;
|
||||||
private pushService: PushService;
|
private pushService: PushService;
|
||||||
|
private redisService: RedisService;
|
||||||
|
private config: ReturnType<typeof loadNotificationConfig>;
|
||||||
private pendingDeduplication = new Map<string, Set<string>>();
|
private pendingDeduplication = new Map<string, Set<string>>();
|
||||||
private preferenceCache = new Map<string, NotificationPreference>();
|
private preferenceCache = new Map<string, NotificationPreference>();
|
||||||
|
|
||||||
@@ -23,6 +35,8 @@ export class NotificationService {
|
|||||||
this.emailService = EmailService.getInstance();
|
this.emailService = EmailService.getInstance();
|
||||||
this.smsService = SMSService.getInstance();
|
this.smsService = SMSService.getInstance();
|
||||||
this.pushService = PushService.getInstance();
|
this.pushService = PushService.getInstance();
|
||||||
|
this.config = loadNotificationConfig();
|
||||||
|
this.redisService = RedisService.getInstance({ url: this.config.redis.url });
|
||||||
}
|
}
|
||||||
|
|
||||||
static getInstance(): NotificationService {
|
static getInstance(): NotificationService {
|
||||||
@@ -204,7 +218,65 @@ export class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkRateLimit(
|
||||||
|
identifier: string,
|
||||||
|
channel: NotificationChannel,
|
||||||
|
customLimit?: number,
|
||||||
|
customWindowSeconds?: number
|
||||||
|
): Promise<RateLimitResult> {
|
||||||
|
const limit = customLimit || this.getLimitForChannel(channel);
|
||||||
|
const windowSeconds = customWindowSeconds || this.config.rateLimits.windowSeconds;
|
||||||
|
const key = `rl:${channel}:${identifier}`;
|
||||||
|
|
||||||
|
const currentCount = await this.redisService.increment(key, windowSeconds);
|
||||||
|
const ttl = await this.redisService.getTTL(key);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: currentCount <= limit,
|
||||||
|
currentCount,
|
||||||
|
limit,
|
||||||
|
remaining: Math.max(0, limit - currentCount),
|
||||||
|
resetInSeconds: Math.max(1, ttl),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deduplicateNotification(
|
||||||
|
dedupKey: DeduplicationKey,
|
||||||
|
customWindowSeconds?: number
|
||||||
|
): Promise<boolean> {
|
||||||
|
const dedupId = `dedup:${dedupKey.userId}:${dedupKey.templateId}:${dedupKey.key}`;
|
||||||
|
const windowSeconds = customWindowSeconds || dedupKey.windowSeconds || this.config.redis.dedupWindowSeconds;
|
||||||
|
|
||||||
|
const wasSet = await this.redisService.setIfNotExists(dedupId, '1', windowSeconds);
|
||||||
|
return wasSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRateLimitConfig(): {
|
||||||
|
emailPerMinute: number;
|
||||||
|
smsPerMinute: number;
|
||||||
|
pushPerMinute: number;
|
||||||
|
windowSeconds: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
emailPerMinute: this.config.rateLimits.emailPerMinute,
|
||||||
|
smsPerMinute: this.config.rateLimits.smsPerMinute,
|
||||||
|
pushPerMinute: this.config.rateLimits.pushPerMinute,
|
||||||
|
windowSeconds: this.config.rateLimits.windowSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
getTemplateService(): TemplateService {
|
getTemplateService(): TemplateService {
|
||||||
return TemplateService.getInstance();
|
return TemplateService.getInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getLimitForChannel(channel: NotificationChannel): number {
|
||||||
|
switch (channel) {
|
||||||
|
case 'email':
|
||||||
|
return this.config.rateLimits.emailPerMinute;
|
||||||
|
case 'sms':
|
||||||
|
return this.config.rateLimits.smsPerMinute;
|
||||||
|
case 'push':
|
||||||
|
return this.config.rateLimits.pushPerMinute;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
packages/shared-notifications/src/services/redis.service.test.ts
Normal file
107
packages/shared-notifications/src/services/redis.service.test.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { RedisService } from '../services/redis.service';
|
||||||
|
|
||||||
|
describe('RedisService', () => {
|
||||||
|
let redis: RedisService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
redis = RedisService.getInstance({ url: 'redis://localhost:6379' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
const client = redis.getClient();
|
||||||
|
await client.flushdb();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('increment', () => {
|
||||||
|
it('should increment counter and set expiry', async () => {
|
||||||
|
const count = await redis.increment('test:key', 60);
|
||||||
|
expect(count).toBe(1);
|
||||||
|
|
||||||
|
const count2 = await redis.increment('test:key', 60);
|
||||||
|
expect(count2).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set TTL on first increment', async () => {
|
||||||
|
await redis.increment('test:ttl', 60);
|
||||||
|
const ttl = await redis.getTTL('test:ttl');
|
||||||
|
expect(ttl).toBeGreaterThan(0);
|
||||||
|
expect(ttl).toBeLessThanOrEqual(60);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCounter', () => {
|
||||||
|
it('should return 0 for non-existent key', async () => {
|
||||||
|
const count = await redis.getCounter('test:nonexistent');
|
||||||
|
expect(count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return current count', async () => {
|
||||||
|
await redis.increment('test:counter', 60);
|
||||||
|
await redis.increment('test:counter', 60);
|
||||||
|
const count = await redis.getCounter('test:counter');
|
||||||
|
expect(count).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setIfNotExists', () => {
|
||||||
|
it('should return true on first set', async () => {
|
||||||
|
const result = await redis.setIfNotExists('test:unique', 'value', 60);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false on duplicate set', async () => {
|
||||||
|
await redis.setIfNotExists('test:dup', 'value', 60);
|
||||||
|
const result = await redis.setIfNotExists('test:dup', 'value', 60);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set TTL', async () => {
|
||||||
|
await redis.setIfNotExists('test:ttl', 'value', 60);
|
||||||
|
const ttl = await redis.getTTL('test:ttl');
|
||||||
|
expect(ttl).toBeGreaterThan(0);
|
||||||
|
expect(ttl).toBeLessThanOrEqual(60);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setWithExpiry', () => {
|
||||||
|
it('should set value with expiry', async () => {
|
||||||
|
const result = await redis.setWithExpiry('test:ex', 'value', 60);
|
||||||
|
expect(result).toBe('OK');
|
||||||
|
|
||||||
|
const value = await redis.get('test:ex');
|
||||||
|
expect(value).toBe('value');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('get', () => {
|
||||||
|
it('should return null for non-existent key', async () => {
|
||||||
|
const value = await redis.get('test:missing');
|
||||||
|
expect(value).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return value for existing key', async () => {
|
||||||
|
await redis.setWithExpiry('test:get', 'hello', 60);
|
||||||
|
const value = await redis.get('test:get');
|
||||||
|
expect(value).toBe('hello');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('should delete a key', async () => {
|
||||||
|
await redis.setWithExpiry('test:del', 'value', 60);
|
||||||
|
const deleted = await redis.delete('test:del');
|
||||||
|
expect(deleted).toBe(1);
|
||||||
|
|
||||||
|
const value = await redis.get('test:del');
|
||||||
|
expect(value).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isConnected', () => {
|
||||||
|
it('should return connection status', async () => {
|
||||||
|
const connected = await redis.isConnected();
|
||||||
|
expect(connected).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
73
packages/shared-notifications/src/services/redis.service.ts
Normal file
73
packages/shared-notifications/src/services/redis.service.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Redis, RedisOptions } from 'ioredis';
|
||||||
|
|
||||||
|
export interface RedisServiceConfig {
|
||||||
|
url?: string;
|
||||||
|
options?: RedisOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RedisService {
|
||||||
|
private static instance: RedisService;
|
||||||
|
private client: Redis;
|
||||||
|
|
||||||
|
private constructor(config?: RedisServiceConfig) {
|
||||||
|
const url = config?.url || process.env.REDIS_URL || 'redis://localhost:6379';
|
||||||
|
this.client = new Redis(url, config?.options || {});
|
||||||
|
}
|
||||||
|
|
||||||
|
static getInstance(config?: RedisServiceConfig): RedisService {
|
||||||
|
if (!RedisService.instance) {
|
||||||
|
RedisService.instance = new RedisService(config);
|
||||||
|
}
|
||||||
|
return RedisService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
getClient(): Redis {
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async isConnected(): Promise<boolean> {
|
||||||
|
return this.client.status === 'ready';
|
||||||
|
}
|
||||||
|
|
||||||
|
async increment(key: string, expirySeconds?: number): Promise<number> {
|
||||||
|
const current = await this.client.incr(key);
|
||||||
|
if (current === 1 && expirySeconds) {
|
||||||
|
await this.client.expire(key, expirySeconds);
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCounter(key: string): Promise<number> {
|
||||||
|
const value = await this.client.get(key);
|
||||||
|
return value ? parseInt(value, 10) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setWithExpiry(key: string, value: string, seconds: number): Promise<'OK' | 1> {
|
||||||
|
return this.client.setex(key, seconds, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setIfNotExists(key: string, value: string, seconds: number): Promise<boolean> {
|
||||||
|
const result = await this.client.set(key, value, 'EX', seconds, 'NX');
|
||||||
|
return result === 'OK';
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string): Promise<string | null> {
|
||||||
|
return this.client.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ttl(key: string): Promise<number> {
|
||||||
|
return this.client.ttl(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string): Promise<number> {
|
||||||
|
return this.client.del(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTTL(key: string): Promise<number> {
|
||||||
|
return this.client.ttl(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
await this.client.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,5 +86,6 @@ export interface DeduplicationKey {
|
|||||||
userId: string;
|
userId: string;
|
||||||
templateId: string;
|
templateId: string;
|
||||||
key: string;
|
key: string;
|
||||||
windowMinutes: number;
|
windowMinutes?: number;
|
||||||
|
windowSeconds?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
9
packages/shared-notifications/vitest.config.ts
Normal file
9
packages/shared-notifications/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user