import { afterEach, describe, expect, it, vi } from "vitest";
import { LifecycleState, ServiceContainer } from "./container.js";
import type { IHostConfigRepository } from "./host-config-repository.js";
import type {
DockerOperations,
IComposeService,
IFileService,
ISSHConnectionPool,
ISSHService,
} from "./interfaces.js";
describe("ServiceContainer", () => {
it("creates default services lazily", () => {
const container = new ServiceContainer();
expect(container.getDockerService()).toBeDefined();
expect(container.getSSHService()).toBeDefined();
expect(container.getComposeService()).toBeDefined();
});
it("allows service overrides", () => {
const container = new ServiceContainer();
const docker = {} as DockerOperations;
const ssh = {} as ISSHService;
const compose = {} as IComposeService;
container.setDockerService(docker);
container.setSSHService(ssh);
container.setComposeService(compose);
expect(container.getDockerService()).toBe(docker);
expect(container.getSSHService()).toBe(ssh);
expect(container.getComposeService()).toBe(compose);
});
describe("getFileService", () => {
it("returns FileService instance", () => {
const container = new ServiceContainer();
const fileService = container.getFileService();
expect(fileService).toBeDefined();
expect(typeof fileService.readFile).toBe("function");
expect(typeof fileService.listDirectory).toBe("function");
expect(typeof fileService.treeDirectory).toBe("function");
expect(typeof fileService.executeCommand).toBe("function");
expect(typeof fileService.findFiles).toBe("function");
expect(typeof fileService.transferFile).toBe("function");
expect(typeof fileService.diffFiles).toBe("function");
});
it("lazily initializes on first call", () => {
const container = new ServiceContainer();
// First call creates instance
const first = container.getFileService();
// Second call returns same instance
const second = container.getFileService();
expect(first).toBe(second);
});
});
describe("setFileService", () => {
it("allows injecting mock for testing", () => {
const container = new ServiceContainer();
const mockFileService: IFileService = {
readFile: vi.fn(),
listDirectory: vi.fn(),
treeDirectory: vi.fn(),
executeCommand: vi.fn(),
findFiles: vi.fn(),
transferFile: vi.fn(),
diffFiles: vi.fn(),
};
container.setFileService(mockFileService);
expect(container.getFileService()).toBe(mockFileService);
});
});
describe("getHostConfigRepository", () => {
it("returns HostConfigRepository instance", () => {
const container = new ServiceContainer();
const repository = container.getHostConfigRepository();
expect(repository).toBeDefined();
expect(typeof repository.loadHosts).toBe("function");
expect(typeof repository.clearCache).toBe("function");
});
it("lazily initializes on first call", () => {
const container = new ServiceContainer();
// First call creates instance
const first = container.getHostConfigRepository();
// Second call returns same instance
const second = container.getHostConfigRepository();
expect(first).toBe(second);
});
});
describe("setHostConfigRepository", () => {
it("allows injecting mock for testing", () => {
const container = new ServiceContainer();
const mockRepository: IHostConfigRepository = {
loadHosts: vi.fn(),
clearCache: vi.fn(),
};
container.setHostConfigRepository(mockRepository);
expect(container.getHostConfigRepository()).toBe(mockRepository);
});
});
describe("compose dependency chain", () => {
it("should create discovery without setter injection (no circular dependency)", () => {
const container = new ServiceContainer();
// Discovery is created independently of ComposeService
const discovery = container.getComposeDiscovery();
expect(discovery).toBeDefined();
// ComposeService is created with discovery as path resolver via constructor
const composeService = container.getComposeService();
expect(composeService).toBeDefined();
});
it("getComposeDiscovery does not trigger ComposeService creation", () => {
const container = new ServiceContainer();
const getComposeServiceSpy = vi.spyOn(container, "getComposeService");
const discovery = container.getComposeDiscovery();
expect(discovery).toBeDefined();
expect(getComposeServiceSpy).not.toHaveBeenCalled();
const lister = container.getComposeProjectLister();
expect(lister).toBeDefined();
expect(typeof lister.listComposeProjects).toBe("function");
expect(getComposeServiceSpy).not.toHaveBeenCalled();
});
});
describe("shutdown", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("clears timeout timer after successful shutdown", async () => {
const container = new ServiceContainer();
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
// Mock dependencies to allow initialize + shutdown
const mockRepo: IHostConfigRepository = {
loadHosts: vi.fn().mockResolvedValue([]),
clearCache: vi.fn(),
};
container.setHostConfigRepository(mockRepo);
const mockPool: ISSHConnectionPool = {
getConnection: vi.fn(),
releaseConnection: vi.fn().mockResolvedValue(undefined),
closeConnection: vi.fn().mockResolvedValue(undefined),
closeAll: vi.fn().mockResolvedValue(undefined),
getStats: vi.fn().mockReturnValue({ active: 0, idle: 0, total: 0 }),
getHealth: vi.fn().mockReturnValue({ healthy: true }),
};
container.setSSHConnectionPool(mockPool);
await container.initialize();
await container.shutdown(5000);
expect(clearTimeoutSpy).toHaveBeenCalled();
expect(container.getLifecycleState()).toBe(LifecycleState.SHUTDOWN);
});
it("is idempotent when already shut down", async () => {
const container = new ServiceContainer();
const mockRepo: IHostConfigRepository = {
loadHosts: vi.fn().mockResolvedValue([]),
clearCache: vi.fn(),
};
container.setHostConfigRepository(mockRepo);
await container.initialize();
await container.shutdown();
// Second call should not throw
await container.shutdown();
expect(container.getLifecycleState()).toBe(LifecycleState.SHUTDOWN);
});
});
});