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

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