From 7b56dcb902f6004db563fb010fb47ce05a74cd42 Mon Sep 17 00:00:00 2001 From: 9qeklajc Date: Wed, 24 Jun 2026 22:54:07 +0200 Subject: [PATCH] resolve review comments --- routstr/wallet.py | 6 +++- tests/integration/test_prune_dead_api_keys.py | 19 +++++++++++ tests/unit/test_wallet.py | 33 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/routstr/wallet.py b/routstr/wallet.py index 30f36ded..346880dd 100644 --- a/routstr/wallet.py +++ b/routstr/wallet.py @@ -441,7 +441,11 @@ async def credit_balance( .where(col(db.ApiKey.hashed_key) == key.hashed_key) .values(balance=(db.ApiKey.balance) + amount) ) - await session.exec(stmt) # type: ignore[call-overload] + result = await session.exec(stmt) # type: ignore[call-overload] + # If pruning removed this key after redemption, do not commit a no-op + # balance update and pretend the top-up succeeded. + if (getattr(result, "rowcount", 0) or 0) == 0: + raise ValueError("API key disappeared before credit could be recorded") await session.commit() await session.refresh(key) diff --git a/tests/integration/test_prune_dead_api_keys.py b/tests/integration/test_prune_dead_api_keys.py index 60b33ef5..4aa95175 100644 --- a/tests/integration/test_prune_dead_api_keys.py +++ b/tests/integration/test_prune_dead_api_keys.py @@ -262,3 +262,22 @@ async def test_transaction_audit_trail_preserved(patched_db_engine: None) -> Non assert surviving is not None, "Financial audit row must survive key deletion" assert surviving.api_key_hashed_key is None, "Link must be nulled, not dangling" assert surviving.amount == 21 + + +@pytest.mark.asyncio +async def test_periodic_prune_disabled_returns_immediately( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Non-positive intervals disable the janitor.""" + from unittest.mock import AsyncMock + + from routstr import auth + from routstr.core.settings import settings + + monkeypatch.setattr(settings, "dead_key_prune_interval_seconds", 0) + sleep_mock = AsyncMock() + monkeypatch.setattr(auth.asyncio, "sleep", sleep_mock) + + await auth.periodic_dead_key_prune() + + sleep_mock.assert_not_called() diff --git a/tests/unit/test_wallet.py b/tests/unit/test_wallet.py index 40a4df6c..c7b7cdce 100644 --- a/tests/unit/test_wallet.py +++ b/tests/unit/test_wallet.py @@ -150,6 +150,7 @@ async def test_credit_balance() -> None: mock_key.balance = 5000000 mock_key.hashed_key = "test_hash" mock_session = AsyncMock() + mock_session.exec.return_value.rowcount = 1 # Mock session.refresh to update the balance (simulates DB reload) async def mock_refresh(key: ApiKey) -> None: @@ -206,6 +207,38 @@ async def test_credit_balance_rejects_zero_amount() -> None: assert not mock_session.commit.called +@pytest.mark.asyncio +async def test_credit_balance_rejects_missing_key() -> None: + """A top-up must fail if the key was pruned after redemption.""" + token_data = { + "token": [{"mint": "http://mint:3338", "proofs": [{"amount": 1000}]}], + "unit": "sat", + } + token_json = json.dumps(token_data) + token_b64 = base64.urlsafe_b64encode(token_json.encode()).decode() + token_str = f"cashuA{token_b64}" + + mock_key = Mock() + mock_key.balance = 0 + mock_key.hashed_key = "test_hash" + mock_session = AsyncMock() + mock_session.exec.return_value.rowcount = 0 + + from routstr.core.settings import settings + + with patch.object(settings, "cashu_mints", ["http://mint:3338"]): + with patch( + "routstr.wallet.recieve_token", + return_value=(1000, "sat", "http://mint:3338"), + ): + with pytest.raises(ValueError, match="disappeared"): + await credit_balance(token_str, mock_key, mock_session) + + # UPDATE matched nothing; committing would hide the failed credit. + assert mock_session.exec.called + assert not mock_session.commit.called + + @pytest.mark.asyncio async def test_swap_to_primary_mint_insufficient_for_fees() -> None: """Token amount is less than melt_quote.amount + melt_quote.fee_reserve."""