nexus/server/src/__tests__/ollama-service.test.ts
Nexus Dev f052066f58 feat(28-01): Ollama service, routes, model catalog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:57:27 +00:00

227 lines
6.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { detectOllama, listOllamaModels, getRecommendedModel } from "../services/ollama.js";
import type { OllamaModel } from "../services/ollama.js";
describe("detectOllama", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns installed:true + version when Ollama responds at /api/version", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ version: "0.5.1" }),
});
vi.stubGlobal("fetch", mockFetch);
const result = await detectOllama();
expect(result.installed).toBe(true);
expect(result.version).toBe("0.5.1");
expect(result.installUrl).toBe("https://ollama.com/download");
});
it("returns installed:false + installUrl when Ollama is absent (ECONNREFUSED)", async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
vi.stubGlobal("fetch", mockFetch);
const result = await detectOllama();
expect(result.installed).toBe(false);
expect(result.version).toBeNull();
expect(result.installUrl).toBe("https://ollama.com/download");
});
it("returns installed:false when fetch times out (AbortController)", async () => {
const mockFetch = vi.fn().mockImplementation((_url: string, opts: { signal?: AbortSignal }) => {
return new Promise<never>((_resolve, reject) => {
if (opts?.signal) {
opts.signal.addEventListener("abort", () => {
reject(new DOMException("The operation was aborted.", "AbortError"));
});
}
// Never resolves — simulates timeout
});
});
vi.stubGlobal("fetch", mockFetch);
const result = await detectOllama();
expect(result.installed).toBe(false);
expect(result.version).toBeNull();
expect(result.installUrl).toBe("https://ollama.com/download");
}, 10000);
it("returns installed:false when response is not ok", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 503,
});
vi.stubGlobal("fetch", mockFetch);
const result = await detectOllama();
expect(result.installed).toBe(false);
expect(result.version).toBeNull();
});
});
describe("listOllamaModels", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns OllamaModel[] mapped from /api/tags response", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
models: [
{
name: "qwen2.5-coder:32b",
model: "qwen2.5-coder:32b",
modified_at: "2026-01-01T00:00:00Z",
size: 23123456789,
digest: "abc123",
details: {
parent_model: "",
format: "gguf",
family: "qwen2",
families: ["qwen2"],
parameter_size: "32.8B",
quantization_level: "Q4_K_M",
},
},
{
name: "llama3.1:8b",
model: "llama3.1:8b",
modified_at: "2026-01-01T00:00:00Z",
size: 4500000000,
digest: "def456",
details: {
parent_model: "",
format: "gguf",
family: "llama",
families: ["llama"],
parameter_size: "8.0B",
quantization_level: "Q4_K_M",
},
},
],
}),
});
vi.stubGlobal("fetch", mockFetch);
const models = await listOllamaModels();
expect(models).toHaveLength(2);
expect(models[0].name).toBe("qwen2.5-coder:32b");
expect(models[0].parameterSize).toBe("32.8B");
expect(models[0].quantization).toBe("Q4_K_M");
expect(models[0].sizeBytes).toBe(23123456789);
expect(models[0].family).toBe("qwen2");
expect(models[0].recommended).toBe(false);
expect(models[0].recommendationReason).toBeNull();
expect(models[1].name).toBe("llama3.1:8b");
expect(models[1].family).toBe("llama");
});
it("returns empty array when Ollama is absent", async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
vi.stubGlobal("fetch", mockFetch);
const models = await listOllamaModels();
expect(models).toEqual([]);
});
it("returns empty array when response is not ok", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 503,
});
vi.stubGlobal("fetch", mockFetch);
const models = await listOllamaModels();
expect(models).toEqual([]);
});
});
describe("getRecommendedModel", () => {
const makeModel = (name: string, family: string): OllamaModel => ({
name,
parameterSize: "7B",
quantization: "Q4_K_M",
sizeBytes: 4000000000,
family,
recommended: false,
recommendationReason: null,
});
it("recommends a 7b-class model when system RAM is 8GB", () => {
const models: OllamaModel[] = [
makeModel("qwen2.5-coder:7b", "qwen2"),
makeModel("qwen2.5-coder:32b", "qwen2"),
];
const ramBytes = 8 * 1024 * 1024 * 1024; // 8GB
const result = getRecommendedModel(models, ramBytes);
const recommended = result.filter((m) => m.recommended);
expect(recommended).toHaveLength(1);
expect(recommended[0].name).toBe("qwen2.5-coder:7b");
expect(recommended[0].recommendationReason).not.toBeNull();
});
it("recommends a 32b-class model when system RAM is 32GB", () => {
const models: OllamaModel[] = [
makeModel("qwen2.5-coder:7b", "qwen2"),
makeModel("qwen2.5-coder:32b", "qwen2"),
];
const ramBytes = 32 * 1024 * 1024 * 1024; // 32GB
const result = getRecommendedModel(models, ramBytes);
const recommended = result.filter((m) => m.recommended);
expect(recommended).toHaveLength(1);
expect(recommended[0].name).toBe("qwen2.5-coder:32b");
expect(recommended[0].recommendationReason).not.toBeNull();
});
it("returns recommended=false for all models not in catalog", () => {
const models: OllamaModel[] = [
makeModel("unknown-model:7b", "unknown"),
makeModel("another-unknown:13b", "mystery"),
];
const ramBytes = 64 * 1024 * 1024 * 1024; // 64GB — plenty of RAM
const result = getRecommendedModel(models, ramBytes);
expect(result.every((m) => !m.recommended)).toBe(true);
});
it("returns empty array when no models provided", () => {
const result = getRecommendedModel([], 16 * 1024 * 1024 * 1024);
expect(result).toEqual([]);
});
it("does not recommend models that exceed 75% of system RAM", () => {
// 4GB RAM — 75% = 3GB. qwen2.5-coder:7b needs 5GB, should NOT be recommended
const models: OllamaModel[] = [
makeModel("qwen2.5-coder:7b", "qwen2"),
];
const ramBytes = 4 * 1024 * 1024 * 1024; // 4GB
const result = getRecommendedModel(models, ramBytes);
expect(result.every((m) => !m.recommended)).toBe(true);
});
});