routes tests

This commit is contained in:
DaKheera47 2026-01-20 07:18:02 +00:00
parent ccb05ac0f4
commit 50edefbebe
10 changed files with 646 additions and 0 deletions

View File

@ -0,0 +1,34 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { Server } from 'http';
import { startServer, stopServer } from './test-utils.js';
describe.sequential('Database API routes', () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
it('clears jobs and pipeline runs', async () => {
const { createJob } = await import('../../repositories/jobs.js');
await createJob({
source: 'manual',
title: 'Cleanup Role',
employer: 'Acme',
jobUrl: 'https://example.com/job/cleanup',
jobDescription: 'Test description',
});
const res = await fetch(`${baseUrl}/api/database`, { method: 'DELETE' });
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.jobsDeleted).toBe(1);
});
});

View File

@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { Server } from 'http';
import { startServer, stopServer } from './test-utils.js';
describe.sequential('Jobs API routes', () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
it('lists jobs and supports status filtering', async () => {
const { createJob } = await import('../../repositories/jobs.js');
const job = await createJob({
source: 'manual',
title: 'Test Role',
employer: 'Acme',
jobUrl: 'https://example.com/job/1',
jobDescription: 'Test description',
});
const listRes = await fetch(`${baseUrl}/api/jobs`);
const listBody = await listRes.json();
expect(listBody.success).toBe(true);
expect(listBody.data.total).toBe(1);
expect(listBody.data.jobs[0].id).toBe(job.id);
const filteredRes = await fetch(`${baseUrl}/api/jobs?status=skipped`);
const filteredBody = await filteredRes.json();
expect(filteredBody.data.total).toBe(0);
});
it('returns 404 for missing jobs', async () => {
const res = await fetch(`${baseUrl}/api/jobs/missing-id`);
expect(res.status).toBe(404);
});
it('validates job updates and supports skip/delete flow', async () => {
const { createJob } = await import('../../repositories/jobs.js');
const job = await createJob({
source: 'manual',
title: 'Test Role',
employer: 'Acme',
jobUrl: 'https://example.com/job/2',
jobDescription: 'Test description',
});
const badRes = await fetch(`${baseUrl}/api/jobs/${job.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ suitabilityScore: 1000 }),
});
expect(badRes.status).toBe(400);
const skipRes = await fetch(`${baseUrl}/api/jobs/${job.id}/skip`, { method: 'POST' });
const skipBody = await skipRes.json();
expect(skipBody.data.status).toBe('skipped');
const deleteRes = await fetch(`${baseUrl}/api/jobs/status/skipped`, { method: 'DELETE' });
const deleteBody = await deleteRes.json();
expect(deleteBody.data.count).toBe(1);
});
it('applies a job and syncs to Notion', async () => {
const { createNotionEntry } = await import('../../services/notion.js');
vi.mocked(createNotionEntry).mockResolvedValue({ pageId: 'page-123' });
const { createJob } = await import('../../repositories/jobs.js');
const job = await createJob({
source: 'manual',
title: 'Test Role',
employer: 'Acme',
jobUrl: 'https://example.com/job/3',
jobDescription: 'Test description',
});
const res = await fetch(`${baseUrl}/api/jobs/${job.id}/apply`, { method: 'POST' });
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.status).toBe('applied');
expect(body.data.notionPageId).toBe('page-123');
expect(body.data.appliedAt).toBeTruthy();
expect(createNotionEntry).toHaveBeenCalledWith(
expect.objectContaining({
id: job.id,
title: job.title,
employer: job.employer,
})
);
});
});

View File

@ -0,0 +1,64 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { Server } from 'http';
import { startServer, stopServer } from './test-utils.js';
describe.sequential('Manual jobs API routes', () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
it('infers manual jobs and rejects empty payloads', async () => {
const badRes = await fetch(`${baseUrl}/api/manual-jobs/infer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(badRes.status).toBe(400);
const { inferManualJobDetails } = await import('../../services/manualJob.js');
vi.mocked(inferManualJobDetails).mockResolvedValue({
job: { title: 'Backend Engineer', employer: 'Acme' },
warning: null,
});
const res = await fetch(`${baseUrl}/api/manual-jobs/infer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobDescription: 'Role description' }),
});
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.job.title).toBe('Backend Engineer');
});
it('imports manual jobs and generates a fallback URL', async () => {
const { scoreJobSuitability } = await import('../../services/scorer.js');
vi.mocked(scoreJobSuitability).mockResolvedValue({ score: 88, reason: 'Strong fit' });
const res = await fetch(`${baseUrl}/api/manual-jobs/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
job: {
title: 'Backend Engineer',
employer: 'Acme',
jobDescription: 'Great role',
},
}),
});
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.source).toBe('manual');
expect(body.data.jobUrl).toMatch(/^manual:\/\//);
await new Promise((resolve) => setTimeout(resolve, 25));
});
});

View File

@ -0,0 +1,66 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { Server } from 'http';
import { startServer, stopServer } from './test-utils.js';
describe.sequential('Pipeline API routes', () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
it('reports pipeline status', async () => {
const res = await fetch(`${baseUrl}/api/pipeline/status`);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.isRunning).toBe(false);
expect(body.data.lastRun).toBeNull();
});
it('validates pipeline run payloads', async () => {
const badRun = await fetch(`${baseUrl}/api/pipeline/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ minSuitabilityScore: 120 }),
});
expect(badRun.status).toBe(400);
const { runPipeline } = await import('../../pipeline/index.js');
const runRes = await fetch(`${baseUrl}/api/pipeline/run`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topN: 5, sources: ['gradcracker'] }),
});
const runBody = await runRes.json();
expect(runBody.success).toBe(true);
expect(runPipeline).toHaveBeenCalledWith({ topN: 5, sources: ['gradcracker'] });
});
it('streams pipeline progress over SSE', async () => {
const controller = new AbortController();
const res = await fetch(`${baseUrl}/api/pipeline/progress`, {
signal: controller.signal,
});
expect(res.status).toBe(200);
expect(res.headers.get('content-type')).toContain('text/event-stream');
const reader = res.body?.getReader();
if (reader) {
const chunk = await reader.read();
controller.abort();
await reader.cancel();
const text = new TextDecoder().decode(chunk.value);
expect(text).toContain('data:');
} else {
controller.abort();
}
});
});

View File

@ -0,0 +1,25 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { Server } from 'http';
import { startServer, stopServer } from './test-utils.js';
describe.sequential('Profile API routes', () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
it('returns base resume projects', async () => {
const res = await fetch(`${baseUrl}/api/profile/projects`);
const body = await res.json();
expect(body.success).toBe(true);
expect(Array.isArray(body.data)).toBe(true);
});
});

View File

@ -0,0 +1,45 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { Server } from 'http';
import { startServer, stopServer } from './test-utils.js';
describe.sequential('Settings API routes', () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
it('returns settings with defaults', async () => {
const res = await fetch(`${baseUrl}/api/settings`);
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.defaultModel).toBe('test-model');
expect(Array.isArray(body.data.searchTerms)).toBe(true);
});
it('rejects invalid settings updates and persists overrides', async () => {
const badPatch = await fetch(`${baseUrl}/api/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobspyResultsWanted: 9999 }),
});
expect(badPatch.status).toBe(400);
const patchRes = await fetch(`${baseUrl}/api/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ searchTerms: ['engineer'] }),
});
const patchBody = await patchRes.json();
expect(patchBody.success).toBe(true);
expect(patchBody.data.searchTerms).toEqual(['engineer']);
expect(patchBody.data.overrideSearchTerms).toEqual(['engineer']);
});
});

View File

@ -0,0 +1,112 @@
import { mkdtemp, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import type { Server } from 'http';
import { vi } from 'vitest';
vi.mock('../../pipeline/index.js', () => {
const progress = {
step: 'idle',
message: 'Ready',
crawlingListPagesProcessed: 0,
crawlingListPagesTotal: 0,
crawlingJobCardsFound: 0,
crawlingJobPagesEnqueued: 0,
crawlingJobPagesSkipped: 0,
crawlingJobPagesProcessed: 0,
jobsDiscovered: 0,
jobsScored: 0,
jobsProcessed: 0,
totalToProcess: 0,
};
return {
runPipeline: vi.fn().mockResolvedValue({ success: true, jobsDiscovered: 0, jobsProcessed: 0 }),
processJob: vi.fn().mockResolvedValue({ success: true }),
summarizeJob: vi.fn().mockResolvedValue({ success: true }),
generateFinalPdf: vi.fn().mockResolvedValue({ success: true }),
getPipelineStatus: vi.fn(() => ({ isRunning: false })),
subscribeToProgress: vi.fn((listener: (data: unknown) => void) => {
listener(progress);
return () => {};
}),
};
});
vi.mock('../../services/notion.js', () => ({
createNotionEntry: vi.fn(),
}));
vi.mock('../../services/manualJob.js', () => ({
inferManualJobDetails: vi.fn(),
}));
vi.mock('../../services/scorer.js', () => ({
scoreJobSuitability: vi.fn(),
}));
vi.mock('../../services/ukvisajobs.js', () => ({
fetchUkVisaJobsPage: vi.fn(),
}));
vi.mock('../../services/visa-sponsors/index.js', () => ({
getStatus: vi.fn(),
searchSponsors: vi.fn(),
getOrganizationDetails: vi.fn(),
downloadLatestCsv: vi.fn(),
}));
const originalEnv = { ...process.env };
export async function startServer(options?: {
env?: Record<string, string | undefined>;
}): Promise<{
server: Server;
baseUrl: string;
closeDb: () => void;
tempDir: string;
}> {
vi.resetModules();
const tempDir = await mkdtemp(join(tmpdir(), 'job-ops-api-test-'));
const envOverrides = options?.env ?? {};
process.env = {
...originalEnv,
DATA_DIR: tempDir,
NODE_ENV: 'test',
MODEL: 'test-model',
JOBSPY_SEARCH_TERMS: 'alpha|beta',
...envOverrides,
};
await import('../../db/migrate.js');
const { createApp } = await import('../../app.js');
const { closeDb } = await import('../../db/index.js');
const { getPipelineStatus } = await import('../../pipeline/index.js');
vi.mocked(getPipelineStatus).mockReturnValue({ isRunning: false });
const app = createApp();
const server = app.listen(0);
await new Promise<void>((resolve) => server.once('listening', () => resolve()));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Failed to resolve server address');
}
return {
server,
baseUrl: `http://127.0.0.1:${address.port}`,
closeDb,
tempDir,
};
}
export async function stopServer(args: {
server: Server;
closeDb: () => void;
tempDir: string;
}) {
await new Promise<void>((resolve) => args.server.close(() => resolve()));
args.closeDb();
await rm(args.tempDir, { recursive: true, force: true });
process.env = { ...originalEnv };
vi.clearAllMocks();
}

View File

@ -0,0 +1,91 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { Server } from 'http';
import { startServer, stopServer } from './test-utils.js';
describe.sequential('UK Visa Jobs API routes', () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
it('enforces pagination rules for search', async () => {
const badRes = await fetch(`${baseUrl}/api/ukvisajobs/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ searchTerms: ['one', 'two'] }),
});
expect(badRes.status).toBe(400);
});
it('searches UK Visa Jobs with valid payloads', async () => {
const { fetchUkVisaJobsPage } = await import('../../services/ukvisajobs.js');
vi.mocked(fetchUkVisaJobsPage).mockResolvedValue({
jobs: [
{
source: 'ukvisajobs',
title: 'Engineer',
employer: 'Acme',
jobUrl: 'https://example.com/visa/1',
},
],
totalJobs: 3,
page: 1,
pageSize: 2,
});
const res = await fetch(`${baseUrl}/api/ukvisajobs/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: 'engineer' }),
});
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.totalPages).toBe(2);
expect(fetchUkVisaJobsPage).toHaveBeenCalledWith({ searchKeyword: 'engineer', page: 1 });
});
it('blocks search when pipeline is running', async () => {
const { getPipelineStatus } = await import('../../pipeline/index.js');
vi.mocked(getPipelineStatus).mockReturnValue({ isRunning: true });
const res = await fetch(`${baseUrl}/api/ukvisajobs/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: 'engineer' }),
});
expect(res.status).toBe(409);
});
it('imports UK Visa Jobs and reports created vs skipped', async () => {
const res = await fetch(`${baseUrl}/api/ukvisajobs/import`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jobs: [
{
title: 'Engineer',
employer: 'Acme',
jobUrl: 'https://example.com/visa/2',
},
{
title: 'Engineer Duplicate',
employer: 'Acme',
jobUrl: 'https://example.com/visa/2',
},
],
}),
});
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.created).toBe(1);
expect(body.data.skipped).toBe(1);
});
});

View File

@ -0,0 +1,76 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { Server } from 'http';
import { startServer, stopServer } from './test-utils.js';
describe.sequential('Visa sponsors API routes', () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer());
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
it('returns status and surfaces update errors', async () => {
const { getStatus, downloadLatestCsv } = await import('../../services/visa-sponsors/index.js');
vi.mocked(getStatus).mockReturnValue({
lastUpdated: null,
csvPath: null,
totalSponsors: 0,
isUpdating: false,
nextScheduledUpdate: null,
error: null,
});
vi.mocked(downloadLatestCsv).mockResolvedValue({ success: false, message: 'failed' });
const statusRes = await fetch(`${baseUrl}/api/visa-sponsors/status`);
const statusBody = await statusRes.json();
expect(statusBody.success).toBe(true);
expect(statusBody.data.totalSponsors).toBe(0);
const updateRes = await fetch(`${baseUrl}/api/visa-sponsors/update`, { method: 'POST' });
expect(updateRes.status).toBe(500);
});
it('validates search payloads and handles missing organizations', async () => {
const { searchSponsors, getOrganizationDetails } = await import('../../services/visa-sponsors/index.js');
vi.mocked(searchSponsors).mockReturnValue([
{
sponsor: {
organisationName: 'Acme',
townCity: 'London',
county: 'London',
typeRating: 'Worker',
route: 'Skilled',
},
score: 95,
matchedName: 'acme',
},
]);
vi.mocked(getOrganizationDetails).mockReturnValue([]);
const badRes = await fetch(`${baseUrl}/api/visa-sponsors/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(badRes.status).toBe(400);
const res = await fetch(`${baseUrl}/api/visa-sponsors/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: 'Acme' }),
});
const body = await res.json();
expect(body.success).toBe(true);
expect(body.data.total).toBe(1);
const orgRes = await fetch(`${baseUrl}/api/visa-sponsors/organization/Acme`);
expect(orgRes.status).toBe(404);
});
});

View File

@ -0,0 +1,35 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import type { Server } from 'http';
import { startServer, stopServer } from './test-utils.js';
describe.sequential('Webhook API routes', () => {
let server: Server;
let baseUrl: string;
let closeDb: () => void;
let tempDir: string;
beforeEach(async () => {
({ server, baseUrl, closeDb, tempDir } = await startServer({
env: { WEBHOOK_SECRET: 'secret' },
}));
});
afterEach(async () => {
await stopServer({ server, closeDb, tempDir });
});
it('rejects invalid webhook credentials and accepts valid ones', async () => {
const badRes = await fetch(`${baseUrl}/api/webhook/trigger`, {
method: 'POST',
});
expect(badRes.status).toBe(401);
const goodRes = await fetch(`${baseUrl}/api/webhook/trigger`, {
method: 'POST',
headers: { Authorization: 'Bearer secret' },
});
const goodBody = await goodRes.json();
expect(goodBody.success).toBe(true);
expect(goodBody.data.message).toBe('Pipeline triggered');
});
});