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_infra/test_vast_power.py
Benjamin Admin bfdaf63ba9 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

511 lines
16 KiB
Python

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