Files
routstr-core/tests/integration/test_wallet_refund.py
T

539 lines
18 KiB
Python

"""
Integration tests for wallet refund functionality.
Tests POST /v1/wallet/refund endpoint including partial and full refunds.
"""
import asyncio
import base64
import json
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
@pytest.mark.integration
@pytest.mark.asyncio
async def test_full_balance_refund_returns_cashu_token(
integration_client: AsyncClient,
authenticated_client: AsyncClient,
testmint_wallet: Any,
db_snapshot: Any,
integration_session: Any,
) -> None:
"""Test full balance refund returns a valid Cashu token when no refund address is set"""
# Get initial balance
response = await authenticated_client.get("/v1/wallet/")
initial_balance = response.json()["balance"]
assert initial_balance == 10_000_000 # 10k sats in msats
# Capture database state
await db_snapshot.capture()
# Request refund
response = await authenticated_client.post("/v1/wallet/refund")
assert response.status_code == 200
data = response.json()
# Should return either sats or msats (as string), and token
assert "token" in data
assert data["token"].startswith("cashuA")
# Check for either sats or msats depending on refund_currency
if "sats" in data:
assert data["sats"] == str(initial_balance // 1000) # Convert msats to sats
elif "msats" in data:
assert data["msats"] == str(initial_balance)
else:
pytest.fail("Response should contain either 'sats' or 'msats'")
# Validate token format
token = data["token"]
try:
# Decode token to verify it's valid
token_data = token[6:] # Remove "cashuA" prefix
decoded = base64.urlsafe_b64decode(token_data)
token_json = json.loads(decoded)
assert "token" in token_json
assert isinstance(token_json["token"], list)
except Exception as e:
pytest.fail(f"Invalid Cashu token format: {e}")
# Try to use the API key - should still work but have 0 balance
response = await authenticated_client.get("/v1/wallet/")
assert response.status_code == 200
assert response.json()["balance"] == 0
# The refund token has been validated above by decoding it
# The API key deletion has been verified by the 401 response
@pytest.mark.integration
@pytest.mark.asyncio
async def test_partial_refund_not_supported(
integration_client: AsyncClient, authenticated_client: AsyncClient
) -> None:
"""Test that partial refunds are not currently supported"""
# Note: Current implementation doesn't support partial refunds via the endpoint
# The refund_balance function supports it, but the endpoint doesn't expose it
# Try to request partial refund (endpoint doesn't accept amount parameter)
response = await authenticated_client.post(
"/v1/wallet/refund",
json={"amount": 5000}, # Try to refund 5 sats
)
# Should still refund full balance (endpoint ignores the parameter)
assert response.status_code == 200
data = response.json()
# Check for either sats or msats
if "sats" in data:
assert data["sats"] == "10000" # Full balance in sats
elif "msats" in data:
assert data["msats"] == "10000000" # Full balance in msats
else:
pytest.fail("Response should contain either 'sats' or 'msats'")
@pytest.mark.integration
@pytest.mark.asyncio
async def test_zero_balance_refund_handling(
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
) -> None:
"""Test refunding when balance is zero"""
# Create API key with zero balance
token = await testmint_wallet.mint_tokens(100)
# Use cashu token as Bearer auth to create API key
integration_client.headers["Authorization"] = f"Bearer {token}"
response = await integration_client.get("/v1/wallet/info")
assert response.status_code == 200
api_key = response.json()["api_key"]
# Get the hashed key (remove "sk-" prefix)
hashed_key = api_key[3:] if api_key.startswith("sk-") else api_key
from sqlmodel import update
await integration_session.execute(
update(ApiKey).where(ApiKey.hashed_key == hashed_key).values(balance=0) # type: ignore[arg-type]
)
await integration_session.commit()
# Try to refund
integration_client.headers["Authorization"] = f"Bearer {api_key}"
response = await integration_client.post("/v1/wallet/refund")
assert response.status_code == 400
assert response.json()["detail"] == "No balance to refund"
# Key should still exist
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
)
assert result.scalar_one_or_none() is not None
@pytest.mark.integration
@pytest.mark.asyncio
async def test_refund_amount_validation(
integration_client: AsyncClient,
authenticated_client: AsyncClient,
integration_session: Any,
) -> None:
"""Test refund amount validation for edge cases"""
# Get API key and verify no refund address is set
response = await authenticated_client.get("/v1/wallet/")
api_key = response.json()["api_key"]
# Get the hashed key (remove "sk-" prefix)
hashed_key = api_key[3:] if api_key.startswith("sk-") else api_key
# Verify the key has no refund address (needed for the "too small" check)
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
)
key = result.scalar_one()
assert key.refund_address is None
@pytest.mark.integration
@pytest.mark.asyncio
@pytest.mark.skip(reason="Lightning address refund functionality not implemented")
async def test_refund_with_lightning_address(
integration_client: AsyncClient,
testmint_wallet: Any,
integration_session: Any,
db_snapshot: Any,
) -> None:
"""Test refund to Lightning address when refund_address is set"""
# Create API key normally first
token = await testmint_wallet.mint_tokens(500)
refund_address = "test@lightning.address"
# Use cashu token as Bearer auth to create API key
integration_client.headers["Authorization"] = f"Bearer {token}"
response = await integration_client.get("/v1/wallet/info")
assert response.status_code == 200
api_key = response.json()["api_key"]
balance = response.json()["balance"]
# Update the key to have a refund address
hashed_key = api_key[3:] if api_key.startswith("sk-") else api_key
from sqlmodel import update
await integration_session.execute(
update(ApiKey)
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
.values(refund_address=refund_address)
)
await integration_session.commit()
# Capture state
await db_snapshot.capture()
# Mock send_to_lnurl function directly
with patch("routstr.balance.send_to_lnurl") as mock_send_to_lnurl:
mock_send_to_lnurl.return_value = {
"amount_sent": balance,
"unit": "msat",
"lnurl": refund_address,
"status": "completed",
}
# Request refund
integration_client.headers["Authorization"] = f"Bearer {api_key}"
response = await integration_client.post("/v1/wallet/refund")
assert response.status_code == 200
data = response.json()
# Should return recipient and msats, but no token
assert data["recipient"] == refund_address
assert data["msats"] == balance
assert "token" not in data
# Verify send_to_lnurl was called with correct parameters
mock_send_to_lnurl.assert_called_once_with(
balance, # amount in msats
"msat", # unit
refund_address, # lnurl
)
# Verify key was deleted by trying to use it
integration_client.headers["Authorization"] = f"Bearer {api_key}"
verify_response = await integration_client.get("/v1/wallet/info")
assert verify_response.status_code == 401
@pytest.mark.integration
@pytest.mark.asyncio
async def test_database_state_after_refund(
integration_client: AsyncClient,
authenticated_client: AsyncClient,
integration_session: Any,
) -> None:
"""Test database state changes after successful refund"""
# Get initial state
response = await authenticated_client.get("/v1/wallet/")
api_key = response.json()["api_key"]
# Get the hashed key (remove "sk-" prefix)
hashed_key = api_key[3:] if api_key.startswith("sk-") else api_key
# Verify key exists before refund
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
)
key_before = result.scalar_one()
assert key_before.balance == 10_000_000
# Refund
response = await authenticated_client.post("/v1/wallet/refund")
assert response.status_code == 200
# Refresh the key to get the updated balance from the database
await integration_session.refresh(key_before)
# Verify key balance is 0 after refund
assert key_before.balance == 0
# Count total keys to ensure it wasn't deleted
result = await integration_session.execute(select(ApiKey))
remaining_keys = result.scalars().all()
assert len(remaining_keys) == 1
@pytest.mark.integration
@pytest.mark.asyncio
async def test_token_is_spendable_at_testmint(
integration_client: AsyncClient,
authenticated_client: AsyncClient,
testmint_wallet: Any,
) -> None:
"""Test that returned Cashu token is spendable at testmint"""
# Get refund token
response = await authenticated_client.post("/v1/wallet/refund")
assert response.status_code == 200
refund_token = response.json()["token"]
# Try to redeem the refund token
# In a real test, this would interact with testmint
# Here we verify the token format is correct
assert refund_token.startswith("cashuA")
# The testmint wallet should be able to track this as a valid token
# Note: Our mock testmint doesn't actually validate tokens created by wallet().send()
# In a real integration test, you would:
# redeemed_amount = await testmint_wallet.redeem_token(refund_token)
# assert redeemed_amount == 10_000 # 10k sats
@pytest.mark.integration
@pytest.mark.asyncio
async def test_concurrent_refund_requests(
integration_client: AsyncClient, testmint_wallet: Any
) -> None:
"""Test handling of concurrent refund requests for the same API key"""
# Create API key
token = await testmint_wallet.mint_tokens(1000)
# Use cashu token as Bearer auth to create API key
integration_client.headers["Authorization"] = f"Bearer {token}"
response = await integration_client.get("/v1/wallet/info")
assert response.status_code == 200
api_key = response.json()["api_key"]
# Create multiple concurrent refund requests
[
{
"method": "POST",
"url": "/v1/wallet/refund",
"headers": {"Authorization": f"Bearer {api_key}"},
}
for _ in range(5)
]
# Execute concurrently with exception handling
async def refund_request(client: AsyncClient, api_key: str) -> Any:
try:
headers = {"Authorization": f"Bearer {api_key}"}
return await client.post("/v1/wallet/refund", headers=headers)
except Exception as e:
# Return a mock response for exceptions
class MockResponse:
status_code = 500
text = str(e)
return MockResponse()
# Create tasks
tasks = [refund_request(integration_client, api_key) for _ in range(5)]
responses = await asyncio.gather(*tasks, return_exceptions=False)
# Count successes and failures
successful = [
r for r in responses if hasattr(r, "status_code") and r.status_code == 200
]
failed = [
r for r in responses if hasattr(r, "status_code") and r.status_code != 200
]
# At least one should succeed (the first one)
assert len(successful) >= 1
assert len(successful) + len(failed) == 5
@pytest.mark.integration
@pytest.mark.asyncio
async def test_refund_during_active_usage(
integration_client: AsyncClient, authenticated_client: AsyncClient
) -> None:
"""Test refunding while the API key is being used"""
# Get API key
response = await authenticated_client.get("/v1/wallet/")
# Create a task that simulates active usage
async def simulate_usage() -> None:
for _ in range(10):
try:
await authenticated_client.get("/v1/wallet/")
except Exception:
# Expect failures after refund
pass
await asyncio.sleep(0.01)
# Start usage simulation
usage_task = asyncio.create_task(simulate_usage())
# Wait a bit then refund
await asyncio.sleep(0.02)
refund_response = await authenticated_client.post("/v1/wallet/refund")
await usage_task
# Refund should succeed
assert refund_response.status_code == 200
# Further usage should return 200 but with 0 balance
response = await authenticated_client.get("/v1/wallet/")
assert response.status_code == 200
assert response.json()["balance"] == 0
@pytest.mark.integration
@pytest.mark.asyncio
async def test_mint_unavailability_handling(
integration_client: AsyncClient, authenticated_client: AsyncClient
) -> None:
"""Test handling when mint service is unavailable"""
# The global mock in conftest.py is already in place,
# so we need to temporarily modify it
from unittest.mock import patch
# Make the send_token method raise an exception
with patch(
"routstr.balance.send_token",
side_effect=Exception("Mint unavailable: Connection refused"),
):
# The exception should propagate as a 503 error (Service Unavailable)
# But we need to handle it properly
try:
response = await authenticated_client.post("/v1/wallet/refund")
# If we get here, check the status code
assert response.status_code == 503
assert "Mint service unavailable" in response.json()["detail"]
except Exception as e:
# If the exception propagates, that's also a failure scenario
assert "Mint unavailable" in str(e)
# Balance should remain unchanged (transaction should roll back)
# Note: Current implementation might not handle this perfectly
wallet_response = await authenticated_client.get("/v1/wallet/")
assert wallet_response.status_code == 200
assert wallet_response.json()["balance"] == 10_000_000
@pytest.mark.integration
@pytest.mark.asyncio
async def test_refund_response_format(
integration_client: AsyncClient,
authenticated_client: AsyncClient,
testmint_wallet: Any,
integration_session: Any,
) -> None:
"""Test the response format for different refund scenarios"""
# Test 1: Refund without refund address (returns token)
response = await authenticated_client.post("/v1/wallet/refund")
assert response.status_code == 200
data = response.json()
assert isinstance(data, dict)
assert "token" in data
assert isinstance(data["token"], str)
# Should have either sats or msats (both as strings)
if "sats" in data:
assert isinstance(data["sats"], str)
elif "msats" in data:
assert isinstance(data["msats"], str)
else:
pytest.fail("Response should contain either 'sats' or 'msats'")
# Test 2: Test with refund address would require creating key via proxy endpoint
# Since refund address headers only work on proxy endpoints, not wallet endpoints
# Skip this part as it's already tested in test_refund_with_lightning_address
@pytest.mark.integration
@pytest.mark.asyncio
async def test_refund_error_handling(
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
) -> None:
"""Test various error scenarios in refund process"""
# Test 1: Refund with corrupted database state
token = await testmint_wallet.mint_tokens(200)
# Use cashu token as Bearer auth to create API key
integration_client.headers["Authorization"] = f"Bearer {token}"
response = await integration_client.get("/v1/wallet/info")
assert response.status_code == 200
api_key = response.json()["api_key"]
# Simulate database corruption by setting negative balance
hashed_key = api_key[3:] if api_key.startswith("sk-") else api_key
from sqlmodel import update
await integration_session.execute(
update(ApiKey)
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
.values(balance=-1000) # Invalid negative balance
)
await integration_session.commit()
integration_client.headers["Authorization"] = f"Bearer {api_key}"
response = await integration_client.post("/v1/wallet/refund")
assert response.status_code == 400
assert response.json()["detail"] == "No balance to refund"
@pytest.mark.integration
@pytest.mark.asyncio
async def test_refund_with_expired_key(
integration_client: AsyncClient, testmint_wallet: Any, integration_session: Any
) -> None:
"""Test refunding an expired API key"""
# Create expired key
from datetime import datetime, timedelta, timezone
token = await testmint_wallet.mint_tokens(500)
past_expiry = int((datetime.now(timezone.utc) - timedelta(hours=1)).timestamp())
# Use cashu token as Bearer auth to create API key
integration_client.headers["Authorization"] = f"Bearer {token}"
response = await integration_client.get("/v1/wallet/info")
assert response.status_code == 200
api_key = response.json()["api_key"]
# Update the key to have expiry time and refund address
hashed_key = api_key[3:] if api_key.startswith("sk-") else api_key
from sqlmodel import update
await integration_session.execute(
update(ApiKey)
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
.values(key_expiry_time=past_expiry, refund_address="expired@ln.address")
)
await integration_session.commit()
# Key should still work until background task processes it
integration_client.headers["Authorization"] = f"Bearer {api_key}"
# Mock the refund to LN address
with patch("routstr.balance.send_to_lnurl") as mock_send_to_lnurl:
mock_send_to_lnurl.return_value = 500
response = await integration_client.post("/v1/wallet/refund")
# Should still allow manual refund
assert response.status_code == 200
assert response.json()["recipient"] == "expired@ln.address"