feat(workspace): add accept and decline invitation use cases
Deploy / Deploy Staging (push) Failing after 5s
Deploy / Deploy Production (push) Has been skipped
Tests / test (push) Failing after 6s
Tests / lint (push) Failing after 5s

- 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
This commit is contained in:
Xiaoxia AI
2026-06-17 05:08:31 +08:00
parent bcc3089f53
commit a8a1f6d290
3 changed files with 515 additions and 0 deletions
@@ -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",
]
@@ -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)}"
@@ -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"