# Sprint 15.3: Test Coverage Gaps

> **Size**: L | **Owner**: Full Stack | **Priority**: HIGH
> **Phase**: 4.5 - Stabilization
> **Prerequisites**: Sprint 15.1 complete
> **Status**: ✅ COMPLETE

---

## Objective

Address critical test coverage gaps identified during Phase 4 closeout:
- Frontend component coverage: **9.9%** (target: 60%)
- gRPC mixin test coverage: **22%** (target: 80%)
- React hook test coverage: **~33%** (target: 80%)
- Assertion roulette tests: **91** (target: ≤50)

---

## Coverage Gap Summary

### Frontend Components (Most Critical)

| Component | Lines | Has Tests | Priority |
|-----------|-------|-----------|----------|
| `integration-config-panel.tsx` | 21,356 | No | P0 |
| `entity-management-panel.tsx` | 13,863 | No | P0 |
| `app-sidebar.tsx` | 5,581 | No | P1 |
| Other components (100+) | varies | 11 tested | P2 |

**Current**: 11/111 components tested (9.9%)
**Target**: 67/111 components tested (60%)

### gRPC Mixins

| Mixin | Lines | Has Tests | Priority |
|-------|-------|-----------|----------|
| `annotation.py` | ~200 | No | P1 |
| `calendar.py` | ~300 | No | P1 |
| `entities.py` | ~250 | No | P1 |
| `export.py` | ~200 | No | P1 |
| `meeting.py` | ~300 | No | P0 |
| `summarization.py` | ~200 | No | P1 |
| `sync.py` | ~150 | No | P2 |
| `diarization_job.py` | ~100 | No | P2 |
| `streaming.py` | 578 | Partial | P0 |
| `diarization.py` | 562 | Partial | P0 |
| `preferences.py` | ~150 | Yes | Done |
| `observability.py` | ~100 | Yes | Done |
| `webhooks.py` | ~150 | Yes | Done |
| `converters.py` | ~200 | Yes | Done |

**Current**: 4/18 mixins with dedicated tests (22%)
**Target**: 14/18 mixins with dedicated tests (78%)

### React Hooks

| Hook | Has Tests | Priority |
|------|-----------|----------|
| `use-audio-devices.ts` | No | P1 |
| `use-diarization.ts` | No | P0 |
| `use-meeting-reminders.ts` | No | P1 |
| `use-integration-sync.ts` | No | P1 |
| `use-entity-extraction.ts` | No | P1 |
| `use-webhooks.ts` | Yes | Done |
| `use-preferences-sync.ts` | Partial | P2 |
| `use-cloud-consent.ts` | No | P1 |
| `use-triggers.ts` | No | P1 |
| `use-calendar-sync.ts` | No | P1 |
| 5 other hooks | Yes | Done |

**Current**: 5/15 hooks with tests (33%)
**Target**: 12/15 hooks with tests (80%)

---

## Task 1: Fix Assertion Roulette Tests

### What is Assertion Roulette?

Tests with multiple assertions without descriptive messages, making failures hard to diagnose:

```python
# BAD: Assertion roulette
def test_meeting_creation():
    meeting = create_meeting()
    assert meeting.id is not None
    assert meeting.state == MeetingState.CREATED
    assert meeting.title == "Test Meeting"
    assert len(meeting.segments) == 0
    # If any fails, which one?

# GOOD: Descriptive assertions
def test_meeting_creation():
    meeting = create_meeting()
    assert meeting.id is not None, "Meeting should have an ID"
    assert meeting.state == MeetingState.CREATED, f"Expected CREATED, got {meeting.state}"
    assert meeting.title == "Test Meeting", f"Expected 'Test Meeting', got '{meeting.title}'"
    assert len(meeting.segments) == 0, f"Expected 0 segments, got {len(meeting.segments)}"
```

### Priority Test Files to Fix

Based on quality test results, focus on files with most assertion roulette violations:

1. `tests/infrastructure/persistence/test_meeting_repository.py`
2. `tests/infrastructure/persistence/test_segment_repository.py`
3. `tests/domain/test_meeting.py`
4. `tests/grpc/test_service.py`
5. `tests/application/test_meeting_service.py`

### Automated Fix Script

```python
# scripts/fix_assertion_roulette.py
import re
import sys
from pathlib import Path

def add_assertion_messages(content: str) -> str:
    """Add messages to bare assertions."""
    # Pattern: assert <expr> (without message)
    pattern = r'^(\s*)(assert\s+.+?)(\s*#.*)?$'

    def add_message(match):
        indent = match.group(1)
        assertion = match.group(2).rstrip()
        comment = match.group(3) or ""

        # Skip if already has message (contains comma after assert expression)
        if ", " in assertion and not assertion.endswith(","):
            return match.group(0)

        # Extract the expression
        expr = assertion.replace("assert ", "")
        # Generate descriptive message
        msg = f'"{expr} should be truthy"'

        return f'{indent}{assertion}, {msg}{comment}'

    return re.sub(pattern, add_message, content, flags=re.MULTILINE)
```

---

## Task 2: Add gRPC Mixin Tests

### Priority 1: `meeting.py` Tests

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

```python
"""Tests for MeetingMixin gRPC handlers."""

import pytest
from uuid import uuid4

from noteflow.grpc._mixins.meeting import MeetingMixin
from noteflow.domain.value_objects import MeetingState
import noteflow.grpc.proto.noteflow_pb2 as noteflow_pb2


class TestCreateMeeting:
    """Tests for CreateMeeting RPC."""

    async def test_create_meeting_returns_valid_meeting(self, servicer, context):
        """CreateMeeting should return a meeting with valid ID and CREATED state."""
        request = noteflow_pb2.CreateMeetingRequest(title="Test Meeting")

        response = await servicer.CreateMeeting(request, context)

        assert response.id, "Meeting should have an ID"
        assert response.state == MeetingState.CREATED, "State should be CREATED"
        assert response.title == "Test Meeting", "Title should match request"

    async def test_create_meeting_without_title_uses_default(self, servicer, context):
        """CreateMeeting without title should use default."""
        request = noteflow_pb2.CreateMeetingRequest()

        response = await servicer.CreateMeeting(request, context)

        assert response.title == "Untitled Meeting", "Should use default title"


class TestStopMeeting:
    """Tests for StopMeeting RPC."""

    async def test_stop_recording_meeting_transitions_to_stopped(self, servicer, context):
        """StopMeeting on RECORDING meeting should transition to STOPPED."""
        # Create and start recording
        meeting = await create_recording_meeting(servicer, context)

        request = noteflow_pb2.StopMeetingRequest(meeting_id=str(meeting.id))
        response = await servicer.StopMeeting(request, context)

        assert response.state == MeetingState.STOPPED, "State should be STOPPED"

    async def test_stop_already_stopped_meeting_is_idempotent(self, servicer, context):
        """StopMeeting on already STOPPED meeting should succeed (idempotent)."""
        meeting = await create_and_stop_meeting(servicer, context)

        request = noteflow_pb2.StopMeetingRequest(meeting_id=str(meeting.id))
        response = await servicer.StopMeeting(request, context)

        assert response.state == MeetingState.STOPPED, "State should remain STOPPED"

    async def test_stop_nonexistent_meeting_returns_not_found(self, servicer, context):
        """StopMeeting on nonexistent meeting should return NOT_FOUND."""
        request = noteflow_pb2.StopMeetingRequest(meeting_id=str(uuid4()))

        with pytest.raises(grpc.RpcError) as exc_info:
            await servicer.StopMeeting(request, context)

        assert exc_info.value.code() == grpc.StatusCode.NOT_FOUND


class TestGetMeeting:
    """Tests for GetMeeting RPC."""

    async def test_get_existing_meeting_returns_meeting(self, servicer, context):
        """GetMeeting should return the requested meeting."""
        meeting = await create_meeting(servicer, context)

        request = noteflow_pb2.GetMeetingRequest(meeting_id=str(meeting.id))
        response = await servicer.GetMeeting(request, context)

        assert response.id == meeting.id, "Should return same meeting"

    async def test_get_nonexistent_meeting_returns_not_found(self, servicer, context):
        """GetMeeting on nonexistent meeting should return NOT_FOUND."""
        request = noteflow_pb2.GetMeetingRequest(meeting_id=str(uuid4()))

        with pytest.raises(grpc.RpcError) as exc_info:
            await servicer.GetMeeting(request, context)

        assert exc_info.value.code() == grpc.StatusCode.NOT_FOUND


class TestDeleteMeeting:
    """Tests for DeleteMeeting RPC."""

    async def test_delete_stopped_meeting_succeeds(self, servicer, context):
        """DeleteMeeting on STOPPED meeting should succeed."""
        meeting = await create_and_stop_meeting(servicer, context)

        request = noteflow_pb2.DeleteMeetingRequest(meeting_id=str(meeting.id))
        response = await servicer.DeleteMeeting(request, context)

        assert response.success is True, "Delete should succeed"

    async def test_delete_recording_meeting_fails(self, servicer, context):
        """DeleteMeeting on RECORDING meeting should fail."""
        meeting = await create_recording_meeting(servicer, context)

        request = noteflow_pb2.DeleteMeetingRequest(meeting_id=str(meeting.id))

        with pytest.raises(grpc.RpcError) as exc_info:
            await servicer.DeleteMeeting(request, context)

        assert exc_info.value.code() == grpc.StatusCode.FAILED_PRECONDITION
```

### Priority 2: `streaming.py` Additional Tests

**File**: `tests/grpc/test_streaming_mixin.py` (extend existing)

```python
class TestStreamTranscription:
    """Tests for StreamTranscription RPC."""

    async def test_stream_requires_meeting_id(self, servicer, context):
        """First chunk without meeting_id should abort."""
        chunk = noteflow_pb2.AudioChunk(audio_data=b"test")

        async def chunks():
            yield chunk

        with pytest.raises(grpc.RpcError) as exc_info:
            async for _ in servicer.StreamTranscription(chunks(), context):
                pass

        assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT

    async def test_stream_rejects_changing_meeting_id(self, servicer, context):
        """Changing meeting_id mid-stream should abort."""
        meeting1 = await create_meeting(servicer, context)
        meeting2 = await create_meeting(servicer, context)

        async def chunks():
            yield noteflow_pb2.AudioChunk(meeting_id=str(meeting1.id), audio_data=b"a")
            yield noteflow_pb2.AudioChunk(meeting_id=str(meeting2.id), audio_data=b"b")

        with pytest.raises(grpc.RpcError) as exc_info:
            async for _ in servicer.StreamTranscription(chunks(), context):
                pass

        assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT

    async def test_concurrent_streams_same_meeting_rejected(self, servicer, context):
        """Two concurrent streams for same meeting should fail one."""
        meeting = await create_meeting(servicer, context)

        async def stream():
            async for _ in servicer.StreamTranscription(
                audio_chunks(meeting.id), context
            ):
                pass

        results = await asyncio.gather(
            stream(), stream(), return_exceptions=True
        )

        success_count = sum(1 for r in results if not isinstance(r, Exception))
        failure_count = sum(1 for r in results if isinstance(r, grpc.RpcError))

        assert success_count == 1, "Exactly one stream should succeed"
        assert failure_count == 1, "Exactly one stream should fail"
```

---

## Task 3: Add React Hook Tests

### Priority 1: `use-diarization.ts` Tests

**File**: `client/src/hooks/__tests__/use-diarization.test.ts`

```typescript
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useDiarization } from '../use-diarization';
import { createTauriAPI } from '@/lib/tauri';

vi.mock('@/lib/tauri');

describe('useDiarization', () => {
  const mockTauri = {
    refineSpeakerDiarization: vi.fn(),
    getDiarizationJobStatus: vi.fn(),
    renameSpeaker: vi.fn(),
  };

  beforeEach(() => {
    vi.clearAllMocks();
    vi.mocked(createTauriAPI).mockReturnValue(mockTauri as any);
  });

  describe('startRefinement', () => {
    it('should start diarization job and poll for completion', async () => {
      mockTauri.refineSpeakerDiarization.mockResolvedValue({ jobId: 'job-1' });
      mockTauri.getDiarizationJobStatus
        .mockResolvedValueOnce({ status: 'RUNNING' })
        .mockResolvedValueOnce({ status: 'COMPLETED', speakerIds: ['speaker-1'] });

      const { result } = renderHook(() => useDiarization('meeting-1'));

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

      await waitFor(() => {
        expect(result.current.status).toBe('completed');
      });

      expect(result.current.speakerIds).toEqual(['speaker-1']);
    });

    it('should handle job failure gracefully', async () => {
      mockTauri.refineSpeakerDiarization.mockResolvedValue({ jobId: 'job-1' });
      mockTauri.getDiarizationJobStatus.mockResolvedValue({
        status: 'FAILED',
        errorMessage: 'Inference error',
      });

      const { result } = renderHook(() => useDiarization('meeting-1'));

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

      await waitFor(() => {
        expect(result.current.status).toBe('failed');
      });

      expect(result.current.error).toBe('Inference error');
    });
  });

  describe('renameSpeaker', () => {
    it('should rename speaker and update local state', async () => {
      mockTauri.renameSpeaker.mockResolvedValue({ success: true });

      const { result } = renderHook(() => useDiarization('meeting-1'));

      await act(async () => {
        await result.current.renameSpeaker('speaker-1', 'Alice');
      });

      expect(mockTauri.renameSpeaker).toHaveBeenCalledWith('meeting-1', 'speaker-1', 'Alice');
    });
  });

  describe('cleanup', () => {
    it('should clear polling on unmount', async () => {
      mockTauri.refineSpeakerDiarization.mockResolvedValue({ jobId: 'job-1' });
      mockTauri.getDiarizationJobStatus.mockResolvedValue({ status: 'RUNNING' });

      const { result, unmount } = renderHook(() => useDiarization('meeting-1'));

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

      unmount();

      // Verify no further polling calls after unmount
      const callCount = mockTauri.getDiarizationJobStatus.mock.calls.length;
      await new Promise(r => setTimeout(r, 100));
      expect(mockTauri.getDiarizationJobStatus).toHaveBeenCalledTimes(callCount);
    });
  });
});
```

### Priority 2: `use-audio-devices.ts` Tests

**File**: `client/src/hooks/__tests__/use-audio-devices.test.ts`

```typescript
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAudioDevices } from '../use-audio-devices';
import { createTauriAPI } from '@/lib/tauri';

vi.mock('@/lib/tauri');

describe('useAudioDevices', () => {
  const mockTauri = {
    listAudioDevices: vi.fn(),
    startInputTest: vi.fn(),
    stopInputTest: vi.fn(),
    getAudioTestStatus: vi.fn(),
  };

  beforeEach(() => {
    vi.clearAllMocks();
    vi.mocked(createTauriAPI).mockReturnValue(mockTauri as any);
  });

  describe('device listing', () => {
    it('should fetch and return available devices', async () => {
      const devices = [
        { id: 'dev-1', name: 'Microphone', isInput: true },
        { id: 'dev-2', name: 'Speakers', isInput: false },
      ];
      mockTauri.listAudioDevices.mockResolvedValue(devices);

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

      await waitFor(() => {
        expect(result.current.devices).toHaveLength(2);
      });

      expect(result.current.inputDevices).toHaveLength(1);
      expect(result.current.outputDevices).toHaveLength(1);
    });
  });

  describe('input test', () => {
    it('should start and stop input test', async () => {
      mockTauri.startInputTest.mockResolvedValue({ success: true });
      mockTauri.stopInputTest.mockResolvedValue({ success: true });

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

      await act(async () => {
        await result.current.startInputTest('dev-1');
      });

      expect(result.current.isTestingInput).toBe(true);
      expect(mockTauri.startInputTest).toHaveBeenCalledWith('dev-1');

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

      expect(result.current.isTestingInput).toBe(false);
    });

    it('should stop input test on unmount', async () => {
      mockTauri.startInputTest.mockResolvedValue({ success: true });
      mockTauri.stopInputTest.mockResolvedValue({ success: true });

      const { result, unmount } = renderHook(() => useAudioDevices());

      await act(async () => {
        await result.current.startInputTest('dev-1');
      });

      unmount();

      expect(mockTauri.stopInputTest).toHaveBeenCalled();
    });
  });
});
```

---

## Task 4: Add Frontend Component Tests

### Priority 0: Integration Config Panel

Given the size (21,356 lines), focus on critical paths:

**File**: `client/src/components/__tests__/integration-config-panel.test.tsx`

```typescript
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { IntegrationConfigPanel } from '../integration-config-panel';
import { createTauriAPI } from '@/lib/tauri';

vi.mock('@/lib/tauri');

describe('IntegrationConfigPanel', () => {
  const mockTauri = {
    listIntegrations: vi.fn(),
    connectIntegration: vi.fn(),
    disconnectIntegration: vi.fn(),
    syncIntegration: vi.fn(),
  };

  beforeEach(() => {
    vi.clearAllMocks();
    vi.mocked(createTauriAPI).mockReturnValue(mockTauri as any);
  });

  describe('integration listing', () => {
    it('should display available integrations', async () => {
      mockTauri.listIntegrations.mockResolvedValue([
        { id: 'google', name: 'Google Calendar', status: 'disconnected' },
        { id: 'outlook', name: 'Outlook Calendar', status: 'connected' },
      ]);

      render(<IntegrationConfigPanel />);

      await waitFor(() => {
        expect(screen.getByText('Google Calendar')).toBeInTheDocument();
        expect(screen.getByText('Outlook Calendar')).toBeInTheDocument();
      });
    });
  });

  describe('OAuth flow', () => {
    it('should initiate OAuth when Connect clicked', async () => {
      mockTauri.listIntegrations.mockResolvedValue([
        { id: 'google', name: 'Google Calendar', status: 'disconnected' },
      ]);
      mockTauri.connectIntegration.mockResolvedValue({ authUrl: 'https://oauth...' });

      render(<IntegrationConfigPanel />);

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

      fireEvent.click(screen.getByText('Connect'));

      await waitFor(() => {
        expect(mockTauri.connectIntegration).toHaveBeenCalledWith('google');
      });
    });
  });

  describe('sync trigger', () => {
    it('should trigger sync for connected integration', async () => {
      mockTauri.listIntegrations.mockResolvedValue([
        { id: 'outlook', name: 'Outlook', status: 'connected' },
      ]);
      mockTauri.syncIntegration.mockResolvedValue({ eventsImported: 5 });

      render(<IntegrationConfigPanel />);

      await waitFor(() => {
        expect(screen.getByText('Sync Now')).toBeInTheDocument();
      });

      fireEvent.click(screen.getByText('Sync Now'));

      await waitFor(() => {
        expect(mockTauri.syncIntegration).toHaveBeenCalledWith('outlook');
      });
    });
  });
});
```

---

## Tasks Checklist

### Phase A: Assertion Roulette Fix (Day 1-2)
- [ ] Run quality tests to identify all assertion roulette files
- [ ] Fix top 20 files with most violations
- [ ] Re-run quality tests to verify reduction
- [ ] Target: 91 → ≤50 violations

### Phase B: gRPC Mixin Tests (Day 3-5)
- [ ] Add `tests/grpc/test_meeting_mixin.py`
- [ ] Add `tests/grpc/test_streaming_mixin.py` (extend)
- [ ] Add `tests/grpc/test_diarization_mixin.py` (extend)
- [ ] Add `tests/grpc/test_annotation_mixin.py`
- [ ] Add `tests/grpc/test_export_mixin.py`
- [ ] Add `tests/grpc/test_entities_mixin.py`
- [ ] Target: 4/18 → 14/18 mixins tested

### Phase C: React Hook Tests (Day 6-7)
- [ ] Add `use-diarization.test.ts`
- [ ] Add `use-audio-devices.test.ts`
- [ ] Add `use-entity-extraction.test.ts`
- [ ] Add `use-meeting-reminders.test.ts`
- [ ] Add `use-integration-sync.test.ts`
- [ ] Add `use-cloud-consent.test.ts`
- [ ] Add `use-triggers.test.ts`
- [ ] Target: 5/15 → 12/15 hooks tested

### Phase D: Frontend Component Tests (Day 8-10)
- [ ] Add `integration-config-panel.test.tsx`
- [ ] Add `entity-management-panel.test.tsx`
- [ ] Add `app-sidebar.test.tsx`
- [ ] Add tests for 10 other high-priority components
- [ ] Target: 11/111 → 25/111 components tested

---

## Acceptance Criteria

### Quantitative

- [ ] Assertion roulette tests: ≤50 (from 91)
- [ ] gRPC mixin test coverage: ≥78% (from 22%)
- [ ] React hook test coverage: ≥80% (from 33%)
- [ ] Frontend component coverage: ≥22% (from 9.9%)

### Quality Gates

- [ ] `pytest tests/quality/` passes
- [ ] `npm run test` passes in client/
- [ ] No new test smells introduced

---

## Files Created

| File | Type | Tests Added |
|------|------|-------------|
| `tests/grpc/test_meeting_mixin.py` | Create | 10+ |
| `tests/grpc/test_streaming_mixin.py` | Extend | 5+ |
| `tests/grpc/test_annotation_mixin.py` | Create | 5+ |
| `tests/grpc/test_export_mixin.py` | Create | 5+ |
| `tests/grpc/test_entities_mixin.py` | Create | 5+ |
| `client/src/hooks/__tests__/use-diarization.test.ts` | Create | 5+ |
| `client/src/hooks/__tests__/use-audio-devices.test.ts` | Create | 5+ |
| `client/src/hooks/__tests__/use-entity-extraction.test.ts` | Create | 5+ |
| `client/src/components/__tests__/integration-config-panel.test.tsx` | Create | 5+ |
| `client/src/components/__tests__/entity-management-panel.test.tsx` | Create | 5+ |

---

## Post-Sprint

- Set up coverage reporting in CI
- Add coverage thresholds as quality gate
- Consider property-based testing for stream handling
- Add visual regression tests for large components
