mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 20:28:23 +00:00
763 lines
25 KiB
Python
763 lines
25 KiB
Python
import json
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
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_from_owner_locked", AsyncMock()
|
|
) as send,
|
|
):
|
|
await _check_and_topup(row)
|
|
|
|
provider.get_balance.assert_not_awaited()
|
|
send.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_routstr_outgoing_audit_is_persisted_under_wallet_guard() -> None:
|
|
provider = MagicMock()
|
|
provider.get_balance = AsyncMock(return_value=0.0)
|
|
provider.topup = AsyncMock(return_value={"error": "stop after send"})
|
|
inside_guard = False
|
|
|
|
@asynccontextmanager
|
|
async def guard() -> AsyncIterator[None]:
|
|
nonlocal inside_guard
|
|
inside_guard = True
|
|
try:
|
|
yield
|
|
finally:
|
|
inside_guard = False
|
|
|
|
async def send(*_args: object) -> str:
|
|
assert inside_guard
|
|
return "cashu-token"
|
|
|
|
async def persist(*_args: object, **_kwargs: object) -> None:
|
|
assert inside_guard
|
|
|
|
with (
|
|
patch(
|
|
"routstr.upstream.auto_topup.RoutstrUpstreamProvider.from_db_row",
|
|
return_value=provider,
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._reconcile_routstr_state",
|
|
AsyncMock(return_value=False),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._routstr_spent_last_24h_sats",
|
|
AsyncMock(return_value=0),
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._claim_routstr_topup",
|
|
AsyncMock(return_value="operation-1"),
|
|
),
|
|
patch("routstr.upstream.auto_topup.wallet_operation_guard", side_effect=guard),
|
|
patch(
|
|
"routstr.upstream.auto_topup.send_token_from_owner_locked",
|
|
side_effect=send,
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup._persist_routstr_token_and_mark_sent",
|
|
side_effect=persist,
|
|
),
|
|
patch(
|
|
"routstr.upstream.auto_topup.token_mint_url",
|
|
return_value="https://mint.test",
|
|
),
|
|
):
|
|
await _check_and_topup(_row())
|
|
|
|
|
|
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_requires_two_below_threshold_reads() -> None:
|
|
provider = MagicMock()
|
|
provider.get_balance = AsyncMock(side_effect=[2.5, 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),
|
|
),
|
|
patch("routstr.upstream.auto_topup._claim_ppq_topup", AsyncMock()) as claim,
|
|
):
|
|
await _check_and_topup(_ppq_row())
|
|
|
|
assert provider.get_balance.await_count == 2
|
|
claim.assert_not_awaited()
|
|
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
|