test_task_crm/tests/api/v1/test_organizations.py

333 lines
10 KiB
Python

"""API tests for organization endpoints."""
from __future__ import annotations
from collections.abc import AsyncGenerator, Sequence
from datetime import timedelta
from typing import 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)
session_local = async_sessionmaker(engine, expire_on_commit=False)
yield session_local
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"