mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-06 12:38:23 +00:00
Routstr-to-Routstr auto top-up had no spend bound. Admin settings were accepted without range or type validation, and every low-balance cycle independently minted a Cashu token and handed it to the configured peer. A malicious, buggy, or persistently non-crediting peer therefore received a fresh bearer token every sixty seconds; nothing in the worker noticed that the previous one had never been credited, and nothing survived a restart, so the bleed was limited only by the owner's mint balance. Auto top-up now mirrors the PPQ claim machinery that already guards the Lightning path. Each provider gets one durable claim row keyed by its id, so a second worker (or the same worker after a restart) loses the insert or the ownership-fenced update instead of paying twice. The claim moves to "sent" before the network call, and only a peer balance that reaches the pre-topup balance plus the top-up amount clears it: an uncredited token holds the slot rather than being retried. Repeated non-credit walks the claim through exponential backoff to a halt that needs an admin release, and a rolling 24h cap bounds the total even when every attempt looks successful. Settings validation now rejects non-positive, non-finite, boolean, huge, and non-integer amounts, amounts outside the per-transaction range, and a missing mint URL, at the admin API as well as in the worker. The claim row is a CashuTransaction like the PPQ one, so no migration is needed; provider delete and type change refuse to orphan it.
678 lines
22 KiB
Python
678 lines
22 KiB
Python
import json
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from routstr.upstream.auto_topup import (
|
|
_check_and_topup,
|
|
_parse_ppq_request_id,
|
|
_run_auto_topup_cycle,
|
|
validate_ppq_auto_topup_settings,
|
|
validate_routstr_auto_topup_settings,
|
|
)
|
|
from routstr.upstream.ppqai import PPQAIUpstreamProvider
|
|
from routstr.wallet import Bolt11PaymentAmbiguous, Bolt11PaymentNotAttempted
|
|
|
|
|
|
def test_ppq_claim_parser_rejects_invalid_expiry() -> None:
|
|
assert (
|
|
_parse_ppq_request_id("ppq:operation:not-a-timestamp:claimed:invoice:none")
|
|
is None
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ppq_balance_rejects_boolean_api_value() -> None:
|
|
provider = PPQAIUpstreamProvider("secret")
|
|
provider.check_balance = AsyncMock(return_value={"balance": False}) # type: ignore[method-assign]
|
|
|
|
assert await provider.get_balance() is None
|
|
|
|
|
|
def _row() -> MagicMock:
|
|
row = MagicMock()
|
|
row.id = "provider-1"
|
|
row.base_url = "https://provider.test"
|
|
row.api_key = "secret"
|
|
row.provider_type = "routstr"
|
|
row.provider_settings = json.dumps(
|
|
{
|
|
"auto_topup": True,
|
|
"topup_threshold": 100,
|
|
"topup_amount_limit": 50,
|
|
"topup_mint_url": "https://mint.test",
|
|
}
|
|
)
|
|
return row
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_auto_topup_refuses_invalid_settings_before_touching_the_wallet() -> None:
|
|
provider = MagicMock()
|
|
provider.get_balance = AsyncMock()
|
|
row = _row()
|
|
row.provider_settings = json.dumps(
|
|
{
|
|
"auto_topup": True,
|
|
"topup_threshold": 100,
|
|
"topup_amount_limit": 10**9,
|
|
"topup_mint_url": "https://mint.test",
|
|
}
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"routstr.upstream.auto_topup.RoutstrUpstreamProvider.from_db_row",
|
|
return_value=provider,
|
|
),
|
|
patch("routstr.upstream.auto_topup.send_token", AsyncMock()) as send,
|
|
):
|
|
await _check_and_topup(row)
|
|
|
|
provider.get_balance.assert_not_awaited()
|
|
send.assert_not_awaited()
|
|
|
|
|
|
def _ppq_row() -> MagicMock:
|
|
row = MagicMock()
|
|
row.id = "ppq-provider-1"
|
|
row.base_url = "https://api.ppq.ai"
|
|
row.api_key = "secret"
|
|
row.provider_type = "ppqai"
|
|
row.provider_settings = json.dumps(
|
|
{
|
|
"auto_topup": True,
|
|
"topup_threshold": 5.0,
|
|
"topup_amount_limit": 10,
|
|
}
|
|
)
|
|
return row
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ppq_auto_topup_pays_invoice_and_confirms_settlement() -> None:
|
|
provider = MagicMock()
|
|
provider.get_balance = AsyncMock(return_value=2.5)
|
|
provider.initiate_topup = AsyncMock(
|
|
return_value=MagicMock(
|
|
invoice_id="invoice-1",
|
|
payment_request="lnbc-invoice",
|
|
amount=10,
|
|
currency="USD",
|
|
expires_at=None,
|
|
)
|
|
)
|
|
provider.check_topup_status = AsyncMock(return_value=True)
|
|
plan = MagicMock()
|
|
plan.invoice_amount_sats = 100
|
|
plan.maximum_spend_sats = 102
|
|
plan.quote.amount = 100
|
|
plan.quote.fee_reserve = 2
|
|
plan.mint_url = "https://mint-rich.test"
|
|
plan.unit = "sat"
|
|
row = _ppq_row()
|
|
|
|
with (
|
|
patch(
|
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
|
return_value=provider,
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
|
AsyncMock(return_value=False),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._claim_ppq_topup",
|
|
AsyncMock(return_value="operation-1"),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup.maximum_owner_cashu_balance_sats",
|
|
AsyncMock(return_value=10_000),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._ppq_spent_last_24h_usd",
|
|
AsyncMock(return_value=0.0),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup.prepare_bolt11_payment",
|
|
AsyncMock(return_value=plan),
|
|
) as prepare,
|
|
patch(
|
|
"routstr.upstream.auto_topup.execute_bolt11_payment",
|
|
AsyncMock(return_value=(101, "https://mint-rich.test", "sat")),
|
|
) as execute,
|
|
patch("routstr.upstream.auto_topup._record_ppq_invoice", AsyncMock()) as record,
|
|
patch(
|
|
"routstr.upstream.auto_topup._record_ppq_payment_spent", AsyncMock()
|
|
) as record_spent,
|
|
patch(
|
|
"routstr.upstream.auto_topup._set_ppq_state_terminal", AsyncMock()
|
|
) as terminal,
|
|
patch("routstr.upstream.auto_topup.sats_usd_price", return_value=0.001),
|
|
):
|
|
await _check_and_topup(row)
|
|
|
|
provider.initiate_topup.assert_awaited_once_with(10)
|
|
prepare.assert_awaited_once_with("lnbc-invoice")
|
|
execute.assert_awaited_once_with(plan)
|
|
record.assert_awaited_once()
|
|
record_spent.assert_awaited_once_with("operation-1", 101)
|
|
provider.check_topup_status.assert_awaited_once_with("invoice-1")
|
|
terminal.assert_awaited_once_with(row, "operation-1", collected=True, swept=False)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ppq_ambiguous_melt_keeps_claim_and_emits_critical_alert() -> None:
|
|
provider = MagicMock()
|
|
provider.get_balance = AsyncMock(return_value=2.5)
|
|
provider.initiate_topup = AsyncMock(
|
|
return_value=MagicMock(
|
|
invoice_id="invoice-1",
|
|
payment_request="lnbc-invoice",
|
|
amount=10,
|
|
currency="USD",
|
|
expires_at=None,
|
|
)
|
|
)
|
|
plan = MagicMock(maximum_spend_sats=102, mint_url="https://mint.test", unit="sat")
|
|
plan.quote.amount = 100
|
|
plan.quote.fee_reserve = 2
|
|
row = _ppq_row()
|
|
|
|
with (
|
|
patch(
|
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
|
return_value=provider,
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
|
AsyncMock(return_value=False),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._claim_ppq_topup",
|
|
AsyncMock(return_value="operation-1"),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup.maximum_owner_cashu_balance_sats",
|
|
AsyncMock(return_value=10_000),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._ppq_spent_last_24h_usd",
|
|
AsyncMock(return_value=0.0),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup.prepare_bolt11_payment",
|
|
AsyncMock(return_value=plan),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup.execute_bolt11_payment",
|
|
AsyncMock(side_effect=Bolt11PaymentAmbiguous("ambiguous melt")),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._record_ppq_invoice",
|
|
AsyncMock(return_value=2_000_000_000),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._mark_ppq_reconcile", AsyncMock()
|
|
) as reconcile_mark,
|
|
patch(
|
|
"routstr.upstream.auto_topup._set_ppq_state_terminal", AsyncMock()
|
|
) as terminal,
|
|
patch("routstr.upstream.auto_topup.sats_usd_price", return_value=0.001),
|
|
patch("routstr.upstream.auto_topup.logger.critical") as critical,
|
|
):
|
|
with pytest.raises(Bolt11PaymentAmbiguous, match="ambiguous melt"):
|
|
await _check_and_topup(row)
|
|
|
|
# The claim is never released — it moves to reconcile for the admin.
|
|
terminal.assert_not_awaited()
|
|
reconcile_mark.assert_awaited_once()
|
|
critical.assert_called_once()
|
|
assert "admin reconciliation" in critical.call_args.args[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ppq_payment_not_attempted_releases_claim_for_retry() -> None:
|
|
provider = MagicMock()
|
|
provider.get_balance = AsyncMock(return_value=2.5)
|
|
provider.initiate_topup = AsyncMock(
|
|
return_value=MagicMock(
|
|
invoice_id="invoice-1",
|
|
payment_request="lnbc-invoice",
|
|
amount=10,
|
|
currency="USD",
|
|
expires_at=None,
|
|
)
|
|
)
|
|
plan = MagicMock(maximum_spend_sats=102, mint_url="https://mint.test", unit="sat")
|
|
plan.quote.amount = 100
|
|
plan.quote.fee_reserve = 2
|
|
plan.quote.quote = "quote-1"
|
|
terminal = AsyncMock(return_value=True)
|
|
row = _ppq_row()
|
|
|
|
with (
|
|
patch(
|
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
|
return_value=provider,
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
|
AsyncMock(return_value=False),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup.maximum_owner_cashu_balance_sats",
|
|
AsyncMock(return_value=10_000),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._ppq_spent_last_24h_usd",
|
|
AsyncMock(return_value=0.0),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._claim_ppq_topup",
|
|
AsyncMock(return_value="operation-1"),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup.prepare_bolt11_payment",
|
|
AsyncMock(return_value=plan),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._record_ppq_invoice",
|
|
AsyncMock(return_value=2_000_000_000),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup.execute_bolt11_payment",
|
|
AsyncMock(side_effect=Bolt11PaymentNotAttempted("unpaid")),
|
|
),
|
|
patch("routstr.upstream.auto_topup._set_ppq_state_terminal", terminal),
|
|
patch(
|
|
"routstr.upstream.auto_topup._mark_ppq_reconcile", AsyncMock()
|
|
) as reconcile,
|
|
patch("routstr.upstream.auto_topup.sats_usd_price", return_value=0.001),
|
|
pytest.raises(Bolt11PaymentNotAttempted, match="unpaid"),
|
|
):
|
|
await _check_and_topup(row)
|
|
|
|
terminal.assert_awaited_once_with(row, "operation-1", collected=False, swept=True)
|
|
reconcile.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ppq_status_error_after_payment_marks_reconcile_and_alerts() -> None:
|
|
provider = MagicMock()
|
|
provider.get_balance = AsyncMock(return_value=2.5)
|
|
provider.initiate_topup = AsyncMock(
|
|
return_value=MagicMock(
|
|
invoice_id="invoice-1",
|
|
payment_request="lnbc-invoice",
|
|
amount=10,
|
|
currency="USD",
|
|
expires_at=None,
|
|
)
|
|
)
|
|
provider.check_topup_status = AsyncMock(side_effect=RuntimeError("PPQ 502"))
|
|
plan = MagicMock(maximum_spend_sats=102, mint_url="https://mint.test", unit="sat")
|
|
plan.quote.amount = 100
|
|
plan.quote.fee_reserve = 2
|
|
plan.quote.quote = "quote-1"
|
|
|
|
with (
|
|
patch(
|
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
|
return_value=provider,
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
|
AsyncMock(return_value=False),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup.maximum_owner_cashu_balance_sats",
|
|
AsyncMock(return_value=10_000),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._ppq_spent_last_24h_usd",
|
|
AsyncMock(return_value=0.0),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._claim_ppq_topup",
|
|
AsyncMock(return_value="operation-1"),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup.prepare_bolt11_payment",
|
|
AsyncMock(return_value=plan),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._record_ppq_invoice",
|
|
AsyncMock(return_value=2_000_000_000),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup.execute_bolt11_payment",
|
|
AsyncMock(return_value=(101, "https://mint.test", "sat")),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._record_ppq_payment_spent", AsyncMock()
|
|
) as spent,
|
|
patch(
|
|
"routstr.upstream.auto_topup._mark_ppq_reconcile", AsyncMock()
|
|
) as reconcile,
|
|
patch(
|
|
"routstr.upstream.auto_topup._set_ppq_state_terminal", AsyncMock()
|
|
) as terminal,
|
|
patch("routstr.upstream.auto_topup.sats_usd_price", return_value=0.001),
|
|
patch("routstr.upstream.auto_topup.logger.critical") as critical,
|
|
):
|
|
await _check_and_topup(_ppq_row())
|
|
|
|
spent.assert_awaited_once_with("operation-1", 101)
|
|
reconcile.assert_awaited_once()
|
|
terminal.assert_not_awaited()
|
|
assert "settlement polling failed" in critical.call_args.args[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ppq_preflight_funding_check_happens_before_invoice_creation() -> None:
|
|
provider = MagicMock()
|
|
provider.get_balance = AsyncMock(return_value=2.5)
|
|
provider.initiate_topup = AsyncMock()
|
|
|
|
with (
|
|
patch(
|
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
|
return_value=provider,
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
|
AsyncMock(return_value=False),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup.maximum_owner_cashu_balance_sats",
|
|
AsyncMock(return_value=1),
|
|
),
|
|
patch("routstr.upstream.auto_topup._claim_ppq_topup", AsyncMock()) as claim,
|
|
patch("routstr.upstream.auto_topup.sats_usd_price", return_value=0.001),
|
|
):
|
|
await _check_and_topup(_ppq_row())
|
|
|
|
provider.initiate_topup.assert_not_awaited()
|
|
claim.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_active_claim_at_cycle_start_suppresses_topup_for_whole_cycle() -> None:
|
|
row = _ppq_row()
|
|
row.id = 1
|
|
session = AsyncMock()
|
|
result = MagicMock()
|
|
result.all.return_value = [row]
|
|
session.exec.return_value = result
|
|
context = MagicMock()
|
|
context.__aenter__ = AsyncMock(return_value=session)
|
|
context.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
with (
|
|
patch(
|
|
"routstr.upstream.auto_topup._reconcile_all_ppq_claims",
|
|
AsyncMock(return_value={1}),
|
|
),
|
|
patch("routstr.upstream.auto_topup.create_session", return_value=context),
|
|
patch("routstr.upstream.auto_topup._check_and_topup", AsyncMock()) as check,
|
|
):
|
|
await _run_auto_topup_cycle()
|
|
|
|
check.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ppq_auto_topup_skips_when_balance_meets_threshold() -> None:
|
|
provider = MagicMock()
|
|
provider.get_balance = AsyncMock(return_value=5.0)
|
|
provider.initiate_topup = AsyncMock()
|
|
|
|
with (
|
|
patch(
|
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
|
return_value=provider,
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
|
AsyncMock(return_value=False),
|
|
),
|
|
):
|
|
await _check_and_topup(_ppq_row())
|
|
|
|
provider.initiate_topup.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ppq_auto_topup_skips_when_daily_spend_cap_reached() -> None:
|
|
provider = MagicMock()
|
|
provider.get_balance = AsyncMock(return_value=2.5)
|
|
provider.initiate_topup = AsyncMock()
|
|
|
|
with (
|
|
patch(
|
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
|
return_value=provider,
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
|
AsyncMock(return_value=False),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup.maximum_owner_cashu_balance_sats",
|
|
AsyncMock(return_value=10_000_000),
|
|
),
|
|
# 1000 USD already spent, exactly the daily cap: the next 10 USD
|
|
# top-up must be refused.
|
|
patch(
|
|
"routstr.upstream.auto_topup._ppq_spent_last_24h_usd",
|
|
AsyncMock(return_value=1000.0),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._claim_ppq_topup",
|
|
AsyncMock(),
|
|
) as claim,
|
|
patch("routstr.upstream.auto_topup.sats_usd_price", return_value=0.001),
|
|
):
|
|
await _check_and_topup(_ppq_row())
|
|
|
|
claim.assert_not_awaited()
|
|
provider.initiate_topup.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ppq_pending_attempt_suppresses_duplicate_topup() -> None:
|
|
provider = MagicMock()
|
|
provider.get_balance = AsyncMock()
|
|
|
|
with (
|
|
patch(
|
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
|
return_value=provider,
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
|
AsyncMock(return_value=True),
|
|
),
|
|
):
|
|
await _check_and_topup(_ppq_row())
|
|
|
|
provider.get_balance.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ppq_auto_topup_rejects_non_finite_balance() -> None:
|
|
provider = MagicMock()
|
|
provider.get_balance = AsyncMock(return_value=float("nan"))
|
|
|
|
with (
|
|
patch(
|
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
|
return_value=provider,
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
|
AsyncMock(return_value=False),
|
|
),
|
|
patch("routstr.upstream.auto_topup._claim_ppq_topup", AsyncMock()) as claim,
|
|
):
|
|
await _check_and_topup(_ppq_row())
|
|
|
|
claim.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_settled_topup_alerts_when_its_claim_was_already_released() -> None:
|
|
provider = MagicMock()
|
|
provider.get_balance = AsyncMock(return_value=2.5)
|
|
provider.initiate_topup = AsyncMock(
|
|
return_value=MagicMock(
|
|
invoice_id="invoice-1",
|
|
payment_request="lnbc-invoice",
|
|
amount=10,
|
|
currency="USD",
|
|
expires_at=None,
|
|
)
|
|
)
|
|
provider.check_topup_status = AsyncMock(return_value=True)
|
|
plan = MagicMock()
|
|
plan.maximum_spend_sats = 102
|
|
plan.quote.amount = 100
|
|
plan.quote.fee_reserve = 2
|
|
plan.mint_url = "https://mint-rich.test"
|
|
plan.unit = "sat"
|
|
|
|
with (
|
|
patch(
|
|
"routstr.upstream.auto_topup.PPQAIUpstreamProvider.from_db_row",
|
|
return_value=provider,
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._reconcile_ppq_state",
|
|
AsyncMock(return_value=False),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._claim_ppq_topup",
|
|
AsyncMock(return_value="operation-1"),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup.prepare_bolt11_payment",
|
|
AsyncMock(return_value=plan),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup.maximum_owner_cashu_balance_sats",
|
|
AsyncMock(return_value=10_000),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._ppq_spent_last_24h_usd",
|
|
AsyncMock(return_value=0.0),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup.execute_bolt11_payment",
|
|
AsyncMock(return_value=(101, "https://mint-rich.test", "sat")),
|
|
),
|
|
patch("routstr.upstream.auto_topup._record_ppq_invoice", AsyncMock()),
|
|
patch("routstr.upstream.auto_topup._record_ppq_payment_spent", AsyncMock()),
|
|
patch(
|
|
"routstr.upstream.auto_topup._set_ppq_state_terminal",
|
|
AsyncMock(return_value=False),
|
|
),
|
|
patch("routstr.upstream.auto_topup.sats_usd_price", return_value=0.001),
|
|
patch("routstr.upstream.auto_topup.logger") as log,
|
|
):
|
|
await _check_and_topup(_ppq_row())
|
|
|
|
assert any(
|
|
"claim was already released" in call.args[0]
|
|
for call in log.critical.call_args_list
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("settings", "expected"),
|
|
[
|
|
({"auto_topup": False, "topup_threshold": -1}, None),
|
|
(
|
|
{"auto_topup": True, "topup_threshold": 5, "topup_amount_limit": 10},
|
|
None,
|
|
),
|
|
(
|
|
{"auto_topup": True, "topup_threshold": None, "topup_amount_limit": 10},
|
|
"threshold",
|
|
),
|
|
(
|
|
{"auto_topup": True, "topup_threshold": 5, "topup_amount_limit": 0.5},
|
|
"whole number",
|
|
),
|
|
(
|
|
{"auto_topup": True, "topup_threshold": 5, "topup_amount_limit": 5000},
|
|
"between",
|
|
),
|
|
(
|
|
{"auto_topup": True, "topup_threshold": True, "topup_amount_limit": 10},
|
|
"threshold",
|
|
),
|
|
],
|
|
)
|
|
def test_ppq_auto_topup_settings_validation(
|
|
settings: dict, expected: str | None
|
|
) -> None:
|
|
problem = validate_ppq_auto_topup_settings(settings)
|
|
if expected is None:
|
|
assert problem is None
|
|
else:
|
|
assert problem is not None and expected in problem
|
|
|
|
|
|
def test_ppq_auto_topup_settings_validation_survives_huge_json_integers() -> None:
|
|
# json.loads happily produces integers past float range; float() raises
|
|
# OverflowError there instead of returning inf.
|
|
problem = validate_ppq_auto_topup_settings(
|
|
{"auto_topup": True, "topup_threshold": 10**400, "topup_amount_limit": 10}
|
|
)
|
|
assert problem is not None and "threshold" in problem
|
|
|
|
|
|
def _routstr_settings(**overrides: object) -> dict:
|
|
settings = {
|
|
"auto_topup": True,
|
|
"topup_threshold": 1,
|
|
"topup_amount_limit": 50,
|
|
"topup_mint_url": "https://mint.test",
|
|
}
|
|
settings.update(overrides)
|
|
return settings
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("settings", "expected"),
|
|
[
|
|
({"auto_topup": False, "topup_threshold": -1}, None),
|
|
(_routstr_settings(), None),
|
|
(_routstr_settings(topup_threshold=None), "threshold"),
|
|
(_routstr_settings(topup_threshold=True), "threshold"),
|
|
(_routstr_settings(topup_threshold=float("inf")), "threshold"),
|
|
(_routstr_settings(topup_amount_limit=0), "positive"),
|
|
(_routstr_settings(topup_amount_limit=True), "positive"),
|
|
(_routstr_settings(topup_amount_limit=1.5), "whole number"),
|
|
(_routstr_settings(topup_amount_limit=10**9), "between"),
|
|
(_routstr_settings(topup_mint_url=""), "mint URL"),
|
|
(_routstr_settings(topup_mint_url=True), "mint URL"),
|
|
],
|
|
)
|
|
def test_routstr_auto_topup_settings_validation(
|
|
settings: dict, expected: str | None
|
|
) -> None:
|
|
problem = validate_routstr_auto_topup_settings(settings)
|
|
if expected is None:
|
|
assert problem is None
|
|
else:
|
|
assert problem is not None and expected in problem
|
|
|
|
|
|
def test_routstr_auto_topup_settings_validation_survives_huge_json_integers() -> None:
|
|
problem = validate_routstr_auto_topup_settings(
|
|
_routstr_settings(topup_amount_limit=10**400)
|
|
)
|
|
assert problem is not None and "positive" in problem
|