import pytest import httpx from typing import List, Dict, Any from decimal import Decimal from datetime import datetime, timezone from app.models import ( User, Group, Expense, ExpenseSplit, SettlementActivity, UserRoleEnum, SplitTypeEnum, ExpenseOverallStatusEnum, ExpenseSplitStatusEnum ) from app.schemas.settlement_activity import SettlementActivityPublic, SettlementActivityCreate from app.schemas.expense import ExpensePublic, ExpenseSplitPublic from app.core.config import settings # For API prefix # Assume db_session, event_loop, client are provided by conftest.py or similar setup # For this example, I'll define basic user/auth fixtures if not assumed from conftest @pytest.fixture async def test_user1_api(db_session, client: httpx.AsyncClient) -> Dict[str, Any]: user = User(email="api.user1@example.com", name="API User 1", hashed_password="password1") db_session.add(user) await db_session.commit() await db_session.refresh(user) # Simulate token login - in a real setup, you'd call a login endpoint # For now, just returning user and headers directly for mock authentication # This would typically be handled by a dependency override in tests # For simplicity, we'll assume current_active_user dependency correctly resolves to this user # when these headers are used (or mock the dependency). return {"user": user, "headers": {"Authorization": f"Bearer token-for-{user.id}"}} @pytest.fixture async def test_user2_api(db_session, client: httpx.AsyncClient) -> Dict[str, Any]: user = User(email="api.user2@example.com", name="API User 2", hashed_password="password2") db_session.add(user) await db_session.commit() await db_session.refresh(user) return {"user": user, "headers": {"Authorization": f"Bearer token-for-{user.id}"}} @pytest.fixture async def test_group_user1_owner_api(db_session, test_user1_api: Dict[str, Any]) -> Group: user1 = test_user1_api["user"] group = Group(name="API Test Group", created_by_id=user1.id) db_session.add(group) await db_session.flush() # Get group.id # Add user1 as owner from app.models import UserGroup user_group_assoc = UserGroup(user_id=user1.id, group_id=group.id, role=UserRoleEnum.owner) db_session.add(user_group_assoc) await db_session.commit() await db_session.refresh(group) return group @pytest.fixture async def test_expense_in_group_api(db_session, test_user1_api: Dict[str, Any], test_group_user1_owner_api: Group) -> Expense: user1 = test_user1_api["user"] expense = Expense( description="Group API Expense", total_amount=Decimal("50.00"), currency="USD", group_id=test_group_user1_owner_api.id, paid_by_user_id=user1.id, created_by_user_id=user1.id, split_type=SplitTypeEnum.EQUAL, overall_settlement_status=ExpenseOverallStatusEnum.unpaid ) db_session.add(expense) await db_session.commit() await db_session.refresh(expense) return expense @pytest.fixture async def test_expense_split_for_user2_api(db_session, test_expense_in_group_api: Expense, test_user1_api: Dict[str, Any], test_user2_api: Dict[str, Any]) -> ExpenseSplit: user1 = test_user1_api["user"] user2 = test_user2_api["user"] # Split for User 1 (payer) split1 = ExpenseSplit( expense_id=test_expense_in_group_api.id, user_id=user1.id, owed_amount=Decimal("25.00"), status=ExpenseSplitStatusEnum.unpaid ) # Split for User 2 (owes) split2 = ExpenseSplit( expense_id=test_expense_in_group_api.id, user_id=user2.id, owed_amount=Decimal("25.00"), status=ExpenseSplitStatusEnum.unpaid ) db_session.add_all([split1, split2]) # Add user2 to the group as a member for permission checks from app.models import UserGroup user_group_assoc = UserGroup(user_id=user2.id, group_id=test_expense_in_group_api.group_id, role=UserRoleEnum.member) db_session.add(user_group_assoc) await db_session.commit() await db_session.refresh(split1) await db_session.refresh(split2) return split2 # Return the split that user2 owes # --- Tests for POST /expense_splits/{expense_split_id}/settle --- @pytest.mark.asyncio async def test_settle_expense_split_by_self_success( client: httpx.AsyncClient, test_user2_api: Dict[str, Any], # User2 will settle their own split test_expense_split_for_user2_api: ExpenseSplit, db_session: AsyncSession # To verify db changes ): user2 = test_user2_api["user"] user2_headers = test_user2_api["headers"] split_to_settle = test_expense_split_for_user2_api payload = SettlementActivityCreate( expense_split_id=split_to_settle.id, paid_by_user_id=user2.id, # User2 is paying amount_paid=split_to_settle.owed_amount # Full payment ) response = await client.post( f"{settings.API_V1_STR}/expense_splits/{split_to_settle.id}/settle", json=payload.model_dump(mode='json'), # Pydantic v2 headers=user2_headers ) assert response.status_code == 201 activity_data = response.json() assert activity_data["amount_paid"] == str(split_to_settle.owed_amount) # Compare as string due to JSON assert activity_data["paid_by_user_id"] == user2.id assert activity_data["expense_split_id"] == split_to_settle.id assert "id" in activity_data # Verify DB state await db_session.refresh(split_to_settle) assert split_to_settle.status == ExpenseSplitStatusEnum.paid assert split_to_settle.paid_at is not None # Verify parent expense status (this requires other splits to be paid too) # For a focused test, we might need to ensure the other split (user1's share) is also paid. # Or, accept 'partially_paid' if only this one is paid. parent_expense_id = split_to_settle.expense_id parent_expense = await db_session.get(Expense, parent_expense_id) await db_session.refresh(parent_expense, attribute_names=['splits']) # Load splits to check status all_splits_paid = all(s.status == ExpenseSplitStatusEnum.paid for s in parent_expense.splits) if all_splits_paid: assert parent_expense.overall_settlement_status == ExpenseOverallStatusEnum.paid else: assert parent_expense.overall_settlement_status == ExpenseOverallStatusEnum.partially_paid @pytest.mark.asyncio async def test_settle_expense_split_by_group_owner_success( client: httpx.AsyncClient, test_user1_api: Dict[str, Any], # User1 is group owner test_user2_api: Dict[str, Any], # User2 owes the split test_expense_split_for_user2_api: ExpenseSplit, db_session: AsyncSession ): user1_headers = test_user1_api["headers"] user_who_owes = test_user2_api["user"] split_to_settle = test_expense_split_for_user2_api payload = SettlementActivityCreate( expense_split_id=split_to_settle.id, paid_by_user_id=user_who_owes.id, # User1 (owner) records that User2 has paid amount_paid=split_to_settle.owed_amount ) response = await client.post( f"{settings.API_V1_STR}/expense_splits/{split_to_settle.id}/settle", json=payload.model_dump(mode='json'), headers=user1_headers # Authenticated as group owner ) assert response.status_code == 201 activity_data = response.json() assert activity_data["paid_by_user_id"] == user_who_owes.id assert activity_data["created_by_user_id"] == test_user1_api["user"].id # Activity created by owner await db_session.refresh(split_to_settle) assert split_to_settle.status == ExpenseSplitStatusEnum.paid @pytest.mark.asyncio async def test_settle_expense_split_path_body_id_mismatch( client: httpx.AsyncClient, test_user2_api: Dict[str, Any], test_expense_split_for_user2_api: ExpenseSplit ): user2_headers = test_user2_api["headers"] split_to_settle = test_expense_split_for_user2_api payload = SettlementActivityCreate( expense_split_id=split_to_settle.id + 1, # Mismatch paid_by_user_id=test_user2_api["user"].id, amount_paid=split_to_settle.owed_amount ) response = await client.post( f"{settings.API_V1_STR}/expense_splits/{split_to_settle.id}/settle", json=payload.model_dump(mode='json'), headers=user2_headers ) assert response.status_code == 400 # As per API endpoint logic @pytest.mark.asyncio async def test_settle_expense_split_not_found( client: httpx.AsyncClient, test_user2_api: Dict[str, Any] ): user2_headers = test_user2_api["headers"] payload = SettlementActivityCreate(expense_split_id=9999, paid_by_user_id=test_user2_api["user"].id, amount_paid=Decimal("10.00")) response = await client.post( f"{settings.API_V1_STR}/expense_splits/9999/settle", json=payload.model_dump(mode='json'), headers=user2_headers ) assert response.status_code == 404 # ItemNotFoundError @pytest.mark.asyncio async def test_settle_expense_split_insufficient_permissions( client: httpx.AsyncClient, test_user1_api: Dict[str, Any], # User1 is not group owner for this setup, nor involved in split test_user2_api: Dict[str, Any], test_expense_split_for_user2_api: ExpenseSplit, # User2 owes this db_session: AsyncSession ): # Create a new user (user3) who is not involved and not an owner user3 = User(email="api.user3@example.com", name="API User 3", hashed_password="password3") db_session.add(user3) await db_session.commit() user3_headers = {"Authorization": f"Bearer token-for-{user3.id}"} split_owner = test_user2_api["user"] # User2 owns the split split_to_settle = test_expense_split_for_user2_api payload = SettlementActivityCreate( expense_split_id=split_to_settle.id, paid_by_user_id=split_owner.id, # User2 is paying amount_paid=split_to_settle.owed_amount ) # User3 (neither payer nor group owner) tries to record User2's payment response = await client.post( f"{settings.API_V1_STR}/expense_splits/{split_to_settle.id}/settle", json=payload.model_dump(mode='json'), headers=user3_headers # Authenticated as User3 ) assert response.status_code == 403 # --- Tests for GET /expense_splits/{expense_split_id}/settlement_activities --- @pytest.mark.asyncio async def test_get_settlement_activities_success( client: httpx.AsyncClient, test_user1_api: Dict[str, Any], # Group owner / expense creator test_user2_api: Dict[str, Any], # User who owes and pays test_expense_split_for_user2_api: ExpenseSplit, db_session: AsyncSession ): user1_headers = test_user1_api["headers"] user2 = test_user2_api["user"] split = test_expense_split_for_user2_api # Create a settlement activity first activity_payload = SettlementActivityCreate(expense_split_id=split.id, paid_by_user_id=user2.id, amount_paid=Decimal("10.00")) await client.post( f"{settings.API_V1_STR}/expense_splits/{split.id}/settle", json=activity_payload.model_dump(mode='json'), headers=test_user2_api["headers"] # User2 settles ) # User1 (group owner) fetches activities response = await client.get( f"{settings.API_V1_STR}/expense_splits/{split.id}/settlement_activities", headers=user1_headers ) assert response.status_code == 200 activities_data = response.json() assert isinstance(activities_data, list) assert len(activities_data) == 1 assert activities_data[0]["amount_paid"] == "10.00" assert activities_data[0]["paid_by_user_id"] == user2.id @pytest.mark.asyncio async def test_get_settlement_activities_split_not_found( client: httpx.AsyncClient, test_user1_api: Dict[str, Any] ): user1_headers = test_user1_api["headers"] response = await client.get( f"{settings.API_V1_STR}/expense_splits/9999/settlement_activities", headers=user1_headers ) assert response.status_code == 404 @pytest.mark.asyncio async def test_get_settlement_activities_no_permission( client: httpx.AsyncClient, test_expense_split_for_user2_api: ExpenseSplit, # Belongs to group of user1/user2 db_session: AsyncSession ): # Create a new user (user3) who is not in the group user3 = User(email="api.user3.other@example.com", name="API User 3 Other", hashed_password="password3") db_session.add(user3) await db_session.commit() user3_headers = {"Authorization": f"Bearer token-for-{user3.id}"} response = await client.get( f"{settings.API_V1_STR}/expense_splits/{test_expense_split_for_user2_api.id}/settlement_activities", headers=user3_headers # Authenticated as User3 ) assert response.status_code == 403 # --- Test existing expense endpoints for new fields --- @pytest.mark.asyncio async def test_get_expense_by_id_includes_new_fields( client: httpx.AsyncClient, test_user1_api: Dict[str, Any], # User in group test_expense_in_group_api: Expense, test_expense_split_for_user2_api: ExpenseSplit # one of the splits ): user1_headers = test_user1_api["headers"] expense_id = test_expense_in_group_api.id response = await client.get(f"{settings.API_V1_STR}/expenses/{expense_id}", headers=user1_headers) assert response.status_code == 200 expense_data = response.json() assert "overall_settlement_status" in expense_data assert expense_data["overall_settlement_status"] == ExpenseOverallStatusEnum.unpaid.value # Initial state assert "splits" in expense_data assert len(expense_data["splits"]) > 0 found_split = False for split_json in expense_data["splits"]: if split_json["id"] == test_expense_split_for_user2_api.id: found_split = True assert "status" in split_json assert split_json["status"] == ExpenseSplitStatusEnum.unpaid.value # Initial state assert "paid_at" in split_json # Should be null initially assert split_json["paid_at"] is None assert "settlement_activities" in split_json assert isinstance(split_json["settlement_activities"], list) assert len(split_json["settlement_activities"]) == 0 # No activities yet break assert found_split, "The specific test split was not found in the expense data." # Placeholder for conftest.py content if needed for local execution understanding """ # conftest.py (example structure) import pytest import asyncio from httpx import AsyncClient from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.orm import sessionmaker from app.main import app # Your FastAPI app from app.database import Base, get_transactional_session # Your DB setup TEST_DATABASE_URL = "sqlite+aiosqlite:///./test.db" engine = create_async_engine(TEST_DATABASE_URL, echo=True) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, class_=AsyncSession) @pytest.fixture(scope="session") def event_loop(): loop = asyncio.get_event_loop_policy().new_event_loop() yield loop loop.close() @pytest.fixture(scope="session", autouse=True) async def setup_db(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) yield async with engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) @pytest.fixture async def db_session() -> AsyncSession: async with TestingSessionLocal() as session: # Transaction is handled by get_transactional_session override or test logic yield session # Rollback changes after test if not using transactional tests per case # await session.rollback() # Or rely on test isolation method @pytest.fixture async def client(db_session) -> AsyncClient: # Depends on db_session to ensure DB is ready async def override_get_transactional_session(): # Provide the test session, potentially managing transactions per test # This is a simplified version; real setup might involve nested transactions # or ensuring each test runs in its own transaction that's rolled back. try: yield db_session # await db_session.commit() # Or commit if test is meant to persist then rollback globally except Exception: # await db_session.rollback() raise # finally: # await db_session.rollback() # Ensure rollback after each test using this fixture app.dependency_overrides[get_transactional_session] = override_get_transactional_session async with AsyncClient(app=app, base_url="http://test") as c: yield c del app.dependency_overrides[get_transactional_session] # Clean up """