diff --git a/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff.py b/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff.py index 8cb6237213..cd0cc725b9 100644 --- a/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff.py +++ b/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff.py @@ -15,6 +15,12 @@ GERMAN_TZ = pytz.timezone("Europe/Berlin") +class OctopusEnergyApiError(Exception): + """Wird geworfen, wenn die Kraken-API kein verwertbares 'data'-Feld liefert + (z.B. GraphQL-Fehler, abgelaufener Token, Wartungsarbeiten bei Octopus).""" + pass + + class OctopusEnergyClient: def __init__(self, email: str, password: str, base_url="https://api.oeg-kraken.energy/v1/graphql/"): self.base_url = base_url @@ -22,8 +28,13 @@ def __init__(self, email: str, password: str, base_url="https://api.oeg-kraken.e self.session = req.get_http_session() self.authenticate(email, password) - def _graphql_request(self, query: str, variables: dict): - """Send a GraphQL request with authentication.""" + def _graphql_request(self, query: str, variables: dict) -> dict: + """Sendet einen GraphQL-Request mit Authentifizierung. + + Wirft OctopusEnergyApiError, wenn die Antwort kein 'data'-Feld enthält + (statt stillschweigend None zurückzugeben und den Fehler an den Aufrufer + weiterzureichen, wo er als kryptischer NoneType-Fehler auftaucht). + """ headers = { "Authorization": f"{self.token}" if self.token else "", "Content-Type": "application/json" @@ -32,13 +43,27 @@ def _graphql_request(self, query: str, variables: dict): response = self.session.post(self.base_url, json=payload, headers=headers) - if response.status_code == 200: - return response.json().get("data") - else: - raise Exception(f"API request failed: {response.text}") + if response.status_code != 200: + raise OctopusEnergyApiError(f"API request failed: {response.text}") + + body = response.json() + + # GraphQL-APIs liefern bei fachlichen Fehlern trotzdem HTTP 200, + # aber ein "errors"-Array statt (oder zusätzlich zu) "data". + if body.get("errors"): + error_messages = "; ".join( + err.get("message", str(err)) for err in body["errors"] + ) + raise OctopusEnergyApiError(f"GraphQL-Fehler: {error_messages}") + + data = body.get("data") + if data is None: + raise OctopusEnergyApiError("Antwort enthält kein 'data'-Feld: " + str(body)[:500]) + + return data def authenticate(self, email: str, password: str): - """Authenticate and store the token.""" + """Authentifiziert und speichert den Token.""" mutation = """ mutation krakenTokenAuthentication($email: String!, $password: String!) { obtainKrakenToken(input: {email: $email, password: $password}) { @@ -49,13 +74,13 @@ def authenticate(self, email: str, password: str): variables = {"email": email, "password": password} data = self._graphql_request(mutation, variables) - if data and "obtainKrakenToken" in data: - self.token = data["obtainKrakenToken"]["token"] - else: - raise Exception("Authentication failed") + if "obtainKrakenToken" not in data or data["obtainKrakenToken"] is None: + raise OctopusEnergyApiError("Authentifizierung fehlgeschlagen: Kein Token in der Antwort enthalten.") - def get_property_ids(self, account_number: str): - """Retrieve property IDs for a given account.""" + self.token = data["obtainKrakenToken"]["token"] + + def get_property_ids(self, account_number: str) -> dict: + """Ruft die Property-IDs für einen Account ab.""" query = """ query getPropertyIds($accountNumber: String!) { account(accountNumber: $accountNumber) { @@ -72,8 +97,8 @@ def get_property_ids(self, account_number: str): variables = {"accountNumber": account_number} return self._graphql_request(query, variables) - def get_smart_meter_usage(self, account_number: str, property_id: str): - """Retrieve tariff and usage information for a property.""" + def get_smart_meter_usage(self, account_number: str, property_id: str) -> dict: + """Ruft Tarif- und Verbrauchsinformationen für eine Property ab.""" query = """ query getSmartMeterUsage($accountNumber: String!, $propertyId: ID!) { account(accountNumber: $accountNumber) { @@ -156,13 +181,23 @@ def process_agreement(agreement: dict, hour_time_utc: datetime, prices: Dict[str prices[timestamp] = rate -def build_tariff_state(data) -> Dict[str, float]: +def build_tariff_state(data: dict) -> Dict[str, float]: current_utc = datetime.now(timezone.utc) prices: Dict[str, float] = {} + property_data = data.get('account', {}).get('property') + if not property_data: + raise OctopusEnergyApiError("Keine Property-Daten in der Antwort enthalten.") + + malos = property_data.get('electricityMalos') + if not malos: + error_message = "Kein electricityMalos-Eintrag in der Antwort enthalten " \ + "(Zählpunkt evtl. noch nicht aktiv/verknüpft)." + raise OctopusEnergyApiError(error_message) + for hour in range(28): hour_time_utc = current_utc + timedelta(hours=hour) - for agreement in data['account']['property']['electricityMalos'][0]['agreements']: + for agreement in malos[0]['agreements']: process_agreement(agreement, hour_time_utc, prices) sorted_prices = dict(sorted(prices.items())) @@ -171,8 +206,13 @@ def build_tariff_state(data) -> Dict[str, float]: def fetch(config: OctopusEnergyTariffConfiguration) -> TariffState: client = OctopusEnergyClient(email=config.email, password=config.password) + property_data = client.get_property_ids(config.accountId) - property_id = property_data["account"]["properties"][0]["id"] + properties = property_data.get('account', {}).get('properties') + if not properties: + raise OctopusEnergyApiError(f"Kein Property zum Account {config.accountId} gefunden.") + property_id = properties[0]['id'] + tariffs = client.get_smart_meter_usage(config.accountId, property_id) prices = build_tariff_state(tariffs) diff --git a/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff_test.py b/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff_test.py index ebd8e8772b..a3547465b5 100644 --- a/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff_test.py +++ b/packages/modules/electricity_pricing/flexible_tariffs/octopusenergy/tariff_test.py @@ -1,4 +1,3 @@ - from datetime import datetime, timezone from typing import Dict from unittest.mock import MagicMock @@ -6,7 +5,7 @@ import pytest from modules.electricity_pricing.flexible_tariffs.octopusenergy import tariff -from modules.electricity_pricing.flexible_tariffs.octopusenergy.tariff import build_tariff_state +from modules.electricity_pricing.flexible_tariffs.octopusenergy.tariff import build_tariff_state, OctopusEnergyApiError TEST_DATA = {'data': {'account': {'property': {'electricityMalos': [ @@ -106,3 +105,62 @@ def test_build_tariff_state(now: datetime, expected_prices: Dict[str, float], mo # assert assert prices == expected_prices + + +# --- Zusätzliche Tests für die Fehlerbehandlung bei fehlenden/kaputten API-Daten --- + +def test_build_tariff_state_raises_on_missing_property(): + # setup: 'property' ist None, z.B. wenn die API keine gültige Property zurückgibt + data = {'account': {'property': None}} + + # execution & assert + with pytest.raises(OctopusEnergyApiError, match="Property"): + build_tariff_state(data) + + +def test_build_tariff_state_raises_on_missing_account(): + # setup: 'account' fehlt komplett in der Antwort + data = {} + + # execution & assert + with pytest.raises(OctopusEnergyApiError, match="Property"): + build_tariff_state(data) + + +def test_build_tariff_state_raises_on_empty_malos(): + # setup: 'electricityMalos' ist leer, z.B. Zählpunkt noch nicht verknüpft + data = {'account': {'property': {'electricityMalos': []}}} + + # execution & assert + with pytest.raises(OctopusEnergyApiError, match="electricityMalos"): + build_tariff_state(data) + + +def test_graphql_request_raises_on_graphql_errors(monkeypatch): + # setup: HTTP 200, aber GraphQL liefert ein 'errors'-Array statt 'data' + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "errors": [{"message": "Token invalid or expired"}] + } + mock_session = MagicMock() + mock_session.post.return_value = mock_response + monkeypatch.setattr(tariff.req, "get_http_session", lambda: mock_session) + + # execution & assert: Authentifizierung im Konstruktor schlägt fehl + with pytest.raises(OctopusEnergyApiError, match="Token invalid or expired"): + tariff.OctopusEnergyClient(email="test@example.com", password="secret") + + +def test_graphql_request_raises_on_missing_data_field(monkeypatch): + # setup: HTTP 200, aber weder 'data' noch 'errors' im Body (unerwartetes Format) + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {} + mock_session = MagicMock() + mock_session.post.return_value = mock_response + monkeypatch.setattr(tariff.req, "get_http_session", lambda: mock_session) + + # execution & assert + with pytest.raises(OctopusEnergyApiError, match="data"): + tariff.OctopusEnergyClient(email="test@example.com", password="secret")