921 lines
36 KiB
Python
921 lines
36 KiB
Python
"""Tests for MeetingMixin gRPC endpoints.
|
|
|
|
Tests cover:
|
|
- CreateMeeting: with title, without title (default), with metadata
|
|
- StopMeeting: success, already stopped (idempotent), not found
|
|
- ListMeetings: empty list, multiple meetings, state filtering, sorting
|
|
- GetMeeting: found, not found, with segments, with summary
|
|
- DeleteMeeting: success, not found
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Callable, cast
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from uuid import UUID, uuid4
|
|
|
|
import pytest
|
|
|
|
from noteflow.domain.entities import Meeting, Segment
|
|
from noteflow.domain.identity import (
|
|
DEFAULT_USER_ID,
|
|
DEFAULT_WORKSPACE_ID,
|
|
OperationContext,
|
|
UserContext,
|
|
WorkspaceContext,
|
|
WorkspaceRole,
|
|
)
|
|
from noteflow.domain.ports.repositories.identity import ProjectRepository, WorkspaceRepository
|
|
from noteflow.domain.value_objects import MeetingId, MeetingState
|
|
from noteflow.grpc.mixins._types import GrpcContext
|
|
from noteflow.grpc.mixins.meeting import MeetingMixin
|
|
from noteflow.grpc.proto import noteflow_pb2
|
|
from noteflow.infrastructure.logging import request_id_var, user_id_var, workspace_id_var
|
|
from noteflow.infrastructure.audio.writer import MeetingAudioWriter
|
|
from noteflow.infrastructure.security.crypto.crypto_box import AesGcmCryptoBox
|
|
|
|
from .proto_types import (
|
|
CreateMeetingRequestProto,
|
|
DeleteMeetingRequestProto,
|
|
DeleteMeetingResponseProto,
|
|
GetMeetingRequestProto,
|
|
ListMeetingsRequestProto,
|
|
ListMeetingsResponseProto,
|
|
MeetingProto,
|
|
MeetingServicerProtocol,
|
|
StopMeetingRequestProto,
|
|
)
|
|
|
|
# Test constants for pagination
|
|
PAGE_LIMIT_SMALL = 25
|
|
PAGE_OFFSET_STANDARD = 50
|
|
|
|
|
|
# ============================================================================
|
|
# Mock Infrastructure
|
|
# ============================================================================
|
|
|
|
|
|
class MockMeetingRepositoryProvider:
|
|
"""Mock repository provider as async context manager."""
|
|
|
|
def __init__(
|
|
self,
|
|
meetings_repo: AsyncMock,
|
|
segments_repo: AsyncMock,
|
|
summaries_repo: AsyncMock,
|
|
diarization_jobs_repo: AsyncMock | None = None,
|
|
projects_repo: ProjectRepository | None = None,
|
|
workspaces_repo: WorkspaceRepository | None = None,
|
|
supports_projects: bool = False,
|
|
supports_workspaces: bool = False,
|
|
) -> None:
|
|
"""Initialize with mock repositories."""
|
|
self.meetings = meetings_repo
|
|
self.segments = segments_repo
|
|
self.summaries = summaries_repo
|
|
self.diarization_jobs = diarization_jobs_repo or AsyncMock()
|
|
self.supports_diarization_jobs = diarization_jobs_repo is not None
|
|
self.projects: ProjectRepository = projects_repo or MagicMock(spec=ProjectRepository)
|
|
self.workspaces: WorkspaceRepository = workspaces_repo or MagicMock(
|
|
spec=WorkspaceRepository
|
|
)
|
|
self.supports_projects = supports_projects
|
|
self.supports_workspaces = supports_workspaces
|
|
self.commit = AsyncMock()
|
|
|
|
async def __aenter__(self) -> MockMeetingRepositoryProvider:
|
|
"""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 MockMeetingMixinServicerHost(MeetingMixin):
|
|
"""Mock servicer host implementing required protocol for MeetingMixin.
|
|
|
|
Implements the minimal ServicerHost protocol needed by MeetingMixin:
|
|
- create_repository_provider()
|
|
- active_streams, stop_requested for StopMeeting
|
|
- audio_writers, close_audio_writer for StopMeeting
|
|
- webhook_service for StopMeeting webhook triggers
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
meetings_repo: AsyncMock,
|
|
segments_repo: AsyncMock | None = None,
|
|
summaries_repo: AsyncMock | None = None,
|
|
diarization_jobs_repo: AsyncMock | None = None,
|
|
projects_repo: ProjectRepository | None = None,
|
|
workspaces_repo: WorkspaceRepository | None = None,
|
|
webhook_service: MagicMock | None = None,
|
|
) -> None:
|
|
"""Initialize with mock repositories."""
|
|
self._meetings_repo = meetings_repo
|
|
self._segments_repo = segments_repo or AsyncMock()
|
|
self._summaries_repo = summaries_repo or AsyncMock()
|
|
self.diarization_jobs_repo = diarization_jobs_repo
|
|
self._projects_repo: ProjectRepository = projects_repo or MagicMock(spec=ProjectRepository)
|
|
self._workspaces_repo: WorkspaceRepository = workspaces_repo or MagicMock(
|
|
spec=WorkspaceRepository
|
|
)
|
|
|
|
# Streaming state required by StopMeeting
|
|
self.active_streams: set[str] = set()
|
|
self.stop_requested: set[str] = set()
|
|
self.audio_writers: dict[str, MeetingAudioWriter] = {}
|
|
|
|
# Webhook service (optional)
|
|
self.webhook_service = webhook_service
|
|
self.project_service = None
|
|
self.summarization_service = None # Post-processing disabled in tests
|
|
self.diarization_auto_refine = False # Auto-diarization disabled in tests
|
|
self.diarization_engine = None
|
|
self.embedder = None # AI embedder disabled in tests
|
|
|
|
def create_repository_provider(self) -> MockMeetingRepositoryProvider:
|
|
"""Create mock repository provider context manager."""
|
|
return MockMeetingRepositoryProvider(
|
|
self._meetings_repo,
|
|
self._segments_repo,
|
|
self._summaries_repo,
|
|
self.diarization_jobs_repo,
|
|
self._projects_repo,
|
|
self._workspaces_repo,
|
|
supports_projects=False,
|
|
supports_workspaces=False,
|
|
)
|
|
|
|
def close_audio_writer(self, meeting_id: str) -> None:
|
|
"""Mock audio writer close."""
|
|
self.audio_writers.pop(meeting_id, None)
|
|
|
|
def get_operation_context(self, context: GrpcContext) -> OperationContext:
|
|
"""Get operation context from gRPC context variables."""
|
|
request_id = request_id_var.get()
|
|
user_id_str = user_id_var.get()
|
|
workspace_id_str = workspace_id_var.get()
|
|
|
|
user_id = UUID(user_id_str) if user_id_str else DEFAULT_USER_ID
|
|
workspace_id = UUID(workspace_id_str) if workspace_id_str else DEFAULT_WORKSPACE_ID
|
|
|
|
return OperationContext(
|
|
user=UserContext(user_id=user_id, display_name=""),
|
|
workspace=WorkspaceContext(
|
|
workspace_id=workspace_id,
|
|
workspace_name="",
|
|
role=WorkspaceRole.OWNER,
|
|
),
|
|
request_id=request_id,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Fixtures
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.fixture
|
|
def meeting_mixin_meetings_repo() -> AsyncMock:
|
|
"""Create mock meetings repository with common methods."""
|
|
repo = AsyncMock()
|
|
repo.create = AsyncMock()
|
|
repo.get = AsyncMock(return_value=None)
|
|
repo.update = AsyncMock()
|
|
repo.delete = AsyncMock(return_value=False)
|
|
repo.list_all = AsyncMock(return_value=([], 0))
|
|
return repo
|
|
|
|
|
|
@pytest.fixture
|
|
def meeting_mixin_segments_repo() -> AsyncMock:
|
|
"""Create mock segments repository."""
|
|
repo = AsyncMock()
|
|
repo.get_by_meeting = AsyncMock(return_value=[])
|
|
return repo
|
|
|
|
|
|
@pytest.fixture
|
|
def meeting_mixin_summaries_repo() -> AsyncMock:
|
|
"""Create mock summaries repository."""
|
|
repo = AsyncMock()
|
|
repo.get_by_meeting = AsyncMock(return_value=None)
|
|
return repo
|
|
|
|
|
|
@pytest.fixture
|
|
def meeting_mixin_servicer(
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
meeting_mixin_segments_repo: AsyncMock,
|
|
meeting_mixin_summaries_repo: AsyncMock,
|
|
) -> MeetingServicerProtocol:
|
|
"""Create servicer with mock repositories for meeting mixin tests."""
|
|
# Cast required: proto stubs are excluded from analysis; mixin methods type as unknown.
|
|
return cast(
|
|
MeetingServicerProtocol,
|
|
MockMeetingMixinServicerHost(
|
|
meeting_mixin_meetings_repo,
|
|
meeting_mixin_segments_repo,
|
|
meeting_mixin_summaries_repo,
|
|
),
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# TestCreateMeeting
|
|
# ============================================================================
|
|
|
|
|
|
class TestCreateMeeting:
|
|
"""Tests for CreateMeeting RPC."""
|
|
|
|
async def test_create_meeting_with_title(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""CreateMeeting creates meeting with provided title."""
|
|
expected_meeting = Meeting.create(title="Team Standup")
|
|
meeting_mixin_meetings_repo.create.return_value = expected_meeting
|
|
|
|
request: CreateMeetingRequestProto = noteflow_pb2.CreateMeetingRequest(title="Team Standup")
|
|
response: MeetingProto = await meeting_mixin_servicer.CreateMeeting(
|
|
request, mock_grpc_context
|
|
)
|
|
|
|
assert response.id != "", "Response should have meeting ID"
|
|
assert response.title == "Team Standup", "Title should match request"
|
|
assert response.state == MeetingState.CREATED.value, "State should be CREATED"
|
|
meeting_mixin_meetings_repo.create.assert_called_once()
|
|
|
|
async def test_create_meeting_without_title_uses_default(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""CreateMeeting uses timestamped default title when not provided."""
|
|
# Meeting.create() generates default title with timestamp
|
|
expected_meeting = Meeting.create(title="")
|
|
meeting_mixin_meetings_repo.create.return_value = expected_meeting
|
|
|
|
request: CreateMeetingRequestProto = noteflow_pb2.CreateMeetingRequest()
|
|
response: MeetingProto = await meeting_mixin_servicer.CreateMeeting(
|
|
request, mock_grpc_context
|
|
)
|
|
|
|
assert response.id != "", "Response should have meeting ID"
|
|
assert "Meeting" in response.title, "Default title should contain 'Meeting'"
|
|
meeting_mixin_meetings_repo.create.assert_called_once()
|
|
|
|
async def test_grpc_create_meeting_with_metadata(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""CreateMeeting preserves metadata from request."""
|
|
metadata = {"project": "NoteFlow", "priority": "high"}
|
|
expected_meeting = Meeting.create(title="Planning", metadata=metadata)
|
|
meeting_mixin_meetings_repo.create.return_value = expected_meeting
|
|
|
|
request: CreateMeetingRequestProto = noteflow_pb2.CreateMeetingRequest(
|
|
title="Planning",
|
|
metadata=metadata,
|
|
)
|
|
response: MeetingProto = await meeting_mixin_servicer.CreateMeeting(
|
|
request, mock_grpc_context
|
|
)
|
|
|
|
assert response.metadata["project"] == "NoteFlow", "Project metadata should match"
|
|
assert response.metadata["priority"] == "high", "Priority metadata should match"
|
|
meeting_mixin_meetings_repo.create.assert_called_once()
|
|
|
|
# Verify the meeting passed to create had metadata
|
|
created_meeting = meeting_mixin_meetings_repo.create.call_args[0][0]
|
|
assert created_meeting.metadata == metadata, "Created meeting should have metadata"
|
|
|
|
async def test_create_meeting_commits_transaction(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""CreateMeeting commits the transaction after creation."""
|
|
expected_meeting = Meeting.create(title="Commit Test")
|
|
meeting_mixin_meetings_repo.create.return_value = expected_meeting
|
|
|
|
request: CreateMeetingRequestProto = noteflow_pb2.CreateMeetingRequest(title="Commit Test")
|
|
await meeting_mixin_servicer.CreateMeeting(request, mock_grpc_context)
|
|
|
|
# Verify create was called (commit happens in context manager)
|
|
meeting_mixin_meetings_repo.create.assert_called_once()
|
|
|
|
|
|
# ============================================================================
|
|
# TestStopMeeting
|
|
# ============================================================================
|
|
|
|
|
|
class TestStopMeeting:
|
|
"""Tests for StopMeeting RPC."""
|
|
|
|
async def test_stop_recording_meeting_transitions_to_stopped(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""StopMeeting transitions RECORDING meeting to STOPPED."""
|
|
meeting_id = MeetingId(uuid4())
|
|
recording_meeting = Meeting.create(title="Recording Meeting")
|
|
recording_meeting.id = meeting_id
|
|
recording_meeting.start_recording()
|
|
meeting_mixin_meetings_repo.get.return_value = recording_meeting
|
|
|
|
request: StopMeetingRequestProto = noteflow_pb2.StopMeetingRequest(
|
|
meeting_id=str(meeting_id)
|
|
)
|
|
response: MeetingProto = await meeting_mixin_servicer.StopMeeting(
|
|
request, mock_grpc_context
|
|
)
|
|
|
|
expected_meeting_id = str(meeting_id)
|
|
assert response.id == expected_meeting_id, "Response ID should match"
|
|
assert response.state == MeetingState.STOPPED.value, "State should be STOPPED"
|
|
meeting_mixin_meetings_repo.update.assert_called_once()
|
|
|
|
@pytest.mark.parametrize(
|
|
("initial_state", "title_suffix"),
|
|
[
|
|
pytest.param(MeetingState.STOPPED, "Stopped", id="stopped"),
|
|
pytest.param(MeetingState.STOPPING, "Stopping", id="stopping"),
|
|
pytest.param(MeetingState.COMPLETED, "Completed", id="completed"),
|
|
],
|
|
)
|
|
async def test_stop_is_idempotent(
|
|
self,
|
|
initial_state: MeetingState,
|
|
title_suffix: str,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""StopMeeting returns success for already-stopped meetings (idempotent)."""
|
|
meeting_id = MeetingId(uuid4())
|
|
meeting = Meeting.create(title=f"Already {title_suffix}")
|
|
meeting.id = meeting_id
|
|
meeting.state = initial_state
|
|
meeting_mixin_meetings_repo.get.return_value = meeting
|
|
|
|
request: StopMeetingRequestProto = noteflow_pb2.StopMeetingRequest(
|
|
meeting_id=str(meeting_id)
|
|
)
|
|
response: MeetingProto = await meeting_mixin_servicer.StopMeeting(
|
|
request, mock_grpc_context
|
|
)
|
|
|
|
assert response.state == initial_state.value, f"State should remain {initial_state.name}"
|
|
meeting_mixin_meetings_repo.update.assert_not_called()
|
|
|
|
async def test_stop_meeting_closes_audio_writer(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
crypto: AesGcmCryptoBox,
|
|
meetings_dir: Path,
|
|
) -> None:
|
|
"""StopMeeting closes audio writer if present."""
|
|
meeting_id = MeetingId(uuid4())
|
|
recording_meeting = Meeting.create(title="Recording with Audio")
|
|
recording_meeting.id = meeting_id
|
|
recording_meeting.start_recording()
|
|
meeting_mixin_meetings_repo.get.return_value = recording_meeting
|
|
|
|
# Add mock audio writer
|
|
audio_writer = MeetingAudioWriter(crypto, meetings_dir)
|
|
meeting_mixin_servicer.audio_writers[str(meeting_id)] = audio_writer
|
|
|
|
request: StopMeetingRequestProto = noteflow_pb2.StopMeetingRequest(
|
|
meeting_id=str(meeting_id)
|
|
)
|
|
await meeting_mixin_servicer.StopMeeting(request, mock_grpc_context)
|
|
|
|
meeting_id_str = str(meeting_id)
|
|
assert meeting_id_str not in meeting_mixin_servicer.audio_writers, (
|
|
"Audio writer should be removed"
|
|
)
|
|
|
|
async def test_stop_meeting_triggers_webhooks(
|
|
self,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
meeting_mixin_segments_repo: AsyncMock,
|
|
meeting_mixin_summaries_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""StopMeeting triggers recording.stopped and meeting.completed webhooks."""
|
|
mock_webhook_service = MagicMock()
|
|
mock_webhook_service.trigger_recording_stopped = AsyncMock()
|
|
mock_webhook_service.trigger_meeting_completed = AsyncMock()
|
|
|
|
servicer: MeetingServicerProtocol = cast(
|
|
MeetingServicerProtocol,
|
|
MockMeetingMixinServicerHost(
|
|
meeting_mixin_meetings_repo,
|
|
meeting_mixin_segments_repo,
|
|
meeting_mixin_summaries_repo,
|
|
webhook_service=mock_webhook_service,
|
|
),
|
|
)
|
|
|
|
meeting_id = MeetingId(uuid4())
|
|
recording_meeting = Meeting.create(title="Webhook Test")
|
|
recording_meeting.id = meeting_id
|
|
recording_meeting.start_recording()
|
|
meeting_mixin_meetings_repo.get.return_value = recording_meeting
|
|
|
|
request: StopMeetingRequestProto = noteflow_pb2.StopMeetingRequest(
|
|
meeting_id=str(meeting_id)
|
|
)
|
|
await servicer.StopMeeting(request, mock_grpc_context)
|
|
|
|
mock_webhook_service.trigger_recording_stopped.assert_called_once()
|
|
mock_webhook_service.trigger_meeting_completed.assert_called_once()
|
|
|
|
|
|
# ============================================================================
|
|
# TestMeetingNotFound
|
|
# ============================================================================
|
|
|
|
|
|
class TestMeetingNotFound:
|
|
"""Tests for meeting-not-found gRPC scenarios."""
|
|
|
|
@staticmethod
|
|
def _configure_get_none(meetings_repo: AsyncMock) -> None:
|
|
meetings_repo.get.return_value = None
|
|
|
|
@staticmethod
|
|
def _configure_delete_false(meetings_repo: AsyncMock) -> None:
|
|
meetings_repo.delete.return_value = False
|
|
|
|
@pytest.mark.parametrize(
|
|
("method_name", "proto_request", "configure"),
|
|
[
|
|
pytest.param(
|
|
"StopMeeting",
|
|
noteflow_pb2.StopMeetingRequest(meeting_id=str(uuid4())),
|
|
_configure_get_none,
|
|
id="stop",
|
|
),
|
|
pytest.param(
|
|
"GetMeeting",
|
|
noteflow_pb2.GetMeetingRequest(meeting_id=str(uuid4())),
|
|
_configure_get_none,
|
|
id="get",
|
|
),
|
|
pytest.param(
|
|
"DeleteMeeting",
|
|
noteflow_pb2.DeleteMeetingRequest(meeting_id=str(uuid4())),
|
|
_configure_delete_false,
|
|
id="delete",
|
|
),
|
|
],
|
|
)
|
|
async def test_meeting_not_found_aborts(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
method_name: str,
|
|
proto_request: object,
|
|
configure: Callable[[AsyncMock], None],
|
|
) -> None:
|
|
"""Meeting operations abort with NOT_FOUND for missing meetings."""
|
|
configure(meeting_mixin_meetings_repo)
|
|
|
|
method = getattr(meeting_mixin_servicer, method_name)
|
|
with pytest.raises(AssertionError, match="Unreachable"):
|
|
await method(proto_request, mock_grpc_context)
|
|
|
|
mock_grpc_context.abort.assert_called_once()
|
|
|
|
|
|
# ============================================================================
|
|
# TestListMeetings
|
|
# ============================================================================
|
|
|
|
|
|
class TestListMeetings:
|
|
"""Tests for ListMeetings RPC."""
|
|
|
|
async def test_list_meetings_returns_empty_list(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""ListMeetings returns empty response when no meetings exist."""
|
|
meeting_mixin_meetings_repo.list_all.return_value = ([], 0)
|
|
|
|
request: ListMeetingsRequestProto = noteflow_pb2.ListMeetingsRequest()
|
|
response: ListMeetingsResponseProto = await meeting_mixin_servicer.ListMeetings(
|
|
request, mock_grpc_context
|
|
)
|
|
|
|
expected_empty = 0
|
|
assert response.total_count == expected_empty, "Total count should be 0"
|
|
assert len(response.meetings) == expected_empty, "Should return no meetings"
|
|
|
|
async def test_list_meetings_returns_multiple_meetings(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""ListMeetings returns all meetings."""
|
|
meetings = [
|
|
Meeting.create(title="Meeting 1"),
|
|
Meeting.create(title="Meeting 2"),
|
|
Meeting.create(title="Meeting 3"),
|
|
]
|
|
expected_count = len(meetings)
|
|
meeting_mixin_meetings_repo.list_all.return_value = (meetings, expected_count)
|
|
|
|
request: ListMeetingsRequestProto = noteflow_pb2.ListMeetingsRequest()
|
|
response: ListMeetingsResponseProto = await meeting_mixin_servicer.ListMeetings(
|
|
request, mock_grpc_context
|
|
)
|
|
|
|
assert response.total_count == expected_count, "Total count should be 3"
|
|
assert len(response.meetings) == expected_count, "Should return 3 meetings"
|
|
assert response.meetings[0].title == "Meeting 1", "First meeting title should match"
|
|
assert response.meetings[1].title == "Meeting 2", "Second meeting title should match"
|
|
assert response.meetings[2].title == "Meeting 3", "Third meeting title should match"
|
|
|
|
async def test_list_meetings_respects_limit(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""ListMeetings respects limit parameter."""
|
|
meeting_mixin_meetings_repo.list_all.return_value = ([], 0)
|
|
|
|
request: ListMeetingsRequestProto = noteflow_pb2.ListMeetingsRequest(limit=PAGE_LIMIT_SMALL)
|
|
await meeting_mixin_servicer.ListMeetings(request, mock_grpc_context)
|
|
|
|
call_kwargs = meeting_mixin_meetings_repo.list_all.call_args[1]
|
|
assert call_kwargs["limit"] == PAGE_LIMIT_SMALL, "Limit should be passed to repository"
|
|
|
|
async def test_list_meetings_uses_default_limit_of_100(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""ListMeetings uses default limit of 100 when not specified."""
|
|
meeting_mixin_meetings_repo.list_all.return_value = ([], 0)
|
|
|
|
request: ListMeetingsRequestProto = noteflow_pb2.ListMeetingsRequest()
|
|
await meeting_mixin_servicer.ListMeetings(request, mock_grpc_context)
|
|
|
|
call_kwargs = meeting_mixin_meetings_repo.list_all.call_args[1]
|
|
assert call_kwargs["limit"] == 100, "Default limit should be 100"
|
|
|
|
async def test_list_meetings_respects_offset(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""ListMeetings respects offset parameter."""
|
|
meeting_mixin_meetings_repo.list_all.return_value = ([], 0)
|
|
|
|
request: ListMeetingsRequestProto = noteflow_pb2.ListMeetingsRequest(
|
|
offset=PAGE_OFFSET_STANDARD
|
|
)
|
|
await meeting_mixin_servicer.ListMeetings(request, mock_grpc_context)
|
|
|
|
call_kwargs = meeting_mixin_meetings_repo.list_all.call_args[1]
|
|
assert call_kwargs["offset"] == PAGE_OFFSET_STANDARD, (
|
|
"Offset should be passed to repository"
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
("proto_sort_order", "expected_desc"),
|
|
[
|
|
(noteflow_pb2.SortOrder.SORT_ORDER_CREATED_ASC, False),
|
|
(noteflow_pb2.SortOrder.SORT_ORDER_CREATED_DESC, True),
|
|
(noteflow_pb2.SortOrder.SORT_ORDER_UNSPECIFIED, True),
|
|
],
|
|
ids=["ascending", "descending", "default_descending"],
|
|
)
|
|
async def test_list_meetings_respects_sort_order(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
proto_sort_order: noteflow_pb2.SortOrder,
|
|
expected_desc: bool,
|
|
) -> None:
|
|
"""ListMeetings respects sort_order parameter."""
|
|
meeting_mixin_meetings_repo.list_all.return_value = ([], 0)
|
|
|
|
request: ListMeetingsRequestProto = noteflow_pb2.ListMeetingsRequest(
|
|
sort_order=proto_sort_order
|
|
)
|
|
await meeting_mixin_servicer.ListMeetings(request, mock_grpc_context)
|
|
|
|
call_kwargs = meeting_mixin_meetings_repo.list_all.call_args[1]
|
|
assert call_kwargs["sort_desc"] == expected_desc, f"Sort desc should be {expected_desc}"
|
|
|
|
async def test_list_meetings_filters_by_state(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""ListMeetings filters by meeting state."""
|
|
meeting_mixin_meetings_repo.list_all.return_value = ([], 0)
|
|
|
|
request: ListMeetingsRequestProto = noteflow_pb2.ListMeetingsRequest(
|
|
states=[
|
|
noteflow_pb2.MEETING_STATE_RECORDING,
|
|
noteflow_pb2.MEETING_STATE_STOPPED,
|
|
]
|
|
)
|
|
await meeting_mixin_servicer.ListMeetings(request, mock_grpc_context)
|
|
|
|
call_kwargs = meeting_mixin_meetings_repo.list_all.call_args[1]
|
|
assert call_kwargs["states"] is not None, "States should be passed to repository"
|
|
assert MeetingState.RECORDING in call_kwargs["states"], "RECORDING should be in filter"
|
|
assert MeetingState.STOPPED in call_kwargs["states"], "STOPPED should be in filter"
|
|
|
|
|
|
# ============================================================================
|
|
# TestGetMeeting
|
|
# ============================================================================
|
|
|
|
|
|
class TestGetMeeting:
|
|
"""Tests for GetMeeting RPC."""
|
|
|
|
async def test_get_meeting_returns_meeting_by_id(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""GetMeeting returns meeting when found."""
|
|
meeting_id = MeetingId(uuid4())
|
|
meeting = Meeting.create(title="Test Meeting")
|
|
meeting.id = meeting_id
|
|
meeting_mixin_meetings_repo.get.return_value = meeting
|
|
|
|
request: GetMeetingRequestProto = noteflow_pb2.GetMeetingRequest(meeting_id=str(meeting_id))
|
|
response: MeetingProto = await meeting_mixin_servicer.GetMeeting(request, mock_grpc_context)
|
|
|
|
expected_meeting_id = str(meeting_id)
|
|
assert response.id == expected_meeting_id, "Response ID should match"
|
|
assert response.title == "Test Meeting", "Title should match"
|
|
meeting_mixin_meetings_repo.get.assert_called_once_with(meeting_id)
|
|
|
|
async def test_get_meeting_includes_segments_when_requested(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
meeting_mixin_segments_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""GetMeeting loads segments when include_segments is True."""
|
|
meeting_id = MeetingId(uuid4())
|
|
meeting = Meeting.create(title="Meeting with Segments")
|
|
meeting.id = meeting_id
|
|
meeting_mixin_meetings_repo.get.return_value = meeting
|
|
|
|
segments = [
|
|
Segment(
|
|
segment_id=i,
|
|
text=t,
|
|
start_time=float(i),
|
|
end_time=float(i + 1),
|
|
meeting_id=meeting_id,
|
|
)
|
|
for i, t in enumerate(["Hello world", "Goodbye world"])
|
|
]
|
|
meeting_mixin_segments_repo.get_by_meeting.return_value = segments
|
|
|
|
request = noteflow_pb2.GetMeetingRequest(meeting_id=str(meeting_id), include_segments=True)
|
|
response = await meeting_mixin_servicer.GetMeeting(request, mock_grpc_context)
|
|
|
|
assert len(response.segments) == 2, "Should include 2 segments"
|
|
assert response.segments[0].text == "Hello world", "First segment text should match"
|
|
meeting_mixin_segments_repo.get_by_meeting.assert_called_once_with(meeting.id)
|
|
|
|
async def test_get_meeting_includes_summary_when_requested(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
meeting_mixin_summaries_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""GetMeeting loads summary when include_summary is True."""
|
|
from noteflow.domain.entities import Summary
|
|
|
|
meeting_id = MeetingId(uuid4())
|
|
meeting = Meeting.create(title="Meeting with Summary")
|
|
meeting.id = meeting_id
|
|
meeting_mixin_meetings_repo.get.return_value = meeting
|
|
|
|
summary = Summary(
|
|
meeting_id=meeting_id,
|
|
executive_summary="This was a productive meeting.",
|
|
key_points=[],
|
|
action_items=[],
|
|
)
|
|
meeting_mixin_summaries_repo.get_by_meeting.return_value = summary
|
|
|
|
request: GetMeetingRequestProto = noteflow_pb2.GetMeetingRequest(
|
|
meeting_id=str(meeting_id),
|
|
include_summary=True,
|
|
)
|
|
response: MeetingProto = await meeting_mixin_servicer.GetMeeting(request, mock_grpc_context)
|
|
|
|
assert response.summary is not None, "Summary should be included"
|
|
assert response.summary.executive_summary == "This was a productive meeting.", (
|
|
"executive summary should match"
|
|
)
|
|
meeting_mixin_summaries_repo.get_by_meeting.assert_called_once_with(meeting.id)
|
|
|
|
async def test_get_meeting_excludes_segments_when_not_requested(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
meeting_mixin_segments_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""GetMeeting does not load segments when include_segments is False."""
|
|
meeting_id = MeetingId(uuid4())
|
|
meeting = Meeting.create(title="Meeting without Segments")
|
|
meeting.id = meeting_id
|
|
meeting_mixin_meetings_repo.get.return_value = meeting
|
|
|
|
request: GetMeetingRequestProto = noteflow_pb2.GetMeetingRequest(
|
|
meeting_id=str(meeting_id),
|
|
include_segments=False,
|
|
)
|
|
await meeting_mixin_servicer.GetMeeting(request, mock_grpc_context)
|
|
|
|
meeting_mixin_segments_repo.get_by_meeting.assert_not_called()
|
|
|
|
|
|
# ============================================================================
|
|
# TestDeleteMeeting
|
|
# ============================================================================
|
|
|
|
|
|
class TestDeleteMeeting:
|
|
"""Tests for DeleteMeeting RPC."""
|
|
|
|
async def test_delete_meeting_succeeds_for_existing(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""DeleteMeeting returns success=true for existing meeting."""
|
|
meeting_mixin_meetings_repo.delete.return_value = True
|
|
meeting_id = uuid4()
|
|
|
|
request: DeleteMeetingRequestProto = noteflow_pb2.DeleteMeetingRequest(
|
|
meeting_id=str(meeting_id)
|
|
)
|
|
response: DeleteMeetingResponseProto = await meeting_mixin_servicer.DeleteMeeting(
|
|
request, mock_grpc_context
|
|
)
|
|
|
|
assert response.success is True, "Should return success=true"
|
|
meeting_mixin_meetings_repo.delete.assert_called_once()
|
|
|
|
|
|
async def test_delete_meeting_commits_after_success(
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""DeleteMeeting commits transaction after successful deletion."""
|
|
meeting_mixin_meetings_repo.delete.return_value = True
|
|
meeting_id = uuid4()
|
|
|
|
request: DeleteMeetingRequestProto = noteflow_pb2.DeleteMeetingRequest(
|
|
meeting_id=str(meeting_id)
|
|
)
|
|
response: DeleteMeetingResponseProto = await meeting_mixin_servicer.DeleteMeeting(
|
|
request, mock_grpc_context
|
|
)
|
|
|
|
assert response.success is True, "Delete should succeed"
|
|
# The commit is called on the context manager, which happens in the mixin
|
|
|
|
|
|
class TestInvalidMeetingIds:
|
|
"""Tests for invalid meeting id handling across operations."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("method_name", "proto_request"),
|
|
[
|
|
pytest.param(
|
|
"StopMeeting",
|
|
noteflow_pb2.StopMeetingRequest(meeting_id="not-a-uuid"),
|
|
id="stop_meeting",
|
|
),
|
|
pytest.param(
|
|
"GetMeeting",
|
|
noteflow_pb2.GetMeetingRequest(meeting_id="not-a-uuid"),
|
|
id="get_meeting",
|
|
),
|
|
pytest.param(
|
|
"DeleteMeeting",
|
|
noteflow_pb2.DeleteMeetingRequest(meeting_id="not-a-uuid"),
|
|
id="delete_meeting",
|
|
),
|
|
],
|
|
)
|
|
async def test_meeting_operations_reject_invalid_id(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
mock_grpc_context: MagicMock,
|
|
method_name: str,
|
|
proto_request: object,
|
|
) -> None:
|
|
with pytest.raises(AssertionError, match="Unreachable"):
|
|
method = getattr(meeting_mixin_servicer, method_name)
|
|
await method(proto_request, mock_grpc_context)
|
|
|
|
mock_grpc_context.abort.assert_called_once()
|
|
|
|
|
|
# ============================================================================
|
|
# TestMeetingProtoConversion
|
|
# ============================================================================
|
|
|
|
|
|
class TestMeetingProtoConversion:
|
|
"""Tests for meeting proto conversion."""
|
|
|
|
async def test_meeting_proto_includes_all_fields(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""Meeting proto includes all expected fields."""
|
|
meeting_id = MeetingId(uuid4())
|
|
meeting = Meeting.create(
|
|
title="Full Meeting",
|
|
metadata={"key": "value"},
|
|
)
|
|
meeting.id = meeting_id
|
|
meeting_mixin_meetings_repo.get.return_value = meeting
|
|
|
|
request: GetMeetingRequestProto = noteflow_pb2.GetMeetingRequest(meeting_id=str(meeting_id))
|
|
response: MeetingProto = await meeting_mixin_servicer.GetMeeting(request, mock_grpc_context)
|
|
|
|
assert response.id == str(meeting_id), "ID should be string UUID"
|
|
assert response.title == "Full Meeting", "Title should match"
|
|
assert response.state == MeetingState.CREATED.value, "State should be CREATED"
|
|
assert response.created_at > 0, "Created_at should be positive timestamp"
|
|
assert response.metadata["key"] == "value", "Metadata should be preserved"
|
|
|
|
async def test_list_response_excludes_segments_for_performance(
|
|
self,
|
|
meeting_mixin_servicer: MeetingServicerProtocol,
|
|
meeting_mixin_meetings_repo: AsyncMock,
|
|
mock_grpc_context: MagicMock,
|
|
) -> None:
|
|
"""ListMeetings response excludes segments for performance."""
|
|
meeting = Meeting.create(title="Meeting")
|
|
meeting_mixin_meetings_repo.list_all.return_value = ([meeting], 1)
|
|
|
|
request: ListMeetingsRequestProto = noteflow_pb2.ListMeetingsRequest()
|
|
response: ListMeetingsResponseProto = await meeting_mixin_servicer.ListMeetings(
|
|
request, mock_grpc_context
|
|
)
|
|
|
|
# Segments are excluded from list response
|
|
expected_empty = 0
|
|
assert len(response.meetings[0].segments) == expected_empty, (
|
|
"Segments should be empty in list"
|
|
)
|