Files
noteflow/tests/grpc/test_entities_mixin.py
Travis Vasceannie d8090a98e8
Some checks failed
CI / test-typescript (push) Has been cancelled
CI / test-rust (push) Has been cancelled
CI / test-python (push) Has been cancelled
ci/cd fixes
2026-01-26 00:28:15 +00:00

728 lines
24 KiB
Python

"""Tests for EntitiesMixin gRPC endpoints.
Tests cover:
- ExtractEntities: extraction with entities returned, meeting not found, NER disabled
- UpdateEntity: success, entity not found, invalid entity_id format
- DeleteEntity: success, entity not found, invalid entity_id format
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from noteflow.application.services.ner import ExtractionResult
from noteflow.domain.entities.named_entity import EntityCategory, NamedEntity
from noteflow.domain.value_objects import MeetingId
from noteflow.grpc.mixins._types import GrpcContext
from noteflow.grpc.mixins.entities import EntitiesMixin
from noteflow.grpc.proto import noteflow_pb2
if TYPE_CHECKING:
pass
class MockRepositoryProvider:
"""Mock repository provider as async context manager."""
def __init__(
self,
entities_repo: AsyncMock,
meetings_repo: AsyncMock | None = None,
) -> None:
"""Initialize with mock entity repository."""
self.supports_entities = True
self.entities = entities_repo
self.meetings = meetings_repo or AsyncMock()
self.commit = AsyncMock()
async def __aenter__(self) -> MockRepositoryProvider:
"""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 MockServicerHost(EntitiesMixin):
"""Mock servicer host implementing required protocol."""
def __init__(
self,
entities_repo: AsyncMock,
meetings_repo: AsyncMock | None = None,
ner_service: AsyncMock | None = None,
) -> None:
"""Initialize with mock repositories and optional NER service."""
self._entities_repo = entities_repo
self._meetings_repo = meetings_repo or AsyncMock()
self.ner_service = ner_service
# Required by log_model_status in _model_status.py
self.asr_engine = None
self.diarization_engine = None
self.summarization_service = None
def create_repository_provider(self) -> MockRepositoryProvider:
"""Create mock repository provider context manager."""
return MockRepositoryProvider(self._entities_repo, self._meetings_repo)
# Type stubs for mixin methods to fix type inference
if TYPE_CHECKING:
async def ExtractEntities(
self,
request: noteflow_pb2.ExtractEntitiesRequest,
context: GrpcContext,
) -> noteflow_pb2.ExtractEntitiesResponse: ...
async def UpdateEntity(
self,
request: noteflow_pb2.UpdateEntityRequest,
context: GrpcContext,
) -> noteflow_pb2.UpdateEntityResponse: ...
async def DeleteEntity(
self,
request: noteflow_pb2.DeleteEntityRequest,
context: GrpcContext,
) -> noteflow_pb2.DeleteEntityResponse: ...
def create_sample_entity(
meeting_id: MeetingId | None = None,
text: str = "Test Entity",
category: EntityCategory = EntityCategory.PERSON,
is_pinned: bool = False,
) -> NamedEntity:
"""Create a sample NamedEntity for testing."""
return NamedEntity(
id=uuid4(),
meeting_id=meeting_id,
text=text,
normalized_text=text.lower(),
category=category,
segment_ids=[0, 1, 2],
confidence=0.95,
is_pinned=is_pinned,
)
@pytest.fixture
def mock_entities_repo() -> AsyncMock:
"""Create mock entity repository with common methods."""
repo = AsyncMock()
repo.get = AsyncMock(return_value=None)
repo.update = AsyncMock(return_value=None)
repo.delete = AsyncMock(return_value=False)
return repo
@pytest.fixture
def mockner_service() -> MagicMock:
"""Create mock NER service."""
service = MagicMock()
service.extract_entities = AsyncMock()
service.is_ner_ready = MagicMock(return_value=True)
return service
class TestExtractEntities:
"""Tests for ExtractEntities RPC."""
@pytest.fixture
def servicer(
self,
mock_entities_repo: AsyncMock,
mock_meetings_repo: AsyncMock,
mockner_service: MagicMock,
) -> MockServicerHost:
"""Create servicer with mock NER service."""
return MockServicerHost(
mock_entities_repo,
mock_meetings_repo,
mockner_service,
)
async def test_returns_extracted_entities(
self,
servicer: MockServicerHost,
mockner_service: MagicMock,
mock_grpc_context: MagicMock,
) -> None:
"""ExtractEntities returns entities from NER service."""
meeting_id = MeetingId(uuid4())
entity1 = create_sample_entity(meeting_id, "John Doe", EntityCategory.PERSON)
entity2 = create_sample_entity(meeting_id, "Acme Corp", EntityCategory.COMPANY)
mockner_service.extract_entities.return_value = ExtractionResult(
entities=[entity1, entity2],
cached=False,
total_count=2,
)
request = noteflow_pb2.ExtractEntitiesRequest(
meeting_id=str(meeting_id),
force_refresh=False,
)
response = await servicer.ExtractEntities(request, mock_grpc_context)
assert len(response.entities) == 2, "should return 2 entities"
assert response.total_count == 2, "total count should be 2"
assert response.cached is False, "should not be cached"
assert response.entities[0].text == "John Doe", "first entity text should match"
assert response.entities[0].category == "person", "first entity category should be person"
assert response.entities[1].text == "Acme Corp", "second entity text should match"
assert response.entities[1].category == "company", (
"second entity category should be company"
)
async def test_returns_cached_entities(
self,
servicer: MockServicerHost,
mockner_service: MagicMock,
mock_grpc_context: MagicMock,
) -> None:
"""ExtractEntities returns cached result when available."""
meeting_id = MeetingId(uuid4())
entity = create_sample_entity(meeting_id, "Cached Person", EntityCategory.PERSON)
mockner_service.extract_entities.return_value = ExtractionResult(
entities=[entity],
cached=True,
total_count=1,
)
request = noteflow_pb2.ExtractEntitiesRequest(
meeting_id=str(meeting_id),
force_refresh=False,
)
response = await servicer.ExtractEntities(request, mock_grpc_context)
assert response.cached is True, "should indicate cached result"
assert len(response.entities) == 1, "should return 1 cached entity"
mockner_service.extract_entities.assert_called_once_with(
meeting_id=meeting_id,
force_refresh=False,
)
async def test_force_refresh_bypasses_cache(
self,
servicer: MockServicerHost,
mockner_service: MagicMock,
mock_grpc_context: MagicMock,
) -> None:
"""ExtractEntities with force_refresh re-extracts entities."""
meeting_id = MeetingId(uuid4())
entity = create_sample_entity(meeting_id, "Fresh Entity", EntityCategory.PERSON)
mockner_service.extract_entities.return_value = ExtractionResult(
entities=[entity],
cached=False,
total_count=1,
)
request = noteflow_pb2.ExtractEntitiesRequest(
meeting_id=str(meeting_id),
force_refresh=True,
)
response = await servicer.ExtractEntities(request, mock_grpc_context)
assert response.cached is False, "should not be cached with force_refresh"
mockner_service.extract_entities.assert_called_once_with(
meeting_id=meeting_id,
force_refresh=True,
)
async def test_aborts_when_meeting_not_found(
self,
servicer: MockServicerHost,
mockner_service: MagicMock,
mock_grpc_context: MagicMock,
) -> None:
"""ExtractEntities aborts when meeting does not exist."""
meeting_id = str(uuid4())
mockner_service.extract_entities.side_effect = ValueError("Meeting not found")
request = noteflow_pb2.ExtractEntitiesRequest(
meeting_id=meeting_id,
force_refresh=False,
)
with pytest.raises(AssertionError, match="Unreachable"):
await servicer.ExtractEntities(request, mock_grpc_context)
mock_grpc_context.abort.assert_called_once()
async def test_aborts_whenner_service_not_configured(
self,
mock_entities_repo: AsyncMock,
mock_meetings_repo: AsyncMock,
mock_grpc_context: MagicMock,
) -> None:
"""ExtractEntities aborts when NER service is None."""
servicer = MockServicerHost(
mock_entities_repo,
mock_meetings_repo,
ner_service=None,
)
request = noteflow_pb2.ExtractEntitiesRequest(
meeting_id=str(uuid4()),
force_refresh=False,
)
with pytest.raises(AssertionError, match="Unreachable"):
await servicer.ExtractEntities(request, mock_grpc_context)
mock_grpc_context.abort.assert_called_once()
async def test_aborts_when_feature_disabled(
self,
servicer: MockServicerHost,
mockner_service: MagicMock,
mock_grpc_context: MagicMock,
) -> None:
"""ExtractEntities aborts when NER feature is disabled."""
mockner_service.extract_entities.side_effect = RuntimeError(
"NER extraction is disabled by feature flag"
)
request = noteflow_pb2.ExtractEntitiesRequest(
meeting_id=str(uuid4()),
force_refresh=False,
)
with pytest.raises(AssertionError, match="Unreachable"):
await servicer.ExtractEntities(request, mock_grpc_context)
mock_grpc_context.abort.assert_called_once()
async def test_extract_aborts_with_invalid_meeting_id_format(
self,
servicer: MockServicerHost,
mock_grpc_context: MagicMock,
) -> None:
"""ExtractEntities aborts with invalid meeting_id format."""
request = noteflow_pb2.ExtractEntitiesRequest(
meeting_id="not-a-uuid",
force_refresh=False,
)
with pytest.raises(AssertionError, match="Unreachable"):
await servicer.ExtractEntities(request, mock_grpc_context)
mock_grpc_context.abort.assert_called_once()
async def test_returns_empty_entities_when_none_found(
self,
servicer: MockServicerHost,
mockner_service: MagicMock,
mock_grpc_context: MagicMock,
) -> None:
"""ExtractEntities returns empty list when no entities extracted."""
mockner_service.extract_entities.return_value = ExtractionResult(
entities=[],
cached=False,
total_count=0,
)
request = noteflow_pb2.ExtractEntitiesRequest(
meeting_id=str(uuid4()),
force_refresh=False,
)
response = await servicer.ExtractEntities(request, mock_grpc_context)
assert len(response.entities) == 0, "should return empty list"
assert response.total_count == 0, "total count should be 0"
async def test_includes_pinned_status_in_response(
self,
servicer: MockServicerHost,
mockner_service: MagicMock,
mock_grpc_context: MagicMock,
) -> None:
"""ExtractEntities includes is_pinned status for each entity."""
meeting_id = MeetingId(uuid4())
pinned_entity = create_sample_entity(
meeting_id, "Important Person", EntityCategory.PERSON, is_pinned=True
)
mockner_service.extract_entities.return_value = ExtractionResult(
entities=[pinned_entity],
cached=True,
total_count=1,
)
request = noteflow_pb2.ExtractEntitiesRequest(
meeting_id=str(meeting_id),
force_refresh=False,
)
response = await servicer.ExtractEntities(request, mock_grpc_context)
assert response.entities[0].is_pinned is True, "entity should be marked as pinned"
class TestUpdateEntity:
"""Tests for UpdateEntity RPC."""
@pytest.fixture
def servicer(
self,
mock_entities_repo: AsyncMock,
mock_meetings_repo: AsyncMock,
) -> MockServicerHost:
"""Create servicer with mock repositories."""
return MockServicerHost(mock_entities_repo, mock_meetings_repo)
async def test_updates_entity_text(
self,
servicer: MockServicerHost,
mock_entities_repo: AsyncMock,
mock_grpc_context: MagicMock,
) -> None:
"""UpdateEntity successfully updates entity text."""
meeting_id = MeetingId(uuid4())
entity = create_sample_entity(meeting_id, "Original Name", EntityCategory.PERSON)
updated_entity = create_sample_entity(meeting_id, "Updated Name", EntityCategory.PERSON)
updated_entity.id = entity.id
mock_entities_repo.get.return_value = entity
mock_entities_repo.update.return_value = updated_entity
request = noteflow_pb2.UpdateEntityRequest(
meeting_id=str(meeting_id),
entity_id=str(entity.id),
text="Updated Name",
)
response = await servicer.UpdateEntity(request, mock_grpc_context)
assert response.entity.text == "Updated Name", "entity text should be updated"
mock_entities_repo.update.assert_called_once_with(
entity_id=entity.id,
text="Updated Name",
category=None,
)
async def test_updates_entity_category(
self,
servicer: MockServicerHost,
mock_entities_repo: AsyncMock,
mock_grpc_context: MagicMock,
) -> None:
"""UpdateEntity successfully updates entity category."""
meeting_id = MeetingId(uuid4())
entity = create_sample_entity(meeting_id, "Test Name", EntityCategory.PERSON)
updated_entity = create_sample_entity(meeting_id, "Test Name", EntityCategory.COMPANY)
updated_entity.id = entity.id
mock_entities_repo.get.return_value = entity
mock_entities_repo.update.return_value = updated_entity
request = noteflow_pb2.UpdateEntityRequest(
meeting_id=str(meeting_id),
entity_id=str(entity.id),
category="company",
)
response = await servicer.UpdateEntity(request, mock_grpc_context)
assert response.entity.category == "company", "entity category should be updated"
async def test_update_aborts_when_entity_belongs_to_different_meeting(
self,
servicer: MockServicerHost,
mock_entities_repo: AsyncMock,
mock_grpc_context: MagicMock,
) -> None:
"""UpdateEntity aborts when entity belongs to a different meeting."""
entity_meeting_id = MeetingId(uuid4())
request_meeting_id = MeetingId(uuid4())
entity = create_sample_entity(entity_meeting_id, "Test", EntityCategory.PERSON)
mock_entities_repo.get.return_value = entity
request = noteflow_pb2.UpdateEntityRequest(
meeting_id=str(request_meeting_id),
entity_id=str(entity.id),
text="New Text",
)
with pytest.raises(AssertionError, match="Unreachable"):
await servicer.UpdateEntity(request, mock_grpc_context)
mock_grpc_context.abort.assert_called_once()
@pytest.mark.parametrize(
("meeting_id", "entity_id"),
[
pytest.param("not-a-uuid", str(uuid4()), id="invalid_meeting_id"),
pytest.param(str(uuid4()), "not-a-uuid", id="invalid_entity_id"),
],
)
async def test_update_aborts_with_invalid_id_format(
self,
servicer: MockServicerHost,
mock_grpc_context: MagicMock,
meeting_id: str,
entity_id: str,
) -> None:
request = noteflow_pb2.UpdateEntityRequest(
meeting_id=meeting_id,
entity_id=entity_id,
text="New Text",
)
with pytest.raises(AssertionError, match="Unreachable"):
await servicer.UpdateEntity(request, mock_grpc_context)
mock_grpc_context.abort.assert_called_once()
async def test_aborts_with_invalid_category(
self,
servicer: MockServicerHost,
mock_entities_repo: AsyncMock,
mock_grpc_context: MagicMock,
) -> None:
"""UpdateEntity aborts with invalid category value."""
meeting_id = MeetingId(uuid4())
entity = create_sample_entity(meeting_id, "Test", EntityCategory.PERSON)
mock_entities_repo.get.return_value = entity
mock_entities_repo.update.side_effect = ValueError("Invalid category")
request = noteflow_pb2.UpdateEntityRequest(
meeting_id=str(meeting_id),
entity_id=str(entity.id),
category="invalid_category",
)
with pytest.raises(AssertionError, match="Unreachable"):
await servicer.UpdateEntity(request, mock_grpc_context)
mock_grpc_context.abort.assert_called_once()
# ============================================================================
# TestEntityNotFound
# ============================================================================
class TestEntityNotFound:
"""Tests for entity operations when entity is missing."""
@pytest.fixture
def servicer(
self,
mock_entities_repo: AsyncMock,
mock_meetings_repo: AsyncMock,
) -> MockServicerHost:
"""Create servicer with mock repositories."""
return MockServicerHost(mock_entities_repo, mock_meetings_repo)
@pytest.mark.parametrize(
("method_name", "proto_request"),
[
pytest.param(
"UpdateEntity",
noteflow_pb2.UpdateEntityRequest(
meeting_id=str(uuid4()),
entity_id=str(uuid4()),
text="New Text",
),
id="update",
),
pytest.param(
"DeleteEntity",
noteflow_pb2.DeleteEntityRequest(
meeting_id=str(uuid4()),
entity_id=str(uuid4()),
),
id="delete",
),
],
)
async def test_entity_not_found_aborts(
self,
servicer: MockServicerHost,
mock_entities_repo: AsyncMock,
mock_grpc_context: MagicMock,
method_name: str,
proto_request: object,
) -> None:
"""Entity operations abort when entity does not exist."""
mock_entities_repo.get.return_value = None
method = getattr(servicer, method_name)
with pytest.raises(AssertionError, match="Unreachable"):
await method(proto_request, mock_grpc_context)
mock_grpc_context.abort.assert_called_once()
class TestDeleteEntity:
"""Tests for DeleteEntity RPC."""
@pytest.fixture
def servicer(
self,
mock_entities_repo: AsyncMock,
mock_meetings_repo: AsyncMock,
) -> MockServicerHost:
"""Create servicer with mock repositories."""
return MockServicerHost(mock_entities_repo, mock_meetings_repo)
async def test_deletes_entity_successfully(
self,
servicer: MockServicerHost,
mock_entities_repo: AsyncMock,
mock_grpc_context: MagicMock,
) -> None:
"""DeleteEntity successfully removes entity."""
meeting_id = MeetingId(uuid4())
entity = create_sample_entity(meeting_id, "To Delete", EntityCategory.PERSON)
mock_entities_repo.get.return_value = entity
mock_entities_repo.delete.return_value = True
request = noteflow_pb2.DeleteEntityRequest(
meeting_id=str(meeting_id),
entity_id=str(entity.id),
)
response = await servicer.DeleteEntity(request, mock_grpc_context)
assert response.success is True, "delete should succeed"
mock_entities_repo.delete.assert_called_once_with(entity.id)
async def test_delete_aborts_when_entity_belongs_to_different_meeting(
self,
servicer: MockServicerHost,
mock_entities_repo: AsyncMock,
mock_grpc_context: MagicMock,
) -> None:
"""DeleteEntity aborts when entity belongs to a different meeting."""
entity_meeting_id = MeetingId(uuid4())
request_meeting_id = MeetingId(uuid4())
entity = create_sample_entity(entity_meeting_id, "Test", EntityCategory.PERSON)
mock_entities_repo.get.return_value = entity
request = noteflow_pb2.DeleteEntityRequest(
meeting_id=str(request_meeting_id),
entity_id=str(entity.id),
)
with pytest.raises(AssertionError, match="Unreachable"):
await servicer.DeleteEntity(request, mock_grpc_context)
mock_grpc_context.abort.assert_called_once()
async def test_aborts_when_delete_fails(
self,
servicer: MockServicerHost,
mock_entities_repo: AsyncMock,
mock_grpc_context: MagicMock,
) -> None:
"""DeleteEntity aborts when delete operation fails."""
meeting_id = MeetingId(uuid4())
entity = create_sample_entity(meeting_id, "Test", EntityCategory.PERSON)
mock_entities_repo.get.return_value = entity
mock_entities_repo.delete.return_value = False
request = noteflow_pb2.DeleteEntityRequest(
meeting_id=str(meeting_id),
entity_id=str(entity.id),
)
with pytest.raises(AssertionError, match="Unreachable"):
await servicer.DeleteEntity(request, mock_grpc_context)
mock_grpc_context.abort.assert_called_once()
@pytest.mark.parametrize(
("meeting_id", "entity_id"),
[
pytest.param("not-a-uuid", str(uuid4()), id="invalid_meeting_id"),
pytest.param(str(uuid4()), "not-a-uuid", id="invalid_entity_id"),
],
)
async def test_delete_aborts_with_invalid_id_format(
self,
servicer: MockServicerHost,
mock_grpc_context: MagicMock,
meeting_id: str,
entity_id: str,
) -> None:
request = noteflow_pb2.DeleteEntityRequest(
meeting_id=meeting_id,
entity_id=entity_id,
)
with pytest.raises(AssertionError, match="Unreachable"):
await servicer.DeleteEntity(request, mock_grpc_context)
mock_grpc_context.abort.assert_called_once()
class TestDatabaseNotSupported:
"""Tests for when database/entities are not available."""
@pytest.fixture
def servicer_no_db(
self,
mock_entities_repo: AsyncMock,
mockner_service: MagicMock,
) -> MockServicerHost:
"""Create servicer with database not supported."""
servicer = MockServicerHost(
mock_entities_repo,
ner_service=mockner_service,
)
provider = MockRepositoryProvider(mock_entities_repo)
provider.supports_entities = False
object.__setattr__(servicer, "create_repository_provider", lambda: provider)
return servicer
async def test_update_entity_aborts_without_database(
self,
servicer_no_db: MockServicerHost,
mock_grpc_context: MagicMock,
) -> None:
"""UpdateEntity aborts when database not available."""
request = noteflow_pb2.UpdateEntityRequest(
meeting_id=str(uuid4()),
entity_id=str(uuid4()),
text="New Text",
)
with pytest.raises(AssertionError, match="Unreachable"):
await servicer_no_db.UpdateEntity(request, mock_grpc_context)
mock_grpc_context.abort.assert_called_once()
async def test_delete_entity_aborts_without_database(
self,
servicer_no_db: MockServicerHost,
mock_grpc_context: MagicMock,
) -> None:
"""DeleteEntity aborts when database not available."""
request = noteflow_pb2.DeleteEntityRequest(
meeting_id=str(uuid4()),
entity_id=str(uuid4()),
)
with pytest.raises(AssertionError, match="Unreachable"):
await servicer_no_db.DeleteEntity(request, mock_grpc_context)
mock_grpc_context.abort.assert_called_once()