A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
590 lines
19 KiB
Python
590 lines
19 KiB
Python
"""
|
|
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)
|