test: add comprehensive test suite with 89% coverage
- Add tests for all backend modules: config, database, models, services, routes, middleware, and providers - Separate dev dependencies using dependency-groups - Update CLAUDE.md with test commands and project structure - Add .coverage and .pytest_cache to gitignore 88 tests covering: - Authentication and authorization - Model routing and key selection - Async logging with batch processing - All API endpoints (chat, openai, anthropic, health) - LiteLLM wrapper (mocked external calls) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
37
CLAUDE.md
37
CLAUDE.md
@@ -10,8 +10,23 @@ zzrouter is an AI model API proxy service with OpenAI-compatible and Anthropic s
|
|||||||
|
|
||||||
```
|
```
|
||||||
zzrouter/
|
zzrouter/
|
||||||
├── backend/ # Python FastAPI proxy server
|
├── backend/ # Python FastAPI proxy server
|
||||||
└── frontend/ # (placeholder)
|
│ ├── app/ # Application code
|
||||||
|
│ │ ├── middleware/ # Request/response middleware
|
||||||
|
│ │ ├── models/ # Database models
|
||||||
|
│ │ ├── providers/ # AI provider clients (OpenAI, Anthropic)
|
||||||
|
│ │ ├── routes/ # API route handlers
|
||||||
|
│ │ ├── services/ # Business logic
|
||||||
|
│ │ ├── config.py # Configuration settings
|
||||||
|
│ │ ├── database.py # Database setup and utilities
|
||||||
|
│ │ └── main.py # FastAPI app entry point
|
||||||
|
│ ├── data/ # SQLite database files
|
||||||
|
│ ├── tests/ # Test files
|
||||||
|
│ ├── main.py # Server entry point
|
||||||
|
│ └── pyproject.toml # Python dependencies
|
||||||
|
├── frontend/ # Frontend (superpowers skill files only)
|
||||||
|
├── docs/ # Documentation
|
||||||
|
└── .playwright/ # Playwright browser automation
|
||||||
```
|
```
|
||||||
|
|
||||||
## Backend Commands
|
## Backend Commands
|
||||||
@@ -19,13 +34,25 @@ zzrouter/
|
|||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies (production only)
|
||||||
uv sync
|
uv sync
|
||||||
|
|
||||||
# Run development server
|
# Install dependencies (with dev tools)
|
||||||
|
uv sync --group dev
|
||||||
|
|
||||||
|
# Run development server (with auto-reload)
|
||||||
uv run uvicorn app.main:app --reload --port 8000
|
uv run uvicorn app.main:app --reload --port 8000
|
||||||
|
|
||||||
# Run directly
|
# Run production server
|
||||||
|
uv run uvicorn app.main:app --port 8000
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
uv run pytest -v
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
uv run pytest -v --cov=app --cov-report=term-missing
|
||||||
|
|
||||||
|
# Alternative: run directly
|
||||||
uv run python main.py
|
uv run python main.py
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
6
backend/.gitignore
vendored
6
backend/.gitignore
vendored
@@ -8,3 +8,9 @@ wheels/
|
|||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
|
backend/data/
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
|||||||
@@ -14,3 +14,24 @@ dependencies = [
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
zzrouter = "app.main:run_server"
|
zzrouter = "app.main:run_server"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["app"]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=9.0.2",
|
||||||
|
"pytest-asyncio>=0.23.0",
|
||||||
|
"pytest-cov>=4.1.0",
|
||||||
|
"pytest-mock>=3.12.0",
|
||||||
|
"httpx>=0.26.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|||||||
1
backend/tests/__init__.py
Normal file
1
backend/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for zzrouter backend."""
|
||||||
168
backend/tests/conftest.py
Normal file
168
backend/tests/conftest.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""Test configuration and shared fixtures."""
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
import aiosqlite
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
# Test database path
|
||||||
|
TEST_DB_PATH = Path(__file__).parent / "data" / "test.db"
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def get_test_db():
|
||||||
|
"""Get in-memory test database connection."""
|
||||||
|
conn = await aiosqlite.connect(":memory:")
|
||||||
|
conn.row_factory = aiosqlite.Row
|
||||||
|
await _init_test_schema(conn)
|
||||||
|
await _seed_test_data(conn)
|
||||||
|
yield conn
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _init_test_schema(db: aiosqlite.Connection):
|
||||||
|
"""Initialize test database schema."""
|
||||||
|
await db.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS client_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
key TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS providers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
base_url TEXT NOT NULL,
|
||||||
|
api_type TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS provider_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
provider_id INTEGER NOT NULL REFERENCES providers(id),
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS request_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
client_key_id INTEGER REFERENCES client_keys(id),
|
||||||
|
provider_id INTEGER REFERENCES providers(id),
|
||||||
|
model TEXT,
|
||||||
|
prompt_tokens INTEGER,
|
||||||
|
completion_tokens INTEGER,
|
||||||
|
latency_ms INTEGER,
|
||||||
|
success BOOLEAN,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_test_data(db: aiosqlite.Connection):
|
||||||
|
"""Seed test data."""
|
||||||
|
# Seed providers
|
||||||
|
await db.executemany(
|
||||||
|
"INSERT INTO providers (name, base_url, api_type) VALUES (?, ?, ?)",
|
||||||
|
[
|
||||||
|
("openai", "https://api.openai.com/v1", "openai"),
|
||||||
|
("anthropic", "https://api.anthropic.com/v1", "anthropic"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# Seed client keys
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO client_keys (key, name, is_active) VALUES (?, ?, ?)",
|
||||||
|
("test-api-key", "test-client", True)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO client_keys (key, name, is_active) VALUES (?, ?, ?)",
|
||||||
|
("disabled-key", "disabled-client", False)
|
||||||
|
)
|
||||||
|
# Seed provider keys
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO provider_keys (provider_id, key, is_active) VALUES (?, ?, ?)",
|
||||||
|
(1, "sk-openai-key-1", True)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO provider_keys (provider_id, key, is_active) VALUES (?, ?, ?)",
|
||||||
|
(1, "sk-openai-key-2", True)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO provider_keys (provider_id, key, is_active) VALUES (?, ?, ?)",
|
||||||
|
(2, "sk-ant-key-1", True)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_db(monkeypatch):
|
||||||
|
"""Mock database connection for tests."""
|
||||||
|
@asynccontextmanager
|
||||||
|
async def mock_db_connection():
|
||||||
|
async with get_test_db() as db:
|
||||||
|
yield db
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.database.db_connection", mock_db_connection)
|
||||||
|
return mock_db_connection
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_litellm():
|
||||||
|
"""Mock LiteLLM completion."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.usage = MagicMock()
|
||||||
|
mock_response.usage.prompt_tokens = 10
|
||||||
|
mock_response.usage.completion_tokens = 20
|
||||||
|
mock_response.model_dump.return_value = {
|
||||||
|
"id": "chatcmpl-test",
|
||||||
|
"object": "chat.completion",
|
||||||
|
"choices": [{"message": {"content": "test response"}}]
|
||||||
|
}
|
||||||
|
|
||||||
|
async def mock_acompletion(*args, **kwargs):
|
||||||
|
return mock_response
|
||||||
|
|
||||||
|
return {"acompletion": mock_acompletion, "response": mock_response}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client_key():
|
||||||
|
"""Return a test client key instance."""
|
||||||
|
from app.models import ClientKey
|
||||||
|
return ClientKey(
|
||||||
|
id=1,
|
||||||
|
key="test-api-key",
|
||||||
|
name="test-client",
|
||||||
|
is_active=True,
|
||||||
|
created_at="2024-01-01 00:00:00"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def provider():
|
||||||
|
"""Return a test provider instance."""
|
||||||
|
from app.models import Provider
|
||||||
|
return Provider(
|
||||||
|
id=1,
|
||||||
|
name="openai",
|
||||||
|
base_url="https://api.openai.com/v1",
|
||||||
|
api_type="openai",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def provider_key():
|
||||||
|
"""Return a test provider key instance."""
|
||||||
|
from app.models import ProviderKey
|
||||||
|
return ProviderKey(
|
||||||
|
id=1,
|
||||||
|
provider_id=1,
|
||||||
|
key="sk-test-key",
|
||||||
|
is_active=True,
|
||||||
|
created_at="2024-01-01 00:00:00"
|
||||||
|
)
|
||||||
47
backend/tests/test_auth.py
Normal file
47
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Tests for authentication service."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, AsyncMock
|
||||||
|
from app.services.auth import AuthService
|
||||||
|
from app.models import ClientKey
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthService:
|
||||||
|
"""Test AuthService."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_key_success(self, mock_db):
|
||||||
|
"""Test validating a valid API key."""
|
||||||
|
with patch("app.services.auth.db_connection", mock_db):
|
||||||
|
result = await AuthService.validate_key("test-api-key")
|
||||||
|
assert result is not None
|
||||||
|
assert result.key == "test-api-key"
|
||||||
|
assert result.name == "test-client"
|
||||||
|
assert result.is_active in (True, 1) # SQLite returns 1 for boolean true
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_key_invalid(self, mock_db):
|
||||||
|
"""Test validating an invalid API key."""
|
||||||
|
with patch("app.services.auth.db_connection", mock_db):
|
||||||
|
result = await AuthService.validate_key("invalid-key")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_key_disabled(self, mock_db):
|
||||||
|
"""Test validating a disabled API key."""
|
||||||
|
with patch("app.services.auth.db_connection", mock_db):
|
||||||
|
result = await AuthService.validate_key("disabled-key")
|
||||||
|
assert result is None # Disabled keys should not validate
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_key_returns_client_key_instance(self, mock_db):
|
||||||
|
"""Test validate_key returns ClientKey instance."""
|
||||||
|
with patch("app.services.auth.db_connection", mock_db):
|
||||||
|
result = await AuthService.validate_key("test-api-key")
|
||||||
|
assert isinstance(result, ClientKey)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_validate_key_case_sensitive(self, mock_db):
|
||||||
|
"""Test key validation is case sensitive."""
|
||||||
|
with patch("app.services.auth.db_connection", mock_db):
|
||||||
|
result = await AuthService.validate_key("TEST-API-KEY")
|
||||||
|
assert result is None # Should not match due to case
|
||||||
47
backend/tests/test_config.py
Normal file
47
backend/tests/test_config.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Tests for config module."""
|
||||||
|
import pytest
|
||||||
|
from app.config import DB_PATH, HOST, PORT, LOG_BATCH_SIZE, LOG_FLUSH_INTERVAL, MODEL_PROVIDER_MAP
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfig:
|
||||||
|
"""Test configuration constants."""
|
||||||
|
|
||||||
|
def test_db_path_is_pathlib_path(self):
|
||||||
|
"""Test DB_PATH is a Path object."""
|
||||||
|
from pathlib import Path
|
||||||
|
assert isinstance(DB_PATH, Path)
|
||||||
|
|
||||||
|
def test_db_path_contains_data_dir(self):
|
||||||
|
"""Test DB_PATH points to data directory."""
|
||||||
|
assert "data" in str(DB_PATH)
|
||||||
|
assert "zzrouter.db" in str(DB_PATH)
|
||||||
|
|
||||||
|
def test_host_default(self):
|
||||||
|
"""Test HOST default value."""
|
||||||
|
assert HOST == "0.0.0.0"
|
||||||
|
|
||||||
|
def test_port_default(self):
|
||||||
|
"""Test PORT default value."""
|
||||||
|
assert PORT == 8000
|
||||||
|
|
||||||
|
def test_log_batch_size_positive(self):
|
||||||
|
"""Test LOG_BATCH_SIZE is positive."""
|
||||||
|
assert LOG_BATCH_SIZE > 0
|
||||||
|
|
||||||
|
def test_log_flush_interval_positive(self):
|
||||||
|
"""Test LOG_FLUSH_INTERVAL is positive."""
|
||||||
|
assert LOG_FLUSH_INTERVAL > 0
|
||||||
|
|
||||||
|
def test_model_provider_map_contains_openai(self):
|
||||||
|
"""Test MODEL_PROVIDER_MAP contains OpenAI models."""
|
||||||
|
assert "gpt" in MODEL_PROVIDER_MAP
|
||||||
|
assert "o1" in MODEL_PROVIDER_MAP
|
||||||
|
assert "o3" in MODEL_PROVIDER_MAP
|
||||||
|
assert MODEL_PROVIDER_MAP["gpt"] == "openai"
|
||||||
|
assert MODEL_PROVIDER_MAP["o1"] == "openai"
|
||||||
|
assert MODEL_PROVIDER_MAP["o3"] == "openai"
|
||||||
|
|
||||||
|
def test_model_provider_map_contains_anthropic(self):
|
||||||
|
"""Test MODEL_PROVIDER_MAP contains Anthropic models."""
|
||||||
|
assert "claude" in MODEL_PROVIDER_MAP
|
||||||
|
assert MODEL_PROVIDER_MAP["claude"] == "anthropic"
|
||||||
159
backend/tests/test_database.py
Normal file
159
backend/tests/test_database.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""Tests for database module."""
|
||||||
|
import pytest
|
||||||
|
import aiosqlite
|
||||||
|
from unittest.mock import patch, AsyncMock
|
||||||
|
from app.database import get_db, db_connection, init_db, seed_default_providers
|
||||||
|
|
||||||
|
|
||||||
|
class TestDatabase:
|
||||||
|
"""Test database functions."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_db_returns_connection(self):
|
||||||
|
"""Test get_db returns aiosqlite connection."""
|
||||||
|
conn = await get_db()
|
||||||
|
assert isinstance(conn, aiosqlite.Connection)
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_db_row_factory(self):
|
||||||
|
"""Test get_db sets row factory."""
|
||||||
|
conn = await get_db()
|
||||||
|
assert conn.row_factory == aiosqlite.Row
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_db_connection_context_manager(self):
|
||||||
|
"""Test db_connection context manager."""
|
||||||
|
async with db_connection() as conn:
|
||||||
|
assert isinstance(conn, aiosqlite.Connection)
|
||||||
|
# Connection should be open
|
||||||
|
cursor = await conn.execute("SELECT 1")
|
||||||
|
result = await cursor.fetchone()
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_db_connection_closes_after_context(self):
|
||||||
|
"""Test connection is closed after context exits."""
|
||||||
|
async with db_connection() as conn:
|
||||||
|
pass
|
||||||
|
# Connection should be closed now
|
||||||
|
# This will raise if connection is still open
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
await conn.execute("SELECT 1")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_init_db_creates_tables(self):
|
||||||
|
"""Test init_db creates all required tables."""
|
||||||
|
# Use in-memory database for testing
|
||||||
|
async with aiosqlite.connect(":memory:") as db:
|
||||||
|
await db.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS client_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
key TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS providers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
base_url TEXT NOT NULL,
|
||||||
|
api_type TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS provider_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
provider_id INTEGER NOT NULL REFERENCES providers(id),
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS request_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
client_key_id INTEGER REFERENCES client_keys(id),
|
||||||
|
provider_id INTEGER REFERENCES providers(id),
|
||||||
|
model TEXT,
|
||||||
|
prompt_tokens INTEGER,
|
||||||
|
completion_tokens INTEGER,
|
||||||
|
latency_ms INTEGER,
|
||||||
|
success BOOLEAN,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Verify tables exist
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
)
|
||||||
|
tables = [row[0] async for row in cursor]
|
||||||
|
assert "client_keys" in tables
|
||||||
|
assert "providers" in tables
|
||||||
|
assert "provider_keys" in tables
|
||||||
|
assert "request_logs" in tables
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_seed_default_providers(self):
|
||||||
|
"""Test seed_default_providers inserts OpenAI and Anthropic."""
|
||||||
|
async with aiosqlite.connect(":memory:") as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
# Create providers table
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE providers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
base_url TEXT NOT NULL,
|
||||||
|
api_type TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Seed providers
|
||||||
|
await db.executemany(
|
||||||
|
"INSERT INTO providers (name, base_url, api_type) VALUES (?, ?, ?)",
|
||||||
|
[
|
||||||
|
("openai", "https://api.openai.com/v1", "openai"),
|
||||||
|
("anthropic", "https://api.anthropic.com/v1", "anthropic"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Verify providers
|
||||||
|
cursor = await db.execute("SELECT name, api_type FROM providers")
|
||||||
|
providers = {row["name"]: row["api_type"] async for row in cursor}
|
||||||
|
assert providers["openai"] == "openai"
|
||||||
|
assert providers["anthropic"] == "anthropic"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_seed_default_providers_idempotent(self):
|
||||||
|
"""Test seeding is idempotent (INSERT OR IGNORE)."""
|
||||||
|
async with aiosqlite.connect(":memory:") as db:
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
await db.execute("""
|
||||||
|
CREATE TABLE providers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
base_url TEXT NOT NULL,
|
||||||
|
api_type TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
# Insert twice
|
||||||
|
for _ in range(2):
|
||||||
|
await db.executemany(
|
||||||
|
"INSERT OR IGNORE INTO providers (name, base_url, api_type) VALUES (?, ?, ?)",
|
||||||
|
[
|
||||||
|
("openai", "https://api.openai.com/v1", "openai"),
|
||||||
|
("anthropic", "https://api.anthropic.com/v1", "anthropic"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
cursor = await db.execute("SELECT COUNT(*) FROM providers")
|
||||||
|
count = (await cursor.fetchone())[0]
|
||||||
|
assert count == 2 # Should still be 2, not 4
|
||||||
92
backend/tests/test_key_selector.py
Normal file
92
backend/tests/test_key_selector.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Tests for key selector service."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from app.services.key_selector import KeySelector
|
||||||
|
from app.models import ProviderKey
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeySelector:
|
||||||
|
"""Test KeySelector."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_next_key_single_key(self, mock_db):
|
||||||
|
"""Test getting next key with single key configured."""
|
||||||
|
selector = KeySelector()
|
||||||
|
with patch("app.services.key_selector.db_connection", mock_db):
|
||||||
|
# First call
|
||||||
|
key1 = await selector.get_next_key(1)
|
||||||
|
assert key1 is not None
|
||||||
|
assert isinstance(key1, ProviderKey)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_next_key_round_robin(self, mock_db):
|
||||||
|
"""Test round-robin key selection."""
|
||||||
|
selector = KeySelector()
|
||||||
|
selector.reset_index(1) # Reset to start fresh
|
||||||
|
|
||||||
|
with patch("app.services.key_selector.db_connection", mock_db):
|
||||||
|
keys = []
|
||||||
|
for _ in range(4): # More calls than keys (2 keys)
|
||||||
|
key = await selector.get_next_key(1)
|
||||||
|
if key:
|
||||||
|
keys.append(key.key)
|
||||||
|
|
||||||
|
# Should cycle through keys
|
||||||
|
assert len(keys) == 4
|
||||||
|
# First and third should be same, second and fourth should be same
|
||||||
|
assert keys[0] == keys[2]
|
||||||
|
assert keys[1] == keys[3]
|
||||||
|
assert keys[0] != keys[1]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_next_key_no_keys_configured(self, mock_db):
|
||||||
|
"""Test when no keys are configured for provider."""
|
||||||
|
selector = KeySelector()
|
||||||
|
with patch("app.services.key_selector.db_connection", mock_db):
|
||||||
|
key = await selector.get_next_key(999) # Non-existent provider
|
||||||
|
assert key is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reset_index(self, mock_db):
|
||||||
|
"""Test resetting round-robin index."""
|
||||||
|
selector = KeySelector()
|
||||||
|
selector._indices[1] = 5
|
||||||
|
|
||||||
|
selector.reset_index(1)
|
||||||
|
assert 1 not in selector._indices
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reset_index_nonexistent(self):
|
||||||
|
"""Test resetting index for non-existent provider."""
|
||||||
|
selector = KeySelector()
|
||||||
|
selector.reset_index(999) # Should not raise
|
||||||
|
assert 999 not in selector._indices
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_concurrent_access(self, mock_db):
|
||||||
|
"""Test thread-safe concurrent access."""
|
||||||
|
import asyncio
|
||||||
|
selector = KeySelector()
|
||||||
|
selector.reset_index(1)
|
||||||
|
|
||||||
|
with patch("app.services.key_selector.db_connection", mock_db):
|
||||||
|
# Run multiple concurrent requests
|
||||||
|
tasks = [selector.get_next_key(1) for _ in range(5)]
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
# All should return valid keys
|
||||||
|
assert all(r is not None for r in results)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_different_providers_independent(self, mock_db):
|
||||||
|
"""Test that different providers have independent indices."""
|
||||||
|
selector = KeySelector()
|
||||||
|
with patch("app.services.key_selector.db_connection", mock_db):
|
||||||
|
key1 = await selector.get_next_key(1)
|
||||||
|
key2 = await selector.get_next_key(2)
|
||||||
|
|
||||||
|
# Both should return valid keys from different providers
|
||||||
|
assert key1 is not None
|
||||||
|
assert key2 is not None
|
||||||
|
assert key1.provider_id == 1
|
||||||
|
assert key2.provider_id == 2
|
||||||
206
backend/tests/test_litellm_wrapper.py
Normal file
206
backend/tests/test_litellm_wrapper.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"""Tests for LiteLLM wrapper."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, AsyncMock, MagicMock
|
||||||
|
from app.providers.litellm_wrapper import LiteLLMWrapper
|
||||||
|
from app.models import Provider
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def openai_provider():
|
||||||
|
"""Return an OpenAI provider."""
|
||||||
|
return Provider(
|
||||||
|
id=1,
|
||||||
|
name="openai",
|
||||||
|
base_url="https://api.openai.com/v1",
|
||||||
|
api_type="openai",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def anthropic_provider():
|
||||||
|
"""Return an Anthropic provider."""
|
||||||
|
return Provider(
|
||||||
|
id=2,
|
||||||
|
name="anthropic",
|
||||||
|
base_url="https://api.anthropic.com/v1",
|
||||||
|
api_type="anthropic",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildModelString:
|
||||||
|
"""Test _build_model_string method."""
|
||||||
|
|
||||||
|
def test_openai_model(self, openai_provider):
|
||||||
|
"""Test building model string for OpenAI."""
|
||||||
|
result = LiteLLMWrapper._build_model_string(openai_provider, "gpt-4o")
|
||||||
|
assert result == "openai/gpt-4o"
|
||||||
|
|
||||||
|
def test_anthropic_model(self, anthropic_provider):
|
||||||
|
"""Test building model string for Anthropic."""
|
||||||
|
result = LiteLLMWrapper._build_model_string(anthropic_provider, "claude-3-opus")
|
||||||
|
assert result == "anthropic/claude-3-opus"
|
||||||
|
|
||||||
|
def test_unknown_provider_type(self):
|
||||||
|
"""Test with unknown provider type."""
|
||||||
|
provider = Provider(
|
||||||
|
id=3,
|
||||||
|
name="unknown",
|
||||||
|
base_url="https://api.example.com",
|
||||||
|
api_type="unknown",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
result = LiteLLMWrapper._build_model_string(provider, "model-name")
|
||||||
|
assert result == "openai/model-name" # Falls back to openai
|
||||||
|
|
||||||
|
|
||||||
|
class TestChatCompletion:
|
||||||
|
"""Test chat_completion method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_completion_non_streaming(self, openai_provider):
|
||||||
|
"""Test non-streaming chat completion."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.usage = MagicMock()
|
||||||
|
mock_response.usage.prompt_tokens = 15
|
||||||
|
mock_response.usage.completion_tokens = 25
|
||||||
|
|
||||||
|
with patch("app.providers.litellm_wrapper.acompletion", new_callable=AsyncMock) as mock_acompletion:
|
||||||
|
mock_acompletion.return_value = mock_response
|
||||||
|
|
||||||
|
response, prompt_tokens, completion_tokens = await LiteLLMWrapper.chat_completion(
|
||||||
|
provider=openai_provider,
|
||||||
|
api_key="sk-test",
|
||||||
|
model="gpt-4o",
|
||||||
|
messages=[{"role": "user", "content": "hi"}],
|
||||||
|
stream=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response == mock_response
|
||||||
|
assert prompt_tokens == 15
|
||||||
|
assert completion_tokens == 25
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_completion_streaming(self, openai_provider):
|
||||||
|
"""Test streaming chat completion."""
|
||||||
|
mock_stream = MagicMock()
|
||||||
|
|
||||||
|
with patch("app.providers.litellm_wrapper.acompletion", new_callable=AsyncMock) as mock_acompletion:
|
||||||
|
mock_acompletion.return_value = mock_stream
|
||||||
|
|
||||||
|
response, prompt_tokens, completion_tokens = await LiteLLMWrapper.chat_completion(
|
||||||
|
provider=openai_provider,
|
||||||
|
api_key="sk-test",
|
||||||
|
model="gpt-4o",
|
||||||
|
messages=[{"role": "user", "content": "hi"}],
|
||||||
|
stream=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# For streaming, token counts are 0 initially
|
||||||
|
assert prompt_tokens == 0
|
||||||
|
assert completion_tokens == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_completion_with_kwargs(self, openai_provider):
|
||||||
|
"""Test chat completion with additional kwargs."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.usage = MagicMock()
|
||||||
|
mock_response.usage.prompt_tokens = 10
|
||||||
|
mock_response.usage.completion_tokens = 20
|
||||||
|
|
||||||
|
with patch("app.providers.litellm_wrapper.acompletion", new_callable=AsyncMock) as mock_acompletion:
|
||||||
|
mock_acompletion.return_value = mock_response
|
||||||
|
|
||||||
|
await LiteLLMWrapper.chat_completion(
|
||||||
|
provider=openai_provider,
|
||||||
|
api_key="sk-test",
|
||||||
|
model="gpt-4o",
|
||||||
|
messages=[{"role": "user", "content": "hi"}],
|
||||||
|
stream=False,
|
||||||
|
temperature=0.7,
|
||||||
|
max_tokens=100
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify kwargs were passed
|
||||||
|
call_kwargs = mock_acompletion.call_args[1]
|
||||||
|
assert call_kwargs["temperature"] == 0.7
|
||||||
|
assert call_kwargs["max_tokens"] == 100
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chat_completion_anthropic(self, anthropic_provider):
|
||||||
|
"""Test chat completion with Anthropic provider."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.usage = MagicMock()
|
||||||
|
mock_response.usage.prompt_tokens = 10
|
||||||
|
mock_response.usage.completion_tokens = 30
|
||||||
|
|
||||||
|
with patch("app.providers.litellm_wrapper.acompletion", new_callable=AsyncMock) as mock_acompletion:
|
||||||
|
mock_acompletion.return_value = mock_response
|
||||||
|
|
||||||
|
response, prompt_tokens, completion_tokens = await LiteLLMWrapper.chat_completion(
|
||||||
|
provider=anthropic_provider,
|
||||||
|
api_key="sk-ant-test",
|
||||||
|
model="claude-3-opus",
|
||||||
|
messages=[{"role": "user", "content": "hi"}],
|
||||||
|
stream=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify model string was built correctly
|
||||||
|
call_kwargs = mock_acompletion.call_args[1]
|
||||||
|
assert call_kwargs["model"] == "anthropic/claude-3-opus"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamResponse:
|
||||||
|
"""Test stream_response method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_response_yields_chunks(self, openai_provider):
|
||||||
|
"""Test stream_response yields SSE-formatted chunks."""
|
||||||
|
mock_chunk1 = MagicMock()
|
||||||
|
mock_chunk1.model_dump.return_value = {"choices": [{"delta": {"content": "Hello"}}]}
|
||||||
|
mock_chunk1.usage = None
|
||||||
|
|
||||||
|
mock_chunk2 = MagicMock()
|
||||||
|
mock_chunk2.model_dump.return_value = {"choices": [{"delta": {"content": "!"}}]}
|
||||||
|
mock_chunk2.usage = MagicMock(prompt_tokens=5, completion_tokens=2)
|
||||||
|
|
||||||
|
async def mock_stream():
|
||||||
|
yield mock_chunk1
|
||||||
|
yield mock_chunk2
|
||||||
|
|
||||||
|
with patch("app.providers.litellm_wrapper.async_logger.log", new_callable=AsyncMock):
|
||||||
|
chunks = []
|
||||||
|
async for chunk in LiteLLMWrapper.stream_response(
|
||||||
|
mock_stream(),
|
||||||
|
client_key_id=1,
|
||||||
|
provider_id=1,
|
||||||
|
model="gpt-4o",
|
||||||
|
start_time=0
|
||||||
|
):
|
||||||
|
chunks.append(chunk)
|
||||||
|
|
||||||
|
assert len(chunks) >= 2 # At least 2 chunks + [DONE]
|
||||||
|
assert any("Hello" in chunk for chunk in chunks)
|
||||||
|
assert "data: [DONE]" in chunks[-1]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stream_response_handles_error(self, openai_provider):
|
||||||
|
"""Test stream_response handles errors."""
|
||||||
|
async def mock_stream_with_error():
|
||||||
|
yield MagicMock(model_dump=lambda: {"choices": []})
|
||||||
|
raise Exception("Stream error")
|
||||||
|
|
||||||
|
with patch("app.providers.litellm_wrapper.async_logger.log", new_callable=AsyncMock):
|
||||||
|
chunks = []
|
||||||
|
async for chunk in LiteLLMWrapper.stream_response(
|
||||||
|
mock_stream_with_error(),
|
||||||
|
client_key_id=1,
|
||||||
|
provider_id=1,
|
||||||
|
model="gpt-4o",
|
||||||
|
start_time=0
|
||||||
|
):
|
||||||
|
chunks.append(chunk)
|
||||||
|
|
||||||
|
# Should include error message
|
||||||
|
assert any("error" in chunk.lower() for chunk in chunks)
|
||||||
158
backend/tests/test_logger.py
Normal file
158
backend/tests/test_logger.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""Tests for async logger service."""
|
||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
from unittest.mock import patch, AsyncMock, MagicMock
|
||||||
|
from app.services.logger import AsyncLogger, LogEntry, async_logger
|
||||||
|
|
||||||
|
|
||||||
|
class TestLogEntry:
|
||||||
|
"""Test LogEntry dataclass."""
|
||||||
|
|
||||||
|
def test_create_log_entry(self):
|
||||||
|
"""Test creating a LogEntry."""
|
||||||
|
entry = LogEntry(
|
||||||
|
client_key_id=1,
|
||||||
|
provider_id=2,
|
||||||
|
model="gpt-4o",
|
||||||
|
prompt_tokens=10,
|
||||||
|
completion_tokens=20,
|
||||||
|
latency_ms=100,
|
||||||
|
success=True
|
||||||
|
)
|
||||||
|
assert entry.client_key_id == 1
|
||||||
|
assert entry.model == "gpt-4o"
|
||||||
|
assert entry.success is True
|
||||||
|
|
||||||
|
def test_log_entry_with_error(self):
|
||||||
|
"""Test LogEntry with error."""
|
||||||
|
entry = LogEntry(
|
||||||
|
client_key_id=1,
|
||||||
|
provider_id=2,
|
||||||
|
model="gpt-4o",
|
||||||
|
success=False,
|
||||||
|
error_message="Rate limit exceeded"
|
||||||
|
)
|
||||||
|
assert entry.success is False
|
||||||
|
assert entry.error_message == "Rate limit exceeded"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAsyncLogger:
|
||||||
|
"""Test AsyncLogger."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_creates_task(self):
|
||||||
|
"""Test start creates background task."""
|
||||||
|
logger = AsyncLogger()
|
||||||
|
assert logger._task is None
|
||||||
|
|
||||||
|
await logger.start()
|
||||||
|
assert logger._task is not None
|
||||||
|
|
||||||
|
await logger.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_cancels_task(self):
|
||||||
|
"""Test stop cancels background task."""
|
||||||
|
logger = AsyncLogger()
|
||||||
|
await logger.start()
|
||||||
|
assert logger._task is not None
|
||||||
|
|
||||||
|
await logger.stop()
|
||||||
|
assert logger._task is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_log_queues_entry(self):
|
||||||
|
"""Test log queues an entry."""
|
||||||
|
logger = AsyncLogger()
|
||||||
|
entry = LogEntry(
|
||||||
|
client_key_id=1,
|
||||||
|
provider_id=2,
|
||||||
|
model="gpt-4o",
|
||||||
|
success=True
|
||||||
|
)
|
||||||
|
|
||||||
|
await logger.log(entry)
|
||||||
|
assert not logger._queue.empty()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_flush_batch_empty_queue(self, mock_db):
|
||||||
|
"""Test flush with empty queue."""
|
||||||
|
logger = AsyncLogger()
|
||||||
|
with patch("app.services.logger.db_connection", mock_db):
|
||||||
|
await logger._flush_batch() # Should not raise
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_flush_batch_with_entries(self, mock_db):
|
||||||
|
"""Test flush with entries in queue."""
|
||||||
|
logger = AsyncLogger()
|
||||||
|
entry = LogEntry(
|
||||||
|
client_key_id=1,
|
||||||
|
provider_id=2,
|
||||||
|
model="gpt-4o",
|
||||||
|
prompt_tokens=10,
|
||||||
|
completion_tokens=20,
|
||||||
|
latency_ms=100,
|
||||||
|
success=True
|
||||||
|
)
|
||||||
|
|
||||||
|
await logger.log(entry)
|
||||||
|
|
||||||
|
with patch("app.services.logger.db_connection", mock_db):
|
||||||
|
await logger._flush_batch()
|
||||||
|
|
||||||
|
assert logger._queue.empty()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_flush_all(self, mock_db):
|
||||||
|
"""Test flush_all drains all entries."""
|
||||||
|
logger = AsyncLogger()
|
||||||
|
|
||||||
|
# Add multiple entries
|
||||||
|
for i in range(5):
|
||||||
|
entry = LogEntry(
|
||||||
|
client_key_id=1,
|
||||||
|
provider_id=2,
|
||||||
|
model=f"model-{i}",
|
||||||
|
success=True
|
||||||
|
)
|
||||||
|
await logger.log(entry)
|
||||||
|
|
||||||
|
with patch("app.services.logger.db_connection", mock_db):
|
||||||
|
await logger._flush_all()
|
||||||
|
|
||||||
|
assert logger._queue.empty()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_stop_cycle(self):
|
||||||
|
"""Test full start/stop cycle."""
|
||||||
|
logger = AsyncLogger()
|
||||||
|
|
||||||
|
await logger.start()
|
||||||
|
await asyncio.sleep(0.1) # Let task run briefly
|
||||||
|
await logger.stop()
|
||||||
|
|
||||||
|
assert logger._task is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multiple_start_calls_safe(self):
|
||||||
|
"""Test multiple start calls are safe."""
|
||||||
|
logger = AsyncLogger()
|
||||||
|
|
||||||
|
await logger.start()
|
||||||
|
first_task = logger._task
|
||||||
|
await logger.start() # Should not create new task
|
||||||
|
|
||||||
|
assert logger._task is first_task
|
||||||
|
|
||||||
|
await logger.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_without_start_safe(self):
|
||||||
|
"""Test stop without start is safe."""
|
||||||
|
logger = AsyncLogger()
|
||||||
|
await logger.stop() # Should not raise
|
||||||
|
|
||||||
|
def test_global_instance_exists(self):
|
||||||
|
"""Test global async_logger instance exists."""
|
||||||
|
assert async_logger is not None
|
||||||
|
assert isinstance(async_logger, AsyncLogger)
|
||||||
106
backend/tests/test_middleware_auth.py
Normal file
106
backend/tests/test_middleware_auth.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""Tests for authentication middleware."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, AsyncMock, MagicMock
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
from app.middleware.auth import get_current_client, AuthMiddleware
|
||||||
|
from app.models import ClientKey
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCurrentClient:
|
||||||
|
"""Test get_current_client dependency."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_credentials(self):
|
||||||
|
"""Test with missing credentials."""
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await get_current_client(None)
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
assert "Missing API key" in str(exc_info.value.detail)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_key(self, mock_db):
|
||||||
|
"""Test with invalid API key."""
|
||||||
|
credentials = HTTPAuthorizationCredentials(
|
||||||
|
scheme="Bearer",
|
||||||
|
credentials="invalid-key"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.middleware.auth.AuthService.validate_key", return_value=None):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await get_current_client(credentials)
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
assert "Invalid API key" in str(exc_info.value.detail)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disabled_key(self):
|
||||||
|
"""Test with disabled API key."""
|
||||||
|
credentials = HTTPAuthorizationCredentials(
|
||||||
|
scheme="Bearer",
|
||||||
|
credentials="disabled-key"
|
||||||
|
)
|
||||||
|
|
||||||
|
disabled_client = ClientKey(
|
||||||
|
id=1,
|
||||||
|
key="disabled-key",
|
||||||
|
name="disabled",
|
||||||
|
is_active=False,
|
||||||
|
created_at="2024-01-01"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.middleware.auth.AuthService.validate_key", return_value=disabled_client):
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await get_current_client(credentials)
|
||||||
|
assert exc_info.value.status_code == 403
|
||||||
|
assert "disabled" in str(exc_info.value.detail).lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_valid_key(self):
|
||||||
|
"""Test with valid API key."""
|
||||||
|
credentials = HTTPAuthorizationCredentials(
|
||||||
|
scheme="Bearer",
|
||||||
|
credentials="test-api-key"
|
||||||
|
)
|
||||||
|
|
||||||
|
valid_client = ClientKey(
|
||||||
|
id=1,
|
||||||
|
key="test-api-key",
|
||||||
|
name="test-client",
|
||||||
|
is_active=True,
|
||||||
|
created_at="2024-01-01"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("app.middleware.auth.AuthService.validate_key", return_value=valid_client):
|
||||||
|
result = await get_current_client(credentials)
|
||||||
|
assert result == valid_client
|
||||||
|
assert result.is_active is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthMiddleware:
|
||||||
|
"""Test AuthMiddleware."""
|
||||||
|
|
||||||
|
def test_default_exclude_paths(self):
|
||||||
|
"""Test default exclude paths."""
|
||||||
|
middleware = AuthMiddleware()
|
||||||
|
assert "/health" in middleware.exclude_paths
|
||||||
|
assert "/docs" in middleware.exclude_paths
|
||||||
|
assert "/openapi.json" in middleware.exclude_paths
|
||||||
|
|
||||||
|
def test_custom_exclude_paths(self):
|
||||||
|
"""Test custom exclude paths."""
|
||||||
|
middleware = AuthMiddleware(exclude_paths=["/custom"])
|
||||||
|
assert "/custom" in middleware.exclude_paths
|
||||||
|
assert "/health" not in middleware.exclude_paths
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_middleware_calls_next(self):
|
||||||
|
"""Test middleware calls next handler."""
|
||||||
|
middleware = AuthMiddleware()
|
||||||
|
|
||||||
|
request = MagicMock()
|
||||||
|
call_next = AsyncMock(return_value=MagicMock())
|
||||||
|
|
||||||
|
response = await middleware(request, call_next)
|
||||||
|
|
||||||
|
call_next.assert_called_once_with(request)
|
||||||
118
backend/tests/test_models.py
Normal file
118
backend/tests/test_models.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""Tests for data models."""
|
||||||
|
import pytest
|
||||||
|
from app.models import ClientKey, Provider, ProviderKey, RequestLog
|
||||||
|
|
||||||
|
|
||||||
|
class TestClientKey:
|
||||||
|
"""Test ClientKey model."""
|
||||||
|
|
||||||
|
def test_create_client_key(self):
|
||||||
|
"""Test creating a ClientKey instance."""
|
||||||
|
key = ClientKey(
|
||||||
|
id=1,
|
||||||
|
key="test-key",
|
||||||
|
name="test-client",
|
||||||
|
is_active=True,
|
||||||
|
created_at="2024-01-01"
|
||||||
|
)
|
||||||
|
assert key.id == 1
|
||||||
|
assert key.key == "test-key"
|
||||||
|
assert key.name == "test-client"
|
||||||
|
assert key.is_active is True
|
||||||
|
|
||||||
|
def test_client_key_with_null_name(self):
|
||||||
|
"""Test ClientKey with null name."""
|
||||||
|
key = ClientKey(
|
||||||
|
id=1,
|
||||||
|
key="test-key",
|
||||||
|
name=None,
|
||||||
|
is_active=True,
|
||||||
|
created_at="2024-01-01"
|
||||||
|
)
|
||||||
|
assert key.name is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestProvider:
|
||||||
|
"""Test Provider model."""
|
||||||
|
|
||||||
|
def test_create_provider(self):
|
||||||
|
"""Test creating a Provider instance."""
|
||||||
|
provider = Provider(
|
||||||
|
id=1,
|
||||||
|
name="openai",
|
||||||
|
base_url="https://api.openai.com/v1",
|
||||||
|
api_type="openai",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
assert provider.id == 1
|
||||||
|
assert provider.name == "openai"
|
||||||
|
assert provider.api_type == "openai"
|
||||||
|
|
||||||
|
def test_provider_anthropic(self):
|
||||||
|
"""Test creating an Anthropic provider."""
|
||||||
|
provider = Provider(
|
||||||
|
id=2,
|
||||||
|
name="anthropic",
|
||||||
|
base_url="https://api.anthropic.com/v1",
|
||||||
|
api_type="anthropic",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
assert provider.name == "anthropic"
|
||||||
|
assert provider.api_type == "anthropic"
|
||||||
|
|
||||||
|
|
||||||
|
class TestProviderKey:
|
||||||
|
"""Test ProviderKey model."""
|
||||||
|
|
||||||
|
def test_create_provider_key(self):
|
||||||
|
"""Test creating a ProviderKey instance."""
|
||||||
|
key = ProviderKey(
|
||||||
|
id=1,
|
||||||
|
provider_id=1,
|
||||||
|
key="sk-test",
|
||||||
|
is_active=True,
|
||||||
|
created_at="2024-01-01"
|
||||||
|
)
|
||||||
|
assert key.id == 1
|
||||||
|
assert key.provider_id == 1
|
||||||
|
assert key.key == "sk-test"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRequestLog:
|
||||||
|
"""Test RequestLog model."""
|
||||||
|
|
||||||
|
def test_create_request_log(self):
|
||||||
|
"""Test creating a RequestLog instance."""
|
||||||
|
log = RequestLog(
|
||||||
|
id=1,
|
||||||
|
client_key_id=1,
|
||||||
|
provider_id=1,
|
||||||
|
model="gpt-4o",
|
||||||
|
prompt_tokens=10,
|
||||||
|
completion_tokens=20,
|
||||||
|
latency_ms=100,
|
||||||
|
success=True,
|
||||||
|
error_message=None,
|
||||||
|
created_at="2024-01-01"
|
||||||
|
)
|
||||||
|
assert log.model == "gpt-4o"
|
||||||
|
assert log.success is True
|
||||||
|
assert log.error_message is None
|
||||||
|
|
||||||
|
def test_request_log_with_error(self):
|
||||||
|
"""Test RequestLog with error."""
|
||||||
|
log = RequestLog(
|
||||||
|
id=None,
|
||||||
|
client_key_id=1,
|
||||||
|
provider_id=1,
|
||||||
|
model="gpt-4o",
|
||||||
|
prompt_tokens=None,
|
||||||
|
completion_tokens=None,
|
||||||
|
latency_ms=100,
|
||||||
|
success=False,
|
||||||
|
error_message="Rate limit exceeded",
|
||||||
|
created_at=None
|
||||||
|
)
|
||||||
|
assert log.id is None
|
||||||
|
assert log.success is False
|
||||||
|
assert log.error_message == "Rate limit exceeded"
|
||||||
97
backend/tests/test_router.py
Normal file
97
backend/tests/test_router.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
"""Tests for model router service."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from app.services.router import ModelRouter, model_router
|
||||||
|
from app.models import Provider
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelRouter:
|
||||||
|
"""Test ModelRouter."""
|
||||||
|
|
||||||
|
def test_get_provider_name_gpt(self):
|
||||||
|
"""Test getting provider for GPT models."""
|
||||||
|
assert ModelRouter.get_provider_name("gpt-4o") == "openai"
|
||||||
|
assert ModelRouter.get_provider_name("gpt-4-turbo") == "openai"
|
||||||
|
assert ModelRouter.get_provider_name("gpt-3.5-turbo") == "openai"
|
||||||
|
|
||||||
|
def test_get_provider_name_o1(self):
|
||||||
|
"""Test getting provider for o1 models."""
|
||||||
|
assert ModelRouter.get_provider_name("o1") == "openai"
|
||||||
|
assert ModelRouter.get_provider_name("o1-mini") == "openai"
|
||||||
|
assert ModelRouter.get_provider_name("o1-preview") == "openai"
|
||||||
|
|
||||||
|
def test_get_provider_name_o3(self):
|
||||||
|
"""Test getting provider for o3 models."""
|
||||||
|
assert ModelRouter.get_provider_name("o3-mini") == "openai"
|
||||||
|
|
||||||
|
def test_get_provider_name_claude(self):
|
||||||
|
"""Test getting provider for Claude models."""
|
||||||
|
assert ModelRouter.get_provider_name("claude-3-opus") == "anthropic"
|
||||||
|
assert ModelRouter.get_provider_name("claude-3-sonnet") == "anthropic"
|
||||||
|
assert ModelRouter.get_provider_name("claude-3-haiku") == "anthropic"
|
||||||
|
|
||||||
|
def test_get_provider_name_unknown(self):
|
||||||
|
"""Test getting provider for unknown models."""
|
||||||
|
assert ModelRouter.get_provider_name("unknown-model") is None
|
||||||
|
assert ModelRouter.get_provider_name("llama-2") is None
|
||||||
|
assert ModelRouter.get_provider_name("mistral") is None
|
||||||
|
|
||||||
|
def test_get_provider_name_case_insensitive(self):
|
||||||
|
"""Test model name matching is case insensitive."""
|
||||||
|
assert ModelRouter.get_provider_name("GPT-4O") == "openai"
|
||||||
|
assert ModelRouter.get_provider_name("CLAUDE-3-OPUS") == "anthropic"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_provider_success(self, mock_db):
|
||||||
|
"""Test getting provider by name."""
|
||||||
|
with patch("app.services.router.db_connection", mock_db):
|
||||||
|
provider = await ModelRouter.get_provider("openai")
|
||||||
|
assert provider is not None
|
||||||
|
assert isinstance(provider, Provider)
|
||||||
|
assert provider.name == "openai"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_provider_anthropic(self, mock_db):
|
||||||
|
"""Test getting Anthropic provider."""
|
||||||
|
with patch("app.services.router.db_connection", mock_db):
|
||||||
|
provider = await ModelRouter.get_provider("anthropic")
|
||||||
|
assert provider is not None
|
||||||
|
assert provider.name == "anthropic"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_provider_not_found(self, mock_db):
|
||||||
|
"""Test getting non-existent provider."""
|
||||||
|
with patch("app.services.router.db_connection", mock_db):
|
||||||
|
provider = await ModelRouter.get_provider("unknown")
|
||||||
|
assert provider is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_route_model_success(self, mock_db):
|
||||||
|
"""Test routing model to provider."""
|
||||||
|
with patch("app.services.router.db_connection", mock_db):
|
||||||
|
provider, error = await ModelRouter.route_model("gpt-4o")
|
||||||
|
assert provider is not None
|
||||||
|
assert error is None
|
||||||
|
assert provider.name == "openai"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_route_model_anthropic(self, mock_db):
|
||||||
|
"""Test routing Claude model."""
|
||||||
|
with patch("app.services.router.db_connection", mock_db):
|
||||||
|
provider, error = await ModelRouter.route_model("claude-3-opus")
|
||||||
|
assert provider is not None
|
||||||
|
assert error is None
|
||||||
|
assert provider.name == "anthropic"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_route_model_unsupported(self, mock_db):
|
||||||
|
"""Test routing unsupported model."""
|
||||||
|
with patch("app.services.router.db_connection", mock_db):
|
||||||
|
provider, error = await ModelRouter.route_model("llama-2")
|
||||||
|
assert provider is None
|
||||||
|
assert "Unsupported model" in error
|
||||||
|
|
||||||
|
def test_global_instance_exists(self):
|
||||||
|
"""Test global model_router instance exists."""
|
||||||
|
assert model_router is not None
|
||||||
|
assert isinstance(model_router, ModelRouter)
|
||||||
129
backend/tests/test_routes_anthropic.py
Normal file
129
backend/tests/test_routes_anthropic.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""Tests for Anthropic native routes."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.models import ClientKey, Provider
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_client_key():
|
||||||
|
"""Return a valid client key for testing."""
|
||||||
|
return ClientKey(
|
||||||
|
id=1,
|
||||||
|
key="test-api-key",
|
||||||
|
name="test-client",
|
||||||
|
is_active=True,
|
||||||
|
created_at="2024-01-01"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_anthropic_provider():
|
||||||
|
"""Return a mock Anthropic provider."""
|
||||||
|
return Provider(
|
||||||
|
id=2,
|
||||||
|
name="anthropic",
|
||||||
|
base_url="https://api.anthropic.com/v1",
|
||||||
|
api_type="anthropic",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnthropicMessages:
|
||||||
|
"""Test /v1/anthropic/messages endpoint."""
|
||||||
|
|
||||||
|
def test_anthropic_unauthorized(self):
|
||||||
|
"""Test Anthropic messages without auth."""
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post(
|
||||||
|
"/v1/anthropic/messages",
|
||||||
|
json={
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"messages": [{"role": "user", "content": "hi"}],
|
||||||
|
"max_tokens": 1024
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_anthropic_success(self, mock_client_key, mock_anthropic_provider):
|
||||||
|
"""Test successful Anthropic messages."""
|
||||||
|
from app.middleware.auth import get_current_client
|
||||||
|
from app.services.router import ModelRouter
|
||||||
|
from app.services.key_selector import key_selector
|
||||||
|
from app.services.logger import async_logger
|
||||||
|
from app.providers.litellm_wrapper import LiteLLMWrapper
|
||||||
|
|
||||||
|
app.dependency_overrides[get_current_client] = lambda: mock_client_key
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.model_dump.return_value = {
|
||||||
|
"id": "msg-test",
|
||||||
|
"content": [{"text": "Hello!"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
original_get_provider = ModelRouter.get_provider
|
||||||
|
original_get_key = key_selector.get_next_key
|
||||||
|
original_chat = LiteLLMWrapper.chat_completion
|
||||||
|
original_log = async_logger.log
|
||||||
|
|
||||||
|
async def mock_get_provider(name):
|
||||||
|
return mock_anthropic_provider
|
||||||
|
|
||||||
|
async def mock_get_key(provider_id):
|
||||||
|
return MagicMock(key="sk-ant-test")
|
||||||
|
|
||||||
|
async def mock_chat(*args, **kwargs):
|
||||||
|
return (mock_response, 10, 20)
|
||||||
|
|
||||||
|
async def mock_log(entry):
|
||||||
|
pass
|
||||||
|
|
||||||
|
ModelRouter.get_provider = staticmethod(lambda name: mock_get_provider(name))
|
||||||
|
key_selector.get_next_key = mock_get_key
|
||||||
|
LiteLLMWrapper.chat_completion = staticmethod(lambda *a, **k: mock_chat(*a, **k))
|
||||||
|
async_logger.log = mock_log
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post(
|
||||||
|
"/v1/anthropic/messages",
|
||||||
|
json={
|
||||||
|
"model": "claude-3-opus",
|
||||||
|
"messages": [{"role": "user", "content": "hi"}],
|
||||||
|
"max_tokens": 1024
|
||||||
|
},
|
||||||
|
headers={"Authorization": "Bearer test-api-key"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
finally:
|
||||||
|
ModelRouter.get_provider = original_get_provider
|
||||||
|
key_selector.get_next_key = original_get_key
|
||||||
|
LiteLLMWrapper.chat_completion = original_chat
|
||||||
|
async_logger.log = original_log
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnthropicModels:
|
||||||
|
"""Test /v1/anthropic/models endpoint."""
|
||||||
|
|
||||||
|
def test_anthropic_models_success(self, mock_client_key):
|
||||||
|
"""Test list Anthropic models."""
|
||||||
|
from app.middleware.auth import get_current_client
|
||||||
|
|
||||||
|
app.dependency_overrides[get_current_client] = lambda: mock_client_key
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get(
|
||||||
|
"/v1/anthropic/models",
|
||||||
|
headers={"Authorization": "Bearer test-api-key"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["object"] == "list"
|
||||||
|
for model in data["data"]:
|
||||||
|
assert model["owned_by"] == "anthropic"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
165
backend/tests/test_routes_chat.py
Normal file
165
backend/tests/test_routes_chat.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""Tests for chat completion routes."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.models import ClientKey, Provider
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_client_key():
|
||||||
|
"""Return a valid client key for testing."""
|
||||||
|
return ClientKey(
|
||||||
|
id=1,
|
||||||
|
key="test-api-key",
|
||||||
|
name="test-client",
|
||||||
|
is_active=True,
|
||||||
|
created_at="2024-01-01"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_provider():
|
||||||
|
"""Return a mock provider."""
|
||||||
|
return Provider(
|
||||||
|
id=1,
|
||||||
|
name="openai",
|
||||||
|
base_url="https://api.openai.com/v1",
|
||||||
|
api_type="openai",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestChatCompletions:
|
||||||
|
"""Test /v1/chat/completions endpoint."""
|
||||||
|
|
||||||
|
def test_chat_unauthorized(self):
|
||||||
|
"""Test chat without auth returns 401."""
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_chat_invalid_key(self):
|
||||||
|
"""Test chat with invalid key returns 401."""
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]},
|
||||||
|
headers={"Authorization": "Bearer invalid-key"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_chat_unsupported_model(self, mock_client_key, mock_provider):
|
||||||
|
"""Test chat with unsupported model returns 400."""
|
||||||
|
from app.middleware.auth import get_current_client
|
||||||
|
from app.services.router import model_router
|
||||||
|
|
||||||
|
app.dependency_overrides[get_current_client] = lambda: mock_client_key
|
||||||
|
|
||||||
|
async def mock_route_model(model):
|
||||||
|
return (None, f"Unsupported model: {model}")
|
||||||
|
|
||||||
|
original_route = model_router.route_model
|
||||||
|
model_router.route_model = mock_route_model
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={"model": "unknown-model", "messages": [{"role": "user", "content": "hi"}]},
|
||||||
|
headers={"Authorization": "Bearer test-api-key"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
finally:
|
||||||
|
model_router.route_model = original_route
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
def test_chat_success(self, mock_client_key, mock_provider):
|
||||||
|
"""Test successful chat completion."""
|
||||||
|
from app.middleware.auth import get_current_client
|
||||||
|
from app.services.router import model_router
|
||||||
|
from app.services.key_selector import key_selector
|
||||||
|
from app.services.logger import async_logger
|
||||||
|
from app.providers.litellm_wrapper import LiteLLMWrapper
|
||||||
|
|
||||||
|
app.dependency_overrides[get_current_client] = lambda: mock_client_key
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.model_dump.return_value = {
|
||||||
|
"id": "chatcmpl-test",
|
||||||
|
"choices": [{"message": {"content": "Hello!"}}]
|
||||||
|
}
|
||||||
|
|
||||||
|
original_route = model_router.route_model
|
||||||
|
original_get_key = key_selector.get_next_key
|
||||||
|
original_chat = LiteLLMWrapper.chat_completion
|
||||||
|
original_log = async_logger.log
|
||||||
|
|
||||||
|
async def mock_route_model(model):
|
||||||
|
return (mock_provider, None)
|
||||||
|
|
||||||
|
async def mock_get_key(provider_id):
|
||||||
|
return MagicMock(key="sk-test")
|
||||||
|
|
||||||
|
async def mock_chat(*args, **kwargs):
|
||||||
|
return (mock_response, 10, 20)
|
||||||
|
|
||||||
|
async def mock_log(entry):
|
||||||
|
pass
|
||||||
|
|
||||||
|
model_router.route_model = mock_route_model
|
||||||
|
key_selector.get_next_key = mock_get_key
|
||||||
|
LiteLLMWrapper.chat_completion = staticmethod(lambda *a, **k: mock_chat(*a, **k))
|
||||||
|
async_logger.log = mock_log
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post(
|
||||||
|
"/v1/chat/completions",
|
||||||
|
json={
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"messages": [{"role": "user", "content": "hi"}],
|
||||||
|
"temperature": 0.7
|
||||||
|
},
|
||||||
|
headers={"Authorization": "Bearer test-api-key"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
finally:
|
||||||
|
model_router.route_model = original_route
|
||||||
|
key_selector.get_next_key = original_get_key
|
||||||
|
LiteLLMWrapper.chat_completion = original_chat
|
||||||
|
async_logger.log = original_log
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class TestListModels:
|
||||||
|
"""Test /v1/models endpoint."""
|
||||||
|
|
||||||
|
def test_list_models_unauthorized(self):
|
||||||
|
"""Test list models without auth."""
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/v1/models")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_list_models_success(self, mock_client_key):
|
||||||
|
"""Test list models with valid auth."""
|
||||||
|
from app.middleware.auth import get_current_client
|
||||||
|
|
||||||
|
app.dependency_overrides[get_current_client] = lambda: mock_client_key
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get(
|
||||||
|
"/v1/models",
|
||||||
|
headers={"Authorization": "Bearer test-api-key"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["object"] == "list"
|
||||||
|
assert len(data["data"]) > 0
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
16
backend/tests/test_routes_health.py
Normal file
16
backend/tests/test_routes_health.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""Tests for health check route."""
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealthRoute:
|
||||||
|
"""Test health check endpoint."""
|
||||||
|
|
||||||
|
def test_health_check(self):
|
||||||
|
"""Test health check returns ok."""
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get("/health")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"status": "ok"}
|
||||||
121
backend/tests/test_routes_openai.py
Normal file
121
backend/tests/test_routes_openai.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""Tests for OpenAI native routes."""
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.models import ClientKey, Provider
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_client_key():
|
||||||
|
"""Return a valid client key for testing."""
|
||||||
|
return ClientKey(
|
||||||
|
id=1,
|
||||||
|
key="test-api-key",
|
||||||
|
name="test-client",
|
||||||
|
is_active=True,
|
||||||
|
created_at="2024-01-01"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_openai_provider():
|
||||||
|
"""Return a mock OpenAI provider."""
|
||||||
|
return Provider(
|
||||||
|
id=1,
|
||||||
|
name="openai",
|
||||||
|
base_url="https://api.openai.com/v1",
|
||||||
|
api_type="openai",
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpenAIChat:
|
||||||
|
"""Test /v1/openai/chat/completions endpoint."""
|
||||||
|
|
||||||
|
def test_openai_chat_unauthorized(self):
|
||||||
|
"""Test OpenAI chat without auth."""
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post(
|
||||||
|
"/v1/openai/chat/completions",
|
||||||
|
json={"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
def test_openai_chat_success(self, mock_client_key, mock_openai_provider):
|
||||||
|
"""Test successful OpenAI chat."""
|
||||||
|
from app.middleware.auth import get_current_client
|
||||||
|
from app.services.router import ModelRouter
|
||||||
|
from app.services.key_selector import key_selector
|
||||||
|
from app.services.logger import async_logger
|
||||||
|
from app.providers.litellm_wrapper import LiteLLMWrapper
|
||||||
|
|
||||||
|
app.dependency_overrides[get_current_client] = lambda: mock_client_key
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.model_dump.return_value = {
|
||||||
|
"id": "chatcmpl-test",
|
||||||
|
"choices": [{"message": {"content": "Hello!"}}]
|
||||||
|
}
|
||||||
|
|
||||||
|
original_get_provider = ModelRouter.get_provider
|
||||||
|
original_get_key = key_selector.get_next_key
|
||||||
|
original_chat = LiteLLMWrapper.chat_completion
|
||||||
|
original_log = async_logger.log
|
||||||
|
|
||||||
|
async def mock_get_provider(name):
|
||||||
|
return mock_openai_provider
|
||||||
|
|
||||||
|
async def mock_get_key(provider_id):
|
||||||
|
return MagicMock(key="sk-test")
|
||||||
|
|
||||||
|
async def mock_chat(*args, **kwargs):
|
||||||
|
return (mock_response, 10, 20)
|
||||||
|
|
||||||
|
async def mock_log(entry):
|
||||||
|
pass
|
||||||
|
|
||||||
|
ModelRouter.get_provider = staticmethod(lambda name: mock_get_provider(name))
|
||||||
|
key_selector.get_next_key = mock_get_key
|
||||||
|
LiteLLMWrapper.chat_completion = staticmethod(lambda *a, **k: mock_chat(*a, **k))
|
||||||
|
async_logger.log = mock_log
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.post(
|
||||||
|
"/v1/openai/chat/completions",
|
||||||
|
json={"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}]},
|
||||||
|
headers={"Authorization": "Bearer test-api-key"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
finally:
|
||||||
|
ModelRouter.get_provider = original_get_provider
|
||||||
|
key_selector.get_next_key = original_get_key
|
||||||
|
LiteLLMWrapper.chat_completion = original_chat
|
||||||
|
async_logger.log = original_log
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpenAIModels:
|
||||||
|
"""Test /v1/openai/models endpoint."""
|
||||||
|
|
||||||
|
def test_openai_models_success(self, mock_client_key):
|
||||||
|
"""Test list OpenAI models."""
|
||||||
|
from app.middleware.auth import get_current_client
|
||||||
|
|
||||||
|
app.dependency_overrides[get_current_client] = lambda: mock_client_key
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
response = client.get(
|
||||||
|
"/v1/openai/models",
|
||||||
|
headers={"Authorization": "Bearer test-api-key"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["object"] == "list"
|
||||||
|
for model in data["data"]:
|
||||||
|
assert model["owned_by"] == "openai"
|
||||||
|
finally:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
161
backend/uv.lock
generated
161
backend/uv.lock
generated
@@ -210,6 +210,75 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coverage"
|
||||||
|
version = "7.13.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "distro"
|
name = "distro"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
@@ -488,6 +557,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
|
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jinja2"
|
name = "jinja2"
|
||||||
version = "3.1.6"
|
version = "3.1.6"
|
||||||
@@ -783,6 +861,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "propcache"
|
name = "propcache"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -929,6 +1016,60 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-asyncio"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-cov"
|
||||||
|
version = "7.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "coverage" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-mock"
|
||||||
|
version = "3.15.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -1547,7 +1688,7 @@ wheels = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "zzrouter-backend"
|
name = "zzrouter-backend"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
@@ -1556,6 +1697,15 @@ dependencies = [
|
|||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "pytest-mock" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiosqlite", specifier = ">=0.20.0" },
|
{ name = "aiosqlite", specifier = ">=0.20.0" },
|
||||||
@@ -1564,3 +1714,12 @@ requires-dist = [
|
|||||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.41.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.41.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "httpx", specifier = ">=0.26.0" },
|
||||||
|
{ name = "pytest", specifier = ">=9.0.2" },
|
||||||
|
{ name = "pytest-asyncio", specifier = ">=0.23.0" },
|
||||||
|
{ name = "pytest-cov", specifier = ">=4.1.0" },
|
||||||
|
{ name = "pytest-mock", specifier = ">=3.12.0" },
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user