developed files
This commit is contained in:
405
tests/unit/test_ai_service.py
Normal file
405
tests/unit/test_ai_service.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
Unit tests for AI document generation service
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
import asyncio
|
||||
|
||||
from src.backend.services.ai_service import AIService, ai_service
|
||||
|
||||
|
||||
class TestAIService:
|
||||
"""Test AI Service functionality."""
|
||||
|
||||
def test_ai_service_initialization(self):
|
||||
"""Test AI service initializes correctly."""
|
||||
service = AIService()
|
||||
|
||||
# Without API keys, clients should be None
|
||||
assert service.claude_client is None
|
||||
assert service.openai_client is None
|
||||
|
||||
@patch('src.backend.services.ai_service.settings')
|
||||
def test_ai_service_with_claude_key(self, mock_settings):
|
||||
"""Test AI service initialization with Claude API key."""
|
||||
mock_settings.claude_api_key = "test-claude-key"
|
||||
mock_settings.openai_api_key = None
|
||||
|
||||
with patch('src.backend.services.ai_service.anthropic.Anthropic') as mock_anthropic:
|
||||
service = AIService()
|
||||
|
||||
mock_anthropic.assert_called_once_with(api_key="test-claude-key")
|
||||
assert service.claude_client is not None
|
||||
assert service.openai_client is None
|
||||
|
||||
@patch('src.backend.services.ai_service.settings')
|
||||
def test_ai_service_with_openai_key(self, mock_settings):
|
||||
"""Test AI service initialization with OpenAI API key."""
|
||||
mock_settings.claude_api_key = None
|
||||
mock_settings.openai_api_key = "test-openai-key"
|
||||
|
||||
with patch('src.backend.services.ai_service.openai.AsyncOpenAI') as mock_openai:
|
||||
service = AIService()
|
||||
|
||||
mock_openai.assert_called_once_with(api_key="test-openai-key")
|
||||
assert service.claude_client is None
|
||||
assert service.openai_client is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_cover_letter_template_fallback(self):
|
||||
"""Test cover letter generation with template fallback."""
|
||||
service = AIService()
|
||||
|
||||
result = await service.generate_cover_letter(
|
||||
job_description="Python developer position requiring FastAPI skills",
|
||||
company_name="Tech Corp",
|
||||
role_title="Senior Python Developer",
|
||||
user_name="John Doe"
|
||||
)
|
||||
|
||||
assert "content" in result
|
||||
assert "model_used" in result
|
||||
assert "prompt" in result
|
||||
|
||||
assert result["model_used"] == "template"
|
||||
assert "Tech Corp" in result["content"]
|
||||
assert "Senior Python Developer" in result["content"]
|
||||
assert "John Doe" in result["content"]
|
||||
assert "Dear Hiring Manager" in result["content"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_cover_letter_with_claude(self):
|
||||
"""Test cover letter generation with Claude API."""
|
||||
service = AIService()
|
||||
|
||||
# Mock Claude client
|
||||
mock_claude = Mock()
|
||||
mock_response = Mock()
|
||||
mock_response.content = [Mock(text="Generated cover letter content")]
|
||||
mock_claude.messages.create.return_value = mock_response
|
||||
service.claude_client = mock_claude
|
||||
|
||||
result = await service.generate_cover_letter(
|
||||
job_description="Python developer position",
|
||||
company_name="Test Company",
|
||||
role_title="Developer",
|
||||
user_name="Test User"
|
||||
)
|
||||
|
||||
assert result["content"] == "Generated cover letter content"
|
||||
assert result["model_used"] == "claude-3-haiku"
|
||||
assert "prompt" in result
|
||||
|
||||
# Verify Claude API was called correctly
|
||||
mock_claude.messages.create.assert_called_once()
|
||||
call_args = mock_claude.messages.create.call_args
|
||||
assert call_args[1]["model"] == "claude-3-haiku-20240307"
|
||||
assert call_args[1]["max_tokens"] == 1000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_cover_letter_with_openai(self):
|
||||
"""Test cover letter generation with OpenAI API."""
|
||||
service = AIService()
|
||||
|
||||
# Mock OpenAI client
|
||||
mock_openai = AsyncMock()
|
||||
mock_response = Mock()
|
||||
mock_response.choices = [Mock(message=Mock(content="OpenAI generated content"))]
|
||||
mock_openai.chat.completions.create.return_value = mock_response
|
||||
service.openai_client = mock_openai
|
||||
|
||||
result = await service.generate_cover_letter(
|
||||
job_description="Software engineer role",
|
||||
company_name="OpenAI Corp",
|
||||
role_title="Engineer",
|
||||
user_name="AI User"
|
||||
)
|
||||
|
||||
assert result["content"] == "OpenAI generated content"
|
||||
assert result["model_used"] == "gpt-3.5-turbo"
|
||||
assert "prompt" in result
|
||||
|
||||
# Verify OpenAI API was called correctly
|
||||
mock_openai.chat.completions.create.assert_called_once()
|
||||
call_args = mock_openai.chat.completions.create.call_args
|
||||
assert call_args[1]["model"] == "gpt-3.5-turbo"
|
||||
assert call_args[1]["max_tokens"] == 1000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_cover_letter_with_user_resume(self):
|
||||
"""Test cover letter generation with user resume included."""
|
||||
service = AIService()
|
||||
|
||||
result = await service.generate_cover_letter(
|
||||
job_description="Python developer position",
|
||||
company_name="Resume Corp",
|
||||
role_title="Developer",
|
||||
user_name="Resume User",
|
||||
user_resume="John Doe\nSoftware Engineer\n5 years Python experience"
|
||||
)
|
||||
|
||||
# Should include resume information in prompt
|
||||
assert "Resume/Background" in result["prompt"]
|
||||
assert result["model_used"] == "template"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_resume_optimization_template(self):
|
||||
"""Test resume optimization with template fallback."""
|
||||
service = AIService()
|
||||
|
||||
current_resume = "John Smith\nDeveloper\n\nExperience:\n- 3 years Python\n- Web development"
|
||||
|
||||
result = await service.generate_resume_optimization(
|
||||
current_resume=current_resume,
|
||||
job_description="Senior Python Developer requiring FastAPI",
|
||||
role_title="Senior Python Developer"
|
||||
)
|
||||
|
||||
assert "content" in result
|
||||
assert "model_used" in result
|
||||
assert "prompt" in result
|
||||
|
||||
assert result["model_used"] == "template"
|
||||
assert "Senior Python Developer" in result["content"]
|
||||
assert current_resume in result["content"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_resume_optimization_with_ai_error(self):
|
||||
"""Test resume optimization when AI service fails."""
|
||||
service = AIService()
|
||||
|
||||
# Mock Claude client that raises an exception
|
||||
mock_claude = Mock()
|
||||
mock_claude.messages.create.side_effect = Exception("API Error")
|
||||
service.claude_client = mock_claude
|
||||
|
||||
result = await service.generate_resume_optimization(
|
||||
current_resume="Test resume",
|
||||
job_description="Test job",
|
||||
role_title="Test role"
|
||||
)
|
||||
|
||||
# Should fallback to template
|
||||
assert result["model_used"] == "template-fallback"
|
||||
assert "Test resume" in result["content"]
|
||||
|
||||
def test_template_cover_letter_generation(self):
|
||||
"""Test template cover letter generation."""
|
||||
service = AIService()
|
||||
|
||||
content = service._generate_template_cover_letter(
|
||||
company_name="Template Corp",
|
||||
role_title="Template Role",
|
||||
user_name="Template User",
|
||||
job_description="Python, JavaScript, React, SQL, AWS, Docker experience required"
|
||||
)
|
||||
|
||||
assert "Template Corp" in content
|
||||
assert "Template Role" in content
|
||||
assert "Template User" in content
|
||||
assert "Dear Hiring Manager" in content
|
||||
|
||||
# Should extract and include relevant skills
|
||||
assert "Python" in content or "Javascript" in content
|
||||
|
||||
def test_template_cover_letter_no_matching_skills(self):
|
||||
"""Test template cover letter when no skills match."""
|
||||
service = AIService()
|
||||
|
||||
content = service._generate_template_cover_letter(
|
||||
company_name="No Skills Corp",
|
||||
role_title="Mysterious Role",
|
||||
user_name="Skill-less User",
|
||||
job_description="Experience with proprietary technology XYZ required"
|
||||
)
|
||||
|
||||
assert "No Skills Corp" in content
|
||||
assert "Mysterious Role" in content
|
||||
assert "Skill-less User" in content
|
||||
# Should not include skill text when no matches
|
||||
assert "with expertise in" not in content
|
||||
|
||||
|
||||
class TestAIServiceIntegration:
|
||||
"""Test AI service integration and edge cases."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_cover_letter_generation(self):
|
||||
"""Test concurrent cover letter generation requests."""
|
||||
service = AIService()
|
||||
|
||||
# Create multiple concurrent requests
|
||||
tasks = [
|
||||
service.generate_cover_letter(
|
||||
job_description=f"Job {i} description",
|
||||
company_name=f"Company {i}",
|
||||
role_title=f"Role {i}",
|
||||
user_name=f"User {i}"
|
||||
)
|
||||
for i in range(5)
|
||||
]
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
# All should complete successfully
|
||||
assert len(results) == 5
|
||||
for i, result in enumerate(results):
|
||||
assert f"Company {i}" in result["content"]
|
||||
assert f"Role {i}" in result["content"]
|
||||
assert result["model_used"] == "template"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cover_letter_with_empty_inputs(self):
|
||||
"""Test cover letter generation with empty inputs."""
|
||||
service = AIService()
|
||||
|
||||
result = await service.generate_cover_letter(
|
||||
job_description="",
|
||||
company_name="",
|
||||
role_title="",
|
||||
user_name=""
|
||||
)
|
||||
|
||||
# Should handle empty inputs gracefully
|
||||
assert "content" in result
|
||||
assert result["model_used"] == "template"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cover_letter_with_very_long_inputs(self):
|
||||
"""Test cover letter generation with very long inputs."""
|
||||
service = AIService()
|
||||
|
||||
long_description = "A" * 10000 # Very long job description
|
||||
|
||||
result = await service.generate_cover_letter(
|
||||
job_description=long_description,
|
||||
company_name="Long Corp",
|
||||
role_title="Long Role",
|
||||
user_name="Long User"
|
||||
)
|
||||
|
||||
# Should handle long inputs
|
||||
assert "content" in result
|
||||
assert result["model_used"] == "template"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_optimization_with_special_characters(self):
|
||||
"""Test resume optimization with special characters."""
|
||||
service = AIService()
|
||||
|
||||
resume_with_special_chars = """
|
||||
José González
|
||||
Software Engineer
|
||||
|
||||
Experience:
|
||||
• 5 years of Python development
|
||||
• Expertise in FastAPI & PostgreSQL
|
||||
• Led team of 10+ developers
|
||||
"""
|
||||
|
||||
result = await service.generate_resume_optimization(
|
||||
current_resume=resume_with_special_chars,
|
||||
job_description="Senior role requiring team leadership",
|
||||
role_title="Senior Developer"
|
||||
)
|
||||
|
||||
assert "content" in result
|
||||
assert "José González" in result["content"]
|
||||
assert result["model_used"] == "template"
|
||||
|
||||
|
||||
class TestAIServiceConfiguration:
|
||||
"""Test AI service configuration and settings."""
|
||||
|
||||
@patch('src.backend.services.ai_service.settings')
|
||||
def test_ai_service_singleton(self, mock_settings):
|
||||
"""Test that ai_service is a singleton instance."""
|
||||
# The ai_service should be the same instance
|
||||
from src.backend.services.ai_service import ai_service as service1
|
||||
from src.backend.services.ai_service import ai_service as service2
|
||||
|
||||
assert service1 is service2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_handling_in_ai_generation(self):
|
||||
"""Test error handling in AI generation methods."""
|
||||
service = AIService()
|
||||
|
||||
# Mock a client that raises an exception
|
||||
service.claude_client = Mock()
|
||||
service.claude_client.messages.create.side_effect = Exception("Network error")
|
||||
|
||||
result = await service.generate_cover_letter(
|
||||
job_description="Test job",
|
||||
company_name="Error Corp",
|
||||
role_title="Error Role",
|
||||
user_name="Error User"
|
||||
)
|
||||
|
||||
# Should fallback gracefully
|
||||
assert result["model_used"] == "template-fallback"
|
||||
assert "Error Corp" in result["content"]
|
||||
|
||||
def test_prompt_construction(self):
|
||||
"""Test that prompts are constructed correctly."""
|
||||
service = AIService()
|
||||
|
||||
# This is tested indirectly through the template generation
|
||||
content = service._generate_template_cover_letter(
|
||||
company_name="Prompt Corp",
|
||||
role_title="Prompt Engineer",
|
||||
user_name="Prompt User",
|
||||
job_description="Looking for someone with strong prompting skills"
|
||||
)
|
||||
|
||||
assert "Prompt Corp" in content
|
||||
assert "Prompt Engineer" in content
|
||||
assert "Prompt User" in content
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestAIServiceWithRealAPIs:
|
||||
"""Integration tests for AI service with real APIs (requires API keys)."""
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not hasattr(ai_service, 'claude_client') or ai_service.claude_client is None,
|
||||
reason="Claude API key not configured"
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_claude_api_call(self):
|
||||
"""Test actual Claude API call (only runs if API key is configured)."""
|
||||
result = await ai_service.generate_cover_letter(
|
||||
job_description="Python developer position with FastAPI",
|
||||
company_name="Real API Corp",
|
||||
role_title="Python Developer",
|
||||
user_name="Integration Test User"
|
||||
)
|
||||
|
||||
assert result["model_used"] == "claude-3-haiku"
|
||||
assert len(result["content"]) > 100 # Should be substantial content
|
||||
assert "Real API Corp" in result["content"]
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not hasattr(ai_service, 'openai_client') or ai_service.openai_client is None,
|
||||
reason="OpenAI API key not configured"
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_openai_api_call(self):
|
||||
"""Test actual OpenAI API call (only runs if API key is configured)."""
|
||||
# Temporarily disable Claude to force OpenAI usage
|
||||
original_claude = ai_service.claude_client
|
||||
ai_service.claude_client = None
|
||||
|
||||
try:
|
||||
result = await ai_service.generate_cover_letter(
|
||||
job_description="Software engineer role requiring Python",
|
||||
company_name="OpenAI Test Corp",
|
||||
role_title="Software Engineer",
|
||||
user_name="OpenAI Test User"
|
||||
)
|
||||
|
||||
assert result["model_used"] == "gpt-3.5-turbo"
|
||||
assert len(result["content"]) > 100
|
||||
assert "OpenAI Test Corp" in result["content"]
|
||||
finally:
|
||||
ai_service.claude_client = original_claude
|
||||
Reference in New Issue
Block a user