import type Docker from "dockerode";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HostConfig } from "../../types.js";
import { ImageService } from "./image-service.js";
import type { ClientManager } from "./utils/client-manager.js";
describe("ImageService", () => {
let service: ImageService;
let mockClientManager: ClientManager;
let mockDocker: Docker;
let mockImage: Docker.Image;
beforeEach(() => {
// Mock image
mockImage = {
remove: vi.fn(),
} as unknown as Docker.Image;
// Mock Docker client
mockDocker = {
listImages: vi.fn(),
pull: vi.fn(),
getImage: vi.fn(() => mockImage),
getContainer: vi.fn(),
createContainer: vi.fn(),
modem: {
followProgress: vi.fn((_stream, callback) => callback(null)),
} as unknown as Docker["modem"],
} as unknown as Docker;
// Mock ClientManager
mockClientManager = {
getClient: vi.fn(() => mockDocker),
} as unknown as ClientManager;
service = new ImageService(mockClientManager);
});
describe("listImages", () => {
it("should list images from multiple hosts", async (): Promise<void> => {
const hosts: HostConfig[] = [
{ name: "host1", host: "server1.example.com" },
{ name: "host2", host: "server2.example.com" },
];
(mockDocker.listImages as ReturnType<typeof vi.fn>).mockResolvedValue([
{
Id: "sha256:abc123",
RepoTags: ["nginx:latest"],
Size: 142000000,
Created: Math.floor(Date.now() / 1000),
Containers: 2,
},
]);
const result = await service.listImages(hosts);
expect(result).toHaveLength(2);
expect(result[0].tags).toContain("nginx:latest");
expect(result[0].hostName).toBe("host1");
expect(result[1].hostName).toBe("host2");
});
it("should filter dangling images", async (): Promise<void> => {
const hosts: HostConfig[] = [{ name: "host1", host: "localhost" }];
(mockDocker.listImages as ReturnType<typeof vi.fn>).mockResolvedValue([
{
Id: "sha256:dangling123",
RepoTags: null,
Size: 50000000,
Created: Math.floor(Date.now() / 1000),
Containers: 0,
},
]);
const result = await service.listImages(hosts, { danglingOnly: true });
expect(result).toHaveLength(1);
expect(result[0].tags).toContain("<none>:<none>");
expect(mockDocker.listImages).toHaveBeenCalledWith({
filters: { dangling: ["true"] },
});
});
});
describe("pullImage", () => {
it("should pull an image successfully", async (): Promise<void> => {
const host: HostConfig = { name: "host1", host: "localhost" };
const imageName = "nginx:latest";
(mockDocker.pull as ReturnType<typeof vi.fn>).mockImplementation((_name, callback) => {
callback(null, {} as NodeJS.ReadableStream);
});
const result = await service.pullImage(imageName, host);
expect(result.status).toContain("Successfully pulled");
expect(mockDocker.pull).toHaveBeenCalledWith(imageName, expect.any(Function));
});
it("should reject empty image name", async (): Promise<void> => {
const host: HostConfig = { name: "host1", host: "localhost" };
await expect(service.pullImage("", host)).rejects.toThrow("Image name is required");
});
it("should handle pull errors", async (): Promise<void> => {
const host: HostConfig = { name: "host1", host: "localhost" };
(mockDocker.pull as ReturnType<typeof vi.fn>).mockImplementation((_name, callback) => {
callback(new Error("Network error"), null);
});
await expect(service.pullImage("nginx:latest", host)).rejects.toThrow("Failed to pull image");
});
});
describe("removeImage", () => {
it("should remove an image", async (): Promise<void> => {
const host: HostConfig = { name: "host1", host: "localhost" };
const result = await service.removeImage("abc123", host);
expect(result.status).toContain("Successfully removed");
expect(mockImage.remove).toHaveBeenCalledWith({ force: undefined });
});
it("should force remove an image", async (): Promise<void> => {
const host: HostConfig = { name: "host1", host: "localhost" };
await service.removeImage("abc123", host, { force: true });
expect(mockImage.remove).toHaveBeenCalledWith({ force: true });
});
});
describe("recreateContainer", () => {
it("should recreate a running container", async (): Promise<void> => {
const host: HostConfig = { name: "host1", host: "localhost" };
const mockContainer = {
inspect: vi.fn().mockResolvedValue({
Config: { Image: "nginx:latest" },
HostConfig: {},
NetworkSettings: { Networks: {} },
State: { Running: true },
}),
stop: vi.fn(),
remove: vi.fn(),
start: vi.fn(),
};
const mockNewContainer = {
id: "new123",
start: vi.fn(),
};
(mockDocker.getContainer as ReturnType<typeof vi.fn>).mockReturnValue(mockContainer);
(mockDocker.createContainer as ReturnType<typeof vi.fn>).mockResolvedValue(mockNewContainer);
(mockDocker.pull as ReturnType<typeof vi.fn>).mockImplementation((_name, callback) => {
callback(null, {} as NodeJS.ReadableStream);
});
const result = await service.recreateContainer("old123", host);
expect(result.status).toBe("Container recreated successfully");
expect(result.containerId).toBe("new123");
expect(mockContainer.stop).toHaveBeenCalled();
expect(mockContainer.remove).toHaveBeenCalled();
expect(mockNewContainer.start).toHaveBeenCalled();
});
it("should skip pulling if pull option is false", async (): Promise<void> => {
const host: HostConfig = { name: "host1", host: "localhost" };
const mockContainer = {
inspect: vi.fn().mockResolvedValue({
Config: { Image: "nginx:latest" },
HostConfig: {},
NetworkSettings: { Networks: {} },
State: { Running: false },
}),
stop: vi.fn(),
remove: vi.fn(),
};
const mockNewContainer = {
id: "new123",
start: vi.fn(),
};
(mockDocker.getContainer as ReturnType<typeof vi.fn>).mockReturnValue(mockContainer);
(mockDocker.createContainer as ReturnType<typeof vi.fn>).mockResolvedValue(mockNewContainer);
await service.recreateContainer("old123", host, { pull: false });
expect(mockDocker.pull).not.toHaveBeenCalled();
});
});
describe("buildImage", () => {
it("should reject invalid image tags", async (): Promise<void> => {
const host: HostConfig = { name: "host1", host: "localhost" };
await expect(
service.buildImage(host, {
context: "/app",
tag: "invalid;tag",
noCache: false,
})
).rejects.toThrow("Invalid image tag");
});
it("should validate context path", async (): Promise<void> => {
const host: HostConfig = { name: "host1", host: "localhost" };
await expect(
service.buildImage(host, {
context: "../../../etc/passwd",
tag: "test:latest",
noCache: false,
})
).rejects.toThrow();
});
});
});