diff --git a/src/analytics/analytics-breakdown.integration.spec.ts b/src/analytics/analytics-breakdown.integration.spec.ts deleted file mode 100644 index 5ccfe77..0000000 --- a/src/analytics/analytics-breakdown.integration.spec.ts +++ /dev/null @@ -1,360 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import * as request from 'supertest'; -import { AnalyticsModule } from './analytics.module'; -import { AnalyticsEvent } from './entities/analytics-event.entity'; -import { TimeFilterModule } from '../timefilter/timefilter.module'; -import { AnalyticsController } from './analytics.controller'; -import { AnalyticsService } from './providers/analytics.service'; -import { AnalyticsExportService } from './providers/analytics-export.service'; -import { AnalyticsBreakdownService } from './providers/analytics-breakdown.service'; - -describe('Analytics Breakdown Integration', () => { - let app: INestApplication; - let analyticsRepository: Repository; - - beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [ - TypeOrmModule.forRoot({ - type: 'sqlite', - database: ':memory:', - entities: [AnalyticsEvent], - synchronize: true, - }), - AnalyticsModule, - TimeFilterModule, - ], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - - analyticsRepository = moduleFixture.get>( - getRepositoryToken(AnalyticsEvent), - ); - }); - - const mockAnalyticsBreakdownService = { - getBreakdown: jest.fn(), -}; - - - beforeEach(async () => { - // Clear database before each test - await analyticsRepository.clear(); - - // Insert test data - const testData: Partial[] = [ - { - eventType: 'question_view', - userId: 123, - metadata: { questionId: 'q1' }, - createdAt: new Date('2024-01-01T10:00:00Z'), - }, - { - eventType: 'question_view', - userId: 456, - metadata: { questionId: 'q2' }, - createdAt: new Date('2024-01-02T11:00:00Z'), - }, - { - eventType: 'answer_submit', - userId: 123, - metadata: { questionId: 'q1', correct: true }, - createdAt: new Date('2024-01-01T10:30:00Z'), - }, - { - eventType: 'puzzle_solved', - userId: 789, - metadata: { puzzleId: 'p1', difficulty: 'easy' }, - createdAt: new Date('2024-01-03T12:00:00Z'), - }, - { - eventType: 'streak_milestone', - userId: 123, - metadata: { milestone: 7, reward: 100 }, - createdAt: new Date('2024-01-04T13:00:00Z'), - }, - ]; - - await analyticsRepository.save(testData); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('GET /analytics/breakdown', () => { - it('should return analytics breakdown by event type', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/breakdown') - .expect(200); - - expect(response.body).toHaveProperty('breakdown'); - expect(response.body).toHaveProperty('totalEvents'); - expect(response.body).toHaveProperty('uniqueEventTypes'); - expect(response.body).toHaveProperty('dateRange'); - - expect(response.body.totalEvents).toBe(5); - expect(response.body.uniqueEventTypes).toBe(4); - - const breakdown = response.body.breakdown; - expect(Array.isArray(breakdown)).toBe(true); - expect(breakdown.length).toBe(4); - - // Check that event types are ordered by count descending - expect(breakdown[0].count).toBeGreaterThanOrEqual(breakdown[1].count); - }); - - it('should include friendly display names', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/breakdown') - .expect(200); - - const breakdown = response.body.breakdown; - - const questionView = breakdown.find(item => item.eventType === 'question_view'); - expect(questionView.displayName).toBe('Question Viewed'); - - const answerSubmit = breakdown.find(item => item.eventType === 'answer_submit'); - expect(answerSubmit.displayName).toBe('Answer Submitted'); - - const puzzleSolved = breakdown.find(item => item.eventType === 'puzzle_solved'); - expect(puzzleSolved.displayName).toBe('Puzzle Solved'); - }); - - it('should calculate percentages correctly', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/breakdown') - .expect(200); - - const breakdown = response.body.breakdown; - const totalEvents = response.body.totalEvents; - - breakdown.forEach(item => { - const expectedPercentage = Math.round((item.count / totalEvents) * 100 * 10) / 10; - expect(item.percentage).toBe(expectedPercentage); - }); - - // Sum of percentages should be 100 (or close due to rounding) - const sumPercentages = breakdown.reduce((sum, item) => sum + item.percentage, 0); - expect(sumPercentages).toBeCloseTo(100, 1); - }); - - it('should filter by time range', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/breakdown') - .query({ - from: '2024-01-01T00:00:00Z', - to: '2024-01-02T23:59:59Z', - }) - .expect(200); - - // Should only include events from Jan 1-2 - expect(response.body.totalEvents).toBe(3); // question_view (2) + answer_submit (1) - }); - - it('should filter by user ID', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/breakdown') - .query({ - userId: '123', - }) - .expect(200); - - // Should only include events for user 123 - expect(response.body.totalEvents).toBe(3); // question_view, answer_submit, streak_milestone - }); - - it('should handle empty results', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/breakdown') - .query({ - userId: '999999', // Non-existent user - }) - .expect(200); - - expect(response.body.totalEvents).toBe(0); - expect(response.body.uniqueEventTypes).toBe(0); - expect(response.body.breakdown).toEqual([]); - }); - - it('should return 400 for invalid date format', async () => { - await request(app.getHttpServer()) - .get('/analytics/breakdown') - .query({ - from: 'invalid-date', - }) - .expect(400); - }); - }); - - describe('GET /analytics/breakdown/top', () => { - it('should return top event types with default limit', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/breakdown/top') - .expect(200); - - expect(Array.isArray(response.body)).toBe(true); - expect(response.body.length).toBeLessThanOrEqual(10); - }); - - it('should return top event types with custom limit', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/breakdown/top') - .query({ limit: 3 }) - .expect(200); - - expect(response.body.length).toBeLessThanOrEqual(3); - }); - - it('should clamp limit to minimum value', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/breakdown/top') - .query({ limit: 0 }) - .expect(200); - - expect(response.body.length).toBeLessThanOrEqual(1); - }); - - it('should clamp limit to maximum value', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/breakdown/top') - .query({ limit: 100 }) - .expect(200); - - expect(response.body.length).toBeLessThanOrEqual(50); - }); - - it('should order by count descending', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/breakdown/top') - .query({ limit: 10 }) - .expect(200); - - for (let i = 0; i < response.body.length - 1; i++) { - expect(response.body[i].count).toBeGreaterThanOrEqual(response.body[i + 1].count); - } - }); - - it('should apply filters correctly', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/breakdown/top') - .query({ - limit: 10, - userId: '123', - }) - .expect(200); - - // Should only include events for user 123 - response.body.forEach(item => { - expect(item.count).toBeLessThanOrEqual(3); // Max 3 events for user 123 - }); - }); - }); - - describe('GET /analytics/breakdown/event-types', () => { - it('should return all available event types', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/breakdown/event-types') - .expect(200); - - expect(Array.isArray(response.body)).toBe(true); - expect(response.body).toContain('question_view'); - expect(response.body).toContain('answer_submit'); - expect(response.body).toContain('puzzle_solved'); - expect(response.body).toContain('streak_milestone'); - }); - - it('should return event types in alphabetical order', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/breakdown/event-types') - .expect(200); - - const sortedEventTypes = [...response.body].sort(); - expect(response.body).toEqual(sortedEventTypes); - }); - - it('should handle empty database', async () => { - // Clear database - await analyticsRepository.clear(); - - const response = await request(app.getHttpServer()) - .get('/analytics/breakdown/event-types') - .expect(200); - - expect(response.body).toEqual([]); - }); - }); - - describe('Data integrity and consistency', () => { - it('should maintain data consistency across endpoints', async () => { - // Get breakdown - const breakdownResponse = await request(app.getHttpServer()) - .get('/analytics/breakdown') - .expect(200); - - // Get top event types - const topResponse = await request(app.getHttpServer()) - .get('/analytics/breakdown/top') - .query({ limit: 10 }) - .expect(200); - - // Get available event types - const eventTypesResponse = await request(app.getHttpServer()) - .get('/analytics/breakdown/event-types') - .expect(200); - - // Verify consistency - expect(breakdownResponse.body.uniqueEventTypes).toBe(eventTypesResponse.body.length); - expect(topResponse.body.length).toBeLessThanOrEqual(eventTypesResponse.body.length); - - // Verify that all event types in breakdown exist in available event types - breakdownResponse.body.breakdown.forEach(item => { - expect(eventTypesResponse.body).toContain(item.eventType); - }); - }); - - it('should handle concurrent requests', async () => { - const promises = Array.from({ length: 5 }, () => - request(app.getHttpServer()) - .get('/analytics/breakdown') - .expect(200) - ); - - const responses = await Promise.all(promises); - - responses.forEach(response => { - expect(response.body).toHaveProperty('breakdown'); - expect(response.body).toHaveProperty('totalEvents'); - expect(response.body.totalEvents).toBe(5); - }); - }); - }); - - describe('Error handling', () => { - it('should handle database connection errors gracefully', async () => { - // This test would require mocking database failures - // For now, we'll test that the endpoint responds correctly to malformed requests - await request(app.getHttpServer()) - .get('/analytics/breakdown') - .query({ - from: 'invalid-date-format', - }) - .expect(400); - }); - - it('should handle missing query parameters', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/breakdown') - .expect(200); - - expect(response.body).toHaveProperty('breakdown'); - expect(response.body).toHaveProperty('totalEvents'); - }); - }); -}); \ No newline at end of file diff --git a/src/analytics/analytics-export.integration.spec.ts b/src/analytics/analytics-export.integration.spec.ts deleted file mode 100644 index 03ee363..0000000 --- a/src/analytics/analytics-export.integration.spec.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import * as request from 'supertest'; -import { AnalyticsModule } from './analytics.module'; -import { AnalyticsEvent } from './entities/analytics-event.entity'; -import { ExportFormat } from './dto/export-analytics-query.dto'; - -describe('Analytics Export Integration', () => { - let app: INestApplication; - let analyticsRepository: Repository; - - beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [ - TypeOrmModule.forRoot({ - type: 'sqlite', - database: ':memory:', - entities: [AnalyticsEvent], - synchronize: true, - }), - AnalyticsModule, - ], - }).compile(); - - app = moduleFixture.createNestApplication(); - await app.init(); - - analyticsRepository = moduleFixture.get>( - getRepositoryToken(AnalyticsEvent), - ); - }); - - beforeEach(async () => { - // Clear database before each test - await analyticsRepository.clear(); - - // Insert test data - const testData: Partial[] = [ - { - eventType: 'puzzle_solved', - userId: 123, - metadata: { puzzleId: 'puzzle-1', difficulty: 'easy' }, - createdAt: new Date('2024-01-01T10:00:00Z'), - }, - { - eventType: 'iq_question_answered', - userId: 456, - metadata: { questionId: 'iq-1', correct: true }, - createdAt: new Date('2024-01-02T11:00:00Z'), - }, - { - eventType: 'streak_milestone', - userId: 789, - metadata: { milestone: 7, reward: 100 }, - createdAt: new Date('2024-01-03T12:00:00Z'), - }, - ]; - - await analyticsRepository.save(testData); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('GET /analytics/export', () => { - it('should export analytics data in CSV format', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/export') - .query({ format: ExportFormat.CSV }) - .expect(200); - - expect(response.headers['content-type']).toContain('text/csv'); - expect(response.headers['content-disposition']).toContain('attachment'); - expect(response.headers['content-disposition']).toContain('.csv'); - expect(response.text).toContain('id,eventType,userId,timestamp,metadata'); - }); - - it('should export analytics data in PDF format', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/export') - .query({ format: ExportFormat.PDF }) - .expect(200); - - expect(response.headers['content-type']).toContain('application/pdf'); - expect(response.headers['content-disposition']).toContain('attachment'); - expect(response.headers['content-disposition']).toContain('.pdf'); - }); - - it('should default to CSV format when no format specified', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/export') - .expect(200); - - expect(response.headers['content-type']).toContain('text/csv'); - }); - - it('should filter by userId', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/export') - .query({ - format: ExportFormat.CSV, - userId: '123' - }) - .expect(200); - - expect(response.text).toContain('123'); - expect(response.text).not.toContain('456'); - }); - - it('should filter by time range', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/export') - .query({ - format: ExportFormat.CSV, - from: '2024-01-01T00:00:00Z', - to: '2024-01-02T23:59:59Z' - }) - .expect(200); - - // Should only include events from Jan 1-2 - expect(response.text).toContain('2024-01-01'); - expect(response.text).toContain('2024-01-02'); - expect(response.text).not.toContain('2024-01-03'); - }); - - it('should filter by event type using metadata', async () => { - // First, let's add an event with specific metadata - await analyticsRepository.save({ - eventType: 'puzzle_solved', - userId: 999, - metadata: { puzzleId: 'puzzle-2', difficulty: 'hard' }, - createdAt: new Date('2024-01-04T10:00:00Z'), - }); - - const response = await request(app.getHttpServer()) - .get('/analytics/export') - .query({ - format: ExportFormat.CSV, - from: '2024-01-04T00:00:00Z', - to: '2024-01-04T23:59:59Z' - }) - .expect(200); - - expect(response.text).toContain('puzzle-2'); - }); - - it('should handle empty results', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/export') - .query({ - format: ExportFormat.CSV, - userId: '999999' // Non-existent user - }) - .expect(200); - - expect(response.text).toContain('id,eventType,userId,timestamp,metadata'); - // Should only have header row - const lines = response.text.split('\n').filter(line => line.trim()); - expect(lines).toHaveLength(1); - }); - - it('should return 400 for invalid date format', async () => { - await request(app.getHttpServer()) - .get('/analytics/export') - .query({ - format: ExportFormat.CSV, - from: 'invalid-date' - }) - .expect(400); - }); - - it('should return 400 for invalid export format', async () => { - await request(app.getHttpServer()) - .get('/analytics/export') - .query({ - format: 'invalid-format' - }) - .expect(400); - }); - }); - - describe('Data integrity in exports', () => { - it('should include all required fields in CSV export', async () => { - const response = await request(app.getHttpServer()) - .get('/analytics/export') - .query({ format: ExportFormat.CSV }) - .expect(200); - - const lines = response.text.split('\n').filter(line => line.trim()); - expect(lines.length).toBeGreaterThan(1); // Header + data rows - - // Check header - const header = lines[0]; - expect(header).toContain('id'); - expect(header).toContain('eventType'); - expect(header).toContain('userId'); - expect(header).toContain('timestamp'); - expect(header).toContain('metadata'); - - // Check data row (first data row) - if (lines.length > 1) { - const dataRow = lines[1]; - const fields = dataRow.split(','); - expect(fields.length).toBeGreaterThanOrEqual(5); - } - }); - - it('should properly escape JSON metadata in CSV', async () => { - // Add event with complex metadata - await analyticsRepository.save({ - eventType: 'complex_event', - userId: 111, - metadata: { - nested: { value: 'test' }, - array: [1, 2, 3], - string: 'test,with,commas' - }, - createdAt: new Date('2024-01-05T10:00:00Z'), - }); - - const response = await request(app.getHttpServer()) - .get('/analytics/export') - .query({ - format: ExportFormat.CSV, - from: '2024-01-05T00:00:00Z', - to: '2024-01-05T23:59:59Z' - }) - .expect(200); - - expect(response.text).toContain('complex_event'); - expect(response.text).toContain('111'); - }); - }); -}); \ No newline at end of file diff --git a/src/analytics/analytics.controller.breakdown.spec.ts b/src/analytics/analytics.controller.breakdown.spec.ts deleted file mode 100644 index c21930f..0000000 --- a/src/analytics/analytics.controller.breakdown.spec.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AnalyticsController } from './analytics.controller'; -import { AnalyticsService } from './providers/analytics.service'; -import { AnalyticsExportService } from './providers/analytics-export.service'; -import { AnalyticsBreakdownService } from './providers/analytics-breakdown.service'; -import { GetAnalyticsQueryDto, TimeFilter } from './dto/get-analytics-query.dto'; -import { AnalyticsBreakdownResponse } from './dto/analytics-breakdown-response.dto'; -import { EventTypeBreakdown } from './dto/analytics-breakdown-response.dto'; - -describe('AnalyticsController - Breakdown Endpoints', () => { - let controller: AnalyticsController; - let analyticsService: jest.Mocked; - let analyticsExportService: jest.Mocked; - let analyticsBreakdownService: jest.Mocked; - - const mockBreakdownResponse: AnalyticsBreakdownResponse = { - breakdown: [ - { - eventType: 'question_view', - count: 124, - displayName: 'Question Viewed', - percentage: 58.8, - }, - { - eventType: 'answer_submit', - count: 87, - displayName: 'Answer Submitted', - percentage: 41.2, - }, - ], - totalEvents: 211, - uniqueEventTypes: 2, - dateRange: '2024-01-01 to 2024-01-31', - }; - - const mockTopEventTypes: EventTypeBreakdown[] = [ - { - eventType: 'question_view', - count: 124, - displayName: 'Question Viewed', - percentage: 58.8, - }, - { - eventType: 'answer_submit', - count: 87, - displayName: 'Answer Submitted', - percentage: 41.2, - }, - ]; - - const mockEventTypes = ['question_view', 'answer_submit', 'puzzle_solved']; - - beforeEach(async () => { - const mockAnalyticsService = { - getAnalytics: jest.fn(), - findAll: jest.fn(), - }; - - const mockAnalyticsExportService = { - exportAnalytics: jest.fn(), - generateFilename: jest.fn(), - }; - - const mockAnalyticsBreakdownService = { - getBreakdown: jest.fn(), - getTopEventTypes: jest.fn(), - getAvailableEventTypes: jest.fn(), - getBreakdownForEventTypes: jest.fn(), - }; - - const module: TestingModule = await Test.createTestingModule({ - controllers: [AnalyticsController], - providers: [ - { - provide: AnalyticsService, - useValue: mockAnalyticsService, - }, - { - provide: AnalyticsExportService, - useValue: mockAnalyticsExportService, - }, - { - provide: AnalyticsBreakdownService, - useValue: mockAnalyticsBreakdownService, - }, - ], - }).compile(); - - controller = module.get(AnalyticsController); - analyticsService = module.get(AnalyticsService); - analyticsExportService = module.get(AnalyticsExportService); - analyticsBreakdownService = module.get(AnalyticsBreakdownService); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); - - describe('getBreakdown', () => { - it('should return analytics breakdown', async () => { - const query: GetAnalyticsQueryDto = { - timeFilter: TimeFilter.WEEKLY, - userId: '123e4567-e89b-12d3-a456-426614174000', - }; - - analyticsBreakdownService.getBreakdown.mockResolvedValue(mockBreakdownResponse); - - const result = await controller.getBreakdown(query); - - expect(analyticsBreakdownService.getBreakdown).toHaveBeenCalledWith(query); - expect(result).toEqual(mockBreakdownResponse); - }); - - it('should handle empty query parameters', async () => { - const query: GetAnalyticsQueryDto = {}; - - analyticsBreakdownService.getBreakdown.mockResolvedValue(mockBreakdownResponse); - - const result = await controller.getBreakdown(query); - - expect(analyticsBreakdownService.getBreakdown).toHaveBeenCalledWith(query); - expect(result).toEqual(mockBreakdownResponse); - }); - - it('should handle service errors', async () => { - const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; - const error = new Error('Service error'); - - analyticsBreakdownService.getBreakdown.mockRejectedValue(error); - - await expect(controller.getBreakdown(query)).rejects.toThrow('Service error'); - }); - }); - - describe('getTopEventTypes', () => { - it('should return top event types with default limit', async () => { - const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; - - analyticsBreakdownService.getTopEventTypes.mockResolvedValue(mockTopEventTypes); - - const result = await controller.getTopEventTypes(undefined, query); - - expect(analyticsBreakdownService.getTopEventTypes).toHaveBeenCalledWith(10, query); - expect(result).toEqual(mockTopEventTypes); - }); - - it('should return top event types with custom limit', async () => { - const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; - const limit = 5; - - analyticsBreakdownService.getTopEventTypes.mockResolvedValue(mockTopEventTypes); - - const result = await controller.getTopEventTypes(limit, query); - - expect(analyticsBreakdownService.getTopEventTypes).toHaveBeenCalledWith(5, query); - expect(result).toEqual(mockTopEventTypes); - }); - - it('should clamp limit to minimum value', async () => { - const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; - const limit = 0; - - analyticsBreakdownService.getTopEventTypes.mockResolvedValue(mockTopEventTypes); - - const result = await controller.getTopEventTypes(limit, query); - - expect(analyticsBreakdownService.getTopEventTypes).toHaveBeenCalledWith(1, query); - expect(result).toEqual(mockTopEventTypes); - }); - - it('should clamp limit to maximum value', async () => { - const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; - const limit = 100; - - analyticsBreakdownService.getTopEventTypes.mockResolvedValue(mockTopEventTypes); - - const result = await controller.getTopEventTypes(limit, query); - - expect(analyticsBreakdownService.getTopEventTypes).toHaveBeenCalledWith(50, query); - expect(result).toEqual(mockTopEventTypes); - }); - - it('should handle service errors', async () => { - const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; - const error = new Error('Service error'); - - analyticsBreakdownService.getTopEventTypes.mockRejectedValue(error); - - await expect(controller.getTopEventTypes(10, query)).rejects.toThrow('Service error'); - }); - }); - - describe('getAvailableEventTypes', () => { - it('should return available event types', async () => { - analyticsBreakdownService.getAvailableEventTypes.mockResolvedValue(mockEventTypes); - - const result = await controller.getAvailableEventTypes(); - - expect(analyticsBreakdownService.getAvailableEventTypes).toHaveBeenCalled(); - expect(result).toEqual(mockEventTypes); - }); - - it('should handle empty event types', async () => { - analyticsBreakdownService.getAvailableEventTypes.mockResolvedValue([]); - - const result = await controller.getAvailableEventTypes(); - - expect(result).toEqual([]); - }); - - it('should handle service errors', async () => { - const error = new Error('Service error'); - - analyticsBreakdownService.getAvailableEventTypes.mockRejectedValue(error); - - await expect(controller.getAvailableEventTypes()).rejects.toThrow('Service error'); - }); - }); - - describe('query parameter validation', () => { - it('should handle all query parameters correctly', async () => { - const query: GetAnalyticsQueryDto = { - timeFilter: TimeFilter.MONTHLY, - from: '2024-01-01T00:00:00Z', - to: '2024-01-31T23:59:59Z', - userId: '123e4567-e89b-12d3-a456-426614174000', - sessionId: '456e7890-e89b-12d3-a456-426614174000', - }; - - analyticsBreakdownService.getBreakdown.mockResolvedValue(mockBreakdownResponse); - - const result = await controller.getBreakdown(query); - - expect(analyticsBreakdownService.getBreakdown).toHaveBeenCalledWith(query); - expect(result).toEqual(mockBreakdownResponse); - }); - - it('should handle partial query parameters', async () => { - const query: GetAnalyticsQueryDto = { - timeFilter: TimeFilter.WEEKLY, - userId: '123e4567-e89b-12d3-a456-426614174000', - }; - - analyticsBreakdownService.getBreakdown.mockResolvedValue(mockBreakdownResponse); - - const result = await controller.getBreakdown(query); - - expect(analyticsBreakdownService.getBreakdown).toHaveBeenCalledWith(query); - expect(result).toEqual(mockBreakdownResponse); - }); - }); - - describe('response structure validation', () => { - it('should return properly structured breakdown response', async () => { - const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; - - analyticsBreakdownService.getBreakdown.mockResolvedValue(mockBreakdownResponse); - - const result = await controller.getBreakdown(query); - - expect(result).toHaveProperty('breakdown'); - expect(result).toHaveProperty('totalEvents'); - expect(result).toHaveProperty('uniqueEventTypes'); - expect(result).toHaveProperty('dateRange'); - expect(Array.isArray(result.breakdown)).toBe(true); - }); - - it('should return properly structured top event types', async () => { - const query: GetAnalyticsQueryDto = { timeFilter: TimeFilter.WEEKLY }; - - analyticsBreakdownService.getTopEventTypes.mockResolvedValue(mockTopEventTypes); - - const result = await controller.getTopEventTypes(10, query); - - expect(Array.isArray(result)).toBe(true); - result.forEach(item => { - expect(item).toHaveProperty('eventType'); - expect(item).toHaveProperty('count'); - expect(item).toHaveProperty('displayName'); - expect(item).toHaveProperty('percentage'); - }); - }); - }); -}); \ No newline at end of file diff --git a/src/analytics/analytics.controller.spec.ts b/src/analytics/analytics.controller.spec.ts deleted file mode 100644 index b73f77a..0000000 --- a/src/analytics/analytics.controller.spec.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { Response } from 'express'; -import { AnalyticsController } from './analytics.controller'; -import { AnalyticsService } from './providers/analytics.service'; -import { AnalyticsExportService } from './providers/analytics-export.service'; -import { AnalyticsEvent } from './entities/analytics-event.entity'; -import { ExportFormat } from './dto/export-analytics-query.dto'; -import { AnalyticsBreakdownService } from './providers/analytics-breakdown.service'; - -describe('AnalyticsController', () => { - let controller: AnalyticsController; - let analyticsService: jest.Mocked; - let analyticsExportService: jest.Mocked; - let mockResponse: Partial; - - const mockAnalyticsData: AnalyticsEvent[] = [ - { - id: 1, - eventType: 'puzzle_solved', - userId: 123, - metadata: { puzzleId: 'puzzle-1', difficulty: 'easy' }, - createdAt: new Date('2024-01-01T10:00:00Z'), - }, - { - id: 2, - eventType: 'iq_question_answered', - userId: 456, - metadata: { questionId: 'iq-1', correct: true }, - createdAt: new Date('2024-01-02T11:00:00Z'), - }, - ]; - - beforeEach(async () => { - const mockAnalyticsService = { - getAnalytics: jest.fn(), - findAll: jest.fn(), - }; - - const mockAnalyticsExportService = { - exportAnalytics: jest.fn(), - generateFilename: jest.fn(), - }; - - const module: TestingModule = await Test.createTestingModule({ - controllers: [AnalyticsController], - providers: [ - { - provide: AnalyticsBreakdownService, - useValue: { - /* mock methods */ - }, - }, - { - provide: AnalyticsService, - useValue: mockAnalyticsService, - }, - { - provide: AnalyticsExportService, - useValue: mockAnalyticsExportService, - }, - ], - }).compile(); - - controller = module.get(AnalyticsController); - analyticsService = module.get(AnalyticsService); - analyticsExportService = module.get(AnalyticsExportService); - - mockResponse = { - setHeader: jest.fn(), - pipe: jest.fn(), - }; - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); - - describe('getAnalytics', () => { - it('should return analytics data', async () => { - const query = { userId: '123' }; - analyticsService.getAnalytics.mockResolvedValue(mockAnalyticsData); - - const result = await controller.getAnalytics(query); - - expect(analyticsService.getAnalytics).toHaveBeenCalledWith(query); - expect(result).toEqual(mockAnalyticsData); - }); - }); - - describe('exportAnalytics', () => { - it('should export analytics data in CSV format', async () => { - const query = { - format: ExportFormat.CSV, - userId: '123', - timeFilter: 'weekly' as any, - }; - - analyticsService.findAll.mockResolvedValue(mockAnalyticsData); - analyticsExportService.exportAnalytics.mockResolvedValue(undefined); - - await controller.exportAnalytics(query, mockResponse as Response); - - expect(analyticsService.findAll).toHaveBeenCalledWith(query); - expect(analyticsExportService.exportAnalytics).toHaveBeenCalledWith( - mockAnalyticsData, - ExportFormat.CSV, - mockResponse, - ); - }); - - it('should export analytics data in PDF format', async () => { - const query = { - format: ExportFormat.PDF, - from: '2024-01-01', - to: '2024-01-31', - }; - - analyticsService.findAll.mockResolvedValue(mockAnalyticsData); - analyticsExportService.exportAnalytics.mockResolvedValue(undefined); - - await controller.exportAnalytics(query, mockResponse as Response); - - expect(analyticsService.findAll).toHaveBeenCalledWith(query); - expect(analyticsExportService.exportAnalytics).toHaveBeenCalledWith( - mockAnalyticsData, - ExportFormat.PDF, - mockResponse, - ); - }); - - it('should default to CSV format when no format specified', async () => { - const query = { userId: '123' }; - - analyticsService.findAll.mockResolvedValue(mockAnalyticsData); - analyticsExportService.exportAnalytics.mockResolvedValue(undefined); - - await controller.exportAnalytics(query, mockResponse as Response); - - expect(analyticsExportService.exportAnalytics).toHaveBeenCalledWith( - mockAnalyticsData, - ExportFormat.CSV, - mockResponse, - ); - }); - - it('should handle empty analytics data', async () => { - const query = { format: ExportFormat.CSV }; - - analyticsService.findAll.mockResolvedValue([]); - analyticsExportService.exportAnalytics.mockResolvedValue(undefined); - - await controller.exportAnalytics(query, mockResponse as Response); - - expect(analyticsExportService.exportAnalytics).toHaveBeenCalledWith( - [], - ExportFormat.CSV, - mockResponse, - ); - }); - - it('should handle service errors', async () => { - const query = { format: ExportFormat.CSV }; - const error = new Error('Service error'); - - analyticsService.findAll.mockRejectedValue(error); - - await expect( - controller.exportAnalytics(query, mockResponse as Response), - ).rejects.toThrow('Service error'); - }); - - it('should handle export service errors', async () => { - const query = { format: ExportFormat.CSV }; - const error = new Error('Export error'); - - analyticsService.findAll.mockResolvedValue(mockAnalyticsData); - analyticsExportService.exportAnalytics.mockRejectedValue(error); - - await expect( - controller.exportAnalytics(query, mockResponse as Response), - ).rejects.toThrow('Export error'); - }); - }); -}); diff --git a/src/analytics/providers/analytics-breakdown.service.spec.ts b/src/analytics/providers/analytics-breakdown.service.spec.ts deleted file mode 100644 index 06ba48a..0000000 --- a/src/analytics/providers/analytics-breakdown.service.spec.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { AnalyticsBreakdownService } from './analytics-breakdown.service'; -import { AnalyticsEvent } from '../entities/analytics-event.entity'; -import { TimeFilterService } from 'src/timefilter/providers/timefilter.service'; -import { GetAnalyticsQueryDto } from '../dto/get-analytics-query.dto'; -import { EventTypeBreakdown, AnalyticsBreakdownResponse } from '../dto/analytics-breakdown-response.dto'; - -describe('AnalyticsBreakdownService', () => { - let service: AnalyticsBreakdownService; - let analyticsRepository: jest.Mocked>; - let timeFilterService: jest.Mocked; - - const mockRawResults = [ - { eventType: 'question_view', count: '124' }, - { eventType: 'answer_submit', count: '87' }, - { eventType: 'puzzle_solved', count: '45' }, - ]; - - const mockQuery: GetAnalyticsQueryDto = { - timeFilter: 'weekly' as any, - userId: '123e4567-e89b-12d3-a456-426614174000', - }; - - beforeEach(async () => { - const mockRepository = { - createQueryBuilder: jest.fn(() => ({ - select: jest.fn().mockReturnThis(), - addSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - groupBy: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - getRawMany: jest.fn().mockResolvedValue(mockRawResults), - })), - }; - - const mockTimeFilterService = { - resolveDateRange: jest.fn().mockReturnValue({ - from: new Date('2024-01-01'), - to: new Date('2024-01-31'), - }), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - AnalyticsBreakdownService, - { - provide: getRepositoryToken(AnalyticsEvent), - useValue: mockRepository, - }, - { - provide: TimeFilterService, - useValue: mockTimeFilterService, - }, - ], - }).compile(); - - service = module.get(AnalyticsBreakdownService); - analyticsRepository = module.get(getRepositoryToken(AnalyticsEvent)); - timeFilterService = module.get(TimeFilterService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('getBreakdown', () => { - it('should return analytics breakdown with proper structure', async () => { - const result = await service.getBreakdown(mockQuery); - - expect(result).toHaveProperty('breakdown'); - expect(result).toHaveProperty('totalEvents'); - expect(result).toHaveProperty('uniqueEventTypes'); - expect(result).toHaveProperty('dateRange'); - expect(Array.isArray(result.breakdown)).toBe(true); - }); - - it('should calculate percentages correctly', async () => { - const result = await service.getBreakdown(mockQuery); - - const totalEvents = 124 + 87 + 45; // 256 - const expectedPercentages = [ - Math.round((124 / totalEvents) * 100 * 10) / 10, // 48.4 - Math.round((87 / totalEvents) * 100 * 10) / 10, // 34.0 - Math.round((45 / totalEvents) * 100 * 10) / 10, // 17.6 - ]; - - result.breakdown.forEach((item, index) => { - expect(item.percentage).toBe(expectedPercentages[index]); - }); - }); - - it('should provide friendly display names', async () => { - const result = await service.getBreakdown(mockQuery); - - expect(result.breakdown[0].displayName).toBe('Question Viewed'); - expect(result.breakdown[1].displayName).toBe('Answer Submitted'); - expect(result.breakdown[2].displayName).toBe('Puzzle Solved'); - }); - - it('should handle empty results', async () => { - const mockEmptyRepository = { - createQueryBuilder: jest.fn(() => ({ - select: jest.fn().mockReturnThis(), - addSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - groupBy: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - getRawMany: jest.fn().mockResolvedValue([]), - })), - }; - - const module = await Test.createTestingModule({ - providers: [ - AnalyticsBreakdownService, - { - provide: getRepositoryToken(AnalyticsEvent), - useValue: mockEmptyRepository, - }, - { - provide: TimeFilterService, - useValue: timeFilterService, - }, - ], - }).compile(); - - const emptyService = module.get(AnalyticsBreakdownService); - const result = await emptyService.getBreakdown(mockQuery); - - expect(result.breakdown).toEqual([]); - expect(result.totalEvents).toBe(0); - expect(result.uniqueEventTypes).toBe(0); - }); - - it('should apply date filters correctly', async () => { - await service.getBreakdown(mockQuery); - - const queryBuilder = analyticsRepository.createQueryBuilder(); - expect(queryBuilder.andWhere).toHaveBeenCalledWith( - 'event.createdAt >= :from', - { from: new Date('2024-01-01') } - ); - expect(queryBuilder.andWhere).toHaveBeenCalledWith( - 'event.createdAt <= :to', - { to: new Date('2024-01-31') } - ); - }); - - it('should apply user filter when provided', async () => { - await service.getBreakdown(mockQuery); - - const queryBuilder = analyticsRepository.createQueryBuilder(); - expect(queryBuilder.andWhere).toHaveBeenCalledWith( - 'event.userId = :userId', - { userId: mockQuery.userId } - ); - }); - - it('should handle database errors gracefully', async () => { - const mockErrorRepository = { - createQueryBuilder: jest.fn(() => ({ - select: jest.fn().mockReturnThis(), - addSelect: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - groupBy: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - getRawMany: jest.fn().mockRejectedValue(new Error('Database error')), - })), - }; - - const module = await Test.createTestingModule({ - providers: [ - AnalyticsBreakdownService, - { - provide: getRepositoryToken(AnalyticsEvent), - useValue: mockErrorRepository, - }, - { - provide: TimeFilterService, - useValue: timeFilterService, - }, - ], - }).compile(); - - const errorService = module.get(AnalyticsBreakdownService); - - await expect(errorService.getBreakdown(mockQuery)).rejects.toThrow('Database error'); - }); - }); - - describe('getAvailableEventTypes', () => { - it('should return array of event types', async () => { - const mockEventTypes = ['question_view', 'answer_submit', 'puzzle_solved']; - const mockRepository = { - createQueryBuilder: jest.fn(() => ({ - select: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - getRawMany: jest.fn().mockResolvedValue( - mockEventTypes.map(type => ({ eventType: type })) - ), - })), - }; - - const module = await Test.createTestingModule({ - providers: [ - AnalyticsBreakdownService, - { - provide: getRepositoryToken(AnalyticsEvent), - useValue: mockRepository, - }, - { - provide: TimeFilterService, - useValue: timeFilterService, - }, - ], - }).compile(); - - const eventTypesService = module.get(AnalyticsBreakdownService); - const result = await eventTypesService.getAvailableEventTypes(); - - expect(result).toEqual(mockEventTypes); - }); - }); - - describe('getBreakdownForEventTypes', () => { - it('should filter by specific event types', async () => { - const eventTypes = ['question_view', 'answer_submit']; - const result = await service.getBreakdownForEventTypes(eventTypes, mockQuery); - - const queryBuilder = analyticsRepository.createQueryBuilder(); - expect(queryBuilder.where).toHaveBeenCalledWith( - 'event.eventType IN (:...eventTypes)', - { eventTypes } - ); - }); - - it('should return filtered breakdown', async () => { - const eventTypes = ['question_view', 'answer_submit']; - const result = await service.getBreakdownForEventTypes(eventTypes, mockQuery); - - expect(result.breakdown.length).toBeLessThanOrEqual(mockRawResults.length); - result.breakdown.forEach(item => { - expect(eventTypes).toContain(item.eventType); - }); - }); - }); - - describe('getTopEventTypes', () => { - it('should limit results to specified number', async () => { - const limit = 5; - await service.getTopEventTypes(limit, mockQuery); - - const queryBuilder = analyticsRepository.createQueryBuilder(); - expect(queryBuilder.limit).toHaveBeenCalledWith(limit); - }); - - it('should clamp limit between 1 and 50', async () => { - // Test lower bound - await service.getTopEventTypes(0, mockQuery); - let queryBuilder = analyticsRepository.createQueryBuilder(); - expect(queryBuilder.limit).toHaveBeenCalledWith(1); - - // Test upper bound - await service.getTopEventTypes(100, mockQuery); - queryBuilder = analyticsRepository.createQueryBuilder(); - expect(queryBuilder.limit).toHaveBeenCalledWith(50); - }); - - it('should return top event types ordered by count', async () => { - const result = await service.getTopEventTypes(10, mockQuery); - - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeLessThanOrEqual(10); - }); - }); - - describe('display name formatting', () => { - it('should format unknown event types correctly', () => { - const service = new AnalyticsBreakdownService( - analyticsRepository as any, - timeFilterService as any - ); - - // Access private method through any - const formatEventTypeName = (service as any).formatEventTypeName; - expect(formatEventTypeName('custom_event_type')).toBe('Custom Event Type'); - expect(formatEventTypeName('single_word')).toBe('Single Word'); - }); - - it('should use predefined display names for known event types', () => { - const service = new AnalyticsBreakdownService( - analyticsRepository as any, - timeFilterService as any - ); - - const getDisplayName = (service as any).getDisplayName; - expect(getDisplayName('question_view')).toBe('Question Viewed'); - expect(getDisplayName('puzzle_solved')).toBe('Puzzle Solved'); - }); - }); - - describe('date range formatting', () => { - it('should format date range correctly', () => { - const service = new AnalyticsBreakdownService( - analyticsRepository as any, - timeFilterService as any - ); - - const generateDateRangeString = (service as any).generateDateRangeString; - - expect(generateDateRangeString()).toBe('All time'); - expect(generateDateRangeString(new Date('2024-01-01'))).toBe('From 2024-01-01'); - expect(generateDateRangeString(undefined, new Date('2024-01-31'))).toBe('Until 2024-01-31'); - expect(generateDateRangeString(new Date('2024-01-01'), new Date('2024-01-31'))).toBe('2024-01-01 to 2024-01-31'); - }); - }); -}); \ No newline at end of file diff --git a/src/analytics/providers/analytics-export.service.spec.ts b/src/analytics/providers/analytics-export.service.spec.ts deleted file mode 100644 index 3483720..0000000 --- a/src/analytics/providers/analytics-export.service.spec.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { Response } from 'express'; -import { AnalyticsExportService } from './analytics-export.service'; -import { AnalyticsEvent } from '../entities/analytics-event.entity'; -import { ExportFormat } from '../dto/export-analytics-query.dto'; - -describe('AnalyticsExportService', () => { - let service: AnalyticsExportService; - let mockResponse: Partial; - - const mockAnalyticsData: AnalyticsEvent[] = [ - { - id: 1, - eventType: 'puzzle_solved', - userId: 123, - metadata: { puzzleId: 'puzzle-1', difficulty: 'easy' }, - createdAt: new Date('2024-01-01T10:00:00Z'), - }, - { - id: 2, - eventType: 'iq_question_answered', - userId: 456, - metadata: { questionId: 'iq-1', correct: true }, - createdAt: new Date('2024-01-02T11:00:00Z'), - }, - ]; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [AnalyticsExportService], - }).compile(); - - service = module.get(AnalyticsExportService); - - // Mock response object - mockResponse = { - setHeader: jest.fn(), - pipe: jest.fn(), - }; - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('exportAnalytics', () => { - it('should export data in CSV format', async () => { - const mockCsvWrite = jest.fn().mockReturnValue({ - pipe: jest.fn(), - }); - - // Mock fast-csv - jest.doMock('fast-csv', () => ({ - write: mockCsvWrite, - })); - - await service.exportAnalytics(mockAnalyticsData, ExportFormat.CSV, mockResponse as Response); - - expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Type', 'text/csv'); - expect(mockResponse.setHeader).toHaveBeenCalledWith( - 'Content-Disposition', - expect.stringContaining('attachment; filename="analytics-export-') - ); - }); - - it('should export data in PDF format', async () => { - const mockPdfDocument = { - pipe: jest.fn(), - fontSize: jest.fn().mockReturnThis(), - font: jest.fn().mockReturnThis(), - text: jest.fn().mockReturnThis(), - moveDown: jest.fn().mockReturnThis(), - end: jest.fn(), - }; - - // Mock pdfkit - jest.doMock('pdfkit', () => { - return jest.fn().mockImplementation(() => mockPdfDocument); - }); - - await service.exportAnalytics(mockAnalyticsData, ExportFormat.PDF, mockResponse as Response); - - expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Type', 'application/pdf'); - expect(mockResponse.setHeader).toHaveBeenCalledWith( - 'Content-Disposition', - expect.stringContaining('attachment; filename="analytics-export-') - ); - }); - - it('should throw error for unsupported format', async () => { - await expect( - service.exportAnalytics(mockAnalyticsData, 'unsupported' as ExportFormat, mockResponse as Response) - ).rejects.toThrow('Unsupported export format: unsupported'); - }); - - it('should handle empty data array', async () => { - const mockCsvWrite = jest.fn().mockReturnValue({ - pipe: jest.fn(), - }); - - jest.doMock('fast-csv', () => ({ - write: mockCsvWrite, - })); - - await service.exportAnalytics([], ExportFormat.CSV, mockResponse as Response); - - expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Type', 'text/csv'); - }); - }); - - describe('generateFilename', () => { - it('should generate CSV filename with timestamp', () => { - const filename = service.generateFilename(ExportFormat.CSV); - expect(filename).toMatch(/^analytics-export-.*\.csv$/); - }); - - it('should generate PDF filename with timestamp', () => { - const filename = service.generateFilename(ExportFormat.PDF); - expect(filename).toMatch(/^analytics-export-.*\.pdf$/); - }); - }); - - describe('error handling', () => { - it('should handle CSV export errors', async () => { - const mockCsvWrite = jest.fn().mockImplementation(() => { - throw new Error('CSV write error'); - }); - - jest.doMock('fast-csv', () => ({ - write: mockCsvWrite, - })); - - await expect( - service.exportAnalytics(mockAnalyticsData, ExportFormat.CSV, mockResponse as Response) - ).rejects.toThrow('CSV write error'); - }); - - it('should handle PDF export errors', async () => { - const mockPdfDocument = { - pipe: jest.fn().mockImplementation(() => { - throw new Error('PDF creation error'); - }), - }; - - jest.doMock('pdfkit', () => { - return jest.fn().mockImplementation(() => mockPdfDocument); - }); - - await expect( - service.exportAnalytics(mockAnalyticsData, ExportFormat.PDF, mockResponse as Response) - ).rejects.toThrow('PDF creation error'); - }); - }); -}); \ No newline at end of file diff --git a/src/gamification/gamification.controller.spec.ts b/src/gamification/gamification.controller.spec.ts deleted file mode 100644 index 3ac7ec1..0000000 --- a/src/gamification/gamification.controller.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { GamificationController } from './gamification.controller'; -import { GamificationService } from './gamification.service'; - -describe('GamificationController', () => { - let controller: GamificationController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [GamificationController], - providers: [ - { - provide: GamificationService, - useValue: {}, - }, - ], - }).compile(); - - controller = module.get(GamificationController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); \ No newline at end of file diff --git a/src/gamification/gamification.service.spec.ts b/src/gamification/gamification.service.spec.ts deleted file mode 100644 index c152dad..0000000 --- a/src/gamification/gamification.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { GamificationService } from './gamification.service'; - -describe('GamificationService', () => { - let service: GamificationService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [GamificationService], - }).compile(); - - service = module.get(GamificationService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/src/gamification/listeners/streak.listener.spec.ts b/src/gamification/listeners/streak.listener.spec.ts deleted file mode 100644 index f51297c..0000000 --- a/src/gamification/listeners/streak.listener.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { StreakListener } from './streak.listener'; -import { DailyStreakService } from '../providers/daily-streak.service'; -import { PuzzleSubmissionDto } from '../dto/puzzle-submission.dto'; - -describe('StreakListener', () => { - let listener: StreakListener; - let streakService: DailyStreakService; - - const mockStreakService = { - updateStreak: jest.fn(), - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - StreakListener, - { - provide: DailyStreakService, - useValue: mockStreakService, - }, - ], - }).compile(); - - listener = module.get(StreakListener); - streakService = module.get(DailyStreakService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('handlePuzzleSubmission', () => { - it('should update streak when puzzle is solved correctly', async () => { - const puzzleSubmission: PuzzleSubmissionDto = { - userId: 1, - puzzleId: 101, - isCorrect: true, - timestamp: new Date(), - }; - - mockStreakService.updateStreak.mockResolvedValue({ - streakCount: 5, - longestStreak: 10, - hasSolvedToday: true, - }); - - await listener.handlePuzzleSubmission(puzzleSubmission); - - expect(mockStreakService.updateStreak).toHaveBeenCalledWith(1); - }); - - it('should not update streak when puzzle is solved incorrectly', async () => { - const puzzleSubmission: PuzzleSubmissionDto = { - userId: 1, - puzzleId: 101, - isCorrect: false, - timestamp: new Date(), - }; - - await listener.handlePuzzleSubmission(puzzleSubmission); - - expect(mockStreakService.updateStreak).not.toHaveBeenCalled(); - }); - - it('should handle errors gracefully', async () => { - const puzzleSubmission: PuzzleSubmissionDto = { - userId: 1, - puzzleId: 101, - isCorrect: true, - timestamp: new Date(), - }; - - mockStreakService.updateStreak.mockRejectedValue(new Error('Database error')); - - // Should not throw error - await expect(listener.handlePuzzleSubmission(puzzleSubmission)).resolves.not.toThrow(); - }); - }); - - describe('handleIQQuestionAnswered', () => { - it('should update streak when IQ question is answered correctly', async () => { - const iqData = { - userId: 1, - isCorrect: true, - }; - - mockStreakService.updateStreak.mockResolvedValue({ - streakCount: 3, - longestStreak: 7, - hasSolvedToday: true, - }); - - await listener.handleIQQuestionAnswered(iqData); - - expect(mockStreakService.updateStreak).toHaveBeenCalledWith(1); - }); - - it('should not update streak when IQ question is answered incorrectly', async () => { - const iqData = { - userId: 1, - isCorrect: false, - }; - - await listener.handleIQQuestionAnswered(iqData); - - expect(mockStreakService.updateStreak).not.toHaveBeenCalled(); - }); - - it('should handle errors gracefully for IQ questions', async () => { - const iqData = { - userId: 1, - isCorrect: true, - }; - - mockStreakService.updateStreak.mockRejectedValue(new Error('Database error')); - - // Should not throw error - await expect(listener.handleIQQuestionAnswered(iqData)).resolves.not.toThrow(); - }); - }); -}); \ No newline at end of file diff --git a/src/iq-assessment/iq-assessment.service.spec.ts b/src/iq-assessment/iq-assessment.service.spec.ts deleted file mode 100644 index 69347bf..0000000 --- a/src/iq-assessment/iq-assessment.service.spec.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; -import { HttpService } from '@nestjs/axios'; -import { Repository } from 'typeorm'; -import { IQAssessmentService } from './providers/iq-assessment.service'; -import { IqAttemptService } from './providers/iq-attempt.service'; -import { IQAssessmentSession } from './entities/iq-assessment-session.entity'; -import { - IQQuestion, - QuestionDifficulty, - QuestionCategory, -} from './entities/iq-question.entity'; -import { IQAnswer } from './entities/iq-answer.entity'; -import { User } from '../users/user.entity'; -import { StandaloneSubmitAnswerDto } from './dto/submit-answer.dto'; -import { RandomQuestionsQueryDto } from './dto/random-questions-query.dto'; -import { of } from 'rxjs'; -import { EventEmitter2 } from '@nestjs/event-emitter'; - -describe('IQAssessmentService', () => { - let service: IQAssessmentService; - let questionRepository: Repository; - let iqAttemptService: IqAttemptService; - let httpService: HttpService; - - const mockQuestion: IQQuestion = { - id: 'test-question-id', - questionText: 'What is 2 + 2?', - options: ['3', '4', '5', '6'], - correctAnswer: '4', - explanation: 'Basic arithmetic', - difficulty: QuestionDifficulty.EASY, - category: QuestionCategory.MATHEMATICS, - answers: [], - attempts: [], - }; - - const mockQuestionRepository = { - findOne: jest.fn(), - createQueryBuilder: jest.fn(() => ({ - orderBy: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getMany: jest.fn().mockResolvedValue([]), - })), - create: jest.fn(), - save: jest.fn(), - }; - - const mockIqAttemptService = { - create: jest.fn(), - }; - - const mockHttpService = { - get: jest.fn(), - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - IQAssessmentService, - { - provide: getRepositoryToken(IQAssessmentSession), - useValue: {}, - }, - { - provide: getRepositoryToken(IQQuestion), - useValue: mockQuestionRepository, - }, - { - provide: getRepositoryToken(IQAnswer), - useValue: {}, - }, - { - provide: getRepositoryToken(User), - useValue: {}, - }, - { - provide: HttpService, - useValue: mockHttpService, - }, - { - provide: IqAttemptService, - useValue: mockIqAttemptService, - }, - { - provide: EventEmitter2, - useValue: { emit: jest.fn() }, - }, - ], - }).compile(); - - service = module.get(IQAssessmentService); - questionRepository = module.get>( - getRepositoryToken(IQQuestion), - ); - iqAttemptService = module.get(IqAttemptService); - httpService = module.get(HttpService); - - mockHttpService.get.mockImplementation(() => ({ - pipe: () => - of({ - response_code: 0, - results: [ - { - question: 'External question 1', - correct_answer: 'A', - incorrect_answers: ['B', 'C', 'D'], - }, - ], - }), - })); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('submitStandaloneAnswer', () => { - it('should submit a correct answer successfully', async () => { - const submitDto: StandaloneSubmitAnswerDto = { - questionId: 'test-question-id', - selectedAnswer: '4', - }; - - mockQuestionRepository.findOne.mockResolvedValue(mockQuestion); - mockIqAttemptService.create.mockResolvedValue({}); - - const result = await service.submitStandaloneAnswer(submitDto); - - expect(result.isCorrect).toBe(true); - expect(result.correctAnswer).toBe('4'); - expect(result.selectedAnswer).toBe('4'); - expect(result.questionId).toBe('test-question-id'); - expect(result.explanation).toBe('Basic arithmetic'); - expect(mockIqAttemptService.create).toHaveBeenCalledWith({ - userId: undefined, - questionId: 'test-question-id', - selectedAnswer: '4', - correctAnswer: '4', - isCorrect: true, - }); - }); - - it('should submit an incorrect answer successfully', async () => { - const submitDto: StandaloneSubmitAnswerDto = { - questionId: 'test-question-id', - selectedAnswer: '5', - }; - - mockQuestionRepository.findOne.mockResolvedValue(mockQuestion); - mockIqAttemptService.create.mockResolvedValue({}); - - const result = await service.submitStandaloneAnswer(submitDto); - - expect(result.isCorrect).toBe(false); - expect(result.correctAnswer).toBe('4'); - expect(result.selectedAnswer).toBe('5'); - expect(mockIqAttemptService.create).toHaveBeenCalledWith({ - userId: undefined, - questionId: 'test-question-id', - selectedAnswer: '5', - correctAnswer: '4', - isCorrect: false, - }); - }); - - it('should throw NotFoundException when question not found', async () => { - const submitDto: StandaloneSubmitAnswerDto = { - questionId: 'non-existent-id', - selectedAnswer: '4', - }; - - mockQuestionRepository.findOne.mockResolvedValue(null); - - await expect(service.submitStandaloneAnswer(submitDto)).rejects.toThrow( - 'Question not found', - ); - }); - }); - - describe('getRandomQuestionsWithFilters', () => { - it('should return filtered questions from database', async () => { - const queryDto: RandomQuestionsQueryDto = { - difficulty: QuestionDifficulty.EASY, - category: QuestionCategory.MATHEMATICS, - count: 2, - }; - - // Return exactly 'count' questions from DB for every call - const mockQuestions = [ - mockQuestion, - { ...mockQuestion, id: 'test-question-id-2' }, - ]; - - // Reset the mock for this test - mockQuestionRepository.createQueryBuilder.mockReturnValue({ - orderBy: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getMany: jest.fn().mockResolvedValue(mockQuestions), - }); - - // Ensure no external API calls are made - const spy = jest.spyOn(mockHttpService, 'get'); - - const result = await service.getRandomQuestionsWithFilters(queryDto); - - expect(result).toHaveLength(2); - expect(result[0].options).toBeDefined(); - expect(spy).not.toHaveBeenCalled(); - }); - - it('should fetch external questions when not enough in database', async () => { - const queryDto: RandomQuestionsQueryDto = { - difficulty: QuestionDifficulty.HARD, - count: 2, - }; - - // Only 1 question in DB, so external API will be called - const mockQuestions = [mockQuestion]; - - // Reset the mock for this test - mockQuestionRepository.createQueryBuilder.mockReturnValue({ - orderBy: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getMany: jest.fn().mockResolvedValue(mockQuestions), - }); - - // Mock the external question that will be created and saved - const externalQuestion = { - id: 'external-question-id', - questionText: 'External question 1', - options: ['A', 'B', 'C', 'D'], - correctAnswer: 'A', - explanation: null, - difficulty: QuestionDifficulty.HARD, - category: QuestionCategory.GENERAL_KNOWLEDGE, - answers: [], - attempts: [], - }; - - mockQuestionRepository.create.mockReturnValue([externalQuestion]); - mockQuestionRepository.save.mockResolvedValue([externalQuestion]); - - const result = await service.getRandomQuestionsWithFilters(queryDto); - - expect(result).toHaveLength(2); // 1 from DB + 1 from external - expect(mockHttpService.get).toHaveBeenCalled(); - }); - }); -}); diff --git a/src/leaderboard/leaderboard.module.ts b/src/leaderboard/leaderboard.module.ts index 0ef8873..04cca4c 100644 --- a/src/leaderboard/leaderboard.module.ts +++ b/src/leaderboard/leaderboard.module.ts @@ -5,12 +5,15 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from 'src/users/user.entity'; import { LeaderboardEntry } from './entities/leaderboard.entity'; import { Badge } from 'src/badge/entities/badge.entity'; +import { GetLeaderboardProvider } from './providers/get-leaderboard.provider'; +import { UpdatePlayerStatsProvider } from './providers/update-player-stats-provider'; +import { GetUserRankProvider } from './providers/get-user-rank-provider'; @Module({ imports: [ TypeOrmModule.forFeature([LeaderboardEntry, User, Badge])], controllers: [LeaderboardController], - providers: [LeaderboardService], + providers: [LeaderboardService, GetLeaderboardProvider, UpdatePlayerStatsProvider, GetUserRankProvider], exports: [LeaderboardService], }) export class LeaderboardModule {} diff --git a/src/leaderboard/leaderboard.service.ts b/src/leaderboard/leaderboard.service.ts index ff8f796..c72c5cb 100644 --- a/src/leaderboard/leaderboard.service.ts +++ b/src/leaderboard/leaderboard.service.ts @@ -1,182 +1,35 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, SelectQueryBuilder } from 'typeorm'; +import { Injectable } from '@nestjs/common'; import { LeaderboardQueryDto, SortBy, TimePeriod, } from './dto/leaderboard-query.dto'; import { UpdateLeaderboardDto } from './dto/update-leaderboard.dto'; -import { LeaderboardResponseDto } from './dto/leaderboard-response.dto'; -import { User } from 'src/users/user.entity'; -import { LeaderboardEntry } from './entities/leaderboard.entity'; -import { Badge } from 'src/badge/entities/badge.entity'; +import { GetLeaderboardProvider } from './providers/get-leaderboard.provider'; +import { UpdatePlayerStatsProvider } from './providers/update-player-stats-provider'; +import { GetUserRankProvider } from './providers/get-user-rank-provider'; @Injectable() export class LeaderboardService { constructor( - @InjectRepository(LeaderboardEntry) - private leaderboardRepository: Repository, - @InjectRepository(User) - private userRepository: Repository, - @InjectRepository(Badge) - private badgeRepository: Repository, + private readonly getLeaderboardService: GetLeaderboardProvider, + private readonly updatePlayerStatsService: UpdatePlayerStatsProvider, + private readonly getUserRankService: GetUserRankProvider, ) {} - async getLeaderboard( - query: LeaderboardQueryDto, - ): Promise { - const { sort, period, limit, offset = 0 } = query; - - let queryBuilder = this.leaderboardRepository - .createQueryBuilder('entry') - .leftJoinAndSelect('entry.user', 'user') - .leftJoinAndSelect('entry.badge', 'badge'); - - // Apply time filters - queryBuilder = this.applyTimeFilter( - queryBuilder, - period || TimePeriod.ALL_TIME, - ); - - // Apply sorting - const orderDirection = 'DESC'; - queryBuilder.orderBy(`entry.${sort}`, orderDirection); - - // Apply pagination - queryBuilder.skip(offset).take(limit); - - const entries = await queryBuilder.getMany(); - - // Add rank to each entry - return entries.map((entry, index) => ({ - id: entry.id, - user: { - id: entry.user.id, - username: entry.user.username - // avatar: entry.user.avatar, - }, - puzzlesCompleted: entry.puzzlesCompleted, - score: entry.score, - tokens: entry.tokens, - badge: entry.badge - ? { - id: entry.badge.id, - name: entry.badge.title, - description: entry.badge.description, - icon: entry.badge.iconUrl, - } - : undefined, - rank: offset + index + 1, - createdAt: entry.createdAt, - updatedAt: entry.updatedAt, - })); + getLeaderboard(query: LeaderboardQueryDto) { + return this.getLeaderboardService.execute(query); } - async updatePlayerStats( - userId: string, - updateData: UpdateLeaderboardDto, - ): Promise { - // Find or create leaderboard entry for user - let entry = await this.leaderboardRepository.findOne({ - where: { user: { id: userId } }, - relations: ['user', 'badge'], - }); - - if (!entry) { - const user = await this.userRepository.findOne({ where: { id: userId } }); - if (!user) { - throw new NotFoundException('User not found'); - } - - entry = this.leaderboardRepository.create({ - user, - puzzlesCompleted: 0, - score: 0, - tokens: 0, - }); - } - - // Update stats (increment existing values) - if (updateData.tokens !== undefined) { - entry.tokens += updateData.tokens; - } - if (updateData.score !== undefined) { - entry.score += updateData.score; - } - if (updateData.puzzlesCompleted !== undefined) { - entry.puzzlesCompleted += updateData.puzzlesCompleted; - } - - // Update badge if provided - if (updateData.badgeId) { - const badge = await this.badgeRepository.findOne({ - where: { id: updateData.badgeId }, - }); - if (!badge) { - throw new NotFoundException('Badge not found'); - } - entry.badge = badge; - } - - return await this.leaderboardRepository.save(entry); + updatePlayerStats(userId: string, dto: UpdateLeaderboardDto) { + return this.updatePlayerStatsService.execute(userId, dto); } - async getUserRank( - userId: string, - sortBy: SortBy = SortBy.TOKENS, - ): Promise { - const userEntry = await this.leaderboardRepository.findOne({ - where: { user: { id: userId } }, - }); - - if (!userEntry) { - throw new NotFoundException('User not found in leaderboard'); - } - - const userValue = userEntry[sortBy]; - - const rank = await this.leaderboardRepository - .createQueryBuilder('entry') - .where(`entry.${sortBy} > :value`, { value: userValue }) - .getCount(); - - return rank + 1; - } - - private applyTimeFilter( - queryBuilder: SelectQueryBuilder, - period: TimePeriod, - ): SelectQueryBuilder { - const now = new Date(); - - switch (period) { - case TimePeriod.WEEKLY: { - const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - queryBuilder.andWhere('entry.updatedAt >= :weekAgo', { weekAgo }); - break; - } - - case TimePeriod.MONTHLY: { - const monthAgo = new Date( - now.getFullYear(), - now.getMonth() - 1, - now.getDate(), - ); - queryBuilder.andWhere('entry.updatedAt >= :monthAgo', { monthAgo }); - break; - } - - case TimePeriod.ALL_TIME: - default: - // No filter for all-time - break; - } - - return queryBuilder; + getUserRank(userId: string, sortBy: SortBy) { + return this.getUserRankService.execute(userId, sortBy); } - async getTopPlayers(limit: number = 10): Promise { + getTopPlayers(limit: number) { return this.getLeaderboard({ sort: SortBy.TOKENS, period: TimePeriod.ALL_TIME, @@ -184,4 +37,4 @@ export class LeaderboardService { offset: 0, }); } -} +} \ No newline at end of file diff --git a/src/leaderboard/providers/get-leaderboard.provider.ts b/src/leaderboard/providers/get-leaderboard.provider.ts new file mode 100644 index 0000000..aec8777 --- /dev/null +++ b/src/leaderboard/providers/get-leaderboard.provider.ts @@ -0,0 +1,73 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { LeaderboardEntry } from '../entities/leaderboard.entity'; +import { Repository, SelectQueryBuilder } from 'typeorm'; +import { LeaderboardQueryDto, TimePeriod } from '../dto/leaderboard-query.dto'; +import { LeaderboardResponseDto } from '../dto/leaderboard-response.dto'; + +@Injectable() +export class GetLeaderboardProvider { + constructor( + @InjectRepository(LeaderboardEntry) + private leaderboardRepository: Repository, + ) {} + + private applyTimeFilter( + queryBuilder: SelectQueryBuilder, + period: TimePeriod, + ): SelectQueryBuilder { + const now = new Date(); + + switch (period) { + case TimePeriod.WEEKLY: + queryBuilder.andWhere('entry.updatedAt >= :weekAgo', { + weekAgo: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000), + }); + break; + + case TimePeriod.MONTHLY: + queryBuilder.andWhere('entry.updatedAt >= :monthAgo', { + monthAgo: new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()), + }); + break; + } + + return queryBuilder; + } + + async execute(query: LeaderboardQueryDto): Promise { + const { sort, period, limit, offset = 0 } = query; + + let queryBuilder = this.leaderboardRepository + .createQueryBuilder('entry') + .leftJoinAndSelect('entry.user', 'user') + .leftJoinAndSelect('entry.badge', 'badge'); + + queryBuilder = this.applyTimeFilter(queryBuilder, period || TimePeriod.ALL_TIME); + queryBuilder.orderBy(`entry.${sort}`, 'DESC').skip(offset).take(limit); + + const entries = await queryBuilder.getMany(); + + return entries.map((entry, index) => ({ + id: entry.id, + user: { + id: entry.user.id, + username: entry.user.username, + }, + puzzlesCompleted: entry.puzzlesCompleted, + score: entry.score, + tokens: entry.tokens, + badge: entry.badge + ? { + id: entry.badge.id, + name: entry.badge.title, + description: entry.badge.description, + icon: entry.badge.iconUrl, + } + : undefined, + rank: offset + index + 1, + createdAt: entry.createdAt, + updatedAt: entry.updatedAt, + })); + } +} diff --git a/src/leaderboard/providers/get-user-rank-provider.ts b/src/leaderboard/providers/get-user-rank-provider.ts new file mode 100644 index 0000000..20b693a --- /dev/null +++ b/src/leaderboard/providers/get-user-rank-provider.ts @@ -0,0 +1,32 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { LeaderboardEntry } from '../entities/leaderboard.entity'; +import { SortBy } from '../dto/leaderboard-query.dto'; + +@Injectable() +export class GetUserRankProvider { + constructor( + @InjectRepository(LeaderboardEntry) + private leaderboardRepository: Repository, + ) {} + + async execute(userId: string, sortBy: SortBy): Promise { + const userEntry = await this.leaderboardRepository.findOne({ + where: { user: { id: userId } }, + }); + + if (!userEntry) { + throw new NotFoundException('User not found in leaderboard'); + } + + const userValue = userEntry[sortBy]; + + const rank = await this.leaderboardRepository + .createQueryBuilder('entry') + .where(`entry.${sortBy} > :value`, { value: userValue }) + .getCount(); + + return rank + 1; + } +} \ No newline at end of file diff --git a/src/leaderboard/providers/update-player-stats-provider.ts b/src/leaderboard/providers/update-player-stats-provider.ts new file mode 100644 index 0000000..b9b419f --- /dev/null +++ b/src/leaderboard/providers/update-player-stats-provider.ts @@ -0,0 +1,57 @@ +// services/update-player-stats.service.ts +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { LeaderboardEntry } from '../entities/leaderboard.entity'; +import { User } from 'src/users/user.entity'; +import { Badge } from 'src/badge/entities/badge.entity'; +import { UpdateLeaderboardDto } from '../dto/update-leaderboard.dto'; + +@Injectable() +export class UpdatePlayerStatsProvider { + constructor( + @InjectRepository(LeaderboardEntry) + private leaderboardRepository: Repository, + @InjectRepository(User) + private userRepository: Repository, + @InjectRepository(Badge) + private badgeRepository: Repository, + ) {} + + async execute( + userId: string, + updateData: UpdateLeaderboardDto, + ): Promise { + let entry = await this.leaderboardRepository.findOne({ + where: { user: { id: userId } }, + relations: ['user', 'badge'], + }); + + if (!entry) { + const user = await this.userRepository.findOne({ where: { id: userId } }); + if (!user) throw new NotFoundException('User not found'); + + entry = this.leaderboardRepository.create({ + user, + puzzlesCompleted: 0, + score: 0, + tokens: 0, + }); + } + + if (updateData.tokens !== undefined) entry.tokens += updateData.tokens; + if (updateData.score !== undefined) entry.score += updateData.score; + if (updateData.puzzlesCompleted !== undefined) + entry.puzzlesCompleted += updateData.puzzlesCompleted; + + if (updateData.badgeId) { + const badge = await this.badgeRepository.findOne({ + where: { id: updateData.badgeId }, + }); + if (!badge) throw new NotFoundException('Badge not found'); + entry.badge = badge; + } + + return this.leaderboardRepository.save(entry); + } +} \ No newline at end of file diff --git a/src/puzzle/puzzle.service.spec.ts b/src/puzzle/puzzle.service.spec.ts index 2cb5099..7fdf0b0 100644 --- a/src/puzzle/puzzle.service.spec.ts +++ b/src/puzzle/puzzle.service.spec.ts @@ -125,7 +125,10 @@ describe('PuzzleService', () => { }; mockPuzzleRepository.findOne.mockResolvedValueOnce(puzzle); // puzzle - mockUserRepository.findOne.mockResolvedValueOnce(user); // user + mockUserRepository.findOne.mockImplementation(({ where: { id } }) => + id === userId ? Promise.resolve(user) : Promise.resolve(null), + ); + // user mockSubmissionRepository.create.mockReturnValue(submission); mockSubmissionRepository.save.mockResolvedValue(submission); mockSubmissionRepository.findOne.mockResolvedValueOnce(null); // no previous correct submission @@ -150,7 +153,10 @@ describe('PuzzleService', () => { expect(mockSubmissionRepository.create).toHaveBeenCalledWith({ userId, puzzleId, - submitDto, + solution: submitDto.solution, + isCorrect: true, + createdAt: expect.any(Date), + skipped: false, }); expect(result.success).toBe(true); @@ -306,7 +312,7 @@ describe('PuzzleService', () => { const progress = [ { id: 1, - userId: '1', + userId: 'user-1', puzzleType: 'logic', completedCount: 5, total: 10, @@ -326,7 +332,7 @@ describe('PuzzleService', () => { expect(result).toEqual(progress); expect(mockProgressRepository.find).toHaveBeenCalledWith({ - where: { userId: '1' }, + where: { userId: 'user-1' }, }); }); });