diff --git a/listener/src/services/subscription-service.test.ts b/listener/src/services/subscription-service.test.ts new file mode 100644 index 0000000..5fa352c --- /dev/null +++ b/listener/src/services/subscription-service.test.ts @@ -0,0 +1,224 @@ +import { SubscriptionService } from './subscription-service'; +import { SubscribeInput } from '../types/subscription'; + +describe('SubscriptionService', () => { + let service: SubscriptionService; + + beforeEach(() => { + service = new SubscriptionService(); + }); + + describe('subscribe', () => { + it('creates a new subscription successfully', () => { + const input: SubscribeInput = { + userId: 'user-1', + channel: 'discord', + }; + + const result = service.subscribe(input); + + expect(result.success).toBe(true); + expect(result.subscription).toBeDefined(); + expect(result.subscription?.userId).toBe('user-1'); + expect(result.subscription?.channel).toBe('discord'); + expect(result.subscription?.active).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('rejects duplicate subscriptions to the same channel', () => { + const input: SubscribeInput = { + userId: 'user-1', + channel: 'discord', + }; + + // First subscription succeeds + const firstResult = service.subscribe(input); + expect(firstResult.success).toBe(true); + expect(firstResult.subscription).toBeDefined(); + + const originalSubscription = firstResult.subscription; + + // Second subscription to same channel fails + const duplicateResult = service.subscribe(input); + expect(duplicateResult.success).toBe(false); + expect(duplicateResult.error).toBe('DUPLICATE_SUBSCRIPTION'); + expect(duplicateResult.message).toContain('already subscribed'); + expect(duplicateResult.subscription).toEqual(originalSubscription); + }); + + it('allows the same user to subscribe to different channels', () => { + const discordResult = service.subscribe({ + userId: 'user-1', + channel: 'discord', + }); + + const emailResult = service.subscribe({ + userId: 'user-1', + channel: 'email', + }); + + expect(discordResult.success).toBe(true); + expect(emailResult.success).toBe(true); + expect(service.getUserSubscriptions('user-1')).toHaveLength(2); + }); + + it('allows different users to subscribe to the same channel', () => { + const user1Result = service.subscribe({ + userId: 'user-1', + channel: 'discord', + }); + + const user2Result = service.subscribe({ + userId: 'user-2', + channel: 'discord', + }); + + expect(user1Result.success).toBe(true); + expect(user2Result.success).toBe(true); + expect(service.count()).toBe(2); + }); + + it('validates required input fields', () => { + const invalidInputs = [ + { userId: '', channel: 'discord' }, + { userId: 'user-1', channel: '' }, + { userId: '', channel: '' }, + ]; + + for (const input of invalidInputs) { + const result = service.subscribe(input); + expect(result.success).toBe(false); + expect(result.error).toBe('INVALID_INPUT'); + expect(result.subscription).toBeNull(); + } + }); + + it('preserves the original subscription when rejecting duplicates', () => { + const input: SubscribeInput = { + userId: 'user-1', + channel: 'discord', + }; + + // Create initial subscription + const firstResult = service.subscribe(input); + const originalId = firstResult.subscription!.id; + const originalCreatedAt = firstResult.subscription!.createdAt; + + // Wait a bit to ensure timestamps would differ + const later = Date.now() + 100; + jest.spyOn(Date, 'now').mockReturnValue(later); + + // Attempt duplicate subscription + const duplicateResult = service.subscribe(input); + + // Verify the returned subscription is the original, unchanged + expect(duplicateResult.subscription?.id).toBe(originalId); + expect(duplicateResult.subscription?.createdAt).toBe(originalCreatedAt); + expect(duplicateResult.subscription?.createdAt).not.toBe(later); + + jest.restoreAllMocks(); + }); + }); + + describe('isSubscribed', () => { + it('returns true for existing active subscription', () => { + service.subscribe({ userId: 'user-1', channel: 'discord' }); + expect(service.isSubscribed('user-1', 'discord')).toBe(true); + }); + + it('returns false for non-existent subscription', () => { + expect(service.isSubscribed('user-1', 'email')).toBe(false); + }); + }); + + describe('getUserSubscriptions', () => { + it('returns all subscriptions for a user', () => { + service.subscribe({ userId: 'user-1', channel: 'discord' }); + service.subscribe({ userId: 'user-1', channel: 'email' }); + service.subscribe({ userId: 'user-2', channel: 'discord' }); + + const subscriptions = service.getUserSubscriptions('user-1'); + expect(subscriptions).toHaveLength(2); + expect(subscriptions.map((s) => s.channel)).toContain('discord'); + expect(subscriptions.map((s) => s.channel)).toContain('email'); + }); + + it('returns empty array for user with no subscriptions', () => { + const subscriptions = service.getUserSubscriptions('user-999'); + expect(subscriptions).toEqual([]); + }); + }); + + describe('getSubscription', () => { + it('retrieves specific subscription by user and channel', () => { + service.subscribe({ userId: 'user-1', channel: 'discord' }); + + const subscription = service.getSubscription('user-1', 'discord'); + expect(subscription).toBeDefined(); + expect(subscription?.userId).toBe('user-1'); + expect(subscription?.channel).toBe('discord'); + }); + + it('returns null for non-existent subscription', () => { + const subscription = service.getSubscription('user-1', 'email'); + expect(subscription).toBeNull(); + }); + }); + + describe('unsubscribe', () => { + it('removes an existing subscription', () => { + service.subscribe({ userId: 'user-1', channel: 'discord' }); + expect(service.isSubscribed('user-1', 'discord')).toBe(true); + + const removed = service.unsubscribe('user-1', 'discord'); + expect(removed).toBe(true); + expect(service.isSubscribed('user-1', 'discord')).toBe(false); + }); + + it('allows re-subscription after unsubscribing', () => { + // Initial subscription + const first = service.subscribe({ userId: 'user-1', channel: 'discord' }); + expect(first.success).toBe(true); + + // Unsubscribe + service.unsubscribe('user-1', 'discord'); + + // Re-subscribe should succeed (not a duplicate since it was removed) + const second = service.subscribe({ userId: 'user-1', channel: 'discord' }); + expect(second.success).toBe(true); + expect(second.subscription?.id).not.toBe(first.subscription?.id); + }); + + it('returns false when unsubscribing non-existent subscription', () => { + const removed = service.unsubscribe('user-1', 'email'); + expect(removed).toBe(false); + }); + }); + + describe('count', () => { + it('returns correct subscription count', () => { + expect(service.count()).toBe(0); + + service.subscribe({ userId: 'user-1', channel: 'discord' }); + expect(service.count()).toBe(1); + + service.subscribe({ userId: 'user-1', channel: 'email' }); + expect(service.count()).toBe(2); + + service.unsubscribe('user-1', 'discord'); + expect(service.count()).toBe(1); + }); + }); + + describe('clear', () => { + it('removes all subscriptions', () => { + service.subscribe({ userId: 'user-1', channel: 'discord' }); + service.subscribe({ userId: 'user-2', channel: 'email' }); + expect(service.count()).toBe(2); + + service.clear(); + expect(service.count()).toBe(0); + expect(service.getUserSubscriptions('user-1')).toEqual([]); + }); + }); +}); diff --git a/listener/src/services/subscription-service.ts b/listener/src/services/subscription-service.ts new file mode 100644 index 0000000..b87f694 --- /dev/null +++ b/listener/src/services/subscription-service.ts @@ -0,0 +1,129 @@ +import { Subscription, SubscribeInput, SubscribeResult } from '../types/subscription'; + +/** + * SubscriptionService manages user subscriptions to notification channels. + * + * Core responsibilities: + * - Prevent duplicate subscriptions (same user + channel) + * - Track active subscriptions + * - Provide query interface for subscription state + * + * This service implements duplicate detection at the application layer, + * ensuring users cannot subscribe twice to the same channel. + */ +export class SubscriptionService { + private subscriptions = new Map(); + + /** + * Generate a unique fingerprint for a subscription. + * Format: "userId:channel" (e.g., "user-123:discord") + */ + private getFingerprint(userId: string, channel: string): string { + return `${userId}:${channel}`; + } + + /** + * Generate a unique subscription ID. + */ + private generateId(): string { + return `sub_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + } + + /** + * Subscribe a user to a notification channel. + * + * Returns existing subscription if already subscribed (idempotent duplicate rejection). + * + * @param input - User ID and channel to subscribe to + * @returns SubscribeResult with success flag and subscription or error + */ + subscribe(input: SubscribeInput): SubscribeResult { + const { userId, channel } = input; + + // Validate input + if (!userId || !channel) { + return { + success: false, + subscription: null, + error: 'INVALID_INPUT', + message: 'userId and channel are required', + }; + } + + const fingerprint = this.getFingerprint(userId, channel); + + // Check for duplicate subscription + if (this.subscriptions.has(fingerprint)) { + const existing = this.subscriptions.get(fingerprint)!; + return { + success: false, + subscription: existing, + error: 'DUPLICATE_SUBSCRIPTION', + message: `User ${userId} is already subscribed to channel ${channel}`, + }; + } + + // Create new subscription + const subscription: Subscription = { + id: this.generateId(), + userId, + channel, + createdAt: Date.now(), + active: true, + }; + + this.subscriptions.set(fingerprint, subscription); + + return { + success: true, + subscription, + }; + } + + /** + * Check if a user is subscribed to a specific channel. + */ + isSubscribed(userId: string, channel: string): boolean { + const fingerprint = this.getFingerprint(userId, channel); + const subscription = this.subscriptions.get(fingerprint); + return !!subscription && subscription.active; + } + + /** + * Get all active subscriptions for a user. + */ + getUserSubscriptions(userId: string): Subscription[] { + return Array.from(this.subscriptions.values()) + .filter((sub) => sub.userId === userId && sub.active); + } + + /** + * Get a specific subscription by user and channel. + */ + getSubscription(userId: string, channel: string): Subscription | null { + const fingerprint = this.getFingerprint(userId, channel); + return this.subscriptions.get(fingerprint) || null; + } + + /** + * Unsubscribe a user from a channel. + */ + unsubscribe(userId: string, channel: string): boolean { + const fingerprint = this.getFingerprint(userId, channel); + return this.subscriptions.delete(fingerprint); + } + + /** + * Get total subscription count (for testing/debugging). + */ + count(): number { + return this.subscriptions.size; + } + + /** + * Clear all subscriptions (for testing). + */ + clear(): void { + this.subscriptions.clear(); + } +} diff --git a/listener/src/types/subscription.ts b/listener/src/types/subscription.ts new file mode 100644 index 0000000..ed5b7a6 --- /dev/null +++ b/listener/src/types/subscription.ts @@ -0,0 +1,27 @@ +/** + * Subscription types for notification channel management. + * + * Subscriptions allow users to explicitly opt-in to specific notification channels. + * Unlike preferences (which toggle existing subscriptions), subscriptions represent + * active registrations that can be created, queried, and removed. + */ + +export interface Subscription { + id: string; + userId: string; + channel: string; // 'discord', 'email', 'telegram', etc. + createdAt: number; + active: boolean; +} + +export interface SubscribeInput { + userId: string; + channel: string; +} + +export interface SubscribeResult { + success: boolean; + subscription: Subscription | null; + error?: 'DUPLICATE_SUBSCRIPTION' | 'INVALID_INPUT'; + message?: string; +}