the refactor part 02

This commit is contained in:
Shroominic
2025-11-28 13:40:35 +09:00
parent fa313e4534
commit 9be8aba417
20 changed files with 238 additions and 286 deletions
+15 -20
View File
@@ -66,7 +66,7 @@ os.environ.update(test_env)
os.environ.pop("ADMIN_PASSWORD", None)
from routstr.core.db import ApiKey, get_session # noqa: E402
from routstr.core.db import TemporaryCredit, get_session # noqa: E402
from routstr.core.main import app, lifespan # noqa: E402
@@ -280,7 +280,7 @@ class TestmintWallet:
return 100000 # 100k sats
async def credit_balance(
self, cashu_token: str, key: ApiKey, session: AsyncSession
self, cashu_token: str, key: TemporaryCredit, session: AsyncSession
) -> int:
"""Credit balance to API key - test implementation"""
try:
@@ -301,9 +301,9 @@ class TestmintWallet:
# Use atomic update to avoid lost update problem in concurrent scenarios
stmt = (
update(ApiKey)
.where(col(ApiKey.hashed_key) == key.hashed_key)
.values(balance=ApiKey.balance + amount_msat)
update(TemporaryCredit)
.where(col(TemporaryCredit.hashed_key) == key.hashed_key)
.values(balance=TemporaryCredit.balance + amount_msat)
)
await session.execute(stmt)
await session.commit()
@@ -390,7 +390,7 @@ class DatabaseSnapshot:
async def capture(self) -> Dict[str, List[Dict]]:
"""Capture current database state"""
# Get all API keys with their data
result = await self.session.execute(select(ApiKey))
result = await self.session.execute(select(TemporaryCredit))
api_keys = result.scalars().all()
snapshot = {
@@ -398,10 +398,8 @@ class DatabaseSnapshot:
{
"hashed_key": key.hashed_key,
"balance": key.balance,
"total_spent": key.total_spent,
"total_requests": key.total_requests,
"refund_address": key.refund_address,
"key_expiry_time": key.key_expiry_time,
"refund_expiration_time": key.refund_expiration_time,
}
for key in api_keys
]
@@ -447,10 +445,8 @@ class DatabaseSnapshot:
for field in [
"balance",
"total_spent",
"total_requests",
"refund_address",
"key_expiry_time",
"refund_expiration_time",
]:
if old[field] != new[field]:
changes[field] = {
@@ -522,18 +518,17 @@ async def integration_app(
with (
patch("routstr.core.db.engine", integration_engine),
patch.object(_settings, "cashu_mints", [mint_url]),
patch("routstr.wallet.credit_balance", testmint_wallet.credit_balance),
patch("routstr.wallet.send_token", testmint_wallet.send_token),
patch("routstr.wallet.send_to_lnurl", testmint_wallet.send_to_lnurl),
patch("routstr.wallet.recieve_token", testmint_wallet.redeem_token),
patch("routstr.wallet.get_balance", testmint_wallet.get_balance),
patch("routstr.payment.wallet.credit_balance", testmint_wallet.credit_balance),
patch("routstr.payment.wallet.send_token", testmint_wallet.send_token),
patch("routstr.payment.wallet.recieve_token", testmint_wallet.redeem_token),
patch("routstr.payment.wallet.get_balance", testmint_wallet.get_balance),
patch("routstr.balance.send_token", testmint_wallet.send_token),
patch("routstr.balance.send_to_lnurl", testmint_wallet.send_to_lnurl),
patch("websockets.connect") as mock_websockets,
patch("routstr.payment.price.btc_usd_price", return_value=50000.0),
patch("routstr.payment.price.sats_usd_price", return_value=0.0005),
patch(
"routstr.payment.helpers.calculate_discounted_max_cost",
"routstr.payment.cost.calculate_discounted_max_cost",
side_effect=_passthrough_discount,
),
):
@@ -705,8 +700,8 @@ async def background_tasks_controller() -> AsyncGenerator[Any, None]:
original_periodic_payout: Optional[Callable] = None
try:
from routstr.payment.models import update_sats_pricing
from routstr.wallet import periodic_payout
from routstr.models.models import update_sats_pricing
from routstr.payment.wallet import periodic_payout
async def controlled_update_pricing() -> None:
while not controller.cancelled:
+45 -40
View File
@@ -9,9 +9,9 @@ from unittest.mock import AsyncMock, patch
import pytest
from routstr.core.db import ApiKey
from routstr.payment.models import Model, Pricing, update_sats_pricing
from routstr.wallet import periodic_payout
from routstr.core.db import TemporaryCredit
from routstr.models.models import Model, Pricing, update_sats_pricing
from routstr.payment.wallet import periodic_payout
@pytest.mark.asyncio
@@ -201,12 +201,12 @@ class TestRefundCheckTask:
) -> None:
"""Test that expired keys with balance and refund address are refunded"""
# Create an expired API key with balance
expired_key = ApiKey(
expired_key = TemporaryCredit(
hashed_key="expired_test_key",
balance=5000, # 5 sats in msats
refund_address="lnurl1test",
key_expiry_time=int(time.time()) - 3600, # Expired 1 hour ago
created_at=datetime.utcnow() - timedelta(days=1),
refund_expiration_time=int(time.time()) - 3600, # Expired 1 hour ago
created=datetime.utcnow() - timedelta(days=1),
)
integration_session.add(expired_key)
await integration_session.commit()
@@ -214,7 +214,7 @@ class TestRefundCheckTask:
# Mock the wallet send_to_lnurl method and get_session
with (
patch(
"routstr.wallet.send_to_lnurl", AsyncMock(return_value=5)
"routstr.payment.lnurl.send_to_lnurl", AsyncMock(return_value=5)
) as mock_send_to_lnurl,
patch("routstr.core.db.get_session") as mock_get_session,
):
@@ -233,8 +233,8 @@ class TestRefundCheckTask:
if (
expired_key.balance > 0
and expired_key.refund_address
and expired_key.key_expiry_time
and expired_key.key_expiry_time < current_time
and expired_key.refund_expiration_time
and expired_key.refund_expiration_time < current_time
):
# Call wallet send_to_lnurl to trigger the refund
amount_sats = expired_key.balance // 1000
@@ -258,12 +258,12 @@ class TestRefundCheckTask:
"""Test that refund check continues after mint errors"""
# Create multiple expired keys
for i in range(3):
key = ApiKey(
key = TemporaryCredit(
hashed_key=f"expired_key_{i}",
balance=1000 * (i + 1),
refund_address=f"lnurl{i}",
key_expiry_time=int(time.time()) - 3600,
created_at=datetime.utcnow(),
refund_expiration_time=int(time.time()) - 3600,
created=datetime.utcnow(),
)
integration_session.add(key)
await integration_session.commit()
@@ -279,7 +279,7 @@ class TestRefundCheckTask:
with (
patch(
"routstr.wallet.send_to_lnurl", mock_send_to_lnurl
"routstr.payment.lnurl.send_to_lnurl", mock_send_to_lnurl
) as mock_send_to_lnurl_patch,
patch("routstr.core.db.get_session") as mock_get_session,
):
@@ -293,15 +293,15 @@ class TestRefundCheckTask:
current_time = int(time.time())
from sqlalchemy import select as sa_select
result = await integration_session.execute(sa_select(ApiKey))
result = await integration_session.execute(sa_select(TemporaryCredit))
keys = result.scalars().all()
for key in keys:
if (
key.balance > 0
and key.refund_address
and key.key_expiry_time
and key.key_expiry_time < current_time
and key.refund_expiration_time
and key.refund_expiration_time < current_time
):
amount_sats = key.balance // 1000
try:
@@ -352,21 +352,21 @@ class TestRefundCheckTask:
current_time = int(time.time())
for data in keys_data:
key = ApiKey(
key = TemporaryCredit(
hashed_key=data["hashed_key"],
balance=data["balance"],
refund_address=data["refund_address"],
key_expiry_time=current_time - 3600
refund_expiration_time=current_time - 3600
if data["expired"]
else current_time + 3600,
created_at=datetime.utcnow(),
created=datetime.utcnow(),
)
integration_session.add(key)
await integration_session.commit()
with (
patch(
"routstr.wallet.send_to_lnurl", AsyncMock(return_value=1)
"routstr.payment.lnurl.send_to_lnurl", AsyncMock(return_value=1)
) as mock_send_to_lnurl,
patch("routstr.core.db.get_session") as mock_get_session,
):
@@ -382,15 +382,15 @@ class TestRefundCheckTask:
current_time = int(time.time())
from sqlalchemy import select as sa_select
result = await integration_session.execute(sa_select(ApiKey))
result = await integration_session.execute(sa_select(TemporaryCredit))
keys = result.scalars().all()
for key in keys:
if (
key.balance > 0
and key.refund_address
and key.key_expiry_time
and key.key_expiry_time < current_time
and key.refund_expiration_time
and key.refund_expiration_time < current_time
):
amount_sats = key.balance // 1000
await mock_send_to_lnurl(key.refund_address, amount=amount_sats)
@@ -410,7 +410,7 @@ class TestRefundCheckTask:
# Check final state
from sqlalchemy import select as sa_select
result = await integration_session.execute(sa_select(ApiKey))
result = await integration_session.execute(sa_select(TemporaryCredit))
remaining_keys_list = result.scalars().all()
remaining_ids = [k.hashed_key for k in remaining_keys_list]
@@ -454,10 +454,10 @@ class TestPeriodicPayoutTask:
for i in range(5):
balance = 10000 * (i + 1) # 10, 20, 30, 40, 50 sats
total_user_balance += balance
key = ApiKey(
key = TemporaryCredit(
hashed_key=f"user_key_{i}",
balance=balance,
created_at=datetime.utcnow(),
created=datetime.utcnow(),
)
integration_session.add(key)
await integration_session.commit()
@@ -466,9 +466,12 @@ class TestPeriodicPayoutTask:
wallet_balance = 200000 # 200 sats total
with (
patch("routstr.wallet.get_balance", AsyncMock(return_value=wallet_balance)),
patch(
"routstr.wallet.send_to_lnurl", AsyncMock(return_value=None)
"routstr.payment.wallet.get_balance",
AsyncMock(return_value=wallet_balance),
),
patch(
"routstr.payment.lnurl.send_to_lnurl", AsyncMock(return_value=None)
) as mock_send_to_lnurl,
):
# Mock environment variables
@@ -481,7 +484,7 @@ class TestPeriodicPayoutTask:
},
):
# Call periodic_payout directly (pay_out was renamed/refactored)
from routstr.wallet import periodic_payout
from routstr.payment.wallet import periodic_payout
await periodic_payout()
@@ -501,7 +504,7 @@ class TestPeriodicPayoutTask:
# key = ApiKey(
# hashed_key="single_user",
# balance=50000, # 50 sats
# created_at=datetime.utcnow(),
# created=datetime.utcnow(),
# )
# integration_session.add(key)
# await integration_session.commit()
@@ -538,7 +541,7 @@ class TestPeriodicPayoutTask:
# key = ApiKey(
# hashed_key="low_revenue_user",
# balance=95000, # 95 sats
# created_at=datetime.utcnow(),
# created=datetime.utcnow(),
# )
# integration_session.add(key)
# await integration_session.commit()
@@ -648,14 +651,14 @@ class TestTaskInteractions:
"""Test that database operations don't deadlock during concurrent task execution"""
# Create test data
for i in range(10):
key = ApiKey(
key = TemporaryCredit(
hashed_key=f"concurrent_key_{i}",
balance=1000 * i,
refund_address=f"lnurl{i}" if i % 2 == 0 else None,
key_expiry_time=int(time.time()) - 3600
refund_expiration_time=int(time.time()) - 3600
if i % 3 == 0
else int(time.time()) + 3600,
created_at=datetime.utcnow(),
created=datetime.utcnow(),
)
integration_session.add(key)
await integration_session.commit()
@@ -664,14 +667,14 @@ class TestTaskInteractions:
async def read_operation() -> int:
from sqlalchemy import select as sa_select
result = await integration_session.execute(sa_select(ApiKey))
result = await integration_session.execute(sa_select(TemporaryCredit))
return len(result.scalars().all())
async def write_operation(key_id: int) -> None:
from sqlalchemy import select as sa_select
stmt = sa_select(ApiKey).where(
ApiKey.hashed_key == f"concurrent_key_{key_id}" # type: ignore[arg-type]
stmt = sa_select(TemporaryCredit).where(
TemporaryCredit.hashed_key == f"concurrent_key_{key_id}" # type: ignore[arg-type]
)
result = await integration_session.execute(stmt)
key = result.scalar_one_or_none()
@@ -708,14 +711,16 @@ class TestTaskInteractions:
# Patch the actual task functions
with (
patch(
"routstr.payment.models.update_sats_pricing",
"routstr.models.models.update_sats_pricing",
lambda: task_with_cleanup("pricing"),
),
patch(
"routstr.wallet.periodic_payout", lambda: task_with_cleanup("refund")
"routstr.payment.wallet.periodic_payout",
lambda: task_with_cleanup("refund"),
),
patch(
"routstr.wallet.periodic_payout", lambda: task_with_cleanup("payout")
"routstr.payment.wallet.periodic_payout",
lambda: task_with_cleanup("payout"),
),
):
# Start all tasks
+27 -38
View File
@@ -11,7 +11,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.core.db import TemporaryCredit
class TestTransactionAtomicity:
@@ -34,7 +34,7 @@ class TestTransactionAtomicity:
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
)
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
api_key = result.scalar_one()
initial_balance = api_key.balance
@@ -47,7 +47,9 @@ class TestTransactionAtomicity:
try:
# Get api key in new session
result = await test_session.execute(
select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
select(TemporaryCredit).where(
TemporaryCredit.hashed_key == api_key_hash
) # type: ignore[arg-type]
)
test_api_key = result.scalar_one()
@@ -72,14 +74,14 @@ class TestTransactionAtomicity:
try:
await integration_session.execute(
update(ApiKey)
.where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
.values(balance=ApiKey.balance - 1000)
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
.values(balance=TemporaryCredit.balance - 1000)
)
# Force a constraint violation or error
await integration_session.execute(
update(ApiKey)
.where(ApiKey.hashed_key == "non_existent_key") # type: ignore[arg-type]
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == "non_existent_key") # type: ignore[arg-type]
.values(balance=-1) # This should fail
)
await integration_session.commit()
@@ -108,13 +110,13 @@ class TestTransactionAtomicity:
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
)
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
api_key = result.scalar_one()
initial_balance = api_key.balance
# Mock wallet to fail after token validation
with patch("routstr.wallet.send_token") as mock_wallet_func:
with patch("routstr.payment.wallet.send_token") as mock_wallet_func:
mock_proof = MagicMock()
mock_proof.amount = 1000
mock_wallet = AsyncMock()
@@ -158,7 +160,7 @@ class TestTransactionAtomicity:
)
# Set a known balance
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
api_key = result.scalar_one()
api_key.balance = 10000
@@ -166,12 +168,12 @@ class TestTransactionAtomicity:
# Simulate concurrent balance updates through direct database operations
async def update_balance(session: AsyncSession, amount: int) -> bool:
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(
TemporaryCredit.hashed_key == api_key_hash
) # type: ignore[arg-type]
result = await session.execute(stmt)
key = result.scalar_one()
key.balance -= amount
key.total_spent += amount
key.total_requests += 1
try:
await session.commit()
return True
@@ -248,7 +250,7 @@ class TestConcurrentOperations:
)
# Set initial balance
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
api_key = result.scalar_one()
initial_balance = 5000
@@ -256,7 +258,7 @@ class TestConcurrentOperations:
await integration_session.commit()
# Mock wallet for topup
with patch("routstr.wallet.send_token") as mock_wallet_func:
with patch("routstr.payment.wallet.send_token") as mock_wallet_func:
mock_proof = MagicMock()
mock_proof.amount = 2000
mock_wallet = AsyncMock()
@@ -323,12 +325,10 @@ class TestConcurrentOperations:
)
# Set a specific balance
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
api_key = result.scalar_one()
api_key.balance = 1000
api_key.total_spent = 0
api_key.total_requests = 0
await integration_session.commit()
# Create a controlled race condition scenario
@@ -336,7 +336,9 @@ class TestConcurrentOperations:
async def check_and_update_balance() -> bool:
# Read current balance
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(
TemporaryCredit.hashed_key == api_key_hash
) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
current_api_key = result.scalar_one()
current_balance = current_api_key.balance
@@ -347,8 +349,6 @@ class TestConcurrentOperations:
# Try to update based on read value
current_api_key.balance = current_balance - 100
current_api_key.total_spent += 100
current_api_key.total_requests += 1
try:
await integration_session.commit()
@@ -371,8 +371,6 @@ class TestConcurrentOperations:
# Final balance should reflect successful updates
expected_balance = 1000 - (successful_updates * 100)
assert api_key.balance == expected_balance
assert api_key.total_spent == successful_updates * 100
assert api_key.total_requests == successful_updates
class TestDataIntegrity:
@@ -396,7 +394,7 @@ class TestDataIntegrity:
)
# Set low balance
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
api_key = result.scalar_one()
api_key.balance = 0
@@ -431,9 +429,7 @@ class TestDataIntegrity:
)
# Try to manually insert duplicate key with same hash
duplicate_key = ApiKey(
hashed_key=api_key_hash, balance=5000, total_spent=0, total_requests=0
)
duplicate_key = TemporaryCredit(hashed_key=api_key_hash, balance=5000)
integration_session.add(duplicate_key)
@@ -481,20 +477,13 @@ class TestDataIntegrity:
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
)
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
api_key = result.scalar_one()
# Test setting invalid values directly
# These should maintain integrity
assert api_key.balance >= 0
assert api_key.total_spent >= 0
assert api_key.total_requests >= 0
# Verify calculations are consistent
if api_key.total_requests > 0:
average_cost = api_key.total_spent / api_key.total_requests
assert average_cost >= 0
class TestPerformance:
@@ -522,7 +511,7 @@ class TestPerformance:
operation_times["select"].append((end - start) * 1000) # Convert to ms
# Test UPDATE performance (via topup)
with patch("routstr.wallet.send_token") as mock_wallet_func:
with patch("routstr.payment.wallet.send_token") as mock_wallet_func:
mock_proof = MagicMock()
mock_proof.amount = 100
mock_wallet = AsyncMock()
@@ -607,7 +596,7 @@ class TestPerformance:
# Primary key lookup should be fast
start = time.time()
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
api_key = result.scalar_one()
end = time.time()
@@ -11,7 +11,7 @@ from httpx import ASGITransport, AsyncClient, ConnectError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.core.db import TemporaryCredit
class TestNetworkFailureScenarios:
@@ -27,7 +27,7 @@ class TestNetworkFailureScenarios:
# Patch the wallet send function to simulate failure across all modules
with (
patch(
"routstr.wallet.send_token",
"routstr.payment.wallet.send_token",
AsyncMock(side_effect=ConnectError("Mint service unavailable")),
),
patch(
@@ -422,7 +422,7 @@ class TestRecoveryScenarios:
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
)
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
initial_key = result.scalar_one()
initial_balance = initial_key.balance
@@ -458,17 +458,15 @@ class TestRecoveryScenarios:
api_key_header[3:] if api_key_header.startswith("sk-") else api_key_header
)
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
api_key = result.scalar_one()
initial_balance = api_key.balance
initial_requests = api_key.total_requests
# Simulate operations that might be interrupted
try:
# Start a transaction
api_key.reserved_balance += 1000
api_key.total_requests += 1
# Don't commit - simulate crash
raise Exception("Simulated database crash")
except Exception:
@@ -478,7 +476,6 @@ class TestRecoveryScenarios:
# Verify state is consistent after "recovery"
await integration_session.refresh(api_key)
assert api_key.balance == initial_balance
assert api_key.total_requests == initial_requests
@pytest.mark.asyncio
async def test_state_consistency_after_failures(
@@ -527,10 +524,7 @@ class TestRecoveryScenarios:
for mod in diff["api_keys"]["modified"]:
# Only acceptable changes are request counts
for field, change in mod["changes"].items():
if field == "total_requests":
# Request count might increase
assert change["delta"] >= 0
elif field == "balance":
if field == "balance":
# Balance should not decrease from failed operations
assert change["delta"] >= 0
else:
@@ -641,12 +635,10 @@ class TestEdgeCaseCombinations:
api_key_hash = test_key[3:] # Remove sk- prefix
# Create the API key with only 500 msats (less than one request cost)
new_key = ApiKey(
new_key = TemporaryCredit(
hashed_key=api_key_hash,
balance=500, # Less than fixed cost per request (1000 msats)
reserved_balance=0,
total_spent=0,
total_requests=0,
)
integration_session.add(new_key)
await integration_session.commit()
@@ -687,7 +679,7 @@ class TestEdgeCaseCombinations:
assert insufficient_funds_count > 0
# Balance should never go negative
stmt = select(ApiKey).where(ApiKey.hashed_key == api_key_hash) # type: ignore[arg-type]
stmt = select(TemporaryCredit).where(TemporaryCredit.hashed_key == api_key_hash) # type: ignore[arg-type]
result = await integration_session.execute(stmt)
final_key = result.scalar_one()
assert final_key.balance >= 0
@@ -379,4 +379,4 @@ async def test_info_endpoints_response_consistency(
# Model IDs should be the same
first_ids = {m["id"] for m in first_models}
response_ids = {m["id"] for m in models}
assert first_ids == response_ids
assert first_ids == response_ids
+3 -5
View File
@@ -149,15 +149,13 @@ class TestPerformanceBaseline:
"""Test database operation performance"""
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.core.db import TemporaryCredit
# Create test data
for i in range(100):
key = ApiKey(
key = TemporaryCredit(
hashed_key=f"test_key_{i}",
balance=1000000,
total_spent=0,
total_requests=0,
)
integration_session.add(key)
await integration_session.commit()
@@ -168,7 +166,7 @@ class TestPerformanceBaseline:
for _ in range(100):
start = time.time()
result = await integration_session.execute(
select(ApiKey).where(ApiKey.balance > 0) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.balance > 0) # type: ignore[arg-type]
)
_ = result.all()
duration = (time.time() - start) * 1000
+25 -25
View File
@@ -9,7 +9,7 @@ from unittest.mock import patch
import pytest
from httpx import AsyncClient
from routstr.discovery import _PROVIDERS_CACHE
from routstr.nostr.discovery import _PROVIDERS_CACHE
from .utils import PerformanceValidator, ResponseValidator
@@ -62,9 +62,9 @@ async def test_providers_endpoint_default_response(
}
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
# Configure mock to return appropriate responses
mock_fetch.side_effect = lambda url: mock_fetch_responses.get(
url, {"status_code": 500, "json": {"error": "Unknown provider"}}
@@ -126,9 +126,9 @@ async def test_providers_endpoint_with_include_json(
}
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {
"status_code": 200,
"json": mock_provider_response,
@@ -200,9 +200,9 @@ async def test_providers_data_structure_validation(
}
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = mock_health_response
response = await integration_client.get("/v1/providers/?include_json=true")
@@ -247,7 +247,7 @@ async def test_providers_endpoint_no_providers_found(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
):
response = await integration_client.get("/v1/providers/")
@@ -308,10 +308,10 @@ async def test_providers_endpoint_offline_providers(
}
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
):
with patch(
"routstr.discovery.fetch_provider_health",
"routstr.nostr.discovery.fetch_provider_health",
side_effect=mock_fetch_provider_health,
):
response = await integration_client.get("/v1/providers/?include_json=true")
@@ -377,9 +377,9 @@ async def test_providers_endpoint_duplicate_urls(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {
"status_code": 200,
"endpoint": "root",
@@ -416,7 +416,7 @@ async def test_providers_endpoint_nostr_relay_failures(
raise Exception("Connection to relay failed")
with patch(
"routstr.discovery.query_nostr_relay_for_providers", side_effect=failing_query
"routstr.nostr.discovery.query_nostr_relay_for_providers", side_effect=failing_query
):
response = await integration_client.get("/v1/providers/")
@@ -454,9 +454,9 @@ async def test_providers_endpoint_malformed_urls(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
response = await integration_client.get("/v1/providers/")
@@ -486,9 +486,9 @@ async def test_providers_endpoint_response_format(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
# Test default format
@@ -536,9 +536,9 @@ async def test_providers_endpoint_performance(integration_client: AsyncClient) -
validator = PerformanceValidator()
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
# Test multiple requests
@@ -576,9 +576,9 @@ async def test_providers_endpoint_concurrent_requests(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
# Create concurrent requests
@@ -618,9 +618,9 @@ async def test_providers_endpoint_parameter_validation(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
# Test various parameter values
@@ -670,9 +670,9 @@ async def test_no_database_changes_during_provider_operations(
]
with patch(
"routstr.discovery.query_nostr_relay_for_providers", return_value=mock_events
"routstr.nostr.discovery.query_nostr_relay_for_providers", return_value=mock_events
):
with patch("routstr.discovery.fetch_provider_health") as mock_fetch:
with patch("routstr.nostr.discovery.fetch_provider_health") as mock_fetch:
mock_fetch.return_value = {"status_code": 200, "json": {"status": "online"}}
# Make multiple requests with different parameters
@@ -14,7 +14,7 @@ import pytest
from httpx import AsyncClient
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.core.db import TemporaryCredit
from .utils import (
ConcurrencyTester,
@@ -297,8 +297,8 @@ async def test_proxy_get_insufficient_balance(
from sqlmodel import update
await integration_session.execute(
update(ApiKey)
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
.values(balance=100) # Only 0.1 sats
)
await integration_session.commit()
@@ -382,7 +382,7 @@ async def test_proxy_get_database_state_verification(
# Get initial key state
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
initial_key = result.scalar_one()
initial_balance = initial_key.balance
@@ -7,7 +7,7 @@ import pytest
from httpx import AsyncClient
from sqlmodel.ext.asyncio.session import AsyncSession
from routstr.core.db import ApiKey, create_session
from routstr.core.db import TemporaryCredit, create_session
@pytest.mark.asyncio
@@ -16,7 +16,7 @@ async def test_reserved_balance_never_negative(integration_client: AsyncClient)
# Create a test API key with limited balance
async with create_session() as session:
test_key = ApiKey(
test_key = TemporaryCredit(
hashed_key="test_reserved_balance_key",
balance=1000, # 1 sat
reserved_balance=0,
@@ -40,7 +40,7 @@ async def test_reserved_balance_never_negative(integration_client: AsyncClient)
# Check reserved balance after failed request
async with create_session() as session:
key = await session.get(ApiKey, "test_reserved_balance_key")
key = await session.get(TemporaryCredit, "test_reserved_balance_key")
assert key is not None
assert key.reserved_balance >= 0, (
f"Reserved balance went negative: {key.reserved_balance}"
@@ -69,7 +69,7 @@ async def test_reserved_balance_never_negative(integration_client: AsyncClient)
# Check final state
async with create_session() as session:
key = await session.get(ApiKey, "test_reserved_balance_key")
key = await session.get(TemporaryCredit, "test_reserved_balance_key")
assert key is not None
assert key.reserved_balance >= 0, (
f"Reserved balance went negative after concurrent requests: {key.reserved_balance}"
@@ -86,7 +86,7 @@ async def test_reserved_balance_with_successful_requests(
# Create a test API key with more balance
async with create_session() as session:
unique_key = f"test_successful_key_{uuid.uuid4().hex[:8]}"
test_key = ApiKey(
test_key = TemporaryCredit(
hashed_key=unique_key,
balance=100000, # 100 sats
reserved_balance=0,
@@ -111,24 +111,23 @@ async def test_reserved_balance_with_successful_requests(
# Check that reserved balance was properly adjusted
async with create_session() as session:
key = await session.get(ApiKey, unique_key)
key = await session.get(TemporaryCredit, unique_key)
assert key is not None
assert key.reserved_balance >= 0, (
f"Reserved balance went negative: {key.reserved_balance}"
)
# Check if the request was processed (might fail due to model pricing in test env)
# The important part is that reserved_balance doesn't go negative
if key.total_spent > 0:
assert key.balance < 100000, (
"Balance should decrease after successful request"
)
if key.balance < 100000:
# Balance decreased
pass
else:
# Request failed, but reserved balance should still be non-negative
assert key.balance == 100000, (
"Balance should remain unchanged if request failed"
)
print(
f"After successful request - Balance: {key.balance}, Reserved: {key.reserved_balance}, Spent: {key.total_spent}"
f"After successful request - Balance: {key.balance}, Reserved: {key.reserved_balance}"
)
@@ -137,11 +136,11 @@ async def test_insufficient_reserved_balance_for_revert(
integration_session: AsyncSession,
) -> None:
"""Test revert_pay_for_request behavior with insufficient reserved balance."""
from routstr.auth import revert_pay_for_request
from routstr.payment.helpers import revert_pay_for_request
# Create key with zero reserved balance
unique_key = f"test_revert_key_{uuid.uuid4().hex[:8]}"
test_key = ApiKey(
test_key = TemporaryCredit(
hashed_key=unique_key,
balance=1000,
reserved_balance=0,
@@ -160,6 +159,3 @@ async def test_insufficient_reserved_balance_for_revert(
assert test_key.reserved_balance == -100, (
f"Expected reserved_balance to be -100, got: {test_key.reserved_balance}"
)
assert test_key.total_requests == -1, (
f"Expected total_requests to be -1, got: {test_key.total_requests}"
)
+12 -16
View File
@@ -11,7 +11,7 @@ import pytest
from httpx import AsyncClient
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.core.db import TemporaryCredit
from .utils import (
CashuTokenGenerator,
@@ -55,13 +55,11 @@ async def test_api_key_generation_valid_token(
# Verify database state directly
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]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
db_key = result.scalar_one()
assert db_key.balance == amount * 1000
assert db_key.total_spent == 0
assert db_key.total_requests == 0
# Verify the API key can be used for authentication
integration_client.headers["Authorization"] = f"Bearer {api_key}"
@@ -93,15 +91,15 @@ async def test_api_key_generation_invalid_token(
integration_client.headers["Authorization"] = f"Bearer {invalid_token}"
response = await integration_client.get("/v1/wallet/info")
# Should fail with 401
assert response.status_code == 401, (
f"Token {invalid_token[:20]}... should be invalid"
# Should fail with 401 or 400
assert response.status_code in [400, 401], (
f"Token {invalid_token[:20]}... should be invalid (400 or 401)"
)
# Validate error response
validator = ResponseValidator()
error_validation = validator.validate_error_response(
response, expected_status=401, expected_error_key="detail"
response, expected_status=response.status_code, expected_error_key="detail"
)
assert error_validation["valid"]
@@ -169,7 +167,7 @@ async def test_authorization_header_validation(
valid_api_key = response.json()["api_key"]
# Test scenarios
test_cases = [
test_cases: list[tuple[dict[str, str], int, str]] = [
# (headers, expected_status, description)
(
{},
@@ -193,7 +191,7 @@ async def test_authorization_header_validation(
integration_client.headers.pop("authorization", None)
# Set test headers
integration_client.headers.update(headers)
integration_client.headers.update(headers) # type: ignore[arg-type]
# Make request to protected endpoint
response = await integration_client.get("/v1/wallet/")
@@ -256,16 +254,14 @@ async def test_database_state_api_key_creation(
# Verify database record
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]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
db_key = result.scalar_one()
# Validate stored data
assert db_key.balance == amount * 1000 # msats
assert db_key.total_spent == 0
assert db_key.total_requests == 0
assert db_key.refund_address is None
assert db_key.key_expiry_time is None
assert db_key.refund_expiration_time is None
# Creation timestamp should be recent (within last minute)
# Note: The model doesn't have a creation timestamp field,
@@ -446,7 +442,7 @@ async def test_concurrent_token_submissions(
for api_key in api_keys:
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]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
db_key = result.scalar_one()
assert db_key.balance == expected_balances[hashed_key]
@@ -568,7 +564,7 @@ async def test_database_timestamp_accuracy(
# Verify key exists in database
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]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
db_key = result.scalar_one()
+15 -13
View File
@@ -11,7 +11,7 @@ import pytest
from httpx import AsyncClient
from sqlmodel import select, update
from routstr.core.db import ApiKey
from routstr.core.db import TemporaryCredit
from .utils import ConcurrencyTester, ResponseValidator
@@ -49,7 +49,7 @@ async def test_wallet_endpoint_with_valid_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]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
db_key = result.scalar_one()
@@ -138,7 +138,9 @@ async def test_wallet_with_zero_balance(
# Manually set balance to zero in database
hashed_key = api_key[3:] # Remove "sk-" prefix
await integration_session.execute(
update(ApiKey).where(ApiKey.hashed_key == hashed_key).values(balance=0) # type: ignore[arg-type]
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == hashed_key)
.values(balance=0) # type: ignore[arg-type]
)
await integration_session.commit()
@@ -181,9 +183,11 @@ async def test_expired_api_key_behavior(
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="test@lightning.address")
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
.values(
refund_expiration_time=past_expiry, refund_address="test@lightning.address"
)
)
await integration_session.commit()
@@ -197,10 +201,10 @@ async def test_expired_api_key_behavior(
# Verify expiry time was stored
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]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
db_key = result.scalar_one()
assert db_key.key_expiry_time == past_expiry
assert db_key.refund_expiration_time == past_expiry
assert db_key.refund_address == "test@lightning.address"
@@ -270,7 +274,7 @@ async def test_wallet_info_data_consistency(
# Verify against database
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]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
db_key = result.scalar_one()
@@ -360,12 +364,10 @@ async def test_wallet_after_partial_spending(
hashed_key = api_key[3:] # Remove "sk-" prefix
await integration_session.execute(
update(ApiKey)
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
.values(
balance=initial_balance - spent_amount,
total_spent=spent_amount,
total_requests=5, # Simulate 5 requests
)
)
await integration_session.commit()
+16 -14
View File
@@ -13,7 +13,7 @@ import pytest
from httpx import AsyncClient
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.core.db import TemporaryCredit
@pytest.mark.integration
@@ -123,7 +123,9 @@ async def test_zero_balance_refund_handling(
from sqlmodel import update
await integration_session.execute(
update(ApiKey).where(ApiKey.hashed_key == hashed_key).values(balance=0) # type: ignore[arg-type]
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == hashed_key)
.values(balance=0) # type: ignore[arg-type]
)
await integration_session.commit()
@@ -136,7 +138,7 @@ async def test_zero_balance_refund_handling(
# Key should still exist
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
assert result.scalar_one_or_none() is not None
@@ -159,7 +161,7 @@ async def test_refund_amount_validation(
# 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]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
key = result.scalar_one()
assert key.refund_address is None
@@ -192,8 +194,8 @@ async def test_refund_with_lightning_address(
from sqlmodel import update
await integration_session.execute(
update(ApiKey)
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
.values(refund_address=refund_address)
)
await integration_session.commit()
@@ -252,7 +254,7 @@ async def test_database_state_after_refund(
# Verify key exists before refund
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
key_before = result.scalar_one()
assert key_before.balance == 10_000_000
@@ -263,12 +265,12 @@ async def test_database_state_after_refund(
# Verify key is deleted after refund
result = await integration_session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
assert result.scalar_one_or_none() is None
# Count total keys to ensure only the specific one was deleted
result = await integration_session.execute(select(ApiKey))
result = await integration_session.execute(select(TemporaryCredit))
remaining_keys = result.scalars().all()
# Should have no keys left (assuming clean test environment)
assert len(remaining_keys) == 0
@@ -480,8 +482,8 @@ async def test_refund_error_handling(
from sqlmodel import update
await integration_session.execute(
update(ApiKey)
.where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
.values(balance=-1000) # Invalid negative balance
)
await integration_session.commit()
@@ -517,9 +519,9 @@ async def test_refund_with_expired_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")
update(TemporaryCredit)
.where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
.values(refund_expiration_time=past_expiry, refund_address="expired@ln.address")
)
await integration_session.commit()
+2 -2
View File
@@ -11,7 +11,7 @@ import pytest
from httpx import AsyncClient
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.core.db import TemporaryCredit
from .utils import (
CashuTokenGenerator,
@@ -64,7 +64,7 @@ async def test_topup_with_valid_token( # type: ignore[no-untyped-def]
# 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]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
db_key = result.scalar_one()
+4 -20
View File
@@ -9,7 +9,7 @@ import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from routstr.core.db import ApiKey
from routstr.core.db import TemporaryCredit
class CashuTokenGenerator:
@@ -97,11 +97,11 @@ class DatabaseStateValidator:
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get_api_key(self, api_key: str) -> Optional[ApiKey]:
async def get_api_key(self, api_key: str) -> Optional[TemporaryCredit]:
"""Get API key from database"""
hashed_key = hashlib.sha256(api_key.encode()).hexdigest()
result = await self.session.execute(
select(ApiKey).where(ApiKey.hashed_key == hashed_key) # type: ignore[arg-type]
select(TemporaryCredit).where(TemporaryCredit.hashed_key == hashed_key) # type: ignore[arg-type]
)
return result.scalar_one_or_none()
@@ -125,20 +125,6 @@ class DatabaseStateValidator:
"current_balance": key_obj.balance,
}
async def validate_request_count(
self, api_key: str, expected_count: int
) -> Dict[str, Any]:
"""Validate request count for an API key"""
key_obj = await self.get_api_key(api_key)
if not key_obj:
return {"valid": False, "error": "API key not found"}
return {
"valid": key_obj.total_requests == expected_count,
"expected": expected_count,
"actual": key_obj.total_requests,
}
async def validate_atomic_update(
self, api_key: str, field: str, expected_value: Any
) -> bool:
@@ -315,7 +301,7 @@ class ConcurrencyTester:
)
tasks = [make_request(req) for req in requests]
return await asyncio.gather(*tasks, return_exceptions=False)
return await asyncio.gather(*tasks, return_exceptions=False) # type: ignore
@staticmethod
async def test_race_condition(
@@ -463,8 +449,6 @@ class TestDataBuilder:
"""Create test API key data"""
data: Dict[str, Any] = {
"balance": balance,
"total_spent": 0,
"total_requests": 0,
}
if refund_address:
+2 -2
View File
@@ -44,9 +44,9 @@ def check_imports() -> bool:
print("Conftest fixtures imported successfully")
# Check routstr modules - imports are for verification only
from routstr.core.db import ApiKey
from routstr.core.db import TemporaryCredit
del ApiKey
del TemporaryCredit
print("Router modules imported successfully")
+1 -1
View File
@@ -12,7 +12,7 @@ from routstr.algorithm import ( # noqa: E402
get_provider_penalty,
should_prefer_model,
)
from routstr.payment.models import Architecture, Model, Pricing # noqa: E402
from routstr.models.models import Architecture, Model, Pricing # noqa: E402
def create_test_model(
+2 -2
View File
@@ -21,11 +21,11 @@ import pytest
os.environ["UPSTREAM_BASE_URL"] = "http://test"
os.environ["UPSTREAM_API_KEY"] = "test"
from routstr.payment.models import ( # noqa: E402
from routstr.models.crud import _model_to_row_payload
from routstr.models.models import ( # noqa: E402
Architecture,
Model,
Pricing,
_model_to_row_payload,
)
+8 -8
View File
@@ -4,10 +4,10 @@ from io import BytesIO
import pytest
from PIL import Image
from routstr.payment.helpers import (
from routstr.payment.cost import (
_calculate_image_tokens,
_estimate_image_tokens_in_messages,
_get_image_dimensions,
estimate_image_tokens_in_messages,
)
@@ -81,7 +81,7 @@ async def test_estimate_image_tokens_base64() -> None:
}
]
tokens = await estimate_image_tokens_in_messages(messages)
tokens = await _estimate_image_tokens_in_messages(messages)
assert tokens > 0
@@ -108,7 +108,7 @@ async def test_estimate_image_tokens_multiple_images() -> None:
}
]
tokens = await estimate_image_tokens_in_messages(messages)
tokens = await _estimate_image_tokens_in_messages(messages)
assert tokens > 0
@@ -148,8 +148,8 @@ async def test_estimate_image_tokens_with_detail() -> None:
}
]
tokens_low = await estimate_image_tokens_in_messages(messages_low)
tokens_high = await estimate_image_tokens_in_messages(messages_high)
tokens_low = await _estimate_image_tokens_in_messages(messages_low)
tokens_high = await _estimate_image_tokens_in_messages(messages_high)
assert tokens_low == 85
assert tokens_high > tokens_low
@@ -163,7 +163,7 @@ async def test_estimate_image_tokens_no_images() -> None:
{"role": "assistant", "content": "I'm doing well, thank you!"},
]
tokens = await estimate_image_tokens_in_messages(messages)
tokens = await _estimate_image_tokens_in_messages(messages)
assert tokens == 0
@@ -185,5 +185,5 @@ async def test_estimate_image_tokens_input_image_type() -> None:
}
]
tokens = await estimate_image_tokens_in_messages(messages)
tokens = await _estimate_image_tokens_in_messages(messages)
assert tokens > 0
+14 -32
View File
@@ -7,11 +7,11 @@ os.environ["UPSTREAM_BASE_URL"] = "http://test"
os.environ["UPSTREAM_API_KEY"] = "test"
from routstr.core.settings import settings # noqa: E402
from routstr.payment.helpers import get_max_cost_for_model # noqa: E402
from routstr.payment.cost import get_max_cost_for_model # noqa: E402
async def test_get_max_cost_for_model_known() -> None:
from routstr.payment.models import Pricing
from routstr.models.models import Pricing
# Mock DB session behavior
mock_session = AsyncMock()
@@ -63,48 +63,32 @@ async def test_get_max_cost_for_model_known() -> None:
with patch.object(settings, "fixed_pricing", False):
with patch.object(settings, "tolerance_percentage", 0):
cost = await get_max_cost_for_model(
"gpt-4", session=mock_session, model_obj=mock_model
)
cost = get_max_cost_for_model(model_obj=mock_model)
assert cost == 500000 # 500 sats * 1000 = msats
async def test_get_max_cost_for_model_unknown() -> None:
mock_session = AsyncMock()
mock_model = Mock()
mock_model.sats_pricing = None
mock_model.id = "unknown-model"
# Mock the exec results to return no model override
async def async_mock_exec(query: Any) -> Any:
result = Mock()
result.first = Mock(return_value=None)
result.all = Mock(return_value=[])
return result
mock_session.exec = AsyncMock(side_effect=async_mock_exec)
mock_session.get = AsyncMock(return_value=None)
# Mock get_upstreams to return empty list
with patch("routstr.proxy.get_upstreams", return_value=[]):
with patch.object(settings, "fixed_cost_per_request", 100):
with patch.object(settings, "tolerance_percentage", 0):
cost = await get_max_cost_for_model(
"unknown-model", session=mock_session, model_obj=None
)
assert cost == 100000
with patch.object(settings, "fixed_cost_per_request", 100):
with patch.object(settings, "tolerance_percentage", 0):
cost = get_max_cost_for_model(model_obj=mock_model)
assert cost == 100000
async def test_get_max_cost_for_model_disabled() -> None:
mock_session = AsyncMock()
mock_model = Mock()
with patch.object(settings, "fixed_pricing", True):
with patch.object(settings, "fixed_cost_per_request", 200):
with patch.object(settings, "tolerance_percentage", 0):
cost = await get_max_cost_for_model("any-model", session=mock_session)
cost = get_max_cost_for_model(model_obj=mock_model)
assert cost == 200000
async def test_get_max_cost_for_model_tolerance() -> None:
from routstr.payment.models import Pricing
mock_session = AsyncMock()
from routstr.models.models import Pricing
# Mock the model with sats_pricing
mock_pricing = Pricing(
@@ -121,7 +105,5 @@ async def test_get_max_cost_for_model_tolerance() -> None:
with patch.object(settings, "fixed_pricing", False):
with patch.object(settings, "tolerance_percentage", 10):
cost = await get_max_cost_for_model(
"gpt-4", session=mock_session, model_obj=mock_model
)
cost = get_max_cost_for_model(model_obj=mock_model)
assert cost == 450000 # 500 sats * 1000 * 0.9 = 450000
+23 -12
View File
@@ -4,8 +4,13 @@ from unittest.mock import AsyncMock, Mock, patch
import pytest
from routstr.core.db import ApiKey
from routstr.wallet import credit_balance, get_balance, recieve_token, send_token
from routstr.core.db import TemporaryCredit
from routstr.payment.wallet import (
credit_balance,
get_balance,
recieve_token,
send_token,
)
@pytest.mark.asyncio
@@ -15,7 +20,7 @@ async def test_get_balance() -> None:
mock_wallet.load_mint = AsyncMock()
mock_wallet.load_proofs = AsyncMock()
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
with patch("routstr.payment.wallet.Wallet.with_db", return_value=mock_wallet):
balance = await get_balance("sat")
assert balance == 50000
@@ -43,7 +48,9 @@ async def test_recieve_token_valid() -> None:
from routstr.core.settings import settings
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize:
with patch(
"routstr.payment.wallet.deserialize_token_from_string"
) as mock_deserialize:
mock_token = Mock()
mock_token.keysets = ["keyset1"]
mock_token.mint = "http://mint:3338"
@@ -54,7 +61,9 @@ async def test_recieve_token_valid() -> None:
mock_wallet.load_mint = AsyncMock()
mock_wallet.load_proofs = AsyncMock()
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
with patch(
"routstr.payment.wallet.Wallet.with_db", return_value=mock_wallet
):
amount, unit, mint = await recieve_token(token_str)
assert amount == 1000
assert unit == "sat"
@@ -65,8 +74,8 @@ async def test_recieve_token_valid() -> None:
async def test_send_token() -> None:
mock_wallet = Mock()
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
with patch("routstr.wallet.send", return_value=(1000, "test_token")):
with patch("routstr.payment.wallet.Wallet.with_db", return_value=mock_wallet):
with patch("routstr.payment.wallet.send", return_value=(1000, "test_token")):
token = await send_token(1000, "sat", "http://mint:3338")
assert token == "test_token"
@@ -87,7 +96,7 @@ async def test_credit_balance() -> None:
mock_session = AsyncMock()
# Mock session.refresh to update the balance (simulates DB reload)
async def mock_refresh(key: ApiKey) -> None:
async def mock_refresh(key: TemporaryCredit) -> None:
key.balance = 6000000
mock_session.refresh.side_effect = mock_refresh
@@ -96,7 +105,7 @@ async def test_credit_balance() -> None:
with patch.object(settings, "cashu_mints", ["http://mint:3338"]):
with patch(
"routstr.wallet.recieve_token",
"routstr.payment.wallet.recieve_token",
return_value=(1000, "sat", "http://mint:3338"),
):
amount = await credit_balance(token_str, mock_key, mock_session)
@@ -112,7 +121,9 @@ async def test_credit_balance() -> None:
async def test_recieve_token_untrusted_mint() -> None:
mock_wallet = Mock()
with patch("routstr.wallet.deserialize_token_from_string") as mock_deserialize:
with patch(
"routstr.payment.wallet.deserialize_token_from_string"
) as mock_deserialize:
mock_token = Mock()
mock_token.keysets = ["keyset1"]
mock_token.mint = "http://untrusted:3338"
@@ -122,9 +133,9 @@ async def test_recieve_token_untrusted_mint() -> None:
mock_wallet.load_mint = AsyncMock()
mock_wallet.load_proofs = AsyncMock()
with patch("routstr.wallet.Wallet.with_db", return_value=mock_wallet):
with patch("routstr.payment.wallet.Wallet.with_db", return_value=mock_wallet):
with patch(
"routstr.wallet.swap_to_primary_mint",
"routstr.payment.wallet.swap_to_primary_mint",
return_value=(900, "sat", "http://mint:3338"),
):
amount, unit, mint = await recieve_token("test_token")