import type Docker from "dockerode";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DockerRawContainerInfo, HostConfig } from "../../types.js";
import { ContainerService } from "./container-service.js";
import type { ClientManager } from "./utils/client-manager.js";
describe("ContainerService", () => {
let service: ContainerService;
let mockClientManager: ClientManager;
let mockDocker: Docker;
let mockContainer: Docker.Container;
beforeEach(() => {
// Mock container
mockContainer = {
start: vi.fn(),
stop: vi.fn(),
restart: vi.fn(),
pause: vi.fn(),
unpause: vi.fn(),
logs: vi.fn(),
stats: vi.fn(),
inspect: vi.fn(),
top: vi.fn(),
} as unknown as Docker.Container;
// Mock Docker client
mockDocker = {
listContainers: vi.fn(),
getContainer: vi.fn(() => mockContainer),
modem: {} as Docker["modem"],
} as unknown as Docker;
// Mock ClientManager
mockClientManager = {
getClient: vi.fn(() => mockDocker),
} as unknown as ClientManager;
service = new ContainerService(mockClientManager);
});
describe("listContainers", () => {
it("should list containers from multiple hosts", async (): Promise<void> => {
const hosts: HostConfig[] = [
{ name: "host1", host: "server1.example.com" },
{ name: "host2", host: "server2.example.com" },
];
(mockDocker.listContainers as ReturnType<typeof vi.fn>).mockResolvedValue([
{
Id: "abc123",
Names: ["/test-container"],
Image: "nginx:latest",
State: "running",
Status: "Up 2 hours",
Created: Math.floor(Date.now() / 1000),
Ports: [],
Labels: {},
},
]);
const result = await service.listContainers(hosts);
expect(result).toHaveLength(2);
expect(result[0].name).toBe("test-container");
expect(result[0].hostName).toBe("host1");
expect(result[1].hostName).toBe("host2");
});
it("should filter containers by state", async (): Promise<void> => {
const hosts: HostConfig[] = [{ name: "host1", host: "localhost" }];
(mockDocker.listContainers as ReturnType<typeof vi.fn>).mockResolvedValue([
{
Id: "abc123",
Names: ["/running-container"],
Image: "nginx:latest",
State: "running",
Status: "Up 2 hours",
Created: Math.floor(Date.now() / 1000),
Ports: [],
Labels: {},
},
{
Id: "def456",
Names: ["/stopped-container"],
Image: "redis:latest",
State: "exited",
Status: "Exited (0) 1 hour ago",
Created: Math.floor(Date.now() / 1000),
Ports: [],
Labels: {},
},
]);
const result = await service.listContainers(hosts, { state: "running" });
expect(result).toHaveLength(1);
expect(result[0].name).toBe("running-container");
});
it("should paginate results with limit", async (): Promise<void> => {
const hosts: HostConfig[] = [{ name: "host1", host: "localhost" }];
// Mock 10 containers
const mockContainers = Array.from({ length: 10 }, (_, i) => ({
Id: `container${i}`,
Names: [`/container-${i}`],
Image: "nginx:latest",
State: "running",
Status: "Up 1 hour",
Created: Math.floor(Date.now() / 1000),
Ports: [],
Labels: {},
}));
(mockDocker.listContainers as ReturnType<typeof vi.fn>).mockResolvedValue(mockContainers);
const result = await service.listContainers(hosts, { limit: 5 });
expect(result).toHaveLength(5);
expect(result[0].name).toBe("container-0");
expect(result[4].name).toBe("container-4");
});
it("should paginate results with offset", async (): Promise<void> => {
const hosts: HostConfig[] = [{ name: "host1", host: "localhost" }];
// Mock 10 containers
const mockContainers = Array.from({ length: 10 }, (_, i) => ({
Id: `container${i}`,
Names: [`/container-${i}`],
Image: "nginx:latest",
State: "running",
Status: "Up 1 hour",
Created: Math.floor(Date.now() / 1000),
Ports: [],
Labels: {},
}));
(mockDocker.listContainers as ReturnType<typeof vi.fn>).mockResolvedValue(mockContainers);
const result = await service.listContainers(hosts, { offset: 5 });
expect(result).toHaveLength(5);
expect(result[0].name).toBe("container-5");
expect(result[4].name).toBe("container-9");
});
it("should paginate results with both limit and offset", async (): Promise<void> => {
const hosts: HostConfig[] = [{ name: "host1", host: "localhost" }];
// Mock 100 containers
const mockContainers = Array.from({ length: 100 }, (_, i) => ({
Id: `container${i}`,
Names: [`/container-${i}`],
Image: "nginx:latest",
State: "running",
Status: "Up 1 hour",
Created: Math.floor(Date.now() / 1000),
Ports: [],
Labels: {},
}));
(mockDocker.listContainers as ReturnType<typeof vi.fn>).mockResolvedValue(mockContainers);
const result = await service.listContainers(hosts, { limit: 10, offset: 20 });
expect(result).toHaveLength(10);
expect(result[0].name).toBe("container-20");
expect(result[9].name).toBe("container-29");
});
it("should handle containers with missing Names field", async (): Promise<void> => {
const hosts: HostConfig[] = [{ name: "host1", host: "localhost" }];
(mockDocker.listContainers as ReturnType<typeof vi.fn>).mockResolvedValue([
{
Id: "abc123def456",
Names: undefined,
Image: "nginx:latest",
State: "running",
Status: "Up 2 hours",
Created: Math.floor(Date.now() / 1000),
Ports: [],
Labels: {},
} as unknown as DockerRawContainerInfo,
{
Id: "def456abc789",
Names: [],
Image: "redis:latest",
State: "running",
Status: "Up 1 hour",
Created: Math.floor(Date.now() / 1000),
Ports: [],
Labels: {},
} as unknown as DockerRawContainerInfo,
]);
const result = await service.listContainers(hosts);
expect(result).toHaveLength(2);
// Missing Names should fall back to truncated ID
expect(result[0].name).toBe("abc123def456");
// Empty Names array should also fall back to truncated ID
expect(result[1].name).toBe("def456abc789");
});
it("should return empty array when offset exceeds total", async (): Promise<void> => {
const hosts: HostConfig[] = [{ name: "host1", host: "localhost" }];
const mockContainers = Array.from({ length: 10 }, (_, i) => ({
Id: `container${i}`,
Names: [`/container-${i}`],
Image: "nginx:latest",
State: "running",
Status: "Up 1 hour",
Created: Math.floor(Date.now() / 1000),
Ports: [],
Labels: {},
}));
(mockDocker.listContainers as ReturnType<typeof vi.fn>).mockResolvedValue(mockContainers);
const result = await service.listContainers(hosts, { offset: 100 });
expect(result).toHaveLength(0);
});
});
describe("containerAction", () => {
it("should start a container", async (): Promise<void> => {
const host: HostConfig = { name: "host1", host: "localhost" };
await service.containerAction("abc123", "start", host);
expect(mockContainer.start).toHaveBeenCalledTimes(1);
});
it("should stop a container with timeout", async (): Promise<void> => {
const host: HostConfig = { name: "host1", host: "localhost" };
await service.containerAction("abc123", "stop", host);
expect(mockContainer.stop).toHaveBeenCalledWith({ t: 10 });
});
});
describe("getContainerLogs", () => {
it("should get container logs", async (): Promise<void> => {
const host: HostConfig = { name: "host1", host: "localhost" };
(mockContainer.logs as ReturnType<typeof vi.fn>).mockResolvedValue(
Buffer.from("2024-01-01T00:00:00.000Z test log\n")
);
const result = await service.getContainerLogs("abc123", host);
expect(result).toHaveLength(1);
expect(result[0].message).toBe("test log");
});
});
describe("getContainerStats", () => {
it("should get container stats", async (): Promise<void> => {
const host: HostConfig = { name: "host1", host: "localhost" };
(mockContainer.stats as ReturnType<typeof vi.fn>).mockResolvedValue({
cpu_stats: {
cpu_usage: { total_usage: 2000000000 },
system_cpu_usage: 10000000000,
online_cpus: 4,
},
precpu_stats: {
cpu_usage: { total_usage: 1000000000 },
system_cpu_usage: 8000000000,
},
memory_stats: {
usage: 100000000,
limit: 200000000,
},
networks: {},
blkio_stats: {},
});
(mockContainer.inspect as ReturnType<typeof vi.fn>).mockResolvedValue({
Name: "/test-container",
});
const result = await service.getContainerStats("abc123", host);
expect(result.containerId).toBe("abc123");
expect(result.containerName).toBe("test-container");
expect(result.cpuPercent).toBeGreaterThan(0);
expect(result.memoryPercent).toBe(50);
});
});
describe("inspectContainer", () => {
it("should inspect a container", async (): Promise<void> => {
const host: HostConfig = { name: "host1", host: "localhost" };
const mockInspectData = { Id: "abc123", Name: "/test-container" };
(mockContainer.inspect as ReturnType<typeof vi.fn>).mockResolvedValue(mockInspectData);
const result = await service.inspectContainer("abc123", host);
expect(result).toBe(mockInspectData);
});
});
describe("findContainerHost", () => {
it("should find container host", async (): Promise<void> => {
const hosts: HostConfig[] = [
{ name: "host1", host: "server1.example.com" },
{ name: "host2", host: "server2.example.com" },
];
(mockDocker.listContainers as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
Id: "abc123def456",
Names: ["/test-container"],
},
]);
const result = await service.findContainerHost("abc123", hosts);
expect(result).not.toBeNull();
expect(result?.host.name).toBe("host2");
expect(result?.container.Id).toBe("abc123def456");
});
it("should tolerate containers with missing Names when scanning hosts", async (): Promise<void> => {
const hosts: HostConfig[] = [
{ name: "host1", host: "server1.example.com" },
{ name: "host2", host: "server2.example.com" },
];
(mockDocker.listContainers as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
Id: "def456abc123",
Names: undefined,
} as unknown as DockerRawContainerInfo,
{
Id: "abc123def456",
Names: ["/test-container"],
} as unknown as DockerRawContainerInfo,
]);
const result = await service.findContainerHost("test-container", hosts);
expect(result).not.toBeNull();
expect(result?.host.name).toBe("host2");
expect(result?.container.Id).toBe("abc123def456");
});
it("should return as soon as one host matches without waiting for slower hosts", async (): Promise<void> => {
const hosts: HostConfig[] = [
{ name: "fast-host", host: "fast.example.com" },
{ name: "slow-host", host: "slow.example.com" },
];
const fastDocker = {
listContainers: vi.fn().mockResolvedValue([
{
Id: "abc123def456",
Names: ["/target-container"],
},
]),
} as unknown as Docker;
const slowDocker = {
listContainers: vi.fn().mockImplementation(() => new Promise(() => {})),
} as unknown as Docker;
(mockClientManager.getClient as ReturnType<typeof vi.fn>).mockImplementation(
(host: HostConfig) => {
return host.name === "fast-host" ? fastDocker : slowDocker;
}
);
const result = await Promise.race([
service.findContainerHost("target-container", hosts),
new Promise<null>((_, reject) =>
setTimeout(() => reject(new Error("findContainerHost timed out")), 250)
),
]);
expect(result).not.toBeNull();
expect(result?.host.name).toBe("fast-host");
expect(result?.container.Id).toBe("abc123def456");
});
});
describe("getContainerProcesses", () => {
it("should get container processes", async (): Promise<void> => {
const host: HostConfig = { name: "host1", host: "localhost" };
(mockContainer.top as ReturnType<typeof vi.fn>).mockResolvedValue({
Titles: ["UID", "PID", "CMD"],
Processes: [["root", "1", "nginx"]],
});
const result = await service.getContainerProcesses("abc123", host);
expect(result.titles).toEqual(["UID", "PID", "CMD"]);
expect(result.processes).toHaveLength(1);
});
});
describe("execContainer", () => {
it("should execute allowed command successfully", async (): Promise<void> => {
const host: HostConfig = { name: "host1", host: "localhost" };
// Mock exec instance and stream context
const mockExec = {
start: vi.fn().mockResolvedValue({
on: vi.fn(),
destroy: vi.fn(),
}),
inspect: vi.fn().mockResolvedValue({ ExitCode: 0 }),
};
const mockContainerExec = vi.fn().mockResolvedValue(mockExec);
(mockContainer as Record<string, unknown>).exec = mockContainerExec;
// Note: execContainer uses complex stream handling from exec-handler
// Full integration testing requires mocking the entire stream pipeline
// This test verifies the method exists and calls getContainer
await expect(
service.execContainer("abc123", host, { command: "echo test" })
).rejects.toThrow();
expect(mockDocker.getContainer).toHaveBeenCalledWith("abc123");
});
it("should pass timeout option to execution", async (): Promise<void> => {
const host: HostConfig = { name: "host1", host: "localhost" };
// Verify timeout is accepted
await expect(
service.execContainer("abc123", host, { command: "echo test", timeout: 5000 })
).rejects.toThrow();
expect(mockDocker.getContainer).toHaveBeenCalled();
});
it("should pass user and workdir options", async (): Promise<void> => {
const host: HostConfig = { name: "host1", host: "localhost" };
// Verify user and workdir are accepted
await expect(
service.execContainer("abc123", host, {
command: "echo test",
user: "root",
workdir: "/app",
})
).rejects.toThrow();
expect(mockDocker.getContainer).toHaveBeenCalled();
});
});
});