""" Tests fuer vast.ai Power Control API. Testet die FastAPI Endpoints fuer Start/Stop/Status. """ import json import pytest from unittest.mock import AsyncMock, patch, MagicMock from datetime import datetime, timezone from pathlib import Path import tempfile import os # Setze ENV vor jedem Import os.environ["VAST_API_KEY"] = "test-api-key" os.environ["VAST_INSTANCE_ID"] = "12345" os.environ["CONTROL_API_KEY"] = "test-control-key" from fastapi.testclient import TestClient from fastapi import FastAPI class TestVastState: """Tests fuer VastState Klasse.""" def test_load_empty_state(self): """Test leerer State wird erstellt.""" with tempfile.TemporaryDirectory() as tmpdir: state_path = Path(tmpdir) / "state.json" os.environ["VAST_STATE_PATH"] = str(state_path) # Importiere nach ENV-Setup from infra.vast_power import VastState state = VastState(path=state_path) assert state.get("desired_state") is None assert state.get("total_runtime_seconds") == 0 def test_set_and_get(self): """Test Wert setzen und lesen.""" with tempfile.TemporaryDirectory() as tmpdir: state_path = Path(tmpdir) / "state.json" from infra.vast_power import VastState state = VastState(path=state_path) state.set("desired_state", "RUNNING") assert state.get("desired_state") == "RUNNING" assert state_path.exists() def test_record_activity(self): """Test Aktivitaet aufzeichnen.""" with tempfile.TemporaryDirectory() as tmpdir: state_path = Path(tmpdir) / "state.json" from infra.vast_power import VastState state = VastState(path=state_path) state.record_activity() last = state.get_last_activity() assert last is not None assert isinstance(last, datetime) def test_record_start_stop_calculates_cost(self): """Test Start/Stop berechnet Kosten.""" with tempfile.TemporaryDirectory() as tmpdir: state_path = Path(tmpdir) / "state.json" from infra.vast_power import VastState state = VastState(path=state_path) # Simuliere Start state.record_start() assert state.get("desired_state") == "RUNNING" # Simuliere Stop mit Kosten ($0.50/h) state.record_stop(dph_total=0.5) assert state.get("desired_state") == "STOPPED" assert state.get("total_runtime_seconds") > 0 class TestAuditLog: """Tests fuer Audit Logging.""" def test_audit_log_writes(self): """Test Audit Log schreibt Eintraege.""" with tempfile.TemporaryDirectory() as tmpdir: audit_path = Path(tmpdir) / "audit.log" # Importiere und patche AUDIT_PATH direkt import infra.vast_power as vp original_path = vp.AUDIT_PATH vp.AUDIT_PATH = audit_path try: vp.audit_log("test_event", actor="test_user", meta={"key": "value"}) assert audit_path.exists() content = audit_path.read_text() entry = json.loads(content.strip()) assert entry["event"] == "test_event" assert entry["actor"] == "test_user" assert entry["meta"]["key"] == "value" finally: vp.AUDIT_PATH = original_path class TestPowerEndpointsAuth: """Tests fuer Authentifizierung der Power Endpoints.""" def test_require_control_key_no_key_configured(self): """Test Fehler wenn CONTROL_API_KEY nicht gesetzt.""" import infra.vast_power as vp from fastapi import HTTPException # Temporaer CONTROL_API_KEY leeren original = vp.CONTROL_API_KEY vp.CONTROL_API_KEY = None try: with pytest.raises(HTTPException) as exc_info: vp.require_control_key("any-key") assert exc_info.value.status_code == 500 assert "not configured" in str(exc_info.value.detail) finally: vp.CONTROL_API_KEY = original def test_require_control_key_wrong_key(self): """Test 401 bei falschem Key.""" import infra.vast_power as vp from fastapi import HTTPException # Setze gueltigen CONTROL_API_KEY original = vp.CONTROL_API_KEY vp.CONTROL_API_KEY = "correct-key" try: with pytest.raises(HTTPException) as exc_info: vp.require_control_key("wrong-key") assert exc_info.value.status_code == 401 finally: vp.CONTROL_API_KEY = original def test_require_control_key_valid(self): """Test kein Fehler bei korrektem Key.""" import infra.vast_power as vp # Setze gueltigen CONTROL_API_KEY original = vp.CONTROL_API_KEY vp.CONTROL_API_KEY = "my-secret-key" try: # Sollte keine Exception werfen result = vp.require_control_key("my-secret-key") assert result is None # Dependency gibt nichts zurueck finally: vp.CONTROL_API_KEY = original def test_require_control_key_none_provided(self): """Test 401 wenn kein Key im Header.""" import infra.vast_power as vp from fastapi import HTTPException original = vp.CONTROL_API_KEY vp.CONTROL_API_KEY = "valid-key" try: with pytest.raises(HTTPException) as exc_info: vp.require_control_key(None) assert exc_info.value.status_code == 401 finally: vp.CONTROL_API_KEY = original class TestStatusEndpoint: """Tests fuer den Status Endpoint.""" def test_status_response_model(self): """Test VastStatusResponse Model Validierung.""" from infra.vast_power import VastStatusResponse # Unconfigured response resp = VastStatusResponse(status="unconfigured", message="Not configured") assert resp.status == "unconfigured" assert resp.instance_id is None # Running response resp = VastStatusResponse( instance_id=12345, status="running", gpu_name="RTX 3090", dph_total=0.45, endpoint_base_url="http://10.0.0.1:8001", auto_shutdown_in_minutes=25, ) assert resp.instance_id == 12345 assert resp.status == "running" assert resp.gpu_name == "RTX 3090" def test_status_returns_instance_info(self): """Test Status gibt korrektes Modell zurueck.""" from infra.vast_client import InstanceInfo, InstanceStatus from infra.vast_power import VastStatusResponse # Simuliere was der Endpoint zurueckgibt mock_instance = InstanceInfo( id=12345, status=InstanceStatus.RUNNING, gpu_name="RTX 3090", dph_total=0.45, public_ipaddr="10.0.0.1", ) # Baue Response wie der Endpoint es tun wuerde endpoint = mock_instance.get_endpoint_url(8001) response = VastStatusResponse( instance_id=mock_instance.id, status=mock_instance.status.value, gpu_name=mock_instance.gpu_name, dph_total=mock_instance.dph_total, endpoint_base_url=endpoint, ) assert response.instance_id == 12345 assert response.status == "running" assert response.gpu_name == "RTX 3090" assert response.dph_total == 0.45 class TestActivityEndpoint: """Tests fuer den Activity Endpoint.""" def test_record_activity_updates_state(self): """Test Activity wird im State aufgezeichnet.""" with tempfile.TemporaryDirectory() as tmpdir: from infra.vast_power import VastState state_path = Path(tmpdir) / "state.json" state = VastState(path=state_path) # Keine Aktivitaet vorher assert state.get_last_activity() is None # Aktivitaet aufzeichnen state.record_activity() # Jetzt sollte Aktivitaet vorhanden sein last = state.get_last_activity() assert last is not None assert isinstance(last, datetime) class TestCostsEndpoint: """Tests fuer den Costs Endpoint.""" def test_costs_response_model(self): """Test CostStatsResponse Model.""" from infra.vast_power import CostStatsResponse resp = CostStatsResponse( total_runtime_hours=2.5, total_cost_usd=1.25, sessions_count=3, avg_session_minutes=50.0, ) assert resp.total_runtime_hours == 2.5 assert resp.total_cost_usd == 1.25 assert resp.sessions_count == 3 class TestAuditEndpoint: """Tests fuer den Audit Log Endpoint.""" def test_audit_entries_parsed(self): """Test Audit Log Eintraege werden geparst.""" with tempfile.TemporaryDirectory() as tmpdir: audit_path = Path(tmpdir) / "audit.log" # Schreibe Test-Eintraege entries = [ '{"ts": "2024-01-15T10:00:00Z", "event": "power_on", "actor": "admin", "meta": {}}', '{"ts": "2024-01-15T11:00:00Z", "event": "power_off", "actor": "admin", "meta": {}}', ] audit_path.write_text("\n".join(entries)) # Lese und parse lines = audit_path.read_text().strip().split("\n") parsed = [json.loads(line) for line in lines] assert len(parsed) == 2 assert parsed[0]["event"] == "power_on" assert parsed[1]["event"] == "power_off" class TestRequestModels: """Tests fuer Request/Response Models.""" def test_power_on_request_defaults(self): """Test PowerOnRequest Defaults.""" from infra.vast_power import PowerOnRequest req = PowerOnRequest() assert req.wait_for_health is True assert req.health_path == "/health" assert req.health_port == 8001 def test_power_on_request_custom(self): """Test PowerOnRequest Custom Werte.""" from infra.vast_power import PowerOnRequest req = PowerOnRequest( wait_for_health=False, health_path="/v1/models", health_port=8000, ) assert req.wait_for_health is False assert req.health_path == "/v1/models" assert req.health_port == 8000 def test_vast_status_response(self): """Test VastStatusResponse Model.""" from infra.vast_power import VastStatusResponse resp = VastStatusResponse( instance_id=12345, status="running", gpu_name="RTX 3090", dph_total=0.5, ) assert resp.instance_id == 12345 assert resp.status == "running" assert resp.auto_shutdown_in_minutes is None def test_power_off_response(self): """Test PowerOffResponse Model.""" from infra.vast_power import PowerOffResponse resp = PowerOffResponse( status="stopped", session_runtime_minutes=30.5, session_cost_usd=0.25, ) assert resp.status == "stopped" assert resp.session_runtime_minutes == 30.5 assert resp.session_cost_usd == 0.25 def test_vast_status_response_with_budget(self): """Test VastStatusResponse mit Budget-Feldern.""" from infra.vast_power import VastStatusResponse resp = VastStatusResponse( instance_id=12345, status="running", gpu_name="RTX 3090", dph_total=0.186, account_credit=23.86, account_total_spend=1.19, session_runtime_minutes=120.5, session_cost_usd=0.37, ) assert resp.instance_id == 12345 assert resp.status == "running" assert resp.account_credit == 23.86 assert resp.account_total_spend == 1.19 assert resp.session_runtime_minutes == 120.5 assert resp.session_cost_usd == 0.37 def test_vast_status_response_budget_none(self): """Test VastStatusResponse ohne Budget (API nicht erreichbar).""" from infra.vast_power import VastStatusResponse resp = VastStatusResponse( instance_id=12345, status="running", account_credit=None, account_total_spend=None, session_runtime_minutes=None, session_cost_usd=None, ) assert resp.account_credit is None assert resp.account_total_spend is None assert resp.session_runtime_minutes is None assert resp.session_cost_usd is None class TestSessionCostCalculation: """Tests fuer Session-Kosten Berechnung.""" def test_session_cost_calculation_basic(self): """Test grundlegende Session-Kosten Berechnung.""" # Formel: (runtime_minutes / 60) * dph_total runtime_minutes = 60.0 # 1 Stunde dph_total = 0.186 # $0.186/h session_cost = (runtime_minutes / 60) * dph_total assert abs(session_cost - 0.186) < 0.001 def test_session_cost_calculation_partial_hour(self): """Test Session-Kosten fuer halbe Stunde.""" runtime_minutes = 30.0 # 30 min dph_total = 0.5 # $0.50/h session_cost = (runtime_minutes / 60) * dph_total assert abs(session_cost - 0.25) < 0.001 # $0.25 def test_session_cost_calculation_multi_hour(self): """Test Session-Kosten fuer mehrere Stunden.""" runtime_minutes = 240.0 # 4 Stunden dph_total = 0.186 # $0.186/h session_cost = (runtime_minutes / 60) * dph_total assert abs(session_cost - 0.744) < 0.001 # $0.744 def test_session_cost_zero_runtime(self): """Test Session-Kosten bei null Laufzeit.""" runtime_minutes = 0.0 dph_total = 0.5 session_cost = (runtime_minutes / 60) * dph_total assert session_cost == 0.0 def test_session_cost_zero_dph(self): """Test Session-Kosten bei null Stundensatz (sollte nie passieren).""" runtime_minutes = 60.0 dph_total = 0.0 session_cost = (runtime_minutes / 60) * dph_total assert session_cost == 0.0 class TestBudgetWarningLevels: """Tests fuer Budget-Warnlevel (UI verwendet diese).""" def test_budget_critical_threshold(self): """Test Budget unter $5 ist kritisch (rot).""" credit = 4.99 assert credit < 5 # Kritisch def test_budget_warning_threshold(self): """Test Budget zwischen $5 und $15 ist Warnung (orange).""" credit = 10.0 assert credit >= 5 and credit < 15 # Warnung def test_budget_ok_threshold(self): """Test Budget ueber $15 ist OK (gruen).""" credit = 23.86 assert credit >= 15 # OK class TestSessionRecoveryAfterRestart: """Tests fuer Session-Recovery nach Container-Neustart.""" def test_state_without_last_start(self): """Test State ohne last_start (nach Neustart).""" with tempfile.TemporaryDirectory() as tmpdir: from infra.vast_power import VastState state_path = Path(tmpdir) / "state.json" state = VastState(path=state_path) # Kein last_start sollte None sein assert state.get("last_start") is None def test_state_preserves_last_start(self): """Test State speichert last_start korrekt.""" with tempfile.TemporaryDirectory() as tmpdir: from infra.vast_power import VastState state_path = Path(tmpdir) / "state.json" state = VastState(path=state_path) # Setze last_start test_time = "2025-12-16T10:00:00+00:00" state.set("last_start", test_time) # Erstelle neuen State-Objekt (simuliert Neustart) state2 = VastState(path=state_path) assert state2.get("last_start") == test_time def test_state_uses_instance_start_date(self): """Test dass Instance start_date verwendet werden kann.""" from infra.vast_client import InstanceInfo, InstanceStatus from datetime import datetime, timezone # Simuliere Instance mit start_date instance = InstanceInfo( id=12345, status=InstanceStatus.RUNNING, started_at=datetime(2025, 12, 16, 10, 0, 0, tzinfo=timezone.utc), ) assert instance.started_at is not None assert instance.started_at.isoformat() == "2025-12-16T10:00:00+00:00"