"""Tests for the hermes_cli.nous_billing HTTP client's response handling. Focus: a 2xx response with a NON-JSON body (e.g. a reverse-proxy / SPA fallback HTML page when a route isn't actually serving the billing API) must surface as a typed BillingError, NOT a raw json.JSONDecodeError that escapes the typed-error contract and reads downstream as "not logged in". """ from __future__ import annotations import io import json from contextlib import contextmanager import pytest from hermes_cli import nous_billing as nb class _FakeResp(io.BytesIO): """Minimal urlopen() context-manager stand-in with a .status attribute.""" def __init__(self, body: bytes, status: int = 200): super().__init__(body) self.status = status def __enter__(self): return self def __exit__(self, *a): self.close() @contextmanager def _stub(monkeypatch, body: bytes, status: int = 200): # Bypass auth/token resolution entirely — we only exercise response parsing. monkeypatch.setattr(nb, "_resolve_token_and_base", lambda **kw: ("tok", "https://portal.example")) monkeypatch.setattr(nb, "_token_cache", None, raising=False) monkeypatch.setattr(nb.urllib.request, "urlopen", lambda req, timeout=None: _FakeResp(body, status)) yield def test_non_json_2xx_body_raises_typed_billing_error(monkeypatch): # A 200 that returns an HTML page (route not actually mounted) must NOT crash # with json.JSONDecodeError — it becomes a typed, non-auth BillingError. html = b"Not Found" with _stub(monkeypatch, html, status=200): with pytest.raises(nb.BillingError) as ei: nb.get_subscription_state() exc = ei.value # Not the auth subclass — this is "endpoint unavailable", not "logged out". assert not isinstance(exc, nb.BillingAuthError) assert getattr(exc, "error", None) == "endpoint_unavailable" def test_empty_2xx_body_returns_empty_dict(monkeypatch): with _stub(monkeypatch, b"", status=200): assert nb.get_billing_state() == {} def test_valid_json_2xx_body_parses(monkeypatch): payload = {"org": {"name": "Acme"}, "balanceUsd": "10"} with _stub(monkeypatch, json.dumps(payload).encode(), status=200): assert nb.get_billing_state() == payload def test_transient_siblings_not_parent_child(): assert issubclass(nb.BillingRateLimited, nb.BillingTransient) assert issubclass(nb.BillingStripeUnavailable, nb.BillingTransient) assert issubclass(nb.BillingUpgradeCapExceeded, nb.BillingTransient) assert not issubclass(nb.BillingStripeUnavailable, nb.BillingRateLimited) assert not issubclass(nb.BillingUpgradeCapExceeded, nb.BillingRateLimited) assert not issubclass(nb.BillingRateLimited, nb.BillingStripeUnavailable) # --------------------------------------------------------------------------- # Subscription change (V3): the request the client actually puts on the wire. # --------------------------------------------------------------------------- @contextmanager def _capture(monkeypatch, body: bytes = b"{}", status: int = 200): """Stub urlopen, recording the urllib.request.Request the client built.""" seen: dict[str, object] = {} monkeypatch.setattr( nb, "_resolve_token_and_base", lambda **kw: ("tok", "https://portal.example") ) def _fake_urlopen(req, timeout=None): seen["method"] = req.get_method() seen["url"] = req.full_url seen["data"] = json.loads(req.data.decode()) if req.data else None seen["headers"] = {k.lower(): v for k, v in req.header_items()} return _FakeResp(body, status) monkeypatch.setattr(nb.urllib.request, "urlopen", _fake_urlopen) yield seen def test_post_subscription_preview_request(monkeypatch): with _capture(monkeypatch) as seen: nb.post_subscription_preview(subscription_type_id="nous-chat-plan-40") assert seen["method"] == "POST" assert seen["url"] == "https://portal.example/api/billing/subscription/preview" assert seen["data"] == {"subscriptionTypeId": "nous-chat-plan-40"} def test_put_pending_change_tier_change_request(monkeypatch): with _capture(monkeypatch) as seen: nb.put_subscription_pending_change(subscription_type_id="nous-chat-plan-10") assert seen["method"] == "PUT" assert ( seen["url"] == "https://portal.example/api/billing/subscription/pending-change" ) assert seen["data"] == { "type": "tier_change", "subscriptionTypeId": "nous-chat-plan-10", } def test_put_pending_change_cancellation_request(monkeypatch): with _capture(monkeypatch) as seen: nb.put_subscription_pending_change(cancel=True) assert seen["method"] == "PUT" assert seen["data"] == {"type": "cancellation"} def test_put_pending_change_without_tier_or_cancel_raises(): # No urlopen stub: a bad call must fail BEFORE any network I/O. with pytest.raises(nb.BillingError) as ei: nb.put_subscription_pending_change() assert getattr(ei.value, "error", None) == "invalid_subscription_type" def test_delete_pending_change_request(monkeypatch): with _capture(monkeypatch) as seen: nb.delete_subscription_pending_change() assert seen["method"] == "DELETE" assert ( seen["url"] == "https://portal.example/api/billing/subscription/pending-change" ) assert seen["data"] is None def test_post_subscription_upgrade_sends_idempotency_key(monkeypatch): with _capture(monkeypatch) as seen: nb.post_subscription_upgrade( subscription_type_id="nous-chat-plan-40", idempotency_key="abc-123" ) assert seen["method"] == "POST" assert seen["url"] == "https://portal.example/api/billing/subscription/upgrade" assert seen["data"] == {"subscriptionTypeId": "nous-chat-plan-40"} assert seen["headers"].get("idempotency-key") == "abc-123" def test_post_subscription_upgrade_blank_key_raises(): with pytest.raises(nb.BillingError) as ei: nb.post_subscription_upgrade( subscription_type_id="nous-chat-plan-40", idempotency_key=" " ) assert getattr(ei.value, "error", None) == "idempotency_key_required"