259 lines
10 KiB
Python
259 lines
10 KiB
Python
# #region Test.Api.Llm.Edge [C:3] [TYPE Module] [SEMANTICS test,llm,routes,edge,coverage]
|
|
# @BRIEF Edge-case tests for LLM API routes — covers uncovered branches from llm.py.
|
|
# @RELATION BINDS_TO -> [LlmRoutes]
|
|
# @TEST_EDGE: is_valid_key_placeholder -> _is_valid_runtime_api_key returns False for ********
|
|
# @TEST_EDGE: is_valid_key_too_short -> _is_valid_runtime_api_key returns False for short keys
|
|
# @TEST_EDGE: fetch_models_invalid_provider_type -> 400 when provider_type is invalid enum
|
|
# @TEST_EDGE: test_provider_config_exception -> connection failure captured as success=false
|
|
# @TEST_EDGE: probe_max_images_binary_search -> binary search between last_ok and first_fail
|
|
# @TEST_EDGE: get_providers_no_decrypted_key -> empty api_key when no stored key
|
|
|
|
import os
|
|
|
|
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
|
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
|
|
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests")
|
|
os.environ.setdefault("DEV_MODE", "true")
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
|
|
if _src not in sys.path:
|
|
sys.path.insert(0, _src)
|
|
|
|
P = "/api/llm"
|
|
|
|
|
|
def _make_provider(**kw):
|
|
p = MagicMock()
|
|
p.id = kw.get("id", "prov-1")
|
|
p.provider_type = kw.get("provider_type", "openai")
|
|
p.name = kw.get("name", "Test Provider")
|
|
p.base_url = kw.get("base_url", "https://api.openai.com")
|
|
p.api_key = kw.get("api_key", "sk-test")
|
|
p.default_model = kw.get("default_model", "gpt-4")
|
|
p.is_active = kw.get("is_active", True)
|
|
p.is_multimodal = kw.get("is_multimodal", True)
|
|
p.max_images = kw.get("max_images", 10)
|
|
p.context_window = kw.get("context_window", 128000)
|
|
p.max_output_tokens = kw.get("max_output_tokens", 4096)
|
|
return p
|
|
|
|
|
|
def _make_client(overrides: dict | None = None) -> TestClient:
|
|
from src.api.routes.llm import router
|
|
from src.core.database import get_db
|
|
from src.dependencies import get_current_user
|
|
from src.schemas.auth import User, RoleSchema
|
|
|
|
app = FastAPI()
|
|
app.include_router(router, prefix=P)
|
|
|
|
mock_user = User(
|
|
id="admin-1", username="admin", email="admin@x.com",
|
|
auth_source="LOCAL",
|
|
created_at=__import__("datetime").datetime.now(),
|
|
roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])],
|
|
)
|
|
|
|
app.dependency_overrides[get_db] = lambda: MagicMock()
|
|
app.dependency_overrides[get_current_user] = lambda: mock_user
|
|
if overrides:
|
|
for dep, fn in overrides.items():
|
|
app.dependency_overrides[dep] = fn
|
|
return TestClient(app)
|
|
|
|
|
|
# ── _is_valid_runtime_api_key edge cases ──────────────────────
|
|
|
|
|
|
class TestIsValidRuntimeApiKey:
|
|
"""Direct test of _is_valid_runtime_api_key edge cases."""
|
|
|
|
# #region test_valid_key_placeholder [C:2] [TYPE Function]
|
|
# @BRIEF "********" returns False.
|
|
def test_valid_key_placeholder(self):
|
|
from src.api.routes.llm import _is_valid_runtime_api_key
|
|
assert _is_valid_runtime_api_key("********") is False
|
|
# #endregion test_valid_key_placeholder
|
|
|
|
# #region test_valid_key_empty_or_none [C:2] [TYPE Function]
|
|
# @BRIEF "EMPTY_OR_NONE" returns False.
|
|
def test_valid_key_empty_or_none(self):
|
|
from src.api.routes.llm import _is_valid_runtime_api_key
|
|
assert _is_valid_runtime_api_key("EMPTY_OR_NONE") is False
|
|
# #endregion test_valid_key_empty_or_none
|
|
|
|
# #region test_valid_key_too_short [C:2] [TYPE Function]
|
|
# @BRIEF Key shorter than 16 chars returns False.
|
|
def test_valid_key_too_short(self):
|
|
from src.api.routes.llm import _is_valid_runtime_api_key
|
|
assert _is_valid_runtime_api_key("short") is False
|
|
# #endregion test_valid_key_too_short
|
|
|
|
# #region test_valid_key_none [C:2] [TYPE Function]
|
|
# @BRIEF None returns False.
|
|
def test_valid_key_none(self):
|
|
from src.api.routes.llm import _is_valid_runtime_api_key
|
|
assert _is_valid_runtime_api_key(None) is False
|
|
# #endregion test_valid_key_none
|
|
|
|
|
|
# ── get_providers — no decrypted key ──────────────────────────
|
|
|
|
|
|
class TestGetProvidersEdge:
|
|
"""GET /api/llm/providers — edge paths."""
|
|
|
|
# #region test_get_providers_no_api_key [C:2] [TYPE Function]
|
|
# @BRIEF Provider has no stored api_key -> returns masked api_key.
|
|
def test_get_providers_no_api_key(self):
|
|
mock_db = MagicMock()
|
|
mock_svc = MagicMock()
|
|
p = _make_provider(api_key="")
|
|
mock_svc.get_all_providers.return_value = [p]
|
|
|
|
from src.core.database import get_db
|
|
with patch("src.api.routes.llm.LLMProviderService", return_value=mock_svc):
|
|
client = _make_client({get_db: lambda: mock_db})
|
|
resp = client.get(f"{P}/providers")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data) == 1
|
|
assert data[0]["api_key"] == "" # no key -> empty string
|
|
# #endregion test_get_providers_no_api_key
|
|
|
|
|
|
# ── fetch_models — invalid provider_type ──────────────────────
|
|
|
|
|
|
class TestFetchModelsEdge:
|
|
"""POST /api/llm/providers/fetch-models — edge paths."""
|
|
|
|
# #region test_fetch_models_invalid_provider_type [C:2] [TYPE Function]
|
|
# @BRIEF Invalid provider_type string -> 400.
|
|
def test_fetch_models_invalid_provider_type(self):
|
|
client = _make_client()
|
|
resp = client.post(f"{P}/providers/fetch-models", json={
|
|
"base_url": "https://api.example.com",
|
|
"provider_type": "nonexistent_type",
|
|
"api_key": "sk-test",
|
|
})
|
|
assert resp.status_code == 400
|
|
assert "Invalid provider_type" in resp.json()["detail"]
|
|
# #endregion test_fetch_models_invalid_provider_type
|
|
|
|
# #region test_fetch_models_masked_key [C:2] [TYPE Function]
|
|
# @BRIEF Masked API key treated as empty -> requires provider_id fallback -> 400.
|
|
def test_fetch_models_masked_key(self):
|
|
client = _make_client()
|
|
resp = client.post(f"{P}/providers/fetch-models", json={
|
|
"base_url": "https://api.example.com",
|
|
"provider_type": "openai",
|
|
"api_key": "********",
|
|
})
|
|
# Masked key is treated as empty, and no provider_id given -> 400
|
|
assert resp.status_code == 400
|
|
assert "api_key or provider_id" in resp.json()["detail"]
|
|
# #endregion test_fetch_models_masked_key
|
|
|
|
|
|
# ── test_provider_config — exception path ─────────────────────
|
|
|
|
|
|
class TestTestProviderConfigEdge:
|
|
"""POST /api/llm/providers/test — edge paths."""
|
|
|
|
# #region test_test_provider_config_exception [C:2] [TYPE Function]
|
|
# @BRIEF LLMClient raises -> success=false with error message.
|
|
def test_test_provider_config_exception(self):
|
|
mock_client = MagicMock()
|
|
mock_client.test_runtime_connection = AsyncMock(side_effect=RuntimeError("Connection refused"))
|
|
|
|
with patch("src.plugins.llm_analysis.service.LLMClient", return_value=mock_client):
|
|
client = _make_client()
|
|
resp = client.post(f"{P}/providers/test", json={
|
|
"provider_type": "openai", "name": "T", "base_url": "https://x.com",
|
|
"api_key": "sk-test", "default_model": "gpt-4",
|
|
})
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["success"] is False
|
|
assert "Connection refused" in data["error"]
|
|
# #endregion test_test_provider_config_exception
|
|
|
|
|
|
# ── probe_max_images — binary search ──────────────────────────
|
|
|
|
|
|
class TestProbeMaxImagesEdge:
|
|
"""POST /api/llm/providers/{provider_id}/probe-max-images — binary search path."""
|
|
|
|
# #region test_probe_max_images_binary_search [C:2] [TYPE Function]
|
|
# @BRIEF 2 images OK, 4 images fail -> binary search between 2 and 4.
|
|
def test_probe_max_images_binary_search(self):
|
|
mock_db = MagicMock()
|
|
mock_svc = MagicMock()
|
|
mock_svc.get_provider.return_value = _make_provider()
|
|
mock_svc.get_decrypted_api_key.return_value = "sk-real-16chars-minimum"
|
|
mock_openai = MagicMock()
|
|
mock_openai.chat.completions = MagicMock()
|
|
|
|
# 1, 2 OK; 4, 8, 16, 32, 64 fail with unknown error
|
|
call_count = [0]
|
|
results = [True, True, False, False, False, False, False]
|
|
|
|
async def _mock_create(**kwargs):
|
|
idx = call_count[0]
|
|
call_count[0] += 1
|
|
if idx < len(results) and results[idx]:
|
|
return MagicMock()
|
|
raise RuntimeError("unsupported")
|
|
|
|
mock_openai.chat.completions.create = _mock_create
|
|
|
|
from src.core.database import get_db
|
|
with (
|
|
patch("src.api.routes.llm.LLMProviderService", return_value=mock_svc),
|
|
patch("openai.AsyncOpenAI", return_value=mock_openai),
|
|
):
|
|
client = _make_client({get_db: lambda: mock_db})
|
|
resp = client.post(f"{P}/providers/prov-1/probe-max-images")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
# 2 was last OK, 4 was first fail -> binary search -> lo=2, hi=4 -> 3 tested -> 3 fails -> lo=2
|
|
assert data["method"] == "binary_search"
|
|
assert data["max_images"] == 2
|
|
# #endregion test_probe_max_images_binary_search
|
|
|
|
# #region test_probe_max_images_all_succeed [C:2] [TYPE Function]
|
|
# @BRIEF All probe sizes succeed (1..64 all OK) -> no_limit_detected.
|
|
def test_probe_max_images_all_succeed(self):
|
|
mock_db = MagicMock()
|
|
mock_svc = MagicMock()
|
|
mock_svc.get_provider.return_value = _make_provider(default_model="gpt-4")
|
|
mock_svc.get_decrypted_api_key.return_value = "sk-real-16chars-minimum"
|
|
mock_openai = MagicMock()
|
|
mock_openai.chat.completions = MagicMock()
|
|
mock_openai.chat.completions.create = AsyncMock(return_value=MagicMock())
|
|
|
|
from src.core.database import get_db
|
|
with (
|
|
patch("src.api.routes.llm.LLMProviderService", return_value=mock_svc),
|
|
patch("openai.AsyncOpenAI", return_value=mock_openai),
|
|
):
|
|
client = _make_client({get_db: lambda: mock_db})
|
|
resp = client.post(f"{P}/providers/prov-1/probe-max-images")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["method"] == "no_limit_detected"
|
|
assert data["max_images"] is None
|
|
# #endregion test_probe_max_images_all_succeed
|
|
# #endregion Test.Api.Llm.Edge
|