import pytest from fastapi import status from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from typing import Callable, Dict, Any from unittest.mock import patch, MagicMock from app.models import User as UserModel, Group as GroupModel, List as ListModel from app.schemas.expense import ExpenseCreate, ExpensePublic, ExpenseUpdate # from app.config import settings # Comment out the original import # Helper to create a URL for an endpoint # API_V1_STR = settings.API_V1_STR # Comment out the original assignment @pytest.fixture(scope="module") def mock_settings_financials(): mock_settings = MagicMock() mock_settings.API_V1_STR = "/api/v1" return mock_settings # Patch the settings in the test module @pytest.fixture(autouse=True) def patch_settings_financials(mock_settings_financials): with patch("app.config.settings", mock_settings_financials): yield def expense_url(endpoint: str = "") -> str: # Use the mocked API_V1_STR via the patched settings object from app.config import settings # Import settings here to use the patched version return f"{settings.API_V1_STR}/financials/expenses{endpoint}" def settlement_url(endpoint: str = "") -> str: # Use the mocked API_V1_STR via the patched settings object from app.config import settings # Import settings here to use the patched version return f"{settings.API_V1_STR}/financials/settlements{endpoint}" @pytest.mark.asyncio async def test_create_new_expense_success_list_context( client: AsyncClient, db_session: AsyncSession, normal_user_token_headers: Dict[str, str], test_user: UserModel, test_list_user_is_member: ListModel, ) -> None: expense_data = ExpenseCreate( description="Test Expense for List", amount=100.00, currency="USD", paid_by_user_id=test_user.id, list_id=test_list_user_is_member.id, group_id=None, ) response = await client.post( expense_url(), headers=normal_user_token_headers, json=expense_data.model_dump(exclude_unset=True) ) assert response.status_code == status.HTTP_201_CREATED content = response.json() assert content["description"] == expense_data.description assert content["amount"] == expense_data.amount assert content["currency"] == expense_data.currency assert content["paid_by_user_id"] == test_user.id assert content["list_id"] == test_list_user_is_member.id if test_list_user_is_member.group_id: assert content["group_id"] == test_list_user_is_member.group_id else: assert content["group_id"] is None assert "id" in content assert "created_at" in content assert "updated_at" in content assert "version" in content assert content["version"] == 1 @pytest.mark.asyncio async def test_create_new_expense_success_group_context( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_user: UserModel, test_group_user_is_member: GroupModel, ) -> None: expense_data = ExpenseCreate( description="Test Expense for Group", amount=50.00, currency="EUR", paid_by_user_id=test_user.id, group_id=test_group_user_is_member.id, list_id=None, ) response = await client.post( expense_url(), headers=normal_user_token_headers, json=expense_data.model_dump(exclude_unset=True) ) assert response.status_code == status.HTTP_201_CREATED content = response.json() assert content["description"] == expense_data.description assert content["paid_by_user_id"] == test_user.id assert content["group_id"] == test_group_user_is_member.id assert content["list_id"] is None assert content["version"] == 1 @pytest.mark.asyncio async def test_create_new_expense_fail_no_list_or_group( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_user: UserModel, ) -> None: expense_data = ExpenseCreate( description="Test Invalid Expense", amount=10.00, currency="USD", paid_by_user_id=test_user.id, list_id=None, group_id=None, ) response = await client.post( expense_url(), headers=normal_user_token_headers, json=expense_data.model_dump(exclude_unset=True) ) assert response.status_code == status.HTTP_400_BAD_REQUEST content = response.json() assert "Expense must be linked to a list_id or group_id" in content["detail"] @pytest.mark.asyncio async def test_create_new_expense_fail_paid_by_other_not_owner( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_user: UserModel, test_group_user_is_member: GroupModel, another_user_in_group: UserModel, ) -> None: expense_data = ExpenseCreate( description="Expense paid by other", amount=75.00, currency="GBP", paid_by_user_id=another_user_in_group.id, group_id=test_group_user_is_member.id, list_id=None, ) response = await client.post( expense_url(), headers=normal_user_token_headers, json=expense_data.model_dump(exclude_unset=True) ) assert response.status_code == status.HTTP_403_FORBIDDEN content = response.json() assert "Only group owners can create expenses paid by others" in content["detail"] @pytest.mark.asyncio async def test_get_expense_success( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_user: UserModel, created_expense: ExpensePublic, ) -> None: response = await client.get( expense_url(f"/{created_expense.id}"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_200_OK content = response.json() assert content["id"] == created_expense.id assert content["description"] == created_expense.description @pytest.mark.asyncio async def test_get_expense_not_found( client: AsyncClient, normal_user_token_headers: Dict[str, str], ) -> None: response = await client.get( expense_url("/999"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_404_NOT_FOUND content = response.json() assert "Expense not found" in content["detail"] @pytest.mark.asyncio async def test_get_expense_forbidden_personal_expense_other_user( client: AsyncClient, normal_user_token_headers: Dict[str, str], personal_expense_of_another_user: ExpensePublic, ) -> None: response = await client.get( expense_url(f"/{personal_expense_of_another_user.id}"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_403_FORBIDDEN content = response.json() assert "You do not have permission to access this expense" in content["detail"] @pytest.mark.asyncio async def test_get_expense_forbidden_not_member_of_list_or_group( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_user: UserModel, another_user: UserModel, expense_in_inaccessible_list_or_group: ExpensePublic, ) -> None: response = await client.get( expense_url(f"/{expense_in_inaccessible_list_or_group.id}"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_403_FORBIDDEN content = response.json() assert "You do not have permission to access this expense" in content["detail"] @pytest.mark.asyncio async def test_get_expense_success_in_list_user_has_access( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_user: UserModel, expense_in_accessible_list: ExpensePublic, test_list_user_is_member: ListModel, ) -> None: response = await client.get( expense_url(f"/{expense_in_accessible_list.id}"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_200_OK content = response.json() assert content["id"] == expense_in_accessible_list.id assert content["list_id"] == test_list_user_is_member.id @pytest.mark.asyncio async def test_get_expense_success_in_group_user_has_access( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_user: UserModel, expense_in_accessible_group: ExpensePublic, test_group_user_is_member: GroupModel, ) -> None: response = await client.get( expense_url(f"/{expense_in_accessible_group.id}"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_200_OK content = response.json() assert content["id"] == expense_in_accessible_group.id assert content["group_id"] == test_group_user_is_member.id @pytest.mark.asyncio async def test_list_list_expenses_success( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_user: UserModel, test_list_user_is_member: ListModel, ) -> None: response = await client.get( expense_url(f"?list_id={test_list_user_is_member.id}"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_200_OK content = response.json() assert isinstance(content, list) for expense in content: assert expense["list_id"] == test_list_user_is_member.id @pytest.mark.asyncio async def test_list_list_expenses_list_not_found( client: AsyncClient, normal_user_token_headers: Dict[str, str], ) -> None: response = await client.get( expense_url("?list_id=999"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_404_NOT_FOUND content = response.json() assert "List not found" in content["detail"] @pytest.mark.asyncio async def test_list_list_expenses_no_access( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_list_user_not_member: ListModel, ) -> None: response = await client.get( expense_url(f"?list_id={test_list_user_not_member.id}"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_403_FORBIDDEN content = response.json() assert "You do not have permission to access this list" in content["detail"] @pytest.mark.asyncio async def test_list_list_expenses_empty( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_list_user_is_member_no_expenses: ListModel, ) -> None: response = await client.get( expense_url(f"?list_id={test_list_user_is_member_no_expenses.id}"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_200_OK content = response.json() assert isinstance(content, list) assert len(content) == 0 @pytest.mark.asyncio async def test_list_list_expenses_pagination( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_user: UserModel, test_list_with_multiple_expenses: ListModel, created_expenses_for_list: list[ExpensePublic], ) -> None: # Test first page response = await client.get( expense_url(f"?list_id={test_list_with_multiple_expenses.id}&skip=0&limit=2"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_200_OK content = response.json() assert isinstance(content, list) assert len(content) == 2 assert content[0]["id"] == created_expenses_for_list[0].id assert content[1]["id"] == created_expenses_for_list[1].id # Test second page response = await client.get( expense_url(f"?list_id={test_list_with_multiple_expenses.id}&skip=2&limit=2"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_200_OK content = response.json() assert isinstance(content, list) assert len(content) == 2 assert content[0]["id"] == created_expenses_for_list[2].id assert content[1]["id"] == created_expenses_for_list[3].id @pytest.mark.asyncio async def test_list_group_expenses_success( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_user: UserModel, test_group_user_is_member: GroupModel, ) -> None: response = await client.get( expense_url(f"?group_id={test_group_user_is_member.id}"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_200_OK content = response.json() assert isinstance(content, list) for expense in content: assert expense["group_id"] == test_group_user_is_member.id @pytest.mark.asyncio async def test_list_group_expenses_group_not_found( client: AsyncClient, normal_user_token_headers: Dict[str, str], ) -> None: response = await client.get( expense_url("?group_id=999"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_404_NOT_FOUND content = response.json() assert "Group not found" in content["detail"] @pytest.mark.asyncio async def test_list_group_expenses_no_access( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_group_user_not_member: GroupModel, ) -> None: response = await client.get( expense_url(f"?group_id={test_group_user_not_member.id}"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_403_FORBIDDEN content = response.json() assert "You do not have permission to access this group" in content["detail"] @pytest.mark.asyncio async def test_list_group_expenses_empty( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_group_user_is_member_no_expenses: GroupModel, ) -> None: response = await client.get( expense_url(f"?group_id={test_group_user_is_member_no_expenses.id}"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_200_OK content = response.json() assert isinstance(content, list) assert len(content) == 0 @pytest.mark.asyncio async def test_list_group_expenses_pagination( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_user: UserModel, test_group_with_multiple_expenses: GroupModel, created_expenses_for_group: list[ExpensePublic], ) -> None: # Test first page response = await client.get( expense_url(f"?group_id={test_group_with_multiple_expenses.id}&skip=0&limit=2"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_200_OK content = response.json() assert isinstance(content, list) assert len(content) == 2 assert content[0]["id"] == created_expenses_for_group[0].id assert content[1]["id"] == created_expenses_for_group[1].id # Test second page response = await client.get( expense_url(f"?group_id={test_group_with_multiple_expenses.id}&skip=2&limit=2"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_200_OK content = response.json() assert isinstance(content, list) assert len(content) == 2 assert content[0]["id"] == created_expenses_for_group[2].id assert content[1]["id"] == created_expenses_for_group[3].id @pytest.mark.asyncio async def test_update_expense_success_payer_updates_details( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_user: UserModel, expense_paid_by_test_user: ExpensePublic, ) -> None: update_data = ExpenseUpdate( description="Updated expense description", version=expense_paid_by_test_user.version, ) response = await client.put( expense_url(f"/{expense_paid_by_test_user.id}"), headers=normal_user_token_headers, json=update_data.model_dump(exclude_unset=True) ) assert response.status_code == status.HTTP_200_OK content = response.json() assert content["description"] == update_data.description assert content["version"] == expense_paid_by_test_user.version + 1 @pytest.mark.asyncio async def test_update_expense_success_group_owner_updates_others_expense( client: AsyncClient, group_owner_token_headers: Dict[str, str], group_owner: UserModel, expense_paid_by_another_in_group_where_test_user_is_owner: ExpensePublic, another_user_in_group: UserModel, ) -> None: update_data = ExpenseUpdate( description="Updated by group owner", version=expense_paid_by_another_in_group_where_test_user_is_owner.version, ) response = await client.put( expense_url(f"/{expense_paid_by_another_in_group_where_test_user_is_owner.id}"), headers=group_owner_token_headers, json=update_data.model_dump(exclude_unset=True) ) assert response.status_code == status.HTTP_200_OK content = response.json() assert content["description"] == update_data.description assert content["version"] == expense_paid_by_another_in_group_where_test_user_is_owner.version + 1 @pytest.mark.asyncio async def test_update_expense_fail_not_payer_nor_group_owner( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_user: UserModel, expense_paid_by_another_in_group_where_test_user_is_member: ExpensePublic, another_user_in_group: UserModel, ) -> None: update_data = ExpenseUpdate( description="Attempted update by non-owner", version=expense_paid_by_another_in_group_where_test_user_is_member.version, ) response = await client.put( expense_url(f"/{expense_paid_by_another_in_group_where_test_user_is_member.id}"), headers=normal_user_token_headers, json=update_data.model_dump(exclude_unset=True) ) assert response.status_code == status.HTTP_403_FORBIDDEN content = response.json() assert "You do not have permission to update this expense" in content["detail"] @pytest.mark.asyncio async def test_update_expense_fail_not_found( client: AsyncClient, normal_user_token_headers: Dict[str, str], ) -> None: update_data = ExpenseUpdate( description="Update attempt on non-existent expense", version=1, ) response = await client.put( expense_url("/999"), headers=normal_user_token_headers, json=update_data.model_dump(exclude_unset=True) ) assert response.status_code == status.HTTP_404_NOT_FOUND content = response.json() assert "Expense not found" in content["detail"] @pytest.mark.asyncio async def test_update_expense_fail_change_paid_by_user_not_owner( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_user: UserModel, expense_paid_by_test_user_in_group: ExpensePublic, another_user_in_same_group: UserModel, ) -> None: update_data = ExpenseUpdate( paid_by_user_id=another_user_in_same_group.id, version=expense_paid_by_test_user_in_group.version, ) response = await client.put( expense_url(f"/{expense_paid_by_test_user_in_group.id}"), headers=normal_user_token_headers, json=update_data.model_dump(exclude_unset=True) ) assert response.status_code == status.HTTP_403_FORBIDDEN content = response.json() assert "Only group owners can change the payer of an expense" in content["detail"] @pytest.mark.asyncio async def test_update_expense_success_owner_changes_paid_by_user( client: AsyncClient, group_owner_token_headers: Dict[str, str], group_owner: UserModel, expense_in_group_owner_group: ExpensePublic, another_user_in_same_group: UserModel, ) -> None: update_data = ExpenseUpdate( paid_by_user_id=another_user_in_same_group.id, version=expense_in_group_owner_group.version, ) response = await client.put( expense_url(f"/{expense_in_group_owner_group.id}"), headers=group_owner_token_headers, json=update_data.model_dump(exclude_unset=True) ) assert response.status_code == status.HTTP_200_OK content = response.json() assert content["paid_by_user_id"] == another_user_in_same_group.id assert content["version"] == expense_in_group_owner_group.version + 1 @pytest.mark.asyncio async def test_delete_expense_success_payer( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_user: UserModel, expense_paid_by_test_user: ExpensePublic, ) -> None: response = await client.delete( expense_url(f"/{expense_paid_by_test_user.id}"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_204_NO_CONTENT @pytest.mark.asyncio async def test_delete_expense_success_group_owner( client: AsyncClient, group_owner_token_headers: Dict[str, str], group_owner: UserModel, expense_paid_by_another_in_group_where_test_user_is_owner: ExpensePublic, ) -> None: response = await client.delete( expense_url(f"/{expense_paid_by_another_in_group_where_test_user_is_owner.id}"), headers=group_owner_token_headers ) assert response.status_code == status.HTTP_204_NO_CONTENT @pytest.mark.asyncio async def test_delete_expense_fail_not_payer_nor_group_owner( client: AsyncClient, normal_user_token_headers: Dict[str, str], test_user: UserModel, expense_paid_by_another_in_group_where_test_user_is_member: ExpensePublic, ) -> None: response = await client.delete( expense_url(f"/{expense_paid_by_another_in_group_where_test_user_is_member.id}"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_403_FORBIDDEN content = response.json() assert "You do not have permission to delete this expense" in content["detail"] @pytest.mark.asyncio async def test_delete_expense_fail_not_found( client: AsyncClient, normal_user_token_headers: Dict[str, str], ) -> None: response = await client.delete( expense_url("/999"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_404_NOT_FOUND content = response.json() assert "Expense not found" in content["detail"] @pytest.mark.asyncio async def test_delete_expense_idempotency( client: AsyncClient, normal_user_token_headers: Dict[str, str], expense_paid_by_test_user: ExpensePublic, ) -> None: # First delete response = await client.delete( expense_url(f"/{expense_paid_by_test_user.id}"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_204_NO_CONTENT # Second delete should also succeed response = await client.delete( expense_url(f"/{expense_paid_by_test_user.id}"), headers=normal_user_token_headers ) assert response.status_code == status.HTTP_204_NO_CONTENT # GET /settlements/{settlement_id} # POST /settlements # GET /groups/{group_id}/settlements # PUT /settlements/{settlement_id} # DELETE /settlements/{settlement_id}