222 lines
8.0 KiB
Python
222 lines
8.0 KiB
Python
"""Integration tests for analytics cache invalidation on meeting completion.
|
|
|
|
Tests cover:
|
|
- Complete meeting → analytics cache invalidated → next query hits DB
|
|
- Cache miss is logged after invalidation
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Final
|
|
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from uuid import uuid4
|
|
|
|
from noteflow.application.services.analytics import AnalyticsService
|
|
from noteflow.domain.entities.analytics import (
|
|
AnalyticsOverview,
|
|
SpeakerStat,
|
|
)
|
|
|
|
EXPECTED_TOTAL_MEETINGS: Final[int] = 1
|
|
EXPECTED_TOTAL_DURATION: Final[float] = 1800.0
|
|
EXPECTED_TOTAL_WORDS: Final[int] = 2500
|
|
EXPECTED_TOTAL_SEGMENTS: Final[int] = 50
|
|
EXPECTED_SPEAKER_COUNT: Final[int] = 2
|
|
|
|
EXPECTED_EMPTY_CACHE: Final[int] = 0
|
|
EXPECTED_SINGLE_CACHE_ENTRY: Final[int] = 1
|
|
EXPECTED_TWO_CACHE_ENTRIES: Final[int] = 2
|
|
|
|
EXPECTED_DB_CALLS_FIRST: Final[int] = 1
|
|
EXPECTED_DB_CALLS_AFTER_CACHE_HIT: Final[int] = 1
|
|
EXPECTED_DB_CALLS_AFTER_INVALIDATION: Final[int] = 2
|
|
|
|
SPEAKER_ALICE_TIME: Final[float] = 900.0
|
|
SPEAKER_ALICE_SEGMENTS: Final[int] = 25
|
|
SPEAKER_ALICE_MEETINGS: Final[int] = 1
|
|
SPEAKER_ALICE_CONFIDENCE: Final[float] = 0.95
|
|
|
|
SPEAKER_BOB_TIME: Final[float] = 900.0
|
|
SPEAKER_BOB_SEGMENTS: Final[int] = 25
|
|
SPEAKER_BOB_MEETINGS: Final[int] = 1
|
|
SPEAKER_BOB_CONFIDENCE: Final[float] = 0.93
|
|
|
|
CACHE_TTL_SECONDS: Final[int] = 60
|
|
|
|
|
|
async def _setup_mock_uow_with_overview(
|
|
analytics_uow_factory: MagicMock,
|
|
sample_overview: AnalyticsOverview,
|
|
) -> MagicMock:
|
|
"""Create a mock UoW configured with overview data."""
|
|
mock_uow = MagicMock()
|
|
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)
|
|
analytics_uow_factory.return_value = mock_uow
|
|
return mock_uow
|
|
|
|
|
|
async def _setup_mock_uow_with_speaker_stats(
|
|
analytics_uow_factory: MagicMock,
|
|
sample_overview: AnalyticsOverview,
|
|
sample_speaker_stats: list[SpeakerStat],
|
|
) -> MagicMock:
|
|
"""Create a mock UoW configured with speaker stats."""
|
|
mock_uow = MagicMock()
|
|
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)
|
|
analytics_uow_factory.return_value = mock_uow
|
|
return mock_uow
|
|
|
|
|
|
async def _verify_cache_hit_then_db_hit(
|
|
mock_uow: MagicMock,
|
|
) -> None:
|
|
"""Verify cache was hit first, then DB was hit after invalidation."""
|
|
assert (
|
|
mock_uow.analytics.get_overview_fast.call_count == EXPECTED_DB_CALLS_FIRST
|
|
), "First query should hit DB"
|
|
|
|
assert (
|
|
mock_uow.analytics.get_overview_fast.call_count == EXPECTED_DB_CALLS_AFTER_INVALIDATION
|
|
), "Second query should hit DB after invalidation"
|
|
|
|
assert (
|
|
mock_uow.analytics.get_overview_fast.call_count == EXPECTED_DB_CALLS_AFTER_INVALIDATION
|
|
), "Second query should hit DB after invalidation"
|
|
|
|
|
|
async def _verify_independent_workspace_caches(
|
|
mock_uow: MagicMock,
|
|
) -> None:
|
|
"""Verify caches are independent - invalidating one doesn't affect other."""
|
|
assert (
|
|
mock_uow.analytics.get_overview_fast.call_count == EXPECTED_TWO_CACHE_ENTRIES
|
|
), "Should have cached two workspaces"
|
|
|
|
|
|
class TestAnalyticsCacheInvalidation:
|
|
"""Integration tests for analytics cache invalidation flow."""
|
|
|
|
async def test_meeting_completion_invalidates_cache_integration(
|
|
self,
|
|
analytics_service: AnalyticsService,
|
|
analytics_uow_factory: MagicMock,
|
|
sample_overview: AnalyticsOverview,
|
|
) -> None:
|
|
"""Test complete flow: meeting completion → cache invalidation → DB hit."""
|
|
workspace_id = uuid4()
|
|
|
|
mock_uow = await _setup_mock_uow_with_overview(analytics_uow_factory, sample_overview)
|
|
|
|
result1 = await analytics_service.get_overview(workspace_id)
|
|
|
|
assert result1.total_meetings == EXPECTED_TOTAL_MEETINGS, (
|
|
"First query should return overview"
|
|
)
|
|
|
|
analytics_service.invalidate_cache(workspace_id)
|
|
|
|
result2 = await analytics_service.get_overview(workspace_id)
|
|
|
|
assert result2.total_meetings == EXPECTED_TOTAL_MEETINGS, (
|
|
"Second query should return cached data"
|
|
)
|
|
|
|
await _verify_cache_hit_then_db_hit(mock_uow)
|
|
|
|
async def test_invalidate_cache_clears_all_cache_types(
|
|
self,
|
|
analytics_service: AnalyticsService,
|
|
analytics_uow_factory: MagicMock,
|
|
sample_overview: AnalyticsOverview,
|
|
sample_speaker_stats: list[SpeakerStat],
|
|
) -> None:
|
|
"""Test that invalidation clears overview, speaker, and entity caches."""
|
|
workspace_id = uuid4()
|
|
|
|
mock_uow = await _setup_mock_uow_with_speaker_stats(
|
|
analytics_uow_factory, sample_overview, sample_speaker_stats
|
|
)
|
|
|
|
await analytics_service.get_overview(workspace_id)
|
|
await analytics_service.get_speaker_stats(workspace_id)
|
|
|
|
assert (
|
|
mock_uow.analytics.get_overview_fast.call_count == EXPECTED_DB_CALLS_FIRST
|
|
), "Overview should have been queried once"
|
|
assert (
|
|
mock_uow.analytics.get_speaker_stats_fast.call_count == EXPECTED_DB_CALLS_FIRST
|
|
), "Speaker stats should have been queried once"
|
|
|
|
analytics_service.invalidate_cache(workspace_id)
|
|
await analytics_service.get_overview(workspace_id)
|
|
await analytics_service.get_speaker_stats(workspace_id)
|
|
|
|
assert (
|
|
mock_uow.analytics.get_overview_fast.call_count == EXPECTED_DB_CALLS_AFTER_INVALIDATION
|
|
), "Overview should query DB after invalidation"
|
|
assert (
|
|
mock_uow.analytics.get_speaker_stats_fast.call_count
|
|
== EXPECTED_DB_CALLS_AFTER_INVALIDATION
|
|
), "Speaker stats should query DB after invalidation"
|
|
|
|
async def test_invalidate_cache_with_none_clears_all_workspaces(
|
|
self,
|
|
analytics_service: AnalyticsService,
|
|
analytics_uow_factory: MagicMock,
|
|
sample_overview: AnalyticsOverview,
|
|
) -> None:
|
|
"""Test that invalidate_cache(None) clears all workspaces."""
|
|
workspace_id_1 = uuid4()
|
|
workspace_id_2 = uuid4()
|
|
|
|
mock_uow = await _setup_mock_uow_with_overview(analytics_uow_factory, sample_overview)
|
|
|
|
await analytics_service.get_overview(workspace_id_1)
|
|
await analytics_service.get_overview(workspace_id_2)
|
|
|
|
assert (
|
|
mock_uow.analytics.get_overview_fast.call_count == EXPECTED_TWO_CACHE_ENTRIES
|
|
), "Should have queried DB twice"
|
|
|
|
analytics_service.invalidate_cache(None)
|
|
|
|
await analytics_service.get_overview(workspace_id_1)
|
|
await analytics_service.get_overview(workspace_id_2)
|
|
|
|
expected_calls_after_invalidation = EXPECTED_TWO_CACHE_ENTRIES + EXPECTED_TWO_CACHE_ENTRIES
|
|
assert (
|
|
mock_uow.analytics.get_overview_fast.call_count == expected_calls_after_invalidation
|
|
), "Should query DB again after invalidating all"
|
|
|
|
async def test_invalidate_cache_preserves_other_workspaces(
|
|
self,
|
|
analytics_service: AnalyticsService,
|
|
analytics_uow_factory: MagicMock,
|
|
sample_overview: AnalyticsOverview,
|
|
) -> None:
|
|
"""Test that invalidating one workspace preserves others."""
|
|
workspace_id_1 = uuid4()
|
|
workspace_id_2 = uuid4()
|
|
|
|
mock_uow = await _setup_mock_uow_with_overview(analytics_uow_factory, sample_overview)
|
|
|
|
await analytics_service.get_overview(workspace_id_1)
|
|
await analytics_service.get_overview(workspace_id_2)
|
|
|
|
await _verify_independent_workspace_caches(mock_uow)
|
|
|
|
analytics_service.invalidate_cache(workspace_id_1)
|
|
|
|
await analytics_service.get_overview(workspace_id_1)
|
|
await analytics_service.get_overview(workspace_id_2)
|
|
|
|
assert (
|
|
mock_uow.analytics.get_overview_fast.call_count == EXPECTED_TWO_CACHE_ENTRIES * 2
|
|
), "Invalidating one workspace should not affect other's cache"
|