Files
socraticode/tests/unit/docker.test.ts
T
2026-02-28 17:06:21 +00:00

401 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ── Module mocks (hoisted — evaluated before imports) ────────────────────
// Make promisify a passthrough so execFileAsync === execFile (the mock below).
// This lets us control all child-process calls with a single vi.fn().
vi.mock("node:util", () => ({
promisify: vi.fn(<T>(fn: T): T => fn),
}));
vi.mock("node:child_process", () => ({
execFile: vi.fn(),
}));
vi.mock("../../src/services/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
// ── Imports (after mocks are registered) ─────────────────────────────────
import { execFile } from "node:child_process";
import {
ensureOllamaContainerReady,
ensureQdrantReady,
isDockerAvailable,
isOllamaImagePresent,
isOllamaRunning,
isQdrantImagePresent,
isQdrantRunning,
pullOllamaImage,
pullQdrantImage,
resetOllamaContainerReadinessCache,
resetQdrantReadinessCache,
startQdrant,
} from "../../src/services/docker.js";
const mockExecFile = vi.mocked(execFile as unknown as (
cmd: string,
args: string[],
opts: object
) => Promise<{ stdout: string; stderr: string }>);
// Shorthand: resolve with empty stdout/stderr (simulates a successful command)
const resolveOk = (stdout = "") =>
mockExecFile.mockResolvedValueOnce({ stdout, stderr: "" });
// Shorthand: reject simulating a timed-out command
const rejectTimeout = () =>
mockExecFile.mockRejectedValueOnce(
Object.assign(new Error("timeout"), { killed: true }),
);
// Shorthand: reject simulating a failed command with stderr
const rejectFailed = (stderr = "some docker error") =>
mockExecFile.mockRejectedValueOnce(
Object.assign(new Error("exit 1"), { stderr }),
);
// ── isDockerAvailable ─────────────────────────────────────────────────────
describe("isDockerAvailable", () => {
beforeEach(() => vi.clearAllMocks());
it("returns true when docker info succeeds", async () => {
resolveOk();
expect(await isDockerAvailable()).toBe(true);
});
it("returns false when docker info fails", async () => {
rejectFailed("Cannot connect to the Docker daemon");
expect(await isDockerAvailable()).toBe(false);
});
});
// ── isQdrantImagePresent ──────────────────────────────────────────────────
describe("isQdrantImagePresent", () => {
beforeEach(() => vi.clearAllMocks());
it("returns true when docker images output contains qdrant/qdrant", async () => {
resolveOk("qdrant/qdrant:v1.17.0\n");
expect(await isQdrantImagePresent()).toBe(true);
});
it("returns false when docker images output does not contain qdrant/qdrant", async () => {
resolveOk("ollama/ollama:latest\n");
expect(await isQdrantImagePresent()).toBe(false);
});
it("returns false when docker command fails", async () => {
rejectFailed();
expect(await isQdrantImagePresent()).toBe(false);
});
});
// ── isQdrantRunning ───────────────────────────────────────────────────────
describe("isQdrantRunning", () => {
beforeEach(() => vi.clearAllMocks());
it("returns true when docker ps output contains the container name", async () => {
resolveOk("socraticode-qdrant\n");
expect(await isQdrantRunning()).toBe(true);
});
it("returns false when container is not in docker ps output", async () => {
resolveOk("some-other-container\n");
expect(await isQdrantRunning()).toBe(false);
});
it("returns false when docker command fails", async () => {
rejectFailed();
expect(await isQdrantRunning()).toBe(false);
});
});
// ── isOllamaImagePresent ──────────────────────────────────────────────────
describe("isOllamaImagePresent", () => {
beforeEach(() => vi.clearAllMocks());
it("returns true when docker images output contains ollama/ollama", async () => {
resolveOk("ollama/ollama:latest\n");
expect(await isOllamaImagePresent()).toBe(true);
});
it("returns false when docker images output does not contain ollama/ollama", async () => {
resolveOk("qdrant/qdrant:v1.17.0\n");
expect(await isOllamaImagePresent()).toBe(false);
});
it("returns false when docker command fails", async () => {
rejectFailed();
expect(await isOllamaImagePresent()).toBe(false);
});
});
// ── isOllamaRunning ───────────────────────────────────────────────────────
describe("isOllamaRunning", () => {
beforeEach(() => vi.clearAllMocks());
it("returns true when docker ps output contains the container name", async () => {
resolveOk("socraticode-ollama\n");
expect(await isOllamaRunning()).toBe(true);
});
it("returns false when container is not in docker ps output", async () => {
resolveOk("other-container\n");
expect(await isOllamaRunning()).toBe(false);
});
it("returns false when docker command fails", async () => {
rejectFailed();
expect(await isOllamaRunning()).toBe(false);
});
});
// ── pullQdrantImage error paths ───────────────────────────────────────────
describe("pullQdrantImage", () => {
beforeEach(() => vi.clearAllMocks());
it("throws a user-friendly timeout message when docker pull is killed", async () => {
rejectTimeout();
await expect(pullQdrantImage()).rejects.toThrow(
"Qdrant image download timed out after 10 minutes",
);
});
it("throws a user-friendly network error message on generic failure", async () => {
rejectFailed("Network unreachable");
await expect(pullQdrantImage()).rejects.toThrow(
"Failed to download Qdrant image",
);
});
it("calls onProgress callback before pulling", async () => {
resolveOk();
const progress: string[] = [];
await pullQdrantImage((msg) => progress.push(msg));
expect(progress[0]).toMatch(/Downloading Qdrant Docker image/);
});
});
// ── pullOllamaImage error paths ───────────────────────────────────────────
describe("pullOllamaImage", () => {
beforeEach(() => vi.clearAllMocks());
it("throws a user-friendly timeout message when docker pull is killed", async () => {
rejectTimeout();
await expect(pullOllamaImage()).rejects.toThrow(
"Ollama image download timed out after 10 minutes",
);
});
it("throws a user-friendly network error message on generic failure", async () => {
rejectFailed("Network unreachable");
await expect(pullOllamaImage()).rejects.toThrow(
"Failed to download Ollama image",
);
});
it("calls onProgress callback before pulling", async () => {
resolveOk();
const progress: string[] = [];
await pullOllamaImage((msg) => progress.push(msg));
expect(progress[0]).toMatch(/Downloading Ollama Docker image/);
});
});
// ── startQdrant error paths ───────────────────────────────────────────────
describe("startQdrant", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("throws a port-conflict message when docker run fails", async () => {
// isQdrantRunning → false (ps returns empty)
resolveOk("");
// doesQdrantContainerExist → false (ps -a returns empty)
resolveOk("");
// docker run → fails
rejectFailed("port is already allocated");
await expect(startQdrant()).rejects.toThrow(
"Failed to start Qdrant container",
);
});
it("skips creation and returns when Qdrant is already running", async () => {
// isQdrantRunning returns true
resolveOk("socraticode-qdrant");
// No further docker calls should be needed
await startQdrant();
// execFile called exactly once (for isQdrantRunning)
expect(mockExecFile).toHaveBeenCalledTimes(1);
});
});
// ── ensureQdrantReady (managed mode) — readiness cache ───────────────────
describe("ensureQdrantReady managed mode cache", () => {
beforeEach(() => {
vi.clearAllMocks();
resetQdrantReadinessCache();
});
it("fast-paths on the second call without invoking docker", async () => {
// First call: docker info + images + ps all succeed; skip actual start by
// reporting image present and container already running.
resolveOk(); // docker info → available
resolveOk("qdrant/qdrant:v1.17.0"); // images → present
resolveOk("socraticode-qdrant"); // ps → running
const first = await ensureQdrantReady();
expect(first).toMatchObject({ started: false, pulled: false });
const callsAfterFirst = mockExecFile.mock.calls.length;
// Second call should be served from cache — no new docker calls
const second = await ensureQdrantReady();
expect(second).toMatchObject({ started: false, pulled: false });
expect(mockExecFile).toHaveBeenCalledTimes(callsAfterFirst);
});
it("throws when Docker is not available in managed mode", async () => {
rejectFailed("Cannot connect to daemon"); // docker info fails
await expect(ensureQdrantReady()).rejects.toThrow("Docker is not available");
});
});
// ── resetOllamaContainerReadinessCache ────────────────────────────────────
describe("resetOllamaContainerReadinessCache", () => {
beforeEach(() => {
vi.clearAllMocks();
resetOllamaContainerReadinessCache();
});
it("forces a re-check after reset", async () => {
// Prime the cache: docker info + images + ps all succeed
resolveOk(); // docker info
resolveOk("ollama/ollama:latest"); // images
resolveOk("socraticode-ollama"); // ps
const first = await ensureOllamaContainerReady();
expect(first).toMatchObject({ started: false, pulled: false });
const callsAfterFirst = mockExecFile.mock.calls.length;
// Reset: next call must re-check
resetOllamaContainerReadinessCache();
resolveOk(); // docker info again
resolveOk("ollama/ollama:latest"); // images again
resolveOk("socraticode-ollama"); // ps again
await ensureOllamaContainerReady();
expect(mockExecFile.mock.calls.length).toBeGreaterThan(callsAfterFirst);
});
});
// ── ensureQdrantReady (external mode) ────────────────────────────────────
// These tests use vi.resetModules() + vi.doMock() to reload docker.ts with
// QDRANT_MODE=external and controlled constants. They must run in isolation.
describe("ensureQdrantReady external mode", () => {
afterEach(() => {
vi.resetModules();
vi.restoreAllMocks();
});
async function loadDockerWithExternalMode(overrides: Record<string, unknown> = {}) {
// Reset module cache so docker.ts re-imports the mocked constants below
vi.resetModules();
vi.doMock("../../src/constants.js", async (importOriginal) => {
const original = await importOriginal<Record<string, unknown>>();
return {
...original,
QDRANT_MODE: "external",
QDRANT_URL: "",
QDRANT_HOST: "localhost",
QDRANT_PORT: 16333,
...overrides,
};
});
vi.doMock("../../src/services/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
vi.doMock("node:child_process", () => ({ execFile: vi.fn() }));
vi.doMock("node:util", () => ({ promisify: <T>(fn: T): T => fn }));
const docker = await import("../../src/services/docker.js");
docker.resetQdrantReadinessCache();
return docker;
}
it("throws when QDRANT_URL is not set and QDRANT_HOST is localhost", async () => {
const docker = await loadDockerWithExternalMode();
await expect(docker.ensureQdrantReady()).rejects.toThrow(
"QDRANT_MODE=external requires QDRANT_URL",
);
});
it("returns { started: false, pulled: false } when external Qdrant is reachable", async () => {
const docker = await loadDockerWithExternalMode({
QDRANT_URL: "http://remote-qdrant:6333",
QDRANT_HOST: "remote-qdrant",
});
// Mock fetch so the health check immediately succeeds
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue({ ok: true } as Response);
const result = await docker.ensureQdrantReady();
expect(result).toEqual({ started: false, pulled: false });
fetchSpy.mockRestore();
});
it("throws 'Cannot reach external Qdrant' when health check fails", async () => {
const docker = await loadDockerWithExternalMode({
QDRANT_URL: "http://unreachable-qdrant:6333",
QDRANT_HOST: "unreachable-qdrant",
});
// Mock fetch to always fail (simulates unreachable server)
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockRejectedValue(new Error("ECONNREFUSED"));
await expect(docker.ensureQdrantReady()).rejects.toThrow(
"Cannot reach external Qdrant",
);
fetchSpy.mockRestore();
}, 30_000); // allow up to 30s for the retry loop (5 retries × 1s)
it("throws 'Cannot reach external Qdrant' when health endpoint returns non-ok", async () => {
const docker = await loadDockerWithExternalMode({
QDRANT_URL: "http://bad-qdrant:6333",
QDRANT_HOST: "bad-qdrant",
});
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue({ ok: false, status: 503 } as Response);
await expect(docker.ensureQdrantReady()).rejects.toThrow(
"Cannot reach external Qdrant",
);
fetchSpy.mockRestore();
}, 30_000);
});