SQLite doesn't support Decimal natively - it stores DECIMAL columns as REAL (float). This caused sqlite3.ProgrammingError when writing Decimal values. Changes: - Add prepare_for_db() helper to convert Decimal→float before writes - Add Pydantic validators to convert float→Decimal on model creation - Update CRUD layer tests to verify float params for SQLite - Add SQLite round-trip tests to verify precision is preserved The data flow is now: Decimal (calculations) → float (prepare_for_db) → SQLite → float → Decimal (validators) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
828 lines
33 KiB
Python
828 lines
33 KiB
Python
"""
|
|
Integration tests for the full transaction processing flow.
|
|
|
|
These tests verify that data flows correctly from:
|
|
CSV parsing → Decimal conversion → calculations → model creation → CRUD
|
|
|
|
This gives us confidence that real Lamassu transactions will be
|
|
processed correctly end-to-end.
|
|
"""
|
|
|
|
import pytest
|
|
from decimal import Decimal
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from typing import Dict, Any
|
|
import csv
|
|
import io
|
|
|
|
from ..calculations import calculate_commission, calculate_distribution, calculate_exchange_rate
|
|
from ..models import (
|
|
CreateDcaPaymentData,
|
|
CreateLamassuTransactionData,
|
|
ClientBalanceSummary,
|
|
CreateDepositData,
|
|
DcaDeposit,
|
|
DcaPayment,
|
|
StoredLamassuTransaction,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# TEST DATA: Real Lamassu CSV output format
|
|
# =============================================================================
|
|
|
|
# This simulates what execute_ssh_query receives from the database
|
|
LAMASSU_CSV_DATA = {
|
|
"8.75pct_large": """transaction_id,fiat_amount,crypto_amount,transaction_time,device_id,status,commission_percentage,discount,crypto_code,fiat_code
|
|
abc123,2000,309200,2025-01-10 14:30:00+00,device1,confirmed,0.0875,0,BTC,GTQ""",
|
|
|
|
"5.5pct_no_discount": """transaction_id,fiat_amount,crypto_amount,transaction_time,device_id,status,commission_percentage,discount,crypto_code,fiat_code
|
|
def456,2000,309500,2025-01-10 15:00:00+00,device1,confirmed,0.055,0,BTC,GTQ""",
|
|
|
|
"5.5pct_90pct_discount": """transaction_id,fiat_amount,crypto_amount,transaction_time,device_id,status,commission_percentage,discount,crypto_code,fiat_code
|
|
ghi789,800,115000,2025-01-10 16:00:00+00,device1,confirmed,0.055,90,BTC,GTQ""",
|
|
|
|
"5.5pct_1300gtq_4clients": """transaction_id,fiat_amount,crypto_amount,transaction_time,device_id,status,commission_percentage,discount,crypto_code,fiat_code
|
|
jkl012,1300,205600,2025-01-10 17:00:00+00,device1,confirmed,0.055,0,BTC,GTQ""",
|
|
}
|
|
|
|
|
|
def parse_csv_like_transaction_processor(csv_data: str) -> Dict[str, Any]:
|
|
"""
|
|
Parse CSV data exactly like transaction_processor.execute_ssh_query does.
|
|
|
|
This is a copy of the parsing logic to test it in isolation.
|
|
"""
|
|
reader = csv.DictReader(io.StringIO(csv_data))
|
|
results = []
|
|
for row in reader:
|
|
processed_row = {}
|
|
for key, value in row.items():
|
|
if value == '' or value is None:
|
|
if key == 'crypto_amount':
|
|
processed_row[key] = 0
|
|
elif key == 'fiat_amount':
|
|
processed_row[key] = Decimal("0")
|
|
elif key in ['commission_percentage', 'discount']:
|
|
processed_row[key] = Decimal("0")
|
|
else:
|
|
processed_row[key] = None
|
|
elif key in ['transaction_id', 'device_id', 'crypto_code', 'fiat_code', 'status']:
|
|
processed_row[key] = str(value)
|
|
elif key == 'crypto_amount':
|
|
try:
|
|
processed_row[key] = int(float(value))
|
|
except (ValueError, TypeError):
|
|
processed_row[key] = 0
|
|
elif key == 'fiat_amount':
|
|
try:
|
|
processed_row[key] = Decimal(str(value))
|
|
except (ValueError, TypeError):
|
|
processed_row[key] = Decimal("0")
|
|
elif key in ['commission_percentage', 'discount']:
|
|
try:
|
|
processed_row[key] = Decimal(str(value))
|
|
except (ValueError, TypeError):
|
|
processed_row[key] = Decimal("0")
|
|
elif key == 'transaction_time':
|
|
timestamp_str = value
|
|
if timestamp_str.endswith('+00'):
|
|
timestamp_str = timestamp_str + ':00'
|
|
elif timestamp_str.endswith('Z'):
|
|
timestamp_str = timestamp_str.replace('Z', '+00:00')
|
|
try:
|
|
dt = datetime.fromisoformat(timestamp_str)
|
|
except ValueError:
|
|
dt = datetime.now(timezone.utc)
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
elif dt.tzinfo != timezone.utc:
|
|
dt = dt.astimezone(timezone.utc)
|
|
processed_row[key] = dt
|
|
else:
|
|
processed_row[key] = value
|
|
results.append(processed_row)
|
|
return results[0] if results else {}
|
|
|
|
|
|
# =============================================================================
|
|
# CSV PARSING TESTS
|
|
# =============================================================================
|
|
|
|
class TestCsvParsing:
|
|
"""Test that CSV parsing produces correct Decimal types."""
|
|
|
|
def test_parse_8_75pct_transaction(self):
|
|
"""Parse 8.75% commission transaction."""
|
|
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["8.75pct_large"])
|
|
|
|
assert tx["transaction_id"] == "abc123"
|
|
assert tx["crypto_amount"] == 309200
|
|
assert tx["fiat_amount"] == Decimal("2000")
|
|
assert tx["commission_percentage"] == Decimal("0.0875")
|
|
assert tx["discount"] == Decimal("0")
|
|
assert isinstance(tx["fiat_amount"], Decimal)
|
|
assert isinstance(tx["commission_percentage"], Decimal)
|
|
assert isinstance(tx["discount"], Decimal)
|
|
|
|
def test_parse_5_5pct_with_discount(self):
|
|
"""Parse 5.5% commission with 90% discount."""
|
|
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["5.5pct_90pct_discount"])
|
|
|
|
assert tx["crypto_amount"] == 115000
|
|
assert tx["fiat_amount"] == Decimal("800")
|
|
assert tx["commission_percentage"] == Decimal("0.055")
|
|
assert tx["discount"] == Decimal("90")
|
|
|
|
def test_timestamp_parsing(self):
|
|
"""Verify timestamp is parsed to UTC datetime."""
|
|
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["8.75pct_large"])
|
|
|
|
assert isinstance(tx["transaction_time"], datetime)
|
|
assert tx["transaction_time"].tzinfo == timezone.utc
|
|
|
|
|
|
# =============================================================================
|
|
# END-TO-END CALCULATION TESTS
|
|
# =============================================================================
|
|
|
|
class TestEndToEndCalculations:
|
|
"""
|
|
Test the full flow: CSV → Decimal → calculations → expected results.
|
|
|
|
These use the same empirical data as test_calculations.py but verify
|
|
the data flows correctly through parsing.
|
|
"""
|
|
|
|
@pytest.mark.parametrize("csv_key,expected_base,expected_commission", [
|
|
("8.75pct_large", 284322, 24878),
|
|
("5.5pct_no_discount", 293365, 16135),
|
|
("5.5pct_90pct_discount", 114371, 629),
|
|
("5.5pct_1300gtq_4clients", 194882, 10718),
|
|
])
|
|
def test_csv_to_commission_calculation(self, csv_key, expected_base, expected_commission):
|
|
"""Verify CSV parsing → commission calculation produces expected results."""
|
|
# Parse CSV like transaction_processor does
|
|
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA[csv_key])
|
|
|
|
# Calculate commission using parsed Decimal values
|
|
base, commission, effective = calculate_commission(
|
|
tx["crypto_amount"],
|
|
tx["commission_percentage"],
|
|
tx["discount"]
|
|
)
|
|
|
|
assert base == expected_base, f"Base mismatch for {csv_key}"
|
|
assert commission == expected_commission, f"Commission mismatch for {csv_key}"
|
|
assert base + commission == tx["crypto_amount"], "Invariant: base + commission = total"
|
|
|
|
def test_full_distribution_flow_two_equal_clients(self):
|
|
"""Test full flow with two equal-balance clients."""
|
|
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["8.75pct_large"])
|
|
|
|
# Calculate commission
|
|
base_sats, commission_sats, effective = calculate_commission(
|
|
tx["crypto_amount"],
|
|
tx["commission_percentage"],
|
|
tx["discount"]
|
|
)
|
|
|
|
# Simulate client balances (as would come from database)
|
|
client_balances = {
|
|
"client_a": Decimal("1000.00"),
|
|
"client_b": Decimal("1000.00"),
|
|
}
|
|
|
|
# Calculate distribution
|
|
distributions = calculate_distribution(base_sats, client_balances)
|
|
|
|
# Verify results
|
|
assert sum(distributions.values()) == base_sats
|
|
assert len(distributions) == 2
|
|
# With equal balances, should be roughly equal (±1 sat for rounding)
|
|
assert abs(distributions["client_a"] - distributions["client_b"]) <= 1
|
|
|
|
def test_full_distribution_flow_four_clients(self):
|
|
"""Test the 1300 GTQ transaction with 4 clients of varying balances."""
|
|
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["5.5pct_1300gtq_4clients"])
|
|
|
|
# Calculate commission
|
|
base_sats, commission_sats, effective = calculate_commission(
|
|
tx["crypto_amount"],
|
|
tx["commission_percentage"],
|
|
tx["discount"]
|
|
)
|
|
|
|
assert base_sats == 194882
|
|
assert commission_sats == 10718
|
|
|
|
# Use the actual balance proportions from the real scenario
|
|
client_balances = {
|
|
"client_a": Decimal("1"),
|
|
"client_b": Decimal("986"),
|
|
"client_c": Decimal("14"),
|
|
"client_d": Decimal("4"),
|
|
}
|
|
|
|
distributions = calculate_distribution(base_sats, client_balances)
|
|
|
|
# Verify invariant
|
|
assert sum(distributions.values()) == base_sats
|
|
|
|
# Verify proportions are reasonable
|
|
total_balance = sum(client_balances.values())
|
|
for client_id, sats in distributions.items():
|
|
expected_proportion = client_balances[client_id] / total_balance
|
|
actual_proportion = Decimal(sats) / Decimal(base_sats)
|
|
# Allow 1% tolerance for rounding
|
|
assert abs(actual_proportion - expected_proportion) < Decimal("0.01"), \
|
|
f"Client {client_id} proportion off: {actual_proportion} vs {expected_proportion}"
|
|
|
|
|
|
# =============================================================================
|
|
# MODEL CREATION TESTS
|
|
# =============================================================================
|
|
|
|
class TestModelCreation:
|
|
"""Test that Pydantic models accept Decimal values correctly."""
|
|
|
|
def test_create_lamassu_transaction_data_with_decimals(self):
|
|
"""Verify CreateLamassuTransactionData accepts Decimal values."""
|
|
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["8.75pct_large"])
|
|
|
|
base_sats, commission_sats, effective = calculate_commission(
|
|
tx["crypto_amount"],
|
|
tx["commission_percentage"],
|
|
tx["discount"]
|
|
)
|
|
exchange_rate = calculate_exchange_rate(base_sats, tx["fiat_amount"])
|
|
|
|
# This should not raise any validation errors
|
|
data = CreateLamassuTransactionData(
|
|
lamassu_transaction_id=tx["transaction_id"],
|
|
fiat_amount=tx["fiat_amount"],
|
|
crypto_amount=tx["crypto_amount"],
|
|
commission_percentage=tx["commission_percentage"],
|
|
discount=tx["discount"],
|
|
effective_commission=effective,
|
|
commission_amount_sats=commission_sats,
|
|
base_amount_sats=base_sats,
|
|
exchange_rate=exchange_rate,
|
|
crypto_code=tx["crypto_code"],
|
|
fiat_code=tx["fiat_code"],
|
|
device_id=tx["device_id"],
|
|
transaction_time=tx["transaction_time"],
|
|
)
|
|
|
|
assert data.fiat_amount == Decimal("2000")
|
|
assert data.commission_percentage == Decimal("0.0875")
|
|
assert data.base_amount_sats == 284322
|
|
assert data.commission_amount_sats == 24878
|
|
|
|
def test_create_dca_payment_data_with_decimals(self):
|
|
"""Verify CreateDcaPaymentData accepts Decimal values."""
|
|
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["5.5pct_no_discount"])
|
|
|
|
base_sats, _, _ = calculate_commission(
|
|
tx["crypto_amount"],
|
|
tx["commission_percentage"],
|
|
tx["discount"]
|
|
)
|
|
exchange_rate = calculate_exchange_rate(base_sats, tx["fiat_amount"])
|
|
|
|
# Simulate a client getting half the distribution
|
|
client_sats = base_sats // 2
|
|
client_fiat = (Decimal(client_sats) / exchange_rate).quantize(Decimal("0.01"))
|
|
|
|
# This should not raise any validation errors
|
|
data = CreateDcaPaymentData(
|
|
client_id="test_client_123",
|
|
amount_sats=client_sats,
|
|
amount_fiat=client_fiat,
|
|
exchange_rate=exchange_rate,
|
|
transaction_type="flow",
|
|
lamassu_transaction_id=tx["transaction_id"],
|
|
transaction_time=tx["transaction_time"],
|
|
)
|
|
|
|
assert isinstance(data.amount_fiat, Decimal)
|
|
assert isinstance(data.exchange_rate, Decimal)
|
|
assert data.amount_sats == client_sats
|
|
|
|
def test_client_balance_summary_with_decimals(self):
|
|
"""Verify ClientBalanceSummary accepts Decimal values."""
|
|
summary = ClientBalanceSummary(
|
|
client_id="test_client",
|
|
total_deposits=Decimal("5000.00"),
|
|
total_payments=Decimal("1234.56"),
|
|
remaining_balance=Decimal("3765.44"),
|
|
currency="GTQ",
|
|
)
|
|
|
|
assert summary.remaining_balance == Decimal("3765.44")
|
|
assert isinstance(summary.total_deposits, Decimal)
|
|
|
|
|
|
# =============================================================================
|
|
# EXCHANGE RATE PRECISION TESTS
|
|
# =============================================================================
|
|
|
|
class TestExchangeRatePrecision:
|
|
"""Test that exchange rate calculations maintain precision."""
|
|
|
|
def test_exchange_rate_round_trip(self):
|
|
"""Verify sats → fiat → sats round-trip maintains precision."""
|
|
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["5.5pct_1300gtq_4clients"])
|
|
|
|
base_sats, _, _ = calculate_commission(
|
|
tx["crypto_amount"],
|
|
tx["commission_percentage"],
|
|
tx["discount"]
|
|
)
|
|
|
|
exchange_rate = calculate_exchange_rate(base_sats, tx["fiat_amount"])
|
|
|
|
# Convert sats to fiat and back
|
|
fiat_equivalent = Decimal(base_sats) / exchange_rate
|
|
sats_back = int((fiat_equivalent * exchange_rate).quantize(Decimal("1")))
|
|
|
|
# Should be within 1 sat of original
|
|
assert abs(sats_back - base_sats) <= 1
|
|
|
|
def test_per_client_fiat_sums_to_total(self):
|
|
"""Verify per-client fiat amounts sum to total fiat (within tolerance)."""
|
|
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["5.5pct_1300gtq_4clients"])
|
|
|
|
base_sats, _, _ = calculate_commission(
|
|
tx["crypto_amount"],
|
|
tx["commission_percentage"],
|
|
tx["discount"]
|
|
)
|
|
|
|
exchange_rate = calculate_exchange_rate(base_sats, tx["fiat_amount"])
|
|
|
|
client_balances = {
|
|
"client_a": Decimal("1"),
|
|
"client_b": Decimal("986"),
|
|
"client_c": Decimal("14"),
|
|
"client_d": Decimal("4"),
|
|
}
|
|
|
|
distributions = calculate_distribution(base_sats, client_balances)
|
|
|
|
# Calculate per-client fiat and sum
|
|
total_fiat_distributed = Decimal("0")
|
|
for client_id, sats in distributions.items():
|
|
client_fiat = (Decimal(sats) / exchange_rate).quantize(Decimal("0.01"))
|
|
total_fiat_distributed += client_fiat
|
|
|
|
# Should be within 0.05 GTQ of original (accounting for per-client rounding)
|
|
# This is the 0.01 discrepancy we discussed, multiplied by number of clients
|
|
assert abs(total_fiat_distributed - tx["fiat_amount"]) < Decimal("0.05"), \
|
|
f"Total distributed {total_fiat_distributed} vs original {tx['fiat_amount']}"
|
|
|
|
|
|
# =============================================================================
|
|
# CRUD LAYER TESTS (with mocked database)
|
|
# =============================================================================
|
|
|
|
class TestCrudLayerDecimalHandling:
|
|
"""
|
|
Test that CRUD operations handle Decimal→float conversion for SQLite.
|
|
|
|
These tests mock the database layer to verify:
|
|
1. Decimal values from models are converted to float via prepare_for_db()
|
|
2. Float values from SQLite are converted back to Decimal via model validators
|
|
"""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_deposit_passes_float_amount_for_sqlite(self):
|
|
"""Verify create_deposit passes float amount to database (SQLite compatibility)."""
|
|
from unittest.mock import patch, AsyncMock
|
|
|
|
# Create deposit data with Decimal
|
|
deposit_data = CreateDepositData(
|
|
client_id="test_client_123",
|
|
amount=Decimal("1500.75"),
|
|
currency="GTQ",
|
|
notes="Test deposit"
|
|
)
|
|
|
|
# Verify the model stored it as Decimal
|
|
assert isinstance(deposit_data.amount, Decimal)
|
|
assert deposit_data.amount == Decimal("1500.75")
|
|
|
|
# Mock the database
|
|
mock_db = AsyncMock()
|
|
mock_db.execute = AsyncMock()
|
|
mock_db.fetchone = AsyncMock(return_value=DcaDeposit(
|
|
id="deposit_123",
|
|
client_id="test_client_123",
|
|
amount=1500.75, # float from SQLite
|
|
currency="GTQ",
|
|
status="pending",
|
|
notes="Test deposit",
|
|
created_at=datetime.now(timezone.utc),
|
|
confirmed_at=None
|
|
))
|
|
|
|
with patch('satmachineadmin.crud.db', mock_db):
|
|
from ..crud import create_deposit
|
|
result = await create_deposit(deposit_data)
|
|
|
|
# Verify db.execute was called
|
|
mock_db.execute.assert_called_once()
|
|
|
|
# Get the parameters passed to execute
|
|
call_args = mock_db.execute.call_args
|
|
params = call_args[0][1] # Second positional arg is the params dict
|
|
|
|
# Verify the amount parameter is float (for SQLite compatibility)
|
|
assert isinstance(params["amount"], float)
|
|
assert params["amount"] == 1500.75
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_dca_payment_passes_float_values_for_sqlite(self):
|
|
"""Verify create_dca_payment passes float fiat and exchange_rate (SQLite compatibility)."""
|
|
from unittest.mock import patch, AsyncMock
|
|
|
|
# Parse a real transaction
|
|
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["5.5pct_no_discount"])
|
|
base_sats, _, _ = calculate_commission(
|
|
tx["crypto_amount"],
|
|
tx["commission_percentage"],
|
|
tx["discount"]
|
|
)
|
|
exchange_rate = calculate_exchange_rate(base_sats, tx["fiat_amount"])
|
|
|
|
# Create payment data
|
|
client_sats = 146682 # Half of base
|
|
client_fiat = (Decimal(client_sats) / exchange_rate).quantize(Decimal("0.01"))
|
|
|
|
payment_data = CreateDcaPaymentData(
|
|
client_id="test_client",
|
|
amount_sats=client_sats,
|
|
amount_fiat=client_fiat,
|
|
exchange_rate=exchange_rate,
|
|
transaction_type="flow",
|
|
lamassu_transaction_id="def456",
|
|
transaction_time=tx["transaction_time"],
|
|
)
|
|
|
|
# Verify model has Decimal types
|
|
assert isinstance(payment_data.amount_fiat, Decimal)
|
|
assert isinstance(payment_data.exchange_rate, Decimal)
|
|
|
|
# Mock database - returns float like SQLite would
|
|
mock_db = AsyncMock()
|
|
mock_db.execute = AsyncMock()
|
|
mock_db.fetchone = AsyncMock(return_value=DcaPayment(
|
|
id="payment_123",
|
|
client_id="test_client",
|
|
amount_sats=client_sats,
|
|
amount_fiat=float(client_fiat), # float from SQLite
|
|
exchange_rate=float(exchange_rate), # float from SQLite
|
|
transaction_type="flow",
|
|
lamassu_transaction_id="def456",
|
|
payment_hash=None,
|
|
status="pending",
|
|
created_at=datetime.now(timezone.utc),
|
|
transaction_time=tx["transaction_time"],
|
|
))
|
|
|
|
with patch('satmachineadmin.crud.db', mock_db):
|
|
from ..crud import create_dca_payment
|
|
result = await create_dca_payment(payment_data)
|
|
|
|
# Verify db.execute was called
|
|
mock_db.execute.assert_called_once()
|
|
|
|
# Get params
|
|
call_args = mock_db.execute.call_args
|
|
params = call_args[0][1]
|
|
|
|
# Verify float types in params (SQLite compatibility)
|
|
assert isinstance(params["amount_fiat"], float)
|
|
assert isinstance(params["exchange_rate"], float)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_lamassu_transaction_passes_floats_for_sqlite(self):
|
|
"""Verify create_lamassu_transaction passes float fields (SQLite compatibility)."""
|
|
from unittest.mock import patch, AsyncMock
|
|
|
|
tx = parse_csv_like_transaction_processor(LAMASSU_CSV_DATA["8.75pct_large"])
|
|
base_sats, commission_sats, effective = calculate_commission(
|
|
tx["crypto_amount"],
|
|
tx["commission_percentage"],
|
|
tx["discount"]
|
|
)
|
|
exchange_rate = calculate_exchange_rate(base_sats, tx["fiat_amount"])
|
|
|
|
transaction_data = CreateLamassuTransactionData(
|
|
lamassu_transaction_id=tx["transaction_id"],
|
|
fiat_amount=tx["fiat_amount"],
|
|
crypto_amount=tx["crypto_amount"],
|
|
commission_percentage=tx["commission_percentage"],
|
|
discount=tx["discount"],
|
|
effective_commission=effective,
|
|
commission_amount_sats=commission_sats,
|
|
base_amount_sats=base_sats,
|
|
exchange_rate=exchange_rate,
|
|
crypto_code=tx["crypto_code"],
|
|
fiat_code=tx["fiat_code"],
|
|
device_id=tx["device_id"],
|
|
transaction_time=tx["transaction_time"],
|
|
)
|
|
|
|
# Verify all Decimal fields in the model
|
|
assert isinstance(transaction_data.fiat_amount, Decimal)
|
|
assert isinstance(transaction_data.commission_percentage, Decimal)
|
|
assert isinstance(transaction_data.discount, Decimal)
|
|
assert isinstance(transaction_data.effective_commission, Decimal)
|
|
assert isinstance(transaction_data.exchange_rate, Decimal)
|
|
|
|
# Mock database - returns floats like SQLite would
|
|
mock_db = AsyncMock()
|
|
mock_db.execute = AsyncMock()
|
|
mock_db.fetchone = AsyncMock(return_value=StoredLamassuTransaction(
|
|
id="tx_123",
|
|
lamassu_transaction_id=tx["transaction_id"],
|
|
fiat_amount=float(tx["fiat_amount"]), # float from SQLite
|
|
crypto_amount=tx["crypto_amount"],
|
|
commission_percentage=float(tx["commission_percentage"]), # float from SQLite
|
|
discount=float(tx["discount"]), # float from SQLite
|
|
effective_commission=float(effective), # float from SQLite
|
|
commission_amount_sats=commission_sats,
|
|
base_amount_sats=base_sats,
|
|
exchange_rate=float(exchange_rate), # float from SQLite
|
|
crypto_code=tx["crypto_code"],
|
|
fiat_code=tx["fiat_code"],
|
|
device_id=tx["device_id"],
|
|
transaction_time=tx["transaction_time"],
|
|
processed_at=datetime.now(timezone.utc),
|
|
clients_count=0,
|
|
distributions_total_sats=0,
|
|
))
|
|
|
|
with patch('satmachineadmin.crud.db', mock_db):
|
|
from ..crud import create_lamassu_transaction
|
|
result = await create_lamassu_transaction(transaction_data)
|
|
|
|
# Verify db.execute was called
|
|
mock_db.execute.assert_called_once()
|
|
|
|
# Get params
|
|
call_args = mock_db.execute.call_args
|
|
params = call_args[0][1]
|
|
|
|
# Verify all Decimal fields are converted to float for SQLite
|
|
assert isinstance(params["fiat_amount"], float), f"fiat_amount is {type(params['fiat_amount'])}"
|
|
assert isinstance(params["commission_percentage"], float)
|
|
assert isinstance(params["discount"], float)
|
|
assert isinstance(params["effective_commission"], float)
|
|
assert isinstance(params["exchange_rate"], float)
|
|
|
|
# Verify values match
|
|
assert params["fiat_amount"] == 2000.0
|
|
assert params["commission_percentage"] == 0.0875
|
|
assert params["base_amount_sats"] == 284322
|
|
assert params["commission_amount_sats"] == 24878
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_client_balance_summary_returns_decimals(self):
|
|
"""Verify get_client_balance_summary returns Decimal types."""
|
|
from unittest.mock import patch, AsyncMock
|
|
|
|
# Mock database responses
|
|
mock_db = AsyncMock()
|
|
|
|
# Mock deposits query result
|
|
mock_db.fetchone = AsyncMock(side_effect=[
|
|
# First call: deposits sum
|
|
{"total": Decimal("5000.00"), "currency": "GTQ"},
|
|
# Second call: payments sum
|
|
{"total": Decimal("1234.56")},
|
|
])
|
|
|
|
with patch('satmachineadmin.crud.db', mock_db):
|
|
from ..crud import get_client_balance_summary
|
|
result = await get_client_balance_summary("test_client")
|
|
|
|
# Verify result has Decimal types
|
|
assert isinstance(result.total_deposits, Decimal)
|
|
assert isinstance(result.total_payments, Decimal)
|
|
assert isinstance(result.remaining_balance, Decimal)
|
|
|
|
# Verify values
|
|
assert result.total_deposits == Decimal("5000.00")
|
|
assert result.total_payments == Decimal("1234.56")
|
|
assert result.remaining_balance == Decimal("3765.44")
|
|
|
|
|
|
# =============================================================================
|
|
# SQLITE ROUND-TRIP TESTS
|
|
# =============================================================================
|
|
|
|
class TestSqliteDecimalRoundTrip:
|
|
"""
|
|
Test that Decimal values survive the SQLite round-trip:
|
|
Decimal → float (prepare_for_db) → SQLite REAL → float → Decimal (validator)
|
|
|
|
SQLite doesn't support Decimal natively - it stores DECIMAL as REAL (float).
|
|
We use prepare_for_db() to convert Decimal→float before writing,
|
|
and Pydantic validators with pre=True to convert float→Decimal on read.
|
|
"""
|
|
|
|
def test_prepare_for_db_converts_decimals_to_float(self):
|
|
"""Verify prepare_for_db converts Decimal to float."""
|
|
from ..crud import prepare_for_db
|
|
|
|
values = {
|
|
"amount": Decimal("1500.75"),
|
|
"exchange_rate": Decimal("146.6825"),
|
|
"commission": Decimal("0.0875"),
|
|
"name": "test", # Non-Decimal should pass through
|
|
"count": 42, # Non-Decimal should pass through
|
|
}
|
|
|
|
result = prepare_for_db(values)
|
|
|
|
# Decimals should be converted to float
|
|
assert isinstance(result["amount"], float)
|
|
assert isinstance(result["exchange_rate"], float)
|
|
assert isinstance(result["commission"], float)
|
|
|
|
# Values should be preserved
|
|
assert result["amount"] == 1500.75
|
|
assert result["exchange_rate"] == 146.6825
|
|
assert result["commission"] == 0.0875
|
|
|
|
# Non-Decimals should pass through unchanged
|
|
assert result["name"] == "test"
|
|
assert result["count"] == 42
|
|
|
|
def test_model_validator_converts_float_to_decimal(self):
|
|
"""Verify model validators convert float back to Decimal."""
|
|
# Simulate what comes back from SQLite (floats)
|
|
db_row = {
|
|
"id": "deposit_123",
|
|
"client_id": "client_abc",
|
|
"amount": 1500.75, # float from SQLite
|
|
"currency": "GTQ",
|
|
"status": "pending",
|
|
"notes": "Test",
|
|
"created_at": datetime.now(timezone.utc),
|
|
"confirmed_at": None,
|
|
}
|
|
|
|
# Create model from "database" data
|
|
deposit = DcaDeposit(**db_row)
|
|
|
|
# Validator should have converted float → Decimal
|
|
assert isinstance(deposit.amount, Decimal)
|
|
assert deposit.amount == Decimal("1500.75")
|
|
|
|
def test_payment_model_converts_multiple_floats(self):
|
|
"""Verify DcaPayment converts all float fields to Decimal."""
|
|
db_row = {
|
|
"id": "payment_123",
|
|
"client_id": "client_abc",
|
|
"amount_sats": 146682,
|
|
"amount_fiat": 1000.50, # float from SQLite
|
|
"exchange_rate": 146.6825, # float from SQLite
|
|
"transaction_type": "flow",
|
|
"lamassu_transaction_id": "tx_456",
|
|
"payment_hash": None,
|
|
"status": "confirmed",
|
|
"created_at": datetime.now(timezone.utc),
|
|
"transaction_time": datetime.now(timezone.utc),
|
|
}
|
|
|
|
payment = DcaPayment(**db_row)
|
|
|
|
assert isinstance(payment.amount_fiat, Decimal)
|
|
assert isinstance(payment.exchange_rate, Decimal)
|
|
assert payment.amount_fiat == Decimal("1000.50")
|
|
assert payment.exchange_rate == Decimal("146.6825")
|
|
|
|
def test_stored_transaction_converts_all_decimal_fields(self):
|
|
"""Verify StoredLamassuTransaction converts all float fields."""
|
|
db_row = {
|
|
"id": "tx_123",
|
|
"lamassu_transaction_id": "lamassu_abc",
|
|
"fiat_amount": 2000.00, # float from SQLite
|
|
"crypto_amount": 309200,
|
|
"commission_percentage": 0.0875, # float from SQLite
|
|
"discount": 0.0, # float from SQLite
|
|
"effective_commission": 0.0875, # float from SQLite
|
|
"commission_amount_sats": 24878,
|
|
"base_amount_sats": 284322,
|
|
"exchange_rate": 142.161, # float from SQLite
|
|
"crypto_code": "BTC",
|
|
"fiat_code": "GTQ",
|
|
"device_id": "device1",
|
|
"transaction_time": datetime.now(timezone.utc),
|
|
"processed_at": datetime.now(timezone.utc),
|
|
"clients_count": 2,
|
|
"distributions_total_sats": 284322,
|
|
}
|
|
|
|
tx = StoredLamassuTransaction(**db_row)
|
|
|
|
# All Decimal fields should be converted
|
|
assert isinstance(tx.fiat_amount, Decimal)
|
|
assert isinstance(tx.commission_percentage, Decimal)
|
|
assert isinstance(tx.discount, Decimal)
|
|
assert isinstance(tx.effective_commission, Decimal)
|
|
assert isinstance(tx.exchange_rate, Decimal)
|
|
|
|
# Values should be preserved
|
|
assert tx.fiat_amount == Decimal("2000.00")
|
|
assert tx.commission_percentage == Decimal("0.0875")
|
|
assert tx.discount == Decimal("0.0")
|
|
assert tx.effective_commission == Decimal("0.0875")
|
|
|
|
def test_full_round_trip_preserves_precision(self):
|
|
"""
|
|
Test complete round-trip: Decimal → prepare_for_db → float → Decimal.
|
|
|
|
This simulates what happens in production:
|
|
1. We have Decimal values from calculations
|
|
2. prepare_for_db converts them to float for SQLite
|
|
3. SQLite stores as REAL
|
|
4. We read back as float
|
|
5. Model validators convert back to Decimal
|
|
"""
|
|
from ..crud import prepare_for_db
|
|
|
|
# Original Decimal values (from calculations)
|
|
original = {
|
|
"fiat_amount": Decimal("1300.00"),
|
|
"commission_percentage": Decimal("0.055"),
|
|
"discount": Decimal("0"),
|
|
"effective_commission": Decimal("0.055"),
|
|
"exchange_rate": Decimal("149.90215490109"), # High precision
|
|
}
|
|
|
|
# Step 1: Convert for database storage
|
|
for_db = prepare_for_db(original)
|
|
|
|
# Verify all are floats
|
|
for key in original:
|
|
assert isinstance(for_db[key], float), f"{key} should be float"
|
|
|
|
# Step 2: Simulate SQLite storage and retrieval
|
|
# (SQLite would store these as REAL and return as float)
|
|
from_db = for_db.copy()
|
|
|
|
# Step 3: Create a model (simulating what happens when reading)
|
|
db_row = {
|
|
"id": "tx_test",
|
|
"lamassu_transaction_id": "test_123",
|
|
"fiat_amount": from_db["fiat_amount"],
|
|
"crypto_amount": 205600,
|
|
"commission_percentage": from_db["commission_percentage"],
|
|
"discount": from_db["discount"],
|
|
"effective_commission": from_db["effective_commission"],
|
|
"commission_amount_sats": 10718,
|
|
"base_amount_sats": 194882,
|
|
"exchange_rate": from_db["exchange_rate"],
|
|
"crypto_code": "BTC",
|
|
"fiat_code": "GTQ",
|
|
"device_id": "device1",
|
|
"transaction_time": datetime.now(timezone.utc),
|
|
"processed_at": datetime.now(timezone.utc),
|
|
"clients_count": 1,
|
|
"distributions_total_sats": 194882,
|
|
}
|
|
|
|
tx = StoredLamassuTransaction(**db_row)
|
|
|
|
# Step 4: Verify Decimal types restored
|
|
assert isinstance(tx.fiat_amount, Decimal)
|
|
assert isinstance(tx.commission_percentage, Decimal)
|
|
assert isinstance(tx.exchange_rate, Decimal)
|
|
|
|
# Step 5: Verify precision for 2 decimal place values
|
|
assert tx.fiat_amount == Decimal("1300.0") # SQLite preserves this exactly
|
|
assert tx.commission_percentage == Decimal("0.055")
|
|
assert tx.discount == Decimal("0.0")
|
|
|
|
def test_client_balance_summary_float_conversion(self):
|
|
"""Verify ClientBalanceSummary converts floats from aggregation queries."""
|
|
# SQLite SUM() returns float
|
|
db_row = {
|
|
"client_id": "client_123",
|
|
"total_deposits": 5000.00, # float from SUM()
|
|
"total_payments": 1234.56, # float from SUM()
|
|
"remaining_balance": 3765.44, # calculated float
|
|
"currency": "GTQ",
|
|
}
|
|
|
|
summary = ClientBalanceSummary(**db_row)
|
|
|
|
assert isinstance(summary.total_deposits, Decimal)
|
|
assert isinstance(summary.total_payments, Decimal)
|
|
assert isinstance(summary.remaining_balance, Decimal)
|
|
assert summary.remaining_balance == Decimal("3765.44")
|