import { beforeEach, describe, expect, it, vi } from "vitest";
import { LifecycleState, ServiceContainer } from "./container.js";
describe("ServiceContainer Lifecycle", () => {
let container: ServiceContainer;
beforeEach(() => {
container = new ServiceContainer();
});
describe("initial state", () => {
it("should start in CREATED state", () => {
expect(container.getLifecycleState()).toBe(LifecycleState.CREATED);
expect(container.isReady()).toBe(false);
});
});
describe("initialize", () => {
it("should transition to INITIALIZING then READY", async () => {
expect(container.getLifecycleState()).toBe(LifecycleState.CREATED);
const initPromise = container.initialize();
// Should be initializing during async operation
// (May already be READY by the time we check due to fast execution)
const stateAfterStart = container.getLifecycleState();
expect([LifecycleState.INITIALIZING, LifecycleState.READY]).toContain(stateAfterStart);
await initPromise;
expect(container.getLifecycleState()).toBe(LifecycleState.READY);
expect(container.isReady()).toBe(true);
});
it("should preload host configurations", async () => {
await container.initialize();
const hostRepo = container.getHostConfigRepository();
const hosts = await hostRepo.loadHosts();
// Hosts should be loaded (at least empty array, no error)
expect(Array.isArray(hosts)).toBe(true);
});
it("should emit lifecycle events", async () => {
const eventEmitter = container.getEventEmitter();
const events: import("../events/types.js").ContainerStateChangedEvent[] = [];
eventEmitter.on("container_state_changed", (event) => {
events.push(event);
});
await container.initialize();
expect(events.length).toBeGreaterThan(0);
expect(events.some((e) => e.action === "start")).toBe(true);
});
it("should throw if already initialized", async () => {
await container.initialize();
await expect(container.initialize()).rejects.toThrow(
/Cannot initialize.*Must be in CREATED state/
);
});
it("should reset to CREATED on initialization failure", async () => {
// Force initialization failure by making hostRepo fail
const mockHostRepo: import("./host-config-repository.js").IHostConfigRepository = {
loadHosts: vi.fn().mockRejectedValue(new Error("Config load failed")),
clearCache: vi.fn(),
};
container.setHostConfigRepository(mockHostRepo);
await expect(container.initialize()).rejects.toThrow(/initialization failed/);
expect(container.getLifecycleState()).toBe(LifecycleState.CREATED);
expect(container.isReady()).toBe(false);
});
});
describe("healthCheck", () => {
beforeEach(async () => {
await container.initialize();
});
it("should return health status for all services", async () => {
const health = await container.healthCheck();
expect(Array.isArray(health)).toBe(true);
expect(health.length).toBeGreaterThan(0);
// Should have at least EventEmitter and HostConfigRepository
const serviceNames = health.map((h) => h.name);
expect(serviceNames).toContain("EventEmitter");
expect(serviceNames).toContain("HostConfigRepository");
});
it("should include health status fields", async () => {
const health = await container.healthCheck();
for (const service of health) {
expect(service).toHaveProperty("name");
expect(service).toHaveProperty("healthy");
expect(typeof service.healthy).toBe("boolean");
if (service.responseTime !== undefined) {
expect(typeof service.responseTime).toBe("number");
expect(service.responseTime).toBeGreaterThanOrEqual(0);
}
}
});
it("should check SSH pool health if pool is initialized", async () => {
// Initialize SSH pool by accessing it
container.getSSHConnectionPool();
const health = await container.healthCheck();
const sshPoolHealth = health.find((h) => h.name === "SSHConnectionPool");
expect(sshPoolHealth).toBeDefined();
// Pool might be unhealthy if no connections yet, just check it was checked
expect(typeof sshPoolHealth?.healthy).toBe("boolean");
});
it("should check Docker service if initialized", async () => {
// Initialize Docker service
container.getDockerService();
const health = await container.healthCheck();
const dockerHealth = health.find((h) => h.name === "DockerService");
expect(dockerHealth).toBeDefined();
expect(dockerHealth?.healthy).toBe(true);
});
it("should report unhealthy service with error message", async () => {
// Mock hostRepo to fail health check
const mockHostRepo: import("./host-config-repository.js").IHostConfigRepository = {
loadHosts: vi.fn().mockRejectedValue(new Error("Connection refused")),
clearCache: vi.fn(),
};
container.setHostConfigRepository(mockHostRepo);
const health = await container.healthCheck();
const hostRepoHealth = health.find((h) => h.name === "HostConfigRepository");
expect(hostRepoHealth).toBeDefined();
expect(hostRepoHealth?.healthy).toBe(false);
expect(hostRepoHealth?.message).toContain("Connection refused");
});
it("should include response times for async checks", async () => {
const health = await container.healthCheck();
// HostConfigRepository performs async check, should have responseTime
const hostRepoHealth = health.find((h) => h.name === "HostConfigRepository");
expect(hostRepoHealth?.responseTime).toBeDefined();
if (hostRepoHealth?.responseTime !== undefined) {
expect(hostRepoHealth.responseTime).toBeGreaterThanOrEqual(0);
}
});
});
describe("shutdown", () => {
beforeEach(async () => {
await container.initialize();
});
it("should transition to SHUTTING_DOWN then SHUTDOWN", async () => {
expect(container.getLifecycleState()).toBe(LifecycleState.READY);
const shutdownPromise = container.shutdown(5000);
// Check state during shutdown
await new Promise((resolve) => setTimeout(resolve, 10));
const stateDuringShutdown = container.getLifecycleState();
expect([LifecycleState.SHUTTING_DOWN, LifecycleState.SHUTDOWN]).toContain(
stateDuringShutdown
);
await shutdownPromise;
expect(container.getLifecycleState()).toBe(LifecycleState.SHUTDOWN);
expect(container.isReady()).toBe(false);
});
it("should close SSH connections", async () => {
const sshPool = container.getSSHConnectionPool();
const closeAllSpy = vi.spyOn(sshPool, "closeAll");
await container.shutdown();
expect(closeAllSpy).toHaveBeenCalled();
});
it("should clear Docker clients", async () => {
const dockerService = container.getDockerService();
const clearSpy = vi.spyOn(dockerService, "clearClients");
await container.shutdown();
expect(clearSpy).toHaveBeenCalled();
});
it("should clear host config cache", async () => {
const hostRepo = container.getHostConfigRepository();
const clearSpy = vi.spyOn(hostRepo, "clearCache");
await container.shutdown();
expect(clearSpy).toHaveBeenCalled();
});
it("should remove all event listeners", async () => {
const eventEmitter = container.getEventEmitter();
const listener = vi.fn();
eventEmitter.on("container_state_changed", listener);
expect(eventEmitter.listenerCount("container_state_changed")).toBeGreaterThan(0);
await container.shutdown();
expect(eventEmitter.listenerCount("container_state_changed")).toBe(0);
});
it("should emit shutdown events", async () => {
const eventEmitter = container.getEventEmitter();
const events: import("../events/types.js").ContainerStateChangedEvent[] = [];
eventEmitter.on("container_state_changed", (event) => {
events.push(event);
});
await container.shutdown();
const stopEvents = events.filter((e) => e.action === "stop");
expect(stopEvents.length).toBeGreaterThan(0);
});
it("should timeout if cleanup takes too long", async () => {
// Mock SSH pool with slow closeAll
const mockPool: Pick<import("./interfaces.js").ISSHConnectionPool, "closeAll" | "getHealth"> =
{
closeAll: vi
.fn()
.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 2000))),
getHealth: vi.fn().mockReturnValue({ healthy: true }),
};
container.setSSHConnectionPool(mockPool as import("./interfaces.js").ISSHConnectionPool);
// Shutdown with 100ms timeout
await expect(container.shutdown(100)).rejects.toThrow(/timed out/);
// Should still be in SHUTDOWN state even after timeout
expect(container.getLifecycleState()).toBe(LifecycleState.SHUTDOWN);
});
it("should use default timeout if not specified", async () => {
// Just verify it doesn't throw
await expect(container.shutdown()).resolves.not.toThrow();
});
it("should be idempotent (safe to call multiple times)", async () => {
await container.shutdown();
expect(container.getLifecycleState()).toBe(LifecycleState.SHUTDOWN);
// Second shutdown should be no-op
await expect(container.shutdown()).resolves.not.toThrow();
expect(container.getLifecycleState()).toBe(LifecycleState.SHUTDOWN);
});
it("should throw if shutdown already in progress", async () => {
// Start shutdown but don't await
const shutdownPromise = container.shutdown(5000);
// Try to start another shutdown immediately
await expect(container.shutdown()).rejects.toThrow(/already in progress/);
// Wait for first shutdown to complete
await shutdownPromise;
});
});
describe("cleanup (deprecated)", () => {
it("should still work for backward compatibility", async () => {
await container.initialize();
await expect(container.cleanup()).resolves.not.toThrow();
// Cleanup completes successfully - pool connections are closed
// No need to check pool health after cleanup
});
});
describe("production startup/shutdown pattern", () => {
it("should support initialize() then shutdown() lifecycle", async () => {
// This is the pattern that index.ts must use
const c = new ServiceContainer();
expect(c.getLifecycleState()).toBe(LifecycleState.CREATED);
await c.initialize();
expect(c.getLifecycleState()).toBe(LifecycleState.READY);
expect(c.isReady()).toBe(true);
await c.shutdown();
expect(c.getLifecycleState()).toBe(LifecycleState.SHUTDOWN);
expect(c.isReady()).toBe(false);
});
});
describe("lifecycle state transitions", () => {
it("should follow valid state machine", async () => {
// CREATED → INITIALIZING → READY
expect(container.getLifecycleState()).toBe(LifecycleState.CREATED);
const initPromise = container.initialize();
await initPromise;
expect(container.getLifecycleState()).toBe(LifecycleState.READY);
// READY → SHUTTING_DOWN → SHUTDOWN
const shutdownPromise = container.shutdown();
await shutdownPromise;
expect(container.getLifecycleState()).toBe(LifecycleState.SHUTDOWN);
});
it("should prevent operations during shutdown", async () => {
await container.initialize();
await container.shutdown();
// Should not be able to re-initialize after shutdown
await expect(container.initialize()).rejects.toThrow(/Must be in CREATED state/);
});
});
describe("isReady", () => {
it("should return false when not ready", () => {
expect(container.isReady()).toBe(false);
});
it("should return true when ready", async () => {
await container.initialize();
expect(container.isReady()).toBe(true);
});
it("should return false after shutdown", async () => {
await container.initialize();
expect(container.isReady()).toBe(true);
await container.shutdown();
expect(container.isReady()).toBe(false);
});
});
});