import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { Service } from '../services/types.js'; // --- I/O mocks. Hoisted so vi.mock factories below can see them. --------------- const execMock = vi.hoisted(() => vi.fn()); vi.mock('child_process', () => ({ exec: execMock })); const createDeploymentMock = vi.hoisted(() => vi.fn()); const updateDeploymentMock = vi.hoisted(() => vi.fn()); vi.mock('./repository.js', () => ({ createDeployment: createDeploymentMock, updateDeployment: updateDeploymentMock, })); const getServiceByIdMock = vi.hoisted(() => vi.fn()); const updateServiceMock = vi.hoisted(() => vi.fn()); vi.mock('../services/repository.js', () => ({ getServiceById: getServiceByIdMock, updateService: updateServiceMock, })); vi.mock('../../lib/config.js', () => ({ config: {}, productId: 'devops-internal', })); const { triggerDeployment } = await import('./orchestrator.js'); function makeService(overrides?: Partial): Service { return { id: 'svc-1', name: 'Test Service', scriptPath: 'deploy.sh', healthUrl: 'https://example.com/health', repoPath: '../repo', status: 'up', version: '1.0.0', productId: 'devops-internal', ...overrides, }; } // promisify(exec) calls exec(cmd, options, cb(err, { stdout, stderr })). Drive // the callback synchronously off the mock so the deferred script work resolves // before our awaited assertion. function setExec(handler: () => { error?: Error & { stdout?: string; stderr?: string }; stdout?: string; stderr?: string }) { execMock.mockImplementation( ( _cmd: string, _opts: unknown, cb: (err: (Error & { stdout?: string; stderr?: string }) | null, result?: { stdout: string; stderr: string }) => void, ) => { const res = handler(); if (res.error) cb(res.error); else cb(null, { stdout: res.stdout ?? '', stderr: res.stderr ?? '' }); }, ); } describe('triggerDeployment', () => { beforeEach(() => { vi.clearAllMocks(); createDeploymentMock.mockImplementation(async (data) => ({ id: 'dep-1', ...data })); updateDeploymentMock.mockResolvedValue({}); getServiceByIdMock.mockImplementation(async (id) => makeService({ id, version: '0.9.0' })); updateServiceMock.mockResolvedValue({}); }); it('creates a pending deployment record and returns its id immediately', async () => { setExec(() => ({ stdout: 'deployed v1.2.3', stderr: '' })); const id = await triggerDeployment(makeService(), 'tester@bytelyst'); expect(id).toBe('dep-1'); expect(createDeploymentMock).toHaveBeenCalledWith({ serviceId: 'svc-1', version: 'pending', triggeredBy: 'tester@bytelyst', productId: 'devops-internal', }); }); // Wait for the post-trigger async work to flush. We can't await the inner // promise directly (orchestrator deliberately fire-and-forgets), so we yield // ticks until updateDeployment is observed. async function flushBackground(): Promise { for (let i = 0; i < 50; i++) { if (updateDeploymentMock.mock.calls.length > 0) return; await Promise.resolve(); } } it('marks the deployment success and updates the service version on a clean run', async () => { setExec(() => ({ stdout: 'release version: 2.5.1\n', stderr: '' })); await triggerDeployment(makeService(), 'tester'); await flushBackground(); const finalCall = updateDeploymentMock.mock.calls.at(-1)![1]; expect(finalCall.status).toBe('success'); expect(finalCall.version).toBe('2.5.1'); expect(typeof finalCall.completedAt).toBe('string'); // Service is moved to 'up' with the extracted version. expect(updateServiceMock).toHaveBeenCalledWith( 'svc-1', expect.objectContaining({ status: 'up', version: '2.5.1' }), ); }); it('falls back to version "unknown" when the script logs no recognizable version', async () => { setExec(() => ({ stdout: 'all good, no numbers here', stderr: '' })); await triggerDeployment(makeService(), 'tester'); await flushBackground(); const finalCall = updateDeploymentMock.mock.calls.at(-1)![1]; expect(finalCall.status).toBe('success'); expect(finalCall.version).toBe('unknown'); }); it('marks the deployment failed and the service down when the script throws', async () => { const err = Object.assign(new Error('exit 1'), { stdout: 'partial', stderr: 'boom' }); setExec(() => ({ error: err })); await triggerDeployment(makeService(), 'tester'); await flushBackground(); const finalCall = updateDeploymentMock.mock.calls.at(-1)![1]; expect(finalCall.status).toBe('failed'); expect(finalCall.logs).toContain('ERROR: exit 1'); expect(finalCall.logs).toContain('STDERR:\nboom'); expect(finalCall).not.toHaveProperty('version'); expect(updateServiceMock).toHaveBeenCalledWith('svc-1', { status: 'down' }); }); it('does not crash when getServiceById returns null in the success path', async () => { getServiceByIdMock.mockResolvedValue(null); setExec(() => ({ stdout: 'version: 1.0.0', stderr: '' })); await triggerDeployment(makeService(), 'tester'); await flushBackground(); expect(updateServiceMock).not.toHaveBeenCalled(); const finalCall = updateDeploymentMock.mock.calls.at(-1)![1]; expect(finalCall.status).toBe('success'); }); });