Files
noteflow/docs/sprints/phase-ongoing/.archive/sprint-gap-011-post-processing-pipeline/TESTING.md
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

28 KiB

Testing Guide: SPRINT-GAP-011

Comprehensive testing strategy for the post-processing pipeline.

Unit Tests

usePostProcessing Hook (client/src/hooks/use-post-processing.test.ts)

import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { usePostProcessing } from './use-post-processing';

// Mock the API
const mockApi = {
  generateSummary: vi.fn(),
  extractEntities: vi.fn(),
  refineSpeakers: vi.fn(),
  getDiarizationJobStatus: vi.fn(),
};

vi.mock('@/contexts/connection-context', () => ({
  useAPI: () => mockApi,
}));

// Mock Tauri event listener
vi.mock('@tauri-apps/api/event', () => ({
  listen: vi.fn(() => Promise.resolve(() => {})),
}));

describe('usePostProcessing', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe('initial state', () => {
    it('should initialize all steps as pending when no processing done', () => {
      const { result } = renderHook(() =>
        usePostProcessing({
          meetingId: 'test-meeting',
          meetingState: 'completed',
        })
      );

      expect(result.current.steps).toEqual([
        { name: 'summary', status: 'pending' },
        { name: 'entities', status: 'pending' },
        { name: 'diarization', status: 'pending' },
      ]);
      expect(result.current.isProcessing).toBe(false);
      expect(result.current.isComplete).toBe(false);
    });

    it('should mark steps as completed if already processed', () => {
      const { result } = renderHook(() =>
        usePostProcessing({
          meetingId: 'test-meeting',
          meetingState: 'completed',
          processingStatus: {
            summary_generated: true,
            entities_extracted: false,
            diarization_refined: true,
          },
        })
      );

      expect(result.current.steps[0].status).toBe('completed'); // summary
      expect(result.current.steps[1].status).toBe('pending'); // entities
      expect(result.current.steps[2].status).toBe('completed'); // diarization
    });
  });

  describe('auto-start', () => {
    it('should not auto-start when autoStart is false', () => {
      renderHook(() =>
        usePostProcessing({
          meetingId: 'test-meeting',
          meetingState: 'completed',
          autoStart: false,
        })
      );

      expect(mockApi.generateSummary).not.toHaveBeenCalled();
      expect(mockApi.extractEntities).not.toHaveBeenCalled();
      expect(mockApi.refineSpeakers).not.toHaveBeenCalled();
    });

    it('should auto-start when autoStart is true and meeting is completed', async () => {
      mockApi.generateSummary.mockResolvedValue({ id: 'summary-1' });
      mockApi.extractEntities.mockResolvedValue([]);
      mockApi.refineSpeakers.mockResolvedValue({ jobId: 'job-1' });
      mockApi.getDiarizationJobStatus.mockResolvedValue({ status: 'completed' });

      renderHook(() =>
        usePostProcessing({
          meetingId: 'test-meeting',
          meetingState: 'completed',
          autoStart: true,
        })
      );

      await waitFor(() => {
        expect(mockApi.generateSummary).toHaveBeenCalledWith('test-meeting', false);
        expect(mockApi.extractEntities).toHaveBeenCalledWith('test-meeting', false);
        expect(mockApi.refineSpeakers).toHaveBeenCalledWith('test-meeting');
      });
    });

    it('should not auto-start when meeting is not completed', () => {
      renderHook(() =>
        usePostProcessing({
          meetingId: 'test-meeting',
          meetingState: 'recording',
          autoStart: true,
        })
      );

      expect(mockApi.generateSummary).not.toHaveBeenCalled();
    });
  });

  describe('manual start', () => {
    it('should start all processing when startProcessing is called', async () => {
      mockApi.generateSummary.mockResolvedValue({ id: 'summary-1' });
      mockApi.extractEntities.mockResolvedValue([]);
      mockApi.refineSpeakers.mockResolvedValue({ jobId: 'job-1' });
      mockApi.getDiarizationJobStatus.mockResolvedValue({ status: 'completed' });

      const { result } = renderHook(() =>
        usePostProcessing({
          meetingId: 'test-meeting',
          meetingState: 'completed',
        })
      );

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

      expect(mockApi.generateSummary).toHaveBeenCalled();
      expect(mockApi.extractEntities).toHaveBeenCalled();
      expect(mockApi.refineSpeakers).toHaveBeenCalled();
    });

    it('should not start if already processing', async () => {
      mockApi.generateSummary.mockImplementation(
        () => new Promise((resolve) => setTimeout(resolve, 1000))
      );
      mockApi.extractEntities.mockResolvedValue([]);
      mockApi.refineSpeakers.mockResolvedValue({ jobId: 'job-1' });
      mockApi.getDiarizationJobStatus.mockResolvedValue({ status: 'completed' });

      const { result } = renderHook(() =>
        usePostProcessing({
          meetingId: 'test-meeting',
          meetingState: 'completed',
        })
      );

      // Start processing
      act(() => {
        result.current.startProcessing();
      });

      // Try to start again immediately
      await act(async () => {
        await result.current.startProcessing();
      });

      // Should only have been called once
      expect(mockApi.generateSummary).toHaveBeenCalledTimes(1);
    });
  });

  describe('step status updates', () => {
    it('should update step status to running then completed', async () => {
      mockApi.generateSummary.mockResolvedValue({ id: 'summary-1' });
      mockApi.extractEntities.mockResolvedValue([]);
      mockApi.refineSpeakers.mockResolvedValue({ jobId: 'job-1' });
      mockApi.getDiarizationJobStatus.mockResolvedValue({ status: 'completed' });

      const { result } = renderHook(() =>
        usePostProcessing({
          meetingId: 'test-meeting',
          meetingState: 'completed',
        })
      );

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

      await waitFor(() => {
        expect(result.current.steps[0].status).toBe('completed');
        expect(result.current.steps[1].status).toBe('completed');
        expect(result.current.steps[2].status).toBe('completed');
      });

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

    it('should mark step as failed on error', async () => {
      mockApi.generateSummary.mockRejectedValue(new Error('Summary failed'));
      mockApi.extractEntities.mockResolvedValue([]);
      mockApi.refineSpeakers.mockResolvedValue({ jobId: 'job-1' });
      mockApi.getDiarizationJobStatus.mockResolvedValue({ status: 'completed' });

      const { result } = renderHook(() =>
        usePostProcessing({
          meetingId: 'test-meeting',
          meetingState: 'completed',
        })
      );

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

      await waitFor(() => {
        expect(result.current.steps[0].status).toBe('failed');
        expect(result.current.steps[0].error).toBe('Summary failed');
      });

      // Other steps should still complete
      expect(result.current.steps[1].status).toBe('completed');
      expect(result.current.hasFailures).toBe(true);
    });

    it('should skip already processed steps', async () => {
      mockApi.generateSummary.mockResolvedValue({ id: 'summary-1' });
      mockApi.extractEntities.mockResolvedValue([]);
      mockApi.refineSpeakers.mockResolvedValue({ jobId: 'job-1' });
      mockApi.getDiarizationJobStatus.mockResolvedValue({ status: 'completed' });

      const { result } = renderHook(() =>
        usePostProcessing({
          meetingId: 'test-meeting',
          meetingState: 'completed',
          processingStatus: {
            summary_generated: true,
            entities_extracted: false,
            diarization_refined: false,
          },
        })
      );

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

      await waitFor(() => {
        expect(result.current.steps[0].status).toBe('skipped');
      });

      // Should not have called generateSummary
      expect(mockApi.generateSummary).not.toHaveBeenCalled();
    });
  });

  describe('callbacks', () => {
    it('should call onStepComplete when step finishes', async () => {
      mockApi.generateSummary.mockResolvedValue({ id: 'summary-1' });
      mockApi.extractEntities.mockResolvedValue([]);
      mockApi.refineSpeakers.mockResolvedValue({ jobId: 'job-1' });
      mockApi.getDiarizationJobStatus.mockResolvedValue({ status: 'completed' });

      const onStepComplete = vi.fn();

      const { result } = renderHook(() =>
        usePostProcessing({
          meetingId: 'test-meeting',
          meetingState: 'completed',
          onStepComplete,
        })
      );

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

      await waitFor(() => {
        expect(onStepComplete).toHaveBeenCalledWith('summary');
        expect(onStepComplete).toHaveBeenCalledWith('entities');
        expect(onStepComplete).toHaveBeenCalledWith('diarization');
      });
    });

    it('should call onComplete when all steps finish', async () => {
      mockApi.generateSummary.mockResolvedValue({ id: 'summary-1' });
      mockApi.extractEntities.mockResolvedValue([]);
      mockApi.refineSpeakers.mockResolvedValue({ jobId: 'job-1' });
      mockApi.getDiarizationJobStatus.mockResolvedValue({ status: 'completed' });

      const onComplete = vi.fn();

      const { result } = renderHook(() =>
        usePostProcessing({
          meetingId: 'test-meeting',
          meetingState: 'completed',
          onComplete,
        })
      );

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

      await waitFor(() => {
        expect(onComplete).toHaveBeenCalled();
      });
    });

    it('should call onStepFailed when step fails', async () => {
      mockApi.generateSummary.mockRejectedValue(new Error('API error'));
      mockApi.extractEntities.mockResolvedValue([]);
      mockApi.refineSpeakers.mockResolvedValue({ jobId: 'job-1' });
      mockApi.getDiarizationJobStatus.mockResolvedValue({ status: 'completed' });

      const onStepFailed = vi.fn();

      const { result } = renderHook(() =>
        usePostProcessing({
          meetingId: 'test-meeting',
          meetingState: 'completed',
          onStepFailed,
        })
      );

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

      await waitFor(() => {
        expect(onStepFailed).toHaveBeenCalledWith('summary', 'API error');
      });
    });
  });

  describe('diarization polling', () => {
    it('should poll until diarization completes', async () => {
      mockApi.generateSummary.mockResolvedValue({ id: 'summary-1' });
      mockApi.extractEntities.mockResolvedValue([]);
      mockApi.refineSpeakers.mockResolvedValue({ jobId: 'job-1' });

      let pollCount = 0;
      mockApi.getDiarizationJobStatus.mockImplementation(() => {
        pollCount++;
        if (pollCount < 3) {
          return Promise.resolve({ status: 'running', progress: pollCount * 30 });
        }
        return Promise.resolve({ status: 'completed' });
      });

      const { result } = renderHook(() =>
        usePostProcessing({
          meetingId: 'test-meeting',
          meetingState: 'completed',
        })
      );

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

      // Wait for polling to complete
      await waitFor(
        () => {
          expect(result.current.steps[2].status).toBe('completed');
        },
        { timeout: 10000 }
      );

      expect(mockApi.getDiarizationJobStatus).toHaveBeenCalledTimes(3);
    });

    it('should handle diarization failure', async () => {
      mockApi.generateSummary.mockResolvedValue({ id: 'summary-1' });
      mockApi.extractEntities.mockResolvedValue([]);
      mockApi.refineSpeakers.mockResolvedValue({ jobId: 'job-1' });
      mockApi.getDiarizationJobStatus.mockResolvedValue({
        status: 'failed',
        error: 'Speaker detection failed',
      });

      const { result } = renderHook(() =>
        usePostProcessing({
          meetingId: 'test-meeting',
          meetingState: 'completed',
        })
      );

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

      await waitFor(() => {
        expect(result.current.steps[2].status).toBe('failed');
        expect(result.current.steps[2].error).toBe('Speaker detection failed');
      });
    });
  });
});

ProcessingStatus Component (client/src/components/meeting/processing-status.test.tsx)

import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { ProcessingStatus } from './processing-status';
import type { ProcessingStep } from '@/hooks/use-post-processing';

describe('ProcessingStatus', () => {
  const defaultSteps: ProcessingStep[] = [
    { name: 'summary', status: 'pending' },
    { name: 'entities', status: 'pending' },
    { name: 'diarization', status: 'pending' },
  ];

  it('should render all steps', () => {
    render(<ProcessingStatus steps={defaultSteps} />);

    expect(screen.getByText('Generate Summary')).toBeInTheDocument();
    expect(screen.getByText('Extract Entities')).toBeInTheDocument();
    expect(screen.getByText('Refine Speakers')).toBeInTheDocument();
  });

  it('should show spinner for running steps', () => {
    const steps: ProcessingStep[] = [
      { name: 'summary', status: 'running' },
      { name: 'entities', status: 'pending' },
      { name: 'diarization', status: 'pending' },
    ];

    render(<ProcessingStatus steps={steps} />);

    // Check for spinner (animate-spin class)
    const spinner = document.querySelector('.animate-spin');
    expect(spinner).toBeInTheDocument();
  });

  it('should show check for completed steps', () => {
    const steps: ProcessingStep[] = [
      { name: 'summary', status: 'completed' },
      { name: 'entities', status: 'pending' },
      { name: 'diarization', status: 'pending' },
    ];

    render(<ProcessingStatus steps={steps} />);

    // Check icon should be visible
    const checkIcon = document.querySelector('.text-green-500');
    expect(checkIcon).toBeInTheDocument();
  });

  it('should show X for failed steps with error message', () => {
    const steps: ProcessingStep[] = [
      { name: 'summary', status: 'failed', error: 'LLM unavailable' },
      { name: 'entities', status: 'pending' },
      { name: 'diarization', status: 'pending' },
    ];

    render(<ProcessingStatus steps={steps} />);

    // X icon should be visible
    const xIcon = document.querySelector('.text-red-500');
    expect(xIcon).toBeInTheDocument();

    // Error message should be shown
    expect(screen.getByText('LLM unavailable')).toBeInTheDocument();
  });

  it('should show progress bar for running steps with progress', () => {
    const steps: ProcessingStep[] = [
      { name: 'summary', status: 'running', progress: 50 },
      { name: 'entities', status: 'pending' },
      { name: 'diarization', status: 'pending' },
    ];

    render(<ProcessingStatus steps={steps} />);

    // Progress bar should be visible
    const progressBar = document.querySelector('[role="progressbar"]');
    expect(progressBar).toBeInTheDocument();
  });

  it('should not render when all steps are complete', () => {
    const steps: ProcessingStep[] = [
      { name: 'summary', status: 'completed' },
      { name: 'entities', status: 'completed' },
      { name: 'diarization', status: 'completed' },
    ];

    const { container } = render(<ProcessingStatus steps={steps} />);

    expect(container.firstChild).toBeNull();
  });

  it('should not render when all steps are skipped', () => {
    const steps: ProcessingStep[] = [
      { name: 'summary', status: 'skipped' },
      { name: 'entities', status: 'skipped' },
      { name: 'diarization', status: 'skipped' },
    ];

    const { container } = render(<ProcessingStatus steps={steps} />);

    expect(container.firstChild).toBeNull();
  });
});

Integration Tests

Backend Processing Status (tests/integration/test_processing_status.py)

import pytest
from noteflow.domain.entities.meeting import Meeting, MeetingState, ProcessingStatus
from noteflow.domain.value_objects import MeetingId


class TestProcessingStatus:
    """Tests for processing status tracking on meetings."""

    @pytest.fixture
    def meeting_with_status(self) -> Meeting:
        return Meeting(
            id=MeetingId.generate(),
            title="Test Meeting",
            state=MeetingState.COMPLETED,
            processing_status=ProcessingStatus(
                summary_generated=False,
                entities_extracted=False,
                diarization_refined=False,
            ),
        )

    async def test_summary_generated_updates_status(
        self,
        mock_uow,
        meeting_with_status,
    ):
        """Generating summary should update processing_status."""
        # Arrange
        async with mock_uow as uow:
            await uow.meetings.create(meeting_with_status)
            await uow.commit()

        # Act - simulate summary generation
        async with mock_uow as uow:
            meeting = await uow.meetings.get(meeting_with_status.id)
            assert meeting is not None
            meeting.processing_status.summary_generated = True
            await uow.meetings.update(meeting)
            await uow.commit()

        # Assert
        async with mock_uow as uow:
            meeting = await uow.meetings.get(meeting_with_status.id)
            assert meeting is not None
            assert meeting.processing_status.summary_generated is True

    async def test_entities_extracted_updates_status(
        self,
        mock_uow,
        meeting_with_status,
    ):
        """Extracting entities should update processing_status."""
        async with mock_uow as uow:
            await uow.meetings.create(meeting_with_status)
            await uow.commit()

        async with mock_uow as uow:
            meeting = await uow.meetings.get(meeting_with_status.id)
            assert meeting is not None
            meeting.processing_status.entities_extracted = True
            await uow.meetings.update(meeting)
            await uow.commit()

        async with mock_uow as uow:
            meeting = await uow.meetings.get(meeting_with_status.id)
            assert meeting is not None
            assert meeting.processing_status.entities_extracted is True

    async def test_diarization_refined_updates_status(
        self,
        mock_uow,
        meeting_with_status,
    ):
        """Refining diarization should update processing_status."""
        async with mock_uow as uow:
            await uow.meetings.create(meeting_with_status)
            await uow.commit()

        async with mock_uow as uow:
            meeting = await uow.meetings.get(meeting_with_status.id)
            assert meeting is not None
            meeting.processing_status.diarization_refined = True
            await uow.meetings.update(meeting)
            await uow.commit()

        async with mock_uow as uow:
            meeting = await uow.meetings.get(meeting_with_status.id)
            assert meeting is not None
            assert meeting.processing_status.diarization_refined is True

    async def test_get_meeting_includes_processing_status(
        self,
        mock_uow,
        meeting_with_status,
    ):
        """GetMeeting should include processing_status in response."""
        meeting_with_status.processing_status.summary_generated = True

        async with mock_uow as uow:
            await uow.meetings.create(meeting_with_status)
            await uow.commit()

        async with mock_uow as uow:
            meeting = await uow.meetings.get(meeting_with_status.id)
            assert meeting is not None
            assert meeting.processing_status is not None
            assert meeting.processing_status.summary_generated is True
            assert meeting.processing_status.entities_extracted is False
            assert meeting.processing_status.diarization_refined is False

Webhook Events (tests/integration/test_webhook_events.py)

import pytest
from unittest.mock import AsyncMock, MagicMock

from noteflow.domain.webhooks.events import WebhookEventType
from noteflow.application.services.webhooks import WebhookService


class TestNewWebhookEvents:
    """Tests for new webhook event types."""

    @pytest.fixture
    def webhook_service(self, mock_uow) -> WebhookService:
        executor = MagicMock()
        executor.deliver = AsyncMock()
        return WebhookService(
            unit_of_work=mock_uow,
            executor=executor,
        )

    async def test_entities_extracted_event(self, webhook_service):
        """Should fire entities.extracted webhook event."""
        entities = [
            MagicMock(category="person"),
            MagicMock(category="person"),
            MagicMock(category="company"),
        ]

        await webhook_service.trigger_entities_extracted(
            meeting_id="meeting-123",
            entities=entities,
        )

        webhook_service._executor.deliver.assert_called()
        call_args = webhook_service._executor.deliver.call_args
        assert call_args[0][0] == WebhookEventType.ENTITIES_EXTRACTED

    async def test_diarization_completed_event(self, webhook_service):
        """Should fire diarization.completed webhook event."""
        await webhook_service.trigger_diarization_completed(
            meeting_id="meeting-123",
            job_id="job-456",
            speaker_count=3,
            segments_updated=42,
        )

        webhook_service._executor.deliver.assert_called()
        call_args = webhook_service._executor.deliver.call_args
        assert call_args[0][0] == WebhookEventType.DIARIZATION_COMPLETED

E2E Tests

Full Processing Flow (client/e2e/post-processing.spec.ts)

import { test, expect } from '@playwright/test';

test.describe('Post-Processing Pipeline', () => {
  test.beforeEach(async ({ page }) => {
    // Navigate to app and ensure connected
    await page.goto('/');
    await expect(page.locator('[data-testid="connection-status"]')).toHaveText(
      'Connected'
    );
  });

  test('shows processing status after recording stops', async ({ page }) => {
    // Start a new recording
    await page.click('[data-testid="new-meeting-button"]');
    await page.fill('[data-testid="meeting-title-input"]', 'E2E Test Meeting');
    await page.click('[data-testid="start-recording-button"]');

    // Wait for recording to start
    await expect(page.locator('[data-testid="recording-indicator"]')).toBeVisible();

    // Record for 5 seconds
    await page.waitForTimeout(5000);

    // Stop recording
    await page.click('[data-testid="stop-recording-button"]');

    // Should navigate to meeting detail
    await expect(page).toHaveURL(/\/meetings\/.+/);

    // Processing status should be visible
    await expect(
      page.locator('[data-testid="processing-status"]')
    ).toBeVisible();

    // Should show steps
    await expect(page.getByText('Generate Summary')).toBeVisible();
    await expect(page.getByText('Extract Entities')).toBeVisible();
    await expect(page.getByText('Refine Speakers')).toBeVisible();
  });

  test('completes all processing steps', async ({ page }) => {
    // This test requires a longer timeout due to LLM calls
    test.setTimeout(120000);

    // Start and complete a recording
    await page.click('[data-testid="new-meeting-button"]');
    await page.fill('[data-testid="meeting-title-input"]', 'Processing Test');
    await page.click('[data-testid="start-recording-button"]');
    await page.waitForTimeout(10000);
    await page.click('[data-testid="stop-recording-button"]');

    // Wait for all processing to complete
    await expect(
      page.locator('[data-testid="summary-status"][data-status="completed"]')
    ).toBeVisible({ timeout: 60000 });

    await expect(
      page.locator('[data-testid="entities-status"][data-status="completed"]')
    ).toBeVisible({ timeout: 60000 });

    await expect(
      page.locator('[data-testid="diarization-status"][data-status="completed"]')
    ).toBeVisible({ timeout: 60000 });

    // Processing status card should disappear after completion
    await expect(
      page.locator('[data-testid="processing-status"]')
    ).not.toBeVisible();

    // Summary should now be visible
    await expect(page.locator('[data-testid="summary-content"]')).toBeVisible();
  });

  test('handles processing failure gracefully', async ({ page }) => {
    // This test simulates a failure scenario
    // Requires mocking or a specific test mode

    await page.click('[data-testid="new-meeting-button"]');
    await page.fill('[data-testid="meeting-title-input"]', 'Failure Test');
    await page.click('[data-testid="start-recording-button"]');
    await page.waitForTimeout(3000);
    await page.click('[data-testid="stop-recording-button"]');

    // If a step fails, it should show error state
    // Other steps should still complete
    await page.waitForSelector(
      '[data-testid="processing-status"]',
      { state: 'visible' }
    );

    // Wait for processing to finish (success or failure)
    await page.waitForFunction(
      () => {
        const steps = document.querySelectorAll('[data-status]');
        return Array.from(steps).every(
          (s) =>
            s.getAttribute('data-status') === 'completed' ||
            s.getAttribute('data-status') === 'failed' ||
            s.getAttribute('data-status') === 'skipped'
        );
      },
      { timeout: 60000 }
    );

    // If summary failed, error should be shown
    const summaryStep = page.locator('[data-testid="summary-status"]');
    const status = await summaryStep.getAttribute('data-status');

    if (status === 'failed') {
      await expect(
        page.locator('[data-testid="summary-error"]')
      ).toBeVisible();
    }
  });

  test('manual processing buttons still work', async ({ page }) => {
    // Navigate to an existing meeting without processing
    await page.goto('/meetings/existing-meeting-id');

    // Manual buttons should be visible
    await expect(
      page.locator('[data-testid="generate-summary-button"]')
    ).toBeVisible();
    await expect(
      page.locator('[data-testid="extract-entities-button"]')
    ).toBeVisible();
    await expect(
      page.locator('[data-testid="refine-speakers-button"]')
    ).toBeVisible();

    // Click generate summary
    await page.click('[data-testid="generate-summary-button"]');

    // Should show loading state
    await expect(
      page.locator('[data-testid="generate-summary-button"]')
    ).toBeDisabled();

    // Wait for completion
    await expect(page.locator('[data-testid="summary-content"]')).toBeVisible({
      timeout: 60000,
    });
  });
});

Test Data Fixtures

Sample Meeting for Testing

// client/src/__fixtures__/meetings.ts
export const meetingWithoutProcessing = {
  id: 'test-meeting-1',
  title: 'Test Meeting',
  state: 'completed',
  created_at: '2024-01-15T10:00:00Z',
  processing_status: {
    summary_generated: false,
    entities_extracted: false,
    diarization_refined: false,
  },
};

export const meetingWithSummary = {
  ...meetingWithoutProcessing,
  id: 'test-meeting-2',
  processing_status: {
    summary_generated: true,
    summary_generated_at: '2024-01-15T10:05:00Z',
    entities_extracted: false,
    diarization_refined: false,
  },
};

export const fullyProcessedMeeting = {
  ...meetingWithoutProcessing,
  id: 'test-meeting-3',
  processing_status: {
    summary_generated: true,
    summary_generated_at: '2024-01-15T10:05:00Z',
    entities_extracted: true,
    entities_extracted_at: '2024-01-15T10:05:30Z',
    diarization_refined: true,
    diarization_refined_at: '2024-01-15T10:06:00Z',
  },
};

Running Tests

# Unit tests (TypeScript)
cd client
npm run test

# Unit tests (Python)
pytest tests/domain/ tests/application/ -v

# Integration tests
pytest tests/integration/ -v

# E2E tests (requires running server)
cd client
npm run e2e

# All quality checks
make quality