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