elearning/Backend/.agent/workflows/testing.md
2026-01-12 03:36:54 +00:00

10 KiB

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:

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:

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:

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:

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:

npm test

// turbo Run specific test file:

npm test -- courses.test.js

// turbo Run with coverage:

npm test -- --coverage

// turbo Run in watch mode:

npm test -- --watch

Test Configuration

Update package.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

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

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:

{
  "validCourse": {
    "title": { "th": "คอร์สทดสอบ", "en": "Test Course" },
    "description": { "th": "รายละเอียด", "en": "Description" },
    "category_id": 1,
    "price": 990,
    "is_free": false
  },
  "invalidCourse": {
    "title": "Invalid"
  }
}

Use in tests:

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)