This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/backend/klausur/tests/test_routes.py
Benjamin Admin bfdaf63ba9 fix: Restore all files lost during destructive rebase
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>
2026-02-09 09:51:32 +01:00

347 lines
12 KiB
Python

"""
Tests for Klausur API Routes.
Verifies:
- API endpoint behavior
- Request validation
- Response format
- Privacy guarantees at API level
"""
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from fastapi.testclient import TestClient
from fastapi import FastAPI
from klausur.routes import router
from klausur.db_models import SessionStatus, DocumentStatus
@pytest.fixture
def app():
"""Create test FastAPI app."""
app = FastAPI()
app.include_router(router, prefix="/api")
return app
@pytest.fixture
def client(app):
"""Create test client."""
return TestClient(app)
class TestSessionEndpoints:
"""Tests for session-related endpoints."""
@patch('klausur.routes.KlausurRepository')
@patch('klausur.routes.get_db')
def test_create_session_returns_201(self, mock_get_db, mock_repo_class, client):
"""Creating a session should return 201."""
# Setup mocks
mock_db = MagicMock()
mock_get_db.return_value = iter([mock_db])
mock_repo = MagicMock()
mock_repo_class.return_value = mock_repo
mock_session = MagicMock()
mock_session.id = "session-123"
mock_session.name = "Test Klausur"
mock_session.subject = "Mathe"
mock_session.class_name = "10a"
mock_session.total_points = 100
mock_session.status = SessionStatus.CREATED
mock_session.document_count = 0
mock_session.processed_count = 0
mock_session.created_at = "2024-01-15T10:00:00"
mock_session.completed_at = None
mock_session.retention_until = "2024-02-15T10:00:00"
mock_repo.create_session.return_value = mock_session
response = client.post("/api/klausur/sessions", json={
"name": "Test Klausur",
"subject": "Mathe",
"class_name": "10a"
})
assert response.status_code == 201
data = response.json()
assert data["name"] == "Test Klausur"
assert data["status"] == "created"
@patch('klausur.routes.KlausurRepository')
@patch('klausur.routes.get_db')
def test_create_session_validates_name(self, mock_get_db, mock_repo_class, client):
"""Session name is required and must not be empty."""
response = client.post("/api/klausur/sessions", json={
"name": "", # Empty name
"subject": "Mathe"
})
assert response.status_code == 422 # Validation error
@patch('klausur.routes.KlausurRepository')
@patch('klausur.routes.get_db')
def test_list_sessions_returns_array(self, mock_get_db, mock_repo_class, client):
"""Listing sessions should return an array."""
mock_db = MagicMock()
mock_get_db.return_value = iter([mock_db])
mock_repo = MagicMock()
mock_repo_class.return_value = mock_repo
mock_repo.list_sessions.return_value = []
response = client.get("/api/klausur/sessions")
assert response.status_code == 200
data = response.json()
assert "sessions" in data
assert isinstance(data["sessions"], list)
@patch('klausur.routes.KlausurRepository')
@patch('klausur.routes.get_db')
def test_get_session_404_when_not_found(self, mock_get_db, mock_repo_class, client):
"""Getting non-existent session should return 404."""
mock_db = MagicMock()
mock_get_db.return_value = iter([mock_db])
mock_repo = MagicMock()
mock_repo_class.return_value = mock_repo
mock_repo.get_session.return_value = None
response = client.get("/api/klausur/sessions/nonexistent-123")
assert response.status_code == 404
class TestQREndpoints:
"""Tests for QR code generation endpoints."""
@patch('klausur.routes.KlausurRepository')
@patch('klausur.routes.get_pseudonymizer')
@patch('klausur.routes.get_db')
def test_generate_qr_batch_creates_tokens(
self, mock_get_db, mock_get_pseudonymizer, mock_repo_class, client
):
"""QR batch generation should create correct number of tokens."""
mock_db = MagicMock()
mock_get_db.return_value = iter([mock_db])
mock_repo = MagicMock()
mock_repo_class.return_value = mock_repo
mock_session = MagicMock()
mock_repo.get_session.return_value = mock_session
mock_batch = MagicMock()
mock_batch.id = "batch-123"
mock_batch.student_count = 5
mock_repo.create_qr_batch.return_value = mock_batch
mock_pseudonymizer = MagicMock()
mock_pseudonymizer.generate_batch_tokens.return_value = [
"token-1", "token-2", "token-3", "token-4", "token-5"
]
mock_get_pseudonymizer.return_value = mock_pseudonymizer
response = client.post("/api/klausur/sessions/session-123/qr-batch", json={
"student_count": 5
})
assert response.status_code == 200
data = response.json()
assert len(data["generated_tokens"]) == 5
@patch('klausur.routes.KlausurRepository')
@patch('klausur.routes.get_db')
def test_qr_batch_validates_student_count(self, mock_get_db, mock_repo_class, client):
"""Student count must be within valid range."""
# Too many students
response = client.post("/api/klausur/sessions/session-123/qr-batch", json={
"student_count": 200 # Max is 100
})
assert response.status_code == 422
class TestUploadEndpoints:
"""Tests for document upload endpoints."""
@patch('klausur.routes.KlausurRepository')
@patch('klausur.routes.get_pseudonymizer')
@patch('klausur.routes.get_db')
def test_upload_applies_redaction_by_default(
self, mock_get_db, mock_get_pseudonymizer, mock_repo_class, client
):
"""Upload should apply header redaction by default."""
mock_db = MagicMock()
mock_get_db.return_value = iter([mock_db])
mock_repo = MagicMock()
mock_repo_class.return_value = mock_repo
mock_session = MagicMock()
mock_repo.get_session.return_value = mock_session
mock_doc = MagicMock()
mock_doc.doc_token = "doc-token-123"
mock_doc.session_id = "session-123"
mock_doc.status = DocumentStatus.UPLOADED
mock_doc.page_number = 1
mock_doc.total_pages = 1
mock_doc.ocr_confidence = 0
mock_doc.ai_score = None
mock_doc.ai_grade = None
mock_doc.ai_feedback = None
mock_doc.created_at = "2024-01-15T10:00:00"
mock_doc.processing_completed_at = None
mock_repo.create_document.return_value = mock_doc
mock_pseudonymizer = MagicMock()
mock_pseudonymizer.detect_qr_code.return_value = MagicMock(doc_token=None)
mock_pseudonymizer.generate_doc_token.return_value = "doc-token-123"
mock_pseudonymizer.smart_redact_header.return_value = MagicMock(
redaction_applied=True,
redacted_image=b"redacted",
redacted_height=300
)
mock_get_pseudonymizer.return_value = mock_pseudonymizer
# Create a minimal file upload
response = client.post(
"/api/klausur/sessions/session-123/upload",
files={"file": ("test.png", b"fake image data", "image/png")}
)
# Verify redaction was called
mock_pseudonymizer.smart_redact_header.assert_called_once()
class TestResultsEndpoints:
"""Tests for results endpoints."""
@patch('klausur.routes.KlausurRepository')
@patch('klausur.routes.get_db')
def test_results_only_return_pseudonymized_data(
self, mock_get_db, mock_repo_class, client
):
"""Results should only contain doc_tokens, not names."""
mock_db = MagicMock()
mock_get_db.return_value = iter([mock_db])
mock_repo = MagicMock()
mock_repo_class.return_value = mock_repo
mock_session = MagicMock()
mock_session.total_points = 100
mock_repo.get_session.return_value = mock_session
mock_doc = MagicMock()
mock_doc.doc_token = "anonymous-token-123"
mock_doc.status = DocumentStatus.COMPLETED
mock_doc.ai_score = 85
mock_doc.ai_grade = "2+"
mock_doc.ai_feedback = "Good work"
mock_doc.ai_details = {}
mock_repo.list_documents.return_value = [mock_doc]
response = client.get("/api/klausur/sessions/session-123/results")
assert response.status_code == 200
data = response.json()
# Results should use doc_token, not student name
assert len(data) == 1
assert "doc_token" in data[0]
assert "student_name" not in data[0]
assert "name" not in data[0]
class TestIdentityMapEndpoints:
"""Tests for identity map (vault) endpoints."""
@patch('klausur.routes.KlausurRepository')
@patch('klausur.routes.get_db')
def test_store_identity_map_accepts_encrypted_data(
self, mock_get_db, mock_repo_class, client
):
"""Identity map endpoint should accept encrypted data."""
mock_db = MagicMock()
mock_get_db.return_value = iter([mock_db])
mock_repo = MagicMock()
mock_repo_class.return_value = mock_repo
mock_session = MagicMock()
mock_repo.update_session_identity_map.return_value = mock_session
# Base64 encoded "encrypted" data
import base64
encrypted = base64.b64encode(b"encrypted identity map").decode()
response = client.post("/api/klausur/sessions/session-123/identity-map", json={
"encrypted_data": encrypted,
"iv": "base64iv=="
})
assert response.status_code == 204
@patch('klausur.routes.KlausurRepository')
@patch('klausur.routes.get_db')
def test_get_identity_map_returns_encrypted_blob(
self, mock_get_db, mock_repo_class, client
):
"""Getting identity map should return encrypted blob."""
mock_db = MagicMock()
mock_get_db.return_value = iter([mock_db])
mock_repo = MagicMock()
mock_repo_class.return_value = mock_repo
mock_session = MagicMock()
mock_session.encrypted_identity_map = b"encrypted data"
mock_session.identity_map_iv = "ivvalue"
mock_repo.get_session.return_value = mock_session
response = client.get("/api/klausur/sessions/session-123/identity-map")
assert response.status_code == 200
data = response.json()
assert "encrypted_data" in data
assert "iv" in data
class TestPrivacyAtAPILevel:
"""Tests verifying privacy guarantees at API level."""
def test_no_student_names_in_any_response_schema(self):
"""Verify response schemas don't include student names."""
from klausur.routes import (
SessionResponse, DocumentResponse, CorrectionResultResponse
)
# Check all response model fields
session_fields = SessionResponse.model_fields.keys()
doc_fields = DocumentResponse.model_fields.keys()
result_fields = CorrectionResultResponse.model_fields.keys()
all_fields = list(session_fields) + list(doc_fields) + list(result_fields)
# Should not contain student-name-related fields
# Note: "name" alone is allowed (e.g., session/exam name like "Mathe Klausur")
forbidden = ["student_name", "schueler_name", "student", "pupil", "schueler"]
for field in all_fields:
assert field.lower() not in forbidden, f"Field '{field}' may contain PII"
def test_identity_map_request_requires_encryption(self):
"""Identity map must be encrypted before storage."""
from klausur.routes import IdentityMapUpdate
# Check that schema requires encrypted_data, not plain names
fields = IdentityMapUpdate.model_fields.keys()
assert "encrypted_data" in fields
assert "names" not in fields
assert "student_names" not in fields