- introduce custom repository exception hierarchy (DuplicateEntryError, IntegrityConstraintError, InvalidInputError) replacing raw ValueError - eliminate all direct repository imports and raw SQL from route layer - add UserService, SessionService, OrganizationService to service layer - add get_stats/get_org_distribution service methods replacing admin inline SQL - fix timing side-channel in authenticate_user via dummy bcrypt check - replace SHA-256 client secret fallback with explicit InvalidClientError - replace assert with InvalidGrantError in authorization code exchange - replace N+1 token revocation loops with bulk UPDATE statements - rename oauth account token fields (drop misleading 'encrypted' suffix) - add Alembic migration 0003 for token field column rename - add 45 new service/repository tests; 975 passing, 94% coverage
448 lines
18 KiB
Python
448 lines
18 KiB
Python
# tests/services/test_organization_service.py
|
|
"""Tests for the OrganizationService class."""
|
|
|
|
import uuid
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from app.core.exceptions import NotFoundError
|
|
from app.models.user_organization import OrganizationRole
|
|
from app.schemas.organizations import OrganizationCreate, OrganizationUpdate
|
|
from app.services.organization_service import OrganizationService, organization_service
|
|
|
|
|
|
def _make_org_create(name=None, slug=None) -> OrganizationCreate:
|
|
"""Helper to create an OrganizationCreate schema with unique defaults."""
|
|
unique = uuid.uuid4().hex[:8]
|
|
return OrganizationCreate(
|
|
name=name or f"Test Org {unique}",
|
|
slug=slug or f"test-org-{unique}",
|
|
description="A test organization",
|
|
is_active=True,
|
|
settings={},
|
|
)
|
|
|
|
|
|
class TestGetOrganization:
|
|
"""Tests for OrganizationService.get_organization method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_organization_found(self, async_test_db, async_test_user):
|
|
"""Test getting an existing organization by ID returns the org."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
async with AsyncTestingSessionLocal() as session:
|
|
created = await organization_service.create_organization(
|
|
session, obj_in=_make_org_create()
|
|
)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
result = await organization_service.get_organization(
|
|
session, str(created.id)
|
|
)
|
|
assert result is not None
|
|
assert result.id == created.id
|
|
assert result.slug == created.slug
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_organization_not_found(self, async_test_db):
|
|
"""Test getting a non-existent organization raises NotFoundError."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
async with AsyncTestingSessionLocal() as session:
|
|
with pytest.raises(NotFoundError):
|
|
await organization_service.get_organization(
|
|
session, str(uuid.uuid4())
|
|
)
|
|
|
|
|
|
class TestCreateOrganization:
|
|
"""Tests for OrganizationService.create_organization method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_organization(self, async_test_db, async_test_user):
|
|
"""Test creating a new organization returns the created org with correct fields."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
obj_in = _make_org_create()
|
|
async with AsyncTestingSessionLocal() as session:
|
|
result = await organization_service.create_organization(
|
|
session, obj_in=obj_in
|
|
)
|
|
assert result is not None
|
|
assert result.name == obj_in.name
|
|
assert result.slug == obj_in.slug
|
|
assert result.description == obj_in.description
|
|
assert result.is_active is True
|
|
|
|
|
|
class TestUpdateOrganization:
|
|
"""Tests for OrganizationService.update_organization method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_organization(self, async_test_db, async_test_user):
|
|
"""Test updating an organization name."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
async with AsyncTestingSessionLocal() as session:
|
|
created = await organization_service.create_organization(
|
|
session, obj_in=_make_org_create()
|
|
)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
org = await organization_service.get_organization(session, str(created.id))
|
|
updated = await organization_service.update_organization(
|
|
session,
|
|
org=org,
|
|
obj_in=OrganizationUpdate(name="Updated Org Name"),
|
|
)
|
|
assert updated.name == "Updated Org Name"
|
|
assert updated.id == created.id
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_organization_with_dict(self, async_test_db, async_test_user):
|
|
"""Test updating an organization using a dict."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
async with AsyncTestingSessionLocal() as session:
|
|
created = await organization_service.create_organization(
|
|
session, obj_in=_make_org_create()
|
|
)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
org = await organization_service.get_organization(session, str(created.id))
|
|
updated = await organization_service.update_organization(
|
|
session,
|
|
org=org,
|
|
obj_in={"description": "Updated description"},
|
|
)
|
|
assert updated.description == "Updated description"
|
|
|
|
|
|
class TestRemoveOrganization:
|
|
"""Tests for OrganizationService.remove_organization method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_organization(self, async_test_db, async_test_user):
|
|
"""Test permanently deleting an organization."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
async with AsyncTestingSessionLocal() as session:
|
|
created = await organization_service.create_organization(
|
|
session, obj_in=_make_org_create()
|
|
)
|
|
org_id = str(created.id)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
await organization_service.remove_organization(session, org_id)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
with pytest.raises(NotFoundError):
|
|
await organization_service.get_organization(session, org_id)
|
|
|
|
|
|
class TestGetMemberCount:
|
|
"""Tests for OrganizationService.get_member_count method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_member_count_empty(self, async_test_db, async_test_user):
|
|
"""Test member count for org with no members is zero."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
async with AsyncTestingSessionLocal() as session:
|
|
created = await organization_service.create_organization(
|
|
session, obj_in=_make_org_create()
|
|
)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
count = await organization_service.get_member_count(
|
|
session, organization_id=created.id
|
|
)
|
|
assert count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_member_count_with_member(self, async_test_db, async_test_user):
|
|
"""Test member count increases after adding a member."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
async with AsyncTestingSessionLocal() as session:
|
|
created = await organization_service.create_organization(
|
|
session, obj_in=_make_org_create()
|
|
)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
await organization_service.add_member(
|
|
session,
|
|
organization_id=created.id,
|
|
user_id=async_test_user.id,
|
|
)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
count = await organization_service.get_member_count(
|
|
session, organization_id=created.id
|
|
)
|
|
assert count == 1
|
|
|
|
|
|
class TestGetMultiWithMemberCounts:
|
|
"""Tests for OrganizationService.get_multi_with_member_counts method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_with_member_counts(self, async_test_db, async_test_user):
|
|
"""Test listing organizations with member counts returns tuple."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
async with AsyncTestingSessionLocal() as session:
|
|
await organization_service.create_organization(
|
|
session, obj_in=_make_org_create()
|
|
)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
orgs, count = await organization_service.get_multi_with_member_counts(
|
|
session, skip=0, limit=10
|
|
)
|
|
assert isinstance(orgs, list)
|
|
assert isinstance(count, int)
|
|
assert count >= 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_multi_with_member_counts_search(
|
|
self, async_test_db, async_test_user
|
|
):
|
|
"""Test listing organizations with a search filter."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
unique = uuid.uuid4().hex[:8]
|
|
org_name = f"Searchable Org {unique}"
|
|
async with AsyncTestingSessionLocal() as session:
|
|
await organization_service.create_organization(
|
|
session,
|
|
obj_in=OrganizationCreate(
|
|
name=org_name,
|
|
slug=f"searchable-org-{unique}",
|
|
is_active=True,
|
|
settings={},
|
|
),
|
|
)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
orgs, count = await organization_service.get_multi_with_member_counts(
|
|
session, skip=0, limit=10, search=f"Searchable Org {unique}"
|
|
)
|
|
assert count >= 1
|
|
# Each element is a dict with key "organization" (an Organization obj) and "member_count"
|
|
names = [o["organization"].name for o in orgs]
|
|
assert org_name in names
|
|
|
|
|
|
class TestGetUserOrganizationsWithDetails:
|
|
"""Tests for OrganizationService.get_user_organizations_with_details method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_organizations_with_details(
|
|
self, async_test_db, async_test_user
|
|
):
|
|
"""Test getting organizations for a user returns list of dicts."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
async with AsyncTestingSessionLocal() as session:
|
|
created = await organization_service.create_organization(
|
|
session, obj_in=_make_org_create()
|
|
)
|
|
await organization_service.add_member(
|
|
session,
|
|
organization_id=created.id,
|
|
user_id=async_test_user.id,
|
|
)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
orgs = await organization_service.get_user_organizations_with_details(
|
|
session, user_id=async_test_user.id
|
|
)
|
|
assert isinstance(orgs, list)
|
|
assert len(orgs) >= 1
|
|
|
|
|
|
class TestGetOrganizationMembers:
|
|
"""Tests for OrganizationService.get_organization_members method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_organization_members(self, async_test_db, async_test_user):
|
|
"""Test getting organization members returns paginated results."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
async with AsyncTestingSessionLocal() as session:
|
|
created = await organization_service.create_organization(
|
|
session, obj_in=_make_org_create()
|
|
)
|
|
await organization_service.add_member(
|
|
session,
|
|
organization_id=created.id,
|
|
user_id=async_test_user.id,
|
|
)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
members, count = await organization_service.get_organization_members(
|
|
session, organization_id=created.id, skip=0, limit=10
|
|
)
|
|
assert isinstance(members, list)
|
|
assert isinstance(count, int)
|
|
assert count >= 1
|
|
|
|
|
|
class TestAddMember:
|
|
"""Tests for OrganizationService.add_member method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_member_default_role(self, async_test_db, async_test_user):
|
|
"""Test adding a user to an org with default MEMBER role."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
async with AsyncTestingSessionLocal() as session:
|
|
created = await organization_service.create_organization(
|
|
session, obj_in=_make_org_create()
|
|
)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
membership = await organization_service.add_member(
|
|
session,
|
|
organization_id=created.id,
|
|
user_id=async_test_user.id,
|
|
)
|
|
assert membership is not None
|
|
assert membership.user_id == async_test_user.id
|
|
assert membership.organization_id == created.id
|
|
assert membership.role == OrganizationRole.MEMBER
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_member_admin_role(self, async_test_db, async_test_user):
|
|
"""Test adding a user to an org with ADMIN role."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
async with AsyncTestingSessionLocal() as session:
|
|
created = await organization_service.create_organization(
|
|
session, obj_in=_make_org_create()
|
|
)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
membership = await organization_service.add_member(
|
|
session,
|
|
organization_id=created.id,
|
|
user_id=async_test_user.id,
|
|
role=OrganizationRole.ADMIN,
|
|
)
|
|
assert membership.role == OrganizationRole.ADMIN
|
|
|
|
|
|
class TestRemoveMember:
|
|
"""Tests for OrganizationService.remove_member method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_member(self, async_test_db, async_test_user):
|
|
"""Test removing a member from an org returns True."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
async with AsyncTestingSessionLocal() as session:
|
|
created = await organization_service.create_organization(
|
|
session, obj_in=_make_org_create()
|
|
)
|
|
await organization_service.add_member(
|
|
session,
|
|
organization_id=created.id,
|
|
user_id=async_test_user.id,
|
|
)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
removed = await organization_service.remove_member(
|
|
session,
|
|
organization_id=created.id,
|
|
user_id=async_test_user.id,
|
|
)
|
|
assert removed is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_remove_member_not_found(self, async_test_db, async_test_user):
|
|
"""Test removing a non-member returns False."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
async with AsyncTestingSessionLocal() as session:
|
|
created = await organization_service.create_organization(
|
|
session, obj_in=_make_org_create()
|
|
)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
removed = await organization_service.remove_member(
|
|
session,
|
|
organization_id=created.id,
|
|
user_id=async_test_user.id,
|
|
)
|
|
assert removed is False
|
|
|
|
|
|
class TestGetUserRoleInOrg:
|
|
"""Tests for OrganizationService.get_user_role_in_org method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_role_in_org(self, async_test_db, async_test_user):
|
|
"""Test getting a user's role in an org they belong to."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
async with AsyncTestingSessionLocal() as session:
|
|
created = await organization_service.create_organization(
|
|
session, obj_in=_make_org_create()
|
|
)
|
|
await organization_service.add_member(
|
|
session,
|
|
organization_id=created.id,
|
|
user_id=async_test_user.id,
|
|
role=OrganizationRole.MEMBER,
|
|
)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
role = await organization_service.get_user_role_in_org(
|
|
session,
|
|
user_id=async_test_user.id,
|
|
organization_id=created.id,
|
|
)
|
|
assert role == OrganizationRole.MEMBER
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_role_in_org_not_member(
|
|
self, async_test_db, async_test_user
|
|
):
|
|
"""Test getting role for a user not in the org returns None."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
async with AsyncTestingSessionLocal() as session:
|
|
created = await organization_service.create_organization(
|
|
session, obj_in=_make_org_create()
|
|
)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
role = await organization_service.get_user_role_in_org(
|
|
session,
|
|
user_id=async_test_user.id,
|
|
organization_id=created.id,
|
|
)
|
|
assert role is None
|
|
|
|
|
|
class TestGetOrgDistribution:
|
|
"""Tests for OrganizationService.get_org_distribution method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_org_distribution_empty(self, async_test_db):
|
|
"""Test org distribution with no memberships returns empty list."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
async with AsyncTestingSessionLocal() as session:
|
|
result = await organization_service.get_org_distribution(session, limit=6)
|
|
assert isinstance(result, list)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_org_distribution_with_members(
|
|
self, async_test_db, async_test_user
|
|
):
|
|
"""Test org distribution returns org name and member count."""
|
|
_test_engine, AsyncTestingSessionLocal = async_test_db
|
|
async with AsyncTestingSessionLocal() as session:
|
|
created = await organization_service.create_organization(
|
|
session, obj_in=_make_org_create()
|
|
)
|
|
await organization_service.add_member(
|
|
session,
|
|
organization_id=created.id,
|
|
user_id=async_test_user.id,
|
|
)
|
|
|
|
async with AsyncTestingSessionLocal() as session:
|
|
result = await organization_service.get_org_distribution(session, limit=6)
|
|
assert isinstance(result, list)
|
|
assert len(result) >= 1
|
|
entry = result[0]
|
|
assert "name" in entry
|
|
assert "value" in entry
|
|
assert entry["value"] >= 1
|