mitlist/be/tests/api/v1/endpoints/test_financials.py

649 lines
22 KiB
Python

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}