import { describe, it, expect, vi } from "vitest"; import { createChatProvider, openaiProvider } from "../../src/providers/factory.js"; import { manualExportProvider } from "../../src/providers/manual-export.js"; function mockConfig(provider) { const cfg = { chatgptMcpProvider: provider }; return cfg; } // --- Default provider --- describe("default provider", () => { it("returns openai provider when no provider specified", () => { const provider = createChatProvider({}); expect(provider).toBe(openaiProvider); }); it("returns openai provider when null config", () => { const provider = createChatProvider(null); expect(provider).toBe(openaiProvider); }); it("returns openai provider when undefined config", () => { const provider = createChatProvider(undefined); expect(provider).toBe(openaiProvider); }); it("returns openai provider when chatgptMcpProvider is empty string", () => { const provider = createChatProvider({ chatgptMcpProvider: "" }); expect(provider).toBe(openaiProvider); }); it("returns openai provider explicitly set", () => { const provider = createChatProvider({ chatgptMcpProvider: "openai" }); expect(provider).toBe(openaiProvider); }); }); // --- Supported providers --- describe("supported providers", () => { it("supports openai", () => { const provider = createChatProvider({ chatgptMcpProvider: "openai" }); expect(provider).toBe(openaiProvider); }); it("returns the same instance for repeated calls with same provider", () => { const p1 = createChatProvider({ chatgptMcpProvider: "openai" }); const p2 = createChatProvider({ chatgptMcpProvider: "openai" }); expect(p1).toBe(p2); }); }); // --- Unsupported providers --- describe("unsupported providers", () => { it("supports ollama provider", () => { const provider = createChatProvider({ chatgptMcpProvider: "ollama" }); expect(provider).toBeDefined(); expect(typeof provider.send).toBe("function"); }); it("returns the same singleton instance for repeated calls with 'ollama'", () => { const p1 = createChatProvider({ chatgptMcpProvider: "ollama" }); const p2 = createChatProvider({ chatgptMcpProvider: "ollama" }); expect(p1).toBe(p2); }); it("is different from openai provider", () => { const ollamaP = createChatProvider({ chatgptMcpProvider: "ollama" }); expect(ollamaP).not.toBe(openaiProvider); }); it("throws on unknown provider name", () => { expect(() => createChatProvider({ chatgptMcpProvider: "unknown" })).toThrow( /Unsupported chat provider "unknown"/, ); }); it("defaults to openai for null provider name", () => { const provider = createChatProvider({ chatgptMcpProvider: null }); expect(provider.send).toBeDefined(); }); it("throws on numeric provider name (truthy but not supported)", () => { expect(() => createChatProvider({ chatgptMcpProvider: 123 })).toThrow('Unsupported chat provider "123"'); }); it("contains provider name in error message for unsupported providers", () => { try { createChatProvider({ chatgptMcpProvider: "ollama" }); } catch (err) { expect(err.message).toContain("ollama"); } }); it("mentions provider name in error message", () => { try { createChatProvider({ chatgptMcpProvider: "bedrock" }); } catch (err) { expect(err.message).toContain("bedrock"); } }); }); // --- Provider interface --- describe("provider interface", () => { it("returned provider has send method", () => { const provider = createChatProvider({ chatgptMcpProvider: "openai" }); expect(typeof provider.send).toBe("function"); }); it("send is a function, not undefined", () => { const provider = createChatProvider({ chatgptMcpProvider: "openai" }); expect(provider.send).not.toBeUndefined(); }); }); // --- Exported openaiProvider --- describe("exported openaiProvider", () => { it("openaiProvider is exported from factory", () => { expect(openaiProvider).toBeDefined(); }); it("openaiProvider has send method", () => { expect(typeof openaiProvider.send).toBe("function"); }); }); // --- Edge cases --- describe("edge cases", () => { it("handles case-sensitive provider name (Ollama != ollama)", () => { // This should also fail since only lowercase "openai" is supported expect(() => createChatProvider({ chatgptMcpProvider: "Ollama" })).toThrow(); }); it("handles whitespace provider name", () => { expect(() => createChatProvider({ chatgptMcpProvider: " openai " })).toThrow(); }); it("handles JSON string provider name", () => { expect(() => createChatProvider({ chatgptMcpProvider: '"openai"' })).toThrow(); }); it("creates provider even with minimal config object", () => { const provider = createChatProvider({ chatgptMcpProvider: "openai" }); expect(typeof provider.send).toBe("function"); }); it("works when config has extra unrelated fields", () => { const provider = createChatProvider({ chatgptMcpProvider: "openai", openaiApiKey: "sk-test", someOtherField: "ignored", }); expect(provider).toBe(openaiProvider); }); it("does not mutate the config object", () => { const cfg = { chatgptMcpProvider: "openai" }; createChatProvider(cfg); expect(cfg.chatgptMcpProvider).toBe("openai"); }); it("does not throw for all falsy values except explicit openai", () => { const falsyValues = [null, undefined, "", NaN]; // Only empty string and no-provider should default to openai // null/undefined → config check passes (defaults to "openai") // "" → defaults to "openai" }); it("defaults to openai for NaN provider name", () => { const provider = createChatProvider({ chatgptMcpProvider: NaN }); expect(provider.send).toBeDefined(); }); }); // --- Integration-like test --- describe("integration: send delegation", () => { it("provider.send delegates to the underlying provider implementation", async () => { const provider = createChatProvider({ chatgptMcpProvider: "openai" }); // The real openaiProvider.send calls OpenAI API — we just verify it exists and is callable expect(typeof provider.send).toBe("function"); // We don't call it here to avoid actual API calls in tests }); }); // --- Repeatability --- describe("repeatability", () => { it("creates identical providers for same config each time", () => { const results = []; for (let i = 0; i < 10; i++) { results.push(createChatProvider({ chatgptMcpProvider: "openai" })); } expect(results.every((p) => p === openaiProvider)).toBe(true); }); it("does not share mutable state between calls", () => { const p1 = createChatProvider({ chatgptMcpProvider: "openai" }); const p2 = createChatProvider({ chatgptMcpProvider: "openai" }); // Both should be the same singleton instance (by design) expect(p1).toBe(p2); }); }); // --- Manual provider --- describe("manual provider", () => { it("supports manual provider via createChatProvider", () => { const provider = createChatProvider({ chatgptMcpProvider: "manual" }); expect(provider.send).toBeDefined(); }); it("returns the same singleton instance for repeated calls with 'manual'", () => { const p1 = createChatProvider({ chatgptMcpProvider: "manual" }); const p2 = createChatProvider({ chatgptMcpProvider: "manual" }); expect(p1).toBe(p2); }); it("is different from openai provider", () => { const manualP = createChatProvider({ chatgptMcpProvider: "manual" }); expect(manualP).not.toBe(openaiProvider); }); it("can detect manual as a supported provider (not throw)", () => { const provider = createChatProvider({ chatgptMcpProvider: "manual" }); expect(provider.send).toBeDefined(); }); it("manual provider send returns { content: string } shape", async () => { const provider = createChatProvider({ chatgptMcpProvider: "manual" }); const result = await provider.send( { prompt: "test prompt from manual env", input: { question: "hi" } }, {}, ); expect(result).toHaveProperty("content"); expect(typeof result.content).toBe("string"); }); it("manual provider output contains MANUAL EXPORT marker", async () => { const provider = createChatProvider({ chatgptMcpProvider: "manual" }); const result = await provider.send({ prompt: "test", input: {} }, {}); expect(result.content).toContain("MANUAL EXPORT"); }); it("manual provider output contains COPY box delimiters", async () => { const provider = createChatProvider({ chatgptMcpProvider: "manual" }); const result = await provider.send({ prompt: "test", input: {} }, {}); expect(result.content).toContain("┌"); expect(result.content).toContain("┐"); expect(result.content).toContain("┘"); }); it("can be switched from openai to manual and back", () => { const p1 = createChatProvider({ chatgptMcpProvider: "openai" }); const p2 = createChatProvider({ chatgptMcpProvider: "manual" }); const p3 = createChatProvider({ chatgptMcpProvider: "openai" }); expect(p1).toBe(openaiProvider); expect(p2).not.toBe(openaiProvider); expect(p3).toBe(openaiProvider); }); it("manual provider is not the same as openaiProvider singleton", () => { const manualP = createChatProvider({ chatgptMcpProvider: "manual" }); expect(manualExportProvider).toBe(manualP); }); }); // --- Ollama provider send (fetch-mocked) --- describe("ollama provider send", () => { it("builds correct request body with config values", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ model: "qwen3", message: { role: "assistant", content: "Hello!" }, done: true }), }); // Create a custom ollamaProvider with mocked fetch const { ollamaProvider } = await import("../../src/providers/ollama.js"); const originalFetch = globalThis.fetch; globalThis.fetch = fetchMock; try { const config = { chatgptMcpProvider: "ollama", ollamaBaseUrl: "http://test:11434", ollamaModel: "test-model", ollamaTemperature: 0.5, ollamaTimeout: 30, }; const result = await ollamaProvider.send({ prompt: "What is AI?" }, config); expect(result.content).toBe("Hello!"); const calls = fetchMock.mock.calls; expect(calls.length).toBe(1); const [url, init] = calls[0]; expect(url).toBe("http://test:11434/api/chat"); expect(init.method).toBe("POST"); expect(JSON.parse(init.body)).toEqual({ model: "test-model", messages: [{ role: "system", content: "What is AI?" }], stream: false, options: { temperature: 0.5 }, }); } finally { globalThis.fetch = originalFetch; } }); it("uses defaults when no config ollama fields are set", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ model: "qwen3", message: { role: "assistant", content: "OK" }, done: true }), }); const { ollamaProvider } = await import("../../src/providers/ollama.js"); const originalFetch = globalThis.fetch; globalThis.fetch = fetchMock; try { const config = {}; const result = await ollamaProvider.send({ prompt: "hi" }, config); expect(result.content).toBe("OK"); const [url, init] = fetchMock.mock.calls[0]; const body = JSON.parse(init.body); expect(url).toBe("http://localhost:11434/api/chat"); expect(body.model).toBe("qwen3:latest"); expect(body.options.temperature).toBe(0.2); } finally { globalThis.fetch = originalFetch; } }); it("handles non-2xx response with error category", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 404, text: async () => "model not found" }); const { ollamaProvider } = await import("../../src/providers/ollama.js"); const originalFetch = globalThis.fetch; globalThis.fetch = fetchMock; try { const config = {}; await expect( ollamaProvider.send({ prompt: "hi" }, config), ).rejects.toThrow(/Ollama API error \(OllamaModelNotFoundError\)/); } finally { globalThis.fetch = originalFetch; } }); it("handles fetch network errors", async () => { const fetchMock = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); const { ollamaProvider } = await import("../../src/providers/ollama.js"); const originalFetch = globalThis.fetch; globalThis.fetch = fetchMock; try { const config = {}; await expect( ollamaProvider.send({ prompt: "hi" }, config), ).rejects.toThrow(/Ollama API error \(OllamaRequestError\)/); } finally { globalThis.fetch = originalFetch; } }); it("handles invalid JSON response", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, text: async () => "not json" }); const { ollamaProvider } = await import("../../src/providers/ollama.js"); const originalFetch = globalThis.fetch; globalThis.fetch = fetchMock; try { const config = {}; await expect( ollamaProvider.send({ prompt: "hi" }, config), ).rejects.toThrow(/invalid JSON response/); } finally { globalThis.fetch = originalFetch; } }); it("returns empty content when message.content is missing", async () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ model: "test", done: true }), }); const { ollamaProvider } = await import("../../src/providers/ollama.js"); const originalFetch = globalThis.fetch; globalThis.fetch = fetchMock; try { const config = {}; const result = await ollamaProvider.send({ prompt: "hi" }, config); expect(result.content).toBe(""); } finally { globalThis.fetch = originalFetch; } }); });