Skip to content

Commit 96a92a9

Browse files
committed
test: add unit tests for question-bank, feedback-templates, event-validation, event-batching services
Closes #1263, #1260, #1258, #1257 — four previously-untested services each get a focused *.spec.ts covering every public method's success and failure/edge-case paths: - QuestionBankService: create (success + repo failure), findByAssessment (pagination shape, defaults, repo failure). - FeedbackTemplatesService: create (defaults + explicit isDefault), findOne (found/NotFoundException), findAll (owner-scoped/unscoped), findDefault, update/remove (partial updates, NotFoundException, ForbiddenException on non-owner), and render (placeholder substitution, verdict thresholds, divide-by-zero guard, rubric/ criterion/level lookups, unknown placeholders). - EventValidationService: missing eventType, unregistered event type, missing required fields, value constraints (min/max/allowedValues), custom validation, validateEventOrThrow, registerSchema/getSchema. - EventBatchingService: addEvent (batch growth, auto-flush at BATCH_SIZE, shutdown discard), forceFlush (persists + clears, no-op on empty, re-queue + rethrow on failure), and the module lifecycle (periodic flush on the configured interval, final flush + interval teardown on destroy). Verified: `npx jest` on all four specs (50 passed), `npm run typecheck` (tsconfig.build.json, which these excluded *.spec.ts files don't affect anyway) and `eslint --fix` are clean.
1 parent 445f560 commit 96a92a9

4 files changed

Lines changed: 668 additions & 0 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { getRepositoryToken } from '@nestjs/typeorm';
3+
import { Repository } from 'typeorm';
4+
import { EventBatchingService } from './event-batching.service';
5+
import { AnalyticsEvent, EventType } from '../entities/event.entity';
6+
7+
function makeEvent(overrides: Partial<AnalyticsEvent> = {}): AnalyticsEvent {
8+
return {
9+
eventType: EventType.CUSTOM,
10+
category: 'c',
11+
action: 'a',
12+
...overrides,
13+
} as AnalyticsEvent;
14+
}
15+
16+
describe('EventBatchingService', () => {
17+
let service: EventBatchingService;
18+
let repo: jest.Mocked<Repository<AnalyticsEvent>>;
19+
const originalBatchSize = process.env.EVENT_BATCH_SIZE;
20+
const originalFlushInterval = process.env.EVENT_FLUSH_INTERVAL_MS;
21+
22+
async function buildService(): Promise<void> {
23+
const module: TestingModule = await Test.createTestingModule({
24+
providers: [
25+
EventBatchingService,
26+
{
27+
provide: getRepositoryToken(AnalyticsEvent),
28+
useValue: { insert: jest.fn().mockResolvedValue(undefined) },
29+
},
30+
],
31+
}).compile();
32+
33+
service = module.get<EventBatchingService>(EventBatchingService);
34+
repo = module.get(getRepositoryToken(AnalyticsEvent));
35+
}
36+
37+
afterEach(() => {
38+
jest.useRealTimers();
39+
process.env.EVENT_BATCH_SIZE = originalBatchSize;
40+
process.env.EVENT_FLUSH_INTERVAL_MS = originalFlushInterval;
41+
});
42+
43+
describe('addEvent', () => {
44+
beforeEach(async () => {
45+
process.env.EVENT_BATCH_SIZE = '3';
46+
await buildService();
47+
});
48+
49+
it('adds an event to the batch without flushing below the batch size', () => {
50+
service.addEvent(makeEvent());
51+
expect(service.getBatchSize()).toBe(1);
52+
expect(repo.insert).not.toHaveBeenCalled();
53+
});
54+
55+
it('flushes automatically once the batch reaches BATCH_SIZE', async () => {
56+
service.addEvent(makeEvent());
57+
service.addEvent(makeEvent());
58+
service.addEvent(makeEvent());
59+
60+
// flushBatch() is fire-and-forget from addEvent — allow its microtask to settle.
61+
await Promise.resolve();
62+
await Promise.resolve();
63+
64+
expect(repo.insert).toHaveBeenCalledTimes(1);
65+
expect(repo.insert).toHaveBeenCalledWith(expect.arrayContaining([expect.any(Object)]));
66+
expect(service.getBatchSize()).toBe(0);
67+
});
68+
69+
it('discards events received after shutdown has begun', () => {
70+
service.onModuleDestroy();
71+
service.addEvent(makeEvent());
72+
73+
expect(service.getBatchSize()).toBe(0);
74+
});
75+
});
76+
77+
describe('forceFlush', () => {
78+
beforeEach(async () => {
79+
process.env.EVENT_BATCH_SIZE = '100';
80+
await buildService();
81+
});
82+
83+
it('persists all pending events and clears the batch', async () => {
84+
service.addEvent(makeEvent({ category: 'a' }));
85+
service.addEvent(makeEvent({ category: 'b' }));
86+
87+
await service.forceFlush();
88+
89+
expect(repo.insert).toHaveBeenCalledTimes(1);
90+
expect(repo.insert).toHaveBeenCalledWith([
91+
expect.objectContaining({ category: 'a' }),
92+
expect.objectContaining({ category: 'b' }),
93+
]);
94+
expect(service.getBatchSize()).toBe(0);
95+
});
96+
97+
it('is a no-op when the batch is empty', async () => {
98+
await service.forceFlush();
99+
expect(repo.insert).not.toHaveBeenCalled();
100+
});
101+
102+
it('re-queues events (up to the retry limit) and rethrows on a failed flush', async () => {
103+
const error = new Error('insert failed');
104+
repo.insert.mockRejectedValueOnce(error);
105+
service.addEvent(makeEvent());
106+
107+
await expect(service.forceFlush()).rejects.toThrow(error);
108+
expect(service.getBatchSize()).toBe(1);
109+
});
110+
});
111+
112+
describe('onModuleInit / onModuleDestroy', () => {
113+
beforeEach(async () => {
114+
jest.useFakeTimers();
115+
process.env.EVENT_BATCH_SIZE = '100';
116+
process.env.EVENT_FLUSH_INTERVAL_MS = '1000';
117+
await buildService();
118+
});
119+
120+
it('periodically flushes any pending events on the configured interval', async () => {
121+
service.onModuleInit();
122+
service.addEvent(makeEvent());
123+
124+
jest.advanceTimersByTime(1000);
125+
await Promise.resolve();
126+
await Promise.resolve();
127+
128+
expect(repo.insert).toHaveBeenCalledTimes(1);
129+
});
130+
131+
it('does not flush on the interval when the batch is empty', () => {
132+
service.onModuleInit();
133+
134+
jest.advanceTimersByTime(1000);
135+
136+
expect(repo.insert).not.toHaveBeenCalled();
137+
});
138+
139+
it('stops the interval and performs a final flush of pending events', async () => {
140+
service.onModuleInit();
141+
service.addEvent(makeEvent());
142+
143+
await service.onModuleDestroy();
144+
145+
expect(repo.insert).toHaveBeenCalledTimes(1);
146+
147+
// Interval must be cleared — advancing time should not trigger another flush.
148+
service.addEvent(makeEvent());
149+
jest.advanceTimersByTime(5000);
150+
expect(repo.insert).toHaveBeenCalledTimes(1);
151+
});
152+
153+
it('returns undefined synchronously on destroy when there is nothing to flush', () => {
154+
service.onModuleInit();
155+
156+
expect(service.onModuleDestroy()).toBeUndefined();
157+
expect(repo.insert).not.toHaveBeenCalled();
158+
});
159+
});
160+
});
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { BadRequestException } from '@nestjs/common';
3+
import { EventValidationService } from './event-validation.service';
4+
import { EventType } from '../entities/event.entity';
5+
6+
const VALID_UUID = '123e4567-e89b-12d3-a456-426614174000';
7+
8+
describe('EventValidationService', () => {
9+
let service: EventValidationService;
10+
11+
beforeEach(async () => {
12+
const module: TestingModule = await Test.createTestingModule({
13+
providers: [EventValidationService],
14+
}).compile();
15+
16+
service = module.get<EventValidationService>(EventValidationService);
17+
});
18+
19+
describe('validateEvent', () => {
20+
it('fails when eventType is missing', () => {
21+
const result = service.validateEvent({});
22+
expect(result).toEqual({ valid: false, errors: ['eventType is required'] });
23+
});
24+
25+
it('allows an event type with no registered schema', () => {
26+
const result = service.validateEvent({ eventType: EventType.LESSON_COMPLETE });
27+
expect(result).toEqual({ valid: true, errors: [] });
28+
});
29+
30+
it('reports every missing required field', () => {
31+
const result = service.validateEvent({ eventType: EventType.SIGNUP });
32+
expect(result.valid).toBe(false);
33+
expect(result.errors).toEqual(
34+
expect.arrayContaining([
35+
'Required field missing: userId',
36+
'Required field missing: category',
37+
'Required field missing: action',
38+
]),
39+
);
40+
});
41+
42+
it('passes a well-formed event that satisfies its schema', () => {
43+
const result = service.validateEvent({
44+
eventType: EventType.SIGNUP,
45+
userId: VALID_UUID,
46+
category: 'auth',
47+
action: 'signup',
48+
} as any);
49+
expect(result).toEqual({ valid: true, errors: [] });
50+
});
51+
52+
it('fails custom validation when userId is not a valid UUID', () => {
53+
const result = service.validateEvent({
54+
eventType: EventType.LOGIN,
55+
userId: 'not-a-uuid',
56+
category: 'auth',
57+
action: 'login',
58+
} as any);
59+
expect(result.valid).toBe(false);
60+
expect(result.errors).toContain('Custom validation failed');
61+
});
62+
63+
it('enforces minValue constraints', () => {
64+
const result = service.validateEvent({
65+
eventType: EventType.PURCHASE,
66+
userId: VALID_UUID,
67+
category: 'commerce',
68+
action: 'purchase',
69+
value: -5,
70+
properties: { courseId: VALID_UUID },
71+
} as any);
72+
expect(result.valid).toBe(false);
73+
expect(result.errors).toContain('Value -5 is below minimum 0');
74+
});
75+
76+
it('enforces maxValue constraints', () => {
77+
service.registerSchema({
78+
eventType: EventType.CUSTOM,
79+
requiredFields: ['category', 'action'],
80+
optionalFields: [],
81+
valueConstraints: { maxValue: 10 },
82+
});
83+
84+
const result = service.validateEvent({
85+
eventType: EventType.CUSTOM,
86+
category: 'c',
87+
action: 'a',
88+
value: 20,
89+
} as any);
90+
expect(result.valid).toBe(false);
91+
expect(result.errors).toContain('Value 20 exceeds maximum 10');
92+
});
93+
94+
it('enforces allowedValues constraints', () => {
95+
service.registerSchema({
96+
eventType: EventType.CUSTOM,
97+
requiredFields: ['category', 'action'],
98+
optionalFields: [],
99+
valueConstraints: { allowedValues: [1, 2, 3] },
100+
});
101+
102+
const result = service.validateEvent({
103+
eventType: EventType.CUSTOM,
104+
category: 'c',
105+
action: 'a',
106+
value: 99,
107+
} as any);
108+
expect(result.valid).toBe(false);
109+
expect(result.errors).toContain('Value 99 is not in allowed values');
110+
});
111+
112+
it('accumulates multiple distinct validation errors', () => {
113+
const result = service.validateEvent({
114+
eventType: EventType.PURCHASE,
115+
value: -1,
116+
} as any);
117+
expect(result.valid).toBe(false);
118+
expect(result.errors.length).toBeGreaterThan(1);
119+
});
120+
});
121+
122+
describe('validateEventOrThrow', () => {
123+
it('does not throw for a valid event', () => {
124+
expect(() =>
125+
service.validateEventOrThrow({
126+
eventType: EventType.CUSTOM,
127+
category: 'c',
128+
action: 'a',
129+
} as any),
130+
).not.toThrow();
131+
});
132+
133+
it('throws BadRequestException with the collected errors for an invalid event', () => {
134+
expect(() => service.validateEventOrThrow({} as any)).toThrow(BadRequestException);
135+
expect(() => service.validateEventOrThrow({} as any)).toThrow(/eventType is required/);
136+
});
137+
});
138+
139+
describe('registerSchema / getSchema', () => {
140+
it('registers a new schema and makes it retrievable', () => {
141+
const schema = {
142+
eventType: EventType.WISHLIST_ADD,
143+
requiredFields: ['userId'],
144+
optionalFields: [],
145+
};
146+
service.registerSchema(schema);
147+
148+
expect(service.getSchema(EventType.WISHLIST_ADD)).toEqual(schema);
149+
});
150+
151+
it('overwrites a previously registered schema for the same event type', () => {
152+
const original = service.getSchema(EventType.SIGNUP);
153+
expect(original).toBeDefined();
154+
155+
const replacement = {
156+
eventType: EventType.SIGNUP,
157+
requiredFields: [],
158+
optionalFields: [],
159+
};
160+
service.registerSchema(replacement);
161+
162+
expect(service.getSchema(EventType.SIGNUP)).toEqual(replacement);
163+
});
164+
165+
it('returns undefined for an event type with no schema', () => {
166+
expect(service.getSchema(EventType.LESSON_COMPLETE)).toBeUndefined();
167+
});
168+
});
169+
});

0 commit comments

Comments
 (0)