test_task_crm/tests/services/test_organization_service.py

214 lines
7.2 KiB
Python

"""Unit tests for OrganizationService."""
from __future__ import annotations
from typing import cast
from unittest.mock import MagicMock
import pytest
from app.models.organization import Organization
from app.models.organization_member import OrganizationMember, OrganizationRole
from app.repositories.org_repo import OrganizationRepository
from app.services.organization_service import (
OrganizationAccessDeniedError,
OrganizationContext,
OrganizationContextMissingError,
OrganizationForbiddenError,
OrganizationMemberAlreadyExistsError,
OrganizationService,
)
from sqlalchemy.ext.asyncio import AsyncSession
class StubOrganizationRepository(OrganizationRepository):
"""Simple in-memory stand-in for OrganizationRepository."""
def __init__(self, membership: OrganizationMember | None) -> None:
super().__init__(session=MagicMock(spec=AsyncSession))
self._membership = membership
async def get_membership(
self,
organization_id: int,
user_id: int,
) -> OrganizationMember | None: # pragma: no cover - helper
if (
self._membership
and self._membership.organization_id == organization_id
and self._membership.user_id == user_id
):
return self._membership
return None
def make_membership(
role: OrganizationRole,
*,
organization_id: int = 1,
user_id: int = 10,
) -> OrganizationMember:
organization = Organization(name="Acme Inc")
organization.id = organization_id
membership = OrganizationMember(
organization_id=organization_id,
user_id=user_id,
role=role,
)
membership.organization = organization
return membership
class SessionStub:
"""Minimal async session stub capturing writes."""
def __init__(self) -> None:
self.added: list[OrganizationMember] = []
self.committed: bool = False
self.refreshed: list[OrganizationMember] = []
def add(self, obj: OrganizationMember) -> None:
self.added.append(obj)
async def commit(self) -> None:
self.committed = True
async def refresh(self, obj: OrganizationMember) -> None:
self.refreshed.append(obj)
class MembershipRepositoryStub(OrganizationRepository):
"""Repository stub that can emulate duplicate checks for add_member."""
def __init__(
self,
memberships: dict[tuple[int, int], OrganizationMember] | None = None,
) -> None:
self._session_stub = SessionStub()
super().__init__(session=cast(AsyncSession, self._session_stub))
self._memberships = memberships or {}
@property
def session_stub(self) -> SessionStub:
return self._session_stub
async def get_membership(self, organization_id: int, user_id: int) -> OrganizationMember | None:
return self._memberships.get((organization_id, user_id))
@pytest.mark.asyncio
async def test_get_context_success() -> None:
membership = make_membership(OrganizationRole.MANAGER)
service = OrganizationService(StubOrganizationRepository(membership))
context = await service.get_context(
user_id=membership.user_id,
organization_id=membership.organization_id,
)
assert context.organization_id == membership.organization_id
assert context.role == OrganizationRole.MANAGER
@pytest.mark.asyncio
async def test_get_context_missing_header() -> None:
service = OrganizationService(StubOrganizationRepository(None))
with pytest.raises(OrganizationContextMissingError):
await service.get_context(user_id=1, organization_id=None)
@pytest.mark.asyncio
async def test_get_context_access_denied() -> None:
service = OrganizationService(StubOrganizationRepository(None))
with pytest.raises(OrganizationAccessDeniedError):
await service.get_context(user_id=1, organization_id=99)
def test_ensure_can_manage_settings_blocks_manager() -> None:
membership = make_membership(OrganizationRole.MANAGER)
organization = membership.organization
assert organization is not None
context = OrganizationContext(organization=organization, membership=membership)
service = OrganizationService(StubOrganizationRepository(membership))
with pytest.raises(OrganizationForbiddenError):
service.ensure_can_manage_settings(context)
def test_member_must_own_entity() -> None:
membership = make_membership(OrganizationRole.MEMBER)
organization = membership.organization
assert organization is not None
context = OrganizationContext(organization=organization, membership=membership)
service = OrganizationService(StubOrganizationRepository(membership))
with pytest.raises(OrganizationForbiddenError):
service.ensure_member_owns_entity(context=context, owner_id=999)
# Same owner should pass silently.
service.ensure_member_owns_entity(context=context, owner_id=membership.user_id)
@pytest.mark.asyncio
async def test_add_member_succeeds_for_owner() -> None:
owner_membership = make_membership(OrganizationRole.OWNER, organization_id=7, user_id=1)
organization = owner_membership.organization
assert organization is not None
context = OrganizationContext(organization=organization, membership=owner_membership)
repo = MembershipRepositoryStub()
service = OrganizationService(repo)
result = await service.add_member(context=context, user_id=42, role=OrganizationRole.MANAGER)
assert result.organization_id == organization.id
assert result.user_id == 42
assert result.role == OrganizationRole.MANAGER
session_stub = repo.session_stub
assert session_stub.committed is True
assert session_stub.added and session_stub.added[0] is result
assert session_stub.refreshed and session_stub.refreshed[0] is result
@pytest.mark.asyncio
async def test_add_member_rejects_duplicate_membership() -> None:
owner_membership = make_membership(OrganizationRole.OWNER, organization_id=5, user_id=10)
organization = owner_membership.organization
assert organization is not None
context = OrganizationContext(organization=organization, membership=owner_membership)
duplicate_user_id = 55
existing = OrganizationMember(
organization_id=organization.id,
user_id=duplicate_user_id,
role=OrganizationRole.MEMBER,
)
repo = MembershipRepositoryStub({(organization.id, duplicate_user_id): existing})
service = OrganizationService(repo)
with pytest.raises(OrganizationMemberAlreadyExistsError):
await service.add_member(
context=context,
user_id=duplicate_user_id,
role=OrganizationRole.MANAGER,
)
@pytest.mark.asyncio
async def test_add_member_requires_privileged_role() -> None:
member_context = make_membership(OrganizationRole.MEMBER, organization_id=3, user_id=77)
organization = member_context.organization
assert organization is not None
context = OrganizationContext(organization=organization, membership=member_context)
repo = MembershipRepositoryStub()
service = OrganizationService(repo)
with pytest.raises(OrganizationForbiddenError):
await service.add_member(context=context, user_id=99, role=OrganizationRole.MANAGER)
# Ensure DB work not attempted when permissions fail.
assert repo.session_stub.committed is False