"""Tests for cloud consent gRPC endpoints. Validates the GrantCloudConsent, RevokeCloudConsent, and GetCloudConsentStatus RPCs work correctly with the summarization service. """ from __future__ import annotations from typing import TYPE_CHECKING, cast from unittest.mock import AsyncMock, MagicMock import pytest from noteflow.application.services.summarization import ( SummarizationService, SummarizationServiceSettings, ) from noteflow.grpc.config.config import ServicesConfig from noteflow.grpc.proto import noteflow_pb2 from noteflow.grpc.service import NoteFlowServicer if TYPE_CHECKING: from collections.abc import Callable class _DummyContext: """Minimal gRPC context for testing.""" def __init__(self) -> None: self.aborted = False self.abort_code: object = None self.abort_details: str = "" async def abort(self, code: object, details: str) -> None: self.aborted = True self.abort_code = code self.abort_details = details raise AssertionError(f"abort called: {code} - {details}") def _create_mocksummarization_service( *, initial_consent: bool = False, on_consent_change: Callable[[bool], None] | None = None, ) -> SummarizationService: """Create a mock summarization service with controllable consent state.""" settings = SummarizationServiceSettings(summary_consent=initial_consent) service = MagicMock(spec=SummarizationService) service.settings = settings async def grant(feature: str) -> None: settings.cloud_consent_granted = True if on_consent_change: on_consent_change(True) async def revoke(feature: str) -> None: settings.cloud_consent_granted = False if on_consent_change: on_consent_change(False) service.grant_feature_consent = AsyncMock(side_effect=grant) service.revoke_feature_consent = AsyncMock(side_effect=revoke) service.cloud_consent_granted = property(lambda _: settings.cloud_consent_granted) # Make cloud_consent_granted work as a property type(service).cloud_consent_granted = property(lambda self: self.settings.cloud_consent_granted) return service class TestGetCloudConsentStatus: """Tests for GetCloudConsentStatus RPC.""" @pytest.mark.asyncio async def test_returns_false_when_consent_not_granted(self) -> None: """Status should be false when consent has not been granted.""" service = _create_mocksummarization_service(initial_consent=False) servicer = NoteFlowServicer(services=ServicesConfig(summarization_service=service)) response = await servicer.GetCloudConsentStatus( noteflow_pb2.GetCloudConsentStatusRequest(), _DummyContext(), ) assert response.consent_granted is False, "consent should be False when not granted" @pytest.mark.asyncio async def test_returns_true_when_consent_granted(self) -> None: """Status should be true when consent has been granted.""" service = _create_mocksummarization_service(initial_consent=True) servicer = NoteFlowServicer(services=ServicesConfig(summarization_service=service)) response = await servicer.GetCloudConsentStatus( noteflow_pb2.GetCloudConsentStatusRequest(), _DummyContext(), ) assert response.consent_granted is True, "consent should be True when granted" @pytest.mark.asyncio async def test_returns_false_when_service_unavailable(self) -> None: """Should return false (safe default) when service not configured.""" servicer = NoteFlowServicer() response = await servicer.GetCloudConsentStatus( noteflow_pb2.GetCloudConsentStatusRequest(), _DummyContext(), ) # Safe default: no consent when service unavailable assert response.consent_granted is False, "should default to False when service unavailable" class TestGrantCloudConsent: """Tests for GrantCloudConsent RPC.""" @pytest.mark.asyncio async def test_grants_consent(self) -> None: """Granting consent should update the service state.""" service = _create_mocksummarization_service(initial_consent=False) servicer = NoteFlowServicer(services=ServicesConfig(summarization_service=service)) response = await servicer.GrantCloudConsent( noteflow_pb2.GrantCloudConsentRequest(), _DummyContext(), ) assert isinstance(response, noteflow_pb2.GrantCloudConsentResponse), ( "GrantCloudConsent should return GrantCloudConsentResponse" ) grant_mock = cast(AsyncMock, service.grant_feature_consent) grant_mock.assert_awaited_once() @pytest.mark.asyncio async def test_grant_is_idempotent(self) -> None: """Granting consent multiple times should not error.""" service = _create_mocksummarization_service(initial_consent=True) servicer = NoteFlowServicer(services=ServicesConfig(summarization_service=service)) # Grant when already granted response = await servicer.GrantCloudConsent( noteflow_pb2.GrantCloudConsentRequest(), _DummyContext(), ) assert isinstance(response, noteflow_pb2.GrantCloudConsentResponse), ( "GrantCloudConsent should return GrantCloudConsentResponse even when already granted" ) @pytest.mark.asyncio async def test_grant_aborts_when_service_unavailable(self) -> None: """Should abort with FAILED_PRECONDITION when service not configured.""" servicer = NoteFlowServicer() context = _DummyContext() with pytest.raises(AssertionError, match="abort called"): await servicer.GrantCloudConsent( noteflow_pb2.GrantCloudConsentRequest(), context, ) assert context.aborted, "should abort when service unavailable" class TestRevokeCloudConsent: """Tests for RevokeCloudConsent RPC.""" @pytest.mark.asyncio async def test_revokes_consent(self) -> None: """Revoking consent should update the service state.""" service = _create_mocksummarization_service(initial_consent=True) servicer = NoteFlowServicer(services=ServicesConfig(summarization_service=service)) response = await servicer.RevokeCloudConsent( noteflow_pb2.RevokeCloudConsentRequest(), _DummyContext(), ) assert isinstance(response, noteflow_pb2.RevokeCloudConsentResponse), ( "RevokeCloudConsent should return RevokeCloudConsentResponse" ) revoke_mock = cast(AsyncMock, service.revoke_feature_consent) revoke_mock.assert_awaited_once() @pytest.mark.asyncio async def test_revoke_is_idempotent(self) -> None: """Revoking consent when not granted should not error.""" service = _create_mocksummarization_service(initial_consent=False) servicer = NoteFlowServicer(services=ServicesConfig(summarization_service=service)) response = await servicer.RevokeCloudConsent( noteflow_pb2.RevokeCloudConsentRequest(), _DummyContext(), ) assert isinstance(response, noteflow_pb2.RevokeCloudConsentResponse), ( "RevokeCloudConsent should return RevokeCloudConsentResponse even when not granted" ) @pytest.mark.asyncio async def test_revoke_aborts_when_service_unavailable(self) -> None: """Should abort with FAILED_PRECONDITION when service not configured.""" servicer = NoteFlowServicer() context = _DummyContext() with pytest.raises(AssertionError, match="abort called"): await servicer.RevokeCloudConsent( noteflow_pb2.RevokeCloudConsentRequest(), context, ) assert context.aborted, "should abort when service unavailable" class TestConsentRoundTrip: """Integration tests for consent grant/revoke/status cycle.""" @pytest.mark.asyncio async def test_grant_then_check_status(self) -> None: """Granting consent should be reflected in status check.""" service = _create_mocksummarization_service(initial_consent=False) servicer = NoteFlowServicer(services=ServicesConfig(summarization_service=service)) context = _DummyContext() # Verify initial state status_before = await servicer.GetCloudConsentStatus( noteflow_pb2.GetCloudConsentStatusRequest(), context, ) assert status_before.consent_granted is False, "initial consent should be False" # Grant consent await servicer.GrantCloudConsent( noteflow_pb2.GrantCloudConsentRequest(), _DummyContext(), ) # Verify new state status_after = await servicer.GetCloudConsentStatus( noteflow_pb2.GetCloudConsentStatusRequest(), _DummyContext(), ) assert status_after.consent_granted is True, "consent should be True after grant" @pytest.mark.asyncio async def test_grant_revoke_cycle(self) -> None: """Full grant/revoke cycle should work correctly.""" service = _create_mocksummarization_service(initial_consent=False) servicer = NoteFlowServicer(services=ServicesConfig(summarization_service=service)) # Grant await servicer.GrantCloudConsent( noteflow_pb2.GrantCloudConsentRequest(), _DummyContext(), ) status = await servicer.GetCloudConsentStatus( noteflow_pb2.GetCloudConsentStatusRequest(), _DummyContext(), ) assert status.consent_granted is True, "consent should be True after grant" # Revoke await servicer.RevokeCloudConsent( noteflow_pb2.RevokeCloudConsentRequest(), _DummyContext(), ) status = await servicer.GetCloudConsentStatus( noteflow_pb2.GetCloudConsentStatusRequest(), _DummyContext(), ) assert status.consent_granted is False, "consent should be False after revoke" @pytest.mark.asyncio async def test_consent_change_callback_invoked(self) -> None: """Consent changes should invoke the on_consent_change callback.""" callback_values: list[bool] = [] def track_changes(granted: bool) -> None: callback_values.append(granted) service = _create_mocksummarization_service( initial_consent=False, on_consent_change=track_changes, ) servicer = NoteFlowServicer(services=ServicesConfig(summarization_service=service)) await servicer.GrantCloudConsent( noteflow_pb2.GrantCloudConsentRequest(), _DummyContext(), ) await servicer.RevokeCloudConsent( noteflow_pb2.RevokeCloudConsentRequest(), _DummyContext(), ) assert callback_values == [True, False], ( "on_consent_change callback should receive [True, False] for grant then revoke" )