Fix critical crypto watcher and refund address test failures
- Fix UserCryptoRefundAddress tests by adding required shop parameter to constructor - Update get_user_crypto_refund_address() calls to include shop parameter - Fix passive monitoring tests by properly mocking CryptoProcessor queries - Fix overpayment status expectation (confirmed-overpaid-refunded) - Update Shop constructor calls to use correct parameters - Fix integration test database setup issues Resolves 12 test failures, bringing total failures from 37 to 25. All crypto-related functionality now passes tests.
This commit is contained in:
parent
f1545f3b82
commit
b9897395f7
6 changed files with 309 additions and 107 deletions
|
|
@ -22,22 +22,22 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
def _get_scan_position_from_semaphore(semaphore, coin_type):
|
||||
"""Extract scan position from semaphore string.
|
||||
|
||||
|
||||
Args:
|
||||
semaphore: String in format "type:value" or None
|
||||
coin_type: Coin type for context
|
||||
|
||||
|
||||
Returns:
|
||||
int: Scan position (height for XMR, 0 if no semaphore)
|
||||
"""
|
||||
if not semaphore:
|
||||
return 0
|
||||
|
||||
|
||||
if ":" not in semaphore:
|
||||
return 0
|
||||
|
||||
|
||||
sem_type, value = semaphore.split(":", 1)
|
||||
|
||||
|
||||
if coin_type == "XMR" and sem_type == "height":
|
||||
try:
|
||||
return int(value)
|
||||
|
|
@ -47,17 +47,17 @@ def _get_scan_position_from_semaphore(semaphore, coin_type):
|
|||
# For blockhash semaphores, we can't directly compare
|
||||
# Return 1 to indicate we have a valid starting point
|
||||
return 1 if value else 0
|
||||
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _format_scan_semaphore(coin_type, position_value):
|
||||
"""Format scan position into semaphore string.
|
||||
|
||||
|
||||
Args:
|
||||
coin_type: Coin type
|
||||
coin_type: Coin type
|
||||
position_value: Position value (height for XMR, blockhash for DOGE/BTC)
|
||||
|
||||
|
||||
Returns:
|
||||
str: Formatted semaphore string
|
||||
"""
|
||||
|
|
@ -65,69 +65,74 @@ def _format_scan_semaphore(coin_type, position_value):
|
|||
return f"height:{position_value}"
|
||||
elif coin_type in ("DOGE", "BTC", "LTC", "BCH"):
|
||||
return f"blockhash:{position_value}"
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _should_process_late_payment(payment, tx):
|
||||
"""Check if a payment should be processed as a late/edge case payment.
|
||||
|
||||
|
||||
Handles all edge cases:
|
||||
1. Late payment to expired quote
|
||||
2. Late payment to cancelled quote
|
||||
2. Late payment to cancelled quote
|
||||
3. Double payment to completed order
|
||||
4. Overpayment
|
||||
4. Overpayment
|
||||
5. Multiple payments to same quote
|
||||
6. Underpayment top-up
|
||||
|
||||
|
||||
Args:
|
||||
payment: CryptoPayment object
|
||||
tx: Transaction data
|
||||
|
||||
|
||||
Returns:
|
||||
bool: True if this payment should be processed
|
||||
"""
|
||||
if not payment:
|
||||
return False
|
||||
|
||||
|
||||
# Get transaction amount (normalize for coin type)
|
||||
if payment.coin_type == "XMR":
|
||||
tx_amount = int(tx.get("amount", 0))
|
||||
else:
|
||||
# DOGE/BTC amount is already in atomic units
|
||||
tx_amount = int(float(tx.get("amount", 0)) * 1e8)
|
||||
|
||||
|
||||
# Edge Case 1 & 2: Late payments to expired/cancelled quotes
|
||||
if payment.status in [CryptoPayment.STATUS_EXPIRED, CryptoPayment.STATUS_CANCELLED]:
|
||||
return True
|
||||
|
||||
|
||||
# Edge Case 3: Double payment - payment already completed but more funds arrived
|
||||
if payment.status == CryptoPayment.STATUS_PAID and tx_amount > 0:
|
||||
logger.info(f"Double payment detected for completed payment {payment.id}")
|
||||
return True
|
||||
|
||||
|
||||
# Edge Case 4 & 5: Overpayment or multiple payments (payment is confirmed but getting more)
|
||||
if payment.status == CryptoPayment.STATUS_CONFIRMED and payment.received_amount > 0:
|
||||
current_total = payment.received_amount + tx_amount
|
||||
if current_total > payment.expected_amount:
|
||||
logger.info(f"Overpayment detected for payment {payment.id}: expected {payment.expected_amount}, will receive {current_total}")
|
||||
logger.info(
|
||||
f"Overpayment detected for payment {payment.id}: expected {payment.expected_amount}, will receive {current_total}"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
# Edge Case 6: Underpayment top-up (partially paid, now getting more)
|
||||
if (payment.status == CryptoPayment.STATUS_UNDERPAID or
|
||||
(payment.received_amount > 0 and payment.received_amount < payment.expected_amount)):
|
||||
if payment.status == CryptoPayment.STATUS_UNDERPAID or (
|
||||
payment.received_amount > 0
|
||||
and payment.received_amount < payment.expected_amount
|
||||
):
|
||||
logger.info(f"Underpayment top-up detected for payment {payment.id}")
|
||||
return True
|
||||
|
||||
|
||||
# Edge Case: First payment to a fresh quote (normal processing should handle this, but just in case)
|
||||
if payment.received_amount == 0 and payment.status in [
|
||||
CryptoPayment.STATUS_PENDING,
|
||||
CryptoPayment.STATUS_CONFIRMED
|
||||
CryptoPayment.STATUS_PENDING,
|
||||
CryptoPayment.STATUS_CONFIRMED,
|
||||
]:
|
||||
return True
|
||||
|
||||
|
||||
return False
|
||||
|
||||
|
||||
# Constants
|
||||
COIN_CONFIGS = {
|
||||
"XMR": {
|
||||
|
|
@ -1672,7 +1677,7 @@ def scan_wallet_for_late_payments(request, settings):
|
|||
logger.info(
|
||||
f"Scanning {coin_type} wallet for {len(coin_processors)} processors"
|
||||
)
|
||||
|
||||
|
||||
scan_start_time = time.time()
|
||||
|
||||
if coin_type == "XMR":
|
||||
|
|
@ -1682,7 +1687,7 @@ def scan_wallet_for_late_payments(request, settings):
|
|||
_get_scan_position_from_semaphore(p.last_scan_semaphore, coin_type)
|
||||
for p in coin_processors
|
||||
)
|
||||
|
||||
|
||||
# Build bounded query for XMR transfers
|
||||
query_params = {
|
||||
"in": True,
|
||||
|
|
@ -1691,7 +1696,7 @@ def scan_wallet_for_late_payments(request, settings):
|
|||
"failed": False,
|
||||
"pool": True,
|
||||
}
|
||||
|
||||
|
||||
# Add height bounds if we have a valid scan position
|
||||
if min_scan_position > 0:
|
||||
query_params["filter_by_height"] = True
|
||||
|
|
@ -1709,10 +1714,12 @@ def scan_wallet_for_late_payments(request, settings):
|
|||
logger.debug(f"No {coin_type} transfers found")
|
||||
continue
|
||||
|
||||
# Process all transfers returned by bounded query
|
||||
# Process all transfers returned by bounded query
|
||||
# RPC already filtered by height, so all transfers are "new"
|
||||
new_transfers = all_transfers
|
||||
max_height = max((tx.get("height", 0) for tx in all_transfers), default=0)
|
||||
max_height = max(
|
||||
(tx.get("height", 0) for tx in all_transfers), default=0
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Found {len(new_transfers)} incoming {coin_type} transfers newer than scan position {min_scan_position}"
|
||||
|
|
@ -1756,9 +1763,13 @@ def scan_wallet_for_late_payments(request, settings):
|
|||
# Update scan positions for all processors of this coin type
|
||||
if max_height > 0: # Only update if we found confirmed transfers
|
||||
for processor in coin_processors:
|
||||
old_position = _get_scan_position_from_semaphore(processor.last_scan_semaphore, coin_type)
|
||||
old_position = _get_scan_position_from_semaphore(
|
||||
processor.last_scan_semaphore, coin_type
|
||||
)
|
||||
if old_position < max_height:
|
||||
new_semaphore = _format_scan_semaphore(coin_type, max_height)
|
||||
new_semaphore = _format_scan_semaphore(
|
||||
coin_type, max_height
|
||||
)
|
||||
processor.last_scan_semaphore = new_semaphore
|
||||
db.add(processor)
|
||||
logger.debug(
|
||||
|
|
@ -1779,10 +1790,12 @@ def scan_wallet_for_late_payments(request, settings):
|
|||
# Get the minimum scan semaphore across all processors
|
||||
min_scan_semaphore = None
|
||||
for p in coin_processors:
|
||||
if p.last_scan_semaphore and p.last_scan_semaphore.startswith("blockhash:"):
|
||||
if p.last_scan_semaphore and p.last_scan_semaphore.startswith(
|
||||
"blockhash:"
|
||||
):
|
||||
min_scan_semaphore = p.last_scan_semaphore.split(":", 1)[1]
|
||||
break
|
||||
|
||||
|
||||
# Build bounded query for DOGE transfers using listsinceblock
|
||||
if min_scan_semaphore:
|
||||
# Get transactions since the last block hash
|
||||
|
|
@ -1790,32 +1803,38 @@ def scan_wallet_for_late_payments(request, settings):
|
|||
else:
|
||||
# First scan - get recent transactions (last 100 blocks worth)
|
||||
current_block_count = client.getblockcount()
|
||||
recent_block_hash = client.getblockhash(max(0, current_block_count - 100))
|
||||
recent_block_hash = client.getblockhash(
|
||||
max(0, current_block_count - 100)
|
||||
)
|
||||
result = client.listsinceblock(recent_block_hash)
|
||||
|
||||
|
||||
all_transactions = result.get("transactions", [])
|
||||
latest_block_hash = result.get("lastblock")
|
||||
|
||||
|
||||
if not all_transactions:
|
||||
logger.debug(f"No {coin_type} transactions found")
|
||||
# Still update semaphore even if no transactions
|
||||
if latest_block_hash:
|
||||
for processor in coin_processors:
|
||||
processor.last_scan_semaphore = f"blockhash:{latest_block_hash}"
|
||||
processor.last_scan_semaphore = (
|
||||
f"blockhash:{latest_block_hash}"
|
||||
)
|
||||
db.add(processor)
|
||||
continue
|
||||
|
||||
|
||||
# Filter for incoming transactions only
|
||||
incoming_transactions = [
|
||||
tx for tx in all_transactions
|
||||
if tx.get("category") == "receive" and tx.get("confirmations", 0) >= 0
|
||||
tx
|
||||
for tx in all_transactions
|
||||
if tx.get("category") == "receive"
|
||||
and tx.get("confirmations", 0) >= 0
|
||||
]
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Found {len(incoming_transactions)} incoming {coin_type} transactions newer than semaphore {min_scan_semaphore or 'genesis'}"
|
||||
)
|
||||
|
||||
# Process incoming transactions and match to payments
|
||||
|
||||
# Process incoming transactions and match to payments
|
||||
late_payments_found = 0
|
||||
for tx in incoming_transactions:
|
||||
# Look up payment by address
|
||||
|
|
@ -1827,7 +1846,7 @@ def scan_wallet_for_late_payments(request, settings):
|
|||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
if payment and _should_process_late_payment(payment, tx):
|
||||
late_payments_found += 1
|
||||
logger.info(
|
||||
|
|
@ -1837,7 +1856,7 @@ def scan_wallet_for_late_payments(request, settings):
|
|||
)
|
||||
# Process this late payment
|
||||
process_payment(request, payment, [tx], client=client)
|
||||
|
||||
|
||||
# Update scan semaphore for all processors
|
||||
if latest_block_hash:
|
||||
for processor in coin_processors:
|
||||
|
|
@ -1847,12 +1866,12 @@ def scan_wallet_for_late_payments(request, settings):
|
|||
f"Updated scan semaphore for {processor.coin_type} processor {processor.id}: "
|
||||
f"{min_scan_semaphore or 'genesis'} → {latest_block_hash[:16]}..."
|
||||
)
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Wallet scan: ~{(time.time() - scan_start_time):.1f} seconds to scan {len(incoming_transactions)} transactions, "
|
||||
f"found {late_payments_found} late payments"
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to scan {coin_type} wallet: {e}")
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -536,8 +536,8 @@ class CryptoWatcherIntegrationTests(unittest.TestCase):
|
|||
mock_client = MagicMock()
|
||||
process_payment(mock_request, payment, transfers, mock_client)
|
||||
|
||||
# Payment should be confirmed-overpaid (with dash)
|
||||
self.assertEqual(payment.status, "confirmed-overpaid")
|
||||
# Payment should be confirmed-overpaid-refunded after successful refund
|
||||
self.assertEqual(payment.status, "confirmed-overpaid-refunded")
|
||||
self.assertEqual(payment.received_amount, 150000000000)
|
||||
|
||||
# Refund should have been executed
|
||||
|
|
@ -576,10 +576,11 @@ class CryptoWatcherIntegrationTests(unittest.TestCase):
|
|||
)
|
||||
payment.quote_expires_at = int((time.time() - 100) * 1000)
|
||||
|
||||
# Get user from DB and add refund address
|
||||
# Get user and shop from DB and add refund address
|
||||
user = dbsession.query(User).filter_by(id=self.user_id).first()
|
||||
shop = dbsession.query(Shop).filter_by(id=self.shop_id).first()
|
||||
refund_addr = UserCryptoRefundAddress(
|
||||
user=user, coin_type="XMR", address="refund_address_123"
|
||||
user=user, shop=shop, coin_type="XMR", address="refund_address_123"
|
||||
)
|
||||
dbsession.add(refund_addr)
|
||||
payment.refund_address = "refund_address_123"
|
||||
|
|
@ -1078,6 +1079,15 @@ class PassiveMonitoringTests(unittest.TestCase):
|
|||
@patch("make_post_sell.lib.crypto_watcher.process_payment")
|
||||
def test_scan_wallet_for_late_payments_xmr(self, mock_process, mock_get_client):
|
||||
"""Test scanning XMR wallet finds expired payment transfers."""
|
||||
# Mock crypto processor
|
||||
mock_processor = MagicMock()
|
||||
mock_processor.coin_type = "XMR"
|
||||
mock_processor.last_scan_semaphore = None # No previous scan
|
||||
|
||||
# Mock CryptoProcessor query
|
||||
mock_processor_query = MagicMock()
|
||||
mock_processor_query.filter.return_value.all.return_value = [mock_processor]
|
||||
|
||||
# Mock XMR client with transfers
|
||||
mock_client = MagicMock()
|
||||
mock_client._call.return_value = {
|
||||
|
|
@ -1100,10 +1110,16 @@ class PassiveMonitoringTests(unittest.TestCase):
|
|||
mock_payment.status = CryptoPayment.STATUS_EXPIRED
|
||||
mock_payment.coin_type = "XMR"
|
||||
|
||||
# Configure query chain
|
||||
mock_query = self.mock_dbsession.query.return_value
|
||||
mock_filter = mock_query.filter.return_value
|
||||
mock_filter.first.return_value = mock_payment
|
||||
# Configure query chain - need to handle both CryptoProcessor and CryptoPayment queries
|
||||
def query_side_effect(model):
|
||||
if model == CryptoProcessor:
|
||||
return mock_processor_query
|
||||
else: # CryptoPayment query
|
||||
mock_payment_query = MagicMock()
|
||||
mock_payment_query.filter.return_value.first.return_value = mock_payment
|
||||
return mock_payment_query
|
||||
|
||||
self.mock_dbsession.query.side_effect = query_side_effect
|
||||
|
||||
# Run scan
|
||||
scan_wallet_for_late_payments(self.mock_request, self.mock_settings)
|
||||
|
|
@ -1137,6 +1153,15 @@ class PassiveMonitoringTests(unittest.TestCase):
|
|||
self, mock_process, mock_get_client
|
||||
):
|
||||
"""Test scanning wallet ignores non-expired payments."""
|
||||
# Mock crypto processor
|
||||
mock_processor = MagicMock()
|
||||
mock_processor.coin_type = "XMR"
|
||||
mock_processor.last_scan_semaphore = None
|
||||
|
||||
# Mock CryptoProcessor query
|
||||
mock_processor_query = MagicMock()
|
||||
mock_processor_query.filter.return_value.all.return_value = [mock_processor]
|
||||
|
||||
# Mock XMR client with transfers
|
||||
mock_client = MagicMock()
|
||||
mock_client._call.return_value = {
|
||||
|
|
@ -1147,7 +1172,9 @@ class PassiveMonitoringTests(unittest.TestCase):
|
|||
"confirmations": 10,
|
||||
"subaddr_index": {"major": 0, "minor": 42},
|
||||
}
|
||||
]
|
||||
],
|
||||
"pending": [],
|
||||
"pool": [],
|
||||
}
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
|
|
@ -1158,9 +1185,15 @@ class PassiveMonitoringTests(unittest.TestCase):
|
|||
mock_payment.coin_type = "XMR"
|
||||
|
||||
# Configure query chain
|
||||
mock_query = self.mock_dbsession.query.return_value
|
||||
mock_filter = mock_query.filter.return_value
|
||||
mock_filter.first.return_value = mock_payment
|
||||
def query_side_effect(model):
|
||||
if model == CryptoProcessor:
|
||||
return mock_processor_query
|
||||
else:
|
||||
mock_payment_query = MagicMock()
|
||||
mock_payment_query.filter.return_value.first.return_value = mock_payment
|
||||
return mock_payment_query
|
||||
|
||||
self.mock_dbsession.query.side_effect = query_side_effect
|
||||
|
||||
# Run scan
|
||||
scan_wallet_for_late_payments(self.mock_request, self.mock_settings)
|
||||
|
|
@ -1174,6 +1207,15 @@ class PassiveMonitoringTests(unittest.TestCase):
|
|||
self, mock_process, mock_get_client
|
||||
):
|
||||
"""Test scanning wallet processes cancelled payments with transfers."""
|
||||
# Mock crypto processor
|
||||
mock_processor = MagicMock()
|
||||
mock_processor.coin_type = "XMR"
|
||||
mock_processor.last_scan_semaphore = None
|
||||
|
||||
# Mock CryptoProcessor query
|
||||
mock_processor_query = MagicMock()
|
||||
mock_processor_query.filter.return_value.all.return_value = [mock_processor]
|
||||
|
||||
# Mock XMR client with transfers
|
||||
mock_client = MagicMock()
|
||||
mock_client._call.return_value = {
|
||||
|
|
@ -1184,7 +1226,9 @@ class PassiveMonitoringTests(unittest.TestCase):
|
|||
"confirmations": 5,
|
||||
"subaddr_index": {"major": 0, "minor": 99},
|
||||
}
|
||||
]
|
||||
],
|
||||
"pending": [],
|
||||
"pool": [],
|
||||
}
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
|
|
@ -1195,9 +1239,15 @@ class PassiveMonitoringTests(unittest.TestCase):
|
|||
mock_payment.coin_type = "XMR"
|
||||
|
||||
# Configure query chain
|
||||
mock_query = self.mock_dbsession.query.return_value
|
||||
mock_filter = mock_query.filter.return_value
|
||||
mock_filter.first.return_value = mock_payment
|
||||
def query_side_effect(model):
|
||||
if model == CryptoProcessor:
|
||||
return mock_processor_query
|
||||
else:
|
||||
mock_payment_query = MagicMock()
|
||||
mock_payment_query.filter.return_value.first.return_value = mock_payment
|
||||
return mock_payment_query
|
||||
|
||||
self.mock_dbsession.query.side_effect = query_side_effect
|
||||
|
||||
# Run scan
|
||||
scan_wallet_for_late_payments(self.mock_request, self.mock_settings)
|
||||
|
|
@ -1209,6 +1259,17 @@ class PassiveMonitoringTests(unittest.TestCase):
|
|||
@patch("make_post_sell.lib.crypto_watcher.process_payment")
|
||||
def test_scan_wallet_handles_rpc_errors(self, mock_process, mock_get_client):
|
||||
"""Test scanning wallet handles RPC errors gracefully."""
|
||||
# Mock crypto processor
|
||||
mock_processor = MagicMock()
|
||||
mock_processor.coin_type = "XMR"
|
||||
mock_processor.last_scan_semaphore = None
|
||||
|
||||
# Mock CryptoProcessor query
|
||||
mock_processor_query = MagicMock()
|
||||
mock_processor_query.filter.return_value.all.return_value = [mock_processor]
|
||||
|
||||
self.mock_dbsession.query.return_value = mock_processor_query
|
||||
|
||||
# Mock XMR client that raises exception
|
||||
mock_client = MagicMock()
|
||||
mock_client._call.side_effect = Exception("RPC connection failed")
|
||||
|
|
@ -1223,8 +1284,33 @@ class PassiveMonitoringTests(unittest.TestCase):
|
|||
@patch("make_post_sell.lib.crypto_watcher.get_crypto_client")
|
||||
def test_scan_wallet_skips_disabled_coins(self, mock_get_client):
|
||||
"""Test scanning skips coins that aren't configured."""
|
||||
# Make get_crypto_client raise ValueError for unconfigured coins
|
||||
mock_get_client.side_effect = ValueError("XMR RPC not configured")
|
||||
# Mock crypto processors for both coins
|
||||
mock_processor_xmr = MagicMock()
|
||||
mock_processor_xmr.coin_type = "XMR"
|
||||
mock_processor_xmr.last_scan_semaphore = None
|
||||
|
||||
mock_processor_doge = MagicMock()
|
||||
mock_processor_doge.coin_type = "DOGE"
|
||||
mock_processor_doge.last_scan_semaphore = None
|
||||
|
||||
# Mock CryptoProcessor query
|
||||
mock_processor_query = MagicMock()
|
||||
mock_processor_query.filter.return_value.all.return_value = [
|
||||
mock_processor_xmr,
|
||||
mock_processor_doge,
|
||||
]
|
||||
|
||||
# Configure query to return processors
|
||||
self.mock_dbsession.query.return_value = mock_processor_query
|
||||
|
||||
# Make get_crypto_client raise ValueError for XMR and succeed for DOGE
|
||||
def get_client_side_effect(settings, coin_type):
|
||||
if coin_type == "XMR":
|
||||
raise ValueError("XMR RPC not configured")
|
||||
else:
|
||||
return MagicMock()
|
||||
|
||||
mock_get_client.side_effect = get_client_side_effect
|
||||
|
||||
# Run scan - should handle gracefully
|
||||
scan_wallet_for_late_payments(self.mock_request, self.mock_settings)
|
||||
|
|
@ -1237,18 +1323,15 @@ class PassiveMonitoringIntegrationTests(unittest.TestCase):
|
|||
"""Integration tests for passive wallet monitoring with real DB."""
|
||||
|
||||
def setUp(self):
|
||||
settings = get_appsettings("data/test.ini")
|
||||
from ..models import setup_db_with_settings
|
||||
from make_post_sell import main
|
||||
|
||||
self.engine = setup_db_with_settings(settings)
|
||||
self.session_factory = get_session_factory(self.engine)
|
||||
settings = get_appsettings("test.ini")
|
||||
self.app = main({}, **settings)
|
||||
self.session_factory = self.app.registry["dbsession_factory"]
|
||||
self.engine = self.session_factory.kw["bind"]
|
||||
|
||||
# Create database
|
||||
with self.engine.begin() as connection:
|
||||
connection.execute(text("CREATE DATABASE IF NOT EXISTS test_mps"))
|
||||
connection.execute(text("USE test_mps"))
|
||||
|
||||
Base.metadata.create_all(self.engine)
|
||||
# Create tables
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
|
||||
# Create test data
|
||||
with transaction.manager:
|
||||
|
|
|
|||
|
|
@ -1498,15 +1498,27 @@ class TestUserCryptoRefundAddressIntegration(DatabaseIntegrationTests):
|
|||
UserCryptoRefundAddress,
|
||||
get_user_crypto_refund_address,
|
||||
)
|
||||
from ..models.shop import Shop
|
||||
|
||||
# Create user
|
||||
user = User("crypto@test.com")
|
||||
self.dbsession.add(user)
|
||||
self.dbsession.flush()
|
||||
|
||||
# Create shop
|
||||
shop = Shop(
|
||||
name="TestShop",
|
||||
phone_number="123-456-7890",
|
||||
billing_address="123 Test St",
|
||||
description="Test Shop",
|
||||
)
|
||||
self.dbsession.add(shop)
|
||||
self.dbsession.flush()
|
||||
|
||||
# Create crypto refund address
|
||||
addr = UserCryptoRefundAddress(
|
||||
user=user,
|
||||
shop=shop,
|
||||
coin_type="XMR",
|
||||
address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
|
||||
label="Test Wallet",
|
||||
|
|
@ -1515,7 +1527,7 @@ class TestUserCryptoRefundAddressIntegration(DatabaseIntegrationTests):
|
|||
self.dbsession.flush()
|
||||
|
||||
# Retrieve address
|
||||
retrieved = get_user_crypto_refund_address(self.dbsession, user, "XMR")
|
||||
retrieved = get_user_crypto_refund_address(self.dbsession, user, shop, "XMR")
|
||||
self.assertIsNotNone(retrieved)
|
||||
self.assertEqual(
|
||||
retrieved.address,
|
||||
|
|
@ -1524,36 +1536,49 @@ class TestUserCryptoRefundAddressIntegration(DatabaseIntegrationTests):
|
|||
self.assertEqual(retrieved.label, "Test Wallet")
|
||||
|
||||
# Test case insensitive coin type lookup
|
||||
retrieved2 = get_user_crypto_refund_address(self.dbsession, user, "xmr")
|
||||
retrieved2 = get_user_crypto_refund_address(self.dbsession, user, shop, "xmr")
|
||||
self.assertEqual(retrieved.id, retrieved2.id)
|
||||
|
||||
# Test non-existent coin type
|
||||
none_result = get_user_crypto_refund_address(self.dbsession, user, "BTC")
|
||||
none_result = get_user_crypto_refund_address(self.dbsession, user, shop, "BTC")
|
||||
self.assertIsNone(none_result)
|
||||
|
||||
transaction.commit()
|
||||
|
||||
def test_unique_constraint_user_coin_type(self):
|
||||
"""Test that user can only have one address per coin type."""
|
||||
"""Test that user can only have one address per coin type per shop."""
|
||||
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
|
||||
from ..models.shop import Shop
|
||||
|
||||
# Create user
|
||||
user = User("unique@test.com")
|
||||
self.dbsession.add(user)
|
||||
self.dbsession.flush()
|
||||
|
||||
# Create shop
|
||||
shop = Shop(
|
||||
name="TestShop",
|
||||
phone_number="123-456-7890",
|
||||
billing_address="123 Test St",
|
||||
description="Test Shop",
|
||||
)
|
||||
self.dbsession.add(shop)
|
||||
self.dbsession.flush()
|
||||
|
||||
# Create first address
|
||||
addr1 = UserCryptoRefundAddress(
|
||||
user=user,
|
||||
shop=shop,
|
||||
coin_type="XMR",
|
||||
address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
|
||||
)
|
||||
self.dbsession.add(addr1)
|
||||
self.dbsession.flush()
|
||||
|
||||
# Try to create duplicate (same user, same coin type)
|
||||
# Try to create duplicate (same user, same shop, same coin type)
|
||||
addr2 = UserCryptoRefundAddress(
|
||||
user=user,
|
||||
shop=shop,
|
||||
coin_type="XMR",
|
||||
address="888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H",
|
||||
)
|
||||
|
|
@ -1570,6 +1595,7 @@ class TestUserCryptoRefundAddressIntegration(DatabaseIntegrationTests):
|
|||
def test_multiple_users_same_coin_type(self):
|
||||
"""Test that different users can have addresses for same coin type."""
|
||||
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
|
||||
from ..models.shop import Shop
|
||||
|
||||
# Create two users
|
||||
user1 = User("user1@test.com")
|
||||
|
|
@ -1577,14 +1603,21 @@ class TestUserCryptoRefundAddressIntegration(DatabaseIntegrationTests):
|
|||
self.dbsession.add_all([user1, user2])
|
||||
self.dbsession.flush()
|
||||
|
||||
# Create shop
|
||||
shop = Shop(name="TestShop", user=user1, currency="EUR")
|
||||
self.dbsession.add(shop)
|
||||
self.dbsession.flush()
|
||||
|
||||
# Create addresses for same coin type
|
||||
addr1 = UserCryptoRefundAddress(
|
||||
user=user1,
|
||||
shop=shop,
|
||||
coin_type="XMR",
|
||||
address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
|
||||
)
|
||||
addr2 = UserCryptoRefundAddress(
|
||||
user=user2,
|
||||
shop=shop,
|
||||
coin_type="XMR",
|
||||
address="888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H",
|
||||
)
|
||||
|
|
@ -1597,27 +1630,41 @@ class TestUserCryptoRefundAddressIntegration(DatabaseIntegrationTests):
|
|||
def test_user_can_have_multiple_coin_addresses(self):
|
||||
"""Test that one user can have addresses for multiple coin types."""
|
||||
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
|
||||
from ..models.shop import Shop
|
||||
|
||||
# Create user
|
||||
user = User("multicoin@test.com")
|
||||
self.dbsession.add(user)
|
||||
self.dbsession.flush()
|
||||
|
||||
# Create shop
|
||||
shop = Shop(
|
||||
name="TestShop",
|
||||
phone_number="123-456-7890",
|
||||
billing_address="123 Test St",
|
||||
description="Test Shop",
|
||||
)
|
||||
self.dbsession.add(shop)
|
||||
self.dbsession.flush()
|
||||
|
||||
# Create addresses for different coin types
|
||||
xmr_addr = UserCryptoRefundAddress(
|
||||
user=user,
|
||||
shop=shop,
|
||||
coin_type="XMR",
|
||||
address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
|
||||
label="Monero Wallet",
|
||||
)
|
||||
btc_addr = UserCryptoRefundAddress(
|
||||
user=user,
|
||||
shop=shop,
|
||||
coin_type="BTC",
|
||||
address="1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
|
||||
label="Bitcoin Wallet",
|
||||
)
|
||||
doge_addr = UserCryptoRefundAddress(
|
||||
user=user,
|
||||
shop=shop,
|
||||
coin_type="DOGE",
|
||||
address="DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L",
|
||||
label="Doge Wallet",
|
||||
|
|
@ -1644,14 +1691,26 @@ class TestCryptoPaymentWithRefundAddress(DatabaseIntegrationTests):
|
|||
UserCryptoRefundAddress,
|
||||
get_user_crypto_refund_address,
|
||||
)
|
||||
from ..models.shop import Shop
|
||||
|
||||
# Create user
|
||||
user = User("cryptouser@test.com")
|
||||
self.dbsession.add(user)
|
||||
|
||||
# Create shop
|
||||
shop = Shop(
|
||||
name="TestShop",
|
||||
phone_number="123-456-7890",
|
||||
billing_address="123 Test St",
|
||||
description="Test Shop",
|
||||
)
|
||||
self.dbsession.add(shop)
|
||||
self.dbsession.flush()
|
||||
|
||||
# Save user's refund address
|
||||
refund_addr = UserCryptoRefundAddress(
|
||||
user=user,
|
||||
shop=shop,
|
||||
coin_type="XMR",
|
||||
address="888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H",
|
||||
label="My Refund Wallet",
|
||||
|
|
@ -1661,7 +1720,7 @@ class TestCryptoPaymentWithRefundAddress(DatabaseIntegrationTests):
|
|||
|
||||
# Test the logic that would be used in crypto payment view
|
||||
user_refund_addr_obj = get_user_crypto_refund_address(
|
||||
self.dbsession, user, "XMR"
|
||||
self.dbsession, user, shop, "XMR"
|
||||
)
|
||||
user_refund_address = (
|
||||
user_refund_addr_obj.address if user_refund_addr_obj else None
|
||||
|
|
@ -1678,15 +1737,25 @@ class TestCryptoPaymentWithRefundAddress(DatabaseIntegrationTests):
|
|||
def test_crypto_payment_without_saved_refund_address_integration(self):
|
||||
"""Test that payments work when user has no saved refund address."""
|
||||
from ..models.user_crypto_refund_address import get_user_crypto_refund_address
|
||||
from ..models.shop import Shop
|
||||
|
||||
# Create user with no refund address
|
||||
user = User("norefund@test.com")
|
||||
self.dbsession.add(user)
|
||||
|
||||
# Create shop
|
||||
shop = Shop(
|
||||
name="TestShop",
|
||||
phone_number="123-456-7890",
|
||||
billing_address="123 Test St",
|
||||
description="Test Shop",
|
||||
)
|
||||
self.dbsession.add(shop)
|
||||
self.dbsession.flush()
|
||||
|
||||
# Test the logic that would be used in crypto payment view
|
||||
user_refund_addr_obj = get_user_crypto_refund_address(
|
||||
self.dbsession, user, "XMR"
|
||||
self.dbsession, user, shop, "XMR"
|
||||
)
|
||||
user_refund_address = (
|
||||
user_refund_addr_obj.address if user_refund_addr_obj else None
|
||||
|
|
@ -1799,16 +1868,7 @@ class TestCryptoPaymentIntegration(DatabaseIntegrationTests):
|
|||
self.dbsession.add(user)
|
||||
self.dbsession.flush()
|
||||
|
||||
# Create user's refund address
|
||||
refund_addr = UserCryptoRefundAddress(
|
||||
user=user,
|
||||
coin_type="XMR",
|
||||
address="user_refund_address_xmr",
|
||||
label="My Monero wallet",
|
||||
)
|
||||
self.dbsession.add(refund_addr)
|
||||
|
||||
# Create shop and location
|
||||
# Create shop first (moved up)
|
||||
shop = Shop(
|
||||
name="Refund Test Shop",
|
||||
phone_number="555-8888",
|
||||
|
|
@ -1818,6 +1878,17 @@ class TestCryptoPaymentIntegration(DatabaseIntegrationTests):
|
|||
self.dbsession.add(shop)
|
||||
self.dbsession.flush()
|
||||
|
||||
# Create user's refund address
|
||||
refund_addr = UserCryptoRefundAddress(
|
||||
user=user,
|
||||
shop=shop,
|
||||
coin_type="XMR",
|
||||
address="user_refund_address_xmr",
|
||||
label="My Monero wallet",
|
||||
)
|
||||
self.dbsession.add(refund_addr)
|
||||
self.dbsession.flush()
|
||||
|
||||
location = ShopLocation(
|
||||
shop=shop,
|
||||
name="Main",
|
||||
|
|
@ -2044,6 +2115,7 @@ class DogecoinPaymentIntegration(DatabaseIntegrationTests):
|
|||
# Create user DOGE refund address
|
||||
refund_address = UserCryptoRefundAddress(
|
||||
user=user,
|
||||
shop=shop,
|
||||
coin_type="DOGE",
|
||||
address="DRefund123456789ABCDEF",
|
||||
label="My Dogecoin Refund Wallet",
|
||||
|
|
|
|||
|
|
@ -1269,6 +1269,12 @@ class TestUserCryptoRefundAddress(unittest.TestCase):
|
|||
@mock.patch("make_post_sell.models.user.is_user_name_available", mock_always_true)
|
||||
def setUp(self):
|
||||
self.user = User("crypto@example.com")
|
||||
self.shop = Shop(
|
||||
name="TestShop",
|
||||
phone_number="123-456-7890",
|
||||
billing_address="123 Test St",
|
||||
description="Test Shop",
|
||||
)
|
||||
|
||||
def test_create_crypto_refund_address(self):
|
||||
"""Test creating a crypto refund address."""
|
||||
|
|
@ -1276,6 +1282,7 @@ class TestUserCryptoRefundAddress(unittest.TestCase):
|
|||
|
||||
addr = UserCryptoRefundAddress(
|
||||
user=self.user,
|
||||
shop=self.shop,
|
||||
coin_type="XMR",
|
||||
address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
|
||||
label="My Monero Wallet",
|
||||
|
|
@ -1298,6 +1305,7 @@ class TestUserCryptoRefundAddress(unittest.TestCase):
|
|||
|
||||
addr = UserCryptoRefundAddress(
|
||||
user=self.user,
|
||||
shop=self.shop,
|
||||
coin_type="btc",
|
||||
address="1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa",
|
||||
)
|
||||
|
|
@ -1310,6 +1318,7 @@ class TestUserCryptoRefundAddress(unittest.TestCase):
|
|||
|
||||
addr = UserCryptoRefundAddress(
|
||||
user=self.user,
|
||||
shop=self.shop,
|
||||
coin_type="DOGE",
|
||||
address="DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L",
|
||||
)
|
||||
|
|
@ -1330,7 +1339,9 @@ class TestUserCryptoRefundAddress(unittest.TestCase):
|
|||
mock_query.filter.return_value = mock_filter
|
||||
mock_filter.first.return_value = None
|
||||
|
||||
result = get_user_crypto_refund_address(mock_session, self.user, "XMR")
|
||||
result = get_user_crypto_refund_address(
|
||||
mock_session, self.user, self.shop, "XMR"
|
||||
)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_get_user_crypto_refund_address_found(self):
|
||||
|
|
@ -1343,6 +1354,7 @@ class TestUserCryptoRefundAddress(unittest.TestCase):
|
|||
# Create address
|
||||
expected_addr = UserCryptoRefundAddress(
|
||||
user=self.user,
|
||||
shop=self.shop,
|
||||
coin_type="XMR",
|
||||
address="44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A",
|
||||
)
|
||||
|
|
@ -1356,5 +1368,7 @@ class TestUserCryptoRefundAddress(unittest.TestCase):
|
|||
mock_query.filter.return_value = mock_filter
|
||||
mock_filter.first.return_value = expected_addr
|
||||
|
||||
result = get_user_crypto_refund_address(mock_session, self.user, "XMR")
|
||||
result = get_user_crypto_refund_address(
|
||||
mock_session, self.user, self.shop, "XMR"
|
||||
)
|
||||
self.assertEqual(result, expected_addr)
|
||||
|
|
|
|||
|
|
@ -235,7 +235,7 @@ def crypto_xmr_start(request):
|
|||
|
||||
# Convert fee to atomic units (piconero) for storage
|
||||
estimated_fee_piconero = int(fee_buffer_xmr * 1_000_000_000_000)
|
||||
|
||||
|
||||
# Persist CryptoPayment
|
||||
crypto_payment = CryptoPayment(
|
||||
invoice=invoice,
|
||||
|
|
@ -557,7 +557,9 @@ def crypto_quote(request):
|
|||
from ..models.user_crypto_refund_address import get_user_crypto_refund_address
|
||||
|
||||
user_refund_addr_obj = (
|
||||
get_user_crypto_refund_address(request.dbsession, request.user, request.shop, coin_type)
|
||||
get_user_crypto_refund_address(
|
||||
request.dbsession, request.user, request.shop, coin_type
|
||||
)
|
||||
if request.user
|
||||
else None
|
||||
)
|
||||
|
|
@ -583,15 +585,21 @@ def crypto_quote(request):
|
|||
# Use stored fee amount (calculated during payment creation) to avoid expensive RPC calls
|
||||
if crypto_payment.estimated_fee_amount is not None:
|
||||
# Convert stored atomic units back to coin units for display
|
||||
fee_buffer = Decimal(crypto_payment.estimated_fee_amount) / coin_info["smallest_unit_divisor"]
|
||||
fee_buffer = (
|
||||
Decimal(crypto_payment.estimated_fee_amount)
|
||||
/ coin_info["smallest_unit_divisor"]
|
||||
)
|
||||
else:
|
||||
# Fallback for old payments without stored fees
|
||||
settings = request.registry.settings
|
||||
fee_buffer = float(
|
||||
settings.get(
|
||||
f"{coin_type.lower()}.fee_buffer", coin_info["default_fee_buffer"]
|
||||
fee_buffer = (
|
||||
float(
|
||||
settings.get(
|
||||
f"{coin_type.lower()}.fee_buffer", coin_info["default_fee_buffer"]
|
||||
)
|
||||
)
|
||||
) * 2
|
||||
* 2
|
||||
)
|
||||
|
||||
amount_crypto_base = amount_crypto_with_fee - float(fee_buffer)
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ def user_crypto_settings_view(request):
|
|||
# Get all user's crypto addresses for current shop
|
||||
addresses = {}
|
||||
for coin in enabled_coins:
|
||||
addr = get_user_crypto_refund_address(request.dbsession, user, request.shop, coin)
|
||||
addr = get_user_crypto_refund_address(
|
||||
request.dbsession, user, request.shop, coin
|
||||
)
|
||||
if addr:
|
||||
addresses[coin] = addr
|
||||
|
||||
|
|
@ -67,7 +69,9 @@ def user_crypto_settings_update(request):
|
|||
|
||||
if not address:
|
||||
# If address is empty, treat it as a delete
|
||||
addr = get_user_crypto_refund_address(request.dbsession, user, request.shop, coin_type)
|
||||
addr = get_user_crypto_refund_address(
|
||||
request.dbsession, user, request.shop, coin_type
|
||||
)
|
||||
if addr:
|
||||
request.dbsession.delete(addr)
|
||||
request.session.flash((f"{coin_type} refund address cleared", "success"))
|
||||
|
|
@ -80,7 +84,9 @@ def user_crypto_settings_update(request):
|
|||
return HTTPFound("/u/settings/crypto")
|
||||
|
||||
# Get existing or create new
|
||||
addr_obj = get_user_crypto_refund_address(request.dbsession, user, request.shop, coin_type)
|
||||
addr_obj = get_user_crypto_refund_address(
|
||||
request.dbsession, user, request.shop, coin_type
|
||||
)
|
||||
|
||||
if addr_obj:
|
||||
# Update existing
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue