Add tests/test_services.py
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
"""Unit-тесты сервисного слоя."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from selectel_ml_tui.config import Credentials, Settings
|
||||
from selectel_ml_tui.models import Consumption, Metrics
|
||||
from selectel_ml_tui.services import (
|
||||
DashboardData,
|
||||
DataService,
|
||||
parse_keys,
|
||||
parse_metrics,
|
||||
parse_models,
|
||||
parse_overview,
|
||||
parse_tokens,
|
||||
)
|
||||
from selectel_ml_tui.storage import Storage
|
||||
|
||||
|
||||
def _summary_response(value: str = "85483") -> dict[str, Any]:
|
||||
return {
|
||||
"status": "success",
|
||||
"data": [{"value": value}],
|
||||
}
|
||||
|
||||
|
||||
async def test_parse_tokens() -> None:
|
||||
tokens = parse_tokens(
|
||||
{"input_tokens": 10, "output_tokens": 20, "cache_read": 30, "cache_write": 40}
|
||||
)
|
||||
assert tokens.input_tokens == 10
|
||||
assert tokens.output_tokens == 20
|
||||
assert tokens.cache_read_tokens == 30
|
||||
assert tokens.cache_write_tokens == 40
|
||||
|
||||
|
||||
async def test_parse_tokens_missing_fields_defaults() -> None:
|
||||
tokens = parse_tokens({})
|
||||
assert tokens.input_tokens == 0
|
||||
assert tokens.output_tokens == 0
|
||||
assert tokens.cache_read_tokens == 0
|
||||
assert tokens.cache_write_tokens == 0
|
||||
|
||||
|
||||
async def test_parse_metrics() -> None:
|
||||
metrics = parse_metrics(
|
||||
{
|
||||
"global": {
|
||||
"requests": 1035,
|
||||
"success_rate_pct": 100,
|
||||
"avg_ttft_seconds": 3.67,
|
||||
"avg_latency_seconds": 11.85,
|
||||
"tokens": {
|
||||
"input": 228542299,
|
||||
"output": 747268,
|
||||
"cached_input": 222355840,
|
||||
"cache_write": 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
assert metrics is not None
|
||||
assert metrics.availability_pct == 100
|
||||
assert metrics.avg_ttft_ms == 3670
|
||||
assert metrics.avg_latency_ms == 11850
|
||||
assert metrics.requests == 1035
|
||||
assert metrics.input_tokens == 228542299
|
||||
assert metrics.output_tokens == 747268
|
||||
assert metrics.cache_read_tokens == 222355840
|
||||
assert metrics.cache_write_tokens == 0
|
||||
|
||||
|
||||
async def test_parse_metrics_empty() -> None:
|
||||
assert parse_metrics({}) is None
|
||||
|
||||
|
||||
async def test_parse_models() -> None:
|
||||
models = parse_models(
|
||||
{
|
||||
"by_model": [
|
||||
{
|
||||
"model": "deepseek/deepseek-v4-flash",
|
||||
"requests": 69,
|
||||
"success_rate_pct": 100,
|
||||
"avg_ttft_seconds": 3.35,
|
||||
},
|
||||
{
|
||||
"model": "deepseek/deepseek-v4-pro",
|
||||
"requests": 966,
|
||||
"success_rate_pct": 100,
|
||||
"avg_ttft_seconds": 3.69,
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
assert len(models) == 2
|
||||
assert models[0].model_name == "deepseek/deepseek-v4-flash"
|
||||
assert models[0].requests == 69
|
||||
assert models[0].availability_pct == 100
|
||||
assert models[0].avg_ttft_ms == 3350
|
||||
assert models[1].model_id == "deepseek/deepseek-v4-pro"
|
||||
|
||||
|
||||
async def test_parse_overview() -> None:
|
||||
import datetime as _dt
|
||||
|
||||
consumption = parse_overview(
|
||||
{
|
||||
"totals": {
|
||||
"requests": 1034,
|
||||
"cost_rub_nano": "1075311673934",
|
||||
"tokens": {
|
||||
"input": 228314832,
|
||||
"output": 746702,
|
||||
"cached_input": 222128640,
|
||||
"cache_write": 0,
|
||||
},
|
||||
"tokens_total": {"total": 451607518},
|
||||
}
|
||||
},
|
||||
_dt.datetime(2026, 8, 19, tzinfo=_dt.timezone.utc),
|
||||
)
|
||||
assert consumption.amount_rub == Decimal("1075.311673934")
|
||||
assert consumption.requests == 1034
|
||||
assert consumption.tokens_total == 451607518
|
||||
assert consumption.input_tokens == 228314832
|
||||
assert consumption.cache_read_tokens == 222128640
|
||||
|
||||
|
||||
async def test_parse_keys() -> None:
|
||||
keys = parse_keys(
|
||||
[
|
||||
{
|
||||
"name": "Mei",
|
||||
"prefix": "sk-sl-v1-9dd61ec",
|
||||
"is_active": True,
|
||||
"budget": {
|
||||
"spend_limit_rub_nano": "1300000000000",
|
||||
"current_usage": {"spend_rub_nano": "1082949415164"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "Ollama Mac Air",
|
||||
"prefix": "sk-sl-v1-65fdb2e",
|
||||
"is_active": False,
|
||||
"budget": {"spend_limit_rub_nano": "100000000000"},
|
||||
},
|
||||
]
|
||||
)
|
||||
assert len(keys) == 2
|
||||
assert keys[0].name == "Mei"
|
||||
assert keys[0].is_active is True
|
||||
assert keys[0].spend_limit_rub == Decimal("1300")
|
||||
assert keys[0].spend_rub == Decimal("1082.949415164")
|
||||
assert keys[0].remaining_rub == Decimal("217.050584836")
|
||||
assert keys[1].is_active is False
|
||||
assert keys[1].spend_limit_rub == Decimal("100")
|
||||
|
||||
|
||||
async def test_refresh_billing_only(tmp_path) -> None:
|
||||
"""Только billing (без IAM): стоимость за месяц есть, gateway отмечен."""
|
||||
settings = Settings()
|
||||
creds = Credentials(static_token="static-token")
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
seen_end: dict[str, str] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if "summary_stats" in request.url.path:
|
||||
seen_end["summary"] = request.url.params.get("end", "")
|
||||
return httpx.Response(200, json=_summary_response())
|
||||
if "consumption" in request.url.path:
|
||||
seen_end["consumption"] = request.url.params.get("end", "")
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"status": "success",
|
||||
"data": [{"period": "2026-08-19T00:00:00", "value": "7133"}],
|
||||
},
|
||||
)
|
||||
return httpx.Response(200, json=_summary_response())
|
||||
|
||||
service = DataService(
|
||||
settings, creds, storage, transport=httpx.MockTransport(handler)
|
||||
)
|
||||
data = await service.refresh()
|
||||
assert data.month_consumption is not None
|
||||
assert data.month_consumption.amount_rub == Decimal("854.83")
|
||||
# Пункт A: end должен содержать время (не усечённую дату) и быть naive
|
||||
# (без смещения часового пояса — иначе API отвечает 422).
|
||||
assert "T" in seen_end["summary"]
|
||||
assert "T" in seen_end["consumption"]
|
||||
assert "+" not in seen_end["summary"]
|
||||
# Пункт B: дневная стоимость сегодня выделена отдельно.
|
||||
assert data.today_amount_rub == Decimal("71.33")
|
||||
assert not data.offline
|
||||
assert data.error is not None
|
||||
assert "сервисный пользователь" in data.error
|
||||
assert storage.get_latest_consumption() is not None
|
||||
history = storage.get_history(days=30)
|
||||
assert len(history) == 1
|
||||
# История хранит дневную стоимость (не месячную сумму).
|
||||
assert history[0].amount_rub == Decimal("71.33")
|
||||
|
||||
|
||||
async def test_refresh_billing_failure_uses_cache(tmp_path) -> None:
|
||||
"""Сбой billing + кэш → офлайн-данные из Storage."""
|
||||
settings = Settings()
|
||||
creds = Credentials(static_token="static-token")
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
storage.save_snapshot(
|
||||
consumption=Consumption(
|
||||
period=dt.datetime(2026, 8, 1, tzinfo=dt.timezone.utc),
|
||||
amount_rub=Decimal("10.00"),
|
||||
)
|
||||
)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(500, text="boom")
|
||||
|
||||
service = DataService(
|
||||
settings, creds, storage, transport=httpx.MockTransport(handler)
|
||||
)
|
||||
data = await service.refresh()
|
||||
assert data.offline is True
|
||||
assert data.source == "cache"
|
||||
assert data.month_consumption is not None
|
||||
assert data.month_consumption.amount_rub == Decimal("10.00")
|
||||
assert data.error is not None
|
||||
|
||||
|
||||
async def test_refresh_with_gateway(tmp_path) -> None:
|
||||
"""Billing + gateway (IAM): заполнены метрики, модели, ключи, бюджет."""
|
||||
settings = Settings()
|
||||
creds = Credentials(
|
||||
static_token="static-token",
|
||||
service_user="svc",
|
||||
password="pwd",
|
||||
project_id="proj-1",
|
||||
domain_id="239633",
|
||||
)
|
||||
storage = Storage(tmp_path / "cache.db")
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
path = request.url.path
|
||||
if path.endswith("/auth/tokens"):
|
||||
return httpx.Response(
|
||||
201,
|
||||
headers={"X-Subject-Token": "iam-token"},
|
||||
json={"token": {"expires_at": "2030-01-01T00:00:00Z"}},
|
||||
)
|
||||
if path.endswith("/gateways"):
|
||||
return httpx.Response(200, json={"data": [{"id": "gw-1"}]})
|
||||
if path.endswith("/overview"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"totals": {
|
||||
"requests": 1034,
|
||||
"cost_rub_nano": "1075311673934",
|
||||
"tokens": {"input": 100, "output": 200},
|
||||
"tokens_total": {"total": 300},
|
||||
},
|
||||
"budget": {
|
||||
"spend_limit_rub_nano": "2000000000000",
|
||||
"spend_rub_nano": "1077874156239",
|
||||
},
|
||||
},
|
||||
)
|
||||
if path.endswith("/metrics"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"global": {"success_rate_pct": 99.5, "avg_ttft_seconds": 0.3},
|
||||
"by_model": [
|
||||
{"model": "deepseek/deepseek-v4-flash", "requests": 69}
|
||||
],
|
||||
},
|
||||
)
|
||||
if path.endswith("/keys"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"data": [
|
||||
{
|
||||
"name": "Mei",
|
||||
"prefix": "sk-sl-v1-9dd61ec",
|
||||
"is_active": True,
|
||||
"budget": {
|
||||
"spend_limit_rub_nano": "1300000000000",
|
||||
"current_usage": {"spend_rub_nano": "1082949415164"},
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
return httpx.Response(200, json=_summary_response())
|
||||
|
||||
service = DataService(
|
||||
settings, creds, storage, transport=httpx.MockTransport(handler)
|
||||
)
|
||||
data = await service.refresh()
|
||||
assert data.month_consumption is not None
|
||||
assert data.month_consumption.amount_rub == Decimal("1075.311673934")
|
||||
assert data.metrics is not None
|
||||
assert data.metrics.availability_pct == 99.5
|
||||
assert data.metrics.avg_ttft_ms == 300
|
||||
assert len(data.models) == 1
|
||||
assert data.models[0].model_name == "deepseek/deepseek-v4-flash"
|
||||
assert len(data.keys) == 1
|
||||
assert data.keys[0].name == "Mei"
|
||||
assert data.budget is not None
|
||||
assert data.budget.spend_limit_rub == Decimal("2000")
|
||||
|
||||
|
||||
async def test_dashboard_data_has_data() -> None:
|
||||
empty = DashboardData()
|
||||
assert not empty.has_data
|
||||
full = DashboardData(metrics=Metrics())
|
||||
assert full.has_data
|
||||
Reference in New Issue
Block a user