360 lines
13 KiB
Python
360 lines
13 KiB
Python
"""Tests for AnalyticsService application service.
|
|
|
|
Tests cover:
|
|
- Analytics overview retrieval
|
|
- Speaker stats retrieval
|
|
- TTL-based caching behavior
|
|
- Cache invalidation
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
|
|
from noteflow.application.services.analytics import AnalyticsService
|
|
from noteflow.domain.entities.analytics import (
|
|
AnalyticsOverview,
|
|
DailyMeetingStats,
|
|
SpeakerStat,
|
|
)
|
|
|
|
# Test constants
|
|
WORKSPACE_ID = uuid4()
|
|
"""Test workspace identifier."""
|
|
|
|
PROJECT_ID_1 = uuid4()
|
|
"""First test project identifier."""
|
|
|
|
PROJECT_ID_2 = uuid4()
|
|
"""Second test project identifier."""
|
|
|
|
EXPECTED_TOTAL_WORDS = 8500
|
|
"""Expected total word count for sample analytics overview."""
|
|
|
|
|
|
@pytest.fixture
|
|
def analytics_uow_factory(mock_uow: MagicMock) -> MagicMock:
|
|
"""Create mock UoW factory for AnalyticsService."""
|
|
factory = MagicMock(return_value=mock_uow)
|
|
return factory
|
|
|
|
|
|
@pytest.fixture
|
|
def analytics_service(analytics_uow_factory: MagicMock) -> AnalyticsService:
|
|
"""Create AnalyticsService with mock UoW factory and short TTL for testing."""
|
|
return AnalyticsService(analytics_uow_factory, cache_ttl_seconds=1)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_overview() -> AnalyticsOverview:
|
|
"""Create a sample analytics overview for testing."""
|
|
return AnalyticsOverview(
|
|
daily=[
|
|
DailyMeetingStats(
|
|
date="2026-01-20",
|
|
meetings=3,
|
|
total_duration=3600.0,
|
|
word_count=5000,
|
|
),
|
|
DailyMeetingStats(
|
|
date="2026-01-21",
|
|
meetings=2,
|
|
total_duration=2400.0,
|
|
word_count=3500,
|
|
),
|
|
],
|
|
total_meetings=5,
|
|
total_duration=6000.0,
|
|
total_words=EXPECTED_TOTAL_WORDS,
|
|
total_segments=150,
|
|
speaker_count=4,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_speaker_stats() -> list[SpeakerStat]:
|
|
"""Create sample speaker statistics for testing."""
|
|
return [
|
|
SpeakerStat(
|
|
speaker_id="SPEAKER_00",
|
|
display_name="Alice",
|
|
total_time=1800.0,
|
|
segment_count=50,
|
|
meeting_count=3,
|
|
avg_confidence=0.92,
|
|
),
|
|
SpeakerStat(
|
|
speaker_id="SPEAKER_01",
|
|
display_name="Bob",
|
|
total_time=1200.0,
|
|
segment_count=35,
|
|
meeting_count=2,
|
|
avg_confidence=0.88,
|
|
),
|
|
]
|
|
|
|
|
|
class TestAnalyticsServiceGetOverview:
|
|
"""Tests for analytics overview retrieval."""
|
|
|
|
async def test_get_overview_success(
|
|
self,
|
|
analytics_service: AnalyticsService,
|
|
analytics_uow_factory: MagicMock,
|
|
sample_overview: AnalyticsOverview,
|
|
) -> None:
|
|
"""Test successful overview retrieval from repository."""
|
|
mock_uow = analytics_uow_factory.return_value
|
|
mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
|
|
mock_uow.__aexit__ = AsyncMock(return_value=None)
|
|
mock_uow.analytics.get_overview_fast = AsyncMock(return_value=sample_overview)
|
|
|
|
result = await analytics_service.get_overview(WORKSPACE_ID)
|
|
|
|
assert result.total_meetings == 5, "Total meetings should match"
|
|
assert result.total_words == EXPECTED_TOTAL_WORDS, "Total words should match"
|
|
assert len(result.daily) == 2, "Should have 2 daily entries"
|
|
mock_uow.analytics.get_overview_fast.assert_called_once()
|
|
|
|
async def test_get_overview_with_project_filter(
|
|
self,
|
|
analytics_service: AnalyticsService,
|
|
analytics_uow_factory: MagicMock,
|
|
sample_overview: AnalyticsOverview,
|
|
) -> None:
|
|
"""Test overview retrieval with project IDs filter."""
|
|
mock_uow = analytics_uow_factory.return_value
|
|
mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
|
|
mock_uow.__aexit__ = AsyncMock(return_value=None)
|
|
mock_uow.analytics.get_overview_fast = AsyncMock(return_value=sample_overview)
|
|
|
|
await analytics_service.get_overview(
|
|
WORKSPACE_ID,
|
|
project_ids=[PROJECT_ID_1, PROJECT_ID_2],
|
|
)
|
|
|
|
mock_uow.analytics.get_overview_fast.assert_called_once_with(
|
|
workspace_id=WORKSPACE_ID,
|
|
project_ids=[PROJECT_ID_1, PROJECT_ID_2],
|
|
start_time=None,
|
|
end_time=None,
|
|
)
|
|
|
|
async def test_get_overview_with_time_range(
|
|
self,
|
|
analytics_service: AnalyticsService,
|
|
analytics_uow_factory: MagicMock,
|
|
sample_overview: AnalyticsOverview,
|
|
) -> None:
|
|
"""Test overview retrieval with time range filter."""
|
|
mock_uow = analytics_uow_factory.return_value
|
|
mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
|
|
mock_uow.__aexit__ = AsyncMock(return_value=None)
|
|
mock_uow.analytics.get_overview_fast = AsyncMock(return_value=sample_overview)
|
|
|
|
start = datetime(2026, 1, 1, tzinfo=UTC)
|
|
end = datetime(2026, 1, 31, tzinfo=UTC)
|
|
|
|
await analytics_service.get_overview(
|
|
WORKSPACE_ID,
|
|
start_time=start,
|
|
end_time=end,
|
|
)
|
|
|
|
mock_uow.analytics.get_overview_fast.assert_called_once_with(
|
|
workspace_id=WORKSPACE_ID,
|
|
project_ids=None,
|
|
start_time=start,
|
|
end_time=end,
|
|
)
|
|
|
|
|
|
class TestAnalyticsServiceGetSpeakerStats:
|
|
"""Tests for speaker statistics retrieval."""
|
|
|
|
async def test_get_speaker_stats_success(
|
|
self,
|
|
analytics_service: AnalyticsService,
|
|
analytics_uow_factory: MagicMock,
|
|
sample_speaker_stats: list[SpeakerStat],
|
|
) -> None:
|
|
"""Test successful speaker stats retrieval from repository."""
|
|
mock_uow = analytics_uow_factory.return_value
|
|
mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
|
|
mock_uow.__aexit__ = AsyncMock(return_value=None)
|
|
mock_uow.analytics.get_speaker_stats_fast = AsyncMock(return_value=sample_speaker_stats)
|
|
|
|
result = await analytics_service.get_speaker_stats(WORKSPACE_ID)
|
|
|
|
assert len(result) == 2, "Should return 2 speakers"
|
|
assert result[0].display_name == "Alice", "First speaker should be Alice"
|
|
assert result[1].display_name == "Bob", "Second speaker should be Bob"
|
|
mock_uow.analytics.get_speaker_stats_fast.assert_called_once()
|
|
|
|
async def test_get_speaker_stats_with_filters(
|
|
self,
|
|
analytics_service: AnalyticsService,
|
|
analytics_uow_factory: MagicMock,
|
|
sample_speaker_stats: list[SpeakerStat],
|
|
) -> None:
|
|
"""Test speaker stats with project and time filters."""
|
|
mock_uow = analytics_uow_factory.return_value
|
|
mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
|
|
mock_uow.__aexit__ = AsyncMock(return_value=None)
|
|
mock_uow.analytics.get_speaker_stats_fast = AsyncMock(return_value=sample_speaker_stats)
|
|
|
|
start = datetime(2026, 1, 15, tzinfo=UTC)
|
|
end = datetime(2026, 1, 22, tzinfo=UTC)
|
|
|
|
await analytics_service.get_speaker_stats(
|
|
WORKSPACE_ID,
|
|
project_ids=[PROJECT_ID_1],
|
|
start_time=start,
|
|
end_time=end,
|
|
)
|
|
|
|
mock_uow.analytics.get_speaker_stats_fast.assert_called_once_with(
|
|
workspace_id=WORKSPACE_ID,
|
|
project_ids=[PROJECT_ID_1],
|
|
start_time=start,
|
|
end_time=end,
|
|
)
|
|
|
|
|
|
class TestAnalyticsServiceCaching:
|
|
"""Tests for TTL-based caching behavior."""
|
|
|
|
async def test_cache_hit_returns_cached_data(
|
|
self,
|
|
analytics_service: AnalyticsService,
|
|
analytics_uow_factory: MagicMock,
|
|
sample_overview: AnalyticsOverview,
|
|
) -> None:
|
|
"""Test that second call returns cached data without DB query."""
|
|
mock_uow = analytics_uow_factory.return_value
|
|
mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
|
|
mock_uow.__aexit__ = AsyncMock(return_value=None)
|
|
mock_uow.analytics.get_overview_fast = AsyncMock(return_value=sample_overview)
|
|
|
|
# First call - cache miss
|
|
await analytics_service.get_overview(WORKSPACE_ID)
|
|
# Second call - should be cache hit
|
|
result = await analytics_service.get_overview(WORKSPACE_ID)
|
|
|
|
assert result.total_meetings == 5, "Should return cached data"
|
|
assert mock_uow.analytics.get_overview_fast.call_count == 1, "Should only call DB once"
|
|
|
|
async def test_cache_expires_after_ttl(
|
|
self,
|
|
analytics_uow_factory: MagicMock,
|
|
sample_overview: AnalyticsOverview,
|
|
) -> None:
|
|
"""Test that cache expires after TTL and queries DB again."""
|
|
# Create service with 0 second TTL for immediate expiry testing
|
|
service = AnalyticsService(analytics_uow_factory, cache_ttl_seconds=0)
|
|
mock_uow = analytics_uow_factory.return_value
|
|
mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
|
|
mock_uow.__aexit__ = AsyncMock(return_value=None)
|
|
mock_uow.analytics.get_overview_fast = AsyncMock(return_value=sample_overview)
|
|
|
|
# First call
|
|
await service.get_overview(WORKSPACE_ID)
|
|
|
|
# Mock time advancement by patching utc_now to return future time
|
|
future_time = datetime.now(UTC) + timedelta(seconds=5)
|
|
with patch(
|
|
"noteflow.application.services.analytics.service.utc_now", return_value=future_time
|
|
):
|
|
# Second call - cache should be expired
|
|
await service.get_overview(WORKSPACE_ID)
|
|
|
|
assert mock_uow.analytics.get_overview_fast.call_count == 2, (
|
|
"Should query DB twice after TTL"
|
|
)
|
|
|
|
async def test_different_filters_use_different_cache_keys(
|
|
self,
|
|
analytics_service: AnalyticsService,
|
|
analytics_uow_factory: MagicMock,
|
|
sample_overview: AnalyticsOverview,
|
|
) -> None:
|
|
"""Test that different filter parameters use separate cache entries."""
|
|
mock_uow = analytics_uow_factory.return_value
|
|
mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
|
|
mock_uow.__aexit__ = AsyncMock(return_value=None)
|
|
mock_uow.analytics.get_overview_fast = AsyncMock(return_value=sample_overview)
|
|
|
|
# Call with no filter
|
|
await analytics_service.get_overview(WORKSPACE_ID)
|
|
# Call with project filter - should be cache miss
|
|
await analytics_service.get_overview(WORKSPACE_ID, project_ids=[PROJECT_ID_1])
|
|
|
|
assert mock_uow.analytics.get_overview_fast.call_count == 2, (
|
|
"Different filters should query DB separately"
|
|
)
|
|
|
|
|
|
class TestAnalyticsServiceCacheInvalidation:
|
|
"""Tests for cache invalidation."""
|
|
|
|
async def test_invalidate_cache_clears_workspace(
|
|
self,
|
|
analytics_service: AnalyticsService,
|
|
analytics_uow_factory: MagicMock,
|
|
sample_overview: AnalyticsOverview,
|
|
) -> None:
|
|
"""Test invalidating cache for specific workspace."""
|
|
mock_uow = analytics_uow_factory.return_value
|
|
mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
|
|
mock_uow.__aexit__ = AsyncMock(return_value=None)
|
|
mock_uow.analytics.get_overview_fast = AsyncMock(return_value=sample_overview)
|
|
|
|
# Populate cache
|
|
await analytics_service.get_overview(WORKSPACE_ID)
|
|
|
|
# Invalidate
|
|
analytics_service.invalidate_cache(WORKSPACE_ID)
|
|
|
|
# Next call should hit DB
|
|
await analytics_service.get_overview(WORKSPACE_ID)
|
|
|
|
assert mock_uow.analytics.get_overview_fast.call_count == 2, (
|
|
"Should query DB after invalidation"
|
|
)
|
|
|
|
async def test_invalidate_all_caches(
|
|
self,
|
|
analytics_service: AnalyticsService,
|
|
analytics_uow_factory: MagicMock,
|
|
sample_overview: AnalyticsOverview,
|
|
sample_speaker_stats: list[SpeakerStat],
|
|
) -> None:
|
|
"""Test invalidating all caches when no workspace specified."""
|
|
mock_uow = analytics_uow_factory.return_value
|
|
mock_uow.__aenter__ = AsyncMock(return_value=mock_uow)
|
|
mock_uow.__aexit__ = AsyncMock(return_value=None)
|
|
mock_uow.analytics.get_overview_fast = AsyncMock(return_value=sample_overview)
|
|
mock_uow.analytics.get_speaker_stats_fast = AsyncMock(return_value=sample_speaker_stats)
|
|
|
|
# Populate both caches
|
|
await analytics_service.get_overview(WORKSPACE_ID)
|
|
await analytics_service.get_speaker_stats(WORKSPACE_ID)
|
|
|
|
# Invalidate all
|
|
analytics_service.invalidate_cache()
|
|
|
|
# Both should hit DB again
|
|
await analytics_service.get_overview(WORKSPACE_ID)
|
|
await analytics_service.get_speaker_stats(WORKSPACE_ID)
|
|
|
|
assert mock_uow.analytics.get_overview_fast.call_count == 2, (
|
|
"Overview should query DB after invalidation"
|
|
)
|
|
assert mock_uow.analytics.get_speaker_stats_fast.call_count == 2, (
|
|
"Speaker stats should query DB after invalidation"
|
|
)
|