""" Unit Tests for BYOEH (Bring-Your-Own-Expectation-Horizon) Module Tests cover: - EH upload and storage - Key sharing system - Invitation flow (Invite, Accept, Decline, Revoke) - Klausur linking - RAG query functionality - Audit logging """ import pytest import json import uuid import hashlib from datetime import datetime, timezone, timedelta from unittest.mock import AsyncMock, MagicMock, patch from fastapi.testclient import TestClient # Import the main app and data structures (post-refactoring modular imports) import sys sys.path.insert(0, '..') from main import app from storage import ( eh_db, eh_key_shares_db, eh_klausur_links_db, eh_audit_db, eh_invitations_db, klausuren_db, ) from models.eh import ( Erwartungshorizont, EHKeyShare, EHKlausurLink, EHShareInvitation, ) from models.exam import Klausur from models.enums import KlausurModus # ============================================= # FIXTURES # ============================================= @pytest.fixture def client(): """Test client for FastAPI app.""" return TestClient(app) @pytest.fixture def auth_headers(): """JWT auth headers for teacher user.""" import jwt token = jwt.encode( { "user_id": "test-teacher-001", "email": "teacher@school.de", "role": "admin", "tenant_id": "school-001" }, "your-super-secret-jwt-key-change-in-production", algorithm="HS256" ) return {"Authorization": f"Bearer {token}"} @pytest.fixture def second_examiner_headers(): """JWT auth headers for second examiner (non-admin teacher).""" import jwt token = jwt.encode( { "user_id": "test-examiner-002", "email": "examiner2@school.de", "role": "teacher", # Non-admin to test access control "tenant_id": "school-001" }, "your-super-secret-jwt-key-change-in-production", algorithm="HS256" ) return {"Authorization": f"Bearer {token}"} @pytest.fixture def sample_eh(): """Create a sample Erwartungshorizont.""" eh_id = str(uuid.uuid4()) eh = Erwartungshorizont( id=eh_id, tenant_id="school-001", teacher_id="test-teacher-001", title="Deutsch LK Abitur 2025", subject="deutsch", niveau="eA", year=2025, aufgaben_nummer="Aufgabe 1", encryption_key_hash="abc123" + "0" * 58, # 64 char hash salt="def456" * 5 + "0" * 2, # 32 char salt encrypted_file_path=f"/app/eh-uploads/school-001/{eh_id}/encrypted.bin", file_size_bytes=1024000, original_filename="erwartungshorizont.pdf", rights_confirmed=True, rights_confirmed_at=datetime.now(timezone.utc), status="indexed", chunk_count=10, indexed_at=datetime.now(timezone.utc), error_message=None, training_allowed=False, created_at=datetime.now(timezone.utc), deleted_at=None ) eh_db[eh_id] = eh return eh @pytest.fixture def sample_klausur(): """Create a sample Klausur.""" klausur_id = str(uuid.uuid4()) klausur = Klausur( id=klausur_id, title="Deutsch LK Q1", subject="deutsch", modus=KlausurModus.VORABITUR, class_id="class-001", year=2025, semester="Q1", erwartungshorizont=None, students=[], created_at=datetime.now(timezone.utc), teacher_id="test-teacher-001" ) klausuren_db[klausur_id] = klausur return klausur @pytest.fixture(autouse=True) def cleanup(): """Clean up databases after each test.""" yield eh_db.clear() eh_key_shares_db.clear() eh_klausur_links_db.clear() eh_audit_db.clear() eh_invitations_db.clear() klausuren_db.clear() @pytest.fixture def sample_invitation(sample_eh): """Create a sample invitation.""" invitation_id = str(uuid.uuid4()) invitation = EHShareInvitation( id=invitation_id, eh_id=sample_eh.id, inviter_id="test-teacher-001", invitee_id="", invitee_email="examiner2@school.de", role="second_examiner", klausur_id=None, message="Bitte EH fuer Zweitkorrektur nutzen", status="pending", expires_at=datetime.now(timezone.utc) + timedelta(days=14), created_at=datetime.now(timezone.utc), accepted_at=None, declined_at=None ) eh_invitations_db[invitation_id] = invitation return invitation # ============================================= # EH CRUD TESTS # ============================================= class TestEHList: """Tests for GET /api/v1/eh""" def test_list_empty(self, client, auth_headers): """List returns empty when no EH exist.""" response = client.get("/api/v1/eh", headers=auth_headers) assert response.status_code == 200 assert response.json() == [] def test_list_own_eh(self, client, auth_headers, sample_eh): """List returns only user's own EH.""" response = client.get("/api/v1/eh", headers=auth_headers) assert response.status_code == 200 data = response.json() assert len(data) == 1 assert data[0]["id"] == sample_eh.id assert data[0]["title"] == sample_eh.title def test_list_filter_by_subject(self, client, auth_headers, sample_eh): """List can filter by subject.""" response = client.get("/api/v1/eh?subject=deutsch", headers=auth_headers) assert response.status_code == 200 assert len(response.json()) == 1 response = client.get("/api/v1/eh?subject=englisch", headers=auth_headers) assert response.status_code == 200 assert len(response.json()) == 0 def test_list_filter_by_year(self, client, auth_headers, sample_eh): """List can filter by year.""" response = client.get("/api/v1/eh?year=2025", headers=auth_headers) assert response.status_code == 200 assert len(response.json()) == 1 response = client.get("/api/v1/eh?year=2024", headers=auth_headers) assert response.status_code == 200 assert len(response.json()) == 0 class TestEHGet: """Tests for GET /api/v1/eh/{id}""" def test_get_existing_eh(self, client, auth_headers, sample_eh): """Get returns EH details.""" response = client.get(f"/api/v1/eh/{sample_eh.id}", headers=auth_headers) assert response.status_code == 200 data = response.json() assert data["id"] == sample_eh.id assert data["title"] == sample_eh.title assert data["subject"] == sample_eh.subject def test_get_nonexistent_eh(self, client, auth_headers): """Get returns 404 for non-existent EH.""" response = client.get(f"/api/v1/eh/{uuid.uuid4()}", headers=auth_headers) assert response.status_code == 404 class TestEHDelete: """Tests for DELETE /api/v1/eh/{id}""" def test_delete_own_eh(self, client, auth_headers, sample_eh): """Owner can delete their EH.""" response = client.delete(f"/api/v1/eh/{sample_eh.id}", headers=auth_headers) assert response.status_code == 200 assert response.json()["status"] == "deleted" # Verify soft delete assert eh_db[sample_eh.id].deleted_at is not None def test_delete_others_eh(self, client, second_examiner_headers, sample_eh): """Non-owner cannot delete EH.""" response = client.delete(f"/api/v1/eh/{sample_eh.id}", headers=second_examiner_headers) assert response.status_code == 403 def test_delete_nonexistent_eh(self, client, auth_headers): """Delete returns 404 for non-existent EH.""" response = client.delete(f"/api/v1/eh/{uuid.uuid4()}", headers=auth_headers) assert response.status_code == 404 # ============================================= # KEY SHARING TESTS # ============================================= class TestEHSharing: """Tests for EH key sharing system.""" def test_share_eh_with_examiner(self, client, auth_headers, sample_eh): """Owner can share EH with another examiner.""" response = client.post( f"/api/v1/eh/{sample_eh.id}/share", headers=auth_headers, json={ "user_id": "test-examiner-002", "role": "second_examiner", "encrypted_passphrase": "encrypted-secret-123", "passphrase_hint": "Das uebliche Passwort" } ) assert response.status_code == 200 data = response.json() assert data["status"] == "shared" assert data["shared_with"] == "test-examiner-002" assert data["role"] == "second_examiner" def test_share_invalid_role(self, client, auth_headers, sample_eh): """Sharing with invalid role fails.""" response = client.post( f"/api/v1/eh/{sample_eh.id}/share", headers=auth_headers, json={ "user_id": "test-examiner-002", "role": "invalid_role", "encrypted_passphrase": "encrypted-secret-123" } ) assert response.status_code == 400 def test_share_others_eh_fails(self, client, second_examiner_headers, sample_eh): """Non-owner cannot share EH.""" response = client.post( f"/api/v1/eh/{sample_eh.id}/share", headers=second_examiner_headers, json={ "user_id": "test-examiner-003", "role": "third_examiner", "encrypted_passphrase": "encrypted-secret-123" } ) assert response.status_code == 403 def test_list_shares(self, client, auth_headers, sample_eh): """Owner can list shares.""" # Create a share first share = EHKeyShare( id=str(uuid.uuid4()), eh_id=sample_eh.id, user_id="test-examiner-002", encrypted_passphrase="encrypted", passphrase_hint="hint", granted_by="test-teacher-001", granted_at=datetime.now(timezone.utc), role="second_examiner", klausur_id=None, active=True ) eh_key_shares_db[sample_eh.id] = [share] response = client.get(f"/api/v1/eh/{sample_eh.id}/shares", headers=auth_headers) assert response.status_code == 200 data = response.json() assert len(data) == 1 assert data[0]["user_id"] == "test-examiner-002" def test_revoke_share(self, client, auth_headers, sample_eh): """Owner can revoke a share.""" share_id = str(uuid.uuid4()) share = EHKeyShare( id=share_id, eh_id=sample_eh.id, user_id="test-examiner-002", encrypted_passphrase="encrypted", passphrase_hint="hint", granted_by="test-teacher-001", granted_at=datetime.now(timezone.utc), role="second_examiner", klausur_id=None, active=True ) eh_key_shares_db[sample_eh.id] = [share] response = client.delete( f"/api/v1/eh/{sample_eh.id}/shares/{share_id}", headers=auth_headers ) assert response.status_code == 200 assert response.json()["status"] == "revoked" # Verify share is inactive assert not eh_key_shares_db[sample_eh.id][0].active def test_get_shared_with_me(self, client, second_examiner_headers, sample_eh): """User can see EH shared with them.""" share = EHKeyShare( id=str(uuid.uuid4()), eh_id=sample_eh.id, user_id="test-examiner-002", encrypted_passphrase="encrypted", passphrase_hint="hint", granted_by="test-teacher-001", granted_at=datetime.now(timezone.utc), role="second_examiner", klausur_id=None, active=True ) eh_key_shares_db[sample_eh.id] = [share] response = client.get("/api/v1/eh/shared-with-me", headers=second_examiner_headers) assert response.status_code == 200 data = response.json() assert len(data) == 1 assert data[0]["eh"]["id"] == sample_eh.id # ============================================= # KLAUSUR LINKING TESTS # ============================================= class TestEHKlausurLinking: """Tests for EH-Klausur linking.""" def test_link_eh_to_klausur(self, client, auth_headers, sample_eh, sample_klausur): """Owner can link EH to Klausur.""" response = client.post( f"/api/v1/eh/{sample_eh.id}/link-klausur", headers=auth_headers, json={"klausur_id": sample_klausur.id} ) assert response.status_code == 200 data = response.json() assert data["status"] == "linked" assert data["eh_id"] == sample_eh.id assert data["klausur_id"] == sample_klausur.id def test_get_linked_eh(self, client, auth_headers, sample_eh, sample_klausur): """Get linked EH for a Klausur.""" link = EHKlausurLink( id=str(uuid.uuid4()), eh_id=sample_eh.id, klausur_id=sample_klausur.id, linked_by="test-teacher-001", linked_at=datetime.now(timezone.utc) ) eh_klausur_links_db[sample_klausur.id] = [link] response = client.get( f"/api/v1/klausuren/{sample_klausur.id}/linked-eh", headers=auth_headers ) assert response.status_code == 200 data = response.json() assert len(data) == 1 assert data[0]["eh"]["id"] == sample_eh.id assert data[0]["is_owner"] is True def test_unlink_eh_from_klausur(self, client, auth_headers, sample_eh, sample_klausur): """Owner can unlink EH from Klausur.""" link = EHKlausurLink( id=str(uuid.uuid4()), eh_id=sample_eh.id, klausur_id=sample_klausur.id, linked_by="test-teacher-001", linked_at=datetime.now(timezone.utc) ) eh_klausur_links_db[sample_klausur.id] = [link] response = client.delete( f"/api/v1/eh/{sample_eh.id}/link-klausur/{sample_klausur.id}", headers=auth_headers ) assert response.status_code == 200 assert response.json()["status"] == "unlinked" # ============================================= # AUDIT LOG TESTS # ============================================= class TestAuditLog: """Tests for audit logging.""" def test_audit_log_on_share(self, client, auth_headers, sample_eh): """Sharing creates audit log entry.""" initial_count = len(eh_audit_db) client.post( f"/api/v1/eh/{sample_eh.id}/share", headers=auth_headers, json={ "user_id": "test-examiner-002", "role": "second_examiner", "encrypted_passphrase": "encrypted-secret-123" } ) assert len(eh_audit_db) > initial_count latest = eh_audit_db[-1] assert latest.action == "share" assert latest.eh_id == sample_eh.id def test_get_audit_log(self, client, auth_headers, sample_eh): """Can retrieve audit log.""" # Create some audit entries by sharing client.post( f"/api/v1/eh/{sample_eh.id}/share", headers=auth_headers, json={ "user_id": "test-examiner-002", "role": "second_examiner", "encrypted_passphrase": "encrypted-secret-123" } ) response = client.get( f"/api/v1/eh/audit-log?eh_id={sample_eh.id}", headers=auth_headers ) assert response.status_code == 200 data = response.json() assert len(data) > 0 # ============================================= # RIGHTS TEXT TESTS # ============================================= class TestRightsText: """Tests for rights confirmation text.""" def test_get_rights_text(self, client, auth_headers): """Can retrieve rights confirmation text.""" response = client.get("/api/v1/eh/rights-text", headers=auth_headers) assert response.status_code == 200 data = response.json() assert "text" in data assert "version" in data assert "Urheberrecht" in data["text"] # ============================================= # ENCRYPTION SERVICE TESTS # ============================================= class TestEncryptionUtils: """Tests for encryption utilities (eh_pipeline.py).""" def test_hash_key(self): """Key hashing produces consistent results.""" from eh_pipeline import hash_key import os passphrase = "test-secret-passphrase" salt_hex = os.urandom(16).hex() hash1 = hash_key(passphrase, salt_hex) hash2 = hash_key(passphrase, salt_hex) assert hash1 == hash2 assert len(hash1) == 64 # SHA-256 hex def test_verify_key_hash(self): """Key hash verification works correctly.""" from eh_pipeline import hash_key, verify_key_hash import os passphrase = "test-secret-passphrase" salt_hex = os.urandom(16).hex() key_hash = hash_key(passphrase, salt_hex) assert verify_key_hash(passphrase, salt_hex, key_hash) is True assert verify_key_hash("wrong-passphrase", salt_hex, key_hash) is False def test_chunk_text(self): """Text chunking produces correct overlap.""" from eh_pipeline import chunk_text text = "A" * 2000 # 2000 characters chunks = chunk_text(text, chunk_size=1000, overlap=200) assert len(chunks) >= 2 # Check overlap assert chunks[0][-200:] == chunks[1][:200] def test_encrypt_decrypt_text(self): """Text encryption and decryption round-trip.""" from eh_pipeline import encrypt_text, decrypt_text plaintext = "Dies ist ein geheimer Text." passphrase = "geheim123" salt = "a" * 32 # 32 hex chars = 16 bytes encrypted = encrypt_text(plaintext, passphrase, salt) decrypted = decrypt_text(encrypted, passphrase, salt) assert decrypted == plaintext # ============================================= # INVITATION FLOW TESTS # ============================================= class TestEHInvitationFlow: """Tests for the Invite/Accept/Decline/Revoke workflow.""" def test_invite_to_eh(self, client, auth_headers, sample_eh): """Owner can send invitation to share EH.""" response = client.post( f"/api/v1/eh/{sample_eh.id}/invite", headers=auth_headers, json={ "invitee_email": "zweitkorrektor@school.de", "role": "second_examiner", "message": "Bitte EH fuer Zweitkorrektur nutzen", "expires_in_days": 14 } ) assert response.status_code == 200 data = response.json() assert data["status"] == "invited" assert data["invitee_email"] == "zweitkorrektor@school.de" assert data["role"] == "second_examiner" assert "invitation_id" in data assert "expires_at" in data def test_invite_invalid_role(self, client, auth_headers, sample_eh): """Invitation with invalid role fails.""" response = client.post( f"/api/v1/eh/{sample_eh.id}/invite", headers=auth_headers, json={ "invitee_email": "zweitkorrektor@school.de", "role": "invalid_role" } ) assert response.status_code == 400 def test_invite_by_non_owner_fails(self, client, second_examiner_headers, sample_eh): """Non-owner cannot send invitation.""" response = client.post( f"/api/v1/eh/{sample_eh.id}/invite", headers=second_examiner_headers, json={ "invitee_email": "drittkorrektor@school.de", "role": "third_examiner" } ) assert response.status_code == 403 def test_duplicate_pending_invitation_fails(self, client, auth_headers, sample_eh, sample_invitation): """Cannot send duplicate pending invitation to same user.""" response = client.post( f"/api/v1/eh/{sample_eh.id}/invite", headers=auth_headers, json={ "invitee_email": "examiner2@school.de", # Same email as sample_invitation "role": "second_examiner" } ) assert response.status_code == 409 # Conflict def test_list_pending_invitations(self, client, second_examiner_headers, sample_eh, sample_invitation): """User can see pending invitations addressed to them.""" response = client.get("/api/v1/eh/invitations/pending", headers=second_examiner_headers) assert response.status_code == 200 data = response.json() assert len(data) == 1 assert data[0]["invitation"]["id"] == sample_invitation.id assert data[0]["eh"]["title"] == sample_eh.title def test_list_sent_invitations(self, client, auth_headers, sample_eh, sample_invitation): """Inviter can see sent invitations.""" response = client.get("/api/v1/eh/invitations/sent", headers=auth_headers) assert response.status_code == 200 data = response.json() assert len(data) == 1 assert data[0]["invitation"]["id"] == sample_invitation.id def test_accept_invitation(self, client, second_examiner_headers, sample_eh, sample_invitation): """Invitee can accept invitation and get access.""" response = client.post( f"/api/v1/eh/invitations/{sample_invitation.id}/accept", headers=second_examiner_headers, json={"encrypted_passphrase": "encrypted-secret-key-for-zk"} ) assert response.status_code == 200 data = response.json() assert data["status"] == "accepted" assert data["eh_id"] == sample_eh.id assert "share_id" in data # Verify invitation status updated assert eh_invitations_db[sample_invitation.id].status == "accepted" # Verify key share created assert sample_eh.id in eh_key_shares_db assert len(eh_key_shares_db[sample_eh.id]) == 1 def test_accept_invitation_wrong_user(self, client, auth_headers, sample_eh, sample_invitation): """Only invitee can accept invitation.""" # auth_headers is for teacher, not the invitee response = client.post( f"/api/v1/eh/invitations/{sample_invitation.id}/accept", headers=auth_headers, json={"encrypted_passphrase": "encrypted-secret"} ) assert response.status_code == 403 def test_accept_expired_invitation(self, client, second_examiner_headers, sample_eh): """Cannot accept expired invitation.""" # Create expired invitation invitation_id = str(uuid.uuid4()) expired_invitation = EHShareInvitation( id=invitation_id, eh_id=sample_eh.id, inviter_id="test-teacher-001", invitee_id="", invitee_email="examiner2@school.de", role="second_examiner", klausur_id=None, message=None, status="pending", expires_at=datetime.now(timezone.utc) - timedelta(days=1), # Expired created_at=datetime.now(timezone.utc) - timedelta(days=15), accepted_at=None, declined_at=None ) eh_invitations_db[invitation_id] = expired_invitation response = client.post( f"/api/v1/eh/invitations/{invitation_id}/accept", headers=second_examiner_headers, json={"encrypted_passphrase": "encrypted-secret"} ) assert response.status_code == 400 assert "expired" in response.json()["detail"].lower() def test_decline_invitation(self, client, second_examiner_headers, sample_invitation): """Invitee can decline invitation.""" response = client.post( f"/api/v1/eh/invitations/{sample_invitation.id}/decline", headers=second_examiner_headers ) assert response.status_code == 200 data = response.json() assert data["status"] == "declined" # Verify status updated assert eh_invitations_db[sample_invitation.id].status == "declined" def test_decline_invitation_wrong_user(self, client, auth_headers, sample_invitation): """Only invitee can decline invitation.""" response = client.post( f"/api/v1/eh/invitations/{sample_invitation.id}/decline", headers=auth_headers ) assert response.status_code == 403 def test_revoke_invitation(self, client, auth_headers, sample_invitation): """Inviter can revoke pending invitation.""" response = client.delete( f"/api/v1/eh/invitations/{sample_invitation.id}", headers=auth_headers ) assert response.status_code == 200 data = response.json() assert data["status"] == "revoked" # Verify status updated assert eh_invitations_db[sample_invitation.id].status == "revoked" def test_revoke_invitation_wrong_user(self, client, second_examiner_headers, sample_invitation): """Only inviter can revoke invitation.""" response = client.delete( f"/api/v1/eh/invitations/{sample_invitation.id}", headers=second_examiner_headers ) assert response.status_code == 403 def test_revoke_non_pending_invitation(self, client, auth_headers, sample_eh): """Cannot revoke already accepted invitation.""" invitation_id = str(uuid.uuid4()) accepted_invitation = EHShareInvitation( id=invitation_id, eh_id=sample_eh.id, inviter_id="test-teacher-001", invitee_id="test-examiner-002", invitee_email="examiner2@school.de", role="second_examiner", klausur_id=None, message=None, status="accepted", expires_at=datetime.now(timezone.utc) + timedelta(days=14), created_at=datetime.now(timezone.utc), accepted_at=datetime.now(timezone.utc), declined_at=None ) eh_invitations_db[invitation_id] = accepted_invitation response = client.delete( f"/api/v1/eh/invitations/{invitation_id}", headers=auth_headers ) assert response.status_code == 400 def test_get_access_chain(self, client, auth_headers, sample_eh, sample_invitation): """Owner can see complete access chain.""" # Add a key share share = EHKeyShare( id=str(uuid.uuid4()), eh_id=sample_eh.id, user_id="test-examiner-003", encrypted_passphrase="encrypted", passphrase_hint="", granted_by="test-teacher-001", granted_at=datetime.now(timezone.utc), role="third_examiner", klausur_id=None, active=True ) eh_key_shares_db[sample_eh.id] = [share] response = client.get(f"/api/v1/eh/{sample_eh.id}/access-chain", headers=auth_headers) assert response.status_code == 200 data = response.json() assert data["eh_id"] == sample_eh.id assert data["owner"]["user_id"] == "test-teacher-001" assert len(data["active_shares"]) == 1 assert len(data["pending_invitations"]) == 1 # sample_invitation class TestInvitationWorkflow: """Integration tests for complete invitation workflow.""" def test_complete_invite_accept_workflow( self, client, auth_headers, second_examiner_headers, sample_eh, sample_klausur ): """Test complete workflow: invite -> accept -> access.""" # 1. Owner invites ZK response = client.post( f"/api/v1/eh/{sample_eh.id}/invite", headers=auth_headers, json={ "invitee_email": "examiner2@school.de", "role": "second_examiner", "klausur_id": sample_klausur.id, "message": "Bitte fuer Zweitkorrektur nutzen" } ) assert response.status_code == 200 invitation_id = response.json()["invitation_id"] # 2. ZK sees pending invitation response = client.get("/api/v1/eh/invitations/pending", headers=second_examiner_headers) assert response.status_code == 200 pending = response.json() assert len(pending) == 1 assert pending[0]["invitation"]["id"] == invitation_id # 3. ZK accepts invitation response = client.post( f"/api/v1/eh/invitations/{invitation_id}/accept", headers=second_examiner_headers, json={"encrypted_passphrase": "encrypted-key-for-zk"} ) assert response.status_code == 200 # 4. ZK can now see EH in shared list response = client.get("/api/v1/eh/shared-with-me", headers=second_examiner_headers) assert response.status_code == 200 shared = response.json() assert len(shared) == 1 assert shared[0]["eh"]["id"] == sample_eh.id # 5. EK sees invitation as accepted response = client.get("/api/v1/eh/invitations/sent", headers=auth_headers) assert response.status_code == 200 sent = response.json() assert len(sent) == 1 assert sent[0]["invitation"]["status"] == "accepted" def test_invite_decline_reinvite_workflow( self, client, auth_headers, second_examiner_headers, sample_eh ): """Test workflow: invite -> decline -> re-invite.""" # 1. Owner invites ZK response = client.post( f"/api/v1/eh/{sample_eh.id}/invite", headers=auth_headers, json={ "invitee_email": "examiner2@school.de", "role": "second_examiner" } ) assert response.status_code == 200 invitation_id = response.json()["invitation_id"] # 2. ZK declines response = client.post( f"/api/v1/eh/invitations/{invitation_id}/decline", headers=second_examiner_headers ) assert response.status_code == 200 # 3. Owner can send new invitation (declined invitation doesn't block) response = client.post( f"/api/v1/eh/{sample_eh.id}/invite", headers=auth_headers, json={ "invitee_email": "examiner2@school.de", "role": "second_examiner", "message": "Zweiter Versuch" } ) assert response.status_code == 200 # New invitation allowed # ============================================= # INTEGRATION TESTS # ============================================= class TestEHWorkflow: """Integration tests for complete EH workflow.""" def test_complete_sharing_workflow( self, client, auth_headers, second_examiner_headers, sample_eh, sample_klausur ): """Test complete workflow: upload -> link -> share -> access.""" # 1. Link EH to Klausur response = client.post( f"/api/v1/eh/{sample_eh.id}/link-klausur", headers=auth_headers, json={"klausur_id": sample_klausur.id} ) assert response.status_code == 200 # 2. Share with second examiner response = client.post( f"/api/v1/eh/{sample_eh.id}/share", headers=auth_headers, json={ "user_id": "test-examiner-002", "role": "second_examiner", "encrypted_passphrase": "encrypted-secret", "klausur_id": sample_klausur.id } ) assert response.status_code == 200 # 3. Second examiner can see shared EH response = client.get("/api/v1/eh/shared-with-me", headers=second_examiner_headers) assert response.status_code == 200 shared = response.json() assert len(shared) == 1 assert shared[0]["eh"]["id"] == sample_eh.id # 4. Second examiner can see linked EH for Klausur response = client.get( f"/api/v1/klausuren/{sample_klausur.id}/linked-eh", headers=second_examiner_headers ) assert response.status_code == 200 linked = response.json() assert len(linked) == 1 assert linked[0]["is_owner"] is False assert linked[0]["share"] is not None if __name__ == "__main__": pytest.main([__file__, "-v"])