Files
noteflow/tests/application/test_analytics_service.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

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