diff --git a/src/analytics/services/event-batching.service.spec.ts b/src/analytics/services/event-batching.service.spec.ts new file mode 100644 index 00000000..3ad72d35 --- /dev/null +++ b/src/analytics/services/event-batching.service.spec.ts @@ -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 { + return { + eventType: EventType.CUSTOM, + category: 'c', + action: 'a', + ...overrides, + } as AnalyticsEvent; +} + +describe('EventBatchingService', () => { + let service: EventBatchingService; + let repo: jest.Mocked>; + const originalBatchSize = process.env.EVENT_BATCH_SIZE; + const originalFlushInterval = process.env.EVENT_FLUSH_INTERVAL_MS; + + async function buildService(): Promise { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EventBatchingService, + { + provide: getRepositoryToken(AnalyticsEvent), + useValue: { insert: jest.fn().mockResolvedValue(undefined) }, + }, + ], + }).compile(); + + service = module.get(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(); + }); + }); +}); diff --git a/src/analytics/services/event-validation.service.spec.ts b/src/analytics/services/event-validation.service.spec.ts new file mode 100644 index 00000000..0e5746d6 --- /dev/null +++ b/src/analytics/services/event-validation.service.spec.ts @@ -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); + }); + + 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(); + }); + }); +}); diff --git a/src/assessment/grading/feedback-templates.service.spec.ts b/src/assessment/grading/feedback-templates.service.spec.ts new file mode 100644 index 00000000..f4041ac9 --- /dev/null +++ b/src/assessment/grading/feedback-templates.service.spec.ts @@ -0,0 +1,247 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { FeedbackTemplatesService } from './feedback-templates.service'; +import { FeedbackTemplate } from './entities/feedback-template.entity'; + +describe('FeedbackTemplatesService', () => { + let service: FeedbackTemplatesService; + let repo: jest.Mocked>; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FeedbackTemplatesService, + { + provide: getRepositoryToken(FeedbackTemplate), + useValue: { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + findAndCount: jest.fn(), + softDelete: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(FeedbackTemplatesService); + repo = module.get(getRepositoryToken(FeedbackTemplate)); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('create', () => { + it('creates and saves a template with defaults applied', async () => { + const dto = { name: 'Standard', body: 'Score: {{score}}' }; + const built = { ...dto, isDefault: false, ownerId: 'owner-1' }; + repo.create.mockReturnValue(built as FeedbackTemplate); + repo.save.mockResolvedValue({ id: 't1', ...built } as FeedbackTemplate); + + const result = await service.create(dto, 'owner-1'); + + expect(repo.create).toHaveBeenCalledWith({ + name: dto.name, + body: dto.body, + isDefault: false, + ownerId: 'owner-1', + }); + expect(repo.save).toHaveBeenCalledWith(built); + expect(result).toEqual({ id: 't1', ...built }); + }); + + it('respects an explicit isDefault flag', async () => { + repo.create.mockImplementation((v) => v as FeedbackTemplate); + repo.save.mockImplementation(async (v) => v as FeedbackTemplate); + + await service.create({ name: 'n', body: 'b', isDefault: true }); + + expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ isDefault: true })); + }); + }); + + describe('findOne', () => { + it('returns the template when found', async () => { + const tpl = { id: 't1' } as FeedbackTemplate; + repo.findOne.mockResolvedValue(tpl); + + await expect(service.findOne('t1')).resolves.toBe(tpl); + }); + + it('throws NotFoundException when missing', async () => { + repo.findOne.mockResolvedValue(null); + + await expect(service.findOne('missing')).rejects.toThrow(NotFoundException); + }); + }); + + describe('findAll', () => { + it('paginates and scopes by owner when provided', async () => { + const templates = [{ id: 't1' }] as FeedbackTemplate[]; + repo.findAndCount.mockResolvedValue([templates, 1]); + + const result = await service.findAll('owner-1', 1, 10); + + expect(repo.findAndCount).toHaveBeenCalledWith({ + where: { ownerId: 'owner-1' }, + order: { isDefault: 'DESC', createdAt: 'DESC' }, + skip: 0, + take: 10, + }); + expect(result.data).toBe(templates); + expect(result.totalPages).toBe(1); + }); + + it('does not scope by owner when omitted', async () => { + repo.findAndCount.mockResolvedValue([[], 0]); + + await service.findAll(); + + expect(repo.findAndCount).toHaveBeenCalledWith(expect.objectContaining({ where: {} })); + }); + }); + + describe('findDefault', () => { + it('queries for the default template scoped to the owner', async () => { + const tpl = { id: 't1', isDefault: true } as FeedbackTemplate; + repo.findOne.mockResolvedValue(tpl); + + const result = await service.findDefault('owner-1'); + + expect(repo.findOne).toHaveBeenCalledWith({ + where: { isDefault: true, ownerId: 'owner-1' }, + order: { createdAt: 'DESC' }, + }); + expect(result).toBe(tpl); + }); + + it('returns null when no default exists', async () => { + repo.findOne.mockResolvedValue(null); + + await expect(service.findDefault()).resolves.toBeNull(); + }); + }); + + describe('update', () => { + it('applies only the provided fields and saves', async () => { + const tpl = { id: 't1', name: 'old', body: 'old body', isDefault: false } as FeedbackTemplate; + repo.findOne.mockResolvedValue(tpl); + repo.save.mockImplementation(async (v) => v as FeedbackTemplate); + + const result = await service.update('t1', { name: 'new' }); + + expect(result.name).toBe('new'); + expect(result.body).toBe('old body'); + expect(repo.save).toHaveBeenCalledWith(tpl); + }); + + it('throws NotFoundException when the template does not exist', async () => { + repo.findOne.mockResolvedValue(null); + + await expect(service.update('missing', {})).rejects.toThrow(NotFoundException); + }); + + it('throws ForbiddenException when requester is not the owner', async () => { + const tpl = { id: 't1', ownerId: 'owner-1' } as FeedbackTemplate; + repo.findOne.mockResolvedValue(tpl); + + await expect(service.update('t1', { name: 'x' }, 'someone-else')).rejects.toThrow( + ForbiddenException, + ); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('allows the owner to update their own template', async () => { + const tpl = { id: 't1', ownerId: 'owner-1', name: 'old' } as FeedbackTemplate; + repo.findOne.mockResolvedValue(tpl); + repo.save.mockImplementation(async (v) => v as FeedbackTemplate); + + await expect(service.update('t1', { name: 'new' }, 'owner-1')).resolves.toEqual( + expect.objectContaining({ name: 'new' }), + ); + }); + }); + + describe('remove', () => { + it('soft-deletes the template', async () => { + const tpl = { id: 't1' } as FeedbackTemplate; + repo.findOne.mockResolvedValue(tpl); + + await service.remove('t1'); + + expect(repo.softDelete).toHaveBeenCalledWith('t1'); + }); + + it('throws ForbiddenException when requester is not the owner', async () => { + const tpl = { id: 't1', ownerId: 'owner-1' } as FeedbackTemplate; + repo.findOne.mockResolvedValue(tpl); + + await expect(service.remove('t1', 'someone-else')).rejects.toThrow(ForbiddenException); + expect(repo.softDelete).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when the template does not exist', async () => { + repo.findOne.mockResolvedValue(null); + + await expect(service.remove('missing')).rejects.toThrow(NotFoundException); + }); + }); + + describe('render', () => { + const baseCtx = { score: 8, maxScore: 10 }; + + it('substitutes score, maxScore, and percentage', () => { + const result = service.render('{{score}}/{{maxScore}} = {{percentage}}', baseCtx); + expect(result).toBe('8/10 = 80%'); + }); + + it('derives the verdict bucket from percentage thresholds', () => { + expect(service.render('{{verdict}}', { score: 9, maxScore: 10 })).toBe('Excellent'); + expect(service.render('{{verdict}}', { score: 5, maxScore: 10 })).toBe('Good'); + expect(service.render('{{verdict}}', { score: 1, maxScore: 10 })).toBe('Needs work'); + }); + + it('treats a zero or negative maxScore as 0% rather than dividing by zero', () => { + const result = service.render('{{percentage}}', { score: 5, maxScore: 0 }); + expect(result).toBe('0%'); + }); + + it('renders the rubric name and per-criterion points/levels', () => { + const ctx = { + score: 8, + maxScore: 10, + rubric: { + name: 'Essay Rubric', + criteria: [ + { + id: 'c1', + title: 'Clarity', + awardedPoints: 4, + selectedLevel: { label: 'Strong' }, + }, + ], + }, + }; + + const result = service.render( + '{{rubric}}: {{criterion.Clarity}} pts ({{level.clarity}})', + ctx, + ); + expect(result).toBe('Essay Rubric: 4 pts (Strong)'); + }); + + it('accepts a raw template string as well as a FeedbackTemplate entity', () => { + expect(service.render('plain: {{score}}', baseCtx)).toBe('plain: 8'); + expect(service.render({ body: 'entity: {{score}}' } as FeedbackTemplate, baseCtx)).toBe( + 'entity: 8', + ); + }); + + it('renders unknown placeholders and missing criteria as an empty string', () => { + expect(service.render('{{unknown}}', baseCtx)).toBe(''); + expect(service.render('{{criterion.Missing}}', baseCtx)).toBe(''); + expect(service.render('{{level.Missing}}', baseCtx)).toBe(''); + }); + }); +}); diff --git a/src/assessment/questions/question-bank.service.spec.ts b/src/assessment/questions/question-bank.service.spec.ts new file mode 100644 index 00000000..f3cbec96 --- /dev/null +++ b/src/assessment/questions/question-bank.service.spec.ts @@ -0,0 +1,92 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { QuestionBankService } from './question-bank.service'; +import { Question } from '../entities/question.entity'; + +describe('QuestionBankService', () => { + let service: QuestionBankService; + let repo: jest.Mocked>; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + QuestionBankService, + { + provide: getRepositoryToken(Question), + useValue: { + save: jest.fn(), + findAndCount: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(QuestionBankService); + repo = module.get(getRepositoryToken(Question)); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('create', () => { + it('saves and returns the question on success', async () => { + const input = { text: 'What is 2+2?' } as Partial; + const saved = { id: 'q1', ...input } as Question; + repo.save.mockResolvedValue(saved); + + const result = await service.create(input); + + expect(repo.save).toHaveBeenCalledWith(input); + expect(result).toBe(saved); + }); + + it('propagates a repository failure', async () => { + const error = new Error('db unavailable'); + repo.save.mockRejectedValue(error); + + await expect(service.create({})).rejects.toThrow(error); + }); + }); + + describe('findByAssessment', () => { + it('returns a paginated response built from the repository result', async () => { + const questions = [{ id: 'q1' }, { id: 'q2' }] as Question[]; + repo.findAndCount.mockResolvedValue([questions, 25]); + + const result = await service.findByAssessment('assessment-1', 2, 10); + + expect(repo.findAndCount).toHaveBeenCalledWith({ + where: { assessment: { id: 'assessment-1' } }, + order: { createdAt: 'DESC' }, + skip: 10, + take: 10, + }); + expect(result).toEqual({ + data: questions, + total: 25, + page: 2, + limit: 10, + totalPages: 3, + hasNextPage: true, + hasPrevPage: true, + }); + }); + + it('defaults to page 1 and limit 10 when not provided', async () => { + repo.findAndCount.mockResolvedValue([[], 0]); + + await service.findByAssessment('assessment-1'); + + expect(repo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ skip: 0, take: 10 }), + ); + }); + + it('propagates a repository failure', async () => { + const error = new Error('query failed'); + repo.findAndCount.mockRejectedValue(error); + + await expect(service.findByAssessment('assessment-1')).rejects.toThrow(error); + }); + }); +});