Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions backend/src/analytics/analytics.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(AnalyticsController);
service = module.get<AnalyticsService>(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);
});
});
48 changes: 48 additions & 0 deletions backend/src/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
69 changes: 69 additions & 0 deletions backend/src/analytics/analytics.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
14 changes: 14 additions & 0 deletions backend/src/analytics/analytics.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
30 changes: 30 additions & 0 deletions backend/src/analytics/analytics.performance.spec.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
51 changes: 51 additions & 0 deletions backend/src/analytics/analytics.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
53 changes: 53 additions & 0 deletions backend/src/analytics/analytics.service.ts
Original file line number Diff line number Diff line change
@@ -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<LessonAnalytics>,
@InjectRepository(QuizAnalytics)
private quizAnalyticsRepo: Repository<QuizAnalytics>,
) {}

async startLesson(userId: number, lessonId: number): Promise<LessonAnalytics> {
const record = this.lessonAnalyticsRepo.create({
userId,
lessonId,
lessonStartTime: new Date(),
});
return this.lessonAnalyticsRepo.save(record);
}

async endLesson(userId: number, lessonId: number): Promise<LessonAnalytics> {
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<QuizAnalytics> {
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 };
}
}
29 changes: 29 additions & 0 deletions backend/src/analytics/entities/lesson_analytics.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
26 changes: 26 additions & 0 deletions backend/src/analytics/entities/quiz_analytics.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading