""" Test suite for Credit Transaction Manager Tests all credit transaction operations including: - Reserve credits - Confirm credits - Refund credits - Add credits (purchases) - Balance verification - Transaction history """ import pytest import uuid from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker from sqlalchemy.pool import StaticPool from core.models import Base, User, CreditTransaction from services.credit_service.transaction_manager import ( CreditTransactionManager, InsufficientCreditsError, TransactionNotFoundError, UserNotFoundError ) # Test database setup TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" @pytest.fixture async def engine(): """Create test database engine.""" engine = create_async_engine( TEST_DATABASE_URL, connect_args={"check_same_thread": False}, poolclass=StaticPool, ) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) yield engine async with engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) await engine.dispose() @pytest.fixture async def session(engine): """Create test database session.""" async_session = async_sessionmaker( engine, class_=AsyncSession, expire_on_commit=False ) async with async_session() as session: yield session @pytest.fixture async def test_user(session): """Create a test user with 100 credits.""" user = User( user_id=f"test_{uuid.uuid4().hex[:8]}", email=f"test_{uuid.uuid4().hex[:8]}@example.com", credits=100, is_active=True ) session.add(user) await session.commit() await session.refresh(user) return user # ============================================================================= # Reserve Credits Tests # ============================================================================= @pytest.mark.asyncio async def test_reserve_credits_success(session, test_user): """Test successfully reserving credits.""" initial_balance = test_user.credits transaction = await CreditTransactionManager.reserve_credits( session=session, user=test_user, amount=10, source="test", reference_type="test", reference_id="test_123", reason="Test reservation" ) await session.commit() await session.refresh(test_user) # Verify transaction assert transaction.transaction_type == "reserve" assert transaction.amount == -10 assert transaction.balance_before == initial_balance assert transaction.balance_after == initial_balance - 10 assert transaction.user_id == test_user.id assert transaction.source == "test" # Verify user balance assert test_user.credits == initial_balance - 10 @pytest.mark.asyncio async def test_reserve_credits_insufficient_funds(session, test_user): """Test reserving more credits than available.""" test_user.credits = 5 await session.commit() with pytest.raises(InsufficientCreditsError): await CreditTransactionManager.reserve_credits( session=session, user=test_user, amount=10, source="test", reference_type="test", reference_id="test_123" ) # Balance should be unchanged await session.refresh(test_user) assert test_user.credits == 5 @pytest.mark.asyncio async def test_reserve_credits_exact_amount(session, test_user): """Test reserving exact credit balance.""" test_user.credits = 10 await session.commit() transaction = await CreditTransactionManager.reserve_credits( session=session, user=test_user, amount=10, source="test", reference_type="test", reference_id="test_123" ) await session.commit() await session.refresh(test_user) assert test_user.credits == 0 assert transaction.balance_after == 0 # ============================================================================= # Confirm Credits Tests # ============================================================================= @pytest.mark.asyncio async def test_confirm_credits_success(session, test_user): """Test confirming reserved credits.""" # First reserve credits reserve_tx = await CreditTransactionManager.reserve_credits( session=session, user=test_user, amount=10, source="test", reference_type="test", reference_id="test_123" ) await session.commit() # Then confirm confirm_tx = await CreditTransactionManager.confirm_credits( session=session, transaction_id=reserve_tx.transaction_id, metadata={"status": "success"} ) await session.commit() # Verify confirmation assert confirm_tx.transaction_type == "confirm" assert confirm_tx.amount == 0 # No balance change assert confirm_tx.user_id == test_user.id assert confirm_tx.metadata["original_transaction_id"] == reserve_tx.transaction_id @pytest.mark.asyncio async def test_confirm_credits_nonexistent_transaction(session, test_user): """Test confirming a non-existent transaction.""" with pytest.raises(TransactionNotFoundError): await CreditTransactionManager.confirm_credits( session=session, transaction_id="nonexistent_tx_id" ) # ============================================================================= # Refund Credits Tests # ============================================================================= @pytest.mark.asyncio async def test_refund_credits_success(session, test_user): """Test refunding reserved credits.""" initial_balance = test_user.credits # Reserve credits reserve_tx = await CreditTransactionManager.reserve_credits( session=session, user=test_user, amount=10, source="test", reference_type="test", reference_id="test_123" ) await session.commit() await session.refresh(test_user) balance_after_reserve = test_user.credits assert balance_after_reserve == initial_balance - 10 # Refund refund_tx = await CreditTransactionManager.refund_credits( session=session, transaction_id=reserve_tx.transaction_id, reason="Test failed - refunding", metadata={"error": "test_error"} ) await session.commit() await session.refresh(test_user) # Verify refund assert refund_tx.transaction_type == "refund" assert refund_tx.amount == 10 # Positive for addition assert refund_tx.balance_before == balance_after_reserve assert refund_tx.balance_after == initial_balance assert test_user.credits == initial_balance @pytest.mark.asyncio async def test_refund_credits_nonexistent_transaction(session, test_user): """Test refunding a non-existent transaction.""" with pytest.raises(TransactionNotFoundError): await CreditTransactionManager.refund_credits( session=session, transaction_id="nonexistent_tx_id", reason="Test refund" ) # ============================================================================= # Add Credits Tests (Purchases) # ============================================================================= @pytest.mark.asyncio async def test_add_credits_success(session, test_user): """Test adding credits from purchase.""" initial_balance = test_user.credits transaction = await CreditTransactionManager.add_credits( session=session, user=test_user, amount=50, source="payment", reference_type="payment", reference_id="pay_123", reason="Purchase: 50 credits", metadata={"package_id": "basic"} ) await session.commit() await session.refresh(test_user) # Verify transaction assert transaction.transaction_type == "purchase" assert transaction.amount == 50 assert transaction.balance_before == initial_balance assert transaction.balance_after == initial_balance + 50 # Verify balance assert test_user.credits == initial_balance + 50 # ============================================================================= # Balance Verification Tests # ============================================================================= @pytest.mark.asyncio async def test_get_balance(session, test_user): """Test getting current balance.""" balance = await CreditTransactionManager.get_balance( session=session, user_id=test_user.id ) assert balance == test_user.credits @pytest.mark.asyncio async def test_get_balance_with_verification(session, test_user): """Test balance verification against transaction history.""" # Perform some transactions await CreditTransactionManager.reserve_credits( session=session, user=test_user, amount=10, source="test", reference_type="test", reference_id="test_1" ) await session.commit() await CreditTransactionManager.add_credits( session=session, user=test_user, amount=20, source="test", reference_type="test", reference_id="test_2" ) await session.commit() # Verify balance balance = await CreditTransactionManager.get_balance( session=session, user_id=test_user.id, verify=True ) await session.refresh(test_user) assert balance == test_user.credits @pytest.mark.asyncio async def test_get_balance_nonexistent_user(session): """Test getting balance for non-existent user.""" with pytest.raises(UserNotFoundError): await CreditTransactionManager.get_balance( session=session, user_id=99999 ) # ============================================================================= # Transaction History Tests # ============================================================================= @pytest.mark.asyncio async def test_get_transaction_history(session, test_user): """Test getting transaction history.""" # Create multiple transactions await CreditTransactionManager.reserve_credits( session=session, user=test_user, amount=10, source="test", reference_type="test", reference_id="test_1" ) await session.commit() await CreditTransactionManager.add_credits( session=session, user=test_user, amount=20, source="test", reference_type="test", reference_id="test_2" ) await session.commit() # Get history history = await CreditTransactionManager.get_transaction_history( session=session, user_id=test_user.id, limit=10 ) assert len(history) == 2 assert history[0].transaction_type in ["reserve", "purchase"] @pytest.mark.asyncio async def test_get_transaction_history_filtered(session, test_user): """Test getting filtered transaction history.""" # Create different transaction types await CreditTransactionManager.reserve_credits( session=session, user=test_user, amount=10, source="test", reference_type="test", reference_id="test_1" ) await session.commit() await CreditTransactionManager.add_credits( session=session, user=test_user, amount=20, source="payment", reference_type="payment", reference_id="pay_1" ) await session.commit() # Filter by purchase only history = await CreditTransactionManager.get_transaction_history( session=session, user_id=test_user.id, transaction_type="purchase" ) assert len(history) == 1 assert history[0].transaction_type == "purchase" # ============================================================================= # Integration Tests # ============================================================================= @pytest.mark.asyncio async def test_full_transaction_flow(session, test_user): """Test complete transaction flow: reserve → confirm.""" initial_balance = test_user.credits # Reserve reserve_tx = await CreditTransactionManager.reserve_credits( session=session, user=test_user, amount=10, source="middleware", reference_type="request", reference_id="POST:/api/endpoint" ) await session.commit() # Confirm confirm_tx = await CreditTransactionManager.confirm_credits( session=session, transaction_id=reserve_tx.transaction_id ) await session.commit() # Verify final state await session.refresh(test_user) assert test_user.credits == initial_balance - 10 # Verify transaction history history = await CreditTransactionManager.get_transaction_history( session=session, user_id=test_user.id ) assert len(history) == 2 assert history[1].transaction_type == "reserve" assert history[0].transaction_type == "confirm" @pytest.mark.asyncio async def test_full_refund_flow(session, test_user): """Test complete refund flow: reserve → refund.""" initial_balance = test_user.credits # Reserve reserve_tx = await CreditTransactionManager.reserve_credits( session=session, user=test_user, amount=10, source="middleware", reference_type="request", reference_id="POST:/api/endpoint" ) await session.commit() # Refund refund_tx = await CreditTransactionManager.refund_credits( session=session, transaction_id=reserve_tx.transaction_id, reason="Request failed" ) await session.commit() # Verify final state await session.refresh(test_user) assert test_user.credits == initial_balance # Back to original # Verify transaction history history = await CreditTransactionManager.get_transaction_history( session=session, user_id=test_user.id ) assert len(history) == 2 assert history[1].transaction_type == "reserve" assert history[0].transaction_type == "refund"