Files
noteflow/tests/conftest.py
Travis Vasceannie 301482c410
Some checks failed
CI / test-python (push) Successful in 8m41s
CI / test-typescript (push) Failing after 6m2s
CI / test-rust (push) Failing after 4m28s
Refactor: Improve CI workflow robustness and test environment variable management, and enable parallel quality test execution.
2026-01-26 02:04:38 +00:00

569 lines
18 KiB
Python

"""Global test fixtures to mock optional extra dependencies.
These stubs allow running the suite without installing heavy/optional packages
like openai/anthropic/ollama/pywinctl, while individual tests can still
override with more specific monkeypatches when needed.
"""
from __future__ import annotations
import asyncio
import os
import sys
import types
from collections.abc import Coroutine, Generator, Sequence
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
from typing import Protocol, cast
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from noteflow.config.constants import DEFAULT_SAMPLE_RATE
from noteflow.config.settings import CalendarIntegrationSettings
from noteflow.domain.entities import Meeting
from noteflow.domain.value_objects import MeetingId
from noteflow.domain.webhooks import WebhookConfig, WebhookEventType
from noteflow.grpc.service import NoteFlowServicer
from noteflow.infrastructure.security.crypto import AesGcmCryptoBox
from noteflow.infrastructure.security.keystore import InMemoryKeyStore
# Re-export for test convenience - tests can import from conftest
SAMPLE_RATE = DEFAULT_SAMPLE_RATE
"""Standard test sample rate (16 kHz). Import this instead of using magic number 16000."""
# ============================================================================
# Shared logging context fixtures
# ============================================================================
@pytest.fixture
def reset_context_vars() -> Generator[None, None, None]:
"""Reset logging context variables before and after each test.
This fixture resets request_id_var, user_id_var, and workspace_id_var
to None for test isolation. Use via pytest.mark.usefixtures or as parameter.
"""
from noteflow.infrastructure.logging import (
request_id_var,
user_id_var,
workspace_id_var,
)
# Reset before test
request_id_var.set(None)
user_id_var.set(None)
workspace_id_var.set(None)
yield
# Reset after test
request_id_var.set(None)
user_id_var.set(None)
workspace_id_var.set(None)
@pytest.fixture
def mock_oauth_manager() -> MagicMock:
"""Create mock OAuthManager for testing OAuth flows.
Provides common OAuth manager mock with initiate_auth, complete_auth,
refresh_tokens, and revoke_tokens methods. Tests requiring specific
return values should configure the mock in their setup.
"""
manager = MagicMock()
manager.initiate_auth = MagicMock(
return_value=("https://auth.example.com/authorize", "state123")
)
manager.complete_auth = AsyncMock()
manager.refresh_tokens = AsyncMock()
manager.revoke_tokens = AsyncMock()
return manager
# ============================================================================
# Test database URL fixture (required for Settings validation)
# ============================================================================
_TEST_DATABASE_URL = "postgresql+asyncpg://test:test@localhost:5432/noteflow_test"
# Set database URL at module load time (before any fixtures run)
# This is safe for xdist because each worker process has isolated env
if "NOTEFLOW_DATABASE_URL" not in os.environ:
os.environ["NOTEFLOW_DATABASE_URL"] = _TEST_DATABASE_URL
@pytest.fixture(autouse=True)
def clear_settings_cache() -> Generator[None, None, None]:
"""Clear cached settings before each test for isolation.
The get_settings() and get_trigger_settings() functions use lru_cache which
can cause test pollution if settings are loaded with different env vars.
"""
from noteflow.config.settings._loaders import get_settings, get_trigger_settings
get_settings.cache_clear()
get_trigger_settings.cache_clear()
yield
get_settings.cache_clear()
get_trigger_settings.cache_clear()
# ============================================================================
# Platform-specific library path setup (run before pytest collection)
# ============================================================================
# macOS Homebrew: Set library path for WeasyPrint's GLib/GTK dependencies
_homebrew_lib = Path("/opt/homebrew/lib")
if sys.platform == "darwin" and _homebrew_lib.exists():
_current_path = os.environ.get("DYLD_LIBRARY_PATH", "")
_homebrew_str = str(_homebrew_lib)
if _homebrew_str not in _current_path:
os.environ["DYLD_LIBRARY_PATH"] = (
f"{_homebrew_str}:{_current_path}" if _current_path else _homebrew_str
)
# ============================================================================
# Module-level mocks (run before pytest collection)
# ============================================================================
# Mock sounddevice if PortAudio is not available (must be done before collection)
if "sounddevice" not in sys.modules:
try:
import sounddevice as _sounddevice
del _sounddevice
except OSError:
# PortAudio library not found - mock the module
def _mock_query_devices() -> list[dict[str, object]]:
return []
sounddevice_module = types.ModuleType("sounddevice")
sounddevice_module.__dict__["InputStream"] = MagicMock
sounddevice_module.__dict__["OutputStream"] = MagicMock
sounddevice_module.__dict__["query_devices"] = _mock_query_devices
sounddevice_module.__dict__["default"] = SimpleNamespace(device=(0, 0))
sys.modules["sounddevice"] = sounddevice_module
@pytest.fixture(autouse=True, scope="session")
def mock_optional_extras() -> None:
"""Install lightweight stubs for optional extra deps if absent."""
if "openai" not in sys.modules:
try:
import openai as _openai
del _openai
except ImportError:
def _default_create(**_: object) -> SimpleNamespace:
return SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="{}"))],
usage=SimpleNamespace(total_tokens=0),
)
def _mock_openai_client(**_: object) -> SimpleNamespace:
return SimpleNamespace(
chat=SimpleNamespace(completions=SimpleNamespace(create=_default_create))
)
openai_module = types.ModuleType("openai")
openai_module.__dict__["OpenAI"] = _mock_openai_client
sys.modules["openai"] = openai_module
if "anthropic" not in sys.modules:
try:
import anthropic as _anthropic
del _anthropic
except ImportError:
def _default_messages_create(**_: object) -> SimpleNamespace:
return SimpleNamespace(
content=[SimpleNamespace(text="{}")],
usage=SimpleNamespace(input_tokens=0, output_tokens=0),
)
def _mock_anthropic_client(**_: object) -> SimpleNamespace:
return SimpleNamespace(messages=SimpleNamespace(create=_default_messages_create))
anthropic_module = types.ModuleType("anthropic")
anthropic_module.__dict__["Anthropic"] = _mock_anthropic_client
sys.modules["anthropic"] = anthropic_module
if "ollama" not in sys.modules:
try:
import ollama as _ollama
del _ollama
except ImportError:
def _default_chat(**_: object) -> dict[str, object]:
return {
"message": {
"content": '{"executive_summary": "", "key_points": [], "action_items": []}'
},
"eval_count": 0,
"prompt_eval_count": 0,
}
def _mock_list() -> dict[str, object]:
return {}
def _mock_ollama_client(**_: object) -> SimpleNamespace:
return SimpleNamespace(list=_mock_list, chat=_default_chat)
ollama_module = types.ModuleType("ollama")
ollama_module.__dict__["Client"] = _mock_ollama_client
sys.modules["ollama"] = ollama_module
# pywinctl depends on pymonctl, which may fail in headless environments
# Mock both if not already present
if "pymonctl" not in sys.modules:
try:
import pymonctl as _pymonctl
del _pymonctl
except Exception:
# Mock pymonctl for headless environments (Xlib.error.DisplayNameError, etc.)
def _mock_get_all_monitors() -> list[object]:
return []
pymonctl_module = types.ModuleType("pymonctl")
pymonctl_module.__dict__["getAllMonitors"] = _mock_get_all_monitors
sys.modules["pymonctl"] = pymonctl_module
if "pywinctl" not in sys.modules:
try:
import pywinctl as _pywinctl
del _pywinctl
except Exception:
# ImportError: package not installed
# OSError/Xlib errors: pywinctl may fail in headless environments
def _mock_get_active_window() -> None:
return None
def _mock_get_all_windows() -> list[object]:
return []
def _mock_get_all_titles() -> list[str]:
return []
pywinctl_module = types.ModuleType("pywinctl")
pywinctl_module.__dict__["getActiveWindow"] = _mock_get_active_window
pywinctl_module.__dict__["getAllWindows"] = _mock_get_all_windows
pywinctl_module.__dict__["getAllTitles"] = _mock_get_all_titles
sys.modules["pywinctl"] = pywinctl_module
@pytest.fixture
def mock_uow() -> MagicMock:
"""Create a mock UnitOfWork for service tests.
Provides a fully-configured mock UnitOfWork with all repository mocks
and async context manager support.
"""
uow = MagicMock()
uow.__aenter__ = AsyncMock(return_value=uow)
uow.__aexit__ = AsyncMock(return_value=None)
uow.commit = AsyncMock()
uow.rollback = AsyncMock()
uow.meetings = MagicMock()
uow.segments = MagicMock()
uow.summaries = MagicMock()
uow.annotations = MagicMock()
uow.preferences = MagicMock()
uow.diarization_jobs = MagicMock()
uow.entities = MagicMock()
uow.webhooks = MagicMock()
uow.integrations = MagicMock()
uow.assets = MagicMock()
uow.assets.delete_meeting_assets = AsyncMock()
uow.tasks = MagicMock()
uow.analytics = MagicMock()
uow.supports_webhooks = True
uow.supports_integrations = True
uow.supports_tasks = True
uow.supports_analytics = True
return uow
@pytest.fixture
def crypto() -> AesGcmCryptoBox:
"""Create crypto instance with in-memory keystore."""
return AesGcmCryptoBox(InMemoryKeyStore())
@pytest.fixture
def meetings_dir(tmp_path: Path) -> Path:
"""Create temporary meetings directory."""
return tmp_path / "meetings"
@pytest.fixture
def webhook_config() -> WebhookConfig:
"""Create a webhook config subscribed to MEETING_COMPLETED event."""
return WebhookConfig.create(
workspace_id=uuid4(),
url="https://example.com/webhook",
events=[WebhookEventType.MEETING_COMPLETED],
name="Test Webhook",
)
@pytest.fixture
def webhook_config_all_events() -> WebhookConfig:
"""Create a webhook config subscribed to all events."""
return WebhookConfig.create(
workspace_id=uuid4(),
url="https://example.com/webhook",
events=[
WebhookEventType.MEETING_COMPLETED,
WebhookEventType.SUMMARY_GENERATED,
WebhookEventType.RECORDING_STARTED,
WebhookEventType.RECORDING_STOPPED,
],
name="All Events Webhook",
secret="test-secret-key",
)
@pytest.fixture
def sample_datetime() -> datetime:
"""Create sample UTC datetime for testing."""
from datetime import UTC, datetime
return datetime(2024, 1, 15, 10, 30, 0, tzinfo=UTC)
@pytest.fixture
def calendar_settings() -> CalendarIntegrationSettings:
"""Create test calendar settings for OAuth testing."""
return CalendarIntegrationSettings(
google_client_id="test-google-client-id",
google_client_secret="test-google-client-secret",
outlook_client_id="test-outlook-client-id",
outlook_client_secret="test-outlook-client-secret",
redirect_uri="http://localhost:8080/callback",
sync_hours_ahead=24,
max_events=20,
sync_interval_minutes=15,
)
# ============================================================================
# Common domain fixtures
# ============================================================================
@pytest.fixture
def meeting_id() -> MeetingId:
"""Create a test meeting ID."""
from noteflow.domain.value_objects import MeetingId
return MeetingId(uuid4())
@pytest.fixture
def sample_meeting() -> Meeting:
"""Create a sample meeting for testing."""
from noteflow.domain.entities import Meeting
return Meeting.create(title="Test Meeting")
@pytest.fixture
def recording_meeting() -> Meeting:
"""Create a meeting in RECORDING state."""
from noteflow.domain.entities import Meeting
meeting = Meeting.create(title="Recording Meeting")
meeting.start_recording()
return meeting
@pytest.fixture
def sample_rate() -> int:
"""Default audio sample rate (16 kHz) for testing."""
return DEFAULT_SAMPLE_RATE
# ============================================================================
# gRPC context mock
# ============================================================================
@pytest.fixture
def mock_grpc_context() -> MagicMock:
"""Create mock gRPC context for servicer tests.
Uses asyncio.sleep(0) as side_effect for abort to avoid
'coroutine was never awaited' warnings during GC.
"""
import asyncio
import grpc.aio
def _abort_side_effect(*args: object, **kwargs: object) -> Coroutine[object, object, None]:
return asyncio.sleep(0)
ctx = MagicMock(spec=grpc.aio.ServicerContext)
ctx.abort = MagicMock(side_effect=_abort_side_effect)
return ctx
# ============================================================================
# ASR engine mock
# ============================================================================
@pytest.fixture
def mockasr_engine() -> MagicMock:
"""Create default mock ASR engine for testing.
Returns:
Mock ASR engine with sync and async transcribe methods.
"""
from dataclasses import dataclass
import numpy as np
from numpy.typing import NDArray
@dataclass
class MockAsrResult:
"""Mock ASR transcription result."""
text: str
start: float = 0.0
end: float = 1.0
language: str = "en"
language_probability: float = 0.99
avg_logprob: float = -0.5
no_speech_prob: float = 0.01
engine = MagicMock()
engine.is_loaded = True
engine.model_size = "base"
def _transcribe(_audio: NDArray[np.float32]) -> list[MockAsrResult]:
return [MockAsrResult(text="Test transcription")]
async def _transcribe_async(
_audio: NDArray[np.float32],
_language: str | None = None,
) -> list[MockAsrResult]:
await asyncio.sleep(0)
return [MockAsrResult(text="Test transcription")]
engine.transcribe = _transcribe
engine.transcribe_async = _transcribe_async
return engine
# ============================================================================
# gRPC Servicer fixtures
# ============================================================================
@pytest.fixture
def memory_servicer(mockasr_engine: MagicMock, tmp_path: Path) -> NoteFlowServicer:
"""Create NoteFlowServicer with in-memory backend for testing.
Uses memory store (no database) for fast unit testing of
concurrency and state management.
"""
return NoteFlowServicer(
asr_engine=mockasr_engine,
session_factory=None,
meetings_dir=tmp_path / "meetings",
)
# ============================================================================
# Typed pytest.approx helper
# ============================================================================
class _ApproxCallable(Protocol):
"""Protocol for pytest.approx with explicit types."""
def __call__(
self,
expected: float,
*,
rel: float | None = None,
abs: float | None = None,
nan_ok: bool = False,
) -> object: ...
class _ApproxSequenceCallable(Protocol):
"""Protocol for pytest.approx with sequence types."""
def __call__(
self,
expected: Sequence[float],
*,
rel: float | None = None,
abs: float | None = None,
nan_ok: bool = False,
) -> object: ...
def approx_float(
expected: float,
*,
rel: float | None = None,
abs: float | None = None,
) -> object:
"""Typed wrapper for pytest.approx to satisfy type checkers.
pytest.approx lacks proper type stubs, leading to reportUnknownMemberType
errors. This wrapper provides explicit typing while delegating to the
underlying pytest.approx functionality.
Args:
expected: The expected float value.
rel: Relative tolerance (as a fraction).
abs: Absolute tolerance.
Returns:
ApproxBase instance for comparison.
Example:
assert result == approx_float(1.5, rel=0.01)
"""
_approx = cast(_ApproxCallable, pytest.approx)
return _approx(expected, rel=rel, abs=abs)
def approx_sequence(
expected: Sequence[float],
*,
rel: float | None = None,
abs: float | None = None,
) -> object:
"""Typed wrapper for pytest.approx with sequence types.
Similar to approx_float but for sequences of floats (e.g., embeddings).
pytest.approx lacks proper type stubs, leading to reportUnknownMemberType
errors. This wrapper provides explicit typing while delegating to the
underlying pytest.approx functionality.
Args:
expected: The expected sequence of float values.
rel: Relative tolerance (as a fraction).
abs: Absolute tolerance.
Returns:
ApproxBase instance for comparison.
Example:
assert embedding == approx_sequence([0.1, 0.2, 0.3], rel=0.01)
"""
_approx = cast(_ApproxSequenceCallable, pytest.approx)
return _approx(expected, rel=rel, abs=abs)