Files
VoiceStudio/tests/test_llm_endpoint_settings.py
3bb401f4e5 test: make LLM-provider state leaks between tests impossible (#878) (#894)
Root cause: LLM provider selection reads three process-global surfaces —
env vars (LLM_DEFAULT_PROVIDER, per-provider *_API_KEY/*_BASE_URL,
TRANSLATE_*), the SQLite settings store (llm.active_provider & co.), and
prefs.json (llm_backend). Importing `main` (TestClient fixtures do)
dotenv-loads the developer's .env and ~/.config/omnivoice/env straight
into os.environ, and several tests/endpoints mutate these surfaces
without teardown — so whichever test imported the app first flipped what
later tests' active_backend_id()/active_provider_id() resolved to
(order-dependent failures in test_engines.py,
test_llm_endpoint_settings.py, test_llm_providers.py).

Fix the class, not the instances:
- tests/conftest.py: redirect OMNIVOICE_DATA_DIR to a per-session tmp dir
  and OMNIVOICE_ENV_FILE into it (before collection freezes
  core.config.DATA_DIR), so tests never read or write the developer's
  real app state and local runs behave like clean CI.
- tests/conftest.py: autouse `_isolate_llm_provider_state` fixture
  snapshots env (derived from llm_providers._PROVIDERS, so new providers
  are guarded automatically), llm.* / secret.llm_key.* settings rows, and
  the prefs llm_backend/env.TRANSLATE* keys before every test and
  restores them exactly afterwards.
- shared `clean_llm_env` fixture clears the FULL provider env surface;
  the four LLM test modules' hand-picked partial delenv lists (which left
  e.g. LLM_DEFAULT_PROVIDER / OPENROUTER_API_KEY standing) now use it.
- tests/test_llm_state_isolation.py: deterministic fail-before/pass-after
  regression pair — pollutes all three surfaces without cleanup, then
  asserts the guard restored them.

Verified: the issue's two-test repro passes; the five LLM-related test
files pass in order; full suite green (2046 passed, 20 skipped,
10 xfailed, 4 xpassed).

Fixes #878

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 19:06:14 +05:30

91 lines
3.4 KiB
Python

"""LLM endpoint settings (Wave 2.4) — GET/PUT /api/settings/llm-endpoint.
Persistence rides the TRANSLATE_* env vars; these tests assert the read
shape, masking, and the set/unchanged/clear semantics, with prefs writes
stubbed so nothing touches the real prefs.json.
"""
import os
os.environ.setdefault("OMNIVOICE_MODEL", "test")
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
import importlib
import pytest
# `openai` is an optional (translator-path) dependency. is_available() short-
# circuits to False without it, so availability assertions are guarded.
_HAS_OPENAI = importlib.util.find_spec("openai") is not None
@pytest.fixture
def settings_mod(monkeypatch, clean_llm_env):
# Stub prefs persistence so PUT doesn't write the developer's prefs.json.
# clean_llm_env clears the full LLM-provider env surface (not just the
# TRANSLATE_* quartet) so 'empty state' really is empty even when an
# earlier `main` import dotenv-loaded provider keys into os.environ (#878).
import core.prefs as prefs
monkeypatch.setattr(prefs, "set_", lambda *a, **k: None)
monkeypatch.setattr(prefs, "delete", lambda *a, **k: None)
return importlib.import_module("api.routers.settings")
def test_get_empty_state(settings_mod):
state = settings_mod.get_llm_endpoint()
assert state["base_url"] == ""
assert state["model"] == ""
assert state["api_key_masked"] is None
assert state["available"] is False
assert state["reason"]
def test_put_sets_and_masks(settings_mod):
body = settings_mod._LLMEndpointBody(
base_url="http://localhost:11434/v1", model="llama3.1", api_key="sk-secret-1234"
)
state = settings_mod.set_llm_endpoint(body)
assert os.environ["TRANSLATE_BASE_URL"] == "http://localhost:11434/v1"
assert os.environ["TRANSLATE_MODEL"] == "llama3.1"
assert os.environ["TRANSLATE_API_KEY"] == "sk-secret-1234"
assert state["api_key_masked"] == "…1234"
if _HAS_OPENAI:
assert state["available"] is True # base_url + key → ready
def test_put_none_field_leaves_unchanged(settings_mod):
settings_mod.set_llm_endpoint(
settings_mod._LLMEndpointBody(base_url="http://x/v1", model="m", api_key="key123456")
)
# api_key omitted (None) — must not clear it.
settings_mod.set_llm_endpoint(
settings_mod._LLMEndpointBody(base_url="http://y/v1", model="m2")
)
assert os.environ["TRANSLATE_BASE_URL"] == "http://y/v1"
assert os.environ["TRANSLATE_API_KEY"] == "key123456"
def test_put_empty_string_clears(settings_mod):
settings_mod.set_llm_endpoint(
settings_mod._LLMEndpointBody(base_url="http://x/v1", api_key="key123456")
)
settings_mod.set_llm_endpoint(settings_mod._LLMEndpointBody(api_key=""))
assert "TRANSLATE_API_KEY" not in os.environ
def test_local_base_url_is_available_without_key(settings_mod):
# A local base_url makes the backend usable even with no key (Ollama):
# is_available()'s api_key falls back to "local" when a base_url is set.
state = settings_mod.set_llm_endpoint(
settings_mod._LLMEndpointBody(base_url="http://localhost:11434/v1", model="llama3.1")
)
assert state["api_key_masked"] is None
if _HAS_OPENAI:
assert state["available"] is True
def test_short_key_masks_to_set(settings_mod):
state = settings_mod.set_llm_endpoint(
settings_mod._LLMEndpointBody(base_url="http://x/v1", api_key="abc")
)
assert state["api_key_masked"] == "set"