chore: sync infra docs and coverage
Some checks failed
CI / Backend Tests (push) Failing after 2m41s
CI / Frontend Tests (push) Successful in 2m14s
CI / Build Docker Images (push) Has been skipped

This commit is contained in:
2025-10-16 22:41:22 -05:00
parent a553b14017
commit 252775faf3
109 changed files with 29696 additions and 208 deletions

View File

@@ -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'];

View File

@@ -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);
});

View File

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

View File

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

View File

@@ -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}`);
});

View File

@@ -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);
}
});

View File

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

View 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');
});
});

View File

@@ -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 () => {

View 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');
});
});

View 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
View File

@@ -0,0 +1 @@
Sample resume content for testing.

View 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');
}
};

View 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
}
);
};

View File

@@ -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', () => {

View 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);
});
});

View 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();
});

View 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
View 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
};