Files
routstr-core/tests/integration/test_wallet_topup.py
T
2026-06-01 23:30:20 +02:00

480 lines
15 KiB
Python

"""
Integration tests for wallet top-up functionality.
Tests POST /v1/wallet/topup endpoint with various token scenarios and edge cases.
"""
import asyncio
from typing import Any
from unittest.mock import patch
import pytest
from httpx import AsyncClient
from sqlmodel import select
from routstr.core.db import ApiKey, CashuTransaction
from .utils import (
CashuTokenGenerator,
ResponseValidator,
)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_topup_with_valid_token( # type: ignore[no-untyped-def]
integration_client: AsyncClient,
authenticated_client: AsyncClient,
testmint_wallet: Any,
db_snapshot,
integration_session,
) -> None:
"""Test topping up an existing wallet with a valid Cashu token"""
# Get initial balance from authenticated client
response = await authenticated_client.get("/v1/wallet/")
initial_balance = response.json()["balance"]
api_key = response.json()["api_key"]
# Capture database state
await db_snapshot.capture()
# Generate a new token for top-up
topup_amount = 500 # 500 sats
token = await testmint_wallet.mint_tokens(topup_amount)
# Top up the existing wallet
response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": token}
)
assert response.status_code == 200
data = response.json()
# Response should contain the added msats
assert "msats" in data
assert data["msats"] == topup_amount * 1000 # Convert to msats
# Verify balance increased
wallet_response = await authenticated_client.get("/v1/wallet/")
new_balance = wallet_response.json()["balance"]
assert new_balance == initial_balance + (topup_amount * 1000)
# Verify database state directly
# Get the hashed key from the API key
hashed_key = api_key[3:] # Remove "sk-" prefix
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
)
db_key = result.scalar_one()
# Verify balance increased in database
assert db_key.balance == new_balance
assert db_key.balance == initial_balance + (topup_amount * 1000)
tx_result = await integration_session.execute(
select(CashuTransaction).where(
CashuTransaction.token == token,
CashuTransaction.type == "in",
)
)
tx = tx_result.scalar_one()
assert tx.api_key_hashed_key == hashed_key
assert tx.source == "apikey"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_topup_with_multiple_denominations( # type: ignore[no-untyped-def]
integration_client: AsyncClient,
authenticated_client: AsyncClient,
testmint_wallet: Any,
integration_session,
) -> None:
"""Test topping up with tokens containing multiple denominations"""
# Generate token with specific denominations
# Cashu uses powers of 2 denominations
amount = 1337 # This will require multiple denominations
token = await testmint_wallet.mint_tokens(amount)
# Verify token has correct total value
# The testmint wallet should handle denomination splitting internally
# Top up the wallet
response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": token}
)
assert response.status_code == 200
data = response.json()
assert data["msats"] == amount * 1000
# Verify balance
wallet_response = await authenticated_client.get("/v1/wallet/")
balance = wallet_response.json()["balance"]
# Should have initial 10k sats + 1337 sats
assert balance == 10_000_000 + (amount * 1000)
@pytest.mark.integration
@pytest.mark.asyncio
async def test_topup_with_invalid_token(
authenticated_client: AsyncClient, db_snapshot: Any
) -> None: # type: ignore[no-untyped-def]
"""Test topping up with various invalid tokens"""
# Capture initial state
initial_response = await authenticated_client.get("/v1/wallet/")
initial_balance = initial_response.json()["balance"]
await db_snapshot.capture()
# Test various invalid tokens
invalid_tokens = [
CashuTokenGenerator.generate_invalid_token(), # Malformed token
"not-a-cashu-token", # Wrong format
"cashuA", # Empty token
"cashuAinvalidbase64!!!", # Invalid base64
]
for invalid_token in invalid_tokens:
response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": invalid_token}
)
# Should fail with 400
assert response.status_code == 400, (
f"Token {invalid_token[:20]}... should be invalid"
)
# Validate error response
validator = ResponseValidator()
error_validation = validator.validate_error_response(
response, expected_status=400, expected_error_key="detail"
)
assert error_validation["valid"]
# Verify balance unchanged
final_response = await authenticated_client.get("/v1/wallet/")
assert final_response.json()["balance"] == initial_balance
# Verify no database changes
diff = await db_snapshot.diff()
assert len(diff["api_keys"]["modified"]) == 0
@pytest.mark.integration
@pytest.mark.asyncio
async def test_topup_with_spent_token( # type: ignore[no-untyped-def]
integration_client: AsyncClient,
authenticated_client: AsyncClient,
testmint_wallet: Any,
db_snapshot,
) -> None:
"""Test topping up with an already spent token"""
# Generate and use a token
amount = 300
token = await testmint_wallet.mint_tokens(amount)
# First use - should succeed
response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": token}
)
assert response.status_code == 200
# Capture state after first use
await db_snapshot.capture()
# Try to use the same token again - should fail
response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": token}
)
assert response.status_code == 400
assert "spent" in response.json()["detail"].lower()
# Verify no additional balance changes
diff = await db_snapshot.diff()
assert len(diff["api_keys"]["modified"]) == 0
@pytest.mark.integration
@pytest.mark.asyncio
async def test_topup_malformed_tokens(authenticated_client: AsyncClient) -> None: # type: ignore[no-untyped-def]
"""Test topping up with malformed tokens returns 400"""
# Test malformed tokens
malformed_tokens = [
"Bearer cashuA123", # Has Bearer prefix
"cashu" + "\x00" + "A123", # Null byte
"cashuA" + "x" * 10000, # Extremely long
"cashuA\n\rtest", # Newline characters
]
for token in malformed_tokens:
response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": token}
)
assert response.status_code == 400
@pytest.mark.integration
@pytest.mark.asyncio
async def test_database_atomic_balance_updates( # type: ignore[no-untyped-def]
integration_client: AsyncClient,
authenticated_client: AsyncClient,
testmint_wallet: Any,
integration_session,
) -> None:
"""Test that balance updates are atomic and prevent race conditions"""
# Get initial state
response = await authenticated_client.get("/v1/wallet/")
initial_balance = response.json()["balance"]
# Generate multiple tokens
amounts = [100, 200, 300]
tokens = []
for amount in amounts:
token = await testmint_wallet.mint_tokens(amount)
tokens.append((token, amount))
# Top up sequentially and verify each update
expected_balance = initial_balance
for i, (token, amount) in enumerate(tokens):
response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": token}
)
assert response.status_code == 200, f"Topup {i + 1} failed: {response.text}"
assert response.json()["msats"] == amount * 1000
expected_balance += amount * 1000
# Verify balance via API endpoint
wallet_resp = await authenticated_client.get("/v1/wallet/")
api_balance = wallet_resp.json()["balance"]
# Verify balance matches what the API returns
assert api_balance == expected_balance
@pytest.mark.integration
@pytest.mark.asyncio
async def test_transaction_history_tracking( # type: ignore[no-untyped-def]
integration_client: AsyncClient,
authenticated_client: AsyncClient,
testmint_wallet: Any,
integration_session,
) -> None:
"""Test that token spending is tracked to prevent reuse"""
# Note: The current implementation doesn't store transaction history
# in the database. It relies on the Cashu wallet to track spent tokens.
# This test verifies that the wallet correctly rejects spent tokens.
# Generate a token
token = await testmint_wallet.mint_tokens(250)
# Use the token
response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": token}
)
assert response.status_code == 200
# Verify token is tracked as spent in testmint wallet
assert len(testmint_wallet.spent_tokens) > 0
# Try to reuse - should fail
response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": token}
)
assert response.status_code == 400
@pytest.mark.integration
@pytest.mark.asyncio
async def test_topup_during_active_proxy_request( # type: ignore[no-untyped-def]
integration_client: AsyncClient,
authenticated_client: AsyncClient,
testmint_wallet: Any,
) -> None:
"""Test topping up while another request is in progress"""
# This test simulates a top-up happening while the wallet is being used
# Since we can't easily simulate a real proxy request, we'll test
# concurrent balance modifications
# Get initial state
await authenticated_client.get("/v1/wallet/")
# Generate tokens
topup_token = await testmint_wallet.mint_tokens(500)
# Create a task that simulates wallet usage (checking balance repeatedly)
async def simulate_usage() -> None:
for _ in range(10):
await authenticated_client.get("/v1/wallet/")
await asyncio.sleep(0.01)
# Run top-up concurrently with simulated usage
usage_task = asyncio.create_task(simulate_usage())
# Perform top-up
topup_response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": topup_token}
)
await usage_task
# Top-up should succeed
assert topup_response.status_code == 200
assert topup_response.json()["msats"] == 500_000
@pytest.mark.integration
@pytest.mark.asyncio
async def test_maximum_balance_limits( # type: ignore[no-untyped-def]
integration_client: AsyncClient,
authenticated_client: AsyncClient,
testmint_wallet: Any,
integration_session,
) -> None:
"""Test if there are any maximum balance limits"""
# Note: The current implementation doesn't enforce maximum balance limits
# This test verifies large balances are handled correctly
# Get current balance
response = await authenticated_client.get("/v1/wallet/")
# Try to add a large amount
large_amount = 1_000_000 # 1 million sats
token = await testmint_wallet.mint_tokens(large_amount)
response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": token}
)
# Should succeed
assert response.status_code == 200
assert response.json()["msats"] == large_amount * 1000
# Verify balance
wallet_response = await authenticated_client.get("/v1/wallet/")
balance = wallet_response.json()["balance"]
assert balance >= large_amount * 1000 # At least the large amount
@pytest.mark.integration
@pytest.mark.asyncio
async def test_network_failure_during_token_verification( # type: ignore[no-untyped-def]
integration_client: AsyncClient,
authenticated_client: AsyncClient,
testmint_wallet: Any,
) -> None:
"""Test handling of network failures during token verification"""
# Generate a valid token
token = await testmint_wallet.mint_tokens(300)
# Mock credit_balance to simulate network failure during token verification
with patch("routstr.balance.credit_balance") as mock_credit_balance:
mock_credit_balance.side_effect = Exception("Network error: Connection timeout")
response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": token}
)
# Should return 500 error for network issues
assert response.status_code == 500
assert "detail" in response.json()
assert response.json()["detail"] == "Internal server error"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_topup_response_format( # type: ignore[no-untyped-def]
authenticated_client: AsyncClient, testmint_wallet: Any
) -> None:
"""Test the response format of successful top-up"""
token = await testmint_wallet.mint_tokens(123)
response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": token}
)
assert response.status_code == 200
data = response.json()
# Validate response structure
assert isinstance(data, dict)
assert "msats" in data
assert isinstance(data["msats"], int)
assert data["msats"] == 123_000 # 123 sats in msats
@pytest.mark.integration
@pytest.mark.asyncio
async def test_topup_with_zero_amount_token( # type: ignore[no-untyped-def]
authenticated_client: AsyncClient, testmint_wallet: Any
) -> None:
"""Test topping up with a token that has zero value"""
# Create a token with 0 amount (edge case)
# The testmint wallet should handle this
with patch.object(
testmint_wallet,
"redeem_token",
return_value=(0, "sat", testmint_wallet.mint_url),
):
token = await testmint_wallet.mint_tokens(0)
response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": token}
)
# Zero/negative redemptions are refused to avoid crediting empty
# or dust tokens (and to prevent orphan zero-balance keys).
assert response.status_code == 400
@pytest.mark.integration
@pytest.mark.asyncio
@pytest.mark.slow
async def test_topup_stress_test( # type: ignore[no-untyped-def]
integration_client: AsyncClient,
authenticated_client: AsyncClient,
testmint_wallet: Any,
) -> None:
"""Stress test with many sequential top-ups"""
# Get initial balance
response = await authenticated_client.get("/v1/wallet/")
initial_balance = response.json()["balance"]
# Perform many small top-ups
num_topups = 50
amount_per_topup = 10 # 10 sats each
successful_topups = 0
for i in range(num_topups):
token = await testmint_wallet.mint_tokens(amount_per_topup)
response = await authenticated_client.post(
"/v1/wallet/topup", params={"cashu_token": token}
)
if response.status_code == 200:
successful_topups += 1
# All should succeed
assert successful_topups == num_topups
# Verify final balance
final_response = await authenticated_client.get("/v1/wallet/")
final_balance = final_response.json()["balance"]
expected_balance = initial_balance + (num_topups * amount_per_topup * 1000)
assert final_balance == expected_balance