diff --git a/tests/unit/test_checkstate.py b/tests/unit/test_checkstate.py index 29ebaf3f..9b4685b8 100644 --- a/tests/unit/test_checkstate.py +++ b/tests/unit/test_checkstate.py @@ -1,10 +1,11 @@ import asyncio +from collections.abc import Callable, Iterator from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch import httpx import pytest -from cashu.core.base import ProofSpentState +from cashu.core.base import Proof, ProofSpentState from routstr import checkstate from routstr.checkstate import _learned_sizes, filter_unspent_proofs @@ -12,7 +13,7 @@ from routstr.mint import MintRateGuard, fail_fast_mint_operations @pytest.fixture(autouse=True) -def isolate(): +def isolate() -> Iterator[None]: _learned_sizes.clear() MintRateGuard._guards.clear() yield @@ -20,17 +21,17 @@ def isolate(): MintRateGuard._guards.clear() -def proofs(count): +def proofs(count: int) -> list[Proof]: return [Mock(Y=str(i)) for i in range(count)] -def response(batch): +def response(batch: list[Proof]) -> SimpleNamespace: return SimpleNamespace( states=[SimpleNamespace(Y=p.Y, state=ProofSpentState.unspent) for p in batch] ) -def rejection(status): +def rejection(status: int) -> httpx.HTTPStatusError: request = httpx.Request("POST", "https://mint.test/v1/checkstate") return httpx.HTTPStatusError( "rejected", @@ -43,7 +44,7 @@ def rejection(status): ) -def wallet(check): +def wallet(check: Callable[[list[Proof]], object]) -> Mock: return Mock( url="https://mint.test", check_proof_state=AsyncMock(side_effect=check), @@ -53,10 +54,10 @@ def wallet(check): @pytest.mark.asyncio @pytest.mark.parametrize("status", [413, 500]) -async def test_adapts_and_reuses_size_without_skipping_proofs(status): - checked = [] +async def test_adapts_and_reuses_size_without_skipping_proofs(status: int) -> None: + checked: list[Proof] = [] - async def check(batch): + async def check(batch: list[Proof]) -> SimpleNamespace: if len(batch) > 120: raise rejection(status) checked.extend(batch) @@ -74,8 +75,8 @@ async def test_adapts_and_reuses_size_without_skipping_proofs(status): @pytest.mark.asyncio -async def test_size_fallback_works_inside_cooldown_probe_under_wallet_guard(): - async def check(batch): +async def test_size_fallback_works_inside_cooldown_probe_under_wallet_guard() -> None: + async def check(batch: list[Proof]) -> SimpleNamespace: if len(batch) > 2: raise rejection(500) return response(batch) @@ -89,7 +90,7 @@ async def test_size_fallback_works_inside_cooldown_probe_under_wallet_guard(): @pytest.mark.asyncio -async def test_429_is_not_a_size_signal(): +async def test_429_is_not_a_size_signal() -> None: w = wallet(Mock(side_effect=rejection(429))) with pytest.raises(httpx.HTTPStatusError): await filter_unspent_proofs(proofs(1000), w, retry_on_rate_limit=False) @@ -100,7 +101,7 @@ async def test_429_is_not_a_size_signal(): @pytest.mark.asyncio @pytest.mark.parametrize("status", [400, 401, 422, 503]) -async def test_other_http_errors_are_not_split(status): +async def test_other_http_errors_are_not_split(status: int) -> None: w = wallet(Mock(side_effect=rejection(status))) with pytest.raises(httpx.HTTPStatusError): await filter_unspent_proofs(proofs(10), w) @@ -108,7 +109,7 @@ async def test_other_http_errors_are_not_split(status): @pytest.mark.asyncio -async def test_singleton_failure_is_bounded_and_does_not_poison_cache(): +async def test_singleton_failure_is_bounded_and_does_not_poison_cache() -> None: w = wallet(Mock(side_effect=rejection(500))) with pytest.raises(httpx.HTTPStatusError): await filter_unspent_proofs(proofs(1000), w) @@ -129,7 +130,7 @@ async def test_singleton_failure_is_bounded_and_does_not_poison_cache(): @pytest.mark.asyncio -async def test_request_budget_counts_successes_and_failures(): +async def test_request_budget_counts_successes_and_failures() -> None: w = wallet(response) with ( patch.object(checkstate, "_DEFAULT_BATCH_SIZE", 1), @@ -142,8 +143,8 @@ async def test_request_budget_counts_successes_and_failures(): @pytest.mark.asyncio -async def test_total_deadline_cancels_slow_check(): - async def check(batch): +async def test_total_deadline_cancels_slow_check() -> None: + async def check(batch: list[Proof]) -> None: await asyncio.Event().wait() w = wallet(check) @@ -157,8 +158,8 @@ async def test_total_deadline_cancels_slow_check(): @pytest.mark.asyncio @pytest.mark.parametrize("malformation", ["missing", "reordered", "unknown"]) -async def test_invalid_response_fails_closed(malformation): - def check(batch): +async def test_invalid_response_fails_closed(malformation: str) -> None: + def check(batch: list[Proof]) -> SimpleNamespace: result = response(batch) if malformation == "missing": result.states.pop() @@ -175,7 +176,7 @@ async def test_invalid_response_fails_closed(malformation): @pytest.mark.asyncio -async def test_only_unspent_proofs_are_spendable(): +async def test_only_unspent_proofs_are_spendable() -> None: ps = proofs(3) states = [ProofSpentState.unspent, ProofSpentState.pending, ProofSpentState.spent] w = wallet( @@ -188,7 +189,7 @@ async def test_only_unspent_proofs_are_spendable(): @pytest.mark.asyncio -async def test_learned_size_is_per_mint_and_expires(): +async def test_learned_size_is_per_mint_and_expires() -> None: w = wallet(response) ps = proofs(5) _learned_sizes[w.url] = (1, 0) @@ -209,11 +210,11 @@ async def test_learned_size_is_per_mint_and_expires(): @pytest.mark.asyncio -async def test_smaller_later_batch_failure_does_not_skip_or_return_partial(): +async def test_smaller_later_batch_failure_does_not_skip_or_return_partial() -> None: ps = proofs(9) - checked = [] + checked: list[Proof] = [] - def check(batch): + def check(batch: list[Proof]) -> SimpleNamespace: if batch[0] is not ps[0] and len(batch) > 1: raise rejection(500) checked.extend(batch) @@ -227,7 +228,9 @@ async def test_smaller_later_batch_failure_does_not_skip_or_return_partial(): @pytest.mark.parametrize("status", [413, 500]) @pytest.mark.parametrize("body", [{"detail": "too big"}, "error"]) -def test_wallet_adapter_preserves_checkstate_http_status(status, body): +def test_wallet_adapter_preserves_checkstate_http_status( + status: int, body: dict[str, str] | str +) -> None: from routstr.wallet import Wallet request = httpx.Request("POST", "https://mint.test/v1/checkstate") @@ -242,8 +245,7 @@ def test_wallet_adapter_preserves_checkstate_http_status(status, body): @pytest.mark.asyncio -async def test_default_batch_fits_real_sdk_model(): - from cashu.core.base import Proof +async def test_default_batch_fits_real_sdk_model() -> None: from cashu.core.models import PostCheckStateRequest limit = PostCheckStateRequest.model_json_schema()["properties"]["Ys"]["maxItems"] @@ -251,9 +253,9 @@ async def test_default_batch_fits_real_sdk_model(): Proof(id="00", amount=1, secret=f"sdk-{i}", C="02" + "00" * 32) for i in range(limit + 1) ] - sizes = [] + sizes: list[int] = [] - def check(batch): + def check(batch: list[Proof]) -> SimpleNamespace: payload = PostCheckStateRequest(Ys=[p.Y for p in batch]) sizes.append(len(payload.Ys)) return response(batch) @@ -264,10 +266,10 @@ async def test_default_batch_fits_real_sdk_model(): @pytest.mark.asyncio -async def test_scan_deadline_opens_cooldown_for_next_guarded_scan(): +async def test_scan_deadline_opens_cooldown_for_next_guarded_scan() -> None: from routstr.mint import MintCooldownError - async def check(batch): + async def check(batch: list[Proof]) -> None: await asyncio.Event().wait() w = wallet(check) @@ -282,10 +284,10 @@ async def test_scan_deadline_opens_cooldown_for_next_guarded_scan(): @pytest.mark.asyncio -async def test_external_cancellation_does_not_open_cooldown(): +async def test_external_cancellation_does_not_open_cooldown() -> None: started = asyncio.Event() - async def check(batch): + async def check(batch: list[Proof]) -> None: started.set() await asyncio.Event().wait() @@ -299,7 +301,7 @@ async def test_external_cancellation_does_not_open_cooldown(): @pytest.mark.asyncio -async def test_scan_deadline_preserves_longer_rate_limit_cooldown(): +async def test_scan_deadline_preserves_longer_rate_limit_cooldown() -> None: w = wallet(response) guard = MintRateGuard.get(w.url) guard.apply_rate_limit_cooldown(120) diff --git a/tests/unit/test_lnurl_change.py b/tests/unit/test_lnurl_change.py index f920c0f3..b7566247 100644 --- a/tests/unit/test_lnurl_change.py +++ b/tests/unit/test_lnurl_change.py @@ -1,9 +1,10 @@ +from collections.abc import AsyncIterator from contextlib import asynccontextmanager from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch import pytest -from cashu.core.base import BlindedSignature, Proof, Unit +from cashu.core.base import BlindedMessage, BlindedSignature, Proof, Unit from cashu.core.crypto import b_dhke from cashu.core.models import PostMeltQuoteResponse from cashu.wallet.v1_api import LedgerAPI @@ -21,8 +22,13 @@ from routstr.wallet import _payout_mint_and_unit [(0, 0, 0, 0), (0, 7, 10, 3), (300000, 7, 10, 3)], ) async def test_capped_payout_recovers_all_change_with_real_cashu_sdk( - unit, scale, liability, input_fee, reserve, actual_fee -): + unit: str, + scale: int, + liability: int, + input_fee: int, + reserve: int, + actual_fee: int, +) -> None: MintRateGuard._guards.clear() private_key = b_dhke.PrivateKey() proof = Proof( @@ -48,7 +54,7 @@ async def test_capped_payout_recovers_all_change_with_real_cashu_sdk( w.sign_proofs_inplace_melt = Mock(side_effect=lambda ps, outputs, quote: ps) w._store_proofs = AsyncMock() - async def invalidate(ps): + async def invalidate(ps: list[Proof]) -> None: w.proofs = [p for p in w.proofs if p not in ps] w.invalidate = AsyncMock(side_effect=invalidate) @@ -59,9 +65,9 @@ async def test_capped_payout_recovers_all_change_with_real_cashu_sdk( [f"path-{i}" for i in range(n)], ) ) - quotes = {} + quotes: dict[str, PostMeltQuoteResponse] = {} - async def quote(invoice): + async def quote(invoice: str) -> PostMeltQuoteResponse: amount_msat = int(invoice) amount = amount_msat // 1000 if unit == "sat" else amount_msat q = PostMeltQuoteResponse( @@ -82,7 +88,9 @@ async def test_capped_payout_recovers_all_change_with_real_cashu_sdk( blank_count = 0 paid_amount = 0 - async def mint_melt(quote_id, inputs, outputs): + async def mint_melt( + quote_id: str, inputs: list[Proof], outputs: list[BlindedMessage] + ) -> PostMeltQuoteResponse: nonlocal selected_total, returned_change, blank_count, paid_amount q = quotes[quote_id] selected_total = sum(p.amount for p in inputs) @@ -104,7 +112,7 @@ async def test_capped_payout_recovers_all_change_with_real_cashu_sdk( return q.model_copy(update={"state": "PAID", "change": signatures}) @asynccontextmanager - async def session(): + async def session() -> AsyncIterator[Mock]: yield Mock() with ( diff --git a/tests/unit/test_payout_limits.py b/tests/unit/test_payout_limits.py index d801d72f..8d3df75e 100644 --- a/tests/unit/test_payout_limits.py +++ b/tests/unit/test_payout_limits.py @@ -1,3 +1,4 @@ +from collections.abc import AsyncIterator from contextlib import asynccontextmanager from unittest.mock import AsyncMock, Mock, patch @@ -8,7 +9,7 @@ from routstr.wallet import _payout_mint_and_unit @asynccontextmanager -async def session(): +async def session() -> AsyncIterator[Mock]: yield Mock() @@ -19,8 +20,8 @@ async def session(): [(1000, 0, 100), (80, 30000, 50), (20, 20000, None), (0, 0, None), (10, 0, None)], ) async def test_payout_limits_and_proof_refresh( - unit, scale, balance, liability, expected -): + unit: str, scale: int, balance: int, liability: int, expected: int | None +) -> None: send = AsyncMock() get_wallet = AsyncMock() check = AsyncMock(side_effect=lambda ps, w: ps) @@ -48,6 +49,7 @@ async def test_payout_limits_and_proof_refresh( if expected is None: send.assert_not_awaited() else: + assert send.await_args is not None assert send.await_args.kwargs["amount"] == expected * scale if balance <= 10: check.assert_not_awaited() @@ -55,7 +57,7 @@ async def test_payout_limits_and_proof_refresh( @pytest.mark.asyncio -async def test_failed_proof_check_never_pays_partial_balance(): +async def test_failed_proof_check_never_pays_partial_balance() -> None: send = AsyncMock() with ( patch("routstr.wallet.get_wallet", AsyncMock()),