389 lines
15 KiB
Python
389 lines
15 KiB
Python
"""Tests for configurable monthly budget limit with percentage-used tracking (#692).
|
|
|
|
Covers:
|
|
- Static analysis: hooks present in config.py, providers.py, panels.js, style.css, i18n.js
|
|
- Backend: monthly_budget propagates through all three response paths
|
|
- Backend: save_settings validates and coerces provider_cost_budget
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
|
|
import urllib.error
|
|
|
|
import api.config as config
|
|
import api.profiles as profiles
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def _read(rel):
|
|
return (ROOT / rel).read_text(encoding="utf-8")
|
|
|
|
|
|
# ── Static analysis ───────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_provider_cost_budget_in_settings_defaults():
|
|
assert "provider_cost_budget" in config._SETTINGS_DEFAULTS, (
|
|
"provider_cost_budget must appear in _SETTINGS_DEFAULTS"
|
|
)
|
|
|
|
|
|
def test_monthly_budget_in_providers_response_dicts():
|
|
src = _read("api/providers.py")
|
|
assert '"monthly_budget"' in src or "'monthly_budget'" in src, (
|
|
"monthly_budget must appear in providers.py response dicts"
|
|
)
|
|
|
|
|
|
def test_get_provider_cost_budget_defined():
|
|
src = _read("api/providers.py")
|
|
assert "_get_provider_cost_budget" in src, (
|
|
"_get_provider_cost_budget helper must be defined in providers.py"
|
|
)
|
|
|
|
|
|
def test_attach_budget_controls_defined():
|
|
src = _read("static/panels.js")
|
|
assert "function _attachBudgetControls" in src, (
|
|
"_attachBudgetControls must be defined in panels.js"
|
|
)
|
|
|
|
|
|
def test_attach_budget_controls_called_in_data_branch():
|
|
src = _read("static/panels.js")
|
|
# Must be called in the hasData branch (after body.appendChild(wrap))
|
|
assert src.count("_attachBudgetControls") >= 2, (
|
|
"_attachBudgetControls must be called in both the data and no-data branches"
|
|
)
|
|
|
|
|
|
def test_budget_dom_classes_in_panels_js():
|
|
src = _read("static/panels.js")
|
|
for cls in ("provider-cost-budget-row", "provider-cost-budget-bar", "provider-cost-budget-input"):
|
|
assert cls in src, f"DOM class '{cls}' must appear in panels.js"
|
|
|
|
|
|
def test_budget_dom_classes_in_style_css():
|
|
src = _read("static/style.css")
|
|
for cls in ("provider-cost-budget-row", "provider-cost-budget-bar", "provider-cost-budget-input"):
|
|
assert cls in src, f"CSS class '{cls}' must appear in style.css"
|
|
|
|
|
|
def test_budget_i18n_keys_in_i18n_js():
|
|
src = _read("static/i18n.js")
|
|
for key in (
|
|
"provider_cost_budget_label",
|
|
"provider_cost_budget_pct",
|
|
"provider_cost_budget_save_failed",
|
|
):
|
|
assert key in src, f"i18n key '{key}' must appear in i18n.js"
|
|
|
|
|
|
def test_settings_post_in_panels_js_for_budget():
|
|
src = _read("static/panels.js")
|
|
assert "provider_cost_budget" in src, (
|
|
"panels.js must POST provider_cost_budget to /api/settings for budget save"
|
|
)
|
|
|
|
|
|
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, payload: bytes):
|
|
self._payload = payload
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *exc):
|
|
return False
|
|
|
|
def read(self):
|
|
return self._payload
|
|
|
|
|
|
def _with_config(model=None, providers=None):
|
|
old_cfg = dict(config.cfg)
|
|
old_mtime = config._cfg_mtime
|
|
config.cfg.clear()
|
|
config.cfg["model"] = model or {}
|
|
if providers is not None:
|
|
config.cfg["providers"] = providers
|
|
try:
|
|
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
|
except Exception:
|
|
config._cfg_mtime = 0.0
|
|
return old_cfg, old_mtime
|
|
|
|
|
|
def _restore_config(old_cfg, old_mtime):
|
|
config.cfg.clear()
|
|
config.cfg.update(old_cfg)
|
|
config._cfg_mtime = old_mtime
|
|
|
|
|
|
def _fake_urlopen_with_usage(usage=5.0, limit=20.0, label="Credits"):
|
|
def fake_urlopen(req, timeout):
|
|
payload = {"data": {"usage": usage, "limit": limit, "label": label}}
|
|
return _FakeResponse(json.dumps(payload).encode("utf-8"))
|
|
return fake_urlopen
|
|
|
|
|
|
# ── Behavioral: monthly_budget in responses ───────────────────────────────────
|
|
|
|
|
|
def test_monthly_budget_in_available_response(monkeypatch, tmp_path):
|
|
"""get_provider_cost_history returns monthly_budget when configured."""
|
|
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
(tmp_path / ".env").write_text("OPENROUTER_API_KEY=test-key\n", encoding="utf-8")
|
|
|
|
settings_path = tmp_path / "settings.json"
|
|
settings_path.write_text(json.dumps({"provider_cost_budget": 75.0}), encoding="utf-8")
|
|
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
|
|
|
|
old_cfg, old_mtime = _with_config(model={"provider": "openrouter"})
|
|
|
|
import api.providers as providers
|
|
monkeypatch.setattr(providers.urllib.request, "urlopen", _fake_urlopen_with_usage(5.0, 20.0))
|
|
|
|
try:
|
|
result = providers.get_provider_cost_history("openrouter", days=7)
|
|
finally:
|
|
_restore_config(old_cfg, old_mtime)
|
|
|
|
assert result["monthly_budget"] == 75.0
|
|
assert result["limit"] == 20.0
|
|
|
|
|
|
def test_monthly_budget_none_when_not_configured(monkeypatch, tmp_path):
|
|
"""get_provider_cost_history returns monthly_budget: None when no budget set."""
|
|
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
(tmp_path / ".env").write_text("OPENROUTER_API_KEY=test-key\n", encoding="utf-8")
|
|
|
|
settings_path = tmp_path / "settings.json"
|
|
settings_path.write_text(json.dumps({}), encoding="utf-8")
|
|
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
|
|
|
|
old_cfg, old_mtime = _with_config(model={"provider": "openrouter"})
|
|
|
|
import api.providers as providers
|
|
monkeypatch.setattr(providers.urllib.request, "urlopen", _fake_urlopen_with_usage(5.0, 20.0))
|
|
|
|
try:
|
|
result = providers.get_provider_cost_history("openrouter", days=7)
|
|
finally:
|
|
_restore_config(old_cfg, old_mtime)
|
|
|
|
assert result["monthly_budget"] is None
|
|
|
|
|
|
def test_monthly_budget_in_unavailable_response(monkeypatch, tmp_path):
|
|
"""monthly_budget is included when upstream OpenRouter call fails."""
|
|
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
(tmp_path / ".env").write_text("OPENROUTER_API_KEY=test-key\n", encoding="utf-8")
|
|
|
|
settings_path = tmp_path / "settings.json"
|
|
settings_path.write_text(json.dumps({"provider_cost_budget": 50.0}), encoding="utf-8")
|
|
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
|
|
|
|
old_cfg, old_mtime = _with_config(model={"provider": "openrouter"})
|
|
|
|
import api.providers as providers
|
|
|
|
req = providers.urllib.request.Request("https://openrouter.ai/api/v1/key")
|
|
|
|
def fail_urlopen(_req, timeout=None):
|
|
raise urllib.error.HTTPError(req.full_url, 500, "Server Error", {}, BytesIO(b""))
|
|
|
|
monkeypatch.setattr(providers.urllib.request, "urlopen", fail_urlopen)
|
|
|
|
try:
|
|
result = providers.get_provider_cost_history("openrouter", days=7)
|
|
finally:
|
|
_restore_config(old_cfg, old_mtime)
|
|
|
|
assert result["status"] == "unavailable"
|
|
assert result["monthly_budget"] == 50.0
|
|
|
|
|
|
def test_monthly_budget_in_no_key_response(monkeypatch, tmp_path):
|
|
"""monthly_budget is included in no_key response."""
|
|
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
# No .env → no key
|
|
|
|
settings_path = tmp_path / "settings.json"
|
|
settings_path.write_text(json.dumps({"provider_cost_budget": 100.0}), encoding="utf-8")
|
|
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
|
|
|
|
old_cfg, old_mtime = _with_config(model={"provider": "openrouter"})
|
|
|
|
import api.providers as providers
|
|
|
|
def explode(*_a, **_kw):
|
|
raise AssertionError("must not call network without a key")
|
|
|
|
monkeypatch.setattr(providers.urllib.request, "urlopen", explode)
|
|
|
|
try:
|
|
result = providers.get_provider_cost_history("openrouter", days=7)
|
|
finally:
|
|
_restore_config(old_cfg, old_mtime)
|
|
|
|
assert result["status"] == "no_key"
|
|
assert result["monthly_budget"] == 100.0
|
|
|
|
|
|
# ── Behavioral: save_settings validation ─────────────────────────────────────
|
|
|
|
|
|
def test_save_settings_persists_float_budget(monkeypatch, tmp_path):
|
|
settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
|
|
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", tmp_path)
|
|
monkeypatch.setattr(config, "resolve_default_workspace", lambda x: tmp_path)
|
|
|
|
config.save_settings({"provider_cost_budget": 100.0})
|
|
loaded = config.load_settings()
|
|
assert loaded["provider_cost_budget"] == 100.0
|
|
|
|
|
|
def test_save_settings_coerces_string_budget(monkeypatch, tmp_path):
|
|
settings_path = tmp_path / "settings.json"
|
|
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
|
|
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", tmp_path)
|
|
monkeypatch.setattr(config, "resolve_default_workspace", lambda x: tmp_path)
|
|
|
|
config.save_settings({"provider_cost_budget": "50.00"})
|
|
loaded = config.load_settings()
|
|
assert loaded["provider_cost_budget"] == 50.0
|
|
|
|
|
|
def test_save_settings_clears_budget_with_none(monkeypatch, tmp_path):
|
|
settings_path = tmp_path / "settings.json"
|
|
# Pre-seed a budget
|
|
settings_path.write_text(json.dumps({"provider_cost_budget": 75.0}), encoding="utf-8")
|
|
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
|
|
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", tmp_path)
|
|
monkeypatch.setattr(config, "resolve_default_workspace", lambda x: tmp_path)
|
|
|
|
config.save_settings({"provider_cost_budget": None})
|
|
loaded = config.load_settings()
|
|
assert loaded["provider_cost_budget"] is None
|
|
|
|
|
|
def test_save_settings_clears_budget_with_empty_string(monkeypatch, tmp_path):
|
|
settings_path = tmp_path / "settings.json"
|
|
settings_path.write_text(json.dumps({"provider_cost_budget": 75.0}), encoding="utf-8")
|
|
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
|
|
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", tmp_path)
|
|
monkeypatch.setattr(config, "resolve_default_workspace", lambda x: tmp_path)
|
|
|
|
config.save_settings({"provider_cost_budget": ""})
|
|
loaded = config.load_settings()
|
|
assert loaded["provider_cost_budget"] is None
|
|
|
|
|
|
def test_save_settings_rejects_zero_budget(monkeypatch, tmp_path):
|
|
settings_path = tmp_path / "settings.json"
|
|
settings_path.write_text(json.dumps({"provider_cost_budget": 50.0}), encoding="utf-8")
|
|
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
|
|
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", tmp_path)
|
|
monkeypatch.setattr(config, "resolve_default_workspace", lambda x: tmp_path)
|
|
|
|
config.save_settings({"provider_cost_budget": 0})
|
|
loaded = config.load_settings()
|
|
# Previous value must be unchanged
|
|
assert loaded["provider_cost_budget"] == 50.0
|
|
|
|
|
|
def test_save_settings_rejects_negative_budget(monkeypatch, tmp_path):
|
|
settings_path = tmp_path / "settings.json"
|
|
settings_path.write_text(json.dumps({"provider_cost_budget": 50.0}), encoding="utf-8")
|
|
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
|
|
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", tmp_path)
|
|
monkeypatch.setattr(config, "resolve_default_workspace", lambda x: tmp_path)
|
|
|
|
config.save_settings({"provider_cost_budget": -10})
|
|
loaded = config.load_settings()
|
|
assert loaded["provider_cost_budget"] == 50.0
|
|
|
|
|
|
def test_save_settings_rejects_invalid_string_budget(monkeypatch, tmp_path):
|
|
settings_path = tmp_path / "settings.json"
|
|
settings_path.write_text(json.dumps({"provider_cost_budget": 50.0}), encoding="utf-8")
|
|
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
|
|
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", tmp_path)
|
|
monkeypatch.setattr(config, "resolve_default_workspace", lambda x: tmp_path)
|
|
|
|
config.save_settings({"provider_cost_budget": "invalid"})
|
|
loaded = config.load_settings()
|
|
assert loaded["provider_cost_budget"] == 50.0
|
|
|
|
|
|
def test_save_settings_rejects_infinity_budget(monkeypatch, tmp_path):
|
|
settings_path = tmp_path / "settings.json"
|
|
settings_path.write_text(json.dumps({"provider_cost_budget": 50.0}), encoding="utf-8")
|
|
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
|
|
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", tmp_path)
|
|
monkeypatch.setattr(config, "resolve_default_workspace", lambda x: tmp_path)
|
|
|
|
config.save_settings({"provider_cost_budget": float("inf")})
|
|
loaded = config.load_settings()
|
|
assert loaded["provider_cost_budget"] == 50.0
|
|
|
|
|
|
def test_save_settings_rejects_nan_budget(monkeypatch, tmp_path):
|
|
settings_path = tmp_path / "settings.json"
|
|
settings_path.write_text(json.dumps({"provider_cost_budget": 50.0}), encoding="utf-8")
|
|
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
|
|
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", tmp_path)
|
|
monkeypatch.setattr(config, "resolve_default_workspace", lambda x: tmp_path)
|
|
|
|
config.save_settings({"provider_cost_budget": float("nan")})
|
|
loaded = config.load_settings()
|
|
assert loaded["provider_cost_budget"] == 50.0
|
|
|
|
|
|
def test_save_settings_rejects_huge_budget(monkeypatch, tmp_path):
|
|
settings_path = tmp_path / "settings.json"
|
|
settings_path.write_text(json.dumps({"provider_cost_budget": 50.0}), encoding="utf-8")
|
|
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
|
|
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", tmp_path)
|
|
monkeypatch.setattr(config, "resolve_default_workspace", lambda x: tmp_path)
|
|
|
|
config.save_settings({"provider_cost_budget": 1e9})
|
|
loaded = config.load_settings()
|
|
assert loaded["provider_cost_budget"] == 50.0
|
|
|
|
|
|
def test_save_settings_rejects_subcent_budget_after_rounding(monkeypatch, tmp_path):
|
|
settings_path = tmp_path / "settings.json"
|
|
settings_path.write_text(json.dumps({"provider_cost_budget": 50.0}), encoding="utf-8")
|
|
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
|
|
monkeypatch.setattr(config, "DEFAULT_WORKSPACE", tmp_path)
|
|
monkeypatch.setattr(config, "resolve_default_workspace", lambda x: tmp_path)
|
|
|
|
config.save_settings({"provider_cost_budget": 0.004})
|
|
loaded = config.load_settings()
|
|
assert loaded["provider_cost_budget"] == 50.0
|
|
|
|
|
|
def test_get_provider_cost_budget_rejects_huge_manual_setting(monkeypatch, tmp_path):
|
|
settings_path = tmp_path / "settings.json"
|
|
settings_path.write_text(json.dumps({"provider_cost_budget": 5e12}), encoding="utf-8")
|
|
monkeypatch.setattr(config, "SETTINGS_FILE", settings_path)
|
|
|
|
import api.providers as providers
|
|
|
|
assert providers._get_provider_cost_budget() is None
|