Skip to content
Merged
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
160 changes: 160 additions & 0 deletions src/analytics/services/event-batching.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { EventBatchingService } from './event-batching.service';
import { AnalyticsEvent, EventType } from '../entities/event.entity';

function makeEvent(overrides: Partial<AnalyticsEvent> = {}): AnalyticsEvent {
return {
eventType: EventType.CUSTOM,
category: 'c',
action: 'a',
...overrides,
} as AnalyticsEvent;
}

describe('EventBatchingService', () => {
let service: EventBatchingService;
let repo: jest.Mocked<Repository<AnalyticsEvent>>;
const originalBatchSize = process.env.EVENT_BATCH_SIZE;
const originalFlushInterval = process.env.EVENT_FLUSH_INTERVAL_MS;

async function buildService(): Promise<void> {
const module: TestingModule = await Test.createTestingModule({
providers: [
EventBatchingService,
{
provide: getRepositoryToken(AnalyticsEvent),
useValue: { insert: jest.fn().mockResolvedValue(undefined) },
},
],
}).compile();

service = module.get<EventBatchingService>(EventBatchingService);
repo = module.get(getRepositoryToken(AnalyticsEvent));
}

afterEach(() => {
jest.useRealTimers();
process.env.EVENT_BATCH_SIZE = originalBatchSize;
process.env.EVENT_FLUSH_INTERVAL_MS = originalFlushInterval;
});

describe('addEvent', () => {
beforeEach(async () => {
process.env.EVENT_BATCH_SIZE = '3';
await buildService();
});

it('adds an event to the batch without flushing below the batch size', () => {
service.addEvent(makeEvent());
expect(service.getBatchSize()).toBe(1);
expect(repo.insert).not.toHaveBeenCalled();
});

it('flushes automatically once the batch reaches BATCH_SIZE', async () => {
service.addEvent(makeEvent());
service.addEvent(makeEvent());
service.addEvent(makeEvent());

// flushBatch() is fire-and-forget from addEvent — allow its microtask to settle.
await Promise.resolve();
await Promise.resolve();

expect(repo.insert).toHaveBeenCalledTimes(1);
expect(repo.insert).toHaveBeenCalledWith(expect.arrayContaining([expect.any(Object)]));
expect(service.getBatchSize()).toBe(0);
});

it('discards events received after shutdown has begun', () => {
service.onModuleDestroy();
service.addEvent(makeEvent());

expect(service.getBatchSize()).toBe(0);
});
});

describe('forceFlush', () => {
beforeEach(async () => {
process.env.EVENT_BATCH_SIZE = '100';
await buildService();
});

it('persists all pending events and clears the batch', async () => {
service.addEvent(makeEvent({ category: 'a' }));
service.addEvent(makeEvent({ category: 'b' }));

await service.forceFlush();

expect(repo.insert).toHaveBeenCalledTimes(1);
expect(repo.insert).toHaveBeenCalledWith([
expect.objectContaining({ category: 'a' }),
expect.objectContaining({ category: 'b' }),
]);
expect(service.getBatchSize()).toBe(0);
});

it('is a no-op when the batch is empty', async () => {
await service.forceFlush();
expect(repo.insert).not.toHaveBeenCalled();
});

it('re-queues events (up to the retry limit) and rethrows on a failed flush', async () => {
const error = new Error('insert failed');
repo.insert.mockRejectedValueOnce(error);
service.addEvent(makeEvent());

await expect(service.forceFlush()).rejects.toThrow(error);
expect(service.getBatchSize()).toBe(1);
});
});

describe('onModuleInit / onModuleDestroy', () => {
beforeEach(async () => {
jest.useFakeTimers();
process.env.EVENT_BATCH_SIZE = '100';
process.env.EVENT_FLUSH_INTERVAL_MS = '1000';
await buildService();
});

it('periodically flushes any pending events on the configured interval', async () => {
service.onModuleInit();
service.addEvent(makeEvent());

jest.advanceTimersByTime(1000);
await Promise.resolve();
await Promise.resolve();

expect(repo.insert).toHaveBeenCalledTimes(1);
});

it('does not flush on the interval when the batch is empty', () => {
service.onModuleInit();

jest.advanceTimersByTime(1000);

expect(repo.insert).not.toHaveBeenCalled();
});

it('stops the interval and performs a final flush of pending events', async () => {
service.onModuleInit();
service.addEvent(makeEvent());

await service.onModuleDestroy();

expect(repo.insert).toHaveBeenCalledTimes(1);

// Interval must be cleared — advancing time should not trigger another flush.
service.addEvent(makeEvent());
jest.advanceTimersByTime(5000);
expect(repo.insert).toHaveBeenCalledTimes(1);
});

it('returns undefined synchronously on destroy when there is nothing to flush', () => {
service.onModuleInit();

expect(service.onModuleDestroy()).toBeUndefined();
expect(repo.insert).not.toHaveBeenCalled();
});
});
});
169 changes: 169 additions & 0 deletions src/analytics/services/event-validation.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException } from '@nestjs/common';
import { EventValidationService } from './event-validation.service';
import { EventType } from '../entities/event.entity';

const VALID_UUID = '123e4567-e89b-12d3-a456-426614174000';

describe('EventValidationService', () => {
let service: EventValidationService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [EventValidationService],
}).compile();

service = module.get<EventValidationService>(EventValidationService);
});

describe('validateEvent', () => {
it('fails when eventType is missing', () => {
const result = service.validateEvent({});
expect(result).toEqual({ valid: false, errors: ['eventType is required'] });
});

it('allows an event type with no registered schema', () => {
const result = service.validateEvent({ eventType: EventType.LESSON_COMPLETE });
expect(result).toEqual({ valid: true, errors: [] });
});

it('reports every missing required field', () => {
const result = service.validateEvent({ eventType: EventType.SIGNUP });
expect(result.valid).toBe(false);
expect(result.errors).toEqual(
expect.arrayContaining([
'Required field missing: userId',
'Required field missing: category',
'Required field missing: action',
]),
);
});

it('passes a well-formed event that satisfies its schema', () => {
const result = service.validateEvent({
eventType: EventType.SIGNUP,
userId: VALID_UUID,
category: 'auth',
action: 'signup',
} as any);
expect(result).toEqual({ valid: true, errors: [] });
});

it('fails custom validation when userId is not a valid UUID', () => {
const result = service.validateEvent({
eventType: EventType.LOGIN,
userId: 'not-a-uuid',
category: 'auth',
action: 'login',
} as any);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Custom validation failed');
});

it('enforces minValue constraints', () => {
const result = service.validateEvent({
eventType: EventType.PURCHASE,
userId: VALID_UUID,
category: 'commerce',
action: 'purchase',
value: -5,
properties: { courseId: VALID_UUID },
} as any);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Value -5 is below minimum 0');
});

it('enforces maxValue constraints', () => {
service.registerSchema({
eventType: EventType.CUSTOM,
requiredFields: ['category', 'action'],
optionalFields: [],
valueConstraints: { maxValue: 10 },
});

const result = service.validateEvent({
eventType: EventType.CUSTOM,
category: 'c',
action: 'a',
value: 20,
} as any);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Value 20 exceeds maximum 10');
});

it('enforces allowedValues constraints', () => {
service.registerSchema({
eventType: EventType.CUSTOM,
requiredFields: ['category', 'action'],
optionalFields: [],
valueConstraints: { allowedValues: [1, 2, 3] },
});

const result = service.validateEvent({
eventType: EventType.CUSTOM,
category: 'c',
action: 'a',
value: 99,
} as any);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Value 99 is not in allowed values');
});

it('accumulates multiple distinct validation errors', () => {
const result = service.validateEvent({
eventType: EventType.PURCHASE,
value: -1,
} as any);
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(1);
});
});

describe('validateEventOrThrow', () => {
it('does not throw for a valid event', () => {
expect(() =>
service.validateEventOrThrow({
eventType: EventType.CUSTOM,
category: 'c',
action: 'a',
} as any),
).not.toThrow();
});

it('throws BadRequestException with the collected errors for an invalid event', () => {
expect(() => service.validateEventOrThrow({} as any)).toThrow(BadRequestException);
expect(() => service.validateEventOrThrow({} as any)).toThrow(/eventType is required/);
});
});

describe('registerSchema / getSchema', () => {
it('registers a new schema and makes it retrievable', () => {
const schema = {
eventType: EventType.WISHLIST_ADD,
requiredFields: ['userId'],
optionalFields: [],
};
service.registerSchema(schema);

expect(service.getSchema(EventType.WISHLIST_ADD)).toEqual(schema);
});

it('overwrites a previously registered schema for the same event type', () => {
const original = service.getSchema(EventType.SIGNUP);
expect(original).toBeDefined();

const replacement = {
eventType: EventType.SIGNUP,
requiredFields: [],
optionalFields: [],
};
service.registerSchema(replacement);

expect(service.getSchema(EventType.SIGNUP)).toEqual(replacement);
});

it('returns undefined for an event type with no schema', () => {
expect(service.getSchema(EventType.LESSON_COMPLETE)).toBeUndefined();
});
});
});
Loading
Loading