"""Tests for the /billing CLI handler (cli.py::_show_billing). Focus on the non-interactive (no live prompt_toolkit app) path — the same discipline as the /credits non-interactive test: it must render text, never invoke the modal (which would read the slash-worker's JSON-RPC stdin and hang). Plus role/kill-switch gating and logged-out handling. """ from __future__ import annotations from decimal import Decimal import pytest import agent.billing_view as bv from agent.billing_view import BillingState, CardInfo, MonthlyCap from cli import HermesCLI @pytest.fixture def cli(): obj = HermesCLI.__new__(HermesCLI) # bypass __init__ (no full app needed) obj._app = None # non-interactive: forces the text path return obj def _boom_modal(*a, **kw): raise AssertionError("modal must NOT be called in non-interactive mode") # ── Card visibility + the add-card path (inline w/ NAS card-resolver) ── def _scripted(*responses): it = iter(responses) def _modal(self, **kw): return next(it) return _modal def test_topup_overview_splits_onetime_from_automatic_copy(cli, monkeypatch, capsys): # (c): the interactive /topup overview states the one-time-vs-automatic # distinction up front in each first sentence, and keeps "credits" out of the # dollars-only surface. Auto-reload OFF → the automatic line omits amounts. cli._app = object() # interactive → reaches the split-copy explainer state = BillingState( logged_in=True, role="OWNER", balance_usd=Decimal("50"), cli_billing_enabled=True, charge_presets=(Decimal("25"),), card=CardInfo(brand="Visa", last4="4242"), portal_url="https://portal/billing", ) monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state) # Overview prints the explainer, then the action modal → back out with "cancel". monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("cancel"), raising=False) cli._show_billing("/topup") out = capsys.readouterr().out assert "Add funds now — a single charge, added to your balance today." in out assert "Refill when low — charges your card automatically when your balance falls below" in out # Dollars-only surface: no "credits" word leaks into /topup. assert "credits" not in out.lower() def test_topup_automatic_copy_generic_when_amounts_missing(cli, monkeypatch, capsys): # (5): auto-reload "enabled" but amounts absent (partial response) → generic # copy, never "charges — automatically … below —.". from agent.billing_view import AutoReload cli._app = object() state = BillingState( logged_in=True, role="OWNER", balance_usd=Decimal("50"), cli_billing_enabled=True, charge_presets=(Decimal("25"),), card=CardInfo(brand="Visa", last4="4242"), auto_reload=AutoReload(enabled=True, threshold_usd=None, reload_to_usd=None), portal_url="https://portal/billing", ) monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state) monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("cancel"), raising=False) cli._show_billing("/topup") out = capsys.readouterr().out assert ( "Refill when low — charges your card automatically when your balance " "falls below the amount you set." ) in out assert "charges — automatically" not in out def test_buy_flow_no_card_back_abandons(cli, monkeypatch, capsys): cli._app = object() nocard = BillingState( logged_in=True, role="OWNER", cli_billing_enabled=True, charge_presets=(Decimal("25"),), card=None, portal_url="https://portal/billing", ) calls = {"n": 0} def _no_fetch(*a, **kw): calls["n"] += 1 return nocard monkeypatch.setattr(bv, "build_billing_state", _no_fetch) monkeypatch.setattr(HermesCLI, "_prompt_text_input_modal", _scripted("cancel"), raising=False) cli._billing_buy_flow(nocard) out = capsys.readouterr().out assert "Add a card first" in out assert "Cancelled. No funds added." in out assert calls["n"] == 0 # backed out before any re-check