Files
noteflow/tests/grpc/test_meeting_mixin.py
Travis Vasceannie b11633192a
Some checks failed
CI / test-python (push) Failing after 22m14s
CI / test-rust (push) Has been cancelled
CI / test-typescript (push) Has been cancelled
deps
2026-01-24 21:31:58 +00:00

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