import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HostConfig } from "../types.js";
import { ContainerHostMapCache } from "./container-host-map-cache.js";
import type { IDockerClientProvider } from "./interfaces.js";
describe("ContainerHostMapCache", () => {
let mockDockerService: IDockerClientProvider;
let cache: ContainerHostMapCache;
let hosts: HostConfig[];
beforeEach(() => {
// Create mock Docker service with getDockerClient returning mock client
const mockClient = {
listContainers: vi.fn().mockResolvedValue([]),
};
mockDockerService = {
getDockerClient: vi.fn().mockReturnValue(mockClient),
clearClients: vi.fn(),
};
// Create test hosts
hosts = [
{ name: "host1", host: "192.168.1.1", protocol: "ssh" },
{ name: "host2", host: "192.168.1.2", protocol: "ssh" },
{ name: "host3", host: "192.168.1.3", protocol: "ssh" },
];
cache = new ContainerHostMapCache();
});
describe("buildMapping", () => {
it("should build mapping for all hosts in parallel", async (): Promise<void> => {
// Mock getDockerClient to return clients with listContainers
const mockClient1 = {
listContainers: vi.fn().mockResolvedValue([
{
Id: "container1-id-full-hash",
Names: ["/container1"],
Image: "nginx:latest",
State: "running",
Status: "Up 1 hour",
Created: Date.now() / 1000,
Ports: [],
Labels: {},
},
]),
};
const mockClient2 = {
listContainers: vi.fn().mockResolvedValue([
{
Id: "container2-id-full-hash",
Names: ["/container2"],
Image: "redis:latest",
State: "running",
Status: "Up 2 hours",
Created: Date.now() / 1000,
Ports: [],
Labels: {},
},
]),
};
const mockClient3 = {
listContainers: vi.fn().mockResolvedValue([
{
Id: "container3-id-full-hash",
Names: ["/container3"],
Image: "postgres:latest",
State: "running",
Status: "Up 3 hours",
Created: Date.now() / 1000,
Ports: [],
Labels: {},
},
]),
};
mockDockerService.getDockerClient = vi.fn().mockImplementation((host: HostConfig) => {
if (host.name === "host1") return mockClient1;
if (host.name === "host2") return mockClient2;
if (host.name === "host3") return mockClient3;
throw new Error(`Unknown host: ${host.name}`);
});
// Build mapping
await cache.buildMapping(hosts, mockDockerService);
// Verify getDockerClient was called for each host
expect(mockDockerService.getDockerClient).toHaveBeenCalledTimes(3);
expect(mockClient1.listContainers).toHaveBeenCalledTimes(1);
expect(mockClient2.listContainers).toHaveBeenCalledTimes(1);
expect(mockClient3.listContainers).toHaveBeenCalledTimes(1);
});
it("should create multiple index keys per container", async (): Promise<void> => {
// Mock getDockerClient to return client with specific container
const mockClient = {
listContainers: vi.fn().mockResolvedValue([
{
Id: "abc123def456789012345678",
Names: ["/my-container"],
Image: "nginx:latest",
State: "running",
Status: "Up 1 hour",
Created: Date.now() / 1000,
Ports: [],
Labels: {},
},
]),
};
mockDockerService.getDockerClient = vi.fn().mockReturnValue(mockClient);
await cache.buildMapping([hosts[0]], mockDockerService);
// Verify container can be found by full ID
const byId = await cache.findContainerHost(
"abc123def456789012345678",
[hosts[0]],
mockDockerService
);
expect(byId).not.toBeNull();
expect(byId?.host.name).toBe("host1");
// Verify container can be found by name
const byName = await cache.findContainerHost("my-container", [hosts[0]], mockDockerService);
expect(byName).not.toBeNull();
expect(byName?.host.name).toBe("host1");
// Verify container can be found by ID prefix (first 12 chars)
const byPrefix = await cache.findContainerHost("abc123def456", [hosts[0]], mockDockerService);
expect(byPrefix).not.toBeNull();
expect(byPrefix?.host.name).toBe("host1");
});
it("should invalidate expired entries after TTL", async (): Promise<void> => {
// Create cache with short TTL (100ms)
const shortTtlCache = new ContainerHostMapCache(100);
const mockClient = {
listContainers: vi.fn().mockResolvedValue([
{
Id: "expired-container-id",
Names: ["/expired-container"],
Image: "nginx:latest",
State: "running",
Status: "Up 1 hour",
Created: Date.now() / 1000,
Ports: [],
Labels: {},
},
]),
};
mockDockerService.getDockerClient = vi.fn().mockReturnValue(mockClient);
// Build mapping
await shortTtlCache.buildMapping([hosts[0]], mockDockerService);
// Verify entry exists immediately
const immediate = await shortTtlCache.findContainerHost(
"expired-container",
[hosts[0]],
mockDockerService
);
expect(immediate).not.toBeNull();
expect(immediate?.host.name).toBe("host1");
// Verify getDockerClient was called once so far
expect(mockDockerService.getDockerClient).toHaveBeenCalledTimes(1);
// Wait for TTL to expire
await new Promise((resolve) => setTimeout(resolve, 150));
// After TTL expires, cache should rebuild mapping on next query
const expired = await shortTtlCache.findContainerHost(
"expired-container",
[hosts[0]],
mockDockerService
);
expect(expired).not.toBeNull();
// Verify cache was rebuilt (getDockerClient called again)
expect(mockDockerService.getDockerClient).toHaveBeenCalledTimes(2);
});
});
describe("invalidateCache", () => {
it("should invalidate entire cache", async (): Promise<void> => {
// Mock containers on different hosts
const mockClient1 = {
listContainers: vi.fn().mockResolvedValue([
{
Id: "host1-container-id",
Names: ["/host1-container"],
Image: "nginx:latest",
State: "running",
Status: "Up 1 hour",
Created: Date.now() / 1000,
Ports: [],
Labels: {},
},
]),
};
const mockClient2 = {
listContainers: vi.fn().mockResolvedValue([
{
Id: "host2-container-id",
Names: ["/host2-container"],
Image: "redis:latest",
State: "running",
Status: "Up 2 hours",
Created: Date.now() / 1000,
Ports: [],
Labels: {},
},
]),
};
mockDockerService.getDockerClient = vi.fn().mockImplementation((host: HostConfig) => {
if (host.name === "host1") return mockClient1;
if (host.name === "host2") return mockClient2;
throw new Error(`Unknown host: ${host.name}`);
});
// Build mapping for both hosts
await cache.buildMapping([hosts[0], hosts[1]], mockDockerService);
// Verify both containers are cached
const host1Result = await cache.findContainerHost(
"host1-container",
hosts,
mockDockerService
);
expect(host1Result).not.toBeNull();
expect(host1Result?.host.name).toBe("host1");
const host2Result = await cache.findContainerHost(
"host2-container",
hosts,
mockDockerService
);
expect(host2Result).not.toBeNull();
expect(host2Result?.host.name).toBe("host2");
const statsBefore = cache.getStats();
expect(statsBefore.size).toBeGreaterThan(0);
// Invalidate cache (clears ENTIRE cache per plan)
cache.invalidateCache();
const statsAfter = cache.getStats();
// Cache should be completely empty
expect(statsAfter.size).toBe(0);
});
});
describe("warmCache", () => {
it("should warm cache on startup", async (): Promise<void> => {
// Create fresh cache for this test
const freshCache = new ContainerHostMapCache();
const mockClient = {
listContainers: vi.fn().mockResolvedValue([
{
Id: "warm-container-id",
Names: ["/warm-container"],
Image: "nginx:latest",
State: "running",
Status: "Up 1 hour",
Created: Date.now() / 1000,
Ports: [],
Labels: {},
},
]),
};
mockDockerService.getDockerClient = vi.fn().mockReturnValue(mockClient);
// Warm cache
await freshCache.warmCache([hosts[0]], mockDockerService);
// Verify container is cached
const result = await freshCache.findContainerHost(
"warm-container",
[hosts[0]],
mockDockerService
);
expect(result).not.toBeNull();
expect(result?.host.name).toBe("host1");
// Verify getDockerClient was called
expect(mockDockerService.getDockerClient).toHaveBeenCalled();
expect(mockClient.listContainers).toHaveBeenCalled();
});
});
describe("incremental scan on cache miss", () => {
it("should stop scanning after finding container on first host", async (): Promise<void> => {
const mockClient1 = {
listContainers: vi.fn().mockResolvedValue([
{
Id: "target-container-full-id",
Names: ["/target-container"],
Image: "nginx:latest",
State: "running",
Status: "Up 1 hour",
Created: Date.now() / 1000,
Ports: [],
Labels: {},
},
]),
};
const mockClient2 = {
listContainers: vi.fn().mockResolvedValue([
{
Id: "other-container-full-id",
Names: ["/other-container"],
Image: "redis:latest",
State: "running",
Status: "Up 2 hours",
Created: Date.now() / 1000,
Ports: [],
Labels: {},
},
]),
};
const mockClient3 = {
listContainers: vi.fn().mockResolvedValue([]),
};
mockDockerService.getDockerClient = vi.fn().mockImplementation((host: HostConfig) => {
if (host.name === "host1") return mockClient1;
if (host.name === "host2") return mockClient2;
if (host.name === "host3") return mockClient3;
throw new Error(`Unknown host: ${host.name}`);
});
// Don't pre-build mapping - go straight to find (cache miss)
const result = await cache.findContainerHost("target-container", hosts, mockDockerService);
expect(result).not.toBeNull();
expect(result?.host.name).toBe("host1");
// Only host1 should have been scanned - incremental scan stops early
expect(mockDockerService.getDockerClient).toHaveBeenCalledTimes(1);
expect(mockClient1.listContainers).toHaveBeenCalledTimes(1);
expect(mockClient2.listContainers).not.toHaveBeenCalled();
expect(mockClient3.listContainers).not.toHaveBeenCalled();
});
it("should scan multiple hosts until container is found", async (): Promise<void> => {
const mockClient1 = {
listContainers: vi.fn().mockResolvedValue([
{
Id: "unrelated-container-id",
Names: ["/unrelated"],
Image: "nginx:latest",
State: "running",
Status: "Up 1 hour",
Created: Date.now() / 1000,
Ports: [],
Labels: {},
},
]),
};
const mockClient2 = {
listContainers: vi.fn().mockResolvedValue([
{
Id: "wanted-container-full-id",
Names: ["/wanted-container"],
Image: "redis:latest",
State: "running",
Status: "Up 2 hours",
Created: Date.now() / 1000,
Ports: [],
Labels: {},
},
]),
};
const mockClient3 = {
listContainers: vi.fn().mockResolvedValue([]),
};
mockDockerService.getDockerClient = vi.fn().mockImplementation((host: HostConfig) => {
if (host.name === "host1") return mockClient1;
if (host.name === "host2") return mockClient2;
if (host.name === "host3") return mockClient3;
throw new Error(`Unknown host: ${host.name}`);
});
const result = await cache.findContainerHost("wanted-container", hosts, mockDockerService);
expect(result).not.toBeNull();
expect(result?.host.name).toBe("host2");
// Should have scanned host1 and host2, but NOT host3
expect(mockDockerService.getDockerClient).toHaveBeenCalledTimes(2);
expect(mockClient1.listContainers).toHaveBeenCalledTimes(1);
expect(mockClient2.listContainers).toHaveBeenCalledTimes(1);
expect(mockClient3.listContainers).not.toHaveBeenCalled();
});
it("should scan all hosts and return null when container not found", async (): Promise<void> => {
const mockClient1 = {
listContainers: vi.fn().mockResolvedValue([]),
};
const mockClient2 = {
listContainers: vi.fn().mockResolvedValue([]),
};
const mockClient3 = {
listContainers: vi.fn().mockResolvedValue([]),
};
mockDockerService.getDockerClient = vi.fn().mockImplementation((host: HostConfig) => {
if (host.name === "host1") return mockClient1;
if (host.name === "host2") return mockClient2;
if (host.name === "host3") return mockClient3;
throw new Error(`Unknown host: ${host.name}`);
});
const result = await cache.findContainerHost("nonexistent", hosts, mockDockerService);
expect(result).toBeNull();
// All hosts should have been scanned
expect(mockDockerService.getDockerClient).toHaveBeenCalledTimes(3);
expect(mockClient1.listContainers).toHaveBeenCalledTimes(1);
expect(mockClient2.listContainers).toHaveBeenCalledTimes(1);
expect(mockClient3.listContainers).toHaveBeenCalledTimes(1);
});
it("should skip failing hosts and continue scanning", async (): Promise<void> => {
const mockClient1 = {
listContainers: vi.fn().mockRejectedValue(new Error("Connection refused")),
};
const mockClient2 = {
listContainers: vi.fn().mockResolvedValue([
{
Id: "found-after-error-id",
Names: ["/found-after-error"],
Image: "redis:latest",
State: "running",
Status: "Up 2 hours",
Created: Date.now() / 1000,
Ports: [],
Labels: {},
},
]),
};
mockDockerService.getDockerClient = vi.fn().mockImplementation((host: HostConfig) => {
if (host.name === "host1") return mockClient1;
if (host.name === "host2") return mockClient2;
throw new Error(`Unknown host: ${host.name}`);
});
const result = await cache.findContainerHost(
"found-after-error",
[hosts[0], hosts[1]],
mockDockerService
);
expect(result).not.toBeNull();
expect(result?.host.name).toBe("host2");
});
});
describe("logging", () => {
it("should log cache hit/miss for observability", async (): Promise<void> => {
// Spy on console.error for logging
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {
/* Mock implementation - suppress console output in tests */
});
const mockClient = {
listContainers: vi.fn().mockResolvedValue([
{
Id: "logged-container-id",
Names: ["/logged-container"],
Image: "nginx:latest",
State: "running",
Status: "Up 1 hour",
Created: Date.now() / 1000,
Ports: [],
Labels: {},
},
]),
};
mockDockerService.getDockerClient = vi.fn().mockReturnValue(mockClient);
await cache.buildMapping([hosts[0]], mockDockerService);
// Clear previous calls
consoleSpy.mockClear();
// Cache hit
const hit = await cache.findContainerHost("logged-container", [hosts[0]], mockDockerService);
expect(hit).not.toBeNull();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("[ContainerHostMapCache] HIT:")
);
// Cache miss - triggers incremental scan
consoleSpy.mockClear();
const miss = await cache.findContainerHost(
"non-existent-container",
[hosts[0]],
mockDockerService
);
expect(miss).toBeNull();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("[ContainerHostMapCache] MISS:")
);
consoleSpy.mockRestore();
});
});
describe("stats", () => {
it("should track and return cache statistics", async (): Promise<void> => {
const mockClient = {
listContainers: vi.fn().mockResolvedValue([
{
Id: "stats-container-id",
Names: ["/stats-container"],
Image: "nginx:latest",
State: "running",
Status: "Up 1 hour",
Created: Date.now() / 1000,
Ports: [],
Labels: {},
},
]),
};
mockDockerService.getDockerClient = vi.fn().mockReturnValue(mockClient);
await cache.buildMapping([hosts[0]], mockDockerService);
// Trigger hits and misses
await cache.findContainerHost("stats-container", [hosts[0]], mockDockerService); // hit
await cache.findContainerHost("non-existent", [hosts[0]], mockDockerService); // miss
const stats = cache.getStats();
expect(stats).toHaveProperty("hits");
expect(stats).toHaveProperty("misses");
expect(stats).toHaveProperty("size");
expect(stats).toHaveProperty("maxSize");
expect(stats).toHaveProperty("evictions");
expect(stats.hits).toBeGreaterThan(0);
expect(stats.misses).toBeGreaterThan(0);
expect(stats.size).toBeGreaterThan(0);
});
it("should log formatted statistics", async (): Promise<void> => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {
/* Mock implementation - suppress console output in tests */
});
const mockClient = {
listContainers: vi.fn().mockResolvedValue([
{
Id: "log-stats-container-id",
Names: ["/log-stats-container"],
Image: "nginx:latest",
State: "running",
Status: "Up 1 hour",
Created: Date.now() / 1000,
Ports: [],
Labels: {},
},
]),
};
mockDockerService.getDockerClient = vi.fn().mockReturnValue(mockClient);
await cache.buildMapping([hosts[0]], mockDockerService);
consoleSpy.mockClear();
cache.logStats();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("[ContainerHostMapCache] Stats:")
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Hits:"));
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Misses:"));
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Hit Rate:"));
consoleSpy.mockRestore();
});
});
});