Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions listener/src/services/subscription-service.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
});
129 changes: 129 additions & 0 deletions listener/src/services/subscription-service.ts
Original file line number Diff line number Diff line change
@@ -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<string, Subscription>();

/**
* 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();
}
}
27 changes: 27 additions & 0 deletions listener/src/types/subscription.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading