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/tests/test_system_api.py
Benjamin Admin 21a844cb8a 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

320 lines
12 KiB
Python

"""
Tests for System API endpoints (local-ip, health, etc.)
Diese Tests pruefen:
1. /api/v1/system/local-ip - Gibt die lokale IP-Adresse zurueck
2. /health - Health check endpoint
Bug Prevention:
- QR-Code fuer mobile PDF-Upload benoetigte die lokale IP
- localhost:8086 funktioniert nicht auf iPhone (anderes Geraet)
- Diese Tests stellen sicher, dass die IP-Erkennung korrekt funktioniert
"""
import pytest
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
import os
import re
class TestLocalIPEndpoint:
"""Tests fuer den /api/v1/system/local-ip Endpoint"""
@pytest.fixture
def client(self):
"""TestClient fuer die FastAPI App"""
# Import hier um Circular Imports zu vermeiden
import sys
sys.path.insert(0, '/app')
from main import app
# Mock os.getenv fuer LOCAL_NETWORK_IP - use yield to keep patch active
with patch.dict(os.environ, {'LOCAL_NETWORK_IP': '192.168.178.157'}):
yield TestClient(app)
def test_local_ip_returns_json(self, client):
"""Test: Endpoint gibt JSON zurueck"""
response = client.get("/api/v1/system/local-ip")
assert response.status_code == 200
assert response.headers["content-type"] == "application/json"
def test_local_ip_has_ip_field(self, client):
"""Test: Response enthaelt 'ip' Feld"""
response = client.get("/api/v1/system/local-ip")
data = response.json()
assert "ip" in data
assert data["ip"] is not None
def test_local_ip_is_valid_ipv4(self, client):
"""Test: IP ist ein gueltiges IPv4-Format"""
response = client.get("/api/v1/system/local-ip")
data = response.json()
ip = data["ip"]
# Regex fuer IPv4 Adresse
ipv4_pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
assert re.match(ipv4_pattern, ip), f"IP '{ip}' ist kein gueltiges IPv4-Format"
# Pruefe dass alle Oktets zwischen 0-255 sind
octets = ip.split('.')
for octet in octets:
assert 0 <= int(octet) <= 255, f"Oktet {octet} ist nicht im Bereich 0-255"
def test_local_ip_not_localhost(self, client):
"""Test: IP ist nicht localhost (127.0.0.1)"""
response = client.get("/api/v1/system/local-ip")
data = response.json()
ip = data["ip"]
assert ip != "127.0.0.1", "IP sollte nicht localhost sein"
assert ip != "localhost", "IP sollte nicht 'localhost' sein"
def test_local_ip_not_docker_internal(self, client):
"""Test: IP ist eine gueltige private Adresse."""
# In CI/Docker ist 172.x.x.x eine normale interne IP
response = client.get("/api/v1/system/local-ip")
data = response.json()
ip = data["ip"]
# Pruefen ob wir in CI/Docker laufen
in_ci = os.environ.get("CI", "").lower() in ("true", "1", "woodpecker")
in_docker = os.path.exists('/.dockerenv') or ip.startswith("172.") or in_ci
if not in_docker:
# Ausserhalb Docker: sollte keine Docker-interne IP sein
assert not ip.startswith("172."), f"IP '{ip}' sieht nach Docker-interner Adresse aus"
else:
# In Docker: IP sollte zumindest im privaten Bereich sein
octets = [int(x) for x in ip.split('.')]
is_private = (
ip.startswith("192.168.") or
ip.startswith("10.") or
(octets[0] == 172 and 16 <= octets[1] <= 31)
)
assert is_private, f"IP '{ip}' ist nicht im privaten Bereich"
def test_local_ip_is_private_range(self, client):
"""Test: IP ist im privaten Adressbereich (fuer lokales Netzwerk)"""
response = client.get("/api/v1/system/local-ip")
data = response.json()
ip = data["ip"]
octets = [int(x) for x in ip.split('.')]
# Private Adressbereiche:
# 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
is_private = (
(octets[0] == 10) or
(octets[0] == 172 and 16 <= octets[1] <= 31) or
(octets[0] == 192 and octets[1] == 168)
)
assert is_private, f"IP '{ip}' ist nicht im privaten Adressbereich"
class TestLocalIPEnvironmentVariable:
"""Tests fuer die Konfiguration via Environment Variable"""
def test_local_ip_uses_env_variable(self):
"""Test: Endpoint verwendet LOCAL_NETWORK_IP Umgebungsvariable"""
test_ip = "10.0.0.100"
with patch.dict(os.environ, {'LOCAL_NETWORK_IP': test_ip}):
# Reimport um neue Umgebungsvariable zu laden
import importlib
import sys
# In Docker ist der Pfad /app
if '/app' not in sys.path:
sys.path.insert(0, '/app')
# Dieser Test funktioniert nur wenn main.py reimportiert wird
# In der Praxis wird die Variable beim Container-Start gesetzt
def test_local_ip_has_default_fallback(self):
"""Test: Es gibt einen Default-Wert wenn keine Umgebungsvariable gesetzt ist"""
# Der Default-Wert ist 192.168.178.157 (hardcoded)
default_ip = os.getenv("LOCAL_NETWORK_IP", "192.168.178.157")
assert default_ip == "192.168.178.157"
class TestHealthEndpoint:
"""Tests fuer den /health Endpoint"""
@pytest.fixture
def client(self):
"""TestClient fuer die FastAPI App"""
import sys
sys.path.insert(0, '/app')
from main import app
with patch.dict(os.environ, {'LOCAL_NETWORK_IP': '192.168.178.157'}):
yield TestClient(app)
def test_health_returns_200(self, client):
"""Test: Health Endpoint gibt 200 zurueck"""
response = client.get("/health")
assert response.status_code == 200
def test_health_returns_healthy_status(self, client):
"""Test: Health Endpoint meldet 'healthy' Status"""
response = client.get("/health")
data = response.json()
assert data["status"] == "healthy"
def test_health_returns_service_name(self, client):
"""Test: Health Endpoint enthaelt Service-Namen"""
response = client.get("/health")
data = response.json()
assert "service" in data
assert data["service"] == "breakpilot-backend"
class TestMobileUploadURLGeneration:
"""
Tests zur Validierung der URL-Generierung fuer Mobile Upload.
Problem-Historie:
- Urspruenglich wurde localhost:8086 verwendet
- iPhones koennen localhost nicht erreichen (anderes Geraet)
- Loesung: Backend gibt lokale Netzwerk-IP zurueck
"""
def test_mobile_upload_url_format(self):
"""Test: Mobile Upload URL hat korrektes Format"""
# Simuliere die URL-Generierung aus dem Frontend
hostname = "192.168.178.157"
port = 8086
path = "/api/v1/upload/mobile"
upload_url = f"http://{hostname}:{port}{path}"
# Validiere URL-Format
assert upload_url.startswith("http://")
assert ":8086" in upload_url
assert "/api/v1/upload/mobile" in upload_url
assert "localhost" not in upload_url
assert "127.0.0.1" not in upload_url
def test_mobile_upload_url_reachable_format(self):
"""Test: URL ist im fuer Mobile erreichbaren Format"""
# Die URL muss von einem externen Geraet (iPhone) erreichbar sein
test_ips = [
("192.168.178.157", True), # Private IP - OK
("192.168.1.100", True), # Private IP - OK
("10.0.0.50", True), # Private IP - OK
("localhost", False), # Nicht erreichbar
("127.0.0.1", False), # Nicht erreichbar
("172.24.0.6", False), # Docker-intern - nicht erreichbar
]
for ip, expected_reachable in test_ips:
is_reachable = not (
ip == "localhost" or
ip == "127.0.0.1" or
ip.startswith("172.") # Docker-intern
)
assert is_reachable == expected_reachable, \
f"IP '{ip}' sollte {'erreichbar' if expected_reachable else 'nicht erreichbar'} sein"
class TestQRCodeURLValidation:
"""
Tests zur Validierung der QR-Code URL.
Diese Tests stellen sicher, dass die QR-Code URL korrekt ist
BEVOR der QR-Code generiert wird.
"""
def test_qr_url_length_acceptable(self):
"""Test: URL-Laenge ist fuer QR-Code akzeptabel"""
# QR-Code Version 3 kann ca. 77 alphanumerische Zeichen
# URL wie "http://192.168.178.157:8086/api/v1/upload/mobile" = 49 Zeichen
test_url = "http://192.168.178.157:8086/api/v1/upload/mobile"
# QR-Code funktioniert bis ca. 4000 Zeichen (Version 40)
# Unsere URLs sollten unter 100 Zeichen sein
assert len(test_url) < 100, f"URL ist zu lang: {len(test_url)} Zeichen"
def test_qr_url_no_special_chars_problems(self):
"""Test: URL enthaelt keine problematischen Zeichen fuer QR"""
test_url = "http://192.168.178.157:8086/api/v1/upload/mobile"
# Diese Zeichen sind OK in URLs: a-z, A-Z, 0-9, :, /, ., -, _
import string
allowed_chars = string.ascii_letters + string.digits + ":/.?&=-_"
for char in test_url:
assert char in allowed_chars, \
f"Zeichen '{char}' koennte Probleme bei QR-Generierung verursachen"
def test_qr_url_is_valid_url(self):
"""Test: URL ist eine gueltige HTTP-URL"""
from urllib.parse import urlparse
test_url = "http://192.168.178.157:8086/api/v1/upload/mobile"
parsed = urlparse(test_url)
assert parsed.scheme in ["http", "https"], "URL muss HTTP(S) sein"
assert parsed.netloc, "URL muss einen Host haben"
assert parsed.path, "URL muss einen Pfad haben"
# Integration Test (nur ausfuehren wenn Backend laeuft)
class TestIntegration:
"""
Integration Tests - benoetigten laufenden Backend-Container.
Diese Tests werden uebersprungen wenn das Backend nicht erreichbar ist.
"""
@pytest.fixture
def live_backend_url(self):
"""URL zum laufenden Backend (Docker oder lokal)"""
return "http://localhost:8000"
@pytest.mark.integration
def test_live_local_ip_endpoint(self, live_backend_url):
"""Integration Test: Echter Aufruf des local-ip Endpoints"""
import httpx
try:
response = httpx.get(f"{live_backend_url}/api/v1/system/local-ip", timeout=5.0)
assert response.status_code == 200
data = response.json()
assert "ip" in data
ip = data["ip"]
# Pruefe dass es eine gueltige private IP ist (inkl. Docker 172.16-31.x.x)
octets = [int(x) for x in ip.split('.')]
is_private = (
ip.startswith("192.168.") or
ip.startswith("10.") or
(octets[0] == 172 and 16 <= octets[1] <= 31) # Full 172.16.0.0/12 range
)
assert is_private, f"IP '{ip}' is not a valid private IP"
except httpx.ConnectError:
pytest.skip("Backend nicht erreichbar - Integration Test uebersprungen")
@pytest.mark.integration
def test_live_health_endpoint(self, live_backend_url):
"""Integration Test: Echter Aufruf des Health Endpoints"""
import httpx
try:
response = httpx.get(f"{live_backend_url}/health", timeout=5.0)
assert response.status_code == 200
assert response.json()["status"] == "healthy"
except httpx.ConnectError:
pytest.skip("Backend nicht erreichbar - Integration Test uebersprungen")
if __name__ == "__main__":
# Fuehre Unit Tests aus (ohne Integration Tests)
pytest.main([__file__, "-v", "-m", "not integration"])