"""API tests for organization endpoints.""" from __future__ import annotations from datetime import timedelta from typing import AsyncGenerator, Sequence, cast import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.schema import Table from app.api.deps import get_db_session from app.core.security import jwt_service from app.main import create_app from app.models import Base from app.models.organization import Organization from app.models.organization_member import OrganizationMember, OrganizationRole from app.models.user import User @pytest_asyncio.fixture() async def session_factory() -> AsyncGenerator[async_sessionmaker[AsyncSession], None]: engine = create_async_engine("sqlite+aiosqlite:///:memory:", future=True) async with engine.begin() as conn: tables: Sequence[Table] = cast( Sequence[Table], (User.__table__, Organization.__table__, OrganizationMember.__table__), ) await conn.run_sync(Base.metadata.create_all, tables=tables) SessionLocal = async_sessionmaker(engine, expire_on_commit=False) yield SessionLocal await engine.dispose() @pytest_asyncio.fixture() async def client( session_factory: async_sessionmaker[AsyncSession], ) -> AsyncGenerator[AsyncClient, None]: app = create_app() async def _get_session_override() -> AsyncGenerator[AsyncSession, None]: async with session_factory() as session: yield session app.dependency_overrides[get_db_session] = _get_session_override transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://testserver") as test_client: yield test_client @pytest.mark.asyncio async def test_list_user_organizations_returns_memberships( session_factory: async_sessionmaker[AsyncSession], client: AsyncClient ) -> None: async with session_factory() as session: user = User(email="owner@example.com", hashed_password="hashed", name="Owner", is_active=True) session.add(user) await session.flush() org_1 = Organization(name="Alpha LLC") org_2 = Organization(name="Beta LLC") session.add_all([org_1, org_2]) await session.flush() membership = OrganizationMember( organization_id=org_1.id, user_id=user.id, role=OrganizationRole.OWNER, ) other_member = OrganizationMember( organization_id=org_2.id, user_id=user.id + 1, role=OrganizationRole.MEMBER, ) session.add_all([membership, other_member]) await session.commit() token = jwt_service.create_access_token( subject=str(user.id), expires_delta=timedelta(minutes=30), claims={"email": user.email}, ) response = await client.get( "/api/v1/organizations/me", headers={"Authorization": f"Bearer {token}"}, ) assert response.status_code == 200 payload = response.json() assert len(payload) == 1 assert payload[0]["id"] == org_1.id assert payload[0]["name"] == org_1.name @pytest.mark.asyncio async def test_list_user_organizations_requires_token(client: AsyncClient) -> None: response = await client.get("/api/v1/organizations/me") assert response.status_code == 401 @pytest.mark.asyncio async def test_owner_can_add_member_to_organization( session_factory: async_sessionmaker[AsyncSession], client: AsyncClient, ) -> None: async with session_factory() as session: owner = User(email="owner-add@example.com", hashed_password="hashed", name="Owner", is_active=True) invitee = User(email="new-member@example.com", hashed_password="hashed", name="Member", is_active=True) session.add_all([owner, invitee]) await session.flush() organization = Organization(name="Membership LLC") session.add(organization) await session.flush() membership = OrganizationMember( organization_id=organization.id, user_id=owner.id, role=OrganizationRole.OWNER, ) session.add(membership) await session.commit() token = jwt_service.create_access_token( subject=str(owner.id), expires_delta=timedelta(minutes=30), claims={"email": owner.email}, ) response = await client.post( "/api/v1/organizations/members", headers={ "Authorization": f"Bearer {token}", "X-Organization-Id": str(organization.id), }, json={"email": invitee.email, "role": OrganizationRole.MANAGER.value}, ) assert response.status_code == 201 payload = response.json() assert payload["organization_id"] == organization.id assert payload["user_id"] == invitee.id assert payload["role"] == OrganizationRole.MANAGER.value async with session_factory() as session: new_membership = await session.scalar( select(OrganizationMember).where( OrganizationMember.organization_id == organization.id, OrganizationMember.user_id == invitee.id, ) ) assert new_membership is not None assert new_membership.role == OrganizationRole.MANAGER @pytest.mark.asyncio async def test_add_member_requires_existing_user( session_factory: async_sessionmaker[AsyncSession], client: AsyncClient, ) -> None: async with session_factory() as session: owner = User(email="owner-missing@example.com", hashed_password="hashed", name="Owner", is_active=True) session.add(owner) await session.flush() organization = Organization(name="Missing LLC") session.add(organization) await session.flush() membership = OrganizationMember( organization_id=organization.id, user_id=owner.id, role=OrganizationRole.OWNER, ) session.add(membership) await session.commit() token = jwt_service.create_access_token( subject=str(owner.id), expires_delta=timedelta(minutes=30), claims={"email": owner.email}, ) response = await client.post( "/api/v1/organizations/members", headers={ "Authorization": f"Bearer {token}", "X-Organization-Id": str(organization.id), }, json={"email": "ghost@example.com"}, ) assert response.status_code == 404 assert response.json()["detail"] == "User not found" @pytest.mark.asyncio async def test_member_role_cannot_add_users( session_factory: async_sessionmaker[AsyncSession], client: AsyncClient, ) -> None: async with session_factory() as session: member_user = User(email="member@example.com", hashed_password="hashed", name="Member", is_active=True) invitee = User(email="invitee@example.com", hashed_password="hashed", name="Invitee", is_active=True) session.add_all([member_user, invitee]) await session.flush() organization = Organization(name="Members Only LLC") session.add(organization) await session.flush() membership = OrganizationMember( organization_id=organization.id, user_id=member_user.id, role=OrganizationRole.MEMBER, ) session.add(membership) await session.commit() token = jwt_service.create_access_token( subject=str(member_user.id), expires_delta=timedelta(minutes=30), claims={"email": member_user.email}, ) response = await client.post( "/api/v1/organizations/members", headers={ "Authorization": f"Bearer {token}", "X-Organization-Id": str(organization.id), }, json={"email": invitee.email}, ) assert response.status_code == 403 assert response.json()["detail"] == "Only owner/admin can modify organization settings" @pytest.mark.asyncio async def test_cannot_add_duplicate_member( session_factory: async_sessionmaker[AsyncSession], client: AsyncClient, ) -> None: async with session_factory() as session: owner = User(email="dup-owner@example.com", hashed_password="hashed", name="Owner", is_active=True) invitee = User(email="dup-member@example.com", hashed_password="hashed", name="Invitee", is_active=True) session.add_all([owner, invitee]) await session.flush() organization = Organization(name="Duplicate LLC") session.add(organization) await session.flush() owner_membership = OrganizationMember( organization_id=organization.id, user_id=owner.id, role=OrganizationRole.OWNER, ) invitee_membership = OrganizationMember( organization_id=organization.id, user_id=invitee.id, role=OrganizationRole.MEMBER, ) session.add_all([owner_membership, invitee_membership]) await session.commit() token = jwt_service.create_access_token( subject=str(owner.id), expires_delta=timedelta(minutes=30), claims={"email": owner.email}, ) response = await client.post( "/api/v1/organizations/members", headers={ "Authorization": f"Bearer {token}", "X-Organization-Id": str(organization.id), }, json={"email": invitee.email}, ) assert response.status_code == 409 assert response.json()["detail"] == "User already belongs to this organization"