elearning/.agent/workflows/testing.md
JakkrapartXD f7330a7b27 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
2026-02-11 15:01:58 +07:00

437 lines
10 KiB
Markdown

---
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)