Closes the Phase 5 P1 testing checkbox. Adds 35 new unit tests across the modules called out in the roadmap and wires a v8 coverage gate into CI. Coverage of newly-tested files (lines / branches): lib/auth.ts 94.4% / 100% lib/csrf.ts 95.1% / 90% modules/health/repository.ts 100% / 92% modules/deployments/orchestrator.ts 95.2% / 74% modules/services/repository.ts 100% / 100% modules/hermes-ops/repository.ts 95.2% / 68% Threshold (lines/funcs/stmts ≥85%, branches ≥65%) is scoped to those six files via `coverage.include` so untested legacy modules (vm, system, audit, route handlers) report but don't gate. Add files there as they gain real tests — ratchet up, never relax. Test approach mirrors the existing services/hermes-ops suites: hoisted mocks for I/O (fetch, child_process, fs/promises, cosmos-init), real JOSE-signed JWTs for the auth path, fake timers for cache TTL and CSRF expiry assertions. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
144 lines
5.3 KiB
TypeScript
144 lines
5.3 KiB
TypeScript
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>): 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<void> {
|
|
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');
|
|
});
|
|
});
|