""" Tests for EduSearch Seeds API. Tests cover: - CRUD operations for seeds - Category management - Bulk import functionality - Statistics endpoint - Error handling """ import pytest from unittest.mock import AsyncMock, MagicMock, patch from fastapi import HTTPException from fastapi.testclient import TestClient import uuid from datetime import datetime class AsyncContextManagerMock: """Helper class to create proper async context managers for testing.""" def __init__(self, return_value): self.return_value = return_value async def __aenter__(self): return self.return_value async def __aexit__(self, exc_type, exc_val, exc_tb): return None def create_mock_pool_and_conn(): """Helper to create properly configured mock pool and connection for async tests.""" mock_pool = MagicMock() mock_conn = AsyncMock() # Configure pool.acquire() to return an async context manager mock_pool.acquire.return_value = AsyncContextManagerMock(mock_conn) return mock_pool, mock_conn def mock_db_pool_patch(mock_pool): """Create a patch for get_db_pool that returns mock_pool.""" async def _mock_get_pool(): return mock_pool return patch("llm_gateway.routes.edu_search_seeds.get_db_pool", new=_mock_get_pool) class TestSeedModels: """Test Pydantic models for seeds.""" def test_seed_base_valid(self): """Test SeedBase with valid data.""" from llm_gateway.routes.edu_search_seeds import SeedBase seed = SeedBase( url="https://www.kmk.org", name="KMK", description="Test description", trust_boost=0.95, enabled=True ) assert seed.url == "https://www.kmk.org" assert seed.name == "KMK" assert seed.trust_boost == 0.95 def test_seed_base_defaults(self): """Test SeedBase default values.""" from llm_gateway.routes.edu_search_seeds import SeedBase seed = SeedBase( url="https://test.de", name="Test" ) assert seed.description is None assert seed.trust_boost == 0.5 assert seed.enabled is True assert seed.source_type == "GOV" assert seed.scope == "FEDERAL" def test_seed_create_model(self): """Test SeedCreate model.""" from llm_gateway.routes.edu_search_seeds import SeedCreate seed = SeedCreate( url="https://www.test.de", name="Test Seed", category_name="federal" ) assert seed.url == "https://www.test.de" assert seed.category_name == "federal" def test_seed_update_model(self): """Test SeedUpdate with partial data.""" from llm_gateway.routes.edu_search_seeds import SeedUpdate seed = SeedUpdate(enabled=False) assert seed.enabled is False assert seed.name is None assert seed.url is None class TestCategoryResponse: """Test category response models.""" def test_category_response(self): """Test CategoryResponse model.""" from llm_gateway.routes.edu_search_seeds import CategoryResponse cat = CategoryResponse( id="550e8400-e29b-41d4-a716-446655440000", name="federal", display_name="Bundesebene", description="KMK, BMBF", icon="icon", sort_order=0, is_active=True ) assert cat.name == "federal" assert cat.display_name == "Bundesebene" class TestDatabaseConnection: """Test database connection handling.""" @pytest.mark.asyncio async def test_get_db_pool_creates_pool(self): """Test that get_db_pool creates a connection pool.""" from llm_gateway.routes.edu_search_seeds import get_db_pool import llm_gateway.routes.edu_search_seeds as module mock_pool = MagicMock() # Reset global pool module._pool = None with patch("llm_gateway.routes.edu_search_seeds.asyncpg.create_pool", new=AsyncMock(return_value=mock_pool)) as mock_create: with patch.dict("os.environ", {"DATABASE_URL": "postgresql://test:test@localhost/test"}): pool = await get_db_pool() mock_create.assert_called_once() assert pool == mock_pool # Cleanup module._pool = None @pytest.mark.asyncio async def test_get_db_pool_reuses_existing(self): """Test that get_db_pool reuses existing pool.""" from llm_gateway.routes.edu_search_seeds import get_db_pool import llm_gateway.routes.edu_search_seeds as module mock_pool = MagicMock() module._pool = mock_pool pool = await get_db_pool() assert pool == mock_pool # Cleanup module._pool = None class TestListCategories: """Test list_categories endpoint.""" @pytest.mark.asyncio async def test_list_categories_success(self): """Test successful category listing.""" from llm_gateway.routes.edu_search_seeds import list_categories mock_pool, mock_conn = create_mock_pool_and_conn() mock_conn.fetch.return_value = [ { "id": uuid.uuid4(), "name": "federal", "display_name": "Bundesebene", "description": "Test", "icon": "icon", "sort_order": 0, "is_active": True, "created_at": "2024-01-01T00:00:00Z" } ] with mock_db_pool_patch(mock_pool): result = await list_categories() assert len(result) == 1 assert result[0].name == "federal" @pytest.mark.asyncio async def test_list_categories_empty(self): """Test category listing with no categories.""" from llm_gateway.routes.edu_search_seeds import list_categories mock_pool, mock_conn = create_mock_pool_and_conn() mock_conn.fetch.return_value = [] with mock_db_pool_patch(mock_pool): result = await list_categories() assert result == [] class TestGetSeed: """Test get_seed endpoint.""" @pytest.mark.asyncio async def test_get_seed_found(self): """Test getting existing seed.""" from llm_gateway.routes.edu_search_seeds import get_seed seed_id = str(uuid.uuid4()) mock_pool, mock_conn = create_mock_pool_and_conn() mock_conn.fetchrow.return_value = { "id": seed_id, "url": "https://test.de", "name": "Test", "description": None, "category": "federal", "category_display_name": "Bundesebene", "source_type": "GOV", "scope": "FEDERAL", "state": None, "trust_boost": 0.5, "enabled": True, "crawl_depth": 2, "crawl_frequency": "weekly", "last_crawled_at": None, "last_crawl_status": None, "last_crawl_docs": 0, "total_documents": 0, "created_at": datetime.now(), "updated_at": datetime.now() } with mock_db_pool_patch(mock_pool): result = await get_seed(seed_id) assert result.url == "https://test.de" assert result.category == "federal" @pytest.mark.asyncio async def test_get_seed_not_found(self): """Test getting non-existing seed returns 404.""" from llm_gateway.routes.edu_search_seeds import get_seed seed_id = str(uuid.uuid4()) mock_pool, mock_conn = create_mock_pool_and_conn() mock_conn.fetchrow.return_value = None with mock_db_pool_patch(mock_pool): with pytest.raises(HTTPException) as exc_info: await get_seed(seed_id) assert exc_info.value.status_code == 404 class TestCreateSeed: """Test create_seed endpoint.""" @pytest.mark.asyncio async def test_create_seed_success(self): """Test successful seed creation.""" from llm_gateway.routes.edu_search_seeds import create_seed, SeedCreate new_seed = SeedCreate( url="https://new-seed.de", name="New Seed", description="Test seed", trust_boost=0.8 ) mock_pool, mock_conn = create_mock_pool_and_conn() new_id = uuid.uuid4() now = datetime.now() # Mock for fetchval (category lookup - returns None since no category) mock_conn.fetchval.return_value = None # Mock for fetchrow (insert returning) mock_conn.fetchrow.return_value = { "id": new_id, "created_at": now, "updated_at": now } with mock_db_pool_patch(mock_pool): result = await create_seed(new_seed) assert result.id == str(new_id) assert result.url == "https://new-seed.de" @pytest.mark.asyncio async def test_create_seed_duplicate_url(self): """Test creating seed with duplicate URL fails with 409.""" from llm_gateway.routes.edu_search_seeds import create_seed, SeedCreate import asyncpg new_seed = SeedCreate( url="https://duplicate.de", name="Duplicate" ) mock_pool, mock_conn = create_mock_pool_and_conn() mock_conn.fetchval.return_value = None # No category mock_conn.fetchrow.side_effect = asyncpg.UniqueViolationError("duplicate key") with mock_db_pool_patch(mock_pool): with pytest.raises(HTTPException) as exc_info: await create_seed(new_seed) assert exc_info.value.status_code == 409 assert "existiert bereits" in exc_info.value.detail class TestUpdateSeed: """Test update_seed endpoint.""" @pytest.mark.asyncio async def test_update_seed_success(self): """Test successful seed update.""" from llm_gateway.routes.edu_search_seeds import update_seed, SeedUpdate seed_id = str(uuid.uuid4()) update_data = SeedUpdate(name="Updated Name") mock_pool, mock_conn = create_mock_pool_and_conn() now = datetime.now() # Mock for execute (update) - returns non-zero mock_conn.execute.return_value = "UPDATE 1" # Mock for fetchval (check if update succeeded) mock_conn.fetchval.return_value = seed_id # Mock for fetchrow (get_seed after update) mock_conn.fetchrow.return_value = { "id": seed_id, "url": "https://test.de", "name": "Updated Name", "description": None, "category": "federal", "category_display_name": "Bundesebene", "source_type": "GOV", "scope": "FEDERAL", "state": None, "trust_boost": 0.5, "enabled": True, "crawl_depth": 2, "crawl_frequency": "weekly", "last_crawled_at": None, "last_crawl_status": None, "last_crawl_docs": 0, "total_documents": 0, "created_at": now, "updated_at": now } with mock_db_pool_patch(mock_pool): result = await update_seed(seed_id, update_data) assert result.name == "Updated Name" @pytest.mark.asyncio async def test_update_seed_not_found(self): """Test updating non-existing seed returns 404.""" from llm_gateway.routes.edu_search_seeds import update_seed, SeedUpdate seed_id = str(uuid.uuid4()) update_data = SeedUpdate(name="Updated") mock_pool, mock_conn = create_mock_pool_and_conn() # UPDATE uses fetchrow with RETURNING id - returns None for not found mock_conn.fetchrow.return_value = None with mock_db_pool_patch(mock_pool): with pytest.raises(HTTPException) as exc_info: await update_seed(seed_id, update_data) assert exc_info.value.status_code == 404 @pytest.mark.asyncio async def test_update_seed_empty_update(self): """Test update with no fields returns 400.""" from llm_gateway.routes.edu_search_seeds import update_seed, SeedUpdate seed_id = str(uuid.uuid4()) update_data = SeedUpdate() mock_pool, mock_conn = create_mock_pool_and_conn() with mock_db_pool_patch(mock_pool): with pytest.raises(HTTPException) as exc_info: await update_seed(seed_id, update_data) assert exc_info.value.status_code == 400 class TestDeleteSeed: """Test delete_seed endpoint.""" @pytest.mark.asyncio async def test_delete_seed_success(self): """Test successful seed deletion.""" from llm_gateway.routes.edu_search_seeds import delete_seed seed_id = str(uuid.uuid4()) mock_pool, mock_conn = create_mock_pool_and_conn() mock_conn.execute.return_value = "DELETE 1" with mock_db_pool_patch(mock_pool): result = await delete_seed(seed_id) assert result["status"] == "deleted" assert result["id"] == seed_id @pytest.mark.asyncio async def test_delete_seed_not_found(self): """Test deleting non-existing seed returns 404.""" from llm_gateway.routes.edu_search_seeds import delete_seed seed_id = str(uuid.uuid4()) mock_pool, mock_conn = create_mock_pool_and_conn() mock_conn.execute.return_value = "DELETE 0" with mock_db_pool_patch(mock_pool): with pytest.raises(HTTPException) as exc_info: await delete_seed(seed_id) assert exc_info.value.status_code == 404 class TestBulkImport: """Test bulk_import_seeds endpoint.""" @pytest.mark.asyncio async def test_bulk_import_success(self): """Test successful bulk import.""" from llm_gateway.routes.edu_search_seeds import bulk_import_seeds, BulkImportRequest, SeedCreate seeds = [ SeedCreate(url="https://test1.de", name="Test 1", category_name="federal"), SeedCreate(url="https://test2.de", name="Test 2", category_name="states") ] request = BulkImportRequest(seeds=seeds) mock_pool, mock_conn = create_mock_pool_and_conn() # Mock category pre-fetch mock_conn.fetch.return_value = [ {"id": uuid.uuid4(), "name": "federal"}, {"id": uuid.uuid4(), "name": "states"} ] # Mock inserts - ON CONFLICT DO NOTHING, no exception mock_conn.execute.return_value = "INSERT 0 1" with mock_db_pool_patch(mock_pool): result = await bulk_import_seeds(request) assert result.imported == 2 assert result.skipped == 0 @pytest.mark.asyncio async def test_bulk_import_with_errors(self): """Test bulk import handles errors gracefully.""" from llm_gateway.routes.edu_search_seeds import bulk_import_seeds, BulkImportRequest, SeedCreate seeds = [SeedCreate(url="https://error.de", name="Error Seed")] request = BulkImportRequest(seeds=seeds) mock_pool, mock_conn = create_mock_pool_and_conn() mock_conn.fetch.return_value = [] # No categories mock_conn.execute.side_effect = Exception("Database error") with mock_db_pool_patch(mock_pool): result = await bulk_import_seeds(request) assert result.imported == 0 assert len(result.errors) == 1 class TestGetStats: """Test get_stats endpoint.""" @pytest.mark.asyncio async def test_get_stats_success(self): """Test successful stats retrieval.""" from llm_gateway.routes.edu_search_seeds import get_stats mock_pool, mock_conn = create_mock_pool_and_conn() # Mock for multiple fetchval calls mock_conn.fetchval.side_effect = [56, 52, 1000, None] # total, enabled, total_docs, last_crawl # Mock for fetch calls (by_category, by_state) mock_conn.fetch.side_effect = [ [{"name": "federal", "count": 5}], # per category [{"state": "BY", "count": 5}] # per state ] with mock_db_pool_patch(mock_pool): result = await get_stats() assert result.total_seeds == 56 assert result.enabled_seeds == 52 assert result.total_documents == 1000 class TestExportForCrawler: """Test export_seeds_for_crawler endpoint.""" @pytest.mark.asyncio async def test_export_for_crawler_success(self): """Test successful crawler export.""" from llm_gateway.routes.edu_search_seeds import export_seeds_for_crawler mock_pool, mock_conn = create_mock_pool_and_conn() mock_conn.fetch.return_value = [ { "url": "https://test.de", "trust_boost": 0.9, "source_type": "GOV", "scope": "FEDERAL", "state": None, "crawl_depth": 2, "category": "federal" } ] with mock_db_pool_patch(mock_pool): result = await export_seeds_for_crawler() assert "seeds" in result assert len(result["seeds"]) == 1 assert "exported_at" in result assert result["total"] == 1 assert result["seeds"][0]["trust"] == 0.9 class TestEdgeCases: """Test edge cases and error handling.""" @pytest.mark.asyncio async def test_invalid_uuid_format(self): """Test handling of invalid UUID format.""" from llm_gateway.routes.edu_search_seeds import get_seed import asyncpg mock_pool, mock_conn = create_mock_pool_and_conn() # asyncpg raises DataError for invalid UUID mock_conn.fetchrow.side_effect = asyncpg.DataError("invalid UUID") with mock_db_pool_patch(mock_pool): with pytest.raises(asyncpg.DataError): await get_seed("not-a-uuid") @pytest.mark.asyncio async def test_database_connection_error(self): """Test handling of database connection errors.""" from llm_gateway.routes.edu_search_seeds import list_categories async def _failing_get_pool(): raise Exception("Connection failed") with patch("llm_gateway.routes.edu_search_seeds.get_db_pool", new=_failing_get_pool): with pytest.raises(Exception) as exc_info: await list_categories() assert "Connection failed" in str(exc_info.value) def test_trust_boost_validation(self): """Test trust_boost must be between 0 and 1.""" from llm_gateway.routes.edu_search_seeds import SeedBase from pydantic import ValidationError # Valid values seed = SeedBase(url="https://test.de", name="Test", trust_boost=0.5) assert seed.trust_boost == 0.5 # Edge values seed_min = SeedBase(url="https://test.de", name="Test", trust_boost=0.0) assert seed_min.trust_boost == 0.0 seed_max = SeedBase(url="https://test.de", name="Test", trust_boost=1.0) assert seed_max.trust_boost == 1.0 # Invalid values with pytest.raises(ValidationError): SeedBase(url="https://test.de", name="Test", trust_boost=1.5) with pytest.raises(ValidationError): SeedBase(url="https://test.de", name="Test", trust_boost=-0.1)