Files
noteflow/tests/grpc/test_project_mixin.py
Travis Vasceannie e8ea0b24d6
Some checks failed
CI / test-typescript (push) Has been cancelled
CI / test-rust (push) Has been cancelled
CI / test-python (push) Has been cancelled
refactor: rename request parameter to proto_request in gRPC test methods for improved clarity.
2026-01-24 17:41:32 +00:00

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()