From 50edefbebe4de8aef65980be2b65ef6bab11d6ad Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 07:18:02 +0000 Subject: [PATCH] routes tests --- .../src/server/api/routes/database.test.ts | 34 ++++++ .../src/server/api/routes/jobs.test.ts | 98 +++++++++++++++ .../src/server/api/routes/manual-jobs.test.ts | 64 ++++++++++ .../src/server/api/routes/pipeline.test.ts | 66 +++++++++++ .../src/server/api/routes/profile.test.ts | 25 ++++ .../src/server/api/routes/settings.test.ts | 45 +++++++ .../src/server/api/routes/test-utils.ts | 112 ++++++++++++++++++ .../src/server/api/routes/ukvisajobs.test.ts | 91 ++++++++++++++ .../server/api/routes/visa-sponsors.test.ts | 76 ++++++++++++ .../src/server/api/routes/webhook.test.ts | 35 ++++++ 10 files changed, 646 insertions(+) create mode 100644 orchestrator/src/server/api/routes/database.test.ts create mode 100644 orchestrator/src/server/api/routes/jobs.test.ts create mode 100644 orchestrator/src/server/api/routes/manual-jobs.test.ts create mode 100644 orchestrator/src/server/api/routes/pipeline.test.ts create mode 100644 orchestrator/src/server/api/routes/profile.test.ts create mode 100644 orchestrator/src/server/api/routes/settings.test.ts create mode 100644 orchestrator/src/server/api/routes/test-utils.ts create mode 100644 orchestrator/src/server/api/routes/ukvisajobs.test.ts create mode 100644 orchestrator/src/server/api/routes/visa-sponsors.test.ts create mode 100644 orchestrator/src/server/api/routes/webhook.test.ts diff --git a/orchestrator/src/server/api/routes/database.test.ts b/orchestrator/src/server/api/routes/database.test.ts new file mode 100644 index 0000000..8f3bfd2 --- /dev/null +++ b/orchestrator/src/server/api/routes/database.test.ts @@ -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); + }); +}); diff --git a/orchestrator/src/server/api/routes/jobs.test.ts b/orchestrator/src/server/api/routes/jobs.test.ts new file mode 100644 index 0000000..b544a3c --- /dev/null +++ b/orchestrator/src/server/api/routes/jobs.test.ts @@ -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, + }) + ); + }); +}); diff --git a/orchestrator/src/server/api/routes/manual-jobs.test.ts b/orchestrator/src/server/api/routes/manual-jobs.test.ts new file mode 100644 index 0000000..c0f2eff --- /dev/null +++ b/orchestrator/src/server/api/routes/manual-jobs.test.ts @@ -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)); + }); +}); diff --git a/orchestrator/src/server/api/routes/pipeline.test.ts b/orchestrator/src/server/api/routes/pipeline.test.ts new file mode 100644 index 0000000..0d7dc34 --- /dev/null +++ b/orchestrator/src/server/api/routes/pipeline.test.ts @@ -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(); + } + }); +}); diff --git a/orchestrator/src/server/api/routes/profile.test.ts b/orchestrator/src/server/api/routes/profile.test.ts new file mode 100644 index 0000000..9e91930 --- /dev/null +++ b/orchestrator/src/server/api/routes/profile.test.ts @@ -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); + }); +}); diff --git a/orchestrator/src/server/api/routes/settings.test.ts b/orchestrator/src/server/api/routes/settings.test.ts new file mode 100644 index 0000000..1e13474 --- /dev/null +++ b/orchestrator/src/server/api/routes/settings.test.ts @@ -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']); + }); +}); diff --git a/orchestrator/src/server/api/routes/test-utils.ts b/orchestrator/src/server/api/routes/test-utils.ts new file mode 100644 index 0000000..ec6bd96 --- /dev/null +++ b/orchestrator/src/server/api/routes/test-utils.ts @@ -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; +}): 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((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((resolve) => args.server.close(() => resolve())); + args.closeDb(); + await rm(args.tempDir, { recursive: true, force: true }); + process.env = { ...originalEnv }; + vi.clearAllMocks(); +} diff --git a/orchestrator/src/server/api/routes/ukvisajobs.test.ts b/orchestrator/src/server/api/routes/ukvisajobs.test.ts new file mode 100644 index 0000000..0fbce6b --- /dev/null +++ b/orchestrator/src/server/api/routes/ukvisajobs.test.ts @@ -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); + }); +}); diff --git a/orchestrator/src/server/api/routes/visa-sponsors.test.ts b/orchestrator/src/server/api/routes/visa-sponsors.test.ts new file mode 100644 index 0000000..aa03fc9 --- /dev/null +++ b/orchestrator/src/server/api/routes/visa-sponsors.test.ts @@ -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); + }); +}); diff --git a/orchestrator/src/server/api/routes/webhook.test.ts b/orchestrator/src/server/api/routes/webhook.test.ts new file mode 100644 index 0000000..b8c6a99 --- /dev/null +++ b/orchestrator/src/server/api/routes/webhook.test.ts @@ -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'); + }); +});