Files
fast-next-template/backend/tests/api/test_organizations.py
Felipe Cardoso c589b565f0 Add pyproject.toml for consolidated project configuration and replace Black, isort, and Flake8 with Ruff
- Introduced `pyproject.toml` to centralize backend tool configurations (e.g., Ruff, mypy, coverage, pytest).
- Replaced Black, isort, and Flake8 with Ruff for linting, formatting, and import sorting.
- Updated `requirements.txt` to include Ruff and remove replaced tools.
- Added `Makefile` to streamline development workflows with commands for linting, formatting, type-checking, testing, and cleanup.
2025-11-10 11:55:15 +01:00

607 lines
21 KiB
Python

# tests/api/test_organizations.py
"""
Tests for organization routes (user endpoints).
These test the routes in app/api/routes/organizations.py which allow
users to view and manage organizations they belong to.
"""
from unittest.mock import patch
from uuid import uuid4
import pytest
import pytest_asyncio
from fastapi import status
from app.core.auth import get_password_hash
from app.models.organization import Organization
from app.models.user import User
from app.models.user_organization import OrganizationRole, UserOrganization
@pytest_asyncio.fixture
async def user_token(client, async_test_user):
"""Get access token for regular user."""
response = await client.post(
"/api/v1/auth/login",
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
assert response.status_code == 200
return response.json()["access_token"]
@pytest_asyncio.fixture
async def second_user(async_test_db):
"""Create a second test user."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
user = User(
id=uuid4(),
email="seconduser@example.com",
password_hash=get_password_hash("TestPassword123!"),
first_name="Second",
last_name="User",
phone_number="+1234567891",
is_active=True,
is_superuser=False,
preferences=None,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
@pytest_asyncio.fixture
async def test_org_with_user_member(async_test_db, async_test_user):
"""Create a test organization with async_test_user as a member."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
org = Organization(
name="Member Org",
slug="member-org",
description="Test organization where user is a member",
)
session.add(org)
await session.commit()
await session.refresh(org)
# Add user as member
membership = UserOrganization(
user_id=async_test_user.id,
organization_id=org.id,
role=OrganizationRole.MEMBER,
is_active=True,
)
session.add(membership)
await session.commit()
return org
@pytest_asyncio.fixture
async def test_org_with_user_admin(async_test_db, async_test_user):
"""Create a test organization with async_test_user as an admin."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
org = Organization(
name="Admin Org",
slug="admin-org",
description="Test organization where user is an admin",
)
session.add(org)
await session.commit()
await session.refresh(org)
# Add user as admin
membership = UserOrganization(
user_id=async_test_user.id,
organization_id=org.id,
role=OrganizationRole.ADMIN,
is_active=True,
)
session.add(membership)
await session.commit()
return org
@pytest_asyncio.fixture
async def test_org_with_user_owner(async_test_db, async_test_user):
"""Create a test organization with async_test_user as owner."""
_test_engine, AsyncTestingSessionLocal = async_test_db
async with AsyncTestingSessionLocal() as session:
org = Organization(
name="Owner Org",
slug="owner-org",
description="Test organization where user is owner",
)
session.add(org)
await session.commit()
await session.refresh(org)
# Add user as owner
membership = UserOrganization(
user_id=async_test_user.id,
organization_id=org.id,
role=OrganizationRole.OWNER,
is_active=True,
)
session.add(membership)
await session.commit()
return org
# ===== GET /api/v1/organizations/me =====
class TestGetMyOrganizations:
"""Tests for GET /api/v1/organizations/me endpoint."""
@pytest.mark.asyncio
async def test_get_my_organizations_success(
self, client, user_token, test_org_with_user_member, test_org_with_user_admin
):
"""Test successfully getting user's organizations (covers lines 54-79)."""
response = await client.get(
"/api/v1/organizations/me",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert isinstance(data, list)
assert len(data) >= 2 # At least the two test orgs
# Verify structure
for org in data:
assert "id" in org
assert "name" in org
assert "slug" in org
assert "member_count" in org
@pytest.mark.asyncio
async def test_get_my_organizations_filter_active(
self, client, async_test_db, async_test_user, user_token
):
"""Test filtering organizations by active status."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create active org
async with AsyncTestingSessionLocal() as session:
active_org = Organization(
name="Active Org", slug="active-org-filter", is_active=True
)
session.add(active_org)
await session.commit()
await session.refresh(active_org)
# Add user membership
membership = UserOrganization(
user_id=async_test_user.id,
organization_id=active_org.id,
role=OrganizationRole.MEMBER,
is_active=True,
)
session.add(membership)
await session.commit()
response = await client.get(
"/api/v1/organizations/me?is_active=true",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert isinstance(data, list)
@pytest.mark.asyncio
async def test_get_my_organizations_empty(self, client, async_test_db):
"""Test getting organizations when user has none."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create user with no org memberships
async with AsyncTestingSessionLocal() as session:
user = User(
id=uuid4(),
email="noorg@example.com",
password_hash=get_password_hash("TestPassword123!"),
first_name="No",
last_name="Org",
is_active=True,
)
session.add(user)
await session.commit()
# Login to get token
login_response = await client.post(
"/api/v1/auth/login",
json={"email": "noorg@example.com", "password": "TestPassword123!"},
)
token = login_response.json()["access_token"]
response = await client.get(
"/api/v1/organizations/me", headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data == []
# ===== GET /api/v1/organizations/{organization_id} =====
class TestGetOrganization:
"""Tests for GET /api/v1/organizations/{organization_id} endpoint."""
@pytest.mark.asyncio
async def test_get_organization_success(
self, client, user_token, test_org_with_user_member
):
"""Test successfully getting organization details (covers lines 103-122)."""
response = await client.get(
f"/api/v1/organizations/{test_org_with_user_member.id}",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["id"] == str(test_org_with_user_member.id)
assert data["name"] == "Member Org"
assert data["slug"] == "member-org"
assert "member_count" in data
@pytest.mark.asyncio
async def test_get_organization_not_found(self, client, user_token):
"""Test getting nonexistent organization returns 403 (permission check happens first)."""
fake_org_id = uuid4()
response = await client.get(
f"/api/v1/organizations/{fake_org_id}",
headers={"Authorization": f"Bearer {user_token}"},
)
# Permission dependency checks membership before endpoint logic
# So non-existent org returns 403 (not a member) instead of 404
assert response.status_code == status.HTTP_403_FORBIDDEN
data = response.json()
assert "errors" in data or "detail" in data
@pytest.mark.asyncio
async def test_get_organization_not_member(
self, client, async_test_db, async_test_user
):
"""Test getting organization where user is not a member fails."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Create org without adding user
async with AsyncTestingSessionLocal() as session:
org = Organization(name="Not Member Org", slug="not-member-org")
session.add(org)
await session.commit()
await session.refresh(org)
org_id = org.id
# Login as user
login_response = await client.post(
"/api/v1/auth/login",
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
token = login_response.json()["access_token"]
response = await client.get(
f"/api/v1/organizations/{org_id}",
headers={"Authorization": f"Bearer {token}"},
)
# Should fail permission check
assert response.status_code == status.HTTP_403_FORBIDDEN
# ===== GET /api/v1/organizations/{organization_id}/members =====
class TestGetOrganizationMembers:
"""Tests for GET /api/v1/organizations/{organization_id}/members endpoint."""
@pytest.mark.asyncio
async def test_get_organization_members_success(
self,
client,
async_test_db,
async_test_user,
second_user,
user_token,
test_org_with_user_member,
):
"""Test successfully getting organization members (covers lines 150-168)."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Add second user to org
async with AsyncTestingSessionLocal() as session:
membership = UserOrganization(
user_id=second_user.id,
organization_id=test_org_with_user_member.id,
role=OrganizationRole.MEMBER,
is_active=True,
)
session.add(membership)
await session.commit()
response = await client.get(
f"/api/v1/organizations/{test_org_with_user_member.id}/members",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert "data" in data
assert "pagination" in data
assert len(data["data"]) >= 2 # At least the two users
@pytest.mark.asyncio
async def test_get_organization_members_with_pagination(
self, client, user_token, test_org_with_user_member
):
"""Test pagination parameters."""
response = await client.get(
f"/api/v1/organizations/{test_org_with_user_member.id}/members?page=1&limit=10",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["pagination"]["page"] == 1
assert "page_size" in data["pagination"] # Uses page_size, not limit
assert "total" in data["pagination"]
@pytest.mark.asyncio
async def test_get_organization_members_filter_active(
self,
client,
async_test_db,
async_test_user,
second_user,
user_token,
test_org_with_user_member,
):
"""Test filtering members by active status."""
_test_engine, AsyncTestingSessionLocal = async_test_db
# Add second user as inactive member
async with AsyncTestingSessionLocal() as session:
membership = UserOrganization(
user_id=second_user.id,
organization_id=test_org_with_user_member.id,
role=OrganizationRole.MEMBER,
is_active=False,
)
session.add(membership)
await session.commit()
# Filter for active only
response = await client.get(
f"/api/v1/organizations/{test_org_with_user_member.id}/members?is_active=true",
headers={"Authorization": f"Bearer {user_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
# Should only see active members
for member in data["data"]:
assert member["is_active"] is True
# ===== PUT /api/v1/organizations/{organization_id} =====
class TestUpdateOrganization:
"""Tests for PUT /api/v1/organizations/{organization_id} endpoint."""
@pytest.mark.asyncio
async def test_update_organization_as_admin_success(
self, client, async_test_user, test_org_with_user_admin
):
"""Test successfully updating organization as admin (covers lines 193-215)."""
# Login as admin user
login_response = await client.post(
"/api/v1/auth/login",
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
admin_token = login_response.json()["access_token"]
response = await client.put(
f"/api/v1/organizations/{test_org_with_user_admin.id}",
json={"name": "Updated Admin Org", "description": "Updated description"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["name"] == "Updated Admin Org"
assert data["description"] == "Updated description"
@pytest.mark.asyncio
async def test_update_organization_as_owner_success(
self, client, async_test_user, test_org_with_user_owner
):
"""Test successfully updating organization as owner."""
# Login as owner user
login_response = await client.post(
"/api/v1/auth/login",
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
owner_token = login_response.json()["access_token"]
response = await client.put(
f"/api/v1/organizations/{test_org_with_user_owner.id}",
json={"name": "Updated Owner Org"},
headers={"Authorization": f"Bearer {owner_token}"},
)
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["name"] == "Updated Owner Org"
@pytest.mark.asyncio
async def test_update_organization_as_member_fails(
self, client, user_token, test_org_with_user_member
):
"""Test updating organization as regular member fails."""
response = await client.put(
f"/api/v1/organizations/{test_org_with_user_member.id}",
json={"name": "Should Fail"},
headers={"Authorization": f"Bearer {user_token}"},
)
# Should fail permission check (need admin or owner)
assert response.status_code == status.HTTP_403_FORBIDDEN
@pytest.mark.asyncio
async def test_update_organization_not_found(
self, client, test_org_with_user_admin
):
"""Test updating nonexistent organization returns 403 (permission check first)."""
# Login as admin
login_response = await client.post(
"/api/v1/auth/login",
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
admin_token = login_response.json()["access_token"]
fake_org_id = uuid4()
response = await client.put(
f"/api/v1/organizations/{fake_org_id}",
json={"name": "Updated"},
headers={"Authorization": f"Bearer {admin_token}"},
)
# Permission dependency checks admin role before endpoint logic
# So non-existent org returns 403 (not an admin) instead of 404
assert response.status_code == status.HTTP_403_FORBIDDEN
data = response.json()
assert "errors" in data or "detail" in data
# ===== Authentication Tests =====
class TestOrganizationAuthentication:
"""Test authentication requirements for organization endpoints."""
@pytest.mark.asyncio
async def test_get_my_organizations_unauthenticated(self, client):
"""Test unauthenticated access to /me fails."""
response = await client.get("/api/v1/organizations/me")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.asyncio
async def test_get_organization_unauthenticated(self, client):
"""Test unauthenticated access to organization details fails."""
fake_id = uuid4()
response = await client.get(f"/api/v1/organizations/{fake_id}")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.asyncio
async def test_get_members_unauthenticated(self, client):
"""Test unauthenticated access to members list fails."""
fake_id = uuid4()
response = await client.get(f"/api/v1/organizations/{fake_id}/members")
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.asyncio
async def test_update_organization_unauthenticated(self, client):
"""Test unauthenticated access to update fails."""
fake_id = uuid4()
response = await client.put(
f"/api/v1/organizations/{fake_id}", json={"name": "Test"}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
# ===== Exception Handler Tests (Database Error Scenarios) =====
class TestOrganizationExceptionHandlers:
"""
Test exception handlers in organization endpoints.
These tests use mocks to trigger database errors and verify
proper error handling. Covers lines: 81-83, 124-128, 170-172, 217-221
"""
@pytest.mark.asyncio
async def test_get_my_organizations_database_error(
self, client, user_token, test_org_with_user_member
):
"""Test generic exception handler in get_my_organizations (covers lines 81-83)."""
with patch(
"app.crud.organization.organization.get_user_organizations_with_details",
side_effect=Exception("Database connection lost"),
):
# The exception handler logs and re-raises, so we expect the exception
# to propagate (which proves the handler executed)
with pytest.raises(Exception, match="Database connection lost"):
await client.get(
"/api/v1/organizations/me",
headers={"Authorization": f"Bearer {user_token}"},
)
@pytest.mark.asyncio
async def test_get_organization_database_error(
self, client, user_token, test_org_with_user_member
):
"""Test generic exception handler in get_organization (covers lines 124-128)."""
with patch(
"app.crud.organization.organization.get",
side_effect=Exception("Database timeout"),
):
with pytest.raises(Exception, match="Database timeout"):
await client.get(
f"/api/v1/organizations/{test_org_with_user_member.id}",
headers={"Authorization": f"Bearer {user_token}"},
)
@pytest.mark.asyncio
async def test_get_organization_members_database_error(
self, client, user_token, test_org_with_user_member
):
"""Test generic exception handler in get_organization_members (covers lines 170-172)."""
with patch(
"app.crud.organization.organization.get_organization_members",
side_effect=Exception("Connection pool exhausted"),
):
with pytest.raises(Exception, match="Connection pool exhausted"):
await client.get(
f"/api/v1/organizations/{test_org_with_user_member.id}/members",
headers={"Authorization": f"Bearer {user_token}"},
)
@pytest.mark.asyncio
async def test_update_organization_database_error(
self, client, async_test_user, test_org_with_user_admin
):
"""Test generic exception handler in update_organization (covers lines 217-221)."""
# Login as admin user
login_response = await client.post(
"/api/v1/auth/login",
json={"email": "testuser@example.com", "password": "TestPassword123!"},
)
admin_token = login_response.json()["access_token"]
with patch(
"app.crud.organization.organization.get",
return_value=test_org_with_user_admin,
):
with patch(
"app.crud.organization.organization.update",
side_effect=Exception("Write lock timeout"),
):
with pytest.raises(Exception, match="Write lock timeout"):
await client.put(
f"/api/v1/organizations/{test_org_with_user_admin.id}",
json={"name": "Should Fail"},
headers={"Authorization": f"Bearer {admin_token}"},
)