mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-15 21:23:17 +00:00
93ae147ec1
Add 5 new test files (116 test cases) targeting previously untested service-layer logic: - database-metrics: metrics CRUD, minute-bucket aggregation, cleanup retention, notification auto-cap at 100, stack alerts CRUD, and stress tests with 1000+ metrics - monitor-service: CPU/memory/network calculation helpers, all 6 alert condition operators, breach state machine lifecycle, global crash detection, host limit thresholds, cleanup delegation, and isProcessing concurrency guard - docker-controller: validateApiData error detection, state-safe start/stop (304 handling), batch container removal with partial failures, disk usage calculation, resource classification (managed/unmanaged/system), orphan detection, and daemon unreachable error propagation - scheduler-service: cron parsing, license tier gating, concurrent task prevention via runningTasks Set, manual trigger, all 4 task types (restart/snapshot/prune/update), wildcard targets, error recording, recovery notifications, and cleanup - compose-service: subprocess spawn/exit handling, WebSocket output, deploy with health probe and atomic rollback, registry auth temp dir lifecycle, and downStack teardown
384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
/**
|
|
* Unit tests for ComposeService — subprocess handling, deploy/rollback,
|
|
* registry auth temp dir management, and WebSocket output.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { EventEmitter } from 'events';
|
|
|
|
// ── Hoisted mocks ──────────────────────────────────────────────────────
|
|
|
|
const {
|
|
mockSpawn,
|
|
mockGetContainersByStack, mockRemoveContainers, mockListContainers,
|
|
mockContainerInspect, mockContainerLogs,
|
|
mockGetRegistries, mockResolveDockerConfig,
|
|
mockBackupStackFiles, mockRestoreStackFiles,
|
|
mockMkdtempSync, mockWriteFileSync, mockUnlinkSync, mockRmdirSync,
|
|
} = vi.hoisted(() => ({
|
|
mockSpawn: vi.fn(),
|
|
mockGetContainersByStack: vi.fn().mockResolvedValue([]),
|
|
mockRemoveContainers: vi.fn().mockResolvedValue([]),
|
|
mockListContainers: vi.fn().mockResolvedValue([]),
|
|
mockContainerInspect: vi.fn().mockResolvedValue({ State: { ExitCode: 0 } }),
|
|
mockContainerLogs: vi.fn().mockResolvedValue(Buffer.from('')),
|
|
mockGetRegistries: vi.fn().mockReturnValue([]),
|
|
mockResolveDockerConfig: vi.fn().mockResolvedValue({ auths: {} }),
|
|
mockBackupStackFiles: vi.fn().mockResolvedValue(undefined),
|
|
mockRestoreStackFiles: vi.fn().mockResolvedValue(undefined),
|
|
mockMkdtempSync: vi.fn().mockReturnValue('/tmp/sencho-docker-test'),
|
|
mockWriteFileSync: vi.fn(),
|
|
mockUnlinkSync: vi.fn(),
|
|
mockRmdirSync: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('child_process', () => ({ spawn: mockSpawn }));
|
|
|
|
vi.mock('fs', () => ({
|
|
default: {
|
|
mkdtempSync: (...args: unknown[]) => mockMkdtempSync(...args),
|
|
writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
|
|
unlinkSync: (...args: unknown[]) => mockUnlinkSync(...args),
|
|
rmdirSync: (...args: unknown[]) => mockRmdirSync(...args),
|
|
},
|
|
mkdtempSync: (...args: unknown[]) => mockMkdtempSync(...args),
|
|
writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
|
|
unlinkSync: (...args: unknown[]) => mockUnlinkSync(...args),
|
|
rmdirSync: (...args: unknown[]) => mockRmdirSync(...args),
|
|
}));
|
|
|
|
vi.mock('../services/NodeRegistry', () => ({
|
|
NodeRegistry: {
|
|
getInstance: () => ({
|
|
getDefaultNodeId: () => 1,
|
|
getComposeDir: () => '/test/compose',
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/DockerController', () => ({
|
|
default: {
|
|
getInstance: () => ({
|
|
getContainersByStack: mockGetContainersByStack,
|
|
removeContainers: mockRemoveContainers,
|
|
getDocker: () => ({
|
|
listContainers: mockListContainers,
|
|
getContainer: () => ({
|
|
inspect: mockContainerInspect,
|
|
logs: mockContainerLogs,
|
|
}),
|
|
}),
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/DatabaseService', () => ({
|
|
DatabaseService: {
|
|
getInstance: () => ({
|
|
getRegistries: mockGetRegistries,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/RegistryService', () => ({
|
|
RegistryService: {
|
|
getInstance: () => ({
|
|
resolveDockerConfig: mockResolveDockerConfig,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/FileSystemService', () => ({
|
|
FileSystemService: {
|
|
getInstance: () => ({
|
|
backupStackFiles: mockBackupStackFiles,
|
|
restoreStackFiles: mockRestoreStackFiles,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../services/LogFormatter', () => ({
|
|
LogFormatter: { formatLine: (line: string) => line },
|
|
}));
|
|
|
|
import { ComposeService } from '../services/ComposeService';
|
|
|
|
/** Creates an EventEmitter that mimics a child_process spawn result */
|
|
function createMockProcess() {
|
|
const proc = new EventEmitter() as EventEmitter & {
|
|
stdout: EventEmitter;
|
|
stderr: EventEmitter;
|
|
kill: ReturnType<typeof vi.fn>;
|
|
};
|
|
proc.stdout = new EventEmitter();
|
|
proc.stderr = new EventEmitter();
|
|
proc.kill = vi.fn();
|
|
return proc;
|
|
}
|
|
|
|
/** Sets up mockSpawn to auto-close with exit code 0 on next tick */
|
|
function setupAutoCloseSpawn(exitCode = 0) {
|
|
mockSpawn.mockImplementation(() => {
|
|
const proc = createMockProcess();
|
|
// Emit close asynchronously (next microtask)
|
|
Promise.resolve().then(() => proc.emit('close', exitCode));
|
|
return proc;
|
|
});
|
|
}
|
|
|
|
function createMockWs() {
|
|
return {
|
|
readyState: 1,
|
|
send: vi.fn(),
|
|
on: vi.fn(),
|
|
close: vi.fn(),
|
|
OPEN: 1,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
// ── runCommand ─────────────────────────────────────────────────────────
|
|
|
|
describe('ComposeService - runCommand', () => {
|
|
it('spawns docker compose with the correct action', async () => {
|
|
const proc = createMockProcess();
|
|
mockSpawn.mockReturnValue(proc);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.runCommand('my-stack', 'restart');
|
|
proc.emit('close', 0);
|
|
await promise;
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'docker',
|
|
['compose', 'restart'],
|
|
expect.objectContaining({ cwd: expect.stringContaining('my-stack') })
|
|
);
|
|
});
|
|
|
|
it('resolves on exit code 0', async () => {
|
|
const proc = createMockProcess();
|
|
mockSpawn.mockReturnValue(proc);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.runCommand('my-stack', 'start');
|
|
proc.emit('close', 0);
|
|
|
|
await expect(promise).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('rejects on non-zero exit code', async () => {
|
|
const proc = createMockProcess();
|
|
mockSpawn.mockReturnValue(proc);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.runCommand('my-stack', 'stop');
|
|
proc.stderr.emit('data', Buffer.from('service not found'));
|
|
proc.emit('close', 1);
|
|
|
|
await expect(promise).rejects.toThrow('service not found');
|
|
});
|
|
|
|
it('sends output to WebSocket when provided', async () => {
|
|
const proc = createMockProcess();
|
|
mockSpawn.mockReturnValue(proc);
|
|
const ws = createMockWs();
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.runCommand('my-stack', 'restart', ws as any);
|
|
proc.stdout.emit('data', Buffer.from('Restarting...'));
|
|
proc.emit('close', 0);
|
|
await promise;
|
|
|
|
expect(ws.send).toHaveBeenCalledWith('Restarting...');
|
|
});
|
|
});
|
|
|
|
// ── deployStack ────────────────────────────────────────────────────────
|
|
|
|
describe('ComposeService - deployStack', () => {
|
|
it('runs docker compose up -d --remove-orphans', async () => {
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([]);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.deployStack('my-stack');
|
|
|
|
// Advance past the 3s health probe timeout
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
await promise;
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'docker',
|
|
['compose', 'up', '-d', '--remove-orphans'],
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
|
|
it('creates backup when atomic=true', async () => {
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([]);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.deployStack('my-stack', undefined, true);
|
|
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
await promise;
|
|
|
|
expect(mockBackupStackFiles).toHaveBeenCalledWith('my-stack');
|
|
});
|
|
|
|
it('throws CONTAINER_CRASHED when exited container has non-zero exit code', async () => {
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([{
|
|
Id: 'crashed-c1',
|
|
State: 'exited',
|
|
Labels: { 'com.docker.compose.project': 'my-stack' },
|
|
}]);
|
|
mockContainerInspect.mockResolvedValue({ State: { ExitCode: 1 } });
|
|
mockContainerLogs.mockResolvedValue(Buffer.from('Error: something failed'));
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
// Attach catch handler immediately so rejection is never "unhandled"
|
|
const result = svc.deployStack('my-stack').then(() => null, (e: Error) => e);
|
|
|
|
await vi.runAllTimersAsync();
|
|
const error = await result;
|
|
expect(error).not.toBeNull();
|
|
expect(error!.message).toContain('CONTAINER_CRASHED');
|
|
});
|
|
|
|
it('rolls back on failure when atomic=true', async () => {
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([{
|
|
Id: 'crashed-c1',
|
|
State: 'exited',
|
|
Labels: { 'com.docker.compose.project': 'my-stack' },
|
|
}]);
|
|
mockContainerInspect.mockResolvedValue({ State: { ExitCode: 1 } });
|
|
mockContainerLogs.mockResolvedValue(Buffer.from('Error'));
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const result = svc.deployStack('my-stack', undefined, true).then(() => null, (e: Error) => e);
|
|
|
|
await vi.runAllTimersAsync();
|
|
const error = await result;
|
|
expect(error).not.toBeNull();
|
|
expect(error!.message).toContain('CONTAINER_CRASHED');
|
|
expect(mockRestoreStackFiles).toHaveBeenCalledWith('my-stack');
|
|
});
|
|
|
|
it('does not roll back when atomic=false', async () => {
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([{
|
|
Id: 'crashed-c1',
|
|
State: 'exited',
|
|
}]);
|
|
mockContainerInspect.mockResolvedValue({ State: { ExitCode: 1 } });
|
|
mockContainerLogs.mockResolvedValue(Buffer.from('Error'));
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const result = svc.deployStack('my-stack', undefined, false).then(() => null, (e: Error) => e);
|
|
|
|
await vi.runAllTimersAsync();
|
|
const error = await result;
|
|
expect(error).not.toBeNull();
|
|
expect(error!.message).toContain('CONTAINER_CRASHED');
|
|
expect(mockRestoreStackFiles).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ── withRegistryAuth ───────────────────────────────────────────────────
|
|
|
|
describe('ComposeService - withRegistryAuth', () => {
|
|
it('passes default env when no registries configured', async () => {
|
|
mockGetRegistries.mockReturnValue([]);
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([]);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.deployStack('my-stack');
|
|
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
await promise;
|
|
|
|
expect(mockMkdtempSync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('creates temp config dir when registries exist', async () => {
|
|
mockGetRegistries.mockReturnValue([{ url: 'https://registry.example.com', username: 'user', password: 'pass' }]);
|
|
mockResolveDockerConfig.mockResolvedValue({ auths: { 'registry.example.com': { auth: 'dXNlcjpwYXNz' } } });
|
|
setupAutoCloseSpawn();
|
|
mockListContainers.mockResolvedValue([]);
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const promise = svc.deployStack('my-stack');
|
|
|
|
await vi.advanceTimersByTimeAsync(3100);
|
|
await promise;
|
|
|
|
expect(mockMkdtempSync).toHaveBeenCalled();
|
|
expect(mockWriteFileSync).toHaveBeenCalled();
|
|
expect(mockUnlinkSync).toHaveBeenCalled();
|
|
expect(mockRmdirSync).toHaveBeenCalled();
|
|
});
|
|
|
|
it('cleans up temp dir even on command failure', async () => {
|
|
mockGetRegistries.mockReturnValue([{ url: 'https://registry.example.com' }]);
|
|
mockResolveDockerConfig.mockResolvedValue({ auths: {} });
|
|
|
|
// Make spawn fail
|
|
mockSpawn.mockImplementation(() => {
|
|
const proc = createMockProcess();
|
|
Promise.resolve().then(() => {
|
|
proc.stderr.emit('data', Buffer.from('pull failed'));
|
|
proc.emit('close', 1);
|
|
});
|
|
return proc;
|
|
});
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
const result = svc.deployStack('my-stack').then(() => null, (e: Error) => e);
|
|
|
|
await vi.runAllTimersAsync();
|
|
const error = await result;
|
|
expect(error).not.toBeNull();
|
|
expect(mockUnlinkSync).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ── downStack ──────────────────────────────────────────────────────────
|
|
|
|
describe('ComposeService - downStack', () => {
|
|
it('runs docker compose down with volumes and remove-orphans', async () => {
|
|
setupAutoCloseSpawn();
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
await svc.downStack('my-stack');
|
|
|
|
expect(mockSpawn).toHaveBeenCalledWith(
|
|
'docker',
|
|
['compose', 'down', '--volumes', '--remove-orphans'],
|
|
expect.any(Object)
|
|
);
|
|
});
|
|
|
|
it('resolves even when command fails (throwOnError=false)', async () => {
|
|
mockSpawn.mockImplementation(() => {
|
|
const proc = createMockProcess();
|
|
Promise.resolve().then(() => proc.emit('close', 1));
|
|
return proc;
|
|
});
|
|
|
|
const svc = ComposeService.getInstance(1);
|
|
await expect(svc.downStack('my-stack')).resolves.toBeUndefined();
|
|
});
|
|
});
|