From c61cde63b7d445865e29b8ca2d68c1eece84b853 Mon Sep 17 00:00:00 2001 From: zeke <40004347+KAJdev@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:33:18 -0800 Subject: [PATCH 1/2] feat: CON-1117 add CLI payment commands --- runpod/rp_cli/main.py | 3 +- runpod/rp_cli/payments.py | 238 ++++++++++++ tests/test_cli/test_payments_command.py | 425 ++++++++++++++++++++++ tests/test_cli/test_payments_transport.py | 170 +++++++++ 4 files changed, 835 insertions(+), 1 deletion(-) create mode 100644 runpod/rp_cli/payments.py create mode 100644 tests/test_cli/test_payments_command.py create mode 100644 tests/test_cli/test_payments_transport.py diff --git a/runpod/rp_cli/main.py b/runpod/rp_cli/main.py index 8943d9960..e3b5d55a4 100644 --- a/runpod/rp_cli/main.py +++ b/runpod/rp_cli/main.py @@ -1170,10 +1170,11 @@ def _mount_groups() -> None: from runpod.cli.groups.ssh.commands import ssh_cli from runpod.rp_cli.catalog import catalog from runpod.rp_cli.endpoint import endpoint + from runpod.rp_cli.payments import payments from runpod.rp_cli.pod import pod from runpod.rp_cli.volume import volume - for command in (ssh_cli, pod, catalog, volume, endpoint): + for command in (ssh_cli, pod, catalog, volume, endpoint, payments): cli.add_command(command) diff --git a/runpod/rp_cli/payments.py b/runpod/rp_cli/payments.py new file mode 100644 index 000000000..fee5f1a47 --- /dev/null +++ b/runpod/rp_cli/payments.py @@ -0,0 +1,238 @@ +"""create and inspect wallet payments without handling wallet signing.""" + +import re +import sys +from urllib.parse import quote +from uuid import uuid4 + +import click + +from runpod import error +from runpod.api.rest import run_rest_request +from runpod.rp_cli.output import json_option, print_fields, print_json + +_STATUS_MESSAGES = { + "REQUIRES_PAYMENT": "Payment is required. Wallet credit is not available.", + "REQUIRES_ACTION": "Action is required. Wallet credit is not available.", + "PROCESSING": "Payment is processing. Wallet credit is not available yet.", + "SETTLED": "Payment is settled. Wallet credit is not available yet.", + "CREDITED": "Payment is credited. Wallet credit is available.", + "CANCELED": "Payment is canceled. Wallet credit is not available.", + "FAILED": "Payment failed. Wallet credit is not available.", + "REVIEW": "Payment needs review. Contact support. Wallet credit is not available.", +} + + +def _payment_response(response: object) -> dict: + if ( + not isinstance(response, dict) + or not isinstance(response.get("id"), str) + or not response["id"] + or not isinstance(response.get("status"), str) + or not response["status"] + ): + raise ValueError("Invalid payment response") + return response + + +def _create_payment( + amount_cents, *, rail, currency, idempotency_key, shared_payment_token +): + body = { + "amountCents": amount_cents, + "currency": currency, + "rail": rail, + "idempotencyKey": idempotency_key, + } + if shared_payment_token is not None: + body["sharedPaymentToken"] = shared_payment_token + return _payment_response( + run_rest_request("POST", "/v2/billing/payments", json=body, max_retries=0) + ) + + +def _get_payment(payment_id: str): + if not payment_id.strip() or payment_id in (".", ".."): + raise ValueError("Invalid payment identifier") + return _payment_response( + run_rest_request("GET", f"/v2/billing/payments/{quote(payment_id, safe='')}") + ) + + +def _amount_cents(amount: str, rail: str) -> int: + if len(amount) > 32 or re.fullmatch(r"[0-9]+(?:\.[0-9]{1,2})?", amount) is None: + raise click.BadParameter( + "use a dollar decimal with at most two decimal places (for example, 10.01)", + param_hint="--amount", + ) + dollars, _, cents = amount.partition(".") + value = int(dollars) * 100 + int(cents.ljust(2, "0")) + maximum = 1_000_000 if rail == "STABLECOIN" else 2_500_000 + if not 1_000 <= value <= maximum: + raise click.BadParameter( + f"must be between $10.00 and ${maximum // 100}.00 for {rail}", + param_hint="--amount", + ) + return value + + +def _read_spt() -> str: + if sys.stdin.isatty(): + raise click.UsageError( + "--spt-stdin requires piped input, not an interactive terminal" + ) + try: + token = sys.stdin.read(515) + except Exception: + raise click.ClickException( + "Could not read the shared payment token from stdin" + ) from None + if token.endswith("\r\n"): + token = token[:-2] + elif token.endswith("\n"): + token = token[:-1] + if len(token) > 512 or re.fullmatch(r"spt_[A-Za-z0-9]+", token) is None: + raise click.UsageError( + "stdin must contain one spt_[A-Za-z0-9]+ token, at most 512 characters" + ) + return token + + +def _request_failure(exc: Exception, creating: bool) -> click.ClickException: + message = "Payment request failed." + if isinstance(exc, error.AuthenticationError): + message = "Payment authentication failed; check your API key." + elif isinstance(exc, error.QueryError) and type(exc.status_code) is int: + message = f"Payment request failed (HTTP {exc.status_code})." + if creating: + message += ( + " If no payment id is known, retry create with the same payload and" + " --idempotency-key set to the original retry key." + " Otherwise, use payments get with that id." + ) + else: + message += " Retry payments get with the same id." + return click.ClickException(message) + + +def _show(payment: dict, as_json: bool) -> None: + if as_json: + print_json(payment) + return + print_fields( + payment, + [ + ("id", lambda row: row["id"]), + ("status", lambda row: row["status"]), + ("amount (cents)", lambda row: row.get("amountCents")), + ("currency", lambda row: row.get("currency")), + ("rail", lambda row: row.get("rail")), + ("created", lambda row: row.get("createdAt")), + ("credited", lambda row: row.get("creditedAt")), + ("balance", lambda row: row.get("balance")), + ], + ) + click.echo( + _STATUS_MESSAGES.get( + payment["status"], + "Wallet credit is not confirmed; only CREDITED confirms it.", + ) + ) + if payment.get("deposit"): + click.echo("Deposit instructions:") + print_fields( + payment["deposit"], + [ + ("network", lambda row: row.get("network")), + ("address", lambda row: row.get("address")), + ("token", lambda row: row.get("token")), + ("token contract", lambda row: row.get("tokenContract")), + ("amount (atomic)", lambda row: row.get("amountAtomic")), + ], + ) + if payment.get("nextAction"): + click.echo("Next action:") + print_fields( + payment["nextAction"], + [ + ("type", lambda row: row.get("type")), + ("url", lambda row: row.get("url")), + ], + ) + if payment["status"] not in ("CREDITED", "CANCELED", "FAILED", "REVIEW"): + click.echo("Poll payments get with this id to check its status.") + + +@click.group() +def payments(): + """Create and inspect wallet payments. Only CREDITED confirms wallet credit.""" + + +@payments.command(name="create") +@click.option( + "--amount", required=True, help="USD dollars, with at most two decimal places." +) +@click.option( + "--rail", + type=click.Choice(["STABLECOIN", "CARD"]), + default="STABLECOIN", + show_default=True, +) +@click.option( + "--currency", type=click.Choice(["usd"]), default="usd", show_default=True +) +@click.option( + "--idempotency-key", + help="Reuse a retry key (1-255 characters). Generated automatically when omitted.", +) +@click.option( + "--spt-stdin", + is_flag=True, + help="Read a shared payment token from piped stdin (required for CARD).", +) +@json_option +def payments_create(amount, rail, currency, idempotency_key, spt_stdin, as_json): + """Create a payment; this does not transfer or sign with a wallet. + + A retry key is generated and written to stderr before sending the request. + If no id is known, reuse it with --idempotency-key and the same payload. + Once an id is known, poll payments get with that id. + """ + amount_cents = _amount_cents(amount, rail) + if idempotency_key is not None and ( + not idempotency_key.strip() or len(idempotency_key) > 255 + ): + raise click.BadParameter( + "use a nonempty key of 1 to 255 characters", param_hint="--idempotency-key" + ) + if spt_stdin and rail != "CARD": + raise click.UsageError("--spt-stdin requires --rail CARD") + if rail == "CARD" and not spt_stdin: + raise click.UsageError("--rail CARD requires --spt-stdin") + token = _read_spt() if spt_stdin else None + if idempotency_key is None: + idempotency_key = str(uuid4()) + click.echo(f"Payment retry key: {idempotency_key}", err=True) + try: + payment = _create_payment( + amount_cents, + rail=rail, + currency=currency, + idempotency_key=idempotency_key, + shared_payment_token=token, + ) + except Exception as exc: + raise _request_failure(exc, creating=True) from None + _show(payment, as_json) + + +@payments.command(name="get") +@click.argument("payment_id") +@json_option +def payments_get(payment_id, as_json): + """Retrieve a payment by PAYMENT_ID without creating another payment.""" + try: + payment = _get_payment(payment_id) + except Exception as exc: + raise _request_failure(exc, creating=False) from None + _show(payment, as_json) diff --git a/tests/test_cli/test_payments_command.py b/tests/test_cli/test_payments_command.py new file mode 100644 index 000000000..b975b5a96 --- /dev/null +++ b/tests/test_cli/test_payments_command.py @@ -0,0 +1,425 @@ +"""payment commands, exact currency parsing, and secret-safe failures.""" + +import io +import json +import traceback +from importlib.metadata import distribution +from unittest.mock import patch +from uuid import UUID + +import click +import pytest +import requests +from click.testing import CliRunner + +import runpod +from runpod import error +from runpod.rp_cli import payments +from runpod.rp_cli.main import cli +from tests.test_cli.test_payments_transport import PAYMENT + +CREATE = ["payments", "create", "--amount", "10.01", "--idempotency-key", "retry-key"] + + +@pytest.fixture(autouse=True) +def isolate_cli(monkeypatch): + monkeypatch.setattr("runpod.rp_cli.update.start_background_check", lambda: None) + monkeypatch.setattr("runpod.rp_cli.console.console._width", 200) + + +@pytest.mark.parametrize("alias", ["rp", "runpod"]) +def test_entrypoints_create_and_get(alias): + entrypoint = next( + entry for entry in distribution("runpod").entry_points if entry.name == alias + ) + assert entrypoint.value == "runpod.rp_cli.main:run" + with patch.object(payments, "_create_payment", return_value=PAYMENT) as create: + result = CliRunner().invoke(cli, [*CREATE, "--json"], prog_name=alias) + assert result.exit_code == 0, result.output + assert json.loads(result.output) == PAYMENT + create.assert_called_once_with( + 1001, + rail="STABLECOIN", + currency="usd", + idempotency_key="retry-key", + shared_payment_token=None, + ) + with patch.object(payments, "_get_payment", return_value=PAYMENT) as get: + result = CliRunner().invoke( + cli, ["payments", "get", "payment-1", "--json"], prog_name=alias + ) + assert result.exit_code == 0, result.output + assert json.loads(result.output) == PAYMENT + get.assert_called_once_with("payment-1") + + +@pytest.mark.parametrize("rail", ["STABLECOIN", "CARD"]) +def test_create_generates_retry_key_without_changing_json_output(rail): + with patch.object(payments, "_create_payment", return_value=PAYMENT) as create: + result = CliRunner().invoke( + cli, + [ + "payments", + "create", + "--amount", + "25", + "--rail", + rail, + "--json", + *(["--spt-stdin"] if rail == "CARD" else []), + ], + input="spt_secret\n" if rail == "CARD" else None, + ) + assert result.exit_code == 0, result.output + key = create.call_args.kwargs["idempotency_key"] + assert UUID(key).version == 4 + assert result.stderr == f"Payment retry key: {key}\n" + assert json.loads(result.stdout) == PAYMENT + assert "spt_secret" not in result.output + + +def test_generated_retry_key_is_available_before_an_interrupted_request(): + def interrupted(*args, **kwargs): + stream = click.get_text_stream("stderr") + stream.flush() + assert kwargs["idempotency_key"] in stream.buffer.getvalue().decode() + raise KeyboardInterrupt + + with patch.object(payments, "_create_payment", side_effect=interrupted) as create: + result = CliRunner().invoke(cli, ["payments", "create", "--amount", "25"]) + assert result.exit_code == 1 + assert create.call_args.kwargs["idempotency_key"] in result.stderr + + +def test_generated_key_can_replay_an_ambiguous_request(): + with patch.object( + payments, + "_create_payment", + side_effect=[requests.Timeout("spt_secret"), PAYMENT], + ) as create: + first = CliRunner().invoke(cli, ["payments", "create", "--amount", "25"]) + assert first.exit_code == 1 + key = create.call_args.kwargs["idempotency_key"] + assert key in first.stderr + assert "--idempotency-key" in first.output + assert "spt_secret" not in first.output + retry = CliRunner().invoke( + cli, ["payments", "create", "--amount", "25", "--idempotency-key", key] + ) + assert retry.exit_code == 0 + assert create.call_args_list[0] == create.call_args_list[1] + + +def test_separate_create_commands_generate_distinct_keys(): + with patch.object(payments, "_create_payment", return_value=PAYMENT) as create: + for _ in range(2): + result = CliRunner().invoke(cli, ["payments", "create", "--amount", "25"]) + assert result.exit_code == 0 + assert ( + create.call_args_list[0].kwargs["idempotency_key"] + != create.call_args_list[1].kwargs["idempotency_key"] + ) + + +@pytest.mark.parametrize( + "amount, rail, cents", + [ + ("10", "STABLECOIN", 1000), + ("10.1", "STABLECOIN", 1010), + ("19.99", "STABLECOIN", 1999), + ("10000.00", "STABLECOIN", 1000000), + ("25000", "CARD", 2500000), + ("00010.01", "CARD", 1001), + ], +) +def test_exact_cents(amount, rail, cents): + with patch.object(payments, "_create_payment", return_value=PAYMENT) as create: + result = CliRunner().invoke( + cli, + [ + *CREATE, + "--amount", + amount, + "--rail", + rail, + *(["--spt-stdin"] if rail == "CARD" else []), + ], + input="spt_test123\n" if rail == "CARD" else None, + ) + assert result.exit_code == 0, result.output + assert create.call_args.args == (cents,) + + +@pytest.mark.parametrize( + "amount, rail", + [ + ("9.99", "STABLECOIN"), + ("10000.01", "STABLECOIN"), + ("25000.01", "CARD"), + ("10.001", "CARD"), + ("10.000", "CARD"), + ("NaN", "CARD"), + ("Infinity", "CARD"), + ("1e2", "CARD"), + (" 10", "CARD"), + ("+10", "CARD"), + ("-10", "CARD"), + ("10.", "CARD"), + ("10", "CARD"), + ("1" * 1000, "CARD"), + ], +) +def test_invalid_amount_never_sends(amount, rail): + with patch.object(payments, "_create_payment") as create: + result = CliRunner().invoke(cli, [*CREATE, "--amount", amount, "--rail", rail]) + assert result.exit_code == 2 + assert "--amount" in result.output + create.assert_not_called() + + +@pytest.mark.parametrize( + "arguments", + [ + [*CREATE, "--idempotency-key", ""], + [*CREATE, "--idempotency-key", " "], + [*CREATE, "--idempotency-key", "k" * 256], + [*CREATE, "--rail", "stablecoin"], + [*CREATE, "--currency", "USD"], + [*CREATE, "--spt-stdin"], + [*CREATE, "--rail", "CARD"], + ], +) +def test_invalid_flags_never_send(arguments): + with patch.object(payments, "_create_payment") as create: + result = CliRunner().invoke(cli, arguments) + assert result.exit_code == 2 + create.assert_not_called() + + +@pytest.mark.parametrize("suffix", ["", "\n", "\r\n"]) +def test_stdin_token(suffix): + token = "spt_" + "a" * 508 + with patch.object(payments, "_create_payment", return_value=PAYMENT) as create: + result = CliRunner().invoke( + cli, + [*CREATE, "--rail", "CARD", "--spt-stdin", "--json"], + input=token + suffix, + ) + assert result.exit_code == 0, result.output + assert create.call_args.kwargs["shared_payment_token"] == token + assert token not in result.output + assert json.loads(result.output) == PAYMENT + + +@pytest.mark.parametrize( + "token", + [ + "", + "spt_", + "spt_secret-123", + " spt_secret", + "spt_secret ", + "spt_secret\n\n", + "spt_secret\nother", + "spt_" + "a" * 509, + "spt_" + "a" * 10000, + ], +) +def test_invalid_stdin_never_echoes_or_sends(token): + with patch.object(payments, "_create_payment") as create: + result = CliRunner().invoke( + cli, [*CREATE, "--rail", "CARD", "--spt-stdin"], input=token + ) + assert result.exit_code == 2 + assert "stdin must contain" in result.output + assert "spt_secret" not in result.output + create.assert_not_called() + + +def test_stdin_read_is_bounded(monkeypatch): + class BoundedInput(io.StringIO): + def read(self, size=-1): + assert size == 515 + return super().read(size) + + monkeypatch.setattr(payments.sys, "stdin", BoundedInput("spt_" + "a" * 10000)) + with pytest.raises(click.UsageError): + payments._read_spt() + + +def test_interactive_stdin_rejected(monkeypatch): + stream = io.StringIO("spt_secret") + monkeypatch.setattr(stream, "isatty", lambda: True) + monkeypatch.setattr(payments.sys, "stdin", stream) + with pytest.raises(click.UsageError, match="piped input"): + payments._read_spt() + + +def test_stdin_read_failure_is_safe(monkeypatch): + stream = io.StringIO() + monkeypatch.setattr( + stream, "read", lambda size: (_ for _ in ()).throw(OSError("spt_secret")) + ) + monkeypatch.setattr(payments.sys, "stdin", stream) + with pytest.raises(click.ClickException) as raised: + payments._read_spt() + assert "spt_secret" not in "".join(traceback.format_exception(raised.value)) + + +def test_spt_environment_is_not_consumed(monkeypatch): + monkeypatch.setenv("RUNPOD_SHARED_PAYMENT_TOKEN", "spt_secret") + monkeypatch.setenv("RUNPOD_SPT", "spt_secret") + with patch.object(payments, "_create_payment", return_value=PAYMENT) as create: + result = CliRunner().invoke(cli, [*CREATE, "--rail", "CARD"]) + assert result.exit_code == 2 + assert "--rail CARD requires --spt-stdin" in result.output + create.assert_not_called() + + +@pytest.mark.parametrize("flag", ["--shared-payment-token", "--spt"]) +def test_no_argv_token_option(flag): + result = CliRunner().invoke(cli, [*CREATE, "--rail", "CARD", flag, "spt_secret"]) + assert result.exit_code == 2 + assert "No such option" in result.output + + +@pytest.mark.parametrize( + "status, phrase", + [ + ("REQUIRES_PAYMENT", "Payment is required"), + ("REQUIRES_ACTION", "Action is required"), + ("PROCESSING", "Payment is processing"), + ("SETTLED", "Payment is settled"), + ("CREDITED", "Wallet credit is available."), + ("CANCELED", "Payment is canceled"), + ("FAILED", "Payment failed"), + ("REVIEW", "Payment needs review"), + ("FUTURE_STATUS", "Wallet credit is not confirmed"), + ], +) +@pytest.mark.parametrize("command", ["create", "get"]) +@pytest.mark.parametrize("as_json", [False, True]) +def test_status_output(status, phrase, command, as_json): + response = dict(PAYMENT, status=status) + arguments = CREATE if command == "create" else ["payments", "get", "payment-1"] + with patch.object(payments, f"_{command}_payment", return_value=response): + result = CliRunner().invoke(cli, [*arguments, *(["--json"] if as_json else [])]) + assert result.exit_code == 0, result.output + if as_json: + assert json.loads(result.output) == response + return + assert status in result.output + assert phrase in result.output + if status != "CREDITED": + assert "Wallet credit is available." not in result.output + if status == "REVIEW": + assert "Contact support" in result.output + assert "Poll payments get" not in result.output + for value in PAYMENT["deposit"].values(): + assert value in result.output + + +@pytest.mark.parametrize( + "action", + [ + {"type": "REDIRECT_TO_URL", "url": "https://example.com/checkout"}, + {"type": "SPT_ISSUER", "url": None}, + {"type": "FUTURE_ACTION", "url": "https://example.com/action"}, + ], +) +@pytest.mark.parametrize("as_json", [False, True]) +def test_next_action_and_nullable_fields(action, as_json): + response = dict( + PAYMENT, rail="CARD", status="REQUIRES_ACTION", deposit=None, nextAction=action + ) + with patch.object(payments, "_get_payment", return_value=response): + result = CliRunner().invoke( + cli, ["payments", "get", "payment-1", *(["--json"] if as_json else [])] + ) + assert result.exit_code == 0, result.output + if as_json: + assert json.loads(result.output) == response + else: + assert action["type"] in result.output + assert "Deposit instructions" not in result.output + if action["url"]: + assert action["url"] in result.output + + +@pytest.mark.parametrize("command", ["create", "get"]) +@pytest.mark.parametrize( + "exc", + [ + error.QueryError("spt_secret", "spt_secret", 400, ["spt_secret"]), + error.RateLimitError("spt_secret", "spt_secret", 1), + error.AuthenticationError("spt_secret"), + requests.RequestException("spt_secret"), + ValueError("spt_secret"), + RuntimeError("spt_secret"), + ], +) +def test_error_messages_and_tracebacks_do_not_reflect_secrets(command, exc): + arguments = ( + [*CREATE, "--rail", "CARD", "--spt-stdin"] + if command == "create" + else ["payments", "get", "payment-1"] + ) + with patch.object(payments, f"_{command}_payment", side_effect=exc): + result = CliRunner().invoke(cli, arguments, input="spt_secret\n") + assert result.exit_code == 1 + assert "spt_secret" not in result.output + assert "spt_secret" not in "".join(traceback.format_exception(result.exception)) + assert "payments get" in result.output + + +def test_upstream_problem_details_are_not_reflected(monkeypatch, caplog): + monkeypatch.setattr(runpod, "api_key", "key") + monkeypatch.setenv("RUNPOD_API_BASE_URL", "https://payments.invalid") + response = requests.Response() + response.status_code = 422 + response._content = b'{"detail":"spt_secret", "errors":["spt_secret"]}' + with patch("runpod.api.rest.requests.request", return_value=response) as send: + result = CliRunner().invoke( + cli, + [*CREATE, "--rail", "CARD", "--spt-stdin", "--json"], + input="spt_secret\n", + ) + assert result.exit_code == 1 + assert "HTTP 422" in result.output + assert "same payload" in result.output + assert "spt_secret" not in result.output + caplog.text + send.assert_called_once() + + +def test_replay_keeps_the_same_key_and_payload(): + with patch.object(payments, "_create_payment", return_value=PAYMENT) as create: + for _ in range(2): + result = CliRunner().invoke(cli, CREATE) + assert result.exit_code == 0 + assert create.call_args_list[0] == create.call_args_list[1] + + +@pytest.mark.parametrize("command", ["create", "get"]) +def test_exception_chain_is_suppressed(command): + arguments = ( + [*CREATE, "--rail", "CARD", "--spt-stdin"] + if command == "create" + else ["payments", "get", "payment-1"] + ) + with patch.object( + payments, f"_{command}_payment", side_effect=RuntimeError("spt_secret") + ): + result = CliRunner().invoke( + cli, arguments, input="spt_secret\n", standalone_mode=False + ) + assert isinstance(result.exception, click.ClickException) + assert result.exception.__cause__ is None + assert result.exception.__suppress_context__ + assert "spt_secret" not in "".join(traceback.format_exception(result.exception)) + + +@pytest.mark.parametrize("name", ["fund", "funding", "x402"]) +def test_no_alternative_names(name): + result = CliRunner().invoke(cli, [name]) + assert result.exit_code == 2 + assert "No such command" in result.output diff --git a/tests/test_cli/test_payments_transport.py b/tests/test_cli/test_payments_transport.py new file mode 100644 index 000000000..08bd74c86 --- /dev/null +++ b/tests/test_cli/test_payments_transport.py @@ -0,0 +1,170 @@ +"""private payment transport and the cli-only package boundary.""" + +import importlib.util +import json +from unittest.mock import patch + +import pytest + +import runpod +from runpod.error import AuthenticationError, QueryError +from runpod.rp_cli import payments +from tests.test_api.test_rest import rest_server as rest_server + +PAYMENT = { + "id": "payment-1", + "amountCents": 1001, + "currency": "usd", + "rail": "STABLECOIN", + "status": "REQUIRES_PAYMENT", + "createdAt": "2026-11-04T12:00:00Z", + "creditedAt": None, + "balance": None, + "deposit": { + "network": "base", + "address": "0x123", + "token": "usdc", + "tokenContract": "0x456", + "amountAtomic": "10010000", + }, + "nextAction": None, + "futureField": {"nested": None}, +} + + +def create(**kwargs): + return payments._create_payment( + 1001, + **{ + "currency": "usd", + "rail": "STABLECOIN", + "idempotency_key": "retry", + "shared_payment_token": None, + **kwargs, + }, + ) + + +def test_payments_are_not_public_sdk_methods(): + for name in ( + "create_payment", + "get_payment", + "create_payment_async", + "get_payment_async", + ): + assert name not in runpod.__all__ + assert not hasattr(runpod, name) + assert importlib.util.find_spec("runpod.api.payments") is None + + +@pytest.mark.parametrize("rail, token", [("STABLECOIN", None), ("CARD", "spt_test123")]) +def test_create_payload_and_explicit_replay(rail, token): + with patch.object(payments, "run_rest_request", return_value=PAYMENT) as send: + for _ in range(2): + assert create(rail=rail, shared_payment_token=token) is PAYMENT + assert send.call_count == 2 + assert send.call_args_list[0] == send.call_args_list[1] + expected = dict(amountCents=1001, currency="usd", rail=rail, idempotencyKey="retry") + if token: + expected["sharedPaymentToken"] = token + send.assert_called_with( + "POST", "/v2/billing/payments", json=expected, max_retries=0 + ) + + +@pytest.mark.parametrize("payment_id", ["", " ", ".", ".."]) +def test_invalid_id_never_sends(payment_id): + with patch.object(payments, "run_rest_request") as send: + with pytest.raises(ValueError): + payments._get_payment(payment_id) + send.assert_not_called() + + +def test_get_encodes_path_segment(): + with patch.object(payments, "run_rest_request", return_value=PAYMENT) as send: + assert payments._get_payment("payment/a?b#c") is PAYMENT + send.assert_called_once_with("GET", "/v2/billing/payments/payment%2Fa%3Fb%23c") + + +@pytest.mark.parametrize( + "status", + [ + "REQUIRES_PAYMENT", + "REQUIRES_ACTION", + "PROCESSING", + "SETTLED", + "CREDITED", + "CANCELED", + "FAILED", + "REVIEW", + "FUTURE_STATUS", + ], +) +def test_all_statuses_and_unknown_fields_are_preserved(status): + response = dict(PAYMENT, status=status) + with patch.object(payments, "run_rest_request", return_value=response): + assert create() is response + assert payments._get_payment("payment-1") is response + + +@pytest.mark.parametrize( + "response", + [ + None, + [], + "bad", + {}, + {"id": "x"}, + {"id": "", "status": "PROCESSING"}, + {"id": "x", "status": None}, + {"id": 1, "status": "PROCESSING"}, + ], +) +def test_malformed_responses_are_errors(response): + with patch.object(payments, "run_rest_request", return_value=response): + with pytest.raises(ValueError, match="Invalid payment response"): + create() + with pytest.raises(ValueError, match="Invalid payment response"): + payments._get_payment("payment-1") + + +def test_real_transport_roundtrip(rest_server, monkeypatch): + monkeypatch.setattr(runpod, "api_key", "key") + rest_server.update( + path="/prefix/v2/billing/payments", + body=json.dumps(PAYMENT).encode(), + expected_body={ + "amountCents": 1001, + "currency": "usd", + "rail": "STABLECOIN", + "idempotencyKey": "retry", + }, + ) + assert create() == PAYMENT + assert create() == PAYMENT + assert rest_server["hits"] == 2 + del rest_server["expected_body"] + rest_server["path"] = "/prefix/v2/billing/payments/payment-1" + assert payments._get_payment("payment-1") == PAYMENT + assert rest_server["hits"] == 3 + + +@pytest.mark.parametrize("status", [400, 401, 402, 403, 404, 409, 422, 429, 500]) +def test_create_http_errors_are_not_retried(rest_server, monkeypatch, status): + monkeypatch.setattr(runpod, "api_key", "key") + rest_server.update( + path="/prefix/v2/billing/payments", + status=status, + body=( + b'{"title":"Rejected", "detail":"Payment unavailable", ' + b'"errors":["invalid"]}' + ), + content_type="application/problem+json", + ) + expected = AuthenticationError if status == 401 else QueryError + with pytest.raises(expected) as raised: + create() + if status != 401: + assert raised.value.status_code == status + assert raised.value.errors == ["invalid"] + assert rest_server["hits"] == 1 From d961c8b9456fe2f8588c7d012d6218c48280e285 Mon Sep 17 00:00:00 2001 From: zeke <40004347+KAJdev@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:03:15 -0800 Subject: [PATCH 2/2] test: simplify payment command imports --- tests/test_cli/test_payments_command.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_cli/test_payments_command.py b/tests/test_cli/test_payments_command.py index b975b5a96..51134208c 100644 --- a/tests/test_cli/test_payments_command.py +++ b/tests/test_cli/test_payments_command.py @@ -12,7 +12,6 @@ import requests from click.testing import CliRunner -import runpod from runpod import error from runpod.rp_cli import payments from runpod.rp_cli.main import cli @@ -373,7 +372,7 @@ def test_error_messages_and_tracebacks_do_not_reflect_secrets(command, exc): def test_upstream_problem_details_are_not_reflected(monkeypatch, caplog): - monkeypatch.setattr(runpod, "api_key", "key") + monkeypatch.setattr("runpod.api_key", "key") monkeypatch.setenv("RUNPOD_API_BASE_URL", "https://payments.invalid") response = requests.Response() response.status_code = 422