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>
249 lines
8.9 KiB
Python
249 lines
8.9 KiB
Python
"""
|
|
Tests for KlausurRepository.
|
|
|
|
Verifies:
|
|
- Teacher isolation (critical for privacy)
|
|
- CRUD operations
|
|
- Data retention cleanup
|
|
"""
|
|
import pytest
|
|
from datetime import datetime, timedelta
|
|
from unittest.mock import MagicMock, patch
|
|
from sqlalchemy.orm import Session
|
|
|
|
from klausur.repository import KlausurRepository
|
|
from klausur.db_models import (
|
|
ExamSession, PseudonymizedDocument, QRBatchJob,
|
|
SessionStatus, DocumentStatus
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_db():
|
|
"""Create a mock database session."""
|
|
return MagicMock(spec=Session)
|
|
|
|
|
|
@pytest.fixture
|
|
def repo(mock_db):
|
|
"""Create a repository with mock DB."""
|
|
return KlausurRepository(mock_db)
|
|
|
|
|
|
class TestTeacherIsolation:
|
|
"""Tests for teacher namespace isolation (CRITICAL for privacy)."""
|
|
|
|
def test_get_session_requires_teacher_id(self, repo, mock_db):
|
|
"""Getting a session must require teacher_id."""
|
|
# Setup mock
|
|
mock_query = MagicMock()
|
|
mock_db.query.return_value = mock_query
|
|
mock_query.filter.return_value = mock_query
|
|
mock_query.first.return_value = None
|
|
|
|
# Attempt to get session
|
|
result = repo.get_session("session-123", "teacher-A")
|
|
|
|
# Verify filter was called (teacher isolation)
|
|
mock_db.query.assert_called_with(ExamSession)
|
|
mock_query.filter.assert_called()
|
|
|
|
def test_list_sessions_only_returns_teacher_sessions(self, repo, mock_db):
|
|
"""Listing sessions must filter by teacher_id."""
|
|
mock_query = MagicMock()
|
|
mock_db.query.return_value = mock_query
|
|
mock_query.filter.return_value = mock_query
|
|
mock_query.order_by.return_value = mock_query
|
|
mock_query.offset.return_value = mock_query
|
|
mock_query.limit.return_value = mock_query
|
|
mock_query.all.return_value = []
|
|
|
|
result = repo.list_sessions("teacher-A")
|
|
|
|
# Verify query chain
|
|
mock_db.query.assert_called_with(ExamSession)
|
|
|
|
def test_get_document_verifies_teacher_ownership(self, repo, mock_db):
|
|
"""Getting a document must verify teacher owns the session."""
|
|
mock_query = MagicMock()
|
|
mock_db.query.return_value = mock_query
|
|
mock_query.join.return_value = mock_query
|
|
mock_query.filter.return_value = mock_query
|
|
mock_query.first.return_value = None
|
|
|
|
result = repo.get_document("doc-token-123", "teacher-A")
|
|
|
|
# Must join with ExamSession to verify teacher_id
|
|
mock_query.join.assert_called()
|
|
|
|
def test_different_teachers_cannot_see_each_others_sessions(self, repo, mock_db):
|
|
"""Teacher A cannot access Teacher B's sessions."""
|
|
# Create mock session owned by teacher-B
|
|
session_b = MagicMock(spec=ExamSession)
|
|
session_b.teacher_id = "teacher-B"
|
|
session_b.id = "session-123"
|
|
|
|
mock_query = MagicMock()
|
|
mock_db.query.return_value = mock_query
|
|
mock_query.filter.return_value = mock_query
|
|
# Return None because filter should exclude teacher-B's session
|
|
mock_query.first.return_value = None
|
|
|
|
# Teacher A tries to access
|
|
result = repo.get_session("session-123", "teacher-A")
|
|
|
|
assert result is None
|
|
|
|
|
|
class TestSessionOperations:
|
|
"""Tests for session CRUD operations."""
|
|
|
|
def test_create_session_sets_teacher_id(self, repo, mock_db):
|
|
"""Creating a session must set the teacher_id."""
|
|
repo.create_session(
|
|
teacher_id="teacher-123",
|
|
name="Mathe Klausur",
|
|
subject="Mathematik"
|
|
)
|
|
|
|
# Verify session was added with teacher_id
|
|
mock_db.add.assert_called_once()
|
|
added_session = mock_db.add.call_args[0][0]
|
|
assert added_session.teacher_id == "teacher-123"
|
|
assert added_session.name == "Mathe Klausur"
|
|
|
|
def test_create_session_sets_retention_date(self, repo, mock_db):
|
|
"""Sessions must have a retention date for auto-deletion."""
|
|
repo.create_session(
|
|
teacher_id="teacher-123",
|
|
name="Test",
|
|
retention_days=30
|
|
)
|
|
|
|
added_session = mock_db.add.call_args[0][0]
|
|
assert added_session.retention_until is not None
|
|
|
|
# Should be approximately 30 days in the future
|
|
expected = datetime.utcnow() + timedelta(days=30)
|
|
diff = abs((added_session.retention_until - expected).total_seconds())
|
|
assert diff < 60 # Within 1 minute
|
|
|
|
def test_delete_session_soft_delete_by_default(self, repo, mock_db):
|
|
"""Deleting should soft-delete by default."""
|
|
mock_session = MagicMock(spec=ExamSession)
|
|
mock_session.status = SessionStatus.CREATED
|
|
|
|
mock_query = MagicMock()
|
|
mock_db.query.return_value = mock_query
|
|
mock_query.filter.return_value = mock_query
|
|
mock_query.first.return_value = mock_session
|
|
|
|
result = repo.delete_session("session-123", "teacher-A")
|
|
|
|
# Should set status to DELETED, not actually delete
|
|
assert mock_session.status == SessionStatus.DELETED
|
|
mock_db.delete.assert_not_called()
|
|
|
|
def test_delete_session_hard_delete_when_requested(self, repo, mock_db):
|
|
"""Hard delete should actually delete the record."""
|
|
mock_session = MagicMock(spec=ExamSession)
|
|
|
|
mock_query = MagicMock()
|
|
mock_db.query.return_value = mock_query
|
|
mock_query.filter.return_value = mock_query
|
|
mock_query.first.return_value = mock_session
|
|
|
|
result = repo.delete_session("session-123", "teacher-A", hard_delete=True)
|
|
|
|
mock_db.delete.assert_called_once_with(mock_session)
|
|
|
|
|
|
class TestDocumentOperations:
|
|
"""Tests for document CRUD operations."""
|
|
|
|
def test_create_document_requires_valid_session(self, repo, mock_db):
|
|
"""Creating a document requires a valid session owned by teacher."""
|
|
# Session not found (wrong teacher or doesn't exist)
|
|
mock_query = MagicMock()
|
|
mock_db.query.return_value = mock_query
|
|
mock_query.filter.return_value = mock_query
|
|
mock_query.first.return_value = None
|
|
|
|
result = repo.create_document(
|
|
session_id="session-123",
|
|
teacher_id="teacher-A"
|
|
)
|
|
|
|
assert result is None
|
|
|
|
def test_update_document_ocr_changes_status(self, repo, mock_db):
|
|
"""Updating OCR results should update document status."""
|
|
mock_doc = MagicMock(spec=PseudonymizedDocument)
|
|
mock_doc.status = DocumentStatus.UPLOADED
|
|
|
|
# Mock get_document
|
|
with patch.object(repo, 'get_document', return_value=mock_doc):
|
|
result = repo.update_document_ocr(
|
|
doc_token="doc-123",
|
|
teacher_id="teacher-A",
|
|
ocr_text="Student answer text",
|
|
confidence=95
|
|
)
|
|
|
|
assert mock_doc.ocr_text == "Student answer text"
|
|
assert mock_doc.ocr_confidence == 95
|
|
assert mock_doc.status == DocumentStatus.OCR_COMPLETED
|
|
|
|
|
|
class TestDataRetention:
|
|
"""Tests for data retention and cleanup."""
|
|
|
|
def test_cleanup_expired_sessions(self, repo, mock_db):
|
|
"""Cleanup should mark expired sessions as deleted."""
|
|
# Create expired session
|
|
expired_session = MagicMock(spec=ExamSession)
|
|
expired_session.retention_until = datetime.utcnow() - timedelta(days=1)
|
|
expired_session.status = SessionStatus.COMPLETED
|
|
expired_session.encrypted_identity_map = b"encrypted_data"
|
|
|
|
mock_query = MagicMock()
|
|
mock_db.query.return_value = mock_query
|
|
mock_query.filter.return_value = mock_query
|
|
mock_query.all.return_value = [expired_session]
|
|
|
|
count = repo.cleanup_expired_sessions()
|
|
|
|
assert count == 1
|
|
assert expired_session.status == SessionStatus.DELETED
|
|
# Identity map should be cleared
|
|
assert expired_session.encrypted_identity_map is None
|
|
|
|
|
|
class TestStatistics:
|
|
"""Tests for anonymized statistics."""
|
|
|
|
def test_get_session_stats_returns_anonymized_data(self, repo, mock_db):
|
|
"""Statistics should not contain any PII."""
|
|
mock_session = MagicMock(spec=ExamSession)
|
|
mock_session.document_count = 25
|
|
mock_session.processed_count = 20
|
|
|
|
mock_query = MagicMock()
|
|
mock_db.query.return_value = mock_query
|
|
mock_query.filter.return_value = mock_query
|
|
# first() is called twice: once for status counts and once for score stats
|
|
# Return a tuple for score_stats that can be subscripted
|
|
mock_query.first.return_value = (85.0, 60, 100) # avg, min, max scores
|
|
mock_query.group_by.return_value = mock_query
|
|
mock_query.all.return_value = []
|
|
|
|
with patch.object(repo, 'get_session', return_value=mock_session):
|
|
stats = repo.get_session_stats("session-123", "teacher-A")
|
|
|
|
# Stats should contain only aggregate data, no PII
|
|
assert "session_id" in stats
|
|
assert "total_documents" in stats
|
|
# Should NOT contain student names or tokens
|
|
assert "student_names" not in stats
|
|
assert "doc_tokens" not in stats
|