chore: sync infra docs and coverage
This commit is contained in:
		| @@ -7,14 +7,39 @@ const optionalNumber = (value, fallback) => { | ||||
|   return Number.isFinite(parsed) ? parsed : fallback; | ||||
| }; | ||||
|  | ||||
| const deriveDatabaseUrl = () => { | ||||
|   if (process.env.DATABASE_URL) { | ||||
|     return process.env.DATABASE_URL; | ||||
|   } | ||||
|  | ||||
|   const { | ||||
|     POSTGRES_USER = 'merchantsofhope_user', | ||||
|     POSTGRES_PASSWORD, | ||||
|     POSTGRES_DB = 'merchantsofhope_supplyanddemandportal', | ||||
|     POSTGRES_HOST = 'merchantsofhope-supplyanddemandportal-database', | ||||
|     POSTGRES_PORT = '5432' | ||||
|   } = process.env; | ||||
|  | ||||
|   if (!POSTGRES_PASSWORD) { | ||||
|     return undefined; | ||||
|   } | ||||
|  | ||||
|   return `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}`; | ||||
| }; | ||||
|  | ||||
| const config = { | ||||
|   env: process.env.NODE_ENV || 'development', | ||||
|   host: process.env.HOST || process.env.BACKEND_HOST || '0.0.0.0', | ||||
|   port: optionalNumber(process.env.PORT || process.env.BACKEND_PORT, 3001), | ||||
|   databaseUrl: process.env.DATABASE_URL, | ||||
|   databaseUrl: deriveDatabaseUrl(), | ||||
|   jwtSecret: process.env.JWT_SECRET, | ||||
|   corsOrigin: process.env.CORS_ORIGIN || '*', | ||||
|   logLevel: process.env.LOG_LEVEL || 'info' | ||||
|   logLevel: process.env.LOG_LEVEL || 'info', | ||||
|   uploadDir: process.env.UPLOAD_DIR || 'uploads/resumes', | ||||
|   rateLimit: { | ||||
|     windowMs: optionalNumber(process.env.RATE_LIMIT_WINDOW_MS, 15 * 60 * 1000), | ||||
|     max: optionalNumber(process.env.RATE_LIMIT_MAX, 100) | ||||
|   } | ||||
| }; | ||||
|  | ||||
| const required = ['databaseUrl', 'jwtSecret']; | ||||
|   | ||||
| @@ -9,11 +9,14 @@ const pool = new Pool({ | ||||
| // Test database connection | ||||
| pool.on('connect', () => { | ||||
|   if (config.logLevel !== 'silent') { | ||||
|     console.log('Connected to MerchantsOfHope-SupplyANdDemandPortal database'); | ||||
|     console.info('Connected to MerchantsOfHope-SupplyANdDemandPortal database'); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| pool.on('error', (err) => { | ||||
|   if (err.code === '57P01' || err.message?.includes('terminating connection')) { | ||||
|     return; | ||||
|   } | ||||
|   console.error('Database connection error:', err); | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -4,7 +4,7 @@ const pool = require('./connection'); | ||||
|  | ||||
| async function migrate() { | ||||
|   try { | ||||
|     console.log('Starting database migration...'); | ||||
|     console.info('Starting database migration...'); | ||||
|      | ||||
|     // Read schema file | ||||
|     const schemaPath = path.join(__dirname, 'schema.sql'); | ||||
| @@ -13,7 +13,7 @@ async function migrate() { | ||||
|     // Execute schema | ||||
|     await pool.query(schema); | ||||
|      | ||||
|     console.log('Database migration completed successfully!'); | ||||
|     console.info('Database migration completed successfully!'); | ||||
|   } catch (error) { | ||||
|     console.error('Migration failed:', error); | ||||
|     process.exit(1); | ||||
|   | ||||
| @@ -3,7 +3,7 @@ const pool = require('./connection'); | ||||
|  | ||||
| async function seed() { | ||||
|   try { | ||||
|     console.log('Starting database seeding...'); | ||||
|     console.info('Starting database seeding...'); | ||||
|      | ||||
|     // Hash passwords | ||||
|     const adminPassword = await bcrypt.hash('admin123', 10); | ||||
| @@ -86,7 +86,7 @@ async function seed() { | ||||
|     ); | ||||
|      | ||||
|     // Insert job posting | ||||
|     const jobResult = await pool.query( | ||||
|     await pool.query( | ||||
|       `INSERT INTO jobs (employer_id, title, description, requirements, responsibilities, location, employment_type, salary_min, salary_max, remote_allowed, experience_level, skills_required, benefits) | ||||
|        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id`, | ||||
|       [ | ||||
| @@ -106,12 +106,12 @@ async function seed() { | ||||
|       ] | ||||
|     ); | ||||
|      | ||||
|     console.log('Database seeding completed successfully!'); | ||||
|     console.log('Sample users created:'); | ||||
|     console.log('- Admin: admin@merchantsofhope.org / admin123'); | ||||
|     console.log('- Recruiter: recruiter@merchantsofhope.org / recruiter123'); | ||||
|     console.log('- Employer: employer@techcorp.com / employer123'); | ||||
|     console.log('- Candidate: candidate@example.com / candidate123'); | ||||
|     console.info('Database seeding completed successfully!'); | ||||
|     console.info('Sample users created:'); | ||||
|     console.info('- Admin: admin@merchantsofhope.org / admin123'); | ||||
|     console.info('- Recruiter: recruiter@merchantsofhope.org / recruiter123'); | ||||
|     console.info('- Employer: employer@techcorp.com / employer123'); | ||||
|     console.info('- Candidate: candidate@example.com / candidate123'); | ||||
|      | ||||
|   } catch (error) { | ||||
|     console.error('Seeding failed:', error); | ||||
|   | ||||
| @@ -2,5 +2,5 @@ const app = require('./server'); | ||||
| const config = require('./config'); | ||||
|  | ||||
| app.listen(config.port, config.host, () => { | ||||
|   console.log(`MerchantsOfHope-SupplyANdDemandPortal backend server running on ${config.host}:${config.port}`); | ||||
|   console.info(`MerchantsOfHope-SupplyANdDemandPortal backend server running on ${config.host}:${config.port}`); | ||||
| }); | ||||
|   | ||||
| @@ -3,22 +3,25 @@ const multer = require('multer'); | ||||
| const path = require('path'); | ||||
| const fs = require('fs'); | ||||
| const { v4: uuidv4 } = require('uuid'); | ||||
| const sanitize = require('sanitize-filename'); | ||||
| const pool = require('../database/connection'); | ||||
| const { authenticateToken, requireRole } = require('../middleware/auth'); | ||||
| const config = require('../config'); | ||||
|  | ||||
| const router = express.Router(); | ||||
|  | ||||
| // Configure multer for file uploads | ||||
| const storage = multer.diskStorage({ | ||||
|   destination: (req, file, cb) => { | ||||
|     const uploadDir = path.join(__dirname, '../../uploads/resumes'); | ||||
|     const uploadDir = path.join(__dirname, '..', '..', config.uploadDir); | ||||
|     if (!fs.existsSync(uploadDir)) { | ||||
|       fs.mkdirSync(uploadDir, { recursive: true }); | ||||
|     } | ||||
|     cb(null, uploadDir); | ||||
|   }, | ||||
|   filename: (req, file, cb) => { | ||||
|     const uniqueName = `${uuidv4()}-${file.originalname}`; | ||||
|     const safeOriginal = sanitize(file.originalname); | ||||
|     const uniqueName = `${uuidv4()}-${safeOriginal || 'resume'}`; | ||||
|     cb(null, uniqueName); | ||||
|   } | ||||
| }); | ||||
|   | ||||
| @@ -2,6 +2,7 @@ const express = require('express'); | ||||
| const cors = require('cors'); | ||||
| const helmet = require('helmet'); | ||||
| const morgan = require('morgan'); | ||||
| const rateLimit = require('express-rate-limit'); | ||||
| const config = require('./config'); | ||||
|  | ||||
| const authRoutes = require('./routes/auth'); | ||||
| @@ -14,6 +15,13 @@ const resumeRoutes = require('./routes/resumes'); | ||||
|  | ||||
| const app = express(); | ||||
|  | ||||
| const authLimiter = rateLimit({ | ||||
|   windowMs: config.rateLimit.windowMs, | ||||
|   max: config.rateLimit.max, | ||||
|   standardHeaders: true, | ||||
|   legacyHeaders: false | ||||
| }); | ||||
|  | ||||
| app.disable('x-powered-by'); | ||||
|  | ||||
| const corsOrigins = config.corsOrigin === '*' | ||||
| @@ -26,7 +34,7 @@ app.use(morgan(config.env === 'production' ? 'combined' : 'dev')); | ||||
| app.use(express.json({ limit: '10mb' })); | ||||
| app.use(express.urlencoded({ extended: true })); | ||||
|  | ||||
| app.use('/api/auth', authRoutes); | ||||
| app.use('/api/auth', authLimiter, authRoutes); | ||||
| app.use('/api/users', userRoutes); | ||||
| app.use('/api/employers', employerRoutes); | ||||
| app.use('/api/candidates', candidateRoutes); | ||||
|   | ||||
							
								
								
									
										79
									
								
								backend/src/tests/applications.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								backend/src/tests/applications.test.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | ||||
| const request = require('supertest'); | ||||
| const app = require('../server'); | ||||
| const { registerUser, createEmployerProfile } = require('./utils'); | ||||
|  | ||||
| async function createJob(token) { | ||||
|   const response = await request(app) | ||||
|     .post('/api/jobs') | ||||
|     .set('Authorization', `Bearer ${token}`) | ||||
|     .send({ | ||||
|       title: 'QA Engineer', | ||||
|       description: 'Ensure software quality', | ||||
|       requirements: ['Attention to detail'], | ||||
|       responsibilities: ['Write test plans'], | ||||
|       location: 'Remote', | ||||
|       employmentType: 'full-time', | ||||
|       remoteAllowed: true | ||||
|     }) | ||||
|     .expect(201); | ||||
|  | ||||
|   return response.body.job; | ||||
| } | ||||
|  | ||||
| describe('Applications API', () => { | ||||
|   it('supports full application lifecycle', async () => { | ||||
|     const { token: employerToken } = await registerUser('employer'); | ||||
|     await createEmployerProfile(employerToken); | ||||
|     const job = await createJob(employerToken); | ||||
|  | ||||
|     const { token: candidateToken } = await registerUser('candidate'); | ||||
|  | ||||
|     await request(app) | ||||
|       .post('/api/candidates') | ||||
|       .set('Authorization', `Bearer ${candidateToken}`) | ||||
|       .send({ | ||||
|         location: 'Remote', | ||||
|         skills: ['Automation'], | ||||
|         experienceLevel: 'mid' | ||||
|       }) | ||||
|       .expect(201); | ||||
|  | ||||
|     const applicationResponse = await request(app) | ||||
|       .post('/api/applications') | ||||
|       .set('Authorization', `Bearer ${candidateToken}`) | ||||
|       .send({ | ||||
|         jobId: job.id, | ||||
|         coverLetter: 'Excited to apply' | ||||
|       }) | ||||
|       .expect(201); | ||||
|  | ||||
|     const applicationId = applicationResponse.body.application.id; | ||||
|  | ||||
|     const listForCandidate = await request(app) | ||||
|       .get('/api/applications') | ||||
|       .set('Authorization', `Bearer ${candidateToken}`) | ||||
|       .expect(200); | ||||
|  | ||||
|     expect(listForCandidate.body.applications).toHaveLength(1); | ||||
|  | ||||
|     await request(app) | ||||
|       .put(`/api/applications/${applicationId}/status`) | ||||
|       .set('Authorization', `Bearer ${employerToken}`) | ||||
|       .send({ status: 'reviewed' }) | ||||
|       .expect(200); | ||||
|  | ||||
|     await request(app) | ||||
|       .put(`/api/applications/${applicationId}/notes`) | ||||
|       .set('Authorization', `Bearer ${employerToken}`) | ||||
|       .send({ notes: 'Strong automation background' }) | ||||
|       .expect(200); | ||||
|  | ||||
|     const detailResponse = await request(app) | ||||
|       .get(`/api/applications/${applicationId}`) | ||||
|       .set('Authorization', `Bearer ${employerToken}`) | ||||
|       .expect(200); | ||||
|  | ||||
|     expect(detailResponse.body.status).toBe('reviewed'); | ||||
|     expect(detailResponse.body.notes).toBe('Strong automation background'); | ||||
|   }); | ||||
| }); | ||||
| @@ -1,23 +1,13 @@ | ||||
| const request = require('supertest'); | ||||
| const app = require('../server'); | ||||
| const pool = require('../database/connection'); | ||||
|  | ||||
| describe('Authentication', () => { | ||||
|   beforeEach(async () => { | ||||
|     // Clean up database before each test | ||||
|     await pool.query('DELETE FROM users WHERE email LIKE $1', ['test%']); | ||||
|   }); | ||||
|  | ||||
|   afterAll(async () => { | ||||
|     await pool.end(); | ||||
|   }); | ||||
|  | ||||
|   describe('POST /api/auth/register', () => { | ||||
|     it('should register a new user successfully', async () => { | ||||
|       const email = `test-${Date.now()}@example.com`; | ||||
|       const userData = { | ||||
|         firstName: 'Test', | ||||
|         lastName: 'User', | ||||
|         email: 'test@example.com', | ||||
|         email, | ||||
|         password: 'password123', | ||||
|         role: 'candidate' | ||||
|       }; | ||||
| @@ -34,10 +24,11 @@ describe('Authentication', () => { | ||||
|     }); | ||||
|  | ||||
|     it('should return error for duplicate email', async () => { | ||||
|       const email = `test-${Date.now()}@example.com`; | ||||
|       const userData = { | ||||
|         firstName: 'Test', | ||||
|         lastName: 'User', | ||||
|         email: 'test@example.com', | ||||
|         email, | ||||
|         password: 'password123', | ||||
|         role: 'candidate' | ||||
|       }; | ||||
| @@ -57,10 +48,11 @@ describe('Authentication', () => { | ||||
|     }); | ||||
|  | ||||
|     it('should return error for invalid role', async () => { | ||||
|       const email = `test-${Date.now()}@example.com`; | ||||
|       const userData = { | ||||
|         firstName: 'Test', | ||||
|         lastName: 'User', | ||||
|         email: 'test@example.com', | ||||
|         email, | ||||
|         password: 'password123', | ||||
|         role: 'invalid_role' | ||||
|       }; | ||||
| @@ -75,12 +67,15 @@ describe('Authentication', () => { | ||||
|   }); | ||||
|  | ||||
|   describe('POST /api/auth/login', () => { | ||||
|     let loginEmail; | ||||
|  | ||||
|     beforeEach(async () => { | ||||
|       // Create a test user | ||||
|       loginEmail = `test-${Date.now()}@example.com`; | ||||
|       const userData = { | ||||
|         firstName: 'Test', | ||||
|         lastName: 'User', | ||||
|         email: 'test@example.com', | ||||
|         email: loginEmail, | ||||
|         password: 'password123', | ||||
|         role: 'candidate' | ||||
|       }; | ||||
| @@ -92,7 +87,7 @@ describe('Authentication', () => { | ||||
|  | ||||
|     it('should login with valid credentials', async () => { | ||||
|       const loginData = { | ||||
|         email: 'test@example.com', | ||||
|         email: loginEmail, | ||||
|         password: 'password123' | ||||
|       }; | ||||
|  | ||||
| @@ -108,7 +103,7 @@ describe('Authentication', () => { | ||||
|  | ||||
|     it('should return error for invalid credentials', async () => { | ||||
|       const loginData = { | ||||
|         email: 'test@example.com', | ||||
|         email: loginEmail, | ||||
|         password: 'wrongpassword' | ||||
|       }; | ||||
|  | ||||
| @@ -137,13 +132,15 @@ describe('Authentication', () => { | ||||
|  | ||||
|   describe('GET /api/auth/me', () => { | ||||
|     let token; | ||||
|     let email; | ||||
|  | ||||
|     beforeEach(async () => { | ||||
|       // Create a test user and get token | ||||
|       email = `test-${Date.now()}@example.com`; | ||||
|       const userData = { | ||||
|         firstName: 'Test', | ||||
|         lastName: 'User', | ||||
|         email: 'test@example.com', | ||||
|         email, | ||||
|         password: 'password123', | ||||
|         role: 'candidate' | ||||
|       }; | ||||
| @@ -162,7 +159,7 @@ describe('Authentication', () => { | ||||
|         .expect(200); | ||||
|  | ||||
|       expect(response.body).toHaveProperty('user'); | ||||
|       expect(response.body.user.email).toBe('test@example.com'); | ||||
|       expect(response.body.user.email).toBe(email); | ||||
|     }); | ||||
|  | ||||
|     it('should return error without token', async () => { | ||||
|   | ||||
							
								
								
									
										44
									
								
								backend/src/tests/candidates.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								backend/src/tests/candidates.test.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | ||||
| const request = require('supertest'); | ||||
| const app = require('../server'); | ||||
| const { registerUser } = require('./utils'); | ||||
|  | ||||
| describe('Candidates API', () => { | ||||
|   it('creates and retrieves candidate profiles with filters', async () => { | ||||
|     const { token } = await registerUser('candidate'); | ||||
|  | ||||
|     const createResponse = await request(app) | ||||
|       .post('/api/candidates') | ||||
|       .set('Authorization', `Bearer ${token}`) | ||||
|       .send({ | ||||
|         phone: '+1-555-5555', | ||||
|         location: 'Austin, TX', | ||||
|         skills: ['JavaScript', 'React'], | ||||
|         experienceLevel: 'mid' | ||||
|       }) | ||||
|       .expect(201); | ||||
|  | ||||
|     const candidateId = createResponse.body.candidate.id; | ||||
|  | ||||
|     const listResponse = await request(app) | ||||
|       .get('/api/candidates?skills=React&location=Austin') | ||||
|       .set('Authorization', `Bearer ${token}`) | ||||
|       .expect(200); | ||||
|  | ||||
|     expect(listResponse.body.candidates).toHaveLength(1); | ||||
|  | ||||
|     const detailResponse = await request(app) | ||||
|       .get(`/api/candidates/${candidateId}`) | ||||
|       .set('Authorization', `Bearer ${token}`) | ||||
|       .expect(200); | ||||
|  | ||||
|     expect(detailResponse.body.skills).toContain('React'); | ||||
|  | ||||
|     const updateResponse = await request(app) | ||||
|       .put(`/api/candidates/${candidateId}`) | ||||
|       .set('Authorization', `Bearer ${token}`) | ||||
|       .send({ availability: 'Immediate' }) | ||||
|       .expect(200); | ||||
|  | ||||
|     expect(updateResponse.body.candidate.availability).toBe('Immediate'); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										34
									
								
								backend/src/tests/employers.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								backend/src/tests/employers.test.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| const request = require('supertest'); | ||||
| const app = require('../server'); | ||||
| const { registerUser, createEmployerProfile } = require('./utils'); | ||||
|  | ||||
| describe('Employers API', () => { | ||||
|   it('allows an employer to create and update their profile', async () => { | ||||
|     const { token } = await registerUser('employer'); | ||||
|  | ||||
|     const employer = await createEmployerProfile(token, { | ||||
|       companyName: 'Acme Corp', | ||||
|       industry: 'Manufacturing' | ||||
|     }); | ||||
|  | ||||
|     expect(employer).toMatchObject({ | ||||
|       company_name: 'Acme Corp', | ||||
|       industry: 'Manufacturing' | ||||
|     }); | ||||
|  | ||||
|     const listResponse = await request(app) | ||||
|       .get('/api/employers') | ||||
|       .set('Authorization', `Bearer ${token}`) | ||||
|       .expect(200); | ||||
|  | ||||
|     expect(listResponse.body.length).toBeGreaterThan(0); | ||||
|  | ||||
|     const updateResponse = await request(app) | ||||
|       .put(`/api/employers/${employer.id}`) | ||||
|       .set('Authorization', `Bearer ${token}`) | ||||
|       .send({ description: 'Updated description' }) | ||||
|       .expect(200); | ||||
|  | ||||
|     expect(updateResponse.body.employer.description).toBe('Updated description'); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										1
									
								
								backend/src/tests/fixtures/resume.txt
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								backend/src/tests/fixtures/resume.txt
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| Sample resume content for testing. | ||||
							
								
								
									
										65
									
								
								backend/src/tests/globalSetup.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								backend/src/tests/globalSetup.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
| const { spawnSync } = require('child_process'); | ||||
| const path = require('path'); | ||||
|  | ||||
| const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); | ||||
|  | ||||
| module.exports = async () => { | ||||
|   process.env.NODE_ENV = process.env.NODE_ENV || 'test'; | ||||
|   process.env.JWT_SECRET = process.env.JWT_SECRET || 'merchantsofhope_test_secret'; | ||||
|   process.env.POSTGRES_DB = process.env.POSTGRES_DB || 'merchantsofhope_test'; | ||||
|   process.env.POSTGRES_USER = process.env.POSTGRES_USER || 'postgres'; | ||||
|   process.env.POSTGRES_PASSWORD = process.env.POSTGRES_PASSWORD || 'postgres'; | ||||
|   process.env.POSTGRES_HOST = process.env.POSTGRES_HOST || '127.0.0.1'; | ||||
|   process.env.POSTGRES_PORT = process.env.POSTGRES_PORT || '55432'; | ||||
|  | ||||
|   const composeFile = path.join(__dirname, '..', '..', '..', 'docker-compose.test.yml'); | ||||
|  | ||||
|   const upResult = spawnSync( | ||||
|     'docker', | ||||
|     ['compose', '-f', composeFile, 'up', '-d', 'merchantsofhope-supplyanddemandportal-test-database'], | ||||
|     { | ||||
|       cwd: path.join(__dirname, '..', '..'), | ||||
|       stdio: 'inherit', | ||||
|       env: process.env | ||||
|     } | ||||
|   ); | ||||
|  | ||||
|   if (upResult.status !== 0) { | ||||
|     throw new Error('Failed to start test database container'); | ||||
|   } | ||||
|  | ||||
|   let ready = false; | ||||
|   for (let attempt = 0; attempt < 30; attempt += 1) { | ||||
|     const health = spawnSync( | ||||
|       'docker', | ||||
|       ['compose', '-f', composeFile, 'exec', '-T', 'merchantsofhope-supplyanddemandportal-test-database', 'pg_isready', '-U', process.env.POSTGRES_USER], | ||||
|       { | ||||
|         cwd: path.join(__dirname, '..', '..'), | ||||
|         env: process.env, | ||||
|         stdio: 'ignore' | ||||
|       } | ||||
|     ); | ||||
|  | ||||
|     if (health.status === 0) { | ||||
|       ready = true; | ||||
|       break; | ||||
|     } | ||||
|  | ||||
|     sleep(1000); | ||||
|   } | ||||
|  | ||||
|   if (!ready) { | ||||
|     throw new Error('Database did not become ready in time'); | ||||
|   } | ||||
|  | ||||
|   const migratePath = path.join(__dirname, '..', 'database', 'migrate.js'); | ||||
|   const result = spawnSync('node', [migratePath], { | ||||
|     cwd: path.join(__dirname, '..', '..'), | ||||
|     stdio: 'inherit', | ||||
|     env: process.env | ||||
|   }); | ||||
|  | ||||
|   if (result.status !== 0) { | ||||
|     throw new Error('Database migration failed before running tests'); | ||||
|   } | ||||
| }; | ||||
							
								
								
									
										20
									
								
								backend/src/tests/globalTeardown.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								backend/src/tests/globalTeardown.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| const { spawnSync } = require('child_process'); | ||||
| const path = require('path'); | ||||
| const pool = require('../database/connection'); | ||||
| const { cleanupUploads } = require('./utils'); | ||||
|  | ||||
| module.exports = async () => { | ||||
|   await cleanupUploads(); | ||||
|   await pool.end(); | ||||
|  | ||||
|   const composeFile = path.join(__dirname, '..', '..', '..', 'docker-compose.test.yml'); | ||||
|   spawnSync( | ||||
|     'docker', | ||||
|     ['compose', '-f', composeFile, 'down', '--volumes'], | ||||
|     { | ||||
|       cwd: path.join(__dirname, '..', '..'), | ||||
|       stdio: 'inherit', | ||||
|       env: process.env | ||||
|     } | ||||
|   ); | ||||
| }; | ||||
| @@ -1,48 +1,14 @@ | ||||
| const request = require('supertest'); | ||||
| const app = require('../server'); | ||||
| const pool = require('../database/connection'); | ||||
| const { registerUser, createEmployerProfile } = require('./utils'); | ||||
|  | ||||
| describe('Jobs API', () => { | ||||
|   let authToken; | ||||
|   let employerId; | ||||
|  | ||||
|   beforeAll(async () => { | ||||
|     // Create test user and get auth token | ||||
|     const userData = { | ||||
|       firstName: 'Test', | ||||
|       lastName: 'Employer', | ||||
|       email: 'employer@test.com', | ||||
|       password: 'password123', | ||||
|       role: 'employer' | ||||
|     }; | ||||
|  | ||||
|     const registerResponse = await request(app) | ||||
|       .post('/api/auth/register') | ||||
|       .send(userData); | ||||
|  | ||||
|     authToken = registerResponse.body.token; | ||||
|  | ||||
|     // Create employer profile | ||||
|     const employerData = { | ||||
|       companyName: 'Test Company', | ||||
|       industry: 'Technology', | ||||
|       companySize: '50-200', | ||||
|       website: 'https://testcompany.com', | ||||
|       description: 'A test company', | ||||
|       address: '123 Test St', | ||||
|       phone: '+1-555-0123' | ||||
|     }; | ||||
|  | ||||
|     const employerResponse = await request(app) | ||||
|       .post('/api/employers') | ||||
|       .set('Authorization', `Bearer ${authToken}`) | ||||
|       .send(employerData); | ||||
|  | ||||
|     employerId = employerResponse.body.employer.id; | ||||
|   }); | ||||
|  | ||||
|   afterAll(async () => { | ||||
|     await pool.end(); | ||||
|   beforeEach(async () => { | ||||
|     const { token } = await registerUser('employer'); | ||||
|     authToken = token; | ||||
|     await createEmployerProfile(token); | ||||
|   }); | ||||
|  | ||||
|   describe('POST /api/jobs', () => { | ||||
|   | ||||
							
								
								
									
										55
									
								
								backend/src/tests/resumes.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								backend/src/tests/resumes.test.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| const path = require('path'); | ||||
| const request = require('supertest'); | ||||
| const app = require('../server'); | ||||
| const { registerUser, ensureUploadDir } = require('./utils'); | ||||
|  | ||||
| describe('Resumes API', () => { | ||||
|   it('handles resume upload lifecycle for candidates', async () => { | ||||
|     const { token } = await registerUser('candidate'); | ||||
|  | ||||
|     const candidateResponse = await request(app) | ||||
|       .post('/api/candidates') | ||||
|       .set('Authorization', `Bearer ${token}`) | ||||
|       .send({ | ||||
|         location: 'Houston, TX', | ||||
|         skills: ['Node.js'] | ||||
|       }) | ||||
|       .expect(201); | ||||
|  | ||||
|     const candidateId = candidateResponse.body.candidate.id; | ||||
|     const uploadDir = await ensureUploadDir(); | ||||
|     expect(uploadDir).toBeTruthy(); | ||||
|  | ||||
|     const uploadResponse = await request(app) | ||||
|       .post('/api/resumes/upload') | ||||
|       .set('Authorization', `Bearer ${token}`) | ||||
|       .field('isPrimary', 'true') | ||||
|       .attach('resume', path.join(__dirname, 'fixtures', 'resume.txt')) | ||||
|       .expect(201); | ||||
|  | ||||
|     const resumeId = uploadResponse.body.resume.id; | ||||
|  | ||||
|     const listResponse = await request(app) | ||||
|       .get(`/api/resumes/candidate/${candidateId}`) | ||||
|       .set('Authorization', `Bearer ${token}`) | ||||
|       .expect(200); | ||||
|  | ||||
|     expect(listResponse.body).toHaveLength(1); | ||||
|     expect(listResponse.body[0].is_primary).toBe(true); | ||||
|  | ||||
|     await request(app) | ||||
|       .put(`/api/resumes/${resumeId}/primary`) | ||||
|       .set('Authorization', `Bearer ${token}`) | ||||
|       .expect(200); | ||||
|  | ||||
|     await request(app) | ||||
|       .get(`/api/resumes/${resumeId}/download`) | ||||
|       .set('Authorization', `Bearer ${token}`) | ||||
|       .expect(200); | ||||
|  | ||||
|     await request(app) | ||||
|       .delete(`/api/resumes/${resumeId}`) | ||||
|       .set('Authorization', `Bearer ${token}`) | ||||
|       .expect(200); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										17
									
								
								backend/src/tests/setup.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								backend/src/tests/setup.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| const pool = require('../database/connection'); | ||||
| const { cleanupUploads } = require('./utils'); | ||||
|  | ||||
| afterEach(async () => { | ||||
|   await pool.query(` | ||||
|     TRUNCATE TABLE | ||||
|       applications, | ||||
|       resumes, | ||||
|       interviews, | ||||
|       jobs, | ||||
|       candidates, | ||||
|       employers, | ||||
|       users | ||||
|     RESTART IDENTITY CASCADE | ||||
|   `); | ||||
|   await cleanupUploads(); | ||||
| }); | ||||
							
								
								
									
										35
									
								
								backend/src/tests/users.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								backend/src/tests/users.test.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| const request = require('supertest'); | ||||
| const app = require('../server'); | ||||
| const { registerUser, createAdminUser } = require('./utils'); | ||||
|  | ||||
| describe('Users API', () => { | ||||
|   it('allows admin to list users and manage activation', async () => { | ||||
|     const { token: adminToken } = await createAdminUser(); | ||||
|     const { user, token } = await registerUser('candidate'); | ||||
|  | ||||
|     const listResponse = await request(app) | ||||
|       .get('/api/users') | ||||
|       .set('Authorization', `Bearer ${adminToken}`) | ||||
|       .expect(200); | ||||
|  | ||||
|     expect(Array.isArray(listResponse.body)).toBe(true); | ||||
|  | ||||
|     await request(app) | ||||
|       .put(`/api/users/${user.id}`) | ||||
|       .set('Authorization', `Bearer ${token}`) | ||||
|       .send({ firstName: 'Updated' }) | ||||
|       .expect(200); | ||||
|  | ||||
|     await request(app) | ||||
|       .put(`/api/users/${user.id}/deactivate`) | ||||
|       .set('Authorization', `Bearer ${adminToken}`) | ||||
|       .expect(200); | ||||
|  | ||||
|     const reactivate = await request(app) | ||||
|       .put(`/api/users/${user.id}/activate`) | ||||
|       .set('Authorization', `Bearer ${adminToken}`) | ||||
|       .expect(200); | ||||
|  | ||||
|     expect(reactivate.body.user.is_active).toBe(true); | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										112
									
								
								backend/src/tests/utils.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								backend/src/tests/utils.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,112 @@ | ||||
| const { randomUUID } = require('crypto'); | ||||
| const path = require('path'); | ||||
| const fs = require('fs'); | ||||
| const bcrypt = require('bcryptjs'); | ||||
| const request = require('supertest'); | ||||
| const app = require('../server'); | ||||
| const config = require('../config'); | ||||
| const pool = require('../database/connection'); | ||||
|  | ||||
| const uniqueEmail = (prefix = 'user') => `${prefix}-${randomUUID()}@example.com`; | ||||
|  | ||||
| const defaultPassword = 'Password123!'; | ||||
|  | ||||
| async function registerUser(role = 'candidate', overrides = {}) { | ||||
|   const userData = { | ||||
|     firstName: overrides.firstName || 'Test', | ||||
|     lastName: overrides.lastName || 'User', | ||||
|     email: overrides.email || uniqueEmail(role), | ||||
|     password: overrides.password || defaultPassword, | ||||
|     role, | ||||
|     ...overrides | ||||
|   }; | ||||
|  | ||||
|   const response = await request(app) | ||||
|     .post('/api/auth/register') | ||||
|     .send(userData); | ||||
|  | ||||
|   return { | ||||
|     token: response.body.token, | ||||
|     user: response.body.user, | ||||
|     credentials: { | ||||
|       email: userData.email, | ||||
|       password: userData.password | ||||
|     } | ||||
|   }; | ||||
| } | ||||
|  | ||||
| async function createEmployerProfile(token, overrides = {}) { | ||||
|   const employerData = { | ||||
|     companyName: overrides.companyName || `Company ${randomUUID().slice(0, 8)}`, | ||||
|     industry: overrides.industry || 'Technology', | ||||
|     companySize: overrides.companySize || '11-50', | ||||
|     website: overrides.website || 'https://example.com', | ||||
|     description: overrides.description || 'Test company description', | ||||
|     address: overrides.address || '123 Test St', | ||||
|     phone: overrides.phone || '+1-555-0000', | ||||
|     ...overrides | ||||
|   }; | ||||
|  | ||||
|   const response = await request(app) | ||||
|     .post('/api/employers') | ||||
|     .set('Authorization', `Bearer ${token}`) | ||||
|     .send(employerData); | ||||
|  | ||||
|   return response.body.employer; | ||||
| } | ||||
|  | ||||
| async function createAdminUser() { | ||||
|   const email = uniqueEmail('admin'); | ||||
|   const password = defaultPassword; | ||||
|   const passwordHash = await bcrypt.hash(password, 10); | ||||
|  | ||||
|   const result = await pool.query( | ||||
|     'INSERT INTO users (email, password_hash, first_name, last_name, role) VALUES ($1, $2, $3, $4, $5) RETURNING id', | ||||
|     [email, passwordHash, 'Admin', 'User', 'admin'] | ||||
|   ); | ||||
|  | ||||
|   const loginResponse = await request(app) | ||||
|     .post('/api/auth/login') | ||||
|     .send({ email, password }); | ||||
|  | ||||
|   return { | ||||
|     token: loginResponse.body.token, | ||||
|     user: loginResponse.body.user, | ||||
|     userId: result.rows[0].id, | ||||
|     credentials: { email, password } | ||||
|   }; | ||||
| } | ||||
|  | ||||
| async function ensureUploadDir() { | ||||
|   const uploadDir = path.join(__dirname, '..', '..', config.uploadDir); | ||||
|   if (!fs.existsSync(uploadDir)) { | ||||
|     fs.mkdirSync(uploadDir, { recursive: true }); | ||||
|   } | ||||
|   return uploadDir; | ||||
| } | ||||
|  | ||||
| async function cleanupUploads() { | ||||
|   const uploadDir = await ensureUploadDir(); | ||||
|   if (!fs.existsSync(uploadDir)) { | ||||
|     return; | ||||
|   } | ||||
|   const entries = fs.readdirSync(uploadDir); | ||||
|   for (const entry of entries) { | ||||
|     const filePath = path.join(uploadDir, entry); | ||||
|     try { | ||||
|       fs.unlinkSync(filePath); | ||||
|     } catch (error) { | ||||
|       // Ignore deletion errors in tests | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| module.exports = { | ||||
|   registerUser, | ||||
|   createEmployerProfile, | ||||
|   createAdminUser, | ||||
|   uniqueEmail, | ||||
|   defaultPassword, | ||||
|   ensureUploadDir, | ||||
|   cleanupUploads | ||||
| }; | ||||
		Reference in New Issue
	
	Block a user