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>
This commit is contained in:
1
backend/tests/test_infra/__init__.py
Normal file
1
backend/tests/test_infra/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for infrastructure management module."""
|
||||
547
backend/tests/test_infra/test_vast_client.py
Normal file
547
backend/tests/test_infra/test_vast_client.py
Normal file
@@ -0,0 +1,547 @@
|
||||
"""
|
||||
Tests fuer VastAIClient.
|
||||
|
||||
Testet den vast.ai REST API Client.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from infra.vast_client import (
|
||||
VastAIClient,
|
||||
InstanceInfo,
|
||||
InstanceStatus,
|
||||
AccountInfo,
|
||||
)
|
||||
|
||||
|
||||
class TestInstanceStatus:
|
||||
"""Tests fuer InstanceStatus Enum."""
|
||||
|
||||
def test_status_values(self):
|
||||
"""Test alle Status-Werte."""
|
||||
assert InstanceStatus.RUNNING.value == "running"
|
||||
assert InstanceStatus.STOPPED.value == "stopped"
|
||||
assert InstanceStatus.EXITED.value == "exited"
|
||||
assert InstanceStatus.LOADING.value == "loading"
|
||||
assert InstanceStatus.SCHEDULING.value == "scheduling"
|
||||
assert InstanceStatus.CREATING.value == "creating"
|
||||
assert InstanceStatus.UNKNOWN.value == "unknown"
|
||||
|
||||
|
||||
class TestInstanceInfo:
|
||||
"""Tests fuer InstanceInfo Dataclass."""
|
||||
|
||||
def test_create_basic(self):
|
||||
"""Test grundlegende Erstellung."""
|
||||
info = InstanceInfo(
|
||||
id=12345,
|
||||
status=InstanceStatus.RUNNING,
|
||||
)
|
||||
assert info.id == 12345
|
||||
assert info.status == InstanceStatus.RUNNING
|
||||
assert info.gpu_name is None
|
||||
assert info.num_gpus == 1
|
||||
|
||||
def test_from_api_response_running(self):
|
||||
"""Test Parse von API Response (running)."""
|
||||
api_data = {
|
||||
"id": 67890,
|
||||
"actual_status": "running",
|
||||
"machine_id": 111,
|
||||
"gpu_name": "RTX 3090",
|
||||
"num_gpus": 1,
|
||||
"gpu_ram": 24.0,
|
||||
"cpu_ram": 64.0,
|
||||
"disk_space": 200.0,
|
||||
"dph_total": 0.45,
|
||||
"public_ipaddr": "192.168.1.100",
|
||||
"ports": {
|
||||
"8001/tcp": [{"HostIp": "0.0.0.0", "HostPort": "12345"}],
|
||||
},
|
||||
"label": "llm-server",
|
||||
"start_date": 1702900800, # Unix timestamp
|
||||
}
|
||||
|
||||
info = InstanceInfo.from_api_response(api_data)
|
||||
|
||||
assert info.id == 67890
|
||||
assert info.status == InstanceStatus.RUNNING
|
||||
assert info.gpu_name == "RTX 3090"
|
||||
assert info.dph_total == 0.45
|
||||
assert info.public_ipaddr == "192.168.1.100"
|
||||
assert info.label == "llm-server"
|
||||
|
||||
def test_from_api_response_stopped(self):
|
||||
"""Test Parse von API Response (gestoppt)."""
|
||||
api_data = {
|
||||
"id": 11111,
|
||||
"actual_status": "exited",
|
||||
}
|
||||
|
||||
info = InstanceInfo.from_api_response(api_data)
|
||||
|
||||
assert info.status == InstanceStatus.EXITED
|
||||
|
||||
def test_from_api_response_unknown_status(self):
|
||||
"""Test Parse von unbekanntem Status."""
|
||||
api_data = {
|
||||
"id": 22222,
|
||||
"actual_status": "weird_status",
|
||||
}
|
||||
|
||||
info = InstanceInfo.from_api_response(api_data)
|
||||
|
||||
assert info.status == InstanceStatus.UNKNOWN
|
||||
|
||||
def test_get_endpoint_url_with_port_mapping(self):
|
||||
"""Test Endpoint URL mit Port-Mapping."""
|
||||
info = InstanceInfo(
|
||||
id=12345,
|
||||
status=InstanceStatus.RUNNING,
|
||||
public_ipaddr="10.0.0.1",
|
||||
ports={
|
||||
"8001/tcp": [{"HostIp": "0.0.0.0", "HostPort": "54321"}],
|
||||
},
|
||||
)
|
||||
|
||||
url = info.get_endpoint_url(8001)
|
||||
assert url == "http://10.0.0.1:54321"
|
||||
|
||||
def test_get_endpoint_url_fallback(self):
|
||||
"""Test Endpoint URL Fallback ohne Port-Mapping."""
|
||||
info = InstanceInfo(
|
||||
id=12345,
|
||||
status=InstanceStatus.RUNNING,
|
||||
public_ipaddr="10.0.0.2",
|
||||
ports={},
|
||||
)
|
||||
|
||||
url = info.get_endpoint_url(8001)
|
||||
assert url == "http://10.0.0.2:8001"
|
||||
|
||||
def test_get_endpoint_url_no_ip(self):
|
||||
"""Test Endpoint URL ohne IP."""
|
||||
info = InstanceInfo(
|
||||
id=12345,
|
||||
status=InstanceStatus.RUNNING,
|
||||
public_ipaddr=None,
|
||||
)
|
||||
|
||||
url = info.get_endpoint_url(8001)
|
||||
assert url is None
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test Serialisierung."""
|
||||
info = InstanceInfo(
|
||||
id=12345,
|
||||
status=InstanceStatus.RUNNING,
|
||||
gpu_name="RTX 4090",
|
||||
dph_total=0.75,
|
||||
)
|
||||
|
||||
data = info.to_dict()
|
||||
|
||||
assert data["id"] == 12345
|
||||
assert data["status"] == "running"
|
||||
assert data["gpu_name"] == "RTX 4090"
|
||||
assert data["dph_total"] == 0.75
|
||||
|
||||
|
||||
class TestAccountInfo:
|
||||
"""Tests fuer AccountInfo Dataclass."""
|
||||
|
||||
def test_create_basic(self):
|
||||
"""Test grundlegende Erstellung."""
|
||||
info = AccountInfo(
|
||||
credit=25.50,
|
||||
balance=0.0,
|
||||
total_spend=10.25,
|
||||
username="testuser",
|
||||
email="test@example.com",
|
||||
has_billing=True,
|
||||
)
|
||||
assert info.credit == 25.50
|
||||
assert info.balance == 0.0
|
||||
assert info.total_spend == 10.25
|
||||
assert info.username == "testuser"
|
||||
assert info.email == "test@example.com"
|
||||
assert info.has_billing is True
|
||||
|
||||
def test_from_api_response_complete(self):
|
||||
"""Test Parse von vollstaendiger API Response."""
|
||||
api_data = {
|
||||
"credit": 23.87674017153,
|
||||
"balance": 0.0,
|
||||
"total_spend": -1.1732598284700013, # API gibt negativ zurueck
|
||||
"username": "benjamin",
|
||||
"email": "benjamin@example.com",
|
||||
"has_billing": True,
|
||||
}
|
||||
|
||||
info = AccountInfo.from_api_response(api_data)
|
||||
|
||||
assert info.credit == 23.87674017153
|
||||
assert info.balance == 0.0
|
||||
assert info.total_spend == 1.1732598284700013 # Sollte positiv sein (abs)
|
||||
assert info.username == "benjamin"
|
||||
assert info.email == "benjamin@example.com"
|
||||
assert info.has_billing is True
|
||||
|
||||
def test_from_api_response_minimal(self):
|
||||
"""Test Parse von minimaler API Response."""
|
||||
api_data = {}
|
||||
|
||||
info = AccountInfo.from_api_response(api_data)
|
||||
|
||||
assert info.credit == 0.0
|
||||
assert info.balance == 0.0
|
||||
assert info.total_spend == 0.0
|
||||
assert info.username == ""
|
||||
assert info.email == ""
|
||||
assert info.has_billing is False
|
||||
|
||||
def test_from_api_response_partial(self):
|
||||
"""Test Parse von teilweiser API Response."""
|
||||
api_data = {
|
||||
"credit": 50.0,
|
||||
"username": "partial_user",
|
||||
}
|
||||
|
||||
info = AccountInfo.from_api_response(api_data)
|
||||
|
||||
assert info.credit == 50.0
|
||||
assert info.username == "partial_user"
|
||||
assert info.email == ""
|
||||
assert info.total_spend == 0.0
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test Serialisierung zu Dictionary."""
|
||||
info = AccountInfo(
|
||||
credit=100.0,
|
||||
balance=5.0,
|
||||
total_spend=25.0,
|
||||
username="dictuser",
|
||||
email="dict@test.com",
|
||||
has_billing=True,
|
||||
)
|
||||
|
||||
data = info.to_dict()
|
||||
|
||||
assert data["credit"] == 100.0
|
||||
assert data["balance"] == 5.0
|
||||
assert data["total_spend"] == 25.0
|
||||
assert data["username"] == "dictuser"
|
||||
assert data["email"] == "dict@test.com"
|
||||
assert data["has_billing"] is True
|
||||
|
||||
def test_total_spend_negative_to_positive(self):
|
||||
"""Test dass negative total_spend Werte positiv werden."""
|
||||
api_data = {
|
||||
"total_spend": -99.99,
|
||||
}
|
||||
|
||||
info = AccountInfo.from_api_response(api_data)
|
||||
|
||||
assert info.total_spend == 99.99
|
||||
|
||||
|
||||
class TestVastAIClient:
|
||||
"""Tests fuer VastAIClient."""
|
||||
|
||||
def test_init(self):
|
||||
"""Test Client Initialisierung."""
|
||||
client = VastAIClient(api_key="test-key", timeout=60.0)
|
||||
|
||||
assert client.api_key == "test-key"
|
||||
assert client.timeout == 60.0
|
||||
assert client._client is None
|
||||
|
||||
def test_build_url(self):
|
||||
"""Test URL Building."""
|
||||
client = VastAIClient(api_key="my-api-key")
|
||||
|
||||
url = client._build_url("/instances/")
|
||||
assert url == "https://console.vast.ai/api/v0/instances/?api_key=my-api-key"
|
||||
|
||||
url2 = client._build_url("/instances/?foo=bar")
|
||||
assert url2 == "https://console.vast.ai/api/v0/instances/?foo=bar&api_key=my-api-key"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_instances(self):
|
||||
"""Test Liste aller Instanzen."""
|
||||
client = VastAIClient(api_key="test-key")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"instances": [
|
||||
{"id": 1, "actual_status": "running", "gpu_name": "RTX 3090"},
|
||||
{"id": 2, "actual_status": "exited"}, # exited statt stopped
|
||||
]
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client, "_get_client") as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_get.return_value = mock_client
|
||||
|
||||
instances = await client.list_instances()
|
||||
|
||||
assert len(instances) == 2
|
||||
assert instances[0].id == 1
|
||||
assert instances[0].status == InstanceStatus.RUNNING
|
||||
assert instances[1].id == 2
|
||||
assert instances[1].status == InstanceStatus.EXITED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_instance(self):
|
||||
"""Test einzelne Instanz abrufen."""
|
||||
client = VastAIClient(api_key="test-key")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"instances": [
|
||||
{"id": 12345, "actual_status": "running", "dph_total": 0.5}
|
||||
]
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client, "_get_client") as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_get.return_value = mock_client
|
||||
|
||||
instance = await client.get_instance(12345)
|
||||
|
||||
assert instance is not None
|
||||
assert instance.id == 12345
|
||||
assert instance.status == InstanceStatus.RUNNING
|
||||
assert instance.dph_total == 0.5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_instance_not_found(self):
|
||||
"""Test Instanz nicht gefunden."""
|
||||
import httpx
|
||||
|
||||
client = VastAIClient(api_key="test-key")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 404
|
||||
|
||||
with patch.object(client, "_get_client") as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.side_effect = httpx.HTTPStatusError(
|
||||
"Not Found", request=MagicMock(), response=mock_response
|
||||
)
|
||||
mock_get.return_value = mock_client
|
||||
|
||||
instance = await client.get_instance(99999)
|
||||
|
||||
assert instance is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_instance(self):
|
||||
"""Test Instanz starten."""
|
||||
client = VastAIClient(api_key="test-key")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client, "_get_client") as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.put.return_value = mock_response
|
||||
mock_get.return_value = mock_client
|
||||
|
||||
success = await client.start_instance(12345)
|
||||
|
||||
assert success is True
|
||||
mock_client.put.assert_called_once()
|
||||
call_args = mock_client.put.call_args
|
||||
assert call_args[1]["json"] == {"state": "running"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_instance(self):
|
||||
"""Test Instanz stoppen."""
|
||||
client = VastAIClient(api_key="test-key")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client, "_get_client") as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.put.return_value = mock_response
|
||||
mock_get.return_value = mock_client
|
||||
|
||||
success = await client.stop_instance(12345)
|
||||
|
||||
assert success is True
|
||||
call_args = mock_client.put.call_args
|
||||
assert call_args[1]["json"] == {"state": "stopped"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_instance_failure(self):
|
||||
"""Test Instanz stoppen fehlschlaegt."""
|
||||
import httpx
|
||||
|
||||
client = VastAIClient(api_key="test-key")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
|
||||
with patch.object(client, "_get_client") as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.put.side_effect = httpx.HTTPStatusError(
|
||||
"Error", request=MagicMock(), response=mock_response
|
||||
)
|
||||
mock_get.return_value = mock_client
|
||||
|
||||
success = await client.stop_instance(12345)
|
||||
|
||||
assert success is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_destroy_instance(self):
|
||||
"""Test Instanz loeschen."""
|
||||
client = VastAIClient(api_key="test-key")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client, "_get_client") as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.delete.return_value = mock_response
|
||||
mock_get.return_value = mock_client
|
||||
|
||||
success = await client.destroy_instance(12345)
|
||||
|
||||
assert success is True
|
||||
mock_client.delete.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_label(self):
|
||||
"""Test Label setzen."""
|
||||
client = VastAIClient(api_key="test-key")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client, "_get_client") as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.put.return_value = mock_response
|
||||
mock_get.return_value = mock_client
|
||||
|
||||
success = await client.set_label(12345, "my-label")
|
||||
|
||||
assert success is True
|
||||
call_args = mock_client.put.call_args
|
||||
assert call_args[1]["json"] == {"label": "my-label"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close(self):
|
||||
"""Test Client schliessen."""
|
||||
client = VastAIClient(api_key="test-key")
|
||||
|
||||
# Erstelle Client
|
||||
await client._get_client()
|
||||
assert client._client is not None
|
||||
|
||||
# Schliesse
|
||||
await client.close()
|
||||
assert client._client is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_account_info_success(self):
|
||||
"""Test Account-Info erfolgreich abrufen."""
|
||||
client = VastAIClient(api_key="test-key")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"credit": 23.87674017153,
|
||||
"balance": 0.0,
|
||||
"total_spend": -1.1732598284700013,
|
||||
"username": "testuser",
|
||||
"email": "test@vast.ai",
|
||||
"has_billing": True,
|
||||
}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client, "_get_client") as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_get.return_value = mock_client
|
||||
|
||||
account = await client.get_account_info()
|
||||
|
||||
assert account is not None
|
||||
assert account.credit == 23.87674017153
|
||||
assert account.total_spend == 1.1732598284700013 # abs()
|
||||
assert account.username == "testuser"
|
||||
assert account.email == "test@vast.ai"
|
||||
assert account.has_billing is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_account_info_api_error(self):
|
||||
"""Test Account-Info bei API-Fehler."""
|
||||
import httpx
|
||||
|
||||
client = VastAIClient(api_key="test-key")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
|
||||
with patch.object(client, "_get_client") as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.side_effect = httpx.HTTPStatusError(
|
||||
"Unauthorized", request=MagicMock(), response=mock_response
|
||||
)
|
||||
mock_get.return_value = mock_client
|
||||
|
||||
account = await client.get_account_info()
|
||||
|
||||
assert account is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_account_info_network_error(self):
|
||||
"""Test Account-Info bei Netzwerk-Fehler."""
|
||||
client = VastAIClient(api_key="test-key")
|
||||
|
||||
with patch.object(client, "_get_client") as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.side_effect = Exception("Network error")
|
||||
mock_get.return_value = mock_client
|
||||
|
||||
account = await client.get_account_info()
|
||||
|
||||
assert account is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_account_info_url(self):
|
||||
"""Test dass get_account_info den korrekten Endpoint aufruft."""
|
||||
client = VastAIClient(api_key="my-test-key")
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"credit": 10.0}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
|
||||
with patch.object(client, "_get_client") as mock_get:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.get.return_value = mock_response
|
||||
mock_get.return_value = mock_client
|
||||
|
||||
await client.get_account_info()
|
||||
|
||||
# Pruefe dass /users/current/ aufgerufen wurde
|
||||
call_args = mock_client.get.call_args
|
||||
called_url = call_args[0][0]
|
||||
assert "/users/current/" in called_url
|
||||
assert "api_key=my-test-key" in called_url
|
||||
510
backend/tests/test_infra/test_vast_power.py
Normal file
510
backend/tests/test_infra/test_vast_power.py
Normal file
@@ -0,0 +1,510 @@
|
||||
"""
|
||||
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"
|
||||
Reference in New Issue
Block a user