From a8a1f6d29022abbcc08068cb8aec3be61a500dee Mon Sep 17 00:00:00 2001 From: Xiaoxia AI Date: Wed, 17 Jun 2026 05:08:31 +0800 Subject: [PATCH] feat(workspace): add accept and decline invitation use cases - Implement AcceptInvitationUseCase with validation - Check invitation status, expiration, and email match - Auto-create WorkspaceMember on accept - Handle case when user is already a member - Implement DeclineInvitationUseCase to reject invitations - Update invitation status (accepted/declined/expired) - Add 9 comprehensive unit tests (all passed) Phase 4 Task 14/68 completed --- packages/application/workspace/__init__.py | 12 + .../workspace/accept_invitation_use_case.py | 188 +++++++++++ tests/unit/test_accept_invitation_use_case.py | 315 ++++++++++++++++++ 3 files changed, 515 insertions(+) create mode 100644 packages/application/workspace/accept_invitation_use_case.py create mode 100644 tests/unit/test_accept_invitation_use_case.py diff --git a/packages/application/workspace/__init__.py b/packages/application/workspace/__init__.py index 28817e980..0c710b9d0 100644 --- a/packages/application/workspace/__init__.py +++ b/packages/application/workspace/__init__.py @@ -9,6 +9,13 @@ from packages.application.workspace.invite_member_use_case import ( InviteMemberRequest, InviteMemberResponse, ) +from packages.application.workspace.accept_invitation_use_case import ( + AcceptInvitationUseCase, + AcceptInvitationRequest, + AcceptInvitationResponse, + DeclineInvitationUseCase, + DeclineInvitationRequest, +) __all__ = [ "CreateWorkspaceUseCase", @@ -17,4 +24,9 @@ __all__ = [ "InviteMemberUseCase", "InviteMemberRequest", "InviteMemberResponse", + "AcceptInvitationUseCase", + "AcceptInvitationRequest", + "AcceptInvitationResponse", + "DeclineInvitationUseCase", + "DeclineInvitationRequest", ] diff --git a/packages/application/workspace/accept_invitation_use_case.py b/packages/application/workspace/accept_invitation_use_case.py new file mode 100644 index 000000000..7cbc0a061 --- /dev/null +++ b/packages/application/workspace/accept_invitation_use_case.py @@ -0,0 +1,188 @@ +""" +接受/拒绝邀请 Use Case +""" +from datetime import datetime, timezone +from typing import Optional +from uuid import uuid4 + +from packages.domain.entities import ( + WorkspaceMember, + InvitationStatus, +) + + +class AcceptInvitationRequest: + """接受邀请请求""" + + def __init__(self, invitation_token: str, user_id: str): + self.invitation_token = invitation_token + self.user_id = user_id + + +class AcceptInvitationResponse: + """接受邀请响应""" + + def __init__( + self, + workspace_id: str, + workspace_name: str, + role: str, + ): + self.workspace_id = workspace_id + self.workspace_name = workspace_name + self.role = role + + +class AcceptInvitationUseCase: + """接受邀请用例""" + + def __init__( + self, + workspace_repository, + workspace_member_repository, + workspace_invitation_repository, + user_repository, + ): + self.workspace_repository = workspace_repository + self.workspace_member_repository = workspace_member_repository + self.workspace_invitation_repository = workspace_invitation_repository + self.user_repository = user_repository + + def execute(self, request: AcceptInvitationRequest) -> tuple[Optional[AcceptInvitationResponse], Optional[str]]: + """ + 执行接受邀请 + + Args: + request: 接受请求 + + Returns: + (响应, 错误信息) + """ + try: + # 1. 验证输入 + if not request.invitation_token: + return None, "Invitation token is required" + + if not request.user_id: + return None, "User ID is required" + + # 2. 查找邀请 + invitation = self.workspace_invitation_repository.find_by_token(request.invitation_token) + if not invitation: + return None, "Invalid invitation token" + + # 3. 检查邀请状态 + if invitation.status != InvitationStatus.PENDING: + return None, f"Invitation has already been {invitation.status}" + + # 4. 检查是否过期 + if invitation.expires_at and datetime.now(timezone.utc) > invitation.expires_at: + # 更新状态为过期 + invitation.status = InvitationStatus.EXPIRED + self.workspace_invitation_repository.save(invitation) + return None, "Invitation has expired" + + # 5. 验证用户存在 + user = self.user_repository.find_by_id(request.user_id) + if not user: + return None, "User not found" + + # 6. 验证用户邮箱匹配 + if user.email.lower() != invitation.invitee_email.lower(): + return None, "This invitation is for a different email address" + + # 7. 验证 Workspace 存在 + workspace = self.workspace_repository.find_by_id(invitation.workspace_id) + if not workspace: + return None, "Workspace not found" + + # 8. 检查用户是否已经是成员 + existing_member = self.workspace_member_repository.find_by_workspace_and_user( + invitation.workspace_id, + request.user_id, + ) + if existing_member: + # 已经是成员,标记邀请为已接受 + invitation.status = InvitationStatus.ACCEPTED + invitation.accepted_at = datetime.now(timezone.utc) + self.workspace_invitation_repository.save(invitation) + + return AcceptInvitationResponse( + workspace_id=workspace.id, + workspace_name=workspace.name, + role=existing_member.role, + ), None + + # 9. 创建成员记录 + member = WorkspaceMember( + id=uuid4().hex, + workspace_id=invitation.workspace_id, + user_id=request.user_id, + role=invitation.role, + invited_by=invitation.inviter_user_id, + joined_at=datetime.now(timezone.utc), + ) + + self.workspace_member_repository.save(member) + + # 10. 更新邀请状态 + invitation.status = InvitationStatus.ACCEPTED + invitation.accepted_at = datetime.now(timezone.utc) + self.workspace_invitation_repository.save(invitation) + + # 11. 返回响应 + return AcceptInvitationResponse( + workspace_id=workspace.id, + workspace_name=workspace.name, + role=member.role, + ), None + + except Exception as e: + return None, f"Failed to accept invitation: {str(e)}" + + +class DeclineInvitationRequest: + """拒绝邀请请求""" + + def __init__(self, invitation_token: str): + self.invitation_token = invitation_token + + +class DeclineInvitationUseCase: + """拒绝邀请用例""" + + def __init__(self, workspace_invitation_repository): + self.workspace_invitation_repository = workspace_invitation_repository + + def execute(self, request: DeclineInvitationRequest) -> tuple[bool, Optional[str]]: + """ + 执行拒绝邀请 + + Args: + request: 拒绝请求 + + Returns: + (是否成功, 错误信息) + """ + try: + # 1. 验证输入 + if not request.invitation_token: + return False, "Invitation token is required" + + # 2. 查找邀请 + invitation = self.workspace_invitation_repository.find_by_token(request.invitation_token) + if not invitation: + return False, "Invalid invitation token" + + # 3. 检查邀请状态 + if invitation.status != InvitationStatus.PENDING: + return False, f"Invitation has already been {invitation.status}" + + # 4. 更新状态为已拒绝 + invitation.status = InvitationStatus.DECLINED + self.workspace_invitation_repository.save(invitation) + + return True, None + + except Exception as e: + return False, f"Failed to decline invitation: {str(e)}" diff --git a/tests/unit/test_accept_invitation_use_case.py b/tests/unit/test_accept_invitation_use_case.py new file mode 100644 index 000000000..43f7b113f --- /dev/null +++ b/tests/unit/test_accept_invitation_use_case.py @@ -0,0 +1,315 @@ +""" +接受/拒绝邀请 Use Case 测试 +""" +import pytest +from unittest.mock import Mock +from datetime import datetime, timedelta, timezone +from packages.application.workspace.accept_invitation_use_case import ( + AcceptInvitationUseCase, + AcceptInvitationRequest, + DeclineInvitationUseCase, + DeclineInvitationRequest, +) +from packages.domain.entities import ( + Workspace, + WorkspaceInvitation, + WorkspaceMember, + User, + InvitationStatus, +) + + +class TestAcceptInvitationUseCase: + """接受邀请测试""" + + @pytest.fixture + def mock_workspace_repo(self): + repo = Mock() + repo.find_by_id = Mock(return_value=None) + return repo + + @pytest.fixture + def mock_member_repo(self): + repo = Mock() + repo.find_by_workspace_and_user = Mock(return_value=None) + repo.save = Mock() + return repo + + @pytest.fixture + def mock_invitation_repo(self): + repo = Mock() + repo.find_by_token = Mock(return_value=None) + repo.save = Mock() + return repo + + @pytest.fixture + def mock_user_repo(self): + repo = Mock() + repo.find_by_id = Mock(return_value=None) + return repo + + @pytest.fixture + def use_case(self, mock_workspace_repo, mock_member_repo, mock_invitation_repo, mock_user_repo): + return AcceptInvitationUseCase( + workspace_repository=mock_workspace_repo, + workspace_member_repository=mock_member_repo, + workspace_invitation_repository=mock_invitation_repo, + user_repository=mock_user_repo, + ) + + @pytest.fixture + def test_workspace(self): + return Workspace( + id="workspace-123", + name="Test Workspace", + owner_user_id="owner-id", + ) + + @pytest.fixture + def test_user(self): + return User( + id="user-123", + email="invitee@test.com", + username="invitee", + display_name="Invitee User", + ) + + @pytest.fixture + def valid_invitation(self): + return WorkspaceInvitation( + id="invitation-1", + workspace_id="workspace-123", + inviter_user_id="inviter-id", + invitee_email="invitee@test.com", + role="member", + invitation_token="valid-token", + status=InvitationStatus.PENDING, + expires_at=datetime.now(timezone.utc) + timedelta(days=7), + ) + + def test_accept_invitation_success( + self, + use_case, + mock_workspace_repo, + mock_member_repo, + mock_invitation_repo, + mock_user_repo, + test_workspace, + test_user, + valid_invitation, + ): + """测试接受邀请成功""" + mock_invitation_repo.find_by_token.return_value = valid_invitation + mock_user_repo.find_by_id.return_value = test_user + mock_workspace_repo.find_by_id.return_value = test_workspace + mock_member_repo.find_by_workspace_and_user.return_value = None + + request = AcceptInvitationRequest( + invitation_token="valid-token", + user_id="user-123", + ) + + response, error = use_case.execute(request) + + assert error is None + assert response is not None + assert response.workspace_id == "workspace-123" + assert response.workspace_name == "Test Workspace" + assert response.role == "member" + + # 验证创建了成员记录 + mock_member_repo.save.assert_called_once() + member = mock_member_repo.save.call_args[0][0] + assert member.user_id == "user-123" + assert member.role == "member" + assert member.invited_by == "inviter-id" + + # 验证更新了邀请状态 + assert valid_invitation.status == InvitationStatus.ACCEPTED + assert valid_invitation.accepted_at is not None + + def test_accept_invitation_invalid_token(self, use_case, mock_invitation_repo): + """测试无效令牌""" + mock_invitation_repo.find_by_token.return_value = None + + request = AcceptInvitationRequest( + invitation_token="invalid-token", + user_id="user-123", + ) + + response, error = use_case.execute(request) + + assert response is None + assert error == "Invalid invitation token" + + def test_accept_invitation_already_accepted( + self, + use_case, + mock_invitation_repo, + valid_invitation, + ): + """测试邀请已被接受""" + valid_invitation.status = InvitationStatus.ACCEPTED + mock_invitation_repo.find_by_token.return_value = valid_invitation + + request = AcceptInvitationRequest( + invitation_token="valid-token", + user_id="user-123", + ) + + response, error = use_case.execute(request) + + assert response is None + assert error == "Invitation has already been accepted" + + def test_accept_invitation_expired( + self, + use_case, + mock_invitation_repo, + valid_invitation, + ): + """测试邀请已过期""" + valid_invitation.expires_at = datetime.now(timezone.utc) - timedelta(days=1) + mock_invitation_repo.find_by_token.return_value = valid_invitation + + request = AcceptInvitationRequest( + invitation_token="valid-token", + user_id="user-123", + ) + + response, error = use_case.execute(request) + + assert response is None + assert error == "Invitation has expired" + assert valid_invitation.status == InvitationStatus.EXPIRED + + def test_accept_invitation_email_mismatch( + self, + use_case, + mock_invitation_repo, + mock_user_repo, + valid_invitation, + ): + """测试邮箱不匹配""" + mock_invitation_repo.find_by_token.return_value = valid_invitation + + different_user = User( + id="user-123", + email="different@test.com", + username="different", + display_name="Different User", + ) + mock_user_repo.find_by_id.return_value = different_user + + request = AcceptInvitationRequest( + invitation_token="valid-token", + user_id="user-123", + ) + + response, error = use_case.execute(request) + + assert response is None + assert error == "This invitation is for a different email address" + + def test_accept_invitation_already_member( + self, + use_case, + mock_workspace_repo, + mock_member_repo, + mock_invitation_repo, + mock_user_repo, + test_workspace, + test_user, + valid_invitation, + ): + """测试用户已经是成员""" + mock_invitation_repo.find_by_token.return_value = valid_invitation + mock_user_repo.find_by_id.return_value = test_user + mock_workspace_repo.find_by_id.return_value = test_workspace + + existing_member = WorkspaceMember( + id="member-1", + workspace_id="workspace-123", + user_id="user-123", + role="admin", + ) + mock_member_repo.find_by_workspace_and_user.return_value = existing_member + + request = AcceptInvitationRequest( + invitation_token="valid-token", + user_id="user-123", + ) + + response, error = use_case.execute(request) + + assert error is None + assert response is not None + assert response.role == "admin" # 返回现有角色 + + # 不创建新成员记录 + mock_member_repo.save.assert_not_called() + + # 但仍标记邀请为已接受 + assert valid_invitation.status == InvitationStatus.ACCEPTED + + +class TestDeclineInvitationUseCase: + """拒绝邀请测试""" + + @pytest.fixture + def mock_invitation_repo(self): + repo = Mock() + repo.find_by_token = Mock(return_value=None) + repo.save = Mock() + return repo + + @pytest.fixture + def use_case(self, mock_invitation_repo): + return DeclineInvitationUseCase( + workspace_invitation_repository=mock_invitation_repo, + ) + + @pytest.fixture + def valid_invitation(self): + return WorkspaceInvitation( + id="invitation-1", + workspace_id="workspace-123", + inviter_user_id="inviter-id", + invitee_email="invitee@test.com", + role="member", + invitation_token="valid-token", + status=InvitationStatus.PENDING, + expires_at=datetime.now(timezone.utc) + timedelta(days=7), + ) + + def test_decline_invitation_success(self, use_case, mock_invitation_repo, valid_invitation): + """测试拒绝邀请成功""" + mock_invitation_repo.find_by_token.return_value = valid_invitation + + request = DeclineInvitationRequest(invitation_token="valid-token") + success, error = use_case.execute(request) + + assert success is True + assert error is None + assert valid_invitation.status == InvitationStatus.DECLINED + + def test_decline_invitation_invalid_token(self, use_case, mock_invitation_repo): + """测试无效令牌""" + mock_invitation_repo.find_by_token.return_value = None + + request = DeclineInvitationRequest(invitation_token="invalid-token") + success, error = use_case.execute(request) + + assert success is False + assert error == "Invalid invitation token" + + def test_decline_invitation_already_accepted(self, use_case, mock_invitation_repo, valid_invitation): + """测试邀请已被接受""" + valid_invitation.status = InvitationStatus.ACCEPTED + mock_invitation_repo.find_by_token.return_value = valid_invitation + + request = DeclineInvitationRequest(invitation_token="valid-token") + success, error = use_case.execute(request) + + assert success is False + assert error == "Invitation has already been accepted"