import pytest from decimal import Decimal from datetime import datetime, timezone from typing import AsyncGenerator, List from sqlalchemy.ext.asyncio import AsyncSession from app.models import ( User, Group, Expense, ExpenseSplit, SettlementActivity, ExpenseSplitStatusEnum, ExpenseOverallStatusEnum, SplitTypeEnum, UserRoleEnum ) from app.crud.settlement_activity import ( create_settlement_activity, get_settlement_activity_by_id, get_settlement_activities_for_split, update_expense_split_status, # For direct testing if needed update_expense_overall_status # For direct testing if needed ) from app.schemas.settlement_activity import SettlementActivityCreate as SettlementActivityCreateSchema @pytest.fixture async def test_user1(db_session: AsyncSession) -> User: user = User(email="user1@example.com", name="Test User 1", hashed_password="password1") db_session.add(user) await db_session.commit() await db_session.refresh(user) return user @pytest.fixture async def test_user2(db_session: AsyncSession) -> User: user = User(email="user2@example.com", name="Test User 2", hashed_password="password2") db_session.add(user) await db_session.commit() await db_session.refresh(user) return user @pytest.fixture async def test_group(db_session: AsyncSession, test_user1: User) -> Group: group = Group(name="Test Group", created_by_id=test_user1.id) db_session.add(group) await db_session.commit() # Add user1 as owner and user2 as member (can be done in specific tests if needed) await db_session.refresh(group) return group @pytest.fixture async def test_expense(db_session: AsyncSession, test_user1: User, test_group: Group) -> Expense: expense = Expense( description="Test Expense for Settlement", total_amount=Decimal("20.00"), currency="USD", expense_date=datetime.now(timezone.utc), split_type=SplitTypeEnum.EQUAL, group_id=test_group.id, paid_by_user_id=test_user1.id, created_by_user_id=test_user1.id, overall_settlement_status=ExpenseOverallStatusEnum.unpaid # Initial status ) db_session.add(expense) await db_session.commit() await db_session.refresh(expense) return expense @pytest.fixture async def test_expense_split_user2_owes(db_session: AsyncSession, test_expense: Expense, test_user2: User) -> ExpenseSplit: # User2 owes 10.00 to User1 (who paid the expense) split = ExpenseSplit( expense_id=test_expense.id, user_id=test_user2.id, owed_amount=Decimal("10.00"), status=ExpenseSplitStatusEnum.unpaid # Initial status ) db_session.add(split) await db_session.commit() await db_session.refresh(split) return split @pytest.fixture async def test_expense_split_user1_owes_self_for_completeness(db_session: AsyncSession, test_expense: Expense, test_user1: User) -> ExpenseSplit: # User1's own share (owes 10.00 to self, effectively settled) # This is often how splits are represented, even for the payer split = ExpenseSplit( expense_id=test_expense.id, user_id=test_user1.id, owed_amount=Decimal("10.00"), # User1's share of the 20.00 expense status=ExpenseSplitStatusEnum.unpaid # Initial status, though payer's own share might be considered paid by some logic ) db_session.add(split) await db_session.commit() await db_session.refresh(split) return split # --- Tests for create_settlement_activity --- @pytest.mark.asyncio async def test_create_settlement_activity_full_payment( db_session: AsyncSession, test_user1: User, # Creator of activity, Payer of expense test_user2: User, # Payer of this settlement activity (settling their debt) test_expense: Expense, test_expense_split_user2_owes: ExpenseSplit, test_expense_split_user1_owes_self_for_completeness: ExpenseSplit # User1's own share ): # Scenario: User2 fully pays their 10.00 share. # User1's share is also part of the expense. Let's assume it's 'paid' by default or handled separately. # For this test, we focus on User2's split. # To make overall expense paid, User1's split also needs to be considered paid. # We can manually update User1's split status to paid for this test case. test_expense_split_user1_owes_self_for_completeness.status = ExpenseSplitStatusEnum.paid test_expense_split_user1_owes_self_for_completeness.paid_at = datetime.now(timezone.utc) db_session.add(test_expense_split_user1_owes_self_for_completeness) await db_session.commit() await db_session.refresh(test_expense_split_user1_owes_self_for_completeness) await db_session.refresh(test_expense) # Refresh expense to reflect split status change activity_data = SettlementActivityCreateSchema( expense_split_id=test_expense_split_user2_owes.id, paid_by_user_id=test_user2.id, # User2 is paying their share amount_paid=Decimal("10.00") ) created_activity = await create_settlement_activity( db=db_session, settlement_activity_in=activity_data, current_user_id=test_user2.id # User2 is recording their own payment ) assert created_activity is not None assert created_activity.expense_split_id == test_expense_split_user2_owes.id assert created_activity.paid_by_user_id == test_user2.id assert created_activity.amount_paid == Decimal("10.00") assert created_activity.created_by_user_id == test_user2.id await db_session.refresh(test_expense_split_user2_owes) await db_session.refresh(test_expense) # Refresh to get updated overall_status assert test_expense_split_user2_owes.status == ExpenseSplitStatusEnum.paid assert test_expense_split_user2_owes.paid_at is not None # Check parent expense status # This depends on all splits being paid for the expense to be fully paid. assert test_expense.overall_settlement_status == ExpenseOverallStatusEnum.paid @pytest.mark.asyncio async def test_create_settlement_activity_partial_payment( db_session: AsyncSession, test_user1: User, # Creator of activity test_user2: User, # Payer of this settlement activity test_expense: Expense, test_expense_split_user2_owes: ExpenseSplit ): activity_data = SettlementActivityCreateSchema( expense_split_id=test_expense_split_user2_owes.id, paid_by_user_id=test_user2.id, amount_paid=Decimal("5.00") ) created_activity = await create_settlement_activity( db=db_session, settlement_activity_in=activity_data, current_user_id=test_user2.id # User2 records their payment ) assert created_activity is not None assert created_activity.amount_paid == Decimal("5.00") await db_session.refresh(test_expense_split_user2_owes) await db_session.refresh(test_expense) assert test_expense_split_user2_owes.status == ExpenseSplitStatusEnum.partially_paid assert test_expense_split_user2_owes.paid_at is None assert test_expense.overall_settlement_status == ExpenseOverallStatusEnum.partially_paid # Assuming other splits are unpaid or partially paid @pytest.mark.asyncio async def test_create_settlement_activity_multiple_payments_to_full( db_session: AsyncSession, test_user1: User, test_user2: User, test_expense: Expense, test_expense_split_user2_owes: ExpenseSplit, test_expense_split_user1_owes_self_for_completeness: ExpenseSplit # User1's own share ): # Assume user1's share is already 'paid' for overall expense status testing test_expense_split_user1_owes_self_for_completeness.status = ExpenseSplitStatusEnum.paid test_expense_split_user1_owes_self_for_completeness.paid_at = datetime.now(timezone.utc) db_session.add(test_expense_split_user1_owes_self_for_completeness) await db_session.commit() # First partial payment activity_data1 = SettlementActivityCreateSchema( expense_split_id=test_expense_split_user2_owes.id, paid_by_user_id=test_user2.id, amount_paid=Decimal("3.00") ) await create_settlement_activity(db=db_session, settlement_activity_in=activity_data1, current_user_id=test_user2.id) await db_session.refresh(test_expense_split_user2_owes) await db_session.refresh(test_expense) assert test_expense_split_user2_owes.status == ExpenseSplitStatusEnum.partially_paid assert test_expense.overall_settlement_status == ExpenseOverallStatusEnum.partially_paid # Second payment completing the amount activity_data2 = SettlementActivityCreateSchema( expense_split_id=test_expense_split_user2_owes.id, paid_by_user_id=test_user2.id, amount_paid=Decimal("7.00") # 3.00 + 7.00 = 10.00 ) await create_settlement_activity(db=db_session, settlement_activity_in=activity_data2, current_user_id=test_user2.id) await db_session.refresh(test_expense_split_user2_owes) await db_session.refresh(test_expense) assert test_expense_split_user2_owes.status == ExpenseSplitStatusEnum.paid assert test_expense_split_user2_owes.paid_at is not None assert test_expense.overall_settlement_status == ExpenseOverallStatusEnum.paid @pytest.mark.asyncio async def test_create_settlement_activity_invalid_split_id( db_session: AsyncSession, test_user1: User ): activity_data = SettlementActivityCreateSchema( expense_split_id=99999, # Non-existent paid_by_user_id=test_user1.id, amount_paid=Decimal("10.00") ) # The CRUD function returns None for not found related objects result = await create_settlement_activity(db=db_session, settlement_activity_in=activity_data, current_user_id=test_user1.id) assert result is None @pytest.mark.asyncio async def test_create_settlement_activity_invalid_paid_by_user_id( db_session: AsyncSession, test_user1: User, test_expense_split_user2_owes: ExpenseSplit ): activity_data = SettlementActivityCreateSchema( expense_split_id=test_expense_split_user2_owes.id, paid_by_user_id=99999, # Non-existent amount_paid=Decimal("10.00") ) result = await create_settlement_activity(db=db_session, settlement_activity_in=activity_data, current_user_id=test_user1.id) assert result is None # --- Tests for get_settlement_activity_by_id --- @pytest.mark.asyncio async def test_get_settlement_activity_by_id_found( db_session: AsyncSession, test_user2: User, test_expense_split_user2_owes: ExpenseSplit ): activity_data = SettlementActivityCreateSchema( expense_split_id=test_expense_split_user2_owes.id, paid_by_user_id=test_user2.id, amount_paid=Decimal("5.00") ) created = await create_settlement_activity(db=db_session, settlement_activity_in=activity_data, current_user_id=test_user2.id) assert created is not None fetched = await get_settlement_activity_by_id(db=db_session, settlement_activity_id=created.id) assert fetched is not None assert fetched.id == created.id assert fetched.amount_paid == Decimal("5.00") @pytest.mark.asyncio async def test_get_settlement_activity_by_id_not_found(db_session: AsyncSession): fetched = await get_settlement_activity_by_id(db=db_session, settlement_activity_id=99999) assert fetched is None # --- Tests for get_settlement_activities_for_split --- @pytest.mark.asyncio async def test_get_settlement_activities_for_split_multiple_found( db_session: AsyncSession, test_user2: User, test_expense_split_user2_owes: ExpenseSplit ): act1_data = SettlementActivityCreateSchema(expense_split_id=test_expense_split_user2_owes.id, paid_by_user_id=test_user2.id, amount_paid=Decimal("2.00")) act2_data = SettlementActivityCreateSchema(expense_split_id=test_expense_split_user2_owes.id, paid_by_user_id=test_user2.id, amount_paid=Decimal("3.00")) await create_settlement_activity(db=db_session, settlement_activity_in=act1_data, current_user_id=test_user2.id) await create_settlement_activity(db=db_session, settlement_activity_in=act2_data, current_user_id=test_user2.id) activities: List[SettlementActivity] = await get_settlement_activities_for_split(db=db_session, expense_split_id=test_expense_split_user2_owes.id) assert len(activities) == 2 amounts = sorted([act.amount_paid for act in activities]) assert amounts == [Decimal("2.00"), Decimal("3.00")] @pytest.mark.asyncio async def test_get_settlement_activities_for_split_none_found( db_session: AsyncSession, test_expense_split_user2_owes: ExpenseSplit # A split with no activities ): activities: List[SettlementActivity] = await get_settlement_activities_for_split(db=db_session, expense_split_id=test_expense_split_user2_owes.id) assert len(activities) == 0 # Note: Direct tests for helper functions update_expense_split_status and update_expense_overall_status # could be added if complex logic within them isn't fully covered by create_settlement_activity tests. # However, their effects are validated through the main CRUD function here. # For example, to test update_expense_split_status directly: # 1. Create an ExpenseSplit. # 2. Create one or more SettlementActivity instances directly in the DB session for that split. # 3. Call await update_expense_split_status(db_session, expense_split_id=split.id). # 4. Assert the split.status and split.paid_at are as expected. # Similar for update_expense_overall_status by setting up multiple splits. # For now, relying on indirect testing via create_settlement_activity. # More tests can be added for edge cases, such as: # - Overpayment (current logic in update_expense_split_status treats >= owed_amount as 'paid'). # - Different users creating the activity vs. paying for it (permission aspects, though that's more for API tests). # - Interactions with different expense split types if that affects status updates. # - Ensuring `overall_settlement_status` correctly reflects if one split is paid, another is unpaid, etc. # (e.g. test_expense_split_user1_owes_self_for_completeness is set to unpaid initially). # A test case where one split becomes 'paid' but another remains 'unpaid' should result in 'partially_paid' for the expense. @pytest.mark.asyncio async def test_create_settlement_activity_overall_status_becomes_partially_paid( db_session: AsyncSession, test_user1: User, test_user2: User, test_expense: Expense, # Overall status is initially unpaid test_expense_split_user2_owes: ExpenseSplit, # User2's split, initially unpaid test_expense_split_user1_owes_self_for_completeness: ExpenseSplit # User1's split, also initially unpaid ): # Sanity check: both splits and expense are unpaid initially assert test_expense_split_user2_owes.status == ExpenseSplitStatusEnum.unpaid assert test_expense_split_user1_owes_self_for_completeness.status == ExpenseSplitStatusEnum.unpaid assert test_expense.overall_settlement_status == ExpenseOverallStatusEnum.unpaid # User2 fully pays their 10.00 share. activity_data = SettlementActivityCreateSchema( expense_split_id=test_expense_split_user2_owes.id, paid_by_user_id=test_user2.id, # User2 is paying their share amount_paid=Decimal("10.00") ) await create_settlement_activity( db=db_session, settlement_activity_in=activity_data, current_user_id=test_user2.id # User2 is recording their own payment ) await db_session.refresh(test_expense_split_user2_owes) await db_session.refresh(test_expense_split_user1_owes_self_for_completeness) # Ensure its status is current await db_session.refresh(test_expense) assert test_expense_split_user2_owes.status == ExpenseSplitStatusEnum.paid assert test_expense_split_user1_owes_self_for_completeness.status == ExpenseSplitStatusEnum.unpaid # User1's split is still unpaid # Since one split is paid and the other is unpaid, the overall expense status should be partially_paid assert test_expense.overall_settlement_status == ExpenseOverallStatusEnum.partially_paid # Example of a placeholder for db_session fixture if not provided by conftest.py # @pytest.fixture # async def db_session() -> AsyncGenerator[AsyncSession, None]: # # This needs to be implemented based on your test database setup # # e.g., using a test-specific database and creating a new session per test # # from app.database import SessionLocal # Assuming SessionLocal is your session factory # # async with SessionLocal() as session: # # async with session.begin(): # Start a transaction # # yield session # # # Transaction will be rolled back here after the test # pass # Replace with actual implementation if needed