mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-28 18:19:28 +00:00
* feat(billing): add payment_method union to the billing-state wire type
* feat(billing): carry the payment-method union through the gateway
NAS now sends a typed `paymentMethod` union on /api/billing/state alongside
the legacy `card` field. The gateway parses payloads field-by-field, so the
new field was dropped on the floor before reaching TUI/Desktop.
Parse it into PaymentMethodInfo and re-emit it as snake_case `payment_method`,
matching the translation the rest of this payload already does. The payment
method id is deliberately not carried through — clients have no use for it.
No client rendering changes: `card` stays populated for cards, so every
existing consumer behaves exactly as before and the new field is inert until
a surface opts into reading it.
* fix(billing): send only the fields each payment-method kind declares
The serializer emitted every key for every kind, so a Link method went out
carrying brand, last4 and wallet set to null. That contradicts the shared
type, where each kind declares its own fields: a client testing `'brand' in
pm` would read every Link method as a card, and one trusting the declared
non-null `brand` could crash on it.
Send each kind's own fields, and forward an unrecognized kind by name alone
so a client that predates it can still say something honest. The shared type
gains the matching fallback arm its own comment already promised.
Tests now follow a payload from the server response through to the client
wire for each kind, rather than checking parsing and serializing separately —
which is why the old expectation locked in the wrong shape without noticing.
* fix(billing): keep the payment-method kind narrowable
Typing the fallback arm's kind as `string & {}` borrowed a trick that only
works on unions of plain strings. On a union of objects it makes the
discriminant non-literal, so TypeScript stops narrowing on every arm — even
`if (pm.kind === 'card')` no longer gives you `brand`. The first client to
use this would have hit a compile error and reached for a cast.
An unrecognised kind now arrives as `unknown`, carrying the real name
alongside it, so every arm has a literal discriminant. A type-level test
pins this: it fails to compile if the discriminant stops narrowing.
The parser settles which kind it is, the way the card parser already does,
so the record cannot hold fields that do not belong to its kind and the
serializer no longer re-checks. The type comment also stops claiming `card`
is a safe signal — it is null for Link, so `!card` does not mean "nothing on
file".
600 lines
19 KiB
Python
600 lines
19 KiB
Python
"""Unit tests for the Phase 2b Remote Spending core + HTTP client.
|
|
|
|
Covers:
|
|
- Decimal money parsing/formatting (server emits decimal strings, not 2dp).
|
|
- BillingState payload parsing (role tiering, presets, bounds, sub-structs).
|
|
- Error-code → typed-exception mapping (the live-verified contract matrix).
|
|
- Fail-open builder behavior.
|
|
- Idempotency key generation.
|
|
- Custom-amount validation against bounds + multipleOf 0.01.
|
|
|
|
No network: HTTP-layer tests drive _raise_for_error directly and monkeypatch the
|
|
request function for the builder.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
import agent.billing_view as bv
|
|
from agent.billing_view import (
|
|
AutoReload,
|
|
AutoReloadCard,
|
|
BillingState,
|
|
CardInfo,
|
|
MonthlyCap,
|
|
PaymentMethodInfo,
|
|
billing_state_from_payload,
|
|
build_billing_state,
|
|
format_money,
|
|
new_idempotency_key,
|
|
parse_money,
|
|
validate_charge_amount,
|
|
)
|
|
import hermes_cli.nous_billing as nb
|
|
from hermes_cli.nous_billing import (
|
|
BillingAuthError,
|
|
BillingError,
|
|
BillingRateLimited,
|
|
BillingScopeRequired,
|
|
BillingStripeUnavailable,
|
|
BillingTransient,
|
|
BillingUpgradeCapExceeded,
|
|
_raise_for_error,
|
|
resolve_portal_base_url,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Decimal money
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw,expected",
|
|
[
|
|
("142.5", Decimal("142.5")), # decimal string, NOT 2dp — the headline case
|
|
("100", Decimal("100")),
|
|
("10000", Decimal("10000")),
|
|
("0.01", Decimal("0.01")),
|
|
(250, Decimal("250")),
|
|
(" 50 ", Decimal("50")),
|
|
],
|
|
)
|
|
def test_parse_money_valid(raw, expected):
|
|
assert parse_money(raw) == expected
|
|
|
|
|
|
@pytest.mark.parametrize("raw", [None, "", "abc", "1.2.3", "$5", {}])
|
|
def test_parse_money_invalid_returns_none(raw):
|
|
assert parse_money(raw) is None
|
|
|
|
|
|
def test_parse_money_never_uses_binary_float():
|
|
# If a float ever sneaks through, we still get an exact decimal, not 0.1+0.2 junk.
|
|
assert parse_money(0.1) == Decimal("0.1")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"value,expected",
|
|
[
|
|
(Decimal("142.5"), "$142.50"),
|
|
(Decimal("100"), "$100"),
|
|
(Decimal("0.01"), "$0.01"),
|
|
(Decimal("1000"), "$1000"),
|
|
(None, "—"),
|
|
],
|
|
)
|
|
def test_format_money(value, expected):
|
|
assert format_money(value) == expected
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# BillingState payload parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _member_payload() -> dict:
|
|
return {
|
|
"org": {"id": "o1", "slug": "acme", "name": "Acme", "role": "MEMBER"},
|
|
"balanceUsd": "142.5",
|
|
"cliBillingEnabled": True,
|
|
"chargePresets": ["100", "250", "500"],
|
|
"bounds": {"minUsd": "10", "maxUsd": "10000"},
|
|
"card": None,
|
|
"monthlyCap": None,
|
|
"autoReload": None,
|
|
}
|
|
|
|
|
|
def _owner_payload() -> dict:
|
|
p = _member_payload()
|
|
p["org"]["role"] = "OWNER"
|
|
p["card"] = {"brand": "visa", "last4": "4242"}
|
|
p["monthlyCap"] = {
|
|
"limitUsd": "1000",
|
|
"spentThisMonthUsd": "180",
|
|
"isDefaultCeiling": True,
|
|
}
|
|
p["autoReload"] = {"enabled": True, "thresholdUsd": "20", "reloadToUsd": "100"}
|
|
return p
|
|
|
|
|
|
def test_state_member_tier_parse():
|
|
s = billing_state_from_payload(_member_payload())
|
|
assert s.logged_in
|
|
assert s.role == "MEMBER"
|
|
assert s.balance_usd == Decimal("142.5")
|
|
assert s.cli_billing_enabled is True
|
|
assert s.charge_presets == (Decimal("100"), Decimal("250"), Decimal("500"))
|
|
assert s.min_usd == Decimal("10") and s.max_usd == Decimal("10000")
|
|
assert s.card is None and s.monthly_cap is None and s.auto_reload is None
|
|
assert s.is_admin is False
|
|
assert s.can_charge is False # not admin
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"role,can_change_plan_raw,is_admin,can_change_plan",
|
|
[
|
|
("OWNER", None, True, True),
|
|
("ADMIN", None, True, True),
|
|
("FINANCE_ADMIN", True, False, True),
|
|
("SECURITY_ADMIN", None, False, False),
|
|
("MEMBER", None, False, False),
|
|
],
|
|
)
|
|
def test_state_five_roles(
|
|
role, can_change_plan_raw, is_admin, can_change_plan
|
|
):
|
|
payload = _member_payload()
|
|
payload["org"]["role"] = role
|
|
if can_change_plan_raw is not None:
|
|
payload["canChangePlan"] = can_change_plan_raw
|
|
|
|
state = billing_state_from_payload(payload)
|
|
|
|
assert state.is_admin is is_admin
|
|
assert state.can_change_plan_raw is can_change_plan_raw
|
|
assert state.can_change_plan is can_change_plan
|
|
if role == "SECURITY_ADMIN":
|
|
assert state.card is None
|
|
assert state.monthly_cap is None
|
|
assert state.auto_reload is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"role,server_capability",
|
|
[("MEMBER", True), ("OWNER", False)],
|
|
)
|
|
def test_state_can_change_plan_prefers_server_capability(role, server_capability):
|
|
payload = _member_payload()
|
|
payload["org"]["role"] = role
|
|
payload["canChangePlan"] = server_capability
|
|
|
|
state = billing_state_from_payload(payload)
|
|
|
|
assert state.can_change_plan is server_capability
|
|
|
|
|
|
def test_state_can_change_plan_falls_back_to_legacy_role_check():
|
|
owner = _member_payload()
|
|
owner["org"]["role"] = "OWNER"
|
|
member = _member_payload()
|
|
|
|
assert billing_state_from_payload(owner).can_change_plan is True
|
|
assert billing_state_from_payload(member).can_change_plan is False
|
|
|
|
|
|
def test_can_charge_finance_admin_with_server_capability():
|
|
"""Server capability can grant FINANCE_ADMIN charge access."""
|
|
payload = _member_payload()
|
|
payload["org"]["role"] = "FINANCE_ADMIN"
|
|
payload["canChangePlan"] = True
|
|
|
|
state = billing_state_from_payload(payload)
|
|
|
|
assert state.is_admin is False
|
|
assert state.can_change_plan is True
|
|
assert state.can_charge is True
|
|
|
|
|
|
def test_state_owner_tier_parse():
|
|
s = billing_state_from_payload(_owner_payload())
|
|
assert s.is_admin is True
|
|
assert s.can_charge is True # admin + kill-switch on
|
|
assert s.card == CardInfo(brand="visa", last4="4242")
|
|
assert s.card is not None and s.card.masked == "visa ····4242"
|
|
assert s.monthly_cap == MonthlyCap(
|
|
limit_usd=Decimal("1000"),
|
|
spent_this_month_usd=Decimal("180"),
|
|
is_default_ceiling=True,
|
|
)
|
|
assert s.auto_reload == AutoReload(
|
|
enabled=True, threshold_usd=Decimal("20"), reload_to_usd=Decimal("100")
|
|
)
|
|
|
|
|
|
def test_state_parses_link_payment_method():
|
|
payload = _owner_payload()
|
|
payload["paymentMethod"] = {
|
|
"kind": "link",
|
|
"email": "billing@example.com",
|
|
"paymentMethodId": "pm_secret",
|
|
"purpose": "top-up",
|
|
"resolvedVia": "customerDefault",
|
|
}
|
|
|
|
state = billing_state_from_payload(payload)
|
|
|
|
assert state.payment_method == PaymentMethodInfo(
|
|
kind="link",
|
|
email="billing@example.com",
|
|
resolved_via="customerDefault",
|
|
)
|
|
|
|
|
|
def test_state_without_payment_method_keeps_it_absent():
|
|
state = billing_state_from_payload(_owner_payload())
|
|
|
|
assert state.payment_method is None
|
|
|
|
|
|
@pytest.mark.parametrize("raw_payment_method", ["link", {"email": "billing@example.com"}])
|
|
def test_state_ignores_malformed_payment_method(raw_payment_method):
|
|
payload = _owner_payload()
|
|
payload["paymentMethod"] = raw_payment_method
|
|
|
|
state = billing_state_from_payload(payload)
|
|
|
|
assert state.payment_method is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw_card,expected",
|
|
[
|
|
(
|
|
{"kind": "canonical", "paymentMethodId": "ignored", "brand": "ignored"},
|
|
AutoReloadCard(kind="canonical"),
|
|
),
|
|
(
|
|
{
|
|
"kind": "distinct",
|
|
"paymentMethodId": "pm_auto",
|
|
"brand": None,
|
|
"last4": None,
|
|
},
|
|
AutoReloadCard(
|
|
kind="distinct",
|
|
payment_method_id="pm_auto",
|
|
brand=None,
|
|
last4=None,
|
|
),
|
|
),
|
|
(
|
|
{"kind": "none", "last4": "ignored"},
|
|
AutoReloadCard(kind="none"),
|
|
),
|
|
],
|
|
)
|
|
def test_state_parses_auto_reload_card_variants(raw_card, expected):
|
|
payload = _owner_payload()
|
|
payload["autoReload"]["card"] = raw_card
|
|
|
|
state = billing_state_from_payload(payload)
|
|
|
|
assert state.auto_reload is not None
|
|
assert state.auto_reload.card == expected
|
|
|
|
|
|
@pytest.mark.parametrize("raw_card", [None, "canonical", {}, {"kind": "future"}])
|
|
def test_state_ignores_unrecognized_auto_reload_card(raw_card):
|
|
payload = _owner_payload()
|
|
payload["autoReload"]["card"] = raw_card
|
|
|
|
state = billing_state_from_payload(payload)
|
|
|
|
assert state.auto_reload is not None
|
|
assert state.auto_reload.card is None
|
|
|
|
|
|
def test_state_can_charge_false_when_killswitch_off():
|
|
p = _owner_payload()
|
|
p["cliBillingEnabled"] = False
|
|
s = billing_state_from_payload(p)
|
|
assert s.is_admin is True
|
|
assert s.can_charge is False # kill-switch off gates the action
|
|
|
|
|
|
def test_state_handles_garbage_substructs():
|
|
p = _member_payload()
|
|
p["card"] = "not-a-dict"
|
|
p["monthlyCap"] = 42
|
|
p["chargePresets"] = ["100", "bad", "250"] # bad preset dropped, not crash
|
|
s = billing_state_from_payload(p)
|
|
assert s.card is None and s.monthly_cap is None
|
|
assert s.charge_presets == (Decimal("100"), Decimal("250"))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Error-code → typed-exception mapping (live-verified contract)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _Headers:
|
|
def __init__(self, d):
|
|
self._d = d
|
|
|
|
def get(self, k):
|
|
return self._d.get(k)
|
|
|
|
|
|
def test_401_maps_to_auth_error():
|
|
with pytest.raises(BillingAuthError) as ei:
|
|
_raise_for_error(401, {"error": "invalid_token"})
|
|
assert ei.value.status == 401
|
|
|
|
|
|
def test_403_insufficient_scope_maps_to_scope_required():
|
|
with pytest.raises(BillingScopeRequired) as ei:
|
|
_raise_for_error(403, {"error": "insufficient_scope", "portalUrl": "/billing"})
|
|
assert ei.value.error == "insufficient_scope"
|
|
# portalUrl is resolved to an absolute URL (relative-by-design from the server).
|
|
assert (ei.value.portal_url or "").startswith("http")
|
|
assert (ei.value.portal_url or "").endswith("/billing")
|
|
|
|
|
|
@pytest.mark.parametrize("status", [429, 503])
|
|
def test_rate_limited_maps_with_retry_after(status):
|
|
with pytest.raises(BillingRateLimited) as ei:
|
|
_raise_for_error(
|
|
status,
|
|
{"error": "rate_limited"},
|
|
_Headers({"Retry-After": "60"}),
|
|
)
|
|
assert ei.value.retry_after == 60
|
|
# Critically: a rate limit is NOT a generic BillingError-only — surfaces branch on type.
|
|
assert isinstance(ei.value, BillingRateLimited)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"status,error,expected_type",
|
|
[
|
|
(503, "stripe_unavailable", BillingStripeUnavailable),
|
|
(429, "upgrade_cap_exceeded", BillingUpgradeCapExceeded),
|
|
],
|
|
)
|
|
def test_specific_billing_throttle_errors_remain_distinguishable(
|
|
status, error, expected_type
|
|
):
|
|
with pytest.raises(expected_type) as ei:
|
|
_raise_for_error(
|
|
status,
|
|
{"error": error},
|
|
_Headers({"Retry-After": "90"}),
|
|
)
|
|
|
|
assert ei.value.error == error
|
|
assert ei.value.retry_after == 90
|
|
assert isinstance(ei.value, BillingTransient)
|
|
assert not isinstance(ei.value, BillingRateLimited)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"error",
|
|
[
|
|
"no_payment_method",
|
|
"cli_billing_disabled",
|
|
"role_required",
|
|
"monthly_cap_exceeded",
|
|
"org_access_denied",
|
|
],
|
|
)
|
|
def test_other_403s_map_to_base_error_with_portal_url(error):
|
|
with pytest.raises(BillingError) as ei:
|
|
_raise_for_error(403, {"error": error, "portalUrl": "/billing?topup=open"})
|
|
# Not a scope/auth/rate subclass — the generic gate-denial path.
|
|
assert not isinstance(ei.value, (BillingScopeRequired, BillingAuthError, BillingRateLimited))
|
|
assert ei.value.error == error
|
|
# portalUrl resolved to an absolute deep-link (server sends it relative).
|
|
assert (ei.value.portal_url or "").startswith("http")
|
|
assert (ei.value.portal_url or "").endswith("/billing?topup=open")
|
|
|
|
|
|
def test_monthly_cap_exceeded_carries_remaining_in_payload():
|
|
with pytest.raises(BillingError) as ei:
|
|
_raise_for_error(
|
|
403,
|
|
{
|
|
"error": "monthly_cap_exceeded",
|
|
"remainingUsd": "12.50",
|
|
"isDefaultCeiling": True,
|
|
"portalUrl": "/billing",
|
|
},
|
|
)
|
|
assert ei.value.payload["remainingUsd"] == "12.50"
|
|
assert ei.value.payload["isDefaultCeiling"] is True
|
|
|
|
|
|
def test_400_amount_out_of_bounds_is_base_error():
|
|
with pytest.raises(BillingError) as ei:
|
|
_raise_for_error(400, {"error": "amount_out_of_bounds", "message": "too big"})
|
|
assert ei.value.status == 400
|
|
assert "too big" in str(ei.value)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# post_charge requires idempotency key (client-side guard)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_post_charge_requires_idempotency_key():
|
|
with pytest.raises(BillingError) as ei:
|
|
nb.post_charge(amount_usd=50, idempotency_key="")
|
|
assert ei.value.error == "idempotency_key_required"
|
|
|
|
|
|
def test_get_charge_status_requires_id():
|
|
with pytest.raises(BillingError) as ei:
|
|
nb.get_charge_status("")
|
|
assert ei.value.error == "invalid_charge_id"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Base-URL resolution precedence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_portal_base_url_env_override(monkeypatch):
|
|
monkeypatch.setenv("HERMES_PORTAL_BASE_URL", "https://preview.example.com/")
|
|
assert resolve_portal_base_url() == "https://preview.example.com"
|
|
|
|
|
|
def test_portal_base_url_falls_back_to_state(monkeypatch):
|
|
monkeypatch.delenv("HERMES_PORTAL_BASE_URL", raising=False)
|
|
monkeypatch.delenv("NOUS_PORTAL_BASE_URL", raising=False)
|
|
assert (
|
|
resolve_portal_base_url({"portal_base_url": "https://stored.example.com/"})
|
|
== "https://stored.example.com"
|
|
)
|
|
|
|
|
|
def test_portal_base_url_default(monkeypatch):
|
|
monkeypatch.delenv("HERMES_PORTAL_BASE_URL", raising=False)
|
|
monkeypatch.delenv("NOUS_PORTAL_BASE_URL", raising=False)
|
|
assert resolve_portal_base_url() == nb.DEFAULT_PORTAL_BASE_URL
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fail-open builder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_build_billing_state_logged_out_on_auth_error(monkeypatch):
|
|
def _auth(*a, **kw):
|
|
raise BillingAuthError("nope", status=401)
|
|
|
|
monkeypatch.setattr(nb, "get_billing_state", _auth)
|
|
s = build_billing_state()
|
|
assert s.logged_in is False
|
|
assert s.error is None # cleanly logged out, not an error
|
|
|
|
|
|
def test_build_billing_state_fail_open_on_http_error(monkeypatch):
|
|
def _boom(*a, **kw):
|
|
raise BillingError("portal exploded", status=500)
|
|
|
|
monkeypatch.setattr(nb, "get_billing_state", _boom)
|
|
s = build_billing_state()
|
|
assert s.logged_in is False
|
|
assert "portal exploded" in (s.error or "")
|
|
|
|
|
|
def test_build_billing_state_parses_and_prefers_server_portal_url(monkeypatch):
|
|
payload = _owner_payload()
|
|
payload["portalUrl"] = "https://portal.example.com/billing?topup=open"
|
|
monkeypatch.setattr(nb, "get_billing_state", lambda *a, **kw: payload)
|
|
s = build_billing_state()
|
|
assert s.logged_in is True
|
|
assert s.portal_url == "https://portal.example.com/billing?topup=open"
|
|
assert s.balance_usd == Decimal("142.5")
|
|
|
|
|
|
def test_build_billing_state_builds_fallback_portal_url(monkeypatch):
|
|
payload = _member_payload() # no portalUrl key
|
|
monkeypatch.setattr(nb, "get_billing_state", lambda *a, **kw: payload)
|
|
monkeypatch.setattr(bv, "_fallback_portal_url", lambda base: "FALLBACK")
|
|
# resolve_portal_base_url is imported into bv via local import; patch nb's.
|
|
s = build_billing_state()
|
|
assert s.portal_url == "FALLBACK"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Idempotency
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_new_idempotency_key_unique_and_uuid_shaped():
|
|
a, b = new_idempotency_key(), new_idempotency_key()
|
|
assert a != b
|
|
assert len(a) == 36 and a.count("-") == 4
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Amount validation (Screen 3 custom input)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_validate_amount_ok():
|
|
v = validate_charge_amount("100", min_usd=Decimal("10"), max_usd=Decimal("10000"))
|
|
assert v.ok and v.amount == Decimal("100")
|
|
|
|
|
|
def test_validate_amount_strips_dollar_sign():
|
|
v = validate_charge_amount("$250", min_usd=Decimal("10"), max_usd=Decimal("10000"))
|
|
assert v.ok and v.amount == Decimal("250")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw,err_substr",
|
|
[
|
|
("", "dollar amount"),
|
|
("0", "greater than"),
|
|
("-5", "greater than"),
|
|
("10.005", "cent"), # multipleOf 0.01 — sub-cent rejected
|
|
("5", "Minimum"), # below bounds.minUsd
|
|
("99999", "Maximum"), # above bounds.maxUsd
|
|
],
|
|
)
|
|
def test_validate_amount_rejections(raw, err_substr):
|
|
v = validate_charge_amount(raw, min_usd=Decimal("10"), max_usd=Decimal("10000"))
|
|
assert not v.ok
|
|
assert err_substr.lower() in (v.error or "").lower()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# HERMES_DEV_BILLING_FIXTURE — offline card/scope state scaffolding (T0)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_billing_fixture_unset_returns_none(monkeypatch):
|
|
"""No env var → fixture is inert (the real portal path runs)."""
|
|
monkeypatch.delenv("HERMES_DEV_BILLING_FIXTURE", raising=False)
|
|
assert bv._dev_fixture_billing_state() is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"name,has_card,is_admin,billing_on",
|
|
[
|
|
("nocard", False, True, True),
|
|
("card", True, True, True),
|
|
("card-autoreload", True, True, True),
|
|
("notadmin", True, False, True),
|
|
("billing-off", False, True, False),
|
|
],
|
|
)
|
|
def test_billing_fixture_card_and_gate_invariants(monkeypatch, name, has_card, is_admin, billing_on):
|
|
"""Each fixture state honors the card/admin/kill-switch contract the gate reads."""
|
|
monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", name)
|
|
s = build_billing_state()
|
|
assert s.logged_in is True
|
|
assert (s.card is not None) is has_card
|
|
assert s.is_admin is is_admin
|
|
assert s.cli_billing_enabled is billing_on
|
|
|
|
|
|
def test_billing_fixture_autoreload_state(monkeypatch):
|
|
"""card-autoreload pairs a card with an enabled auto-reload (drives that screen)."""
|
|
monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", "card-autoreload")
|
|
s = build_billing_state()
|
|
assert s.card is not None
|
|
assert s.auto_reload is not None and s.auto_reload.enabled is True
|
|
|
|
|
|
def test_billing_fixture_logged_out_and_unknown(monkeypatch):
|
|
monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", "logged-out")
|
|
assert build_billing_state().logged_in is False
|
|
monkeypatch.setenv("HERMES_DEV_BILLING_FIXTURE", "bogus-state")
|
|
s = build_billing_state()
|
|
assert s.logged_in is False and "bogus-state" in (s.error or "")
|