mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-08-09 19:04:47 +00:00
539 lines
18 KiB
Python
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"
|