290 lines
9.5 KiB
Python
290 lines
9.5 KiB
Python
"""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"
|