- 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
521 lines
20 KiB
Python
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"
|
|
|
|
|