# Sprint 7: Wiring & Correctness

> **Priority**: 1 | **Owner**: Backend + Client | **Complexity**: Low-Medium
> **Prerequisite**: None

---

## Validation Status (2025-12-28)

### ✅ COMPLETED

| Component | Location | Status | Finding | Task |
|---|---|---|---|---|
| ServerInfo call path | `client/src/api/tauri-adapter.ts:100` | ✅ | `getServerInfo()` uses `GET_SERVER_INFO` command | Task 1 ✓ |
| Tauri ServerInfo command | `client/src-tauri/src/commands/connection.rs` | ✅ | Command exists and is correct | — |
| Upcoming meetings data | `client/src/components/upcoming-meetings.tsx` | ✅ | Uses `useCalendarSync` hook for live calendar data | Task 2 ✓ |
| Calendar API hook | `client/src/hooks/use-calendar-sync.ts` | ✅ | Live `listCalendarEvents()` exists in adapter | — |
| Speaker rename persistence | `client/src/pages/People.tsx:226-228` | ✅ | Calls `preferences.setGlobalSpeakerName()` | — |
| Rename speaker API | `client/src/api/tauri-adapter.ts:160` | ✅ | `renameSpeaker()` RPC available | — |
| Cloud consent control | `src/noteflow/application/services/summarization_service.py:154-166` | ✅ | `grant/revoke_cloud_consent()` implemented | — |
| Cloud consent persistence | `src/noteflow/grpc/server.py` | ✅ | `on_consent_change` callback wired to DB via preferences | Task 3 ✓ |
| Cloud consent gRPC | `src/noteflow/grpc/_mixins/summarization.py` | ✅ | `GrantCloudConsent`, `RevokeCloudConsent`, `GetCloudConsentStatus` RPCs | Task 3 ✓ |
| Consent Rust commands | `client/src-tauri/src/commands/summary.rs` | ✅ | `grant_cloud_consent`, `revoke_cloud_consent`, `get_cloud_consent_status` commands | Task 3 ✓ |
| Cloud consent UI | `client/src/components/settings/export-ai-section.tsx` | ✅ | `CloudConsentToggle` component with `useCloudConsent` hook | Task 4 ✓ |

---

## Objective

Remove mock/placeholder wiring where live backend capabilities already exist, and expose missing consent controls to the UI layer.

---

## Current State Analysis

### What Already Works ✅

1. **Speaker Rename** — Complete end-to-end flow:
   - `People.tsx:226-228` calls `preferences.setGlobalSpeakerName(speakerId, newName)`
   - `tauri-adapter.ts:160` provides `renameSpeaker()` RPC
   - Backend `RenameSpeaker` RPC exists in `grpc/_mixins/diarization.py`

2. **Summarization Consent** — Service layer complete:
   - `grant_cloud_consent()` / `revoke_cloud_consent()` methods at lines 154-166
   - `on_consent_change` callback for persistence
   - Cloud mode blocked without consent (lines 273-278)

### What Needs Work ⚠️

1. **ServerInfo Wiring** — `getServerInfo()` currently calls `CONNECT` instead of the `GET_SERVER_INFO` command. The `get_server_info` command exists but is unused. (Task 1)

2. **Upcoming Meetings Mock Data** — `generateMockEvents()` at `upcoming-meetings.tsx:42-93` creates synthetic events instead of calling live calendar API. The `listCalendarEvents()` RPC exists but is not wired to this component. No error handling for API failures. (Task 2)

3. **Cloud Consent Full Stack** — Multiple gaps exist in the consent pipeline: (Task 3 + Task 4)
   - **gRPC layer**: No `GrantCloudConsent`, `RevokeCloudConsent`, or `GetCloudConsentStatus` RPCs
   - **Persistence**: The `on_consent_change` callback exists but is not wired to the database — consent resets on restart
   - **Rust/Tauri**: No Tauri commands to invoke consent RPCs from the client
   - **UI**: No toggle in Settings.tsx to control consent

---

## Scope

| Task | Effort | Risk |
|------|--------|------|
| Fix `getServerInfo()` to use `GET_SERVER_INFO` command | Low | Low |
| Wire upcoming meetings to live calendar (with error handling) | Low | Low |
| Add consent RPCs to gRPC layer (with persistence + Rust commands) | Medium | Low |
| Add consent toggle to Settings UI | Low | Low |

---

## Implementation Plan

### Task 1: Fix ServerInfo Wiring

**Files to Modify**:
- `client/src/api/tauri-adapter.ts`

**Changes**:
The `getServerInfo()` function incorrectly calls `CONNECT` instead of `GET_SERVER_INFO`. The command exists but is unused.

```typescript
// client/src/api/tauri-adapter.ts:100
// BEFORE (incorrect):
async getServerInfo(): Promise<ServerInfo> {
  return invoke<ServerInfo>(TauriCommands.CONNECT);
}

// AFTER (correct):
async getServerInfo(): Promise<ServerInfo> {
  return invoke<ServerInfo>(TauriCommands.GET_SERVER_INFO);
}
```

This is a one-line fix that enables proper server info retrieval without triggering a connection attempt.

---

### Task 2: Wire Upcoming Meetings to Live Calendar Data

**Files to Modify**:
- `client/src/components/upcoming-meetings.tsx`

**Changes**:
Replace `generateMockEvents()` with the existing calendar sync hook, including proper error handling for graceful degradation:

```typescript
import { useCalendarSync } from '@/hooks/use-calendar-sync';

export function UpcomingMeetings({ maxEvents = 10 }: UpcomingMeetingsProps) {
  const integrations = preferences.getIntegrations();
  const calendarIntegrations = integrations.filter(i => i.type === 'calendar');
  const connectedCalendars = calendarIntegrations.filter(i => i.status === 'connected');

  // Use real calendar API instead of mock generator
  const { state, fetchEvents } = useCalendarSync({
    hoursAhead: 24 * 7, // 7 days ahead
    limit: maxEvents,
  });

  useEffect(() => {
    if (connectedCalendars.length > 0) {
      void fetchEvents();
    }
  }, [connectedCalendars.length, fetchEvents]);

  // Handle loading and error states gracefully
  if (state.status === 'loading' && state.events.length === 0) {
    return <UpcomingMeetingsSkeleton />;
  }

  if (state.status === 'error') {
    return (
      <div className="text-muted-foreground text-sm p-4">
        <p>Unable to load calendar events.</p>
        <button
          onClick={() => void fetchEvents()}
          className="text-primary hover:underline mt-2"
        >
          Try again
        </button>
      </div>
    );
  }

  const events = useMemo(
    () => state.events.slice(0, maxEvents),
    [state.events, maxEvents]
  );

  if (connectedCalendars.length === 0 || events.length === 0) {
    return <EmptyState message="No upcoming meetings" />;
  }

  // ... rest of component
}
```

**Key improvements over original proposal**:
- Loading skeleton during initial fetch
- Error state with retry button
- Empty state when no calendars connected (no API call)
- Graceful degradation on network failures

---

### Task 3: Add Cloud Consent gRPC Endpoints (with Persistence)

**Files to Modify**:
- `src/noteflow/grpc/_mixins/summarization.py` — Add RPCs
- `src/noteflow/grpc/proto/noteflow.proto` — Add messages
- `src/noteflow/grpc/server.py` — Wire consent persistence callback
- `client/src-tauri/src/commands/summary.rs` — Add Tauri commands
- `client/src/api/tauri-adapter.ts` — Add client methods
- `client/src/api/interface.ts` — Add interface methods

**Proto Additions**:
```protobuf
message GrantCloudConsentRequest {}
message GrantCloudConsentResponse {}  // Empty on success; errors via gRPC status

message RevokeCloudConsentRequest {}
message RevokeCloudConsentResponse {}  // Empty on success; errors via gRPC status

message GetCloudConsentStatusRequest {}
message GetCloudConsentStatusResponse {
  bool consent_granted = 1;
}
```

**gRPC Mixin** (`summarization.py`):
```python
from noteflow.grpc._mixins.errors import abort_failed_precondition

async def GrantCloudConsent(
    self: ServicerHost,
    request: noteflow_pb2.GrantCloudConsentRequest,
    context: grpc.aio.ServicerContext,
) -> noteflow_pb2.GrantCloudConsentResponse:
    """Grant consent for cloud-based summarization."""
    if self._summarization_service is None:
        await abort_failed_precondition(
            context,
            "Summarization service not available",
        )
    await self._summarization_service.grant_cloud_consent()
    return noteflow_pb2.GrantCloudConsentResponse()

async def RevokeCloudConsent(
    self: ServicerHost,
    request: noteflow_pb2.RevokeCloudConsentRequest,
    context: grpc.aio.ServicerContext,
) -> noteflow_pb2.RevokeCloudConsentResponse:
    """Revoke consent for cloud-based summarization."""
    if self._summarization_service is None:
        await abort_failed_precondition(
            context,
            "Summarization service not available",
        )
    await self._summarization_service.revoke_cloud_consent()
    return noteflow_pb2.RevokeCloudConsentResponse()

async def GetCloudConsentStatus(
    self: ServicerHost,
    request: noteflow_pb2.GetCloudConsentStatusRequest,
    context: grpc.aio.ServicerContext,
) -> noteflow_pb2.GetCloudConsentStatusResponse:
    """Return current cloud consent status."""
    if self._summarization_service is None:
        return noteflow_pb2.GetCloudConsentStatusResponse(consent_granted=False)
    return noteflow_pb2.GetCloudConsentStatusResponse(
        consent_granted=self._summarization_service.cloud_consent_granted,
    )
```

**Consent Persistence Wiring** (`server.py`):

The `SummarizationService` has an `on_consent_change` callback that must be wired to persist consent to the database. Without this, consent resets on server restart.

```python
# In create_and_start_server() after creating session_factory:

# Load cloud consent from database and set up persistence callback
if session_factory:
    async with SqlAlchemyUnitOfWork(session_factory) as uow:
        cloud_consent = await uow.preferences.get_bool("cloud_consent_granted", False)
        summarization_service.settings.cloud_consent_granted = cloud_consent
        logger.info("Loaded cloud consent from database: %s", cloud_consent)

    # Create consent persistence callback
    async def persist_consent(granted: bool) -> None:
        async with SqlAlchemyUnitOfWork(session_factory) as uow:
            await uow.preferences.set("cloud_consent_granted", granted)
            await uow.commit()
            logger.info("Persisted cloud consent: %s", granted)

    summarization_service.on_consent_change = persist_consent
```

**Rust/Tauri Commands** (`client/src-tauri/src/commands/summary.rs`):

```rust
use tauri::command;
use crate::grpc::GrpcClient;
use crate::state::AppState;

#[derive(serde::Serialize)]
pub struct ConsentStatus {
    pub consent_granted: bool,
}

#[command]
pub async fn grant_cloud_consent(state: tauri::State<'_, AppState>) -> Result<(), String> {
    let client = state.grpc_client.lock().await;
    let client = client.as_ref().ok_or("Not connected to server")?;

    client
        .grant_cloud_consent(noteflow::GrantCloudConsentRequest {})
        .await
        .map_err(|e| format!("Failed to grant consent: {}", e))?;

    Ok(())
}

#[command]
pub async fn revoke_cloud_consent(state: tauri::State<'_, AppState>) -> Result<(), String> {
    let client = state.grpc_client.lock().await;
    let client = client.as_ref().ok_or("Not connected to server")?;

    client
        .revoke_cloud_consent(noteflow::RevokeCloudConsentRequest {})
        .await
        .map_err(|e| format!("Failed to revoke consent: {}", e))?;

    Ok(())
}

#[command]
pub async fn get_cloud_consent_status(
    state: tauri::State<'_, AppState>,
) -> Result<ConsentStatus, String> {
    let client = state.grpc_client.lock().await;
    let client = client.as_ref().ok_or("Not connected to server")?;

    let response = client
        .get_cloud_consent_status(noteflow::GetCloudConsentStatusRequest {})
        .await
        .map_err(|e| format!("Failed to get consent status: {}", e))?;

    Ok(ConsentStatus {
        consent_granted: response.into_inner().consent_granted,
    })
}
```

**Register Commands** (`main.rs`):
```rust
.invoke_handler(tauri::generate_handler![
    // ... existing handlers ...
    commands::summary::grant_cloud_consent,
    commands::summary::revoke_cloud_consent,
    commands::summary::get_cloud_consent_status,
])
```

**TypeScript Adapter** (`tauri-adapter.ts`):
```typescript
async grantCloudConsent(): Promise<void> {
  await invoke(TauriCommands.GRANT_CLOUD_CONSENT);
}

async revokeCloudConsent(): Promise<void> {
  await invoke(TauriCommands.REVOKE_CLOUD_CONSENT);
}

async getCloudConsentStatus(): Promise<{ consentGranted: boolean }> {
  return invoke<{ consent_granted: boolean }>(TauriCommands.GET_CLOUD_CONSENT_STATUS)
    .then(r => ({ consentGranted: r.consent_granted }));
}
```

**Add to TauriCommands** (`constants.ts`):
```typescript
GRANT_CLOUD_CONSENT: 'grant_cloud_consent',
REVOKE_CLOUD_CONSENT: 'revoke_cloud_consent',
GET_CLOUD_CONSENT_STATUS: 'get_cloud_consent_status',
```

---

### Task 4: Add Cloud Consent UI Toggle

**Files to Modify**:
- `client/src/pages/Settings.tsx` — Add toggle component
- `client/src/hooks/use-cloud-consent.ts` — New hook for consent state

**Hook Implementation**:
```typescript
export function useCloudConsent() {
  const [isGranted, setIsGranted] = useState(false);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    getAPI().getCloudConsentStatus().then(status => {
      setIsGranted(status.consentGranted);
      setIsLoading(false);
    });
  }, []);

  const toggle = async () => {
    setIsLoading(true);
    try {
      if (isGranted) {
        await getAPI().revokeCloudConsent();
        setIsGranted(false);
      } else {
        await getAPI().grantCloudConsent();
        setIsGranted(true);
      }
    } finally {
      setIsLoading(false);
    }
  };

  return { isGranted, isLoading, toggle };
}
```

---

## Behavioral Test Specifications

### Task 1: ServerInfo Wiring Tests

**File**: `client/src/api/tauri-adapter.test.ts`

```typescript
import { describe, it, expect, vi } from 'vitest';
import { createTauriAPI, TauriCommands } from './tauri-adapter';

describe('TauriAPI', () => {
  describe('getServerInfo', () => {
    it('calls GET_SERVER_INFO command, not CONNECT', async () => {
      const mockInvoke = vi.fn().mockResolvedValue({
        version: '1.0.0',
        features: ['summarization', 'diarization'],
      });
      const mockListen = vi.fn();

      const api = createTauriAPI(mockInvoke, mockListen);
      await api.getServerInfo();

      // CRITICAL: Must use GET_SERVER_INFO, not CONNECT
      expect(mockInvoke).toHaveBeenCalledWith(TauriCommands.GET_SERVER_INFO);
      expect(mockInvoke).not.toHaveBeenCalledWith(TauriCommands.CONNECT);
    });

    it('returns server info without side effects', async () => {
      const serverInfo = {
        version: '1.2.3',
        features: ['asr', 'summarization'],
      };
      const mockInvoke = vi.fn().mockResolvedValue(serverInfo);
      const mockListen = vi.fn();

      const api = createTauriAPI(mockInvoke, mockListen);
      const result = await api.getServerInfo();

      expect(result).toEqual(serverInfo);
    });
  });
});
```

### Task 2: Calendar Error Handling Tests

**File**: `client/src/components/upcoming-meetings.test.tsx` (additions)

```typescript
describe('UpcomingMeetings', () => {
  // ... existing tests ...

  describe('when calendar API fails', () => {
    it('shows error state with retry button', async () => {
      const mockApi = {
        listCalendarEvents: vi.fn().mockRejectedValue(new Error('Network error')),
      };
      vi.spyOn(api, 'getAPI').mockReturnValue(mockApi as any);
      vi.spyOn(preferences, 'getIntegrations').mockReturnValue([
        { id: '1', type: 'calendar', status: 'connected', name: 'Work' },
      ]);

      render(<UpcomingMeetings />, { wrapper: createWrapper() });

      await waitFor(() => {
        expect(screen.getByText(/unable to load calendar events/i)).toBeInTheDocument();
      });
      expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument();
    });

    it('retries fetch when retry button clicked', async () => {
      const mockApi = {
        listCalendarEvents: vi
          .fn()
          .mockRejectedValueOnce(new Error('Network error'))
          .mockResolvedValueOnce([{ id: '1', title: 'Recovered Event' }]),
      };
      vi.spyOn(api, 'getAPI').mockReturnValue(mockApi as any);
      vi.spyOn(preferences, 'getIntegrations').mockReturnValue([
        { id: '1', type: 'calendar', status: 'connected', name: 'Work' },
      ]);

      render(<UpcomingMeetings />, { wrapper: createWrapper() });

      await waitFor(() => {
        expect(screen.getByText(/unable to load/i)).toBeInTheDocument();
      });

      await act(async () => {
        fireEvent.click(screen.getByRole('button', { name: /try again/i }));
      });

      await waitFor(() => {
        expect(screen.getByText('Recovered Event')).toBeInTheDocument();
      });
    });

    it('shows loading skeleton during initial fetch', async () => {
      let resolvePromise: (value: unknown) => void;
      const mockApi = {
        listCalendarEvents: vi.fn().mockReturnValue(
          new Promise((resolve) => { resolvePromise = resolve; })
        ),
      };
      vi.spyOn(api, 'getAPI').mockReturnValue(mockApi as any);
      vi.spyOn(preferences, 'getIntegrations').mockReturnValue([
        { id: '1', type: 'calendar', status: 'connected', name: 'Work' },
      ]);

      render(<UpcomingMeetings />, { wrapper: createWrapper() });

      expect(screen.getByTestId('upcoming-meetings-skeleton')).toBeInTheDocument();

      await act(async () => {
        resolvePromise!([{ id: '1', title: 'Event' }]);
      });

      await waitFor(() => {
        expect(screen.queryByTestId('upcoming-meetings-skeleton')).not.toBeInTheDocument();
      });
    });
  });
});
```

### Task 3: Backend Consent Tests

**File**: `tests/grpc/test_consent_rpcs.py`

```python
"""Behavioral tests for cloud consent gRPC endpoints."""

import pytest

from noteflow.grpc import noteflow_pb2


class TestCloudConsentGranting:
    """Test granting cloud consent via gRPC."""

    async def test_grant_consent_when_not_granted_enables_cloud_mode(
        self,
        grpc_client: NoteFlowClient,
    ) -> None:
        """User can grant consent when not previously granted."""
        # Arrange: consent not granted
        status = await grpc_client.GetCloudConsentStatus(
            noteflow_pb2.GetCloudConsentStatusRequest()
        )
        assert not status.consent_granted, "Precondition: consent should start as not granted"

        # Act: grant consent (empty response indicates success)
        await grpc_client.GrantCloudConsent(
            noteflow_pb2.GrantCloudConsentRequest()
        )

        # Assert: consent is now granted
        status = await grpc_client.GetCloudConsentStatus(
            noteflow_pb2.GetCloudConsentStatusRequest()
        )
        assert status.consent_granted

    async def test_grant_consent_is_idempotent(
        self,
        grpc_client: NoteFlowClient,
    ) -> None:
        """Granting consent multiple times succeeds without error."""
        # Act: grant twice — no exception means success
        await grpc_client.GrantCloudConsent(noteflow_pb2.GrantCloudConsentRequest())
        await grpc_client.GrantCloudConsent(noteflow_pb2.GrantCloudConsentRequest())

        # Assert: status reflects granted
        status = await grpc_client.GetCloudConsentStatus(
            noteflow_pb2.GetCloudConsentStatusRequest()
        )
        assert status.consent_granted


class TestCloudConsentRevocation:
    """Test revoking cloud consent via gRPC."""

    async def test_revoke_consent_disables_cloud_summarization(
        self,
        grpc_client: NoteFlowClient,
        meeting_with_segments: str,
    ) -> None:
        """After revoking consent, summarization falls back to local mode."""
        # Arrange: grant then revoke consent
        await grpc_client.GrantCloudConsent(noteflow_pb2.GrantCloudConsentRequest())
        await grpc_client.RevokeCloudConsent(noteflow_pb2.RevokeCloudConsentRequest())

        # Act: request cloud summarization
        response = await grpc_client.GenerateSummary(
            noteflow_pb2.GenerateSummaryRequest(
                meeting_id=meeting_with_segments,
                mode="CLOUD",
            )
        )

        # Assert: summary generated but mode fell back to LOCAL
        assert response.summary is not None
        assert response.mode_used == "LOCAL"

    async def test_revoke_consent_updates_status(
        self,
        grpc_client: NoteFlowClient,
    ) -> None:
        """Revoking consent updates the consent status."""
        # Arrange: grant consent
        await grpc_client.GrantCloudConsent(noteflow_pb2.GrantCloudConsentRequest())

        # Act: revoke
        await grpc_client.RevokeCloudConsent(noteflow_pb2.RevokeCloudConsentRequest())

        # Assert
        status = await grpc_client.GetCloudConsentStatus(
            noteflow_pb2.GetCloudConsentStatusRequest()
        )
        assert not status.consent_granted


class TestConsentPersistence:
    """Test that consent state persists across service restarts."""

    @pytest.mark.integration
    async def test_consent_survives_server_restart(
        self,
        grpc_server_factory: Callable[[], NoteFlowServer],
    ) -> None:
        """Consent persists to database and survives restart."""
        # Arrange: start server, grant consent
        server1 = grpc_server_factory()
        client1 = await server1.client()
        await client1.GrantCloudConsent(noteflow_pb2.GrantCloudConsentRequest())
        await server1.stop()

        # Act: start new server instance
        server2 = grpc_server_factory()
        client2 = await server2.client()

        # Assert: consent still granted
        status = await client2.GetCloudConsentStatus(
            noteflow_pb2.GetCloudConsentStatusRequest()
        )
        assert status.consent_granted

    @pytest.mark.integration
    async def test_consent_stored_in_preferences_table(
        self,
        grpc_client: NoteFlowClient,
        async_session: AsyncSession,
    ) -> None:
        """Consent is persisted to the user_preferences table."""
        # Arrange: ensure no consent
        await grpc_client.RevokeCloudConsent(noteflow_pb2.RevokeCloudConsentRequest())

        # Act: grant consent
        await grpc_client.GrantCloudConsent(noteflow_pb2.GrantCloudConsentRequest())

        # Assert: verify in database
        from noteflow.infrastructure.persistence.models.identity.settings import (
            UserPreferencesModel,
        )
        result = await async_session.execute(
            select(UserPreferencesModel).where(
                UserPreferencesModel.key == "cloud_consent_granted"
            )
        )
        row = result.scalar_one()
        assert row.value is True

    @pytest.mark.integration
    async def test_revoke_updates_preferences_table(
        self,
        grpc_client: NoteFlowClient,
        async_session: AsyncSession,
    ) -> None:
        """Revoking consent updates the database."""
        # Arrange: grant consent
        await grpc_client.GrantCloudConsent(noteflow_pb2.GrantCloudConsentRequest())

        # Act: revoke
        await grpc_client.RevokeCloudConsent(noteflow_pb2.RevokeCloudConsentRequest())

        # Assert: verify in database
        from noteflow.infrastructure.persistence.models.identity.settings import (
            UserPreferencesModel,
        )
        result = await async_session.execute(
            select(UserPreferencesModel).where(
                UserPreferencesModel.key == "cloud_consent_granted"
            )
        )
        row = result.scalar_one()
        assert row.value is False
```

### Application Service Tests

**File**: `tests/application/test_summarization_consent.py`

```python
"""Behavioral tests for summarization consent at application layer."""

import pytest
from unittest.mock import AsyncMock, MagicMock

from noteflow.application.services.summarization_service import (
    SummarizationService,
    SummarizationServiceSettings,
)


class TestConsentCallbacks:
    """Test consent change callback behavior."""

    async def test_granting_consent_triggers_callback(self) -> None:
        """Callback invoked when consent is granted."""
        callback = AsyncMock()
        service = SummarizationService(
            settings=SummarizationServiceSettings(),
            on_consent_change=callback,
        )

        await service.grant_cloud_consent()

        callback.assert_awaited_once_with(True)

    async def test_revoking_consent_triggers_callback(self) -> None:
        """Callback invoked when consent is revoked."""
        callback = AsyncMock()
        service = SummarizationService(
            settings=SummarizationServiceSettings(cloud_consent_granted=True),
            on_consent_change=callback,
        )

        await service.revoke_cloud_consent()

        callback.assert_awaited_once_with(False)

    async def test_no_callback_when_none_configured(self) -> None:
        """Service works without callback configured."""
        service = SummarizationService(
            settings=SummarizationServiceSettings(),
            on_consent_change=None,
        )

        # Should not raise
        await service.grant_cloud_consent()
        await service.revoke_cloud_consent()


class TestConsentEnforcement:
    """Test that consent is enforced during summarization."""

    @pytest.mark.parametrize(
        ("initial_consent", "requested_mode", "expected_mode"),
        [
            pytest.param(False, "CLOUD", "LOCAL", id="no-consent-falls-back-to-local"),
            pytest.param(True, "CLOUD", "CLOUD", id="with-consent-uses-cloud"),
            pytest.param(False, "LOCAL", "LOCAL", id="local-mode-ignores-consent"),
            pytest.param(True, "LOCAL", "LOCAL", id="local-mode-respects-choice"),
        ],
    )
    async def test_mode_selection_respects_consent(
        self,
        initial_consent: bool,
        requested_mode: str,
        expected_mode: str,
        mock_cloud_provider: MagicMock,
        mock_local_provider: MagicMock,
    ) -> None:
        """Summarization mode respects consent state."""
        service = SummarizationService(
            settings=SummarizationServiceSettings(
                cloud_consent_granted=initial_consent,
            ),
            cloud_provider=mock_cloud_provider,
            local_provider=mock_local_provider,
        )

        result = await service.summarize(
            meeting_id="test-meeting",
            mode=requested_mode,
        )

        assert result.mode_used == expected_mode
```

### Frontend Integration Tests

**File**: `client/src/hooks/use-cloud-consent.test.ts`

```typescript
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { useCloudConsent } from './use-cloud-consent';
import * as api from '../api';

describe('useCloudConsent', () => {
  describe('initial state loading', () => {
    it('loads consent status on mount', async () => {
      const mockApi = {
        getCloudConsentStatus: vi.fn().mockResolvedValue({ consentGranted: true }),
      };
      vi.spyOn(api, 'getAPI').mockReturnValue(mockApi as any);

      const { result } = renderHook(() => useCloudConsent());

      expect(result.current.isLoading).toBe(true);

      await waitFor(() => {
        expect(result.current.isLoading).toBe(false);
      });

      expect(result.current.isGranted).toBe(true);
    });
  });

  describe('toggling consent', () => {
    it('grants consent when not granted', async () => {
      const mockApi = {
        getCloudConsentStatus: vi.fn().mockResolvedValue({ consentGranted: false }),
        grantCloudConsent: vi.fn().mockResolvedValue({ success: true }),
      };
      vi.spyOn(api, 'getAPI').mockReturnValue(mockApi as any);

      const { result } = renderHook(() => useCloudConsent());
      await waitFor(() => expect(result.current.isLoading).toBe(false));

      await act(async () => {
        await result.current.toggle();
      });

      expect(mockApi.grantCloudConsent).toHaveBeenCalled();
      expect(result.current.isGranted).toBe(true);
    });

    it('revokes consent when already granted', async () => {
      const mockApi = {
        getCloudConsentStatus: vi.fn().mockResolvedValue({ consentGranted: true }),
        revokeCloudConsent: vi.fn().mockResolvedValue({ success: true }),
      };
      vi.spyOn(api, 'getAPI').mockReturnValue(mockApi as any);

      const { result } = renderHook(() => useCloudConsent());
      await waitFor(() => expect(result.current.isLoading).toBe(false));

      await act(async () => {
        await result.current.toggle();
      });

      expect(mockApi.revokeCloudConsent).toHaveBeenCalled();
      expect(result.current.isGranted).toBe(false);
    });
  });
});
```

### Calendar Wiring Tests

**File**: `client/src/components/upcoming-meetings.test.tsx`

```typescript
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { UpcomingMeetings } from './upcoming-meetings';
import * as api from '../api';

const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });
  return ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
};

describe('UpcomingMeetings', () => {
  describe('when calendars are connected', () => {
    it('fetches real events from calendar API', async () => {
      const mockEvents = [
        {
          id: '1',
          title: 'Team Standup',
          start: new Date(Date.now() + 3600000).toISOString(),
          end: new Date(Date.now() + 5400000).toISOString(),
        },
      ];
      const mockApi = {
        listCalendarEvents: vi.fn().mockResolvedValue(mockEvents),
      };
      vi.spyOn(api, 'getAPI').mockReturnValue(mockApi as any);

      render(<UpcomingMeetings />, { wrapper: createWrapper() });

      await waitFor(() => {
        expect(screen.getByText('Team Standup')).toBeInTheDocument();
      });
      expect(mockApi.listCalendarEvents).toHaveBeenCalled();
    });

    it('displays events sorted by start time', async () => {
      const mockEvents = [
        { id: '2', title: 'Later Meeting', start: new Date(Date.now() + 7200000).toISOString() },
        { id: '1', title: 'Earlier Meeting', start: new Date(Date.now() + 3600000).toISOString() },
      ];
      vi.spyOn(api, 'getAPI').mockReturnValue({
        listCalendarEvents: vi.fn().mockResolvedValue(mockEvents),
      } as any);

      render(<UpcomingMeetings />, { wrapper: createWrapper() });

      await waitFor(() => {
        const items = screen.getAllByRole('listitem');
        expect(items[0]).toHaveTextContent('Earlier Meeting');
        expect(items[1]).toHaveTextContent('Later Meeting');
      });
    });
  });

  describe('when no calendars connected', () => {
    it('shows empty state without API calls', async () => {
      const mockApi = {
        listCalendarEvents: vi.fn(),
      };
      vi.spyOn(api, 'getAPI').mockReturnValue(mockApi as any);

      render(<UpcomingMeetings />, { wrapper: createWrapper() });

      await waitFor(() => {
        expect(screen.getByText(/no upcoming meetings/i)).toBeInTheDocument();
      });
      expect(mockApi.listCalendarEvents).not.toHaveBeenCalled();
    });
  });
});
```

---

## Acceptance Criteria

### Functional

- [x] `getServerInfo()` uses correct Tauri command (not `CONNECT`)
- [x] Upcoming meetings displays real calendar events (not mock data)
- [x] Calendar errors show retry option (graceful degradation)
- [x] Cloud consent can be granted via Settings UI
- [x] Cloud consent can be revoked via Settings UI
- [x] Consent state persists across app restarts (database-backed)
- [x] Summarization falls back to LOCAL when consent not granted

### Technical

- [x] `generateMockEvents()` removed from production code
- [x] Consent RPCs follow existing gRPC patterns (empty success responses)
- [x] Consent persistence wired via `on_consent_change` callback
- [x] Rust commands registered in Tauri handler
- [x] UI toggle reflects actual backend state
- [x] Calendar API errors show user-friendly messages

### Quality Gates

- [x] `pytest tests/quality/` passes (48 passed)
- [x] `cargo clippy` passes (Rust consent commands)
- [x] `npm run test` passes (174 tests passed)
- [x] No new `# type: ignore` without justification
- [x] All public functions have docstrings

---

## Rollback Plan

1. **ServerInfo issues**: Revert single-line change in `tauri-adapter.ts`
2. **Calendar wiring issues**: Re-enable mock generator with feature flag
3. **Consent RPC failures**: Return safe defaults (consent=false)
4. **Consent persistence failures**: Fall back to in-memory only
5. **UI regressions**: Hide consent toggle behind feature flag

---

## Post-Sprint

- [x] Remove dead `generateMockEvents()` code (already removed)
- [ ] Document consent flow in user guide

> **Note**: Telemetry for consent events deferred to a privacy-focused sprint that addresses user opt-out, data retention, and GDPR compliance.
