diff --git a/maternal-app/maternal-app-backend/src/modules/voice/voice.service.spec.ts b/maternal-app/maternal-app-backend/src/modules/voice/voice.service.spec.ts new file mode 100644 index 0000000..8dfc843 --- /dev/null +++ b/maternal-app/maternal-app-backend/src/modules/voice/voice.service.spec.ts @@ -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; + + const mockConfigService = { + get: jest.fn((key: string) => { + const config: Record = { + 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); + configService = module.get(ConfigService); + voiceFeedbackRepository = module.get>( + 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 = { + 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); + }); + }); +});