feat: add recommended courses and quiz multiple attempts
- Add is_recommended field to Course model - Add allow_multiple_attempts field to Quiz model - Create RecommendedCoursesController for admin management - List all approved courses - Get course by ID - Toggle recommendation status - Add is_recommended filter to CoursesService.ListCourses - Add allow_multiple_attempts to quiz update and response types - Update ChaptersLessonService.updateQuiz to support allow_multiple_attempts
This commit is contained in:
parent
623f797763
commit
f7330a7b27
17 changed files with 3963 additions and 5 deletions
437
.agent/workflows/testing.md
Normal file
437
.agent/workflows/testing.md
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
---
|
||||
description: How to test backend APIs and services
|
||||
---
|
||||
|
||||
# Testing Workflow
|
||||
|
||||
Follow these steps to write and run tests for the E-Learning Platform backend using TypeScript and Jest.
|
||||
|
||||
---
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── unit/ # Unit tests (services, utilities)
|
||||
├── integration/ # Integration tests (API endpoints)
|
||||
├── fixtures/ # Test data
|
||||
└── setup.js # Test setup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Setup Test Environment
|
||||
|
||||
Create `tests/setup.ts`:
|
||||
|
||||
```typescript
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
beforeAll(async () => {
|
||||
// Setup test database
|
||||
await prisma.$executeRaw`TRUNCATE TABLE "User" CASCADE`;
|
||||
|
||||
// Create test data
|
||||
await createTestUsers();
|
||||
await createTestCourses();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
async function createTestUsers() {
|
||||
// Create admin, instructor, student
|
||||
await prisma.user.createMany({
|
||||
data: [
|
||||
{
|
||||
username: 'admin',
|
||||
email: 'admin@test.com',
|
||||
password: await bcrypt.hash('password123', 10),
|
||||
role_id: 1
|
||||
},
|
||||
{
|
||||
username: 'instructor1',
|
||||
email: 'instructor@test.com',
|
||||
password: await bcrypt.hash('password123', 10),
|
||||
role_id: 2
|
||||
},
|
||||
{
|
||||
username: 'student1',
|
||||
email: 'student@test.com',
|
||||
password: await bcrypt.hash('password123', 10),
|
||||
role_id: 3
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Write Unit Tests
|
||||
|
||||
Create `tests/unit/course.service.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { courseService } from '../../src/services/course.service';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
describe('Course Service', () => {
|
||||
describe('createCourse', () => {
|
||||
it('should create course with valid data', async () => {
|
||||
const courseData = {
|
||||
title: { th: 'ทดสอบ', en: 'Test' },
|
||||
description: { th: 'รายละเอียด', en: 'Description' },
|
||||
category_id: 1,
|
||||
price: 990,
|
||||
is_free: false
|
||||
};
|
||||
|
||||
const course = await courseService.create(courseData, 1);
|
||||
|
||||
expect(course).toHaveProperty('id');
|
||||
expect(course.status).toBe('DRAFT');
|
||||
expect(course.title.th).toBe('ทดสอบ');
|
||||
});
|
||||
|
||||
it('should throw error with invalid category', async () => {
|
||||
const courseData = {
|
||||
title: { th: 'ทดสอบ', en: 'Test' },
|
||||
category_id: 9999, // Invalid
|
||||
price: 990
|
||||
};
|
||||
|
||||
await expect(
|
||||
courseService.create(courseData, 1)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkLessonAccess', () => {
|
||||
it('should allow access to unlocked lesson', async () => {
|
||||
const result = await courseService.checkLessonAccess(1, 1);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny access to locked lesson', async () => {
|
||||
const result = await courseService.checkLessonAccess(1, 5);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe('incomplete_prerequisites');
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Write Integration Tests
|
||||
|
||||
Create `tests/integration/courses.test.ts`:
|
||||
|
||||
```typescript
|
||||
import request from 'supertest';
|
||||
import app from '../../src/app';
|
||||
|
||||
describe('Course API', () => {
|
||||
let adminToken: string;
|
||||
let instructorToken: string;
|
||||
let studentToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Login as different users
|
||||
const adminRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'admin', password: 'password123' });
|
||||
adminToken = adminRes.body.token;
|
||||
|
||||
const instructorRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'instructor1', password: 'password123' });
|
||||
instructorToken = instructorRes.body.token;
|
||||
|
||||
const studentRes = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'student1', password: 'password123' });
|
||||
studentToken = studentRes.body.token;
|
||||
});
|
||||
|
||||
describe('POST /api/instructor/courses', () => {
|
||||
it('should create course as instructor', async () => {
|
||||
const courseData = {
|
||||
title: { th: 'คอร์สใหม่', en: 'New Course' },
|
||||
description: { th: 'รายละเอียด', en: 'Description' },
|
||||
category_id: 1,
|
||||
price: 990,
|
||||
is_free: false
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/instructor/courses')
|
||||
.set('Authorization', `Bearer ${instructorToken}`)
|
||||
.send(courseData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toHaveProperty('id');
|
||||
expect(response.body.status).toBe('DRAFT');
|
||||
});
|
||||
|
||||
it('should return 403 as student', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/instructor/courses')
|
||||
.set('Authorization', `Bearer ${studentToken}`)
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should return 401 without auth', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/instructor/courses')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/courses', () => {
|
||||
it('should list public courses', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/courses')
|
||||
.query({ page: 1, limit: 10 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toHaveProperty('data');
|
||||
expect(response.body).toHaveProperty('pagination');
|
||||
expect(Array.isArray(response.body.data)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter by category', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/courses')
|
||||
.query({ category: 1 });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
response.body.data.forEach((course: any) => {
|
||||
expect(course.category_id).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Test File Uploads
|
||||
|
||||
Create `tests/integration/file-upload.test.ts`:
|
||||
|
||||
```typescript
|
||||
import request from 'supertest';
|
||||
import app from '../../src/app';
|
||||
import path from 'path';
|
||||
|
||||
describe('File Upload API', () => {
|
||||
let instructorToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'instructor1', password: 'password123' });
|
||||
instructorToken = res.body.token;
|
||||
});
|
||||
|
||||
describe('POST /api/instructor/lessons/:lessonId/attachments', () => {
|
||||
it('should upload PDF attachment', async () => {
|
||||
const filePath = path.join(__dirname, '../fixtures/test.pdf');
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/instructor/courses/1/lessons/1/attachments')
|
||||
.set('Authorization', `Bearer ${instructorToken}`)
|
||||
.attach('file', filePath)
|
||||
.field('description_th', 'ไฟล์ทดสอบ')
|
||||
.field('description_en', 'Test file');
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body).toHaveProperty('file_name');
|
||||
expect(response.body.mime_type).toBe('application/pdf');
|
||||
});
|
||||
|
||||
it('should reject invalid file type', async () => {
|
||||
const filePath = path.join(__dirname, '../fixtures/test.exe');
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/instructor/courses/1/lessons/1/attachments')
|
||||
.set('Authorization', `Bearer ${instructorToken}`)
|
||||
.attach('file', filePath);
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(response.body.error.code).toBe('INVALID_FILE_TYPE');
|
||||
});
|
||||
|
||||
it('should reject file too large', async () => {
|
||||
// Mock large file
|
||||
const response = await request(app)
|
||||
.post('/api/instructor/courses/1/lessons/1/attachments')
|
||||
.set('Authorization', `Bearer ${instructorToken}`)
|
||||
.attach('file', Buffer.alloc(101 * 1024 * 1024)); // 101 MB
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(response.body.error.code).toBe('FILE_TOO_LARGE');
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Run Tests
|
||||
|
||||
// turbo
|
||||
Run all tests:
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
// turbo
|
||||
Run specific test file:
|
||||
```bash
|
||||
npm test -- courses.test.js
|
||||
```
|
||||
|
||||
// turbo
|
||||
Run with coverage:
|
||||
```bash
|
||||
npm test -- --coverage
|
||||
```
|
||||
|
||||
// turbo
|
||||
Run in watch mode:
|
||||
```bash
|
||||
npm test -- --watch
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Configuration
|
||||
|
||||
Update `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test": "jest --runInBand",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "ts-jest",
|
||||
"testEnvironment": "node",
|
||||
"coveragePathIgnorePatterns": ["/node_modules/"],
|
||||
"setupFilesAfterEnv": ["<rootDir>/tests/setup.ts"],
|
||||
"testMatch": ["**/tests/**/*.test.ts"],
|
||||
"moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mocking
|
||||
|
||||
### Mock Prisma
|
||||
```javascript
|
||||
jest.mock('@prisma/client', () => {
|
||||
const mockPrisma = {
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn()
|
||||
},
|
||||
course: {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn()
|
||||
}
|
||||
};
|
||||
return { PrismaClient: jest.fn(() => mockPrisma) };
|
||||
});
|
||||
```
|
||||
|
||||
### Mock S3/MinIO
|
||||
```javascript
|
||||
jest.mock('../src/services/s3.service', () => ({
|
||||
uploadFile: jest.fn().mockResolvedValue({
|
||||
url: 'https://s3.example.com/test.pdf',
|
||||
key: 'test.pdf'
|
||||
}),
|
||||
deleteFile: jest.fn().mockResolvedValue(true)
|
||||
}));
|
||||
```
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Test Fixtures
|
||||
|
||||
Create `tests/fixtures/courses.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"validCourse": {
|
||||
"title": { "th": "คอร์สทดสอบ", "en": "Test Course" },
|
||||
"description": { "th": "รายละเอียด", "en": "Description" },
|
||||
"category_id": 1,
|
||||
"price": 990,
|
||||
"is_free": false
|
||||
},
|
||||
"invalidCourse": {
|
||||
"title": "Invalid"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use in tests:
|
||||
```javascript
|
||||
const fixtures = require('../fixtures/courses.json');
|
||||
|
||||
it('should create course', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/instructor/courses')
|
||||
.send(fixtures.validCourse);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Test setup configured
|
||||
- [ ] Unit tests written for services
|
||||
- [ ] Integration tests written for API endpoints
|
||||
- [ ] File upload tests included
|
||||
- [ ] Authentication/authorization tested
|
||||
- [ ] Error cases tested
|
||||
- [ ] All tests passing
|
||||
- [ ] Coverage > 80%
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Test Isolation**: Each test should be independent
|
||||
2. **Descriptive Names**: Use clear test descriptions
|
||||
3. **AAA Pattern**: Arrange, Act, Assert
|
||||
4. **Mock External Services**: Don't call real APIs in tests
|
||||
5. **Test Edge Cases**: Not just happy path
|
||||
6. **Clean Up**: Reset database state between tests
|
||||
7. **Fast Tests**: Keep tests fast (< 5 seconds total)
|
||||
Loading…
Add table
Add a link
Reference in a new issue