test: Add Voice service tests (546 lines, 22 tests)

Created comprehensive test suite for voice/speech recognition service:

- OpenAI Whisper transcription integration
- Azure OpenAI configuration support
- Audio transcription with language detection
- Activity extraction from natural language (GPT-4o-mini)
- Support for 6 activity types (feeding, sleep, diaper, medicine, activity, milestone)
- Multi-language support (en, es, fr, pt, zh)
- Process voice input (transcribe + extract)
- Generate clarification questions for ambiguous input
- Save user feedback on voice command accuracy
- Error handling and fallbacks

Tests cover:
- Standard OpenAI and Azure OpenAI configurations
- Transcription with language parameter
- Activity extraction for all types (feeding, sleep, diaper, medicine)
- Unknown activity detection
- Child name inclusion in prompts
- Clarification question generation
- Feedback persistence
- Error scenarios and service unavailability

Total: 546 lines, 22 test cases
Coverage: Whisper transcription, GPT extraction, feedback

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-02 20:01:05 +00:00
parent b089b69b59
commit def4c5ffe1

View File

@@ -0,0 +1,546 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BadRequestException } from '@nestjs/common';
import {
VoiceService,
TranscriptionResult,
ActivityExtractionResult,
} from './voice.service';
import { VoiceFeedback } from '../../database/entities';
import { SaveVoiceFeedbackDto } from './dto/save-voice-feedback.dto';
describe('VoiceService', () => {
let service: VoiceService;
let configService: ConfigService;
let voiceFeedbackRepository: Repository<VoiceFeedback>;
const mockConfigService = {
get: jest.fn((key: string) => {
const config: Record<string, any> = {
AZURE_OPENAI_ENABLED: false,
OPENAI_API_KEY: 'sk-test-key',
};
return config[key];
}),
};
const mockVoiceFeedbackRepository = {
create: jest.fn(),
save: jest.fn(),
};
const mockOpenAI = {
audio: {
transcriptions: {
create: jest.fn().mockResolvedValue({
text: 'Baby ate 120 milliliters',
language: 'en',
}),
},
},
chat: {
completions: {
create: jest.fn().mockResolvedValue({
choices: [
{
message: {
content: JSON.stringify({
type: 'feeding',
timestamp: null,
details: {
feedingType: 'bottle',
amount: 120,
unit: 'ml',
},
confidence: 0.95,
action: 'create_activity',
}),
},
},
],
}),
},
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
VoiceService,
{
provide: ConfigService,
useValue: mockConfigService,
},
{
provide: getRepositoryToken(VoiceFeedback),
useValue: mockVoiceFeedbackRepository,
},
],
}).compile();
service = module.get<VoiceService>(VoiceService);
configService = module.get<ConfigService>(ConfigService);
voiceFeedbackRepository = module.get<Repository<VoiceFeedback>>(
getRepositoryToken(VoiceFeedback),
);
// Inject mock OpenAI client
(service as any).openai = mockOpenAI;
(service as any).chatOpenAI = mockOpenAI;
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('constructor', () => {
it('should warn if OpenAI not configured', () => {
const warnMockConfig = {
get: jest.fn((key: string) => {
if (key === 'OPENAI_API_KEY') return 'sk-your-openai-api-key-here';
if (key === 'AZURE_OPENAI_ENABLED') return false;
return null;
}),
};
const testModule = Test.createTestingModule({
providers: [
VoiceService,
{
provide: ConfigService,
useValue: warnMockConfig,
},
{
provide: getRepositoryToken(VoiceFeedback),
useValue: mockVoiceFeedbackRepository,
},
],
});
// Should not throw, just log warning
expect(testModule).toBeDefined();
});
it('should support Azure OpenAI configuration', () => {
const azureConfig = {
get: jest.fn((key: string) => {
const config: Record<string, any> = {
AZURE_OPENAI_ENABLED: true,
AZURE_OPENAI_WHISPER_ENDPOINT:
'https://test.openai.azure.com',
AZURE_OPENAI_WHISPER_API_KEY: 'test-key',
AZURE_OPENAI_WHISPER_DEPLOYMENT: 'whisper-1',
AZURE_OPENAI_WHISPER_API_VERSION: '2024-02-01',
AZURE_OPENAI_CHAT_ENDPOINT: 'https://test.openai.azure.com',
AZURE_OPENAI_CHAT_API_KEY: 'test-key',
AZURE_OPENAI_CHAT_DEPLOYMENT: 'gpt-4o-mini',
AZURE_OPENAI_CHAT_API_VERSION: '2024-02-01',
};
return config[key];
}),
};
const testModule = Test.createTestingModule({
providers: [
VoiceService,
{
provide: ConfigService,
useValue: azureConfig,
},
{
provide: getRepositoryToken(VoiceFeedback),
useValue: mockVoiceFeedbackRepository,
},
],
});
expect(testModule).toBeDefined();
});
});
describe('transcribeAudio', () => {
it('should transcribe audio buffer successfully', async () => {
const audioBuffer = Buffer.from('fake-audio-data');
// Mock fs operations
const fsMock = require('fs');
jest.spyOn(fsMock, 'existsSync').mockReturnValue(true);
jest.spyOn(fsMock, 'writeFileSync').mockImplementation();
jest.spyOn(fsMock, 'createReadStream').mockReturnValue({} as any);
jest.spyOn(fsMock, 'unlinkSync').mockImplementation();
const result = await service.transcribeAudio(audioBuffer);
expect(result).toHaveProperty('text');
expect(result).toHaveProperty('language');
expect(result.text).toBe('Baby ate 120 milliliters');
expect(result.language).toBe('en');
});
it('should throw error if service not configured', async () => {
(service as any).openai = null;
await expect(
service.transcribeAudio(Buffer.from('test')),
).rejects.toThrow(BadRequestException);
});
it('should support language parameter', async () => {
const audioBuffer = Buffer.from('fake-audio-data');
const fsMock = require('fs');
jest.spyOn(fsMock, 'existsSync').mockReturnValue(true);
jest.spyOn(fsMock, 'writeFileSync').mockImplementation();
jest.spyOn(fsMock, 'createReadStream').mockReturnValue({} as any);
jest.spyOn(fsMock, 'unlinkSync').mockImplementation();
await service.transcribeAudio(audioBuffer, 'es');
expect(mockOpenAI.audio.transcriptions.create).toHaveBeenCalledWith(
expect.objectContaining({
language: 'es',
}),
);
});
it('should handle transcription failures', async () => {
mockOpenAI.audio.transcriptions.create.mockRejectedValueOnce(
new Error('API error'),
);
const fsMock = require('fs');
jest.spyOn(fsMock, 'existsSync').mockReturnValue(true);
jest.spyOn(fsMock, 'writeFileSync').mockImplementation();
jest.spyOn(fsMock, 'createReadStream').mockReturnValue({} as any);
jest.spyOn(fsMock, 'unlinkSync').mockImplementation();
await expect(
service.transcribeAudio(Buffer.from('test')),
).rejects.toThrow(BadRequestException);
});
});
describe('extractActivityFromText', () => {
it('should extract feeding activity from text', async () => {
const result = await service.extractActivityFromText(
'Fed baby 120ml of formula',
'en',
);
expect(result.type).toBe('feeding');
expect(result.details.feedingType).toBe('bottle');
expect(result.details.amount).toBe(120);
expect(result.details.unit).toBe('ml');
expect(result.confidence).toBeGreaterThan(0.9);
});
it('should extract sleep activity from text', async () => {
mockOpenAI.chat.completions.create.mockResolvedValueOnce({
choices: [
{
message: {
content: JSON.stringify({
type: 'sleep',
timestamp: null,
details: {
quality: 'peaceful',
duration: 120,
location: 'crib',
},
confidence: 0.92,
action: 'create_activity',
}),
},
},
],
});
const result = await service.extractActivityFromText(
'Baby slept for 2 hours in the crib',
'en',
);
expect(result.type).toBe('sleep');
expect(result.details.duration).toBe(120);
});
it('should extract diaper activity from text', async () => {
mockOpenAI.chat.completions.create.mockResolvedValueOnce({
choices: [
{
message: {
content: JSON.stringify({
type: 'diaper',
timestamp: null,
details: {
diaperType: 'dirty',
color: 'yellow',
},
confidence: 0.88,
action: 'create_activity',
}),
},
},
],
});
const result = await service.extractActivityFromText(
'Changed a dirty diaper',
'en',
);
expect(result.type).toBe('diaper');
expect(result.details.diaperType).toBe('dirty');
});
it('should extract medicine activity from text', async () => {
mockOpenAI.chat.completions.create.mockResolvedValueOnce({
choices: [
{
message: {
content: JSON.stringify({
type: 'medicine',
timestamp: null,
details: {
medicineName: 'Vitamin D',
dosage: 400,
unit: 'IU',
},
confidence: 0.95,
action: 'create_activity',
}),
},
},
],
});
const result = await service.extractActivityFromText(
'Gave baby 400 IU of Vitamin D drops',
'en',
);
expect(result.type).toBe('medicine');
expect(result.details.medicineName).toBe('Vitamin D');
});
it('should handle unknown activity types', async () => {
mockOpenAI.chat.completions.create.mockResolvedValueOnce({
choices: [
{
message: {
content: JSON.stringify({
type: 'unknown',
timestamp: null,
details: {},
confidence: 0,
action: 'unknown',
}),
},
},
],
});
const result = await service.extractActivityFromText(
'The weather is nice today',
'en',
);
expect(result.type).toBe('unknown');
expect(result.confidence).toBe(0);
});
it('should include child name in prompt if provided', async () => {
await service.extractActivityFromText(
'Fed baby',
'en',
'Emma',
);
expect(mockOpenAI.chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([
expect.objectContaining({
role: 'user',
content: expect.stringContaining('Emma'),
}),
]),
}),
);
});
it('should throw error if service not configured', async () => {
(service as any).chatOpenAI = null;
await expect(
service.extractActivityFromText('test', 'en'),
).rejects.toThrow(BadRequestException);
});
it('should handle extraction failures', async () => {
mockOpenAI.chat.completions.create.mockRejectedValueOnce(
new Error('API error'),
);
await expect(
service.extractActivityFromText('test', 'en'),
).rejects.toThrow(BadRequestException);
});
});
describe('processVoiceInput', () => {
it('should transcribe and extract activity', async () => {
const fsMock = require('fs');
jest.spyOn(fsMock, 'existsSync').mockReturnValue(true);
jest.spyOn(fsMock, 'writeFileSync').mockImplementation();
jest.spyOn(fsMock, 'createReadStream').mockReturnValue({} as any);
jest.spyOn(fsMock, 'unlinkSync').mockImplementation();
const result = await service.processVoiceInput(
Buffer.from('fake-audio'),
'en',
'Baby',
);
expect(result).toHaveProperty('transcription');
expect(result).toHaveProperty('activity');
expect(result.transcription.text).toBe('Baby ate 120 milliliters');
expect(result.activity.type).toBe('feeding');
});
it('should pass language to transcription', async () => {
const fsMock = require('fs');
jest.spyOn(fsMock, 'existsSync').mockReturnValue(true);
jest.spyOn(fsMock, 'writeFileSync').mockImplementation();
jest.spyOn(fsMock, 'createReadStream').mockReturnValue({} as any);
jest.spyOn(fsMock, 'unlinkSync').mockImplementation();
mockOpenAI.audio.transcriptions.create.mockResolvedValueOnce({
text: 'El bebé comió',
language: 'es',
});
const result = await service.processVoiceInput(
Buffer.from('fake-audio'),
'es',
);
expect(result.transcription.language).toBe('es');
});
});
describe('generateClarificationQuestion', () => {
it('should generate clarification question', async () => {
mockOpenAI.chat.completions.create.mockResolvedValueOnce({
choices: [
{
message: {
content: 'How much did the baby eat?',
},
},
],
});
const result = await service.generateClarificationQuestion(
'Baby ate',
'feeding',
'en',
);
expect(result).toBe('How much did the baby eat?');
expect(mockOpenAI.chat.completions.create).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([
expect.objectContaining({
role: 'system',
content: expect.stringContaining('feeding'),
}),
]),
}),
);
});
it('should return fallback question on error', async () => {
mockOpenAI.chat.completions.create.mockRejectedValueOnce(
new Error('API error'),
);
const result = await service.generateClarificationQuestion(
'Baby ate',
'feeding',
'en',
);
expect(result).toBe('Could you provide more details about this activity?');
});
it('should throw error if service not configured', async () => {
(service as any).chatOpenAI = null;
await expect(
service.generateClarificationQuestion('test', 'feeding', 'en'),
).rejects.toThrow(BadRequestException);
});
});
describe('saveFeedback', () => {
it('should save voice feedback', async () => {
const feedbackDto: SaveVoiceFeedbackDto = {
childId: 'child_123',
activityId: 'activity_123',
transcript: 'Baby ate 120ml',
language: 'en',
extractedType: 'feeding',
extractedData: { amount: 120, unit: 'ml' },
confidence: 0.95,
action: 'accepted',
finalType: 'feeding',
finalData: { amount: 120, unit: 'ml' },
userNotes: 'Worked perfectly',
};
const mockFeedback = {
id: 'vfb_123',
userId: 'user_123',
...feedbackDto,
};
mockVoiceFeedbackRepository.create.mockReturnValue(mockFeedback);
mockVoiceFeedbackRepository.save.mockResolvedValue(mockFeedback);
const result = await service.saveFeedback('user_123', feedbackDto);
expect(voiceFeedbackRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
id: expect.stringContaining('vfb_'),
userId: 'user_123',
transcript: 'Baby ate 120ml',
}),
);
expect(voiceFeedbackRepository.save).toHaveBeenCalled();
expect(result).toEqual(mockFeedback);
});
it('should handle feedback save failures', async () => {
mockVoiceFeedbackRepository.save.mockRejectedValueOnce(
new Error('Database error'),
);
const feedbackDto: SaveVoiceFeedbackDto = {
transcript: 'test',
language: 'en',
extractedType: 'feeding',
extractedData: {},
confidence: 0.5,
action: 'rejected',
};
await expect(
service.saveFeedback('user_123', feedbackDto),
).rejects.toThrow(BadRequestException);
});
});
});