diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3b1b9d5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,241 @@ +name: CI Pipeline + +# Trigger CI on pull requests and pushes to main/develop +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +# Cancel in-progress runs if a new push is made +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # Job 1: Lint and Code Quality Checks + lint: + name: Lint & Code Quality + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run ESLint + run: npm run lint + continue-on-error: false + + - name: Check code formatting (if you add prettier) + run: | + if npm run format:check 2>/dev/null; then + echo "Code formatting check passed" + else + echo "No format:check script found, skipping" + fi + continue-on-error: true + + # Job 2: Build Check + build: + name: Build Frontend + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build frontend + run: npm run build + env: + NODE_ENV: production + + - name: Check build artifacts + run: | + if [ -d "dist" ]; then + echo "✅ Build successful - dist folder created" + ls -la dist/ + else + echo "❌ Build failed - no dist folder" + exit 1 + fi + + # Job 3: Backend Tests + test-backend: + name: Backend Tests + runs-on: ubuntu-latest + + # Run MongoDB as a service + services: + mongodb: + image: mongo:7 + ports: + - 27017:27017 + options: >- + --health-cmd "mongosh --eval 'db.adminCommand({ping: 1})'" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run unit tests + run: npm test -- --testPathPattern="__tests__/unit" + env: + MONGODB_TEST_URI: mongodb://localhost:27017/tlef-test + NODE_ENV: test + + - name: Run integration tests + run: npm test -- --testPathPattern="__tests__/integration" + env: + MONGODB_TEST_URI: mongodb://localhost:27017/tlef-test + NODE_ENV: test + JWT_SECRET: test-secret-key-for-ci + JWT_REFRESH_SECRET: test-refresh-secret-for-ci + + - name: Generate coverage report + run: npm run test:coverage + env: + MONGODB_TEST_URI: mongodb://localhost:27017/tlef-test + NODE_ENV: test + + - name: Upload coverage to Codecov (optional) + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./routes/create/coverage/lcov.info + flags: backend + name: backend-coverage + continue-on-error: true + + - name: Comment coverage on PR (optional) + uses: romeovs/lcov-reporter-action@v0.3.1 + with: + lcov-file: ./routes/create/coverage/lcov.info + github-token: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true + + # Job 4: Security Audit + security: + name: Security Audit + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Run npm audit + run: npm audit --audit-level=moderate + continue-on-error: true + + - name: Check for known vulnerabilities + run: | + npm audit --json > audit-report.json || true + if [ -f audit-report.json ]; then + echo "Audit report generated" + cat audit-report.json + fi + + # Job 5: Type Checking (if you add TypeScript) + typecheck: + name: Type Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run TypeScript type check + run: | + if npm run typecheck 2>/dev/null; then + echo "✅ Type checking passed" + else + echo "⚠️ No typecheck script found, skipping" + fi + continue-on-error: true + + # Job 6: All Checks Summary + all-checks: + name: All CI Checks Passed + runs-on: ubuntu-latest + needs: [lint, build, test-backend, security] + if: always() + + steps: + - name: Check if all jobs succeeded + run: | + if [ "${{ needs.lint.result }}" != "success" ]; then + echo "❌ Lint failed" + exit 1 + fi + if [ "${{ needs.build.result }}" != "success" ]; then + echo "❌ Build failed" + exit 1 + fi + if [ "${{ needs.test-backend.result }}" != "success" ]; then + echo "❌ Backend tests failed" + exit 1 + fi + if [ "${{ needs.security.result }}" != "success" ]; then + echo "⚠️ Security audit had warnings (not blocking)" + fi + echo "✅ All required checks passed!" + + - name: Post success comment on PR + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '✅ All CI checks passed! Ready for review and merge.' + }) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md new file mode 100644 index 0000000..95c11aa --- /dev/null +++ b/TESTING_GUIDE.md @@ -0,0 +1,458 @@ +# Testing Guide for TLEF-CREATE + +## Overview + +This project uses **Jest** for backend testing. Tests are organized into **unit tests** and **integration tests**. + +## Test Types Explained + +### 1. Unit Tests 🧪 +**What:** Test individual functions/components in isolation +**Speed:** Fast (milliseconds) +**Location:** `routes/create/__tests__/unit/` + +**Example:** +```javascript +test('should generate 6-character course code', () => { + const code = generateCourseCode(); + expect(code.length).toBe(6); + expect(code).toMatch(/^[A-Z0-9]{6}$/); +}); +``` + +**When to write:** +- Testing utility functions +- Testing data transformations +- Testing validation logic +- Testing formatters/parsers + +### 2. Integration Tests 🔗 +**What:** Test how multiple components work together +**Speed:** Medium (seconds) +**Location:** `routes/create/__tests__/integration/` + +**Example:** +```javascript +test('should upload file and trigger RAG processing', async () => { + const response = await request(app) + .post('/api/materials/upload') + .set('Authorization', `Bearer ${authToken}`) + .attach('files', 'test.pdf') + .field('folderId', folderId) + .expect(201); + + expect(response.body.data.materials[0].processingStatus).toBe('processing'); + + // Verify in database + const material = await Material.findById(response.body.data.materials[0]._id); + expect(material).toBeTruthy(); +}); +``` + +**When to write:** +- Testing API endpoints +- Testing database operations +- Testing authentication flows +- Testing file uploads +- Testing RAG processing pipeline + +### 3. End-to-End (E2E) Tests 🎭 +**What:** Test complete user workflows from browser +**Speed:** Slow (minutes) +**Status:** Not implemented yet + +**Example (would use Playwright/Cypress):** +```javascript +test('Instructor creates course with materials', async () => { + await page.goto('http://localhost:5173'); + await page.click('button:has-text("Create Course")'); + await page.fill('input[name="courseName"]', 'EOSC 533'); + await page.click('button:has-text("Next")'); + await page.setInputFiles('input[type="file"]', 'lecture1.pdf'); + await page.click('button:has-text("Create Course")'); + await expect(page.locator('text=EOSC 533')).toBeVisible(); +}); +``` + +**When to write:** +- Testing critical user journeys +- Testing across frontend + backend +- Testing real browser interactions + +## Running Tests + +### Run all tests +```bash +npm test +``` + +### Run tests in watch mode (auto-rerun on changes) +```bash +npm run test:watch +``` + +### Run tests with coverage report +```bash +npm run test:coverage +``` + +### Run specific test file +```bash +NODE_OPTIONS='--experimental-vm-modules' jest routes/create/__tests__/unit/responseFormatter.test.js +``` + +### Run tests matching a pattern +```bash +NODE_OPTIONS='--experimental-vm-modules' jest --testNamePattern="should create text material" +``` + +## Test Structure + +### Anatomy of a Test + +```javascript +import { describe, test, expect, beforeEach, afterEach } from '@jest/globals'; + +describe('Feature Name', () => { + // Setup before each test + beforeEach(async () => { + // Clean database + await Material.deleteMany({}); + }); + + // Cleanup after each test + afterEach(async () => { + // Close connections, etc. + }); + + describe('Specific Functionality', () => { + test('should do something specific', async () => { + // Arrange: Set up test data + const testData = { name: 'Test' }; + + // Act: Perform the action + const result = await someFunction(testData); + + // Assert: Verify the result + expect(result).toBe(expected); + }); + }); +}); +``` + +## Writing Good Tests + +### ✅ DO: +- **Test behavior, not implementation** + ```javascript + // Good + expect(response.body.data.material.name).toBe('Test Material'); + + // Bad + expect(material._saveCalled).toBe(true); + ``` + +- **Use descriptive test names** + ```javascript + // Good + test('should reject duplicate files with same checksum') + + // Bad + test('test 1') + ``` + +- **Follow AAA pattern: Arrange, Act, Assert** + ```javascript + test('should calculate total price', () => { + // Arrange + const items = [{ price: 10 }, { price: 20 }]; + + // Act + const total = calculateTotal(items); + + // Assert + expect(total).toBe(30); + }); + ``` + +- **Test edge cases** + ```javascript + test('should handle empty material list'); + test('should handle null input'); + test('should handle very large files'); + ``` + +### ❌ DON'T: +- Don't test implementation details +- Don't write tests that depend on execution order +- Don't use real external services (mock them) +- Don't ignore test failures + +## Current Test Coverage + +### Backend Tests ✅ + +**Unit Tests:** +- ✅ Response formatters (successResponse, errorResponse, etc.) +- ✅ Async handler utility +- ⚠️ Missing: Course code generation +- ⚠️ Missing: RAG utility functions +- ⚠️ Missing: File validation + +**Integration Tests:** +- ✅ Authentication (register, login, token refresh) +- ✅ Folders (create, get, update, delete) +- ✅ Materials (upload files, add URL, add text) +- ✅ Quizzes (create, get, update, delete) +- ⚠️ Missing: Learning objectives API +- ⚠️ Missing: Question generation API +- ⚠️ Missing: Streaming SSE endpoints +- ⚠️ Missing: RAG processing pipeline + +### Frontend Tests ❌ +- ❌ No React component tests yet +- ❌ No React hook tests +- ❌ No UI interaction tests + +**Recommendation:** Add frontend tests using: +- **Vitest** (faster than Jest for Vite projects) +- **React Testing Library** (for component testing) + +## Example: Writing a New Test + +### Unit Test Example + +Create: `routes/create/__tests__/unit/courseCode.test.js` + +```javascript +import { describe, test, expect } from '@jest/globals'; +import { generateCourseCode } from '../../utils/courseCode.js'; + +describe('Course Code Generation', () => { + test('should generate 6-character code', () => { + const code = generateCourseCode(); + expect(code).toHaveLength(6); + }); + + test('should only contain valid characters', () => { + const code = generateCourseCode(); + expect(code).toMatch(/^[A-Z2-9]{6}$/); + expect(code).not.toMatch(/[IO01]/); // No confusing chars + }); + + test('should generate unique codes', () => { + const codes = new Set(); + for (let i = 0; i < 100; i++) { + codes.add(generateCourseCode()); + } + expect(codes.size).toBe(100); // All unique + }); +}); +``` + +### Integration Test Example + +Create: `routes/create/__tests__/integration/ragProcessing.test.js` + +```javascript +import { describe, test, expect, beforeEach } from '@jest/globals'; +import request from 'supertest'; +import app from '../../server.js'; +import Material from '../../models/Material.js'; + +describe('RAG Processing Integration', () => { + let authToken; + let folderId; + + beforeEach(async () => { + // Setup auth and folder + }); + + test('should process uploaded file through RAG pipeline', async () => { + const response = await request(app) + .post('/api/materials/upload') + .set('Authorization', `Bearer ${authToken}`) + .attach('files', Buffer.from('test content'), 'test.pdf') + .field('folderId', folderId) + .expect(201); + + const materialId = response.body.data.materials[0]._id; + + // Wait for processing + await new Promise(resolve => setTimeout(resolve, 2000)); + + // Check material status + const material = await Material.findById(materialId); + expect(material.processingStatus).toBe('completed'); + expect(material.qdrantDocumentId).toBeDefined(); + }, 10000); // 10 second timeout for slow processing +}); +``` + +## Mocking External Services + +### Mock Database +```javascript +import { jest } from '@jest/globals'; + +// Mock mongoose +jest.mock('mongoose', () => ({ + connect: jest.fn().mockResolvedValue(true), + disconnect: jest.fn().mockResolvedValue(true) +})); +``` + +### Mock RAG Service +```javascript +jest.mock('../../services/ragService.js', () => ({ + default: { + processAndEmbedMaterial: jest.fn().mockResolvedValue({ + success: true, + chunksCount: 5 + }) + } +})); +``` + +### Mock LLM Service +```javascript +jest.mock('../../services/llmService.js', () => ({ + default: { + generateQuestion: jest.fn().mockResolvedValue({ + questionData: { + questionText: 'Mock question?', + options: ['A', 'B', 'C', 'D'] + } + }) + } +})); +``` + +## Test Database Setup + +For integration tests, use a separate test database: + +```javascript +// __tests__/setup.js +import mongoose from 'mongoose'; + +const MONGODB_TEST_URI = process.env.MONGODB_TEST_URI || 'mongodb://localhost:27017/tlef-test'; + +beforeAll(async () => { + await mongoose.connect(MONGODB_TEST_URI); +}); + +afterAll(async () => { + await mongoose.connection.dropDatabase(); + await mongoose.connection.close(); +}); +``` + +## Continuous Integration (CI) + +Add to your GitHub Actions workflow: + +```yaml +# .github/workflows/test.yml +name: Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + + services: + mongodb: + image: mongo:7 + ports: + - 27017:27017 + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: '20' + - run: npm install + - run: npm test + - run: npm run test:coverage +``` + +## Common Jest Matchers + +```javascript +// Equality +expect(value).toBe(4); // Strict equality (===) +expect(obj).toEqual({ a: 1 }); // Deep equality + +// Truthiness +expect(value).toBeTruthy(); +expect(value).toBeFalsy(); +expect(value).toBeNull(); +expect(value).toBeDefined(); + +// Numbers +expect(value).toBeGreaterThan(3); +expect(value).toBeLessThan(5); + +// Strings +expect(str).toMatch(/pattern/); +expect(str).toContain('substring'); + +// Arrays +expect(arr).toHaveLength(3); +expect(arr).toContain(item); + +// Objects +expect(obj).toHaveProperty('key'); +expect(obj).toMatchObject({ a: 1 }); + +// Async +await expect(promise).resolves.toBe(value); +await expect(promise).rejects.toThrow(Error); + +// Functions +expect(fn).toThrow(); +expect(fn).toHaveBeenCalled(); +expect(fn).toHaveBeenCalledWith(arg1, arg2); +``` + +## Debugging Tests + +### Run specific test +```bash +npm test -- --testNamePattern="should create text material" +``` + +### Run with verbose output +```bash +npm test -- --verbose +``` + +### Run with debug logging +```bash +DEBUG=* npm test +``` + +### Use Node debugger +```bash +node --inspect-brk node_modules/.bin/jest --runInBand +``` + +Then open `chrome://inspect` in Chrome. + +## Next Steps + +1. ✅ **Backend unit tests** - Already have some, expand coverage +2. ✅ **Backend integration tests** - Already have some, expand coverage +3. ⚠️ **Add frontend tests** - Set up Vitest + React Testing Library +4. ⚠️ **Add E2E tests** - Set up Playwright for critical workflows +5. ⚠️ **CI/CD integration** - Run tests on every commit + +## Resources + +- [Jest Documentation](https://jestjs.io/docs/getting-started) +- [Supertest Documentation](https://github.com/ladjs/supertest) +- [React Testing Library](https://testing-library.com/react) +- [Playwright](https://playwright.dev/) +- [Testing Best Practices](https://kentcdodds.com/blog/common-mistakes-with-react-testing-library) diff --git a/package.json b/package.json index dd172b7..a95186c 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,9 @@ "production": "npm run build && NODE_ENV=production npm start", "lint": "eslint .", "preview": "vite preview", - "test": "cd routes/create && jest", - "test:watch": "cd routes/create && jest --watch", - "test:coverage": "cd routes/create && jest --coverage", + "test": "NODE_OPTIONS='--experimental-vm-modules' jest --config routes/create/jest.config.js", + "test:watch": "NODE_OPTIONS='--experimental-vm-modules' jest --config routes/create/jest.config.js --watch", + "test:coverage": "NODE_OPTIONS='--experimental-vm-modules' jest --config routes/create/jest.config.js --coverage", "deploy:staging": "npm run build && echo 'Ready for staging deployment!'", "health": "node -e \"console.log('TLEF-CREATE Health Check: OK')\"" }, diff --git a/routes/create/__tests__/setup.js b/routes/create/__tests__/setup.js index 2d6f0f6..ffb07ab 100644 --- a/routes/create/__tests__/setup.js +++ b/routes/create/__tests__/setup.js @@ -1,8 +1,12 @@ import mongoose from 'mongoose'; import dotenv from 'dotenv'; +import { jest } from '@jest/globals'; dotenv.config(); +// Global test timeout +jest.setTimeout(30000); + beforeAll(async () => { // Use test database (you can create a separate test database) const testDbUri = process.env.MONGODB_TEST_URI || 'mongodb://localhost:27017/tlef_test'; @@ -22,7 +26,4 @@ afterEach(async () => { const collection = collections[key]; await collection.deleteMany({}); } -}); - -// Global test timeout -jest.setTimeout(30000); \ No newline at end of file +}); \ No newline at end of file diff --git a/routes/create/__tests__/unit/asyncHandler.test.js b/routes/create/__tests__/unit/asyncHandler.test.js index ae7cf94..661acfc 100644 --- a/routes/create/__tests__/unit/asyncHandler.test.js +++ b/routes/create/__tests__/unit/asyncHandler.test.js @@ -1,4 +1,4 @@ -import { describe, test, expect } from '@jest/globals'; +import { describe, test, expect, jest } from '@jest/globals'; import { asyncHandler } from '../../utils/asyncHandler.js'; describe('AsyncHandler Utility', () => { diff --git a/routes/create/__tests__/unit/responseFormatter.test.js b/routes/create/__tests__/unit/responseFormatter.test.js index 8aa82bb..7eeed29 100644 --- a/routes/create/__tests__/unit/responseFormatter.test.js +++ b/routes/create/__tests__/unit/responseFormatter.test.js @@ -1,4 +1,4 @@ -import { describe, test, expect } from '@jest/globals'; +import { describe, test, expect, jest } from '@jest/globals'; import { successResponse, errorResponse, notFoundResponse, unauthorizedResponse } from '../../utils/responseFormatter.js'; describe('Response Formatter Utils', () => { diff --git a/routes/create/jest.config.js b/routes/create/jest.config.js index f993981..7c067c1 100644 --- a/routes/create/jest.config.js +++ b/routes/create/jest.config.js @@ -1,13 +1,16 @@ export default { testEnvironment: 'node', testMatch: ['**/__tests__/**/*.test.js'], + transform: {}, // Use Node's native ESM support collectCoverageFrom: [ '**/*.js', '!**/*.test.js', '!**/node_modules/**', '!server.js', - '!test-server.js' + '!test-server.js', + '!jest.config.js' ], setupFilesAfterEnv: ['/__tests__/setup.js'], - verbose: true + verbose: true, + testTimeout: 10000 // 10 seconds for integration tests }; \ No newline at end of file