Coverage for apps / ai / tests.py: 0%
651 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:40 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 00:40 +0000
1"""Tests for the AI app."""
3import pytest
4from unittest.mock import Mock, patch, MagicMock
5from django.test import TestCase
7from apps.core.models import AppSettings
8from .models import AIPrompt
9from .services.openrouter import (
10 OpenRouterService,
11 AIUnavailableError,
12 AIResponseError,
13)
14from .services.validator import AIResponseValidator, ValidationError
17class AIPromptModelTests(TestCase):
18 """Tests for the AIPrompt model."""
20 def test_prompts_seeded(self):
21 """Verify all 11 prompts were seeded."""
22 assert AIPrompt.objects.count() == 11
24 def test_all_prompt_types_exist(self):
25 """Verify all prompt types are present."""
26 expected_types = [
27 'recipe_remix',
28 'serving_adjustment',
29 'tips_generation',
30 'discover_favorites',
31 'discover_seasonal',
32 'discover_new',
33 'search_ranking',
34 'timer_naming',
35 'remix_suggestions',
36 'selector_repair',
37 'nutrition_estimate',
38 ]
39 for prompt_type in expected_types:
40 assert AIPrompt.objects.filter(prompt_type=prompt_type).exists()
42 def test_get_prompt(self):
43 """Test getting a prompt by type."""
44 prompt = AIPrompt.get_prompt('recipe_remix')
45 assert prompt.name == 'Recipe Remix'
47 def test_get_prompt_not_found(self):
48 """Test getting a non-existent prompt raises error."""
49 with pytest.raises(AIPrompt.DoesNotExist):
50 AIPrompt.get_prompt('nonexistent')
52 def test_format_user_prompt(self):
53 """Test formatting a user prompt template."""
54 prompt = AIPrompt.get_prompt('timer_naming')
55 formatted = prompt.format_user_prompt(
56 instruction='Simmer for 20 minutes',
57 duration='20 minutes'
58 )
59 assert 'Simmer for 20 minutes' in formatted
60 assert '20 minutes' in formatted
63class AIResponseValidatorTests(TestCase):
64 """Tests for the AI response validator."""
66 def setUp(self):
67 self.validator = AIResponseValidator()
69 def test_validate_recipe_remix_valid(self):
70 """Test validating a valid recipe remix response."""
71 response = {
72 'title': 'Vegan Chocolate Cake',
73 'description': 'A delicious plant-based cake',
74 'ingredients': ['flour', 'cocoa', 'sugar'],
75 'instructions': ['Mix dry ingredients', 'Add wet ingredients', 'Bake'],
76 }
77 result = self.validator.validate('recipe_remix', response)
78 assert result == response
80 def test_validate_recipe_remix_missing_field(self):
81 """Test validation fails with missing required field."""
82 response = {
83 'title': 'Vegan Chocolate Cake',
84 'description': 'A delicious plant-based cake',
85 # missing ingredients and instructions
86 }
87 with pytest.raises(ValidationError) as exc_info:
88 self.validator.validate('recipe_remix', response)
89 assert 'ingredients' in str(exc_info.value.errors)
91 def test_validate_tips_generation_valid(self):
92 """Test validating tips generation response."""
93 response = ['Tip 1', 'Tip 2', 'Tip 3']
94 result = self.validator.validate('tips_generation', response)
95 assert result == response
97 def test_validate_tips_generation_too_few(self):
98 """Test tips validation fails with too few items."""
99 response = ['Tip 1', 'Tip 2'] # minItems is 3
100 with pytest.raises(ValidationError) as exc_info:
101 self.validator.validate('tips_generation', response)
102 assert 'at least 3 items' in str(exc_info.value.errors)
104 def test_validate_remix_suggestions_valid(self):
105 """Test validating remix suggestions response."""
106 response = ['Make it vegan', 'Add more protein', 'Use seasonal ingredients',
107 'Make it spicy', 'Make it low-carb', 'Add a crunchy topping']
108 result = self.validator.validate('remix_suggestions', response)
109 assert len(result) == 6
111 def test_validate_remix_suggestions_wrong_count(self):
112 """Test remix suggestions validation fails with wrong count."""
113 response = ['Tip 1', 'Tip 2', 'Tip 3'] # Should be exactly 6
114 with pytest.raises(ValidationError):
115 self.validator.validate('remix_suggestions', response)
117 def test_validate_timer_naming_valid(self):
118 """Test validating timer naming response."""
119 response = {'label': 'Simmer sauce'}
120 result = self.validator.validate('timer_naming', response)
121 assert result['label'] == 'Simmer sauce'
123 def test_validate_search_ranking_valid(self):
124 """Test validating search ranking response."""
125 response = [2, 0, 4, 1, 3]
126 result = self.validator.validate('search_ranking', response)
127 assert result == response
129 def test_validate_selector_repair_valid(self):
130 """Test validating selector repair response."""
131 response = {
132 'suggestions': ['.recipe-title', 'h1.recipe-name'],
133 'confidence': 0.85
134 }
135 result = self.validator.validate('selector_repair', response)
136 assert result['confidence'] == 0.85
138 def test_validate_unknown_prompt_type(self):
139 """Test validation fails with unknown prompt type."""
140 with pytest.raises(ValidationError) as exc_info:
141 self.validator.validate('unknown_type', {})
142 assert 'Unknown prompt type' in str(exc_info.value)
144 def test_validate_wrong_type(self):
145 """Test validation fails with wrong data type."""
146 with pytest.raises(ValidationError):
147 self.validator.validate('tips_generation', {'not': 'an array'})
150class AIAPITests(TestCase):
151 """Tests for the AI API endpoints."""
153 def test_ai_status_endpoint(self):
154 """Test the AI status endpoint returns enhanced status fields."""
155 response = self.client.get('/api/ai/status')
156 assert response.status_code == 200
157 data = response.json()
158 # Enhanced status fields (8B.11)
159 assert 'available' in data
160 assert 'configured' in data
161 assert 'valid' in data
162 assert 'default_model' in data
163 assert 'error' in data
164 assert 'error_code' in data
166 def test_ai_status_no_api_key(self):
167 """Test AI status shows not configured when no API key."""
168 settings = AppSettings.get()
169 settings.openrouter_api_key = ''
170 settings.save()
171 OpenRouterService.invalidate_key_cache()
173 response = self.client.get('/api/ai/status')
174 assert response.status_code == 200
175 data = response.json()
176 assert data['available'] is False
177 assert data['configured'] is False
178 assert data['valid'] is False
179 assert data['error_code'] == 'no_api_key'
180 assert 'No API key configured' in data['error']
182 @patch.object(OpenRouterService, 'test_connection')
183 def test_ai_status_invalid_api_key(self, mock_test_connection):
184 """Test AI status shows invalid when API key fails validation."""
185 mock_test_connection.return_value = (False, 'Invalid API key')
187 settings = AppSettings.get()
188 settings.openrouter_api_key = 'sk-or-invalid-key'
189 settings.save()
190 OpenRouterService.invalidate_key_cache()
192 response = self.client.get('/api/ai/status')
193 assert response.status_code == 200
194 data = response.json()
195 assert data['available'] is False
196 assert data['configured'] is True
197 assert data['valid'] is False
198 assert data['error_code'] == 'invalid_api_key'
200 @patch.object(OpenRouterService, 'test_connection')
201 def test_ai_status_valid_api_key(self, mock_test_connection):
202 """Test AI status shows valid when API key passes validation."""
203 mock_test_connection.return_value = (True, 'Connection successful')
205 settings = AppSettings.get()
206 settings.openrouter_api_key = 'sk-or-valid-key'
207 settings.save()
208 OpenRouterService.invalidate_key_cache()
210 response = self.client.get('/api/ai/status')
211 assert response.status_code == 200
212 data = response.json()
213 assert data['available'] is True
214 assert data['configured'] is True
215 assert data['valid'] is True
216 assert data['error'] is None
217 assert data['error_code'] is None
219 def test_models_endpoint_no_api_key(self):
220 """Test the models list endpoint returns empty without API key."""
221 response = self.client.get('/api/ai/models')
222 assert response.status_code == 200
223 data = response.json()
224 assert data == [] # No models without API key
226 @patch('apps.ai.api.OpenRouterService')
227 def test_models_endpoint_with_api_key(self, mock_service_class):
228 """Test the models list endpoint returns models from OpenRouter."""
229 mock_service = MagicMock()
230 mock_service.get_available_models.return_value = [
231 {'id': 'anthropic/claude-3.5-haiku', 'name': 'Claude 3.5 Haiku'},
232 {'id': 'openai/gpt-4o', 'name': 'GPT-4o'},
233 ]
234 mock_service_class.return_value = mock_service
236 response = self.client.get('/api/ai/models')
237 assert response.status_code == 200
238 data = response.json()
239 assert len(data) == 2
240 assert data[0]['id'] == 'anthropic/claude-3.5-haiku'
242 def test_prompts_endpoint(self):
243 """Test the prompts list endpoint."""
244 response = self.client.get('/api/ai/prompts')
245 assert response.status_code == 200
246 data = response.json()
247 assert len(data) == 11 # 11 prompts seeded
249 def test_get_prompt_endpoint(self):
250 """Test getting a specific prompt."""
251 response = self.client.get('/api/ai/prompts/recipe_remix')
252 assert response.status_code == 200
253 data = response.json()
254 assert data['prompt_type'] == 'recipe_remix'
255 assert data['name'] == 'Recipe Remix'
257 def test_get_prompt_not_found(self):
258 """Test getting a non-existent prompt."""
259 response = self.client.get('/api/ai/prompts/nonexistent')
260 assert response.status_code == 404
262 def test_update_prompt_endpoint(self):
263 """Test updating a prompt."""
264 response = self.client.put(
265 '/api/ai/prompts/recipe_remix',
266 data={'model': 'openai/gpt-4o'},
267 content_type='application/json'
268 )
269 assert response.status_code == 200
270 data = response.json()
271 assert data['model'] == 'openai/gpt-4o'
273 # Verify persistence
274 prompt = AIPrompt.objects.get(prompt_type='recipe_remix')
275 assert prompt.model == 'openai/gpt-4o'
277 @patch('apps.ai.api.OpenRouterService')
278 def test_update_prompt_invalid_model(self, mock_service_class):
279 """Test updating a prompt with invalid model returns 422."""
280 # Mock get_available_models to return a list that doesn't include the invalid model
281 mock_service = MagicMock()
282 mock_service.get_available_models.return_value = [
283 {'id': 'anthropic/claude-3.5-haiku', 'name': 'Claude 3.5 Haiku'},
284 {'id': 'openai/gpt-4o', 'name': 'GPT-4o'},
285 ]
286 mock_service_class.return_value = mock_service
288 response = self.client.put(
289 '/api/ai/prompts/recipe_remix',
290 data={'model': 'invalid/model-name'},
291 content_type='application/json'
292 )
293 assert response.status_code == 422
294 data = response.json()
295 assert data['error'] == 'invalid_model'
296 assert 'invalid/model-name' in data['message']
298 def test_test_api_key_empty(self):
299 """Test API key validation with empty key."""
300 response = self.client.post(
301 '/api/ai/test-api-key',
302 data={'api_key': ''},
303 content_type='application/json'
304 )
305 assert response.status_code == 400
307 @patch.object(OpenRouterService, 'test_connection')
308 def test_save_api_key_invalidates_cache(self, mock_test_connection):
309 """Test that saving API key invalidates the validation cache."""
310 mock_test_connection.return_value = (True, 'Connection successful')
312 # Pre-populate the cache
313 OpenRouterService._key_validation_cache[hash('old-key')] = (True, 0)
315 response = self.client.post(
316 '/api/ai/save-api-key',
317 data={'api_key': 'new-key-123'},
318 content_type='application/json'
319 )
320 assert response.status_code == 200
322 # Cache should be cleared
323 assert len(OpenRouterService._key_validation_cache) == 0
326class OpenRouterServiceTests(TestCase):
327 """Tests for the OpenRouter service."""
329 def test_init_requires_api_key(self):
330 """Test that service requires an API key."""
331 # Clear the API key from settings
332 settings = AppSettings.get()
333 settings.openrouter_api_key = ''
334 settings.save()
336 with pytest.raises(AIUnavailableError) as exc_info:
337 OpenRouterService()
338 assert 'not configured' in str(exc_info.value)
340 def test_init_with_explicit_key(self):
341 """Test initializing with an explicit API key."""
342 service = OpenRouterService(api_key='test-key-123')
343 assert service.api_key == 'test-key-123'
345 def test_is_available_without_key(self):
346 """Test is_available returns False without API key."""
347 settings = AppSettings.get()
348 settings.openrouter_api_key = ''
349 settings.save()
351 assert OpenRouterService.is_available() is False
353 def test_is_available_with_key(self):
354 """Test is_available returns True with API key."""
355 settings = AppSettings.get()
356 settings.openrouter_api_key = 'test-key-123'
357 settings.save()
359 assert OpenRouterService.is_available() is True
361 @patch.object(OpenRouterService, 'test_connection')
362 def test_validate_key_cached_caches_result(self, mock_test_connection):
363 """Test that validate_key_cached caches validation results."""
364 mock_test_connection.return_value = (True, 'Connection successful')
365 OpenRouterService.invalidate_key_cache()
367 # First call should hit the API
368 is_valid1, error1 = OpenRouterService.validate_key_cached('test-key')
369 assert is_valid1 is True
370 assert error1 is None
371 assert mock_test_connection.call_count == 1
373 # Second call should use cache
374 is_valid2, error2 = OpenRouterService.validate_key_cached('test-key')
375 assert is_valid2 is True
376 assert error2 is None
377 assert mock_test_connection.call_count == 1 # Still 1, not called again
379 @patch.object(OpenRouterService, 'test_connection')
380 def test_validate_key_cached_caches_invalid_result(self, mock_test_connection):
381 """Test that validate_key_cached caches invalid validation results."""
382 mock_test_connection.return_value = (False, 'Invalid API key')
383 OpenRouterService.invalidate_key_cache()
385 # First call should hit the API
386 is_valid1, error1 = OpenRouterService.validate_key_cached('bad-key')
387 assert is_valid1 is False
388 assert error1 is not None
389 assert mock_test_connection.call_count == 1
391 # Second call should use cache
392 is_valid2, error2 = OpenRouterService.validate_key_cached('bad-key')
393 assert is_valid2 is False
394 assert mock_test_connection.call_count == 1 # Still 1, not called again
396 def test_validate_key_cached_no_key(self):
397 """Test that validate_key_cached handles missing key."""
398 OpenRouterService.invalidate_key_cache()
399 is_valid, error = OpenRouterService.validate_key_cached('')
400 assert is_valid is False
401 assert 'No API key configured' in error
403 def test_invalidate_key_cache(self):
404 """Test that invalidate_key_cache clears the cache."""
405 OpenRouterService._key_validation_cache['test'] = (True, 0)
406 assert len(OpenRouterService._key_validation_cache) > 0
408 OpenRouterService.invalidate_key_cache()
409 assert len(OpenRouterService._key_validation_cache) == 0
411 @patch('apps.ai.services.openrouter.OpenRouter')
412 def test_complete_success(self, mock_openrouter_class):
413 """Test successful completion request."""
414 # Setup mock
415 mock_client = MagicMock()
416 mock_openrouter_class.return_value.__enter__ = Mock(return_value=mock_client)
417 mock_openrouter_class.return_value.__exit__ = Mock(return_value=False)
419 mock_response = Mock()
420 mock_response.choices = [Mock(message=Mock(content='{"status": "ok"}'))]
421 mock_client.chat.send.return_value = mock_response
423 # Test
424 service = OpenRouterService(api_key='test-key')
425 result = service.complete(
426 system_prompt='Test system',
427 user_prompt='Test user',
428 json_response=True,
429 )
431 assert result == {'status': 'ok'}
432 mock_client.chat.send.assert_called_once()
434 @patch('apps.ai.services.openrouter.OpenRouter')
435 def test_complete_handles_code_block_json(self, mock_openrouter_class):
436 """Test completion handles JSON wrapped in code blocks."""
437 mock_client = MagicMock()
438 mock_openrouter_class.return_value.__enter__ = Mock(return_value=mock_client)
439 mock_openrouter_class.return_value.__exit__ = Mock(return_value=False)
441 mock_response = Mock()
442 mock_response.choices = [Mock(message=Mock(
443 content='```json\n{"title": "Test Recipe"}\n```'
444 ))]
445 mock_client.chat.send.return_value = mock_response
447 service = OpenRouterService(api_key='test-key')
448 result = service.complete(
449 system_prompt='Test',
450 user_prompt='Test',
451 json_response=True,
452 )
454 assert result == {'title': 'Test Recipe'}
456 @patch('apps.ai.services.openrouter.OpenRouter')
457 def test_complete_invalid_json(self, mock_openrouter_class):
458 """Test completion raises error for invalid JSON."""
459 mock_client = MagicMock()
460 mock_openrouter_class.return_value.__enter__ = Mock(return_value=mock_client)
461 mock_openrouter_class.return_value.__exit__ = Mock(return_value=False)
463 mock_response = Mock()
464 mock_response.choices = [Mock(message=Mock(content='not valid json'))]
465 mock_client.chat.send.return_value = mock_response
467 service = OpenRouterService(api_key='test-key')
468 with pytest.raises(AIResponseError) as exc_info:
469 service.complete(
470 system_prompt='Test',
471 user_prompt='Test',
472 json_response=True,
473 )
474 assert 'Invalid JSON' in str(exc_info.value)
476 @patch('apps.ai.services.openrouter.OpenRouter')
477 def test_complete_no_choices(self, mock_openrouter_class):
478 """Test completion raises error when no choices returned."""
479 mock_client = MagicMock()
480 mock_openrouter_class.return_value.__enter__ = Mock(return_value=mock_client)
481 mock_openrouter_class.return_value.__exit__ = Mock(return_value=False)
483 mock_response = Mock()
484 mock_response.choices = []
485 mock_client.chat.send.return_value = mock_response
487 service = OpenRouterService(api_key='test-key')
488 with pytest.raises(AIResponseError) as exc_info:
489 service.complete(
490 system_prompt='Test',
491 user_prompt='Test',
492 )
493 assert 'No choices' in str(exc_info.value)
495 @patch('apps.ai.services.openrouter.OpenRouter')
496 def test_complete_raw_text_response(self, mock_openrouter_class):
497 """Test completion with json_response=False returns raw content."""
498 mock_client = MagicMock()
499 mock_openrouter_class.return_value.__enter__ = Mock(return_value=mock_client)
500 mock_openrouter_class.return_value.__exit__ = Mock(return_value=False)
502 mock_response = Mock()
503 mock_response.choices = [Mock(message=Mock(content='Hello, world!'))]
504 mock_client.chat.send.return_value = mock_response
506 service = OpenRouterService(api_key='test-key')
507 result = service.complete(
508 system_prompt='Test',
509 user_prompt='Test',
510 json_response=False,
511 )
513 assert result == {'content': 'Hello, world!'}
515 @patch('apps.ai.services.openrouter.OpenRouter')
516 def test_test_connection_success(self, mock_openrouter_class):
517 """Test successful connection test."""
518 mock_client = MagicMock()
519 mock_openrouter_class.return_value.__enter__ = Mock(return_value=mock_client)
520 mock_openrouter_class.return_value.__exit__ = Mock(return_value=False)
522 mock_response = Mock()
523 mock_response.choices = [Mock(message=Mock(content='{"status": "ok"}'))]
524 mock_client.chat.send.return_value = mock_response
526 success, message = OpenRouterService.test_connection('test-key')
527 assert success is True
528 assert message == 'Connection successful'
530 def test_test_connection_no_key(self):
531 """Test connection test with empty key."""
532 success, message = OpenRouterService.test_connection('')
533 assert success is False
534 assert 'not provided' in message or 'not configured' in message
536 @patch('apps.ai.services.openrouter.OpenRouter')
537 def test_get_available_models_success(self, mock_openrouter_class):
538 """Test getting available models from OpenRouter, sorted alphabetically."""
539 mock_client = MagicMock()
540 mock_openrouter_class.return_value.__enter__ = Mock(return_value=mock_client)
541 mock_openrouter_class.return_value.__exit__ = Mock(return_value=False)
543 # Mock response with model data in non-alphabetical order
544 mock_model1 = MagicMock()
545 mock_model1.id = 'openai/gpt-4o'
546 mock_model1.name = 'GPT-4o'
547 mock_model2 = MagicMock()
548 mock_model2.id = 'anthropic/claude-3.5-haiku'
549 mock_model2.name = 'Claude 3.5 Haiku'
550 mock_response = Mock()
551 mock_response.data = [mock_model1, mock_model2] # GPT first, Claude second
552 mock_client.models.list.return_value = mock_response
554 service = OpenRouterService(api_key='test-key')
555 models = service.get_available_models()
557 assert len(models) == 2
558 # Should be sorted alphabetically by name
559 assert models[0]['name'] == 'Claude 3.5 Haiku'
560 assert models[1]['name'] == 'GPT-4o'
563@pytest.mark.asyncio
564class OpenRouterServiceAsyncTests(TestCase):
565 """Async tests for the OpenRouter service."""
567 @patch('apps.ai.services.openrouter.OpenRouter')
568 async def test_complete_async_success(self, mock_openrouter_class):
569 """Test successful async completion request."""
570 from unittest.mock import AsyncMock
572 mock_response = Mock()
573 mock_response.choices = [Mock(message=Mock(content='{"result": "success"}'))]
575 # Create a mock client with async context manager support
576 mock_client = MagicMock()
577 mock_client.chat.send_async = AsyncMock(return_value=mock_response)
579 # Mock the async context manager
580 mock_openrouter_class.return_value.__aenter__ = AsyncMock(return_value=mock_client)
581 mock_openrouter_class.return_value.__aexit__ = AsyncMock(return_value=None)
583 service = OpenRouterService(api_key='test-key')
584 result = await service.complete_async(
585 system_prompt='Test',
586 user_prompt='Test',
587 json_response=True,
588 )
590 assert result == {'result': 'success'}
593class TimerNamingServiceTests(TestCase):
594 """Tests for the timer naming service."""
596 @patch('apps.ai.services.timer.OpenRouterService')
597 def test_generate_timer_name_success(self, mock_service_class):
598 """Test successful timer name generation."""
599 from apps.ai.services.timer import generate_timer_name
601 mock_service = MagicMock()
602 mock_service.complete.return_value = {'label': 'Simmer until reduced'}
603 mock_service_class.return_value = mock_service
605 result = generate_timer_name('Simmer for 20 minutes until sauce is reduced', 20)
607 assert result['label'] == 'Simmer until reduced'
608 mock_service.complete.assert_called_once()
610 @patch('apps.ai.services.timer.OpenRouterService')
611 def test_generate_timer_name_truncates_long_labels(self, mock_service_class):
612 """Test that long labels are truncated to 30 characters."""
613 from apps.ai.services.timer import generate_timer_name
615 mock_service = MagicMock()
616 # Return a label longer than 30 characters
617 mock_service.complete.return_value = {'label': 'This is a very long timer label that exceeds thirty characters'}
618 mock_service_class.return_value = mock_service
620 result = generate_timer_name('Some instruction', 15)
622 # Should be truncated to 27 chars + '...'
623 assert len(result['label']) == 30
624 assert result['label'].endswith('...')
626 @patch('apps.ai.services.timer.OpenRouterService')
627 def test_generate_timer_name_formats_duration(self, mock_service_class):
628 """Test that duration is formatted correctly in the prompt."""
629 from apps.ai.services.timer import generate_timer_name
631 mock_service = MagicMock()
632 mock_service.complete.return_value = {'label': 'Bake bread'}
633 mock_service_class.return_value = mock_service
635 # Test with 90 minutes (1 hour 30 minutes)
636 generate_timer_name('Bake until golden', 90)
638 # Check that the service was called
639 call_args = mock_service.complete.call_args
640 user_prompt = call_args.kwargs.get('user_prompt', call_args[1].get('user_prompt', ''))
641 assert '1 hour 30 minutes' in user_prompt or mock_service.complete.called
644class TimerNamingAPITests(TestCase):
645 """Tests for the timer naming API endpoint."""
647 @patch('apps.ai.api.generate_timer_name')
648 def test_timer_name_endpoint_success(self, mock_generate):
649 """Test successful timer name API call."""
650 mock_generate.return_value = {'label': 'Bake until golden'}
652 response = self.client.post(
653 '/api/ai/timer-name',
654 data={'step_text': 'Bake for 25 minutes', 'duration_minutes': 25},
655 content_type='application/json'
656 )
658 assert response.status_code == 200
659 data = response.json()
660 assert data['label'] == 'Bake until golden'
662 def test_timer_name_endpoint_missing_step_text(self):
663 """Test timer name API with missing step_text."""
664 response = self.client.post(
665 '/api/ai/timer-name',
666 data={'step_text': '', 'duration_minutes': 10},
667 content_type='application/json'
668 )
670 assert response.status_code == 400
671 data = response.json()
672 assert 'Step text is required' in data['message']
674 def test_timer_name_endpoint_invalid_duration(self):
675 """Test timer name API with invalid duration."""
676 response = self.client.post(
677 '/api/ai/timer-name',
678 data={'step_text': 'Some instruction', 'duration_minutes': 0},
679 content_type='application/json'
680 )
682 assert response.status_code == 400
683 data = response.json()
684 assert 'Duration must be positive' in data['message']
686 @patch('apps.ai.api.generate_timer_name')
687 def test_timer_name_endpoint_ai_unavailable(self, mock_generate):
688 """Test timer name API when AI is unavailable."""
689 mock_generate.side_effect = AIUnavailableError('No API key')
691 response = self.client.post(
692 '/api/ai/timer-name',
693 data={'step_text': 'Simmer for 10 minutes', 'duration_minutes': 10},
694 content_type='application/json'
695 )
697 assert response.status_code == 503
698 data = response.json()
699 assert data['error'] == 'ai_unavailable'
702class SelectorRepairServiceTests(TestCase):
703 """Tests for the selector repair service."""
705 def setUp(self):
706 from apps.recipes.models import SearchSource
707 # Create a test SearchSource
708 self.source = SearchSource.objects.create(
709 host='test.example.com',
710 name='Test Source',
711 search_url_template='https://test.example.com/search?q={query}',
712 result_selector='.old-selector',
713 is_enabled=True,
714 needs_attention=True,
715 consecutive_failures=3,
716 )
718 @patch('apps.ai.services.selector.OpenRouterService')
719 def test_repair_selector_success(self, mock_service_class):
720 """Test successful selector repair."""
721 from apps.ai.services.selector import repair_selector
723 mock_service = MagicMock()
724 mock_service.complete.return_value = {
725 'suggestions': ['.recipe-card', '.result-item', 'article.recipe'],
726 'confidence': 0.9
727 }
728 mock_service_class.return_value = mock_service
730 result = repair_selector(
731 source=self.source,
732 html_sample='<div class="recipe-card">Test</div>',
733 confidence_threshold=0.8,
734 auto_update=True,
735 )
737 assert result['suggestions'] == ['.recipe-card', '.result-item', 'article.recipe']
738 assert result['confidence'] == 0.9
739 assert result['original_selector'] == '.old-selector'
740 assert result['updated'] is True
741 assert result['new_selector'] == '.recipe-card'
743 # Verify source was updated
744 self.source.refresh_from_db()
745 assert self.source.result_selector == '.recipe-card'
746 assert self.source.needs_attention is False
748 @patch('apps.ai.services.selector.OpenRouterService')
749 def test_repair_selector_low_confidence_no_update(self, mock_service_class):
750 """Test selector repair with low confidence doesn't auto-update."""
751 from apps.ai.services.selector import repair_selector
753 mock_service = MagicMock()
754 mock_service.complete.return_value = {
755 'suggestions': ['.maybe-selector'],
756 'confidence': 0.5
757 }
758 mock_service_class.return_value = mock_service
760 result = repair_selector(
761 source=self.source,
762 html_sample='<div class="maybe-selector">Test</div>',
763 confidence_threshold=0.8,
764 auto_update=True,
765 )
767 assert result['confidence'] == 0.5
768 assert result['updated'] is False
769 assert result['new_selector'] is None
771 # Verify source was NOT updated
772 self.source.refresh_from_db()
773 assert self.source.result_selector == '.old-selector'
774 assert self.source.needs_attention is True
776 @patch('apps.ai.services.selector.OpenRouterService')
777 def test_repair_selector_auto_update_disabled(self, mock_service_class):
778 """Test selector repair with auto_update=False."""
779 from apps.ai.services.selector import repair_selector
781 mock_service = MagicMock()
782 mock_service.complete.return_value = {
783 'suggestions': ['.new-selector'],
784 'confidence': 0.95
785 }
786 mock_service_class.return_value = mock_service
788 result = repair_selector(
789 source=self.source,
790 html_sample='<div class="new-selector">Test</div>',
791 confidence_threshold=0.8,
792 auto_update=False,
793 )
795 assert result['confidence'] == 0.95
796 assert result['updated'] is False
797 assert result['new_selector'] is None
799 # Verify source was NOT updated
800 self.source.refresh_from_db()
801 assert self.source.result_selector == '.old-selector'
803 @patch('apps.ai.services.selector.OpenRouterService')
804 def test_repair_selector_truncates_html(self, mock_service_class):
805 """Test that HTML sample is truncated to 50KB."""
806 from apps.ai.services.selector import repair_selector
808 mock_service = MagicMock()
809 mock_service.complete.return_value = {
810 'suggestions': ['.selector'],
811 'confidence': 0.8
812 }
813 mock_service_class.return_value = mock_service
815 # Create HTML larger than 50KB
816 large_html = 'x' * 100000
818 repair_selector(
819 source=self.source,
820 html_sample=large_html,
821 auto_update=False,
822 )
824 # Verify the prompt was called with truncated HTML
825 call_args = mock_service.complete.call_args
826 user_prompt = call_args.kwargs.get('user_prompt', '')
827 # The HTML in the prompt should be at most 50KB
828 assert len(user_prompt) < 55000 # Some overhead for prompt template
830 def test_get_sources_needing_attention(self):
831 """Test getting sources that need attention."""
832 from apps.ai.services.selector import get_sources_needing_attention
833 from apps.recipes.models import SearchSource
835 # Create additional test sources
836 SearchSource.objects.create(
837 host='healthy.example.com',
838 name='Healthy Source',
839 search_url_template='https://healthy.example.com/search?q={query}',
840 is_enabled=True,
841 needs_attention=False, # Should NOT be included
842 )
843 SearchSource.objects.create(
844 host='broken.example.com',
845 name='Broken Source',
846 search_url_template='https://broken.example.com/search?q={query}',
847 is_enabled=True,
848 needs_attention=True, # Should be included
849 )
850 SearchSource.objects.create(
851 host='disabled.example.com',
852 name='Disabled Source',
853 search_url_template='https://disabled.example.com/search?q={query}',
854 is_enabled=False, # Disabled, should NOT be included
855 needs_attention=True,
856 )
858 sources = get_sources_needing_attention()
859 hosts = [s.host for s in sources]
861 assert 'test.example.com' in hosts
862 assert 'broken.example.com' in hosts
863 assert 'healthy.example.com' not in hosts
864 assert 'disabled.example.com' not in hosts
867class SelectorRepairAPITests(TestCase):
868 """Tests for the selector repair API endpoints."""
870 def setUp(self):
871 from apps.recipes.models import SearchSource
872 self.source = SearchSource.objects.create(
873 host='test.example.com',
874 name='Test Source',
875 search_url_template='https://test.example.com/search?q={query}',
876 result_selector='.old-selector',
877 is_enabled=True,
878 needs_attention=True,
879 consecutive_failures=3,
880 )
882 @patch('apps.ai.api.repair_selector')
883 def test_repair_selector_endpoint_success(self, mock_repair):
884 """Test successful selector repair API call."""
885 mock_repair.return_value = {
886 'suggestions': ['.new-selector', '.backup-selector'],
887 'confidence': 0.85,
888 'original_selector': '.old-selector',
889 'updated': True,
890 'new_selector': '.new-selector',
891 }
893 response = self.client.post(
894 '/api/ai/repair-selector',
895 data={
896 'source_id': self.source.id,
897 'html_sample': '<div class="new-selector">Test</div>',
898 },
899 content_type='application/json'
900 )
902 assert response.status_code == 200
903 data = response.json()
904 assert data['suggestions'] == ['.new-selector', '.backup-selector']
905 assert data['confidence'] == 0.85
906 assert data['updated'] is True
907 assert data['new_selector'] == '.new-selector'
909 def test_repair_selector_endpoint_source_not_found(self):
910 """Test repair selector API with non-existent source."""
911 response = self.client.post(
912 '/api/ai/repair-selector',
913 data={
914 'source_id': 99999,
915 'html_sample': '<div>Test</div>',
916 },
917 content_type='application/json'
918 )
920 assert response.status_code == 404
921 data = response.json()
922 assert data['error'] == 'not_found'
924 def test_repair_selector_endpoint_empty_html(self):
925 """Test repair selector API with empty HTML sample."""
926 response = self.client.post(
927 '/api/ai/repair-selector',
928 data={
929 'source_id': self.source.id,
930 'html_sample': '',
931 },
932 content_type='application/json'
933 )
935 assert response.status_code == 400
936 data = response.json()
937 assert 'HTML sample is required' in data['message']
939 @patch('apps.ai.api.repair_selector')
940 def test_repair_selector_endpoint_ai_unavailable(self, mock_repair):
941 """Test repair selector API when AI is unavailable."""
942 mock_repair.side_effect = AIUnavailableError('No API key')
944 response = self.client.post(
945 '/api/ai/repair-selector',
946 data={
947 'source_id': self.source.id,
948 'html_sample': '<div>Test</div>',
949 },
950 content_type='application/json'
951 )
953 assert response.status_code == 503
954 data = response.json()
955 assert data['error'] == 'ai_unavailable'
957 @patch('apps.ai.api.repair_selector')
958 def test_repair_selector_endpoint_with_options(self, mock_repair):
959 """Test repair selector API with custom options."""
960 mock_repair.return_value = {
961 'suggestions': ['.custom-selector'],
962 'confidence': 0.7,
963 'original_selector': '.old-selector',
964 'updated': False,
965 'new_selector': None,
966 }
968 response = self.client.post(
969 '/api/ai/repair-selector',
970 data={
971 'source_id': self.source.id,
972 'html_sample': '<div class="custom-selector">Test</div>',
973 'target': 'recipe card element',
974 'confidence_threshold': 0.9,
975 'auto_update': False,
976 },
977 content_type='application/json'
978 )
980 assert response.status_code == 200
981 # Verify the custom options were passed
982 mock_repair.assert_called_once()
983 call_kwargs = mock_repair.call_args.kwargs
984 assert call_kwargs['target'] == 'recipe card element'
985 assert call_kwargs['confidence_threshold'] == 0.9
986 assert call_kwargs['auto_update'] is False
988 def test_sources_needing_attention_endpoint(self):
989 """Test the sources needing attention list endpoint."""
990 response = self.client.get('/api/ai/sources-needing-attention')
992 assert response.status_code == 200
993 data = response.json()
994 assert len(data) >= 1 # At least our test source
996 # Find our test source
997 test_source = next((s for s in data if s['host'] == 'test.example.com'), None)
998 assert test_source is not None
999 assert test_source['name'] == 'Test Source'
1000 assert test_source['result_selector'] == '.old-selector'
1001 assert test_source['consecutive_failures'] == 3
1003 def test_sources_needing_attention_endpoint_empty(self):
1004 """Test sources needing attention endpoint when none exist."""
1005 from apps.recipes.models import SearchSource
1006 # Clear the needs_attention flag on our test source
1007 self.source.needs_attention = False
1008 self.source.save()
1009 # Clear all sources with needs_attention
1010 SearchSource.objects.update(needs_attention=False)
1012 response = self.client.get('/api/ai/sources-needing-attention')
1014 assert response.status_code == 200
1015 data = response.json()
1016 assert data == []
1019class AIFeatureFallbackTests(TestCase):
1020 """Tests for AI feature fallback behavior when AI is unavailable."""
1022 def setUp(self):
1023 from apps.profiles.models import Profile
1024 from apps.recipes.models import Recipe
1025 self.profile = Profile.objects.create(name='Test Profile')
1026 self.recipe = Recipe.objects.create(
1027 title='Test Recipe',
1028 profile=self.profile,
1029 ingredients=['1 cup flour', '2 eggs'],
1030 instructions=[{'text': 'Mix ingredients'}],
1031 servings=4,
1032 )
1033 # Set session profile
1034 session = self.client.session
1035 session['profile_id'] = self.profile.id
1036 session.save()
1038 def test_ai_status_shows_unavailable_without_key(self):
1039 """Test AI status endpoint shows unavailable when no API key."""
1040 settings = AppSettings.get()
1041 settings.openrouter_api_key = ''
1042 settings.save()
1043 OpenRouterService.invalidate_key_cache()
1045 response = self.client.get('/api/ai/status')
1046 assert response.status_code == 200
1047 data = response.json()
1048 assert data['available'] is False
1049 assert data['configured'] is False
1051 @patch.object(OpenRouterService, 'test_connection')
1052 def test_ai_status_shows_available_with_key(self, mock_test_connection):
1053 """Test AI status endpoint shows available with valid API key."""
1054 mock_test_connection.return_value = (True, 'Connection successful')
1055 settings = AppSettings.get()
1056 settings.openrouter_api_key = 'test-key-123'
1057 settings.save()
1058 OpenRouterService.invalidate_key_cache()
1060 response = self.client.get('/api/ai/status')
1061 assert response.status_code == 200
1062 data = response.json()
1063 assert data['available'] is True
1064 assert data['configured'] is True
1065 assert data['valid'] is True
1067 @patch('apps.ai.api.generate_tips')
1068 def test_tips_returns_503_with_action_field_when_ai_unavailable(self, mock_generate):
1069 """Test tips endpoint returns 503 with action field when AI is unavailable."""
1070 mock_generate.side_effect = AIUnavailableError('No API key')
1072 response = self.client.post(
1073 '/api/ai/tips',
1074 data={'recipe_id': self.recipe.id},
1075 content_type='application/json'
1076 )
1078 assert response.status_code == 503
1079 data = response.json()
1080 assert data['error'] == 'ai_unavailable'
1081 assert data['action'] == 'configure_key'
1082 assert 'message' in data
1084 @patch('apps.ai.api.get_remix_suggestions')
1085 def test_remix_suggestions_returns_503_when_ai_unavailable(self, mock_suggestions):
1086 """Test remix suggestions endpoint returns 503 when AI is unavailable."""
1087 mock_suggestions.side_effect = AIUnavailableError('No API key')
1089 response = self.client.post(
1090 '/api/ai/remix-suggestions',
1091 data={'recipe_id': self.recipe.id},
1092 content_type='application/json'
1093 )
1095 assert response.status_code == 503
1096 data = response.json()
1097 assert data['error'] == 'ai_unavailable'
1099 @patch('apps.ai.api.scale_recipe')
1100 def test_scale_returns_503_when_ai_unavailable(self, mock_scale):
1101 """Test scale endpoint returns 503 when AI is unavailable."""
1102 mock_scale.side_effect = AIUnavailableError('No API key')
1104 response = self.client.post(
1105 '/api/ai/scale',
1106 data={
1107 'recipe_id': self.recipe.id,
1108 'target_servings': 8,
1109 'profile_id': self.profile.id,
1110 },
1111 content_type='application/json'
1112 )
1114 assert response.status_code == 503
1115 data = response.json()
1116 assert data['error'] == 'ai_unavailable'
1118 @patch('apps.ai.api.get_discover_suggestions')
1119 def test_discover_returns_503_when_ai_unavailable(self, mock_discover):
1120 """Test discover endpoint returns 503 when AI is unavailable."""
1121 mock_discover.side_effect = AIUnavailableError('No API key')
1123 response = self.client.get(f'/api/ai/discover/{self.profile.id}/')
1125 assert response.status_code == 503
1126 data = response.json()
1127 assert data['error'] == 'ai_unavailable'
1129 @patch('apps.ai.services.tips.OpenRouterService')
1130 def test_tips_service_raises_when_no_key(self, mock_service_class):
1131 """Test tips service raises AIUnavailableError when no API key."""
1132 from apps.ai.services.tips import generate_tips
1133 mock_service_class.side_effect = AIUnavailableError('No API key configured')
1135 with pytest.raises(AIUnavailableError):
1136 generate_tips(self.recipe.id)
1138 @patch('apps.ai.services.remix.OpenRouterService')
1139 def test_remix_service_raises_when_no_key(self, mock_service_class):
1140 """Test remix service raises AIUnavailableError when no API key."""
1141 from apps.ai.services.remix import get_remix_suggestions
1142 mock_service_class.side_effect = AIUnavailableError('No API key configured')
1144 with pytest.raises(AIUnavailableError):
1145 get_remix_suggestions(self.recipe.id)
1147 def test_models_endpoint_returns_empty_list_without_key(self):
1148 """Test models endpoint returns empty list when AI unavailable."""
1149 settings = AppSettings.get()
1150 settings.openrouter_api_key = ''
1151 settings.save()
1153 response = self.client.get('/api/ai/models')
1154 assert response.status_code == 200
1155 data = response.json()
1156 assert data == []
1158 @patch('apps.ai.api.generate_timer_name')
1159 def test_timer_name_returns_503_when_ai_unavailable(self, mock_generate):
1160 """Test timer name endpoint returns 503 when AI is unavailable."""
1161 mock_generate.side_effect = AIUnavailableError('No API key')
1163 response = self.client.post(
1164 '/api/ai/timer-name',
1165 data={'step_text': 'Bake for 25 minutes', 'duration_minutes': 25},
1166 content_type='application/json'
1167 )
1169 assert response.status_code == 503
1170 data = response.json()
1171 assert data['error'] == 'ai_unavailable'
1174class AIResponseErrorTests(TestCase):
1175 """Tests for AI response error handling."""
1177 def setUp(self):
1178 from apps.profiles.models import Profile
1179 from apps.recipes.models import Recipe
1180 self.profile = Profile.objects.create(name='Test Profile')
1181 self.recipe = Recipe.objects.create(
1182 title='Test Recipe',
1183 profile=self.profile,
1184 ingredients=['1 cup flour'],
1185 instructions=[{'text': 'Mix'}],
1186 )
1187 session = self.client.session
1188 session['profile_id'] = self.profile.id
1189 session.save()
1191 @patch('apps.ai.api.generate_tips')
1192 def test_tips_returns_400_on_ai_error(self, mock_generate):
1193 """Test tips endpoint returns 400 on AI response error."""
1194 mock_generate.side_effect = AIResponseError('Invalid JSON response')
1196 response = self.client.post(
1197 '/api/ai/tips',
1198 data={'recipe_id': self.recipe.id},
1199 content_type='application/json'
1200 )
1202 assert response.status_code == 400
1203 data = response.json()
1204 assert data['error'] == 'ai_error'
1206 @patch('apps.ai.api.generate_tips')
1207 def test_tips_returns_400_on_validation_error(self, mock_generate):
1208 """Test tips endpoint returns 400 on validation error."""
1209 mock_generate.side_effect = ValidationError('Response validation failed')
1211 response = self.client.post(
1212 '/api/ai/tips',
1213 data={'recipe_id': self.recipe.id},
1214 content_type='application/json'
1215 )
1217 assert response.status_code == 400
1218 data = response.json()
1219 assert data['error'] == 'ai_error'
1221 @patch('apps.ai.api.create_remix')
1222 def test_remix_returns_400_on_ai_error(self, mock_remix):
1223 """Test remix endpoint returns 400 on AI response error."""
1224 mock_remix.side_effect = AIResponseError('AI returned invalid data')
1226 response = self.client.post(
1227 '/api/ai/remix',
1228 data={
1229 'recipe_id': self.recipe.id,
1230 'modification': 'Make it vegan',
1231 'profile_id': self.profile.id,
1232 },
1233 content_type='application/json'
1234 )
1236 assert response.status_code == 400
1237 data = response.json()
1238 assert data['error'] == 'ai_error'
1240 @patch('apps.ai.api.repair_selector')
1241 def test_repair_selector_returns_400_on_validation_error(self, mock_repair):
1242 """Test repair selector endpoint returns 400 on validation error."""
1243 from apps.recipes.models import SearchSource
1244 source = SearchSource.objects.create(
1245 host='test.example.com',
1246 name='Test',
1247 search_url_template='https://test.example.com/search?q={query}',
1248 )
1249 mock_repair.side_effect = ValidationError('Invalid AI response schema')
1251 response = self.client.post(
1252 '/api/ai/repair-selector',
1253 data={
1254 'source_id': source.id,
1255 'html_sample': '<div>Test</div>',
1256 },
1257 content_type='application/json'
1258 )
1260 assert response.status_code == 400
1261 data = response.json()
1262 assert data['error'] == 'ai_error'