diff --git a/backend/src/analytics/analytics.controller.spec.ts b/backend/src/analytics/analytics.controller.spec.ts new file mode 100644 index 0000000..2208687 --- /dev/null +++ b/backend/src/analytics/analytics.controller.spec.ts @@ -0,0 +1,54 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AnalyticsController } from './analytics.controller'; +import { AnalyticsService } from './analytics.service'; + +describe('AnalyticsController', () => { + let controller: AnalyticsController; + let service: AnalyticsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AnalyticsController], + providers: [ + { + provide: AnalyticsService, + useValue: { + startLesson: jest.fn(), + endLesson: jest.fn(), + recordQuizScore: jest.fn(), + getUserAnalytics: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(AnalyticsController); + service = module.get(AnalyticsService); + }); + + it('should call startLesson with user and lessonId', async () => { + const req = { user: { id: 1 } }; + const dto = { lessonId: 2 }; + await controller.lessonStart(req as any, dto); + expect(service.startLesson).toHaveBeenCalledWith(1, 2); + }); + + it('should call endLesson with user and lessonId', async () => { + const req = { user: { id: 1 } }; + const dto = { lessonId: 2 }; + await controller.lessonEnd(req as any, dto); + expect(service.endLesson).toHaveBeenCalledWith(1, 2); + }); + + it('should call recordQuizScore with correct params', async () => { + const req = { user: { id: 1 } }; + const dto = { lessonId: 2, quizId: 3, score: 80, attempt: 1 }; + await controller.quizScore(req as any, dto); + expect(service.recordQuizScore).toHaveBeenCalledWith(1, 2, 3, 80, 1); + }); + + it('should call getUserAnalytics with userId', async () => { + await controller.getUserAnalytics(1); + expect(service.getUserAnalytics).toHaveBeenCalledWith(1); + }); +}); diff --git a/backend/src/analytics/analytics.controller.ts b/backend/src/analytics/analytics.controller.ts new file mode 100644 index 0000000..711e837 --- /dev/null +++ b/backend/src/analytics/analytics.controller.ts @@ -0,0 +1,48 @@ +// Analytics controller skeleton +import { Body, Controller, Get, Param, Post, Request } from '@nestjs/common'; +import { AnalyticsService } from './analytics.service'; + +// DTOs +class LessonStartDto { + lessonId: number; +} +class LessonEndDto { + lessonId: number; +} +class QuizScoreDto { + lessonId: number; + quizId: number; + score: number; + attempt: number; +} + +@Controller('analytics') +export class AnalyticsController { + constructor(private readonly analyticsService: AnalyticsService) {} + + @Post('lesson-start') + async lessonStart(@Request() req, @Body() dto: LessonStartDto) { + return this.analyticsService.startLesson(req.user.id, dto.lessonId); + } + + @Post('lesson-end') + async lessonEnd(@Request() req, @Body() dto: LessonEndDto) { + return this.analyticsService.endLesson(req.user.id, dto.lessonId); + } + + @Post('quiz-score') + async quizScore(@Request() req, @Body() dto: QuizScoreDto) { + return this.analyticsService.recordQuizScore( + req.user.id, + dto.lessonId, + dto.quizId, + dto.score, + dto.attempt, + ); + } + + @Get('user/:userId') + async getUserAnalytics(@Param('userId') userId: number) { + return this.analyticsService.getUserAnalytics(userId); + } +} diff --git a/backend/src/analytics/analytics.e2e-spec.ts b/backend/src/analytics/analytics.e2e-spec.ts new file mode 100644 index 0000000..d26328a --- /dev/null +++ b/backend/src/analytics/analytics.e2e-spec.ts @@ -0,0 +1,69 @@ +import request from 'supertest'; +import { Test } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { AnalyticsModule } from './analytics.module'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { LessonAnalytics } from './entities/lesson_analytics.entity'; +import { QuizAnalytics } from './entities/quiz_analytics.entity'; + +describe('Analytics Endpoints (e2e)', () => { + let app: INestApplication; + let lessonAnalyticsRepo = { create: jest.fn(), save: jest.fn(), findOne: jest.fn(), find: jest.fn() }; + let quizAnalyticsRepo = { create: jest.fn(), save: jest.fn(), find: jest.fn() }; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [AnalyticsModule], + }) + .overrideProvider(getRepositoryToken(LessonAnalytics)) + .useValue(lessonAnalyticsRepo) + .overrideProvider(getRepositoryToken(QuizAnalytics)) + .useValue(quizAnalyticsRepo) + .compile(); + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('POST /analytics/lesson-start', async () => { + lessonAnalyticsRepo.create.mockReturnValue({ userId: 1, lessonId: 2, lessonStartTime: new Date() }); + lessonAnalyticsRepo.save.mockResolvedValue({ userId: 1, lessonId: 2, lessonStartTime: new Date() }); + await request(app.getHttpServer()) + .post('/analytics/lesson-start') + .send({ lessonId: 2 }) + .set('Authorization', 'Bearer testtoken') + .expect(201); + }); + + it('POST /analytics/lesson-end', async () => { + lessonAnalyticsRepo.findOne.mockResolvedValue({ userId: 1, lessonId: 2, lessonStartTime: new Date(Date.now() - 1000) }); + lessonAnalyticsRepo.save.mockResolvedValue({ userId: 1, lessonId: 2, lessonStartTime: new Date(Date.now() - 1000), lessonEndTime: new Date(), durationSeconds: 1 }); + await request(app.getHttpServer()) + .post('/analytics/lesson-end') + .send({ lessonId: 2 }) + .set('Authorization', 'Bearer testtoken') + .expect(201); + }); + + it('POST /analytics/quiz-score', async () => { + quizAnalyticsRepo.create.mockReturnValue({ userId: 1, lessonId: 2, quizId: 3, score: 90, attempt: 1 }); + quizAnalyticsRepo.save.mockResolvedValue({ userId: 1, lessonId: 2, quizId: 3, score: 90, attempt: 1 }); + await request(app.getHttpServer()) + .post('/analytics/quiz-score') + .send({ lessonId: 2, quizId: 3, score: 90, attempt: 1 }) + .set('Authorization', 'Bearer testtoken') + .expect(201); + }); + + it('GET /analytics/user/:userId', async () => { + lessonAnalyticsRepo.find.mockResolvedValue([]); + quizAnalyticsRepo.find.mockResolvedValue([]); + await request(app.getHttpServer()) + .get('/analytics/user/1') + .set('Authorization', 'Bearer testtoken') + .expect(200); + }); + + afterAll(async () => { + await app.close(); + }); +}); diff --git a/backend/src/analytics/analytics.module.ts b/backend/src/analytics/analytics.module.ts new file mode 100644 index 0000000..80bed1a --- /dev/null +++ b/backend/src/analytics/analytics.module.ts @@ -0,0 +1,14 @@ +// Analytics module definition +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AnalyticsService } from './analytics.service'; +import { AnalyticsController } from './analytics.controller'; +import { LessonAnalytics } from './entities/lesson_analytics.entity'; +import { QuizAnalytics } from './entities/quiz_analytics.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([LessonAnalytics, QuizAnalytics])], + controllers: [AnalyticsController], + providers: [AnalyticsService], +}) +export class AnalyticsModule {} diff --git a/backend/src/analytics/analytics.performance.spec.ts b/backend/src/analytics/analytics.performance.spec.ts new file mode 100644 index 0000000..d68e573 --- /dev/null +++ b/backend/src/analytics/analytics.performance.spec.ts @@ -0,0 +1,30 @@ +import { AnalyticsService } from './analytics.service'; + +describe('AnalyticsService Performance', () => { + let service: AnalyticsService; + let lessonAnalyticsRepo: any; + let quizAnalyticsRepo: any; + + beforeEach(() => { + lessonAnalyticsRepo = { + find: jest.fn(), + }; + quizAnalyticsRepo = { + find: jest.fn(), + }; + service = new AnalyticsService(lessonAnalyticsRepo, quizAnalyticsRepo); + }); + + it('should efficiently fetch analytics for many lessons', async () => { + const lessons = Array.from({ length: 1000 }, (_, i) => ({ userId: 1, lessonId: i, lessonStartTime: new Date(), lessonEndTime: new Date(), durationSeconds: 100 })); + const quizzes = Array.from({ length: 1000 }, (_, i) => ({ userId: 1, lessonId: i, quizId: i, score: 80, attempt: 1 })); + lessonAnalyticsRepo.find.mockResolvedValue(lessons); + quizAnalyticsRepo.find.mockResolvedValue(quizzes); + const start = Date.now(); + const result = await service.getUserAnalytics(1); + const duration = Date.now() - start; + expect(result.lessons.length).toBe(1000); + expect(result.quizzes.length).toBe(1000); + expect(duration).toBeLessThan(500); // Should be fast + }); +}); diff --git a/backend/src/analytics/analytics.service.spec.ts b/backend/src/analytics/analytics.service.spec.ts new file mode 100644 index 0000000..42d1f7f --- /dev/null +++ b/backend/src/analytics/analytics.service.spec.ts @@ -0,0 +1,51 @@ +import { AnalyticsService } from './analytics.service'; + +describe('AnalyticsService', () => { + let service: AnalyticsService; + let lessonAnalyticsRepo: any; + let quizAnalyticsRepo: any; + + beforeEach(() => { + lessonAnalyticsRepo = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + }; + quizAnalyticsRepo = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + }; + service = new AnalyticsService(lessonAnalyticsRepo, quizAnalyticsRepo); + }); + + it('should calculate lesson duration correctly', async () => { + const now = new Date(); + const start = new Date(now.getTime() - 5000); + lessonAnalyticsRepo.findOne.mockResolvedValue({ + userId: 1, + lessonId: 2, + lessonStartTime: start, + lessonEndTime: null, + durationSeconds: null, + }); + lessonAnalyticsRepo.save.mockImplementation(async (record) => record); + const result = await service.endLesson(1, 2); + expect(result.durationSeconds).toBeCloseTo(5, 0); + }); + + it('should record quiz score', async () => { + quizAnalyticsRepo.create.mockReturnValue({ + userId: 1, + lessonId: 2, + quizId: 3, + score: 90, + attempt: 1, + }); + quizAnalyticsRepo.save.mockImplementation(async (record) => record); + const result = await service.recordQuizScore(1, 2, 3, 90, 1); + expect(result.score).toBe(90); + expect(result.attempt).toBe(1); + }); +}); diff --git a/backend/src/analytics/analytics.service.ts b/backend/src/analytics/analytics.service.ts new file mode 100644 index 0000000..931fdbc --- /dev/null +++ b/backend/src/analytics/analytics.service.ts @@ -0,0 +1,53 @@ +// Analytics service skeleton +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { LessonAnalytics } from './entities/lesson_analytics.entity'; +import { QuizAnalytics } from './entities/quiz_analytics.entity'; + +@Injectable() +export class AnalyticsService { + constructor( + @InjectRepository(LessonAnalytics) + private lessonAnalyticsRepo: Repository, + @InjectRepository(QuizAnalytics) + private quizAnalyticsRepo: Repository, + ) {} + + async startLesson(userId: number, lessonId: number): Promise { + const record = this.lessonAnalyticsRepo.create({ + userId, + lessonId, + lessonStartTime: new Date(), + }); + return this.lessonAnalyticsRepo.save(record); + } + + async endLesson(userId: number, lessonId: number): Promise { + const record = await this.lessonAnalyticsRepo.findOne({ + where: { userId, lessonId }, + order: { lessonStartTime: 'DESC' }, + }); + if (!record) throw new Error('Lesson start not found'); + record.lessonEndTime = new Date(); + record.durationSeconds = Math.floor((record.lessonEndTime.getTime() - record.lessonStartTime.getTime()) / 1000); + return this.lessonAnalyticsRepo.save(record); + } + + async recordQuizScore(userId: number, lessonId: number, quizId: number, score: number, attempt: number): Promise { + const record = this.quizAnalyticsRepo.create({ + userId, + lessonId, + quizId, + score, + attempt, + }); + return this.quizAnalyticsRepo.save(record); + } + + async getUserAnalytics(userId: number) { + const lessons = await this.lessonAnalyticsRepo.find({ where: { userId } }); + const quizzes = await this.quizAnalyticsRepo.find({ where: { userId } }); + return { lessons, quizzes }; + } +} diff --git a/backend/src/analytics/entities/lesson_analytics.entity.ts b/backend/src/analytics/entities/lesson_analytics.entity.ts new file mode 100644 index 0000000..01f852b --- /dev/null +++ b/backend/src/analytics/entities/lesson_analytics.entity.ts @@ -0,0 +1,29 @@ +// LessonAnalytics entity +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +@Entity('lesson_analytics') +export class LessonAnalytics { + @PrimaryGeneratedColumn() + id: number; + + @Column() + userId: number; + + @Column() + lessonId: number; + + @Column({ type: 'timestamp' }) + lessonStartTime: Date; + + @Column({ type: 'timestamp', nullable: true }) + lessonEndTime: Date; + + @Column({ type: 'int', nullable: true }) + durationSeconds: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/analytics/entities/quiz_analytics.entity.ts b/backend/src/analytics/entities/quiz_analytics.entity.ts new file mode 100644 index 0000000..18244b5 --- /dev/null +++ b/backend/src/analytics/entities/quiz_analytics.entity.ts @@ -0,0 +1,26 @@ +// QuizAnalytics entity +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +@Entity('quiz_analytics') +export class QuizAnalytics { + @PrimaryGeneratedColumn() + id: number; + + @Column() + userId: number; + + @Column() + lessonId: number; + + @Column() + quizId: number; + + @Column('int') + score: number; + + @Column('int') + attempt: number; + + @CreateDateColumn() + createdAt: Date; +}