201 lines
7.7 KiB
Python
201 lines
7.7 KiB
Python
"""
|
|
Tests for the Literal[...] constraint on CreateCredentialRequest.provider
|
|
(api/models.py). Previously `provider: str` accepted any string; a typo'd
|
|
or bogus provider would flow through to the domain layer and fail later
|
|
with a less clear error instead of a clean 422 at the API boundary.
|
|
|
|
Also the sync-enforcement for the provider registry
|
|
(open_notebook/ai/provider_registry.py): the registry is the backend
|
|
source of truth, and the one remaining manual copy (the
|
|
SupportedProvider Literal) must match it exactly. The frontend consumes
|
|
GET /api/providers at runtime, so it needs no cross-check.
|
|
"""
|
|
|
|
from typing import get_args
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from pydantic import ValidationError
|
|
|
|
from api.models import CreateCredentialRequest, SupportedProvider
|
|
from open_notebook.ai.provider_registry import PROVIDERS
|
|
|
|
KNOWN_GOOD_PROVIDERS = [
|
|
"openai",
|
|
"anthropic",
|
|
"google",
|
|
"groq",
|
|
"mistral",
|
|
"deepseek",
|
|
"xai",
|
|
"openrouter",
|
|
"dashscope",
|
|
"minimax",
|
|
"voyage",
|
|
"elevenlabs",
|
|
"deepgram",
|
|
"ollama",
|
|
"azure",
|
|
"vertex",
|
|
"openai_compatible",
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
from api.main import app
|
|
|
|
return TestClient(app)
|
|
|
|
|
|
class TestProviderRegistryIsTheSourceOfTruth:
|
|
"""The registry drives every backend surface; the Literal is the only
|
|
manual copy and must match it."""
|
|
|
|
def test_literal_matches_registry_keys(self):
|
|
assert set(get_args(SupportedProvider)) == set(PROVIDERS.keys())
|
|
|
|
def test_registry_matches_known_good_provider_list(self):
|
|
assert set(PROVIDERS.keys()) == set(KNOWN_GOOD_PROVIDERS)
|
|
|
|
def test_registry_specs_are_internally_consistent(self):
|
|
for name, spec in PROVIDERS.items():
|
|
assert spec.name == name
|
|
assert spec.display_name
|
|
assert spec.modalities, f"{name} has no modalities"
|
|
assert spec.required_env or spec.required_any_env, (
|
|
f"{name} has no env var configuration"
|
|
)
|
|
if spec.openai_compat_discovery_url:
|
|
# The discovery table takes the API key from required_env[0]
|
|
assert len(spec.required_env) == 1, (
|
|
f"{name} uses openai-compat discovery but does not have "
|
|
f"exactly one required env var"
|
|
)
|
|
|
|
def test_discovery_functions_cover_registry(self):
|
|
from open_notebook.ai.model_discovery import PROVIDER_DISCOVERY_FUNCTIONS
|
|
|
|
assert set(PROVIDER_DISCOVERY_FUNCTIONS.keys()) == set(PROVIDERS.keys())
|
|
|
|
def test_registry_rejects_duplicate_provider_names(self):
|
|
"""A plain dict comprehension would silently drop the earlier spec
|
|
on a name collision; the registry builder must raise instead."""
|
|
from open_notebook.ai.provider_registry import (
|
|
ProviderSpec,
|
|
_build_registry,
|
|
)
|
|
|
|
duplicate = (
|
|
ProviderSpec(name="dupe", display_name="Dupe A", modalities=("language",)),
|
|
ProviderSpec(name="dupe", display_name="Dupe B", modalities=("language",)),
|
|
)
|
|
with pytest.raises(ValueError, match="Duplicate provider name"):
|
|
_build_registry(duplicate)
|
|
|
|
def test_openai_compat_discovery_urls_are_exactly_as_expected(self):
|
|
"""Pin the derived provider -> discovery URL mapping so a registry
|
|
edit can't silently drop or misassign a URL (both the model_discovery
|
|
table and the credentials_service url_map are built from these)."""
|
|
from open_notebook.ai.model_discovery import OPENAI_COMPAT_PROVIDERS
|
|
|
|
expected = {
|
|
"openai": "https://api.openai.com/v1/models",
|
|
"groq": "https://api.groq.com/openai/v1/models",
|
|
"mistral": "https://api.mistral.ai/v1/models",
|
|
"deepseek": "https://api.deepseek.com/models",
|
|
"xai": "https://api.x.ai/v1/models",
|
|
"openrouter": "https://openrouter.ai/api/v1/models",
|
|
"dashscope": "https://dashscope.aliyuncs.com/compatible-mode/v1/models",
|
|
"minimax": "https://api.minimax.io/v1/models",
|
|
}
|
|
assert {
|
|
name: spec.openai_compat_discovery_url
|
|
for name, spec in PROVIDERS.items()
|
|
if spec.openai_compat_discovery_url
|
|
} == expected
|
|
assert {
|
|
name: spec.url for name, spec in OPENAI_COMPAT_PROVIDERS.items()
|
|
} == expected
|
|
|
|
|
|
class TestSupportedProviderMatchesOtherSourcesOfTruth:
|
|
def test_matches_known_good_provider_list(self):
|
|
assert set(get_args(SupportedProvider)) == set(KNOWN_GOOD_PROVIDERS)
|
|
|
|
def test_matches_connection_tester_test_models_keys(self):
|
|
from open_notebook.ai.connection_tester import TEST_MODELS
|
|
|
|
assert set(get_args(SupportedProvider)) == set(TEST_MODELS.keys())
|
|
|
|
def test_matches_credentials_service_provider_env_config_keys(self):
|
|
from api.credentials_service import PROVIDER_ENV_CONFIG
|
|
|
|
assert set(get_args(SupportedProvider)) == set(PROVIDER_ENV_CONFIG.keys())
|
|
|
|
|
|
class TestProvidersEndpoint:
|
|
def test_get_providers_returns_registry_metadata(self, client):
|
|
response = client.get("/api/providers")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
# The frontend renders providers in response order, so the endpoint
|
|
# must preserve the registry declaration order (the display order).
|
|
assert [p["name"] for p in data] == list(PROVIDERS.keys())
|
|
|
|
openai = next(p for p in data if p["name"] == "openai")
|
|
assert openai["display_name"] == "OpenAI"
|
|
assert "language" in openai["modalities"]
|
|
assert openai["docs_url"].startswith("https://")
|
|
assert isinstance(openai["env_configured"], bool)
|
|
|
|
|
|
class TestCreateCredentialRequestValidation:
|
|
@pytest.mark.parametrize("provider", KNOWN_GOOD_PROVIDERS)
|
|
def test_accepts_every_known_provider(self, provider):
|
|
request = CreateCredentialRequest(name="Test", provider=provider)
|
|
assert request.provider == provider
|
|
|
|
@pytest.mark.parametrize(
|
|
"bad_provider",
|
|
["openai ", " openai", "OpenAI", "opnai", "not_a_real_provider", "", "sqlite"],
|
|
)
|
|
def test_rejects_unknown_or_malformed_provider(self, bad_provider):
|
|
with pytest.raises(ValidationError):
|
|
CreateCredentialRequest(name="Test", provider=bad_provider)
|
|
|
|
|
|
class TestCreateCredentialEndpointRejectsBadProvider:
|
|
def test_post_credentials_with_bogus_provider_returns_422(self, client):
|
|
response = client.post(
|
|
"/api/credentials",
|
|
json={"name": "Test", "provider": "not_a_real_provider"},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
def test_post_credentials_with_valid_provider_passes_validation(
|
|
self, client, monkeypatch
|
|
):
|
|
"""Doesn't assert overall success - just that a valid provider clears
|
|
the 422 validation gate and reaches actual route logic.
|
|
|
|
The encryption-key gate is forced to fail so the request never reaches
|
|
the persistence layer: without this, running the suite with a real
|
|
`.env` (SURREAL_URL + encryption key set) wrote a "Test" credential to
|
|
the live dev database on every run.
|
|
"""
|
|
from api.routers import credentials as credentials_router
|
|
|
|
def _no_key():
|
|
raise ValueError("encryption key intentionally absent in tests")
|
|
|
|
monkeypatch.setattr(credentials_router, "require_encryption_key", _no_key)
|
|
response = client.post(
|
|
"/api/credentials",
|
|
json={"name": "Test", "provider": "openai", "api_key": "sk-test"},
|
|
)
|
|
# Provider validation happens before the route body runs; the forced
|
|
# key failure maps to a non-422 error, proving validation was cleared
|
|
# without persisting anything.
|
|
assert response.status_code != 422
|