837 lines
30 KiB
Python
837 lines
30 KiB
Python
"""Tests for ProjectMixin gRPC endpoints.
|
|
|
|
Tests cover:
|
|
- CreateProject: basic creation, with settings, with slug
|
|
- GetProject: found, not found
|
|
- GetProjectBySlug: found, not found
|
|
- ListProjects: empty list, multiple projects, pagination
|
|
- UpdateProject: name, description, settings
|
|
- ArchiveProject: success, default project error
|
|
- RestoreProject: success, not found
|
|
- DeleteProject: success, not found (returns success=false)
|
|
- AddProjectMember: success
|
|
- UpdateProjectMemberRole: success, not found
|
|
- RemoveProjectMember: success, not found (returns success=false)
|
|
- ListProjectMembers: empty list, multiple members
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from noteflow.application.services.project_service import ProjectService
|
|
from noteflow.domain.entities.project import (
|
|
ExportRules,
|
|
Project,
|
|
ProjectSettings,
|
|
TriggerRules,
|
|
)
|
|
from noteflow.domain.identity.entities import ProjectMembership
|
|
from noteflow.domain.identity.roles import ProjectRole
|
|
from noteflow.domain.ports.repositories.identity import (
|
|
ProjectMembershipRepository,
|
|
ProjectRepository,
|
|
WorkspaceRepository,
|
|
)
|
|
from noteflow.domain.value_objects import ExportFormat
|
|
from noteflow.grpc.mixins.project import ProjectMembershipMixin, ProjectMixin
|
|
from noteflow.grpc.proto import noteflow_pb2
|
|
|
|
# ============================================================================
|
|
# Mock Infrastructure
|
|
# ============================================================================
|
|
|
|
|
|
class MockProjectRepositoryProvider:
|
|
"""Mock repository provider as async context manager."""
|
|
|
|
def __init__(
|
|
self,
|
|
supports_projects: bool = True,
|
|
supports_workspaces: bool = True,
|
|
projects_repo: ProjectRepository | None = None,
|
|
memberships_repo: ProjectMembershipRepository | None = None,
|
|
workspaces_repo: WorkspaceRepository | None = None,
|
|
) -> None:
|
|
"""Initialize provider with capability flags."""
|
|
self.supports_projects = supports_projects
|
|
self.supports_workspaces = supports_workspaces
|
|
self.projects: ProjectRepository = projects_repo or MagicMock(spec=ProjectRepository)
|
|
self.project_memberships: ProjectMembershipRepository = memberships_repo or MagicMock(
|
|
spec=ProjectMembershipRepository
|
|
)
|
|
self.workspaces: WorkspaceRepository = workspaces_repo or MagicMock(
|
|
spec=WorkspaceRepository
|
|
)
|
|
self.commit = AsyncMock()
|
|
|
|
async def __aenter__(self) -> MockProjectRepositoryProvider:
|
|
"""Enter async context."""
|
|
return self
|
|
|
|
async def __aexit__(
|
|
self,
|
|
exc_type: type[BaseException] | None,
|
|
exc_val: BaseException | None,
|
|
exc_tb: object,
|
|
) -> None:
|
|
"""Exit async context."""
|
|
|
|
|
|
class MockProjectServicerHost(ProjectMixin, ProjectMembershipMixin):
|
|
"""Mock servicer host implementing required protocol for ProjectMixin.
|
|
|
|
Implements the minimal ServicerHost protocol needed by ProjectMixin
|
|
and ProjectMembershipMixin:
|
|
- project_service for all project operations
|
|
- create_repository_provider() for UnitOfWork context
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
project_service: MagicMock | None,
|
|
supports_projects: bool = True,
|
|
supports_workspaces: bool = True,
|
|
) -> None:
|
|
"""Initialize with mock project service and repository provider."""
|
|
self.project_service = project_service
|
|
self._supports_projects = supports_projects
|
|
self._supports_workspaces = supports_workspaces
|
|
|
|
def create_repository_provider(self) -> MockProjectRepositoryProvider:
|
|
"""Create mock repository provider context manager."""
|
|
return MockProjectRepositoryProvider(
|
|
supports_projects=self._supports_projects,
|
|
supports_workspaces=self._supports_workspaces,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Fixtures
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def mockproject_service() -> MagicMock:
|
|
"""Create mock project service with common methods."""
|
|
service = MagicMock(spec=ProjectService)
|
|
# Project CRUD
|
|
service.create_project = AsyncMock(return_value=None)
|
|
service.get_project = AsyncMock(return_value=None)
|
|
service.get_project_by_slug = AsyncMock(return_value=None)
|
|
service.list_projects = AsyncMock(return_value=[])
|
|
service.update_project = AsyncMock(return_value=None)
|
|
service.archive_project = AsyncMock(return_value=None)
|
|
service.restore_project = AsyncMock(return_value=None)
|
|
service.delete_project = AsyncMock(return_value=False)
|
|
# Membership
|
|
service.add_project_member = AsyncMock(return_value=None)
|
|
service.update_project_member_role = AsyncMock(return_value=None)
|
|
service.remove_project_member = AsyncMock(return_value=False)
|
|
service.list_project_members = AsyncMock(return_value=[])
|
|
return service
|
|
|
|
|
|
@pytest.fixture
|
|
def project_mixin_servicer(
|
|
mockproject_service: MagicMock,
|
|
) -> MockProjectServicerHost:
|
|
"""Create servicer with mock project service."""
|
|
return MockProjectServicerHost(mockproject_service)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_project() -> Project:
|
|
"""Create sample project for testing."""
|
|
return Project(
|
|
id=uuid4(),
|
|
workspace_id=uuid4(),
|
|
name="Test Project",
|
|
slug="test-project",
|
|
description="A test project",
|
|
is_default=False,
|
|
settings=ProjectSettings(),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_project_with_settings() -> Project:
|
|
"""Create sample project with full settings."""
|
|
template_id = uuid4()
|
|
return Project(
|
|
id=uuid4(),
|
|
workspace_id=uuid4(),
|
|
name="Configured Project",
|
|
slug="configured",
|
|
settings=ProjectSettings(
|
|
export_rules=ExportRules(
|
|
default_format=ExportFormat.PDF,
|
|
include_audio=True,
|
|
include_timestamps=True,
|
|
template_id=template_id,
|
|
),
|
|
trigger_rules=TriggerRules(
|
|
auto_start_enabled=True,
|
|
calendar_match_patterns=["*standup*"],
|
|
app_match_patterns=["Zoom"],
|
|
),
|
|
rag_enabled=True,
|
|
default_summarization_template="professional",
|
|
),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_project_membership() -> ProjectMembership:
|
|
"""Create sample project membership."""
|
|
return ProjectMembership(
|
|
project_id=uuid4(),
|
|
user_id=uuid4(),
|
|
role=ProjectRole.EDITOR,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# TestCreateProject
|
|
# ============================================================================
|
|
|
|
|
|
class TestCreateProject:
|
|
"""Tests for CreateProject RPC."""
|
|
|
|
async def test_create_project_basic(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
sample_project: Project,
|
|
) -> None:
|
|
"""CreateProject creates project with provided name."""
|
|
mockproject_service.create_project.return_value = sample_project
|
|
|
|
request = noteflow_pb2.CreateProjectRequest(
|
|
workspace_id=str(sample_project.workspace_id),
|
|
name="Test Project",
|
|
)
|
|
response = await project_mixin_servicer.CreateProject(request, mock_grpc_context)
|
|
|
|
# Response is ProjectProto directly (not wrapped)
|
|
expected_project_id = str(sample_project.id)
|
|
assert response.id == expected_project_id, "ID should match"
|
|
assert response.name == "Test Project", "Name should match"
|
|
mockproject_service.create_project.assert_called_once()
|
|
|
|
async def test_create_project_with_slug(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
sample_project: Project,
|
|
) -> None:
|
|
"""CreateProject accepts optional slug."""
|
|
mockproject_service.create_project.return_value = sample_project
|
|
|
|
request = noteflow_pb2.CreateProjectRequest(
|
|
workspace_id=str(sample_project.workspace_id),
|
|
name="Test Project",
|
|
slug="test-project",
|
|
)
|
|
response = await project_mixin_servicer.CreateProject(request, mock_grpc_context)
|
|
|
|
# Response is ProjectProto directly
|
|
assert response.slug == "test-project", "Slug should match"
|
|
|
|
async def test_create_project_with_settings(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
sample_project_with_settings: Project,
|
|
) -> None:
|
|
"""CreateProject accepts settings."""
|
|
mockproject_service.create_project.return_value = sample_project_with_settings
|
|
|
|
request = noteflow_pb2.CreateProjectRequest(
|
|
workspace_id=str(sample_project_with_settings.workspace_id),
|
|
name="Configured Project",
|
|
settings=noteflow_pb2.ProjectSettingsProto(
|
|
rag_enabled=True,
|
|
export_rules=noteflow_pb2.ExportRulesProto(
|
|
default_format=noteflow_pb2.EXPORT_FORMAT_PDF,
|
|
),
|
|
),
|
|
)
|
|
response = await project_mixin_servicer.CreateProject(request, mock_grpc_context)
|
|
|
|
# Response is ProjectProto directly
|
|
assert response.settings.rag_enabled is True, "rag_enabled should be set"
|
|
|
|
|
|
# ============================================================================
|
|
# TestGetProject
|
|
# ============================================================================
|
|
|
|
|
|
class TestGetProject:
|
|
"""Tests for GetProject RPC."""
|
|
|
|
async def test_get_project_found(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
sample_project: Project,
|
|
) -> None:
|
|
"""GetProject returns project when found."""
|
|
mockproject_service.get_project.return_value = sample_project
|
|
|
|
request = noteflow_pb2.GetProjectRequest(project_id=str(sample_project.id))
|
|
response = await project_mixin_servicer.GetProject(request, mock_grpc_context)
|
|
|
|
# Response is ProjectProto directly
|
|
expected_project_id = str(sample_project.id)
|
|
assert response.id == expected_project_id, "ID should match"
|
|
assert response.name == sample_project.name, "Name should match"
|
|
|
|
|
|
# ============================================================================
|
|
# TestGetProjectBySlug
|
|
# ============================================================================
|
|
|
|
|
|
class TestGetProjectBySlug:
|
|
"""Tests for GetProjectBySlug RPC."""
|
|
|
|
async def test_get_project_by_slug_found(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
sample_project: Project,
|
|
) -> None:
|
|
"""GetProjectBySlug returns project when found."""
|
|
mockproject_service.get_project_by_slug.return_value = sample_project
|
|
|
|
request = noteflow_pb2.GetProjectBySlugRequest(
|
|
workspace_id=str(sample_project.workspace_id),
|
|
slug="test-project",
|
|
)
|
|
response = await project_mixin_servicer.GetProjectBySlug(request, mock_grpc_context)
|
|
|
|
# Response is ProjectProto directly
|
|
assert response.slug == "test-project", "Slug should match"
|
|
|
|
|
|
# ============================================================================
|
|
# TestListProjects
|
|
# ============================================================================
|
|
|
|
|
|
class TestListProjects:
|
|
"""Tests for ListProjects RPC."""
|
|
|
|
async def test_list_projects_empty(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""ListProjects returns empty list when no projects exist."""
|
|
mockproject_service.list_projects.return_value = []
|
|
|
|
request = noteflow_pb2.ListProjectsRequest(workspace_id=str(uuid4()))
|
|
response = await project_mixin_servicer.ListProjects(request, mock_grpc_context)
|
|
|
|
assert len(response.projects) == 0, "Should return empty list"
|
|
|
|
async def test_list_projects_returns_multiple(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""ListProjects returns multiple projects."""
|
|
workspace_id = uuid4()
|
|
projects = [
|
|
Project(id=uuid4(), workspace_id=workspace_id, name=f"Project {i}") for i in range(3)
|
|
]
|
|
mockproject_service.list_projects.return_value = projects
|
|
|
|
request = noteflow_pb2.ListProjectsRequest(workspace_id=str(workspace_id))
|
|
response = await project_mixin_servicer.ListProjects(request, mock_grpc_context)
|
|
|
|
assert len(response.projects) == 3, "Should return all projects"
|
|
|
|
async def test_list_projects_with_pagination(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""ListProjects respects pagination parameters."""
|
|
workspace_id = uuid4()
|
|
mockproject_service.list_projects.return_value = []
|
|
|
|
request = noteflow_pb2.ListProjectsRequest(
|
|
workspace_id=str(workspace_id),
|
|
limit=10,
|
|
offset=5,
|
|
)
|
|
await project_mixin_servicer.ListProjects(request, mock_grpc_context)
|
|
|
|
mockproject_service.list_projects.assert_called_once()
|
|
call_kwargs = mockproject_service.list_projects.call_args[1]
|
|
assert call_kwargs["limit"] == 10, "Limit should be passed"
|
|
assert call_kwargs["offset"] == 5, "Offset should be passed"
|
|
|
|
|
|
# ============================================================================
|
|
# TestUpdateProject
|
|
# ============================================================================
|
|
|
|
|
|
class TestUpdateProject:
|
|
"""Tests for UpdateProject RPC."""
|
|
|
|
async def test_update_project_name(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
sample_project: Project,
|
|
) -> None:
|
|
"""UpdateProject updates project name."""
|
|
updated_project = Project(
|
|
id=sample_project.id,
|
|
workspace_id=sample_project.workspace_id,
|
|
name="Updated Name",
|
|
)
|
|
mockproject_service.update_project.return_value = updated_project
|
|
|
|
request = noteflow_pb2.UpdateProjectRequest(
|
|
project_id=str(sample_project.id),
|
|
name="Updated Name",
|
|
)
|
|
response = await project_mixin_servicer.UpdateProject(request, mock_grpc_context)
|
|
|
|
# Response is ProjectProto directly
|
|
assert response.name == "Updated Name", "Name should be updated"
|
|
|
|
|
|
# ============================================================================
|
|
# TestProjectNotFound
|
|
# ============================================================================
|
|
|
|
# Test data for not-found tests - defined at module level to avoid eager test detection
|
|
_PROJECT_NOT_FOUND_TEST_CASES: list[tuple[str, str, object]] = [
|
|
("GetProject", "get_project", noteflow_pb2.GetProjectRequest(project_id=str(uuid4()))),
|
|
(
|
|
"GetProjectBySlug",
|
|
"get_project_by_slug",
|
|
noteflow_pb2.GetProjectBySlugRequest(workspace_id=str(uuid4()), slug="nonexistent"),
|
|
),
|
|
(
|
|
"UpdateProject",
|
|
"update_project",
|
|
noteflow_pb2.UpdateProjectRequest(project_id=str(uuid4()), name="Updated Name"),
|
|
),
|
|
(
|
|
"ArchiveProject",
|
|
"archive_project",
|
|
noteflow_pb2.ArchiveProjectRequest(project_id=str(uuid4())),
|
|
),
|
|
(
|
|
"RestoreProject",
|
|
"restore_project",
|
|
noteflow_pb2.RestoreProjectRequest(project_id=str(uuid4())),
|
|
),
|
|
(
|
|
"AddProjectMember",
|
|
"add_project_member",
|
|
noteflow_pb2.AddProjectMemberRequest(
|
|
project_id=str(uuid4()), user_id=str(uuid4()), role=noteflow_pb2.PROJECT_ROLE_EDITOR
|
|
),
|
|
),
|
|
(
|
|
"UpdateProjectMemberRole",
|
|
"update_project_member_role",
|
|
noteflow_pb2.UpdateProjectMemberRoleRequest(
|
|
project_id=str(uuid4()), user_id=str(uuid4()), role=noteflow_pb2.PROJECT_ROLE_ADMIN
|
|
),
|
|
),
|
|
]
|
|
|
|
_PROJECT_NOT_FOUND_IDS: list[str] = [
|
|
"get_project",
|
|
"get_project_by_slug",
|
|
"update_project",
|
|
"archive_project",
|
|
"restore_project",
|
|
"add_project_member",
|
|
"update_project_member_role",
|
|
]
|
|
|
|
|
|
class TestProjectNotFound:
|
|
"""Tests for project RPCs returning NOT_FOUND."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("method_name", "service_attr", "proto_request"),
|
|
_PROJECT_NOT_FOUND_TEST_CASES,
|
|
ids=_PROJECT_NOT_FOUND_IDS,
|
|
)
|
|
async def test_project_not_found_aborts(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
method_name: str,
|
|
service_attr: str,
|
|
proto_request: object,
|
|
) -> None:
|
|
"""Project RPCs should abort when service returns None."""
|
|
getattr(mockproject_service, service_attr).return_value = None
|
|
|
|
method = getattr(project_mixin_servicer, method_name)
|
|
with pytest.raises(AssertionError, match="Unreachable"):
|
|
await method(proto_request, mock_grpc_context)
|
|
|
|
mock_grpc_context.abort.assert_called_once()
|
|
|
|
|
|
# ============================================================================
|
|
# TestArchiveProject
|
|
# ============================================================================
|
|
|
|
|
|
class TestArchiveProject:
|
|
"""Tests for ArchiveProject RPC."""
|
|
|
|
async def test_archive_project_success(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
sample_project: Project,
|
|
) -> None:
|
|
"""ArchiveProject archives project successfully."""
|
|
sample_project.archive()
|
|
mockproject_service.archive_project.return_value = sample_project
|
|
|
|
request = noteflow_pb2.ArchiveProjectRequest(project_id=str(sample_project.id))
|
|
response = await project_mixin_servicer.ArchiveProject(request, mock_grpc_context)
|
|
|
|
# Response is ProjectProto directly
|
|
assert response.archived_at > 0, "archived_at should be set"
|
|
|
|
async def test_archive_default_project_fails(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""ArchiveProject fails for default project (mixin converts to gRPC error)."""
|
|
from noteflow.domain.errors import CannotArchiveDefaultProjectError
|
|
|
|
mockproject_service.archive_project.side_effect = CannotArchiveDefaultProjectError(
|
|
"test-id"
|
|
)
|
|
|
|
request = noteflow_pb2.ArchiveProjectRequest(project_id=str(uuid4()))
|
|
|
|
# Mixin catches the error and calls abort_failed_precondition
|
|
with pytest.raises(AssertionError, match="Unreachable"):
|
|
await project_mixin_servicer.ArchiveProject(request, mock_grpc_context)
|
|
|
|
mock_grpc_context.abort.assert_called_once()
|
|
|
|
|
|
# ============================================================================
|
|
# TestRestoreProject
|
|
# ============================================================================
|
|
|
|
|
|
class TestRestoreProject:
|
|
"""Tests for RestoreProject RPC."""
|
|
|
|
async def test_restore_project_success(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
sample_project: Project,
|
|
) -> None:
|
|
"""RestoreProject restores archived project."""
|
|
mockproject_service.restore_project.return_value = sample_project
|
|
|
|
request = noteflow_pb2.RestoreProjectRequest(project_id=str(sample_project.id))
|
|
response = await project_mixin_servicer.RestoreProject(request, mock_grpc_context)
|
|
|
|
# Response is ProjectProto directly
|
|
assert response.archived_at == 0, "archived_at should be cleared"
|
|
|
|
|
|
# ============================================================================
|
|
# TestDeleteProject
|
|
# ============================================================================
|
|
|
|
|
|
class TestDeleteProject:
|
|
"""Tests for DeleteProject RPC."""
|
|
|
|
async def test_delete_project_success(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""DeleteProject deletes project successfully."""
|
|
mockproject_service.delete_project.return_value = True
|
|
|
|
request = noteflow_pb2.DeleteProjectRequest(project_id=str(uuid4()))
|
|
response = await project_mixin_servicer.DeleteProject(request, mock_grpc_context)
|
|
|
|
assert response.success is True, "Should return success"
|
|
|
|
async def test_delete_project_not_found_returns_success_false(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""DeleteProject returns success=false when project doesn't exist."""
|
|
mockproject_service.delete_project.return_value = False
|
|
|
|
request = noteflow_pb2.DeleteProjectRequest(project_id=str(uuid4()))
|
|
response = await project_mixin_servicer.DeleteProject(request, mock_grpc_context)
|
|
|
|
# Mixin returns success=false without aborting
|
|
assert response.success is False, "Should return success=false"
|
|
|
|
|
|
# ============================================================================
|
|
# TestAddProjectMember
|
|
# ============================================================================
|
|
|
|
|
|
class TestAddProjectMember:
|
|
"""Tests for AddProjectMember RPC."""
|
|
|
|
async def test_add_project_member_success(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
sample_project_membership: ProjectMembership,
|
|
) -> None:
|
|
"""AddProjectMember adds member successfully."""
|
|
mockproject_service.add_project_member.return_value = sample_project_membership
|
|
|
|
request = noteflow_pb2.AddProjectMemberRequest(
|
|
project_id=str(sample_project_membership.project_id),
|
|
user_id=str(sample_project_membership.user_id),
|
|
role=noteflow_pb2.PROJECT_ROLE_EDITOR,
|
|
)
|
|
response = await project_mixin_servicer.AddProjectMember(request, mock_grpc_context)
|
|
|
|
# Response is ProjectMembershipProto directly
|
|
expected_user_id = str(sample_project_membership.user_id)
|
|
assert response.user_id == expected_user_id, "User ID should match"
|
|
assert response.role == noteflow_pb2.PROJECT_ROLE_EDITOR, "Role should match"
|
|
|
|
|
|
# ============================================================================
|
|
# TestUpdateProjectMemberRole
|
|
# ============================================================================
|
|
|
|
|
|
class TestUpdateProjectMemberRole:
|
|
"""Tests for UpdateProjectMemberRole RPC."""
|
|
|
|
async def test_update_project_member_role_success(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""UpdateProjectMemberRole updates role successfully."""
|
|
updated_membership = ProjectMembership(
|
|
project_id=uuid4(),
|
|
user_id=uuid4(),
|
|
role=ProjectRole.ADMIN,
|
|
)
|
|
mockproject_service.update_project_member_role.return_value = updated_membership
|
|
|
|
request = noteflow_pb2.UpdateProjectMemberRoleRequest(
|
|
project_id=str(updated_membership.project_id),
|
|
user_id=str(updated_membership.user_id),
|
|
role=noteflow_pb2.PROJECT_ROLE_ADMIN,
|
|
)
|
|
response = await project_mixin_servicer.UpdateProjectMemberRole(request, mock_grpc_context)
|
|
|
|
# Response is ProjectMembershipProto directly
|
|
assert response.role == noteflow_pb2.PROJECT_ROLE_ADMIN, "Role should be updated"
|
|
|
|
|
|
# ============================================================================
|
|
# TestRemoveProjectMember
|
|
# ============================================================================
|
|
|
|
|
|
class TestRemoveProjectMember:
|
|
"""Tests for RemoveProjectMember RPC."""
|
|
|
|
async def test_remove_project_member_success(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""RemoveProjectMember removes member successfully."""
|
|
mockproject_service.remove_project_member.return_value = True
|
|
|
|
request = noteflow_pb2.RemoveProjectMemberRequest(
|
|
project_id=str(uuid4()),
|
|
user_id=str(uuid4()),
|
|
)
|
|
response = await project_mixin_servicer.RemoveProjectMember(request, mock_grpc_context)
|
|
|
|
assert response.success is True, "Should return success"
|
|
|
|
async def test_remove_project_member_not_found(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""RemoveProjectMember returns success=false when membership doesn't exist."""
|
|
mockproject_service.remove_project_member.return_value = False
|
|
|
|
request = noteflow_pb2.RemoveProjectMemberRequest(
|
|
project_id=str(uuid4()),
|
|
user_id=str(uuid4()),
|
|
)
|
|
response = await project_mixin_servicer.RemoveProjectMember(request, mock_grpc_context)
|
|
|
|
# Mixin returns success=false without aborting
|
|
assert response.success is False, "Should return success=false"
|
|
|
|
|
|
# ============================================================================
|
|
# TestListProjectMembers
|
|
# ============================================================================
|
|
|
|
|
|
class TestListProjectMembers:
|
|
"""Tests for ListProjectMembers RPC."""
|
|
|
|
async def test_list_project_members_empty(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""ListProjectMembers returns empty list when no members exist."""
|
|
mockproject_service.list_project_members.return_value = []
|
|
|
|
request = noteflow_pb2.ListProjectMembersRequest(project_id=str(uuid4()))
|
|
response = await project_mixin_servicer.ListProjectMembers(request, mock_grpc_context)
|
|
|
|
assert len(response.members) == 0, "Should return empty list"
|
|
|
|
async def test_list_project_members_returns_multiple(
|
|
self,
|
|
project_mixin_servicer: MockProjectServicerHost,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""ListProjectMembers returns multiple members."""
|
|
project_id = uuid4()
|
|
members = [
|
|
ProjectMembership(
|
|
project_id=project_id,
|
|
user_id=uuid4(),
|
|
role=ProjectRole.EDITOR,
|
|
)
|
|
for _ in range(3)
|
|
]
|
|
mockproject_service.list_project_members.return_value = members
|
|
|
|
request = noteflow_pb2.ListProjectMembersRequest(project_id=str(project_id))
|
|
response = await project_mixin_servicer.ListProjectMembers(request, mock_grpc_context)
|
|
|
|
assert len(response.members) == 3, "Should return all members"
|
|
|
|
|
|
# ============================================================================
|
|
# TestProjectServiceNotConfigured
|
|
# ============================================================================
|
|
|
|
|
|
class TestProjectServiceNotConfigured:
|
|
"""Tests for when project service is not configured."""
|
|
|
|
async def test_create_project_fails_without_service(
|
|
self,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""CreateProject aborts when project service is not configured."""
|
|
servicer = MockProjectServicerHost(project_service=None)
|
|
|
|
request = noteflow_pb2.CreateProjectRequest(
|
|
workspace_id=str(uuid4()),
|
|
name="Test",
|
|
)
|
|
|
|
with pytest.raises(AssertionError, match="Unreachable"):
|
|
await servicer.CreateProject(request, mock_grpc_context)
|
|
|
|
mock_grpc_context.abort.assert_called_once()
|
|
|
|
async def test_get_project_fails_without_service(
|
|
self,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""GetProject aborts when project service is not configured."""
|
|
servicer = MockProjectServicerHost(project_service=None)
|
|
|
|
request = noteflow_pb2.GetProjectRequest(project_id=str(uuid4()))
|
|
|
|
with pytest.raises(AssertionError, match="Unreachable"):
|
|
await servicer.GetProject(request, mock_grpc_context)
|
|
|
|
mock_grpc_context.abort.assert_called_once()
|
|
|
|
|
|
# ============================================================================
|
|
# TestDatabaseNotConfigured
|
|
# ============================================================================
|
|
|
|
|
|
class TestDatabaseNotConfigured:
|
|
"""Tests for when database (supports_projects) is not configured."""
|
|
|
|
async def test_create_project_fails_without_database(
|
|
self,
|
|
mockproject_service: MagicMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""CreateProject aborts when database doesn't support projects."""
|
|
servicer = MockProjectServicerHost(
|
|
project_service=mockproject_service,
|
|
supports_projects=False,
|
|
)
|
|
|
|
request = noteflow_pb2.CreateProjectRequest(
|
|
workspace_id=str(uuid4()),
|
|
name="Test",
|
|
)
|
|
|
|
with pytest.raises(AssertionError, match="Unreachable"):
|
|
await servicer.CreateProject(request, mock_grpc_context)
|
|
|
|
mock_grpc_context.abort.assert_called_once()
|