developed files

This commit is contained in:
2025-08-02 20:51:59 -04:00
parent c9f25ea149
commit 2d6c3bff56
24 changed files with 2660 additions and 220 deletions

1
tests/unit/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Unit tests package

View 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

View File

@@ -0,0 +1,375 @@
"""
Unit tests for authentication endpoints
"""
import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch
import uuid
from src.backend.main import app
from src.backend.models.user import User
from src.backend.api.auth import hash_password, verify_password, create_access_token
class TestAuthenticationAPI:
"""Test authentication API endpoints."""
def test_register_user_success(self, test_client, test_db):
"""Test successful user registration."""
user_data = {
"email": "newuser@test.com",
"password": "securepassword123",
"first_name": "New",
"last_name": "User"
}
response = test_client.post("/api/auth/register", json=user_data)
assert response.status_code == 200
data = response.json()
assert data["email"] == "newuser@test.com"
assert data["full_name"] == "New User"
assert data["first_name"] == "New"
assert data["last_name"] == "User"
assert data["is_active"] == True
assert "id" in data
# Password should never be returned
assert "password" not in data
assert "password_hash" not in data
def test_register_user_duplicate_email(self, test_client, test_user):
"""Test registration with duplicate email fails."""
user_data = {
"email": test_user.email, # Use existing user's email
"password": "differentpassword",
"first_name": "Duplicate",
"last_name": "User"
}
response = test_client.post("/api/auth/register", json=user_data)
assert response.status_code == 400
data = response.json()
assert "already registered" in data["detail"].lower()
def test_register_user_invalid_email(self, test_client):
"""Test registration with invalid email format."""
user_data = {
"email": "invalid-email-format",
"password": "securepassword123",
"first_name": "Invalid",
"last_name": "Email"
}
response = test_client.post("/api/auth/register", json=user_data)
assert response.status_code == 422 # Validation error
def test_register_user_missing_fields(self, test_client):
"""Test registration with missing required fields."""
user_data = {
"email": "incomplete@test.com",
# Missing password, first_name, last_name
}
response = test_client.post("/api/auth/register", json=user_data)
assert response.status_code == 422
def test_login_success(self, test_client, test_user):
"""Test successful login."""
login_data = {
"email": test_user.email,
"password": "testpassword123"
}
response = test_client.post("/api/auth/login", json=login_data)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert "token_type" in data
assert data["token_type"] == "bearer"
# Verify token structure
token = data["access_token"]
assert len(token.split('.')) == 3 # JWT has 3 parts
def test_login_wrong_password(self, test_client, test_user):
"""Test login with incorrect password."""
login_data = {
"email": test_user.email,
"password": "wrongpassword"
}
response = test_client.post("/api/auth/login", json=login_data)
assert response.status_code == 401
data = response.json()
assert "incorrect" in data["detail"].lower()
def test_login_nonexistent_user(self, test_client):
"""Test login with non-existent user."""
login_data = {
"email": "nonexistent@test.com",
"password": "somepassword"
}
response = test_client.post("/api/auth/login", json=login_data)
assert response.status_code == 401
def test_login_invalid_email_format(self, test_client):
"""Test login with invalid email format."""
login_data = {
"email": "not-an-email",
"password": "somepassword"
}
response = test_client.post("/api/auth/login", json=login_data)
assert response.status_code == 422 # Validation error
def test_get_current_user_success(self, test_client, test_user_token):
"""Test getting current user with valid token."""
headers = {"Authorization": f"Bearer {test_user_token}"}
response = test_client.get("/api/auth/me", headers=headers)
assert response.status_code == 200
data = response.json()
assert "email" in data
assert "id" in data
assert "full_name" in data
assert "is_active" in data
# Ensure sensitive data is not returned
assert "password" not in data
assert "password_hash" not in data
def test_get_current_user_invalid_token(self, test_client):
"""Test getting current user with invalid token."""
headers = {"Authorization": "Bearer invalid.token.here"}
response = test_client.get("/api/auth/me", headers=headers)
assert response.status_code == 401
def test_get_current_user_no_token(self, test_client):
"""Test getting current user without authorization header."""
response = test_client.get("/api/auth/me")
assert response.status_code == 403 # FastAPI HTTPBearer returns 403
def test_get_current_user_malformed_header(self, test_client):
"""Test getting current user with malformed authorization header."""
malformed_headers = [
{"Authorization": "Bearer"},
{"Authorization": "NotBearer validtoken"},
{"Authorization": "Bearer "},
{"Authorization": "invalid-format"}
]
for headers in malformed_headers:
response = test_client.get("/api/auth/me", headers=headers)
assert response.status_code in [401, 403]
class TestPasswordUtilities:
"""Test password hashing and verification utilities."""
def test_hash_password(self):
"""Test password hashing function."""
password = "testpassword123"
hashed = hash_password(password)
assert hashed != password # Should be hashed
assert len(hashed) > 0
assert hashed.startswith('$2b$') # bcrypt format
def test_verify_password_correct(self):
"""Test password verification with correct password."""
password = "testpassword123"
hashed = hash_password(password)
assert verify_password(password, hashed) == True
def test_verify_password_incorrect(self):
"""Test password verification with incorrect password."""
password = "testpassword123"
wrong_password = "wrongpassword"
hashed = hash_password(password)
assert verify_password(wrong_password, hashed) == False
def test_hash_different_passwords_different_hashes(self):
"""Test that different passwords produce different hashes."""
password1 = "password123"
password2 = "password456"
hash1 = hash_password(password1)
hash2 = hash_password(password2)
assert hash1 != hash2
def test_hash_same_password_different_hashes(self):
"""Test that same password produces different hashes (salt)."""
password = "testpassword123"
hash1 = hash_password(password)
hash2 = hash_password(password)
assert hash1 != hash2 # Should be different due to salt
# But both should verify correctly
assert verify_password(password, hash1) == True
assert verify_password(password, hash2) == True
class TestJWTTokens:
"""Test JWT token creation and validation."""
def test_create_access_token(self):
"""Test JWT token creation."""
data = {"sub": str(uuid.uuid4()), "email": "test@example.com"}
token = create_access_token(data)
assert isinstance(token, str)
assert len(token.split('.')) == 3 # JWT format: header.payload.signature
def test_create_token_with_different_data(self):
"""Test that different data creates different tokens."""
data1 = {"sub": str(uuid.uuid4()), "email": "user1@example.com"}
data2 = {"sub": str(uuid.uuid4()), "email": "user2@example.com"}
token1 = create_access_token(data1)
token2 = create_access_token(data2)
assert token1 != token2
def test_token_contains_expiration(self):
"""Test that created tokens contain expiration claim."""
from jose import jwt
from src.backend.core.config import settings
data = {"sub": str(uuid.uuid4())}
token = create_access_token(data)
# Decode without verification to check claims
decoded = jwt.get_unverified_claims(token)
assert "exp" in decoded
assert "sub" in decoded
class TestUserModel:
"""Test User model properties and methods."""
def test_user_full_name_property(self):
"""Test that full_name property works correctly."""
user = User(
email="test@example.com",
password_hash="hashed_password",
full_name="John Doe"
)
assert user.full_name == "John Doe"
assert user.first_name == "John"
assert user.last_name == "Doe"
def test_user_single_name(self):
"""Test user with single name."""
user = User(
email="test@example.com",
password_hash="hashed_password",
full_name="Madonna"
)
assert user.full_name == "Madonna"
assert user.first_name == "Madonna"
assert user.last_name == ""
def test_user_multiple_last_names(self):
"""Test user with multiple last names."""
user = User(
email="test@example.com",
password_hash="hashed_password",
full_name="John van der Berg"
)
assert user.full_name == "John van der Berg"
assert user.first_name == "John"
assert user.last_name == "van der Berg"
def test_user_is_active_property(self):
"""Test user is_active property."""
user = User(
email="test@example.com",
password_hash="hashed_password",
full_name="Test User"
)
assert user.is_active == True # Default is True
class TestAuthenticationEdgeCases:
"""Test edge cases and error conditions."""
def test_register_empty_names(self, test_client):
"""Test registration with empty names."""
user_data = {
"email": "empty@test.com",
"password": "password123",
"first_name": "",
"last_name": ""
}
response = test_client.post("/api/auth/register", json=user_data)
# Should still work but create empty full_name
assert response.status_code == 200
data = response.json()
assert data["full_name"] == " " # Space between empty names
def test_register_very_long_email(self, test_client):
"""Test registration with very long email."""
long_email = "a" * 250 + "@test.com" # Very long email
user_data = {
"email": long_email,
"password": "password123",
"first_name": "Long",
"last_name": "Email"
}
response = test_client.post("/api/auth/register", json=user_data)
# Should handle long emails (within DB constraints)
if len(long_email) <= 255:
assert response.status_code == 200
else:
assert response.status_code in [400, 422]
def test_register_unicode_names(self, test_client):
"""Test registration with unicode characters in names."""
user_data = {
"email": "unicode@test.com",
"password": "password123",
"first_name": "José",
"last_name": "González"
}
response = test_client.post("/api/auth/register", json=user_data)
assert response.status_code == 200
data = response.json()
assert data["full_name"] == "José González"
assert data["first_name"] == "José"
assert data["last_name"] == "González"
def test_case_insensitive_email_login(self, test_client, test_user):
"""Test that email login is case insensitive."""
# Try login with different case
login_data = {
"email": test_user.email.upper(),
"password": "testpassword123"
}
response = test_client.post("/api/auth/login", json=login_data)
# This might fail if email comparison is case-sensitive
# The actual behavior depends on implementation
assert response.status_code in [200, 401]