Files
noteflow/tests/grpc/test_preferences_mixin.py
Travis Vasceannie 1ce24cdf7b feat: reorganize Claude hooks and add RAG documentation structure with error handling policies
- Moved all hookify configuration files from `.claude/` to `.claude/hooks/` subdirectory for better organization
- Added four new blocking hooks to prevent common error handling anti-patterns:
  - `block-broad-exception-handler`: Prevents catching generic `Exception` with only logging
  - `block-datetime-now-fallback`: Blocks returning `datetime.now()` as fallback on parse failures to prevent data corruption
  - `block-default
2026-01-15 15:58:06 +00:00

521 lines
20 KiB
Python

"""Tests for PreferencesMixin gRPC endpoints.
Tests cover:
- GetPreferences: retrieval with and without key filtering
- SetPreferences: merge and replace modes
- ETag-based conflict detection
- Invalid JSON handling
"""
from __future__ import annotations
import json
from datetime import UTC
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock
import pytest
from noteflow.grpc.mixins._types import GrpcContext
from noteflow.grpc.mixins.preferences import PreferencesMixin, compute_etag
from noteflow.grpc.proto import noteflow_pb2
from noteflow.infrastructure.persistence.repositories.preferences_repo import (
PreferenceWithMetadata,
)
# Test constants
ETAG_HEX_DIGEST_LENGTH = 32
if TYPE_CHECKING:
from datetime import datetime
class MockRepositoryProvider:
"""Mock repository provider as async context manager."""
def __init__(self, preferences_repo: AsyncMock) -> None:
"""Initialize with mock preferences repository."""
self.supports_preferences = True
self.preferences = preferences_repo
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(PreferencesMixin):
"""Mock servicer host implementing required protocol."""
def __init__(self, preferences_repo: AsyncMock) -> None:
"""Initialize with mock preferences repository."""
self._preferences_repo = preferences_repo
def create_repository_provider(self) -> MockRepositoryProvider:
"""Create mock repository provider context manager."""
return MockRepositoryProvider(self._preferences_repo)
# Type stubs for mixin methods to fix type inference
if TYPE_CHECKING:
async def GetPreferences(
self,
request: noteflow_pb2.GetPreferencesRequest,
context: GrpcContext,
) -> noteflow_pb2.GetPreferencesResponse: ...
async def SetPreferences(
self,
request: noteflow_pb2.SetPreferencesRequest,
context: GrpcContext,
) -> noteflow_pb2.SetPreferencesResponse: ...
def create_pref_with_metadata(
key: str,
value: object,
updated_at: datetime,
) -> PreferenceWithMetadata:
"""Create a PreferenceWithMetadata instance for testing."""
return PreferenceWithMetadata(key=key, value=value, updated_at=updated_at)
class TestComputeEtag:
"""Tests for ETag computation."""
def test_computes_deterministic_etag(self) -> None:
"""ETag is deterministic for same inputs."""
prefs = {"key1": '"value1"', "key2": '"value2"'}
updated_at = 1234567890.0
etag1 = compute_etag(prefs, updated_at)
etag2 = compute_etag(prefs, updated_at)
assert etag1 == etag2, "ETag should be identical for same inputs"
assert len(etag1) == ETAG_HEX_DIGEST_LENGTH, "ETag should be 32 chars (MD5 hex digest)"
def test_different_values_produce_different_etag(self) -> None:
"""Different preference values produce different ETags."""
prefs1 = {"key1": '"value1"'}
prefs2 = {"key1": '"value2"'}
updated_at = 1234567890.0
etag1 = compute_etag(prefs1, updated_at)
etag2 = compute_etag(prefs2, updated_at)
assert etag1 != etag2, "Different values should produce different ETags"
def test_different_timestamps_produce_different_etag(self) -> None:
"""Different timestamps produce different ETags."""
prefs = {"key1": '"value1"'}
etag1 = compute_etag(prefs, 1234567890.0)
etag2 = compute_etag(prefs, 1234567891.0)
assert etag1 != etag2, "Different timestamps should produce different ETags"
class TestGetPreferences:
"""Tests for GetPreferences RPC.
Uses mock_grpc_context and mock_preferences_repo fixtures from conftest.py.
"""
@pytest.fixture
def servicer(self, mock_preferences_repo: AsyncMock) -> MockServicerHost:
"""Create servicer with mock repository."""
return MockServicerHost(mock_preferences_repo)
async def test_returns_all_preferences_when_no_keys_specified(
self,
servicer: MockServicerHost,
mock_preferences_repo: AsyncMock,
mock_grpc_context: MagicMock,
sample_datetime: datetime,
) -> None:
"""GetPreferences returns all preferences when no keys filter."""
mock_preferences_repo.get_all_with_metadata.return_value = [
create_pref_with_metadata("theme", "dark", sample_datetime),
create_pref_with_metadata("volume", 80, sample_datetime),
]
request = noteflow_pb2.GetPreferencesRequest()
response = await servicer.GetPreferences(request, mock_grpc_context)
assert response.preferences["theme"] == '"dark"', "theme should be JSON-encoded"
assert response.preferences["volume"] == "80", "volume should be JSON-encoded"
assert len(response.preferences) == 2, "should return all 2 preferences"
assert response.etag, "should include ETag for sync"
async def test_filters_by_keys_when_specified(
self,
servicer: MockServicerHost,
mock_preferences_repo: AsyncMock,
mock_grpc_context: MagicMock,
sample_datetime: datetime,
) -> None:
"""GetPreferences filters by keys when provided."""
mock_preferences_repo.get_all_with_metadata.return_value = [
create_pref_with_metadata("theme", "dark", sample_datetime),
]
request = noteflow_pb2.GetPreferencesRequest(keys=["theme"])
response = await servicer.GetPreferences(request, mock_grpc_context)
mock_preferences_repo.get_all_with_metadata.assert_called_once_with(["theme"])
assert "theme" in response.preferences, "filtered response should contain requested key"
async def test_returns_empty_response_when_no_preferences(
self,
servicer: MockServicerHost,
mock_preferences_repo: AsyncMock,
mock_grpc_context: MagicMock,
) -> None:
"""GetPreferences returns empty response when no preferences exist."""
mock_preferences_repo.get_all_with_metadata.return_value = []
request = noteflow_pb2.GetPreferencesRequest()
response = await servicer.GetPreferences(request, mock_grpc_context)
assert len(response.preferences) == 0, "empty preferences should return empty map"
assert response.updated_at == 0.0, "empty preferences should have zero timestamp"
assert response.etag, "response should include ETag even when empty"
async def test_returns_correct_updated_at_timestamp(
self,
servicer: MockServicerHost,
mock_preferences_repo: AsyncMock,
mock_grpc_context: MagicMock,
) -> None:
"""GetPreferences returns the maximum updated_at timestamp."""
from datetime import datetime
older = datetime(2024, 1, 10, 0, 0, 0, tzinfo=UTC)
newer = datetime(2024, 1, 15, 0, 0, 0, tzinfo=UTC)
mock_preferences_repo.get_all_with_metadata.return_value = [
create_pref_with_metadata("old_pref", "value", older),
create_pref_with_metadata("new_pref", "value", newer),
]
request = noteflow_pb2.GetPreferencesRequest()
response = await servicer.GetPreferences(request, mock_grpc_context)
assert response.updated_at == newer.timestamp(), "should return max updated_at timestamp"
class TestSetPreferences:
"""Tests for SetPreferences RPC.
Uses mock_grpc_context and mock_preferences_repo fixtures from conftest.py.
"""
@pytest.fixture
def servicer(self, mock_preferences_repo: AsyncMock) -> MockServicerHost:
"""Create servicer with mock repository."""
return MockServicerHost(mock_preferences_repo)
async def test_merge_mode_only_updates_provided_keys(
self,
servicer: MockServicerHost,
mock_preferences_repo: AsyncMock,
mock_grpc_context: MagicMock,
sample_datetime: datetime,
) -> None:
"""SetPreferences in merge mode only updates provided keys."""
mock_preferences_repo.get_all_with_metadata.return_value = [
create_pref_with_metadata("existing", "value", sample_datetime),
create_pref_with_metadata("theme", "light", sample_datetime),
]
request = noteflow_pb2.SetPreferencesRequest(
preferences={"theme": '"dark"'},
merge=True,
)
response = await servicer.SetPreferences(request, mock_grpc_context)
assert response.success is True, "merge mode should succeed"
assert response.conflict is False, "merge mode should not report conflict"
mock_preferences_repo.set_bulk.assert_called_once_with({"theme": "dark"})
mock_preferences_repo.delete.assert_not_called()
async def test_replace_mode_deletes_missing_keys(
self,
servicer: MockServicerHost,
mock_preferences_repo: AsyncMock,
mock_grpc_context: MagicMock,
sample_datetime: datetime,
) -> None:
"""SetPreferences in replace mode deletes keys not in request."""
mock_preferences_repo.get_all_with_metadata.return_value = [
create_pref_with_metadata("keep", "value", sample_datetime),
create_pref_with_metadata("delete_me", "value", sample_datetime),
]
request = noteflow_pb2.SetPreferencesRequest(
preferences={"keep": '"updated"'},
merge=False,
)
response = await servicer.SetPreferences(request, mock_grpc_context)
assert response.success is True, "replace mode should succeed"
mock_preferences_repo.delete.assert_called_once_with("delete_me")
mock_preferences_repo.set_bulk.assert_called_once_with({"keep": "updated"})
async def test_detects_etag_conflict(
self,
servicer: MockServicerHost,
mock_preferences_repo: AsyncMock,
mock_grpc_context: MagicMock,
sample_datetime: datetime,
) -> None:
"""SetPreferences detects ETag conflict and returns server state."""
mock_preferences_repo.get_all_with_metadata.return_value = [
create_pref_with_metadata("theme", "dark", sample_datetime),
]
request = noteflow_pb2.SetPreferencesRequest(
preferences={"theme": '"light"'},
if_match="invalid_etag",
merge=True,
)
response = await servicer.SetPreferences(request, mock_grpc_context)
assert response.success is False, "should fail on ETag mismatch"
assert response.conflict is True, "should indicate conflict"
assert response.conflict_message, "should include conflict message"
assert "theme" in response.server_preferences, "should return server preferences"
mock_preferences_repo.set_bulk.assert_not_called()
async def test_succeeds_with_matching_etag(
self,
servicer: MockServicerHost,
mock_preferences_repo: AsyncMock,
mock_grpc_context: MagicMock,
sample_datetime: datetime,
) -> None:
"""SetPreferences succeeds when ETag matches."""
prefs = [create_pref_with_metadata("theme", "dark", sample_datetime)]
mock_preferences_repo.get_all_with_metadata.return_value = prefs
# Compute the correct ETag
current_dict = {p.key: json.dumps(p.value) for p in prefs}
correct_etag = compute_etag(current_dict, sample_datetime.timestamp())
request = noteflow_pb2.SetPreferencesRequest(
preferences={"theme": '"light"'},
if_match=correct_etag,
merge=True,
)
response = await servicer.SetPreferences(request, mock_grpc_context)
assert response.success is True, "matching ETag should succeed"
assert response.conflict is False, "matching ETag should not report conflict"
async def test_returns_updated_state_after_success(
self,
servicer: MockServicerHost,
mock_preferences_repo: AsyncMock,
mock_grpc_context: MagicMock,
sample_datetime: datetime,
) -> None:
"""SetPreferences returns updated server state after success."""
from datetime import timedelta
initial = [create_pref_with_metadata("theme", "light", sample_datetime)]
updated = [
create_pref_with_metadata(
"theme", "dark", sample_datetime + timedelta(seconds=1)
)
]
mock_preferences_repo.get_all_with_metadata.side_effect = [initial, updated]
request = noteflow_pb2.SetPreferencesRequest(
preferences={"theme": '"dark"'},
merge=True,
)
response = await servicer.SetPreferences(request, mock_grpc_context)
assert response.success is True, "should succeed with valid request"
assert response.server_preferences["theme"] == '"dark"', "should return updated value"
assert response.etag, "should include new ETag"
assert response.server_updated_at > 0, "should include updated timestamp"
async def test_handles_complex_json_values(
self,
servicer: MockServicerHost,
mock_preferences_repo: AsyncMock,
mock_grpc_context: MagicMock,
) -> None:
"""SetPreferences handles complex nested JSON values."""
mock_preferences_repo.get_all_with_metadata.return_value = []
complex_value = {"nested": {"key": "value"}, "list": [1, 2, 3]}
request = noteflow_pb2.SetPreferencesRequest(
preferences={"config": json.dumps(complex_value)},
merge=True,
)
response = await servicer.SetPreferences(request, mock_grpc_context)
assert response.success is True, "complex JSON values should be accepted"
mock_preferences_repo.set_bulk.assert_called_once_with({"config": complex_value})
async def test_rejects_invalid_json_values(
self,
servicer: MockServicerHost,
mock_preferences_repo: AsyncMock,
mock_grpc_context: MagicMock,
) -> None:
"""SetPreferences aborts when preference value is invalid JSON."""
mock_preferences_repo.get_all_with_metadata.return_value = []
request = noteflow_pb2.SetPreferencesRequest(
preferences={"theme": "not-valid-json{"},
merge=True,
)
# abort helpers raise AssertionError after mock context.abort()
with pytest.raises(AssertionError, match="Unreachable"):
await servicer.SetPreferences(request, mock_grpc_context)
mock_grpc_context.abort.assert_called_once()
mock_preferences_repo.set_bulk.assert_not_called()
async def test_succeeds_with_empty_preferences(
self,
servicer: MockServicerHost,
mock_preferences_repo: AsyncMock,
mock_grpc_context: MagicMock,
sample_datetime: datetime,
) -> None:
"""SetPreferences with empty preferences is valid (clears in replace mode)."""
mock_preferences_repo.get_all_with_metadata.return_value = [
create_pref_with_metadata("existing", "value", sample_datetime),
]
request = noteflow_pb2.SetPreferencesRequest(
preferences={},
merge=False,
)
response = await servicer.SetPreferences(request, mock_grpc_context)
assert response.success is True, "empty replace should succeed"
mock_preferences_repo.delete.assert_called_once_with("existing")
async def test_force_update_without_etag(
self,
servicer: MockServicerHost,
mock_preferences_repo: AsyncMock,
mock_grpc_context: MagicMock,
sample_datetime: datetime,
) -> None:
"""SetPreferences with empty if_match bypasses ETag check."""
mock_preferences_repo.get_all_with_metadata.return_value = [
create_pref_with_metadata("theme", "dark", sample_datetime),
]
request = noteflow_pb2.SetPreferencesRequest(
preferences={"theme": '"light"'},
if_match="", # Empty means force update
merge=True,
)
response = await servicer.SetPreferences(request, mock_grpc_context)
assert response.success is True, "empty etag should bypass check"
assert response.conflict is False, "empty etag should not report conflict"
mock_preferences_repo.set_bulk.assert_called_once()
async def test_handles_null_preference_value(
self,
servicer: MockServicerHost,
mock_preferences_repo: AsyncMock,
mock_grpc_context: MagicMock,
) -> None:
"""SetPreferences handles null values correctly."""
mock_preferences_repo.get_all_with_metadata.return_value = []
request = noteflow_pb2.SetPreferencesRequest(
preferences={"nullPref": "null"},
merge=True,
)
response = await servicer.SetPreferences(request, mock_grpc_context)
assert response.success is True, "null preference value should be accepted"
mock_preferences_repo.set_bulk.assert_called_once_with({"nullPref": None})
async def test_handles_unicode_keys_and_values(
self,
servicer: MockServicerHost,
mock_preferences_repo: AsyncMock,
mock_grpc_context: MagicMock,
) -> None:
"""SetPreferences handles unicode in keys and values."""
mock_preferences_repo.get_all_with_metadata.return_value = []
request = noteflow_pb2.SetPreferencesRequest(
preferences={"日本語キー": '"émoji 🎉 value"'},
merge=True,
)
response = await servicer.SetPreferences(request, mock_grpc_context)
assert response.success is True, "unicode keys and values should be accepted"
mock_preferences_repo.set_bulk.assert_called_once_with({"日本語キー": "émoji 🎉 value"})
class TestDatabaseNotSupported:
"""Tests for when database/preferences are not available.
Uses mock_grpc_context fixture from tests/conftest.py.
"""
@pytest.fixture
def servicer_no_db(self) -> MockServicerHost:
"""Create servicer with database not supported."""
repo = AsyncMock()
servicer = MockServicerHost(repo)
# Override repository provider to not support preferences
provider = MockRepositoryProvider(repo)
provider.supports_preferences = False
servicer.create_repository_provider = lambda: provider
return servicer
async def test_get_preferences_aborts_without_database(
self,
servicer_no_db: MockServicerHost,
mock_grpc_context: MagicMock,
) -> None:
"""GetPreferences aborts when database not available."""
request = noteflow_pb2.GetPreferencesRequest()
# abort helpers raise AssertionError after mock context.abort()
with pytest.raises(AssertionError, match="Unreachable"):
await servicer_no_db.GetPreferences(request, mock_grpc_context)
assert mock_grpc_context.abort.call_count == 1, "should abort once when DB unavailable"
async def test_set_preferences_aborts_without_database(
self,
servicer_no_db: MockServicerHost,
mock_grpc_context: MagicMock,
) -> None:
"""SetPreferences aborts when database not available."""
request = noteflow_pb2.SetPreferencesRequest(
preferences={"theme": '"dark"'},
merge=True,
)
# abort helpers raise AssertionError after mock context.abort()
with pytest.raises(AssertionError, match="Unreachable"):
await servicer_no_db.SetPreferences(request, mock_grpc_context)
assert mock_grpc_context.abort.call_count == 1, "should abort once when DB unavailable"