- Updated basedpyright linting results (705 files analyzed, analysis time reduced from 22.928s to 13.105s). - Updated biome linting artifact with warning about unnecessary hook dependency (preferencesVersion) in MeetingDetail.tsx.
28 KiB
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.webhook_service 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