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

1"""Tests for the AI app.""" 

2 

3import pytest 

4from unittest.mock import Mock, patch, MagicMock 

5from django.test import TestCase 

6 

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 

15 

16 

17class AIPromptModelTests(TestCase): 

18 """Tests for the AIPrompt model.""" 

19 

20 def test_prompts_seeded(self): 

21 """Verify all 11 prompts were seeded.""" 

22 assert AIPrompt.objects.count() == 11 

23 

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() 

41 

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' 

46 

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') 

51 

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 

61 

62 

63class AIResponseValidatorTests(TestCase): 

64 """Tests for the AI response validator.""" 

65 

66 def setUp(self): 

67 self.validator = AIResponseValidator() 

68 

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 

79 

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) 

90 

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 

96 

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) 

103 

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 

110 

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) 

116 

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' 

122 

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 

128 

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 

137 

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) 

143 

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'}) 

148 

149 

150class AIAPITests(TestCase): 

151 """Tests for the AI API endpoints.""" 

152 

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 

165 

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() 

172 

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'] 

181 

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') 

186 

187 settings = AppSettings.get() 

188 settings.openrouter_api_key = 'sk-or-invalid-key' 

189 settings.save() 

190 OpenRouterService.invalidate_key_cache() 

191 

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' 

199 

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') 

204 

205 settings = AppSettings.get() 

206 settings.openrouter_api_key = 'sk-or-valid-key' 

207 settings.save() 

208 OpenRouterService.invalidate_key_cache() 

209 

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 

218 

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 

225 

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 

235 

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' 

241 

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 

248 

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' 

256 

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 

261 

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' 

272 

273 # Verify persistence 

274 prompt = AIPrompt.objects.get(prompt_type='recipe_remix') 

275 assert prompt.model == 'openai/gpt-4o' 

276 

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 

287 

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'] 

297 

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 

306 

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') 

311 

312 # Pre-populate the cache 

313 OpenRouterService._key_validation_cache[hash('old-key')] = (True, 0) 

314 

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 

321 

322 # Cache should be cleared 

323 assert len(OpenRouterService._key_validation_cache) == 0 

324 

325 

326class OpenRouterServiceTests(TestCase): 

327 """Tests for the OpenRouter service.""" 

328 

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() 

335 

336 with pytest.raises(AIUnavailableError) as exc_info: 

337 OpenRouterService() 

338 assert 'not configured' in str(exc_info.value) 

339 

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' 

344 

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() 

350 

351 assert OpenRouterService.is_available() is False 

352 

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() 

358 

359 assert OpenRouterService.is_available() is True 

360 

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() 

366 

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 

372 

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 

378 

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() 

384 

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 

390 

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 

395 

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 

402 

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 

407 

408 OpenRouterService.invalidate_key_cache() 

409 assert len(OpenRouterService._key_validation_cache) == 0 

410 

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) 

418 

419 mock_response = Mock() 

420 mock_response.choices = [Mock(message=Mock(content='{"status": "ok"}'))] 

421 mock_client.chat.send.return_value = mock_response 

422 

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 ) 

430 

431 assert result == {'status': 'ok'} 

432 mock_client.chat.send.assert_called_once() 

433 

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) 

440 

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 

446 

447 service = OpenRouterService(api_key='test-key') 

448 result = service.complete( 

449 system_prompt='Test', 

450 user_prompt='Test', 

451 json_response=True, 

452 ) 

453 

454 assert result == {'title': 'Test Recipe'} 

455 

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) 

462 

463 mock_response = Mock() 

464 mock_response.choices = [Mock(message=Mock(content='not valid json'))] 

465 mock_client.chat.send.return_value = mock_response 

466 

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) 

475 

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) 

482 

483 mock_response = Mock() 

484 mock_response.choices = [] 

485 mock_client.chat.send.return_value = mock_response 

486 

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) 

494 

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) 

501 

502 mock_response = Mock() 

503 mock_response.choices = [Mock(message=Mock(content='Hello, world!'))] 

504 mock_client.chat.send.return_value = mock_response 

505 

506 service = OpenRouterService(api_key='test-key') 

507 result = service.complete( 

508 system_prompt='Test', 

509 user_prompt='Test', 

510 json_response=False, 

511 ) 

512 

513 assert result == {'content': 'Hello, world!'} 

514 

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) 

521 

522 mock_response = Mock() 

523 mock_response.choices = [Mock(message=Mock(content='{"status": "ok"}'))] 

524 mock_client.chat.send.return_value = mock_response 

525 

526 success, message = OpenRouterService.test_connection('test-key') 

527 assert success is True 

528 assert message == 'Connection successful' 

529 

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 

535 

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) 

542 

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 

553 

554 service = OpenRouterService(api_key='test-key') 

555 models = service.get_available_models() 

556 

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' 

561 

562 

563@pytest.mark.asyncio 

564class OpenRouterServiceAsyncTests(TestCase): 

565 """Async tests for the OpenRouter service.""" 

566 

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 

571 

572 mock_response = Mock() 

573 mock_response.choices = [Mock(message=Mock(content='{"result": "success"}'))] 

574 

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) 

578 

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) 

582 

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 ) 

589 

590 assert result == {'result': 'success'} 

591 

592 

593class TimerNamingServiceTests(TestCase): 

594 """Tests for the timer naming service.""" 

595 

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 

600 

601 mock_service = MagicMock() 

602 mock_service.complete.return_value = {'label': 'Simmer until reduced'} 

603 mock_service_class.return_value = mock_service 

604 

605 result = generate_timer_name('Simmer for 20 minutes until sauce is reduced', 20) 

606 

607 assert result['label'] == 'Simmer until reduced' 

608 mock_service.complete.assert_called_once() 

609 

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 

614 

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 

619 

620 result = generate_timer_name('Some instruction', 15) 

621 

622 # Should be truncated to 27 chars + '...' 

623 assert len(result['label']) == 30 

624 assert result['label'].endswith('...') 

625 

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 

630 

631 mock_service = MagicMock() 

632 mock_service.complete.return_value = {'label': 'Bake bread'} 

633 mock_service_class.return_value = mock_service 

634 

635 # Test with 90 minutes (1 hour 30 minutes) 

636 generate_timer_name('Bake until golden', 90) 

637 

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 

642 

643 

644class TimerNamingAPITests(TestCase): 

645 """Tests for the timer naming API endpoint.""" 

646 

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'} 

651 

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 ) 

657 

658 assert response.status_code == 200 

659 data = response.json() 

660 assert data['label'] == 'Bake until golden' 

661 

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 ) 

669 

670 assert response.status_code == 400 

671 data = response.json() 

672 assert 'Step text is required' in data['message'] 

673 

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 ) 

681 

682 assert response.status_code == 400 

683 data = response.json() 

684 assert 'Duration must be positive' in data['message'] 

685 

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') 

690 

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 ) 

696 

697 assert response.status_code == 503 

698 data = response.json() 

699 assert data['error'] == 'ai_unavailable' 

700 

701 

702class SelectorRepairServiceTests(TestCase): 

703 """Tests for the selector repair service.""" 

704 

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 ) 

717 

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 

722 

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 

729 

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 ) 

736 

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' 

742 

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 

747 

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 

752 

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 

759 

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 ) 

766 

767 assert result['confidence'] == 0.5 

768 assert result['updated'] is False 

769 assert result['new_selector'] is None 

770 

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 

775 

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 

780 

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 

787 

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 ) 

794 

795 assert result['confidence'] == 0.95 

796 assert result['updated'] is False 

797 assert result['new_selector'] is None 

798 

799 # Verify source was NOT updated 

800 self.source.refresh_from_db() 

801 assert self.source.result_selector == '.old-selector' 

802 

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 

807 

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 

814 

815 # Create HTML larger than 50KB 

816 large_html = 'x' * 100000 

817 

818 repair_selector( 

819 source=self.source, 

820 html_sample=large_html, 

821 auto_update=False, 

822 ) 

823 

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 

829 

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 

834 

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 ) 

857 

858 sources = get_sources_needing_attention() 

859 hosts = [s.host for s in sources] 

860 

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 

865 

866 

867class SelectorRepairAPITests(TestCase): 

868 """Tests for the selector repair API endpoints.""" 

869 

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 ) 

881 

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 } 

892 

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 ) 

901 

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' 

908 

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 ) 

919 

920 assert response.status_code == 404 

921 data = response.json() 

922 assert data['error'] == 'not_found' 

923 

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 ) 

934 

935 assert response.status_code == 400 

936 data = response.json() 

937 assert 'HTML sample is required' in data['message'] 

938 

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') 

943 

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 ) 

952 

953 assert response.status_code == 503 

954 data = response.json() 

955 assert data['error'] == 'ai_unavailable' 

956 

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 } 

967 

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 ) 

979 

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 

987 

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') 

991 

992 assert response.status_code == 200 

993 data = response.json() 

994 assert len(data) >= 1 # At least our test source 

995 

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 

1002 

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) 

1011 

1012 response = self.client.get('/api/ai/sources-needing-attention') 

1013 

1014 assert response.status_code == 200 

1015 data = response.json() 

1016 assert data == [] 

1017 

1018 

1019class AIFeatureFallbackTests(TestCase): 

1020 """Tests for AI feature fallback behavior when AI is unavailable.""" 

1021 

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() 

1037 

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() 

1044 

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 

1050 

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() 

1059 

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 

1066 

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') 

1071 

1072 response = self.client.post( 

1073 '/api/ai/tips', 

1074 data={'recipe_id': self.recipe.id}, 

1075 content_type='application/json' 

1076 ) 

1077 

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 

1083 

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') 

1088 

1089 response = self.client.post( 

1090 '/api/ai/remix-suggestions', 

1091 data={'recipe_id': self.recipe.id}, 

1092 content_type='application/json' 

1093 ) 

1094 

1095 assert response.status_code == 503 

1096 data = response.json() 

1097 assert data['error'] == 'ai_unavailable' 

1098 

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') 

1103 

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 ) 

1113 

1114 assert response.status_code == 503 

1115 data = response.json() 

1116 assert data['error'] == 'ai_unavailable' 

1117 

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') 

1122 

1123 response = self.client.get(f'/api/ai/discover/{self.profile.id}/') 

1124 

1125 assert response.status_code == 503 

1126 data = response.json() 

1127 assert data['error'] == 'ai_unavailable' 

1128 

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') 

1134 

1135 with pytest.raises(AIUnavailableError): 

1136 generate_tips(self.recipe.id) 

1137 

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') 

1143 

1144 with pytest.raises(AIUnavailableError): 

1145 get_remix_suggestions(self.recipe.id) 

1146 

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() 

1152 

1153 response = self.client.get('/api/ai/models') 

1154 assert response.status_code == 200 

1155 data = response.json() 

1156 assert data == [] 

1157 

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') 

1162 

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 ) 

1168 

1169 assert response.status_code == 503 

1170 data = response.json() 

1171 assert data['error'] == 'ai_unavailable' 

1172 

1173 

1174class AIResponseErrorTests(TestCase): 

1175 """Tests for AI response error handling.""" 

1176 

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() 

1190 

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') 

1195 

1196 response = self.client.post( 

1197 '/api/ai/tips', 

1198 data={'recipe_id': self.recipe.id}, 

1199 content_type='application/json' 

1200 ) 

1201 

1202 assert response.status_code == 400 

1203 data = response.json() 

1204 assert data['error'] == 'ai_error' 

1205 

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') 

1210 

1211 response = self.client.post( 

1212 '/api/ai/tips', 

1213 data={'recipe_id': self.recipe.id}, 

1214 content_type='application/json' 

1215 ) 

1216 

1217 assert response.status_code == 400 

1218 data = response.json() 

1219 assert data['error'] == 'ai_error' 

1220 

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') 

1225 

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 ) 

1235 

1236 assert response.status_code == 400 

1237 data = response.json() 

1238 assert data['error'] == 'ai_error' 

1239 

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') 

1250 

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 ) 

1259 

1260 assert response.status_code == 400 

1261 data = response.json() 

1262 assert data['error'] == 'ai_error' 

← Back to Dashboard