/** * embedding-factory.test.ts - Tests for createEmbeddingProvider factory. * * Verifies the resolution precedence: * 1. explicit `kind` argument * 2. QMD_EMBED_PROVIDER env * 3. QMD_EMBED_ENDPOINT env (forces openai) * 4. config file `embedProvider.kind` / `embedProvider.endpoint` * 5. fallback: local */ import { describe, test, expect, beforeEach, afterEach } from "vitest"; import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { resolveProviderKind, createEmbeddingProvider, loadConfigFile, } from "../src/embedding/factory.js"; import { OpenAIEmbeddingsProvider } from "../src/embedding/openai.js"; import { LocalLlamaCppProvider } from "../src/embedding/local.js"; let workDir: string; let configPath: string; beforeEach(() => { workDir = mkdtempSync(join(tmpdir(), "qmd-factory-test-")); mkdirSync(join(workDir, "qmd"), { recursive: true }); configPath = join(workDir, "qmd", "config.json"); }); afterEach(() => { rmSync(workDir, { recursive: true, force: true }); }); // ─────────────────────────── Helpers ───────────────────────────────────────── function writeConfig(obj: Record) { writeFileSync(configPath, JSON.stringify(obj)); } const EMPTY_ENV: Record = {}; // ─────────────────────────── resolveProviderKind ───────────────────────────── describe("resolveProviderKind", () => { test("explicit kind argument wins", () => { expect( resolveProviderKind({ kind: "local", env: { QMD_EMBED_ENDPOINT: "https://x" }, configPath, }), ).toBe("local"); expect( resolveProviderKind({ kind: "openai", env: EMPTY_ENV, configPath, }), ).toBe("openai"); }); test("QMD_EMBED_PROVIDER env wins over QMD_EMBED_ENDPOINT", () => { expect( resolveProviderKind({ env: { QMD_EMBED_PROVIDER: "local", QMD_EMBED_ENDPOINT: "https://x" }, configPath, }), ).toBe("local"); }); test("QMD_EMBED_ENDPOINT presence → openai", () => { expect( resolveProviderKind({ env: { QMD_EMBED_ENDPOINT: "https://ai.example.com" }, configPath, }), ).toBe("openai"); }); test("QMD_EMBED_ENDPOINT empty string ignored", () => { expect( resolveProviderKind({ env: { QMD_EMBED_ENDPOINT: "" }, configPath, }), ).toBe("local"); }); test("config file embedProvider.kind respected", () => { writeConfig({ embedProvider: { kind: "openai", endpoint: "https://ai.example.com" } }); expect(resolveProviderKind({ env: EMPTY_ENV, configPath })).toBe("openai"); }); test("config file embedProvider.endpoint alone → openai", () => { writeConfig({ embedProvider: { endpoint: "https://ai.example.com" } }); expect(resolveProviderKind({ env: EMPTY_ENV, configPath })).toBe("openai"); }); test("no signal anywhere → local fallback", () => { expect(resolveProviderKind({ env: EMPTY_ENV, configPath })).toBe("local"); }); test("invalid env QMD_EMBED_PROVIDER is ignored", () => { expect( resolveProviderKind({ env: { QMD_EMBED_PROVIDER: "garbage" }, configPath, }), ).toBe("local"); }); test("uppercase env QMD_EMBED_PROVIDER normalized", () => { expect( resolveProviderKind({ env: { QMD_EMBED_PROVIDER: "OPENAI", QMD_EMBED_ENDPOINT: "https://x" }, configPath, }), ).toBe("openai"); }); }); // ─────────────────────────── createEmbeddingProvider ───────────────────────── describe("createEmbeddingProvider", () => { test("openai kind w/ endpoint env → OpenAIEmbeddingsProvider", () => { const p = createEmbeddingProvider({ env: { QMD_EMBED_ENDPOINT: "https://ai.example.com" }, configPath, }); expect(p).toBeInstanceOf(OpenAIEmbeddingsProvider); expect(p.kind).toBe("openai"); expect(p.getModelId()).toBe("embeddinggemma"); }); test("openai kind w/ explicit options merges over env", () => { const p = createEmbeddingProvider({ env: { QMD_EMBED_ENDPOINT: "https://env.example.com", QMD_EMBED_API_KEY: "env-key" }, configPath, openai: { endpoint: "https://override.example.com" }, }); // Cast to access internal properties for verification const inner = p as OpenAIEmbeddingsProvider & { endpoint: string; apiKey: string }; expect(inner["endpoint"]).toBe("https://override.example.com"); // apiKey should still come from env since we didn't override it expect(inner["apiKey"]).toBe("env-key"); }); test("openai kind reads modelId from env", () => { const p = createEmbeddingProvider({ env: { QMD_EMBED_ENDPOINT: "https://ai.example.com", QMD_EMBED_MODEL_ID: "custom-model", }, configPath, }); expect(p.getModelId()).toBe("custom-model"); }); test("openai kind reads upstream model from env", () => { const p = createEmbeddingProvider({ env: { QMD_EMBED_ENDPOINT: "https://ai.example.com", QMD_EMBED_UPSTREAM_MODEL: "embeddinggemma:300m", }, configPath, }) as OpenAIEmbeddingsProvider & { upstreamModel: string }; expect(p["upstreamModel"]).toBe("embeddinggemma:300m"); }); test("openai kind reads batch size and timeout from env", () => { const p = createEmbeddingProvider({ env: { QMD_EMBED_ENDPOINT: "https://ai.example.com", QMD_EMBED_BATCH_SIZE: "32", QMD_EMBED_TIMEOUT_MS: "5000", }, configPath, }) as OpenAIEmbeddingsProvider & { batchSize: number; timeoutMs: number }; expect(p["batchSize"]).toBe(32); expect(p["timeoutMs"]).toBe(5000); }); test("openai kind merges config file values", () => { writeConfig({ embedProvider: { kind: "openai", endpoint: "https://config.example.com", apiKey: "config-key", modelId: "config-model", batchSize: 16, }, }); const p = createEmbeddingProvider({ env: EMPTY_ENV, configPath, }) as OpenAIEmbeddingsProvider & { endpoint: string; apiKey: string; batchSize: number; }; expect(p["endpoint"]).toBe("https://config.example.com"); expect(p["apiKey"]).toBe("config-key"); expect(p.getModelId()).toBe("config-model"); expect(p["batchSize"]).toBe(16); }); test("env wins over config file", () => { writeConfig({ embedProvider: { endpoint: "https://config.example.com", }, }); const p = createEmbeddingProvider({ env: { QMD_EMBED_ENDPOINT: "https://env.example.com" }, configPath, }) as OpenAIEmbeddingsProvider & { endpoint: string }; expect(p["endpoint"]).toBe("https://env.example.com"); }); test("openai kind without endpoint throws", () => { expect(() => createEmbeddingProvider({ kind: "openai", env: EMPTY_ENV, configPath }), ).toThrow(/endpoint/); }); test("local kind explicitly requested → LocalLlamaCppProvider", () => { const p = createEmbeddingProvider({ kind: "local", env: EMPTY_ENV, configPath, }); expect(p).toBeInstanceOf(LocalLlamaCppProvider); expect(p.kind).toBe("local"); }); test("default fallback → LocalLlamaCppProvider", () => { const p = createEmbeddingProvider({ env: EMPTY_ENV, configPath }); expect(p).toBeInstanceOf(LocalLlamaCppProvider); }); }); // ─────────────────────────── loadConfigFile ────────────────────────────────── describe("loadConfigFile", () => { test("missing file → empty object", () => { expect(loadConfigFile(join(workDir, "missing.json"))).toEqual({}); }); test("invalid JSON → empty object (no throw)", () => { writeFileSync(configPath, "not json"); expect(loadConfigFile(configPath)).toEqual({}); }); test("valid JSON parsed", () => { writeConfig({ embedProvider: { kind: "openai" } }); expect(loadConfigFile(configPath)).toEqual({ embedProvider: { kind: "openai" }, }); }); });