From ce652a7e487b2ce3cc7886a455fb8b7f1bf3d724 Mon Sep 17 00:00:00 2001 From: k1nq Date: Sat, 29 Nov 2025 08:57:12 +0500 Subject: [PATCH] feat: add membership management tests; implement session and repository stubs for member addition and duplicate checks --- tests/services/test_organization_service.py | 97 ++++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/tests/services/test_organization_service.py b/tests/services/test_organization_service.py index a1c3322..31dc9c5 100644 --- a/tests/services/test_organization_service.py +++ b/tests/services/test_organization_service.py @@ -1,6 +1,7 @@ """Unit tests for OrganizationService.""" from __future__ import annotations +from typing import cast from unittest.mock import MagicMock import pytest # type: ignore[import-not-found] @@ -14,6 +15,7 @@ from app.services.organization_service import ( OrganizationContext, OrganizationContextMissingError, OrganizationForbiddenError, + OrganizationMemberAlreadyExistsError, OrganizationService, ) @@ -47,6 +49,40 @@ def make_membership(role: OrganizationRole, *, organization_id: int = 1, user_id 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) @@ -96,4 +132,63 @@ def test_member_must_own_entity() -> None: 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) \ No newline at end of file + 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 \ No newline at end of file