""" 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"])