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