mirror of
https://github.com/Routstr/routstr-core.git
synced 2026-10-05 12:28:22 +00:00
/v1/wallet/refund answered three distinct refusals with a bare `detail`
string: "No balance to refund", "Balance too small to refund" and "Cannot
refund key. There are ongoing requests for this api key." They are
indistinguishable to a client, and the consequences differ: the first proves
the key holds nothing and its stored copy can be dropped, the second is dust
no retry can pay out, and the third is a transient race whose balance is
still on the key and must be kept.
The SDK tried to tell them apart by matching the whole error string, but the
refund error it compares against is built as "API key refund failed:
<detail>", so its no-balance branch never matched. Every dead key stayed in
storage and was re-swept every five minutes, forever.
All three now carry the structured envelope the other refund errors already
use ({"error": {"type", "code", "message"}}), with codes
`no_balance_to_refund`, `balance_too_small_to_refund` and
`refund_ongoing_requests`. Messages and status codes are unchanged, so
message-matching clients (including SDK <= 0.4.8) behave exactly as before.
641 lines
22 KiB
Python
641 lines
22 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 AsyncMock, patch
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlmodel import select
|
|
|
|
from routstr.core.db import ApiKey, CashuTransaction
|
|
from routstr.wallet import MintConnectionError
|
|
|
|
|
|
@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
|
|
error = response.json()["detail"]["error"]
|
|
assert error["code"] == "no_balance_to_refund"
|
|
assert error["message"] == "No balance to refund"
|
|
assert error["type"] == "invalid_request_error"
|
|
|
|
# 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.refund.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_rejects_concurrent_topup_on_same_key(
|
|
authenticated_client: AsyncClient,
|
|
testmint_wallet: Any,
|
|
) -> None:
|
|
"""Test refund returns 409 when a concurrent topup changes the balance first."""
|
|
from routstr import balance as balance_module
|
|
|
|
wallet_response = await authenticated_client.get("/v1/wallet/")
|
|
assert wallet_response.status_code == 200
|
|
initial_balance = wallet_response.json()["balance"]
|
|
|
|
topup_amount_sat = 500
|
|
topup_token = await testmint_wallet.mint_tokens(topup_amount_sat)
|
|
|
|
key_looked_up = asyncio.Event()
|
|
allow_refund_to_continue = asyncio.Event()
|
|
original_lookup = balance_module._lookup_key_no_create
|
|
delayed_once = False
|
|
|
|
async def delayed_lookup_key_no_create(*args: Any, **kwargs: Any) -> ApiKey | None:
|
|
nonlocal delayed_once
|
|
key = await original_lookup(*args, **kwargs)
|
|
if not delayed_once and key is not None:
|
|
delayed_once = True
|
|
key_looked_up.set()
|
|
await allow_refund_to_continue.wait()
|
|
return key
|
|
|
|
async def issue_refund() -> Any:
|
|
return await authenticated_client.post("/v1/wallet/refund")
|
|
|
|
async def issue_topup() -> Any:
|
|
await key_looked_up.wait()
|
|
try:
|
|
return await authenticated_client.post(
|
|
"/v1/wallet/topup", params={"cashu_token": topup_token}
|
|
)
|
|
finally:
|
|
allow_refund_to_continue.set()
|
|
|
|
with patch(
|
|
"routstr.balance._lookup_key_no_create", new=delayed_lookup_key_no_create
|
|
):
|
|
refund_response, topup_response = await asyncio.gather(
|
|
issue_refund(), issue_topup()
|
|
)
|
|
|
|
assert topup_response.status_code == 200
|
|
assert topup_response.json()["msats"] == topup_amount_sat * 1000
|
|
assert refund_response.status_code == 409
|
|
assert (
|
|
refund_response.json()["detail"]
|
|
== "Balance changed concurrently. Please retry the refund."
|
|
)
|
|
|
|
final_balance_response = await authenticated_client.get("/v1/wallet/")
|
|
assert final_balance_response.status_code == 200
|
|
assert final_balance_response.json()["balance"] == (
|
|
initial_balance + topup_amount_sat * 1000
|
|
)
|
|
|
|
|
|
@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_wallet_history_returns_apikey_transactions(
|
|
authenticated_client: AsyncClient,
|
|
testmint_wallet: Any,
|
|
integration_session: Any,
|
|
) -> None:
|
|
wallet_response = await authenticated_client.get("/v1/wallet/")
|
|
api_key = wallet_response.json()["api_key"]
|
|
hashed_key = api_key[3:] if api_key.startswith("sk-") else api_key
|
|
|
|
topup_token = await testmint_wallet.mint_tokens(250)
|
|
topup_response = await authenticated_client.post(
|
|
"/v1/wallet/topup", params={"cashu_token": topup_token}
|
|
)
|
|
assert topup_response.status_code == 200
|
|
|
|
refund_response = await authenticated_client.post("/v1/wallet/refund")
|
|
assert refund_response.status_code == 200
|
|
|
|
history_response = await authenticated_client.get("/v1/wallet/history")
|
|
assert history_response.status_code == 200
|
|
transactions = history_response.json()["transactions"]
|
|
assert len(transactions) >= 2
|
|
assert all("api_key_hashed_key" not in tx for tx in transactions)
|
|
assert {tx["type"] for tx in transactions} >= {"in", "out"}
|
|
|
|
db_result = await integration_session.execute(
|
|
select(CashuTransaction).where(
|
|
CashuTransaction.api_key_hashed_key == hashed_key
|
|
)
|
|
)
|
|
db_transactions = db_result.scalars().all()
|
|
assert len(db_transactions) >= 2
|
|
|
|
|
|
@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
|
|
raw_error = "Mint unavailable: Connection refused"
|
|
|
|
# Make the send_token method raise a typed mint connection exception.
|
|
with patch(
|
|
"routstr.refund.send_token",
|
|
side_effect=MintConnectionError(raw_error),
|
|
):
|
|
response = await authenticated_client.post("/v1/wallet/refund")
|
|
assert response.status_code == 503
|
|
assert response.json()["detail"] == "Mint service unavailable"
|
|
assert raw_error not in response.text
|
|
|
|
# Balance should remain unchanged (transaction should roll back)
|
|
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
|
|
error = response.json()["detail"]["error"]
|
|
assert error["code"] == "no_balance_to_refund"
|
|
assert error["message"] == "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.refund.get_lnurl_data", AsyncMock()),
|
|
patch("routstr.refund.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"
|