diff --git a/twitch/management/commands/better_import_drops.py b/twitch/management/commands/better_import_drops.py index 7d3f20e..c86b235 100644 --- a/twitch/management/commands/better_import_drops.py +++ b/twitch/management/commands/better_import_drops.py @@ -221,6 +221,51 @@ def detect_non_campaign_keyword(raw_text: str) -> str | None: return None +def detect_error_only_response( + parsed_json: JSONReturnType | tuple[JSONReturnType, list[dict[str, str]]] | str, +) -> str | None: + """Detect if response contains only GraphQL errors and no data. + + Args: + parsed_json: The parsed JSON data from the file. + + Returns: + Error description if only errors present, None if data exists. + """ + # Handle tuple from json_repair + if isinstance(parsed_json, tuple): + parsed_json = parsed_json[0] + + # Check if it's a list of responses + if isinstance(parsed_json, list): + for item in parsed_json: + if isinstance(item, dict) and "errors" in item: + errors: Any = item.get("errors") + data: Any = item.get("data") + # Data is missing if key doesn't exist or value is None + if errors and data is None and isinstance(errors, list) and len(errors) > 0: + first_error: dict[str, Any] = errors[0] + message: str = first_error.get("message", "unknown error") + return f"error_only: {message}" + return None + + if not isinstance(parsed_json, dict): + return None + + # Check if it's a single response dict + if "errors" in parsed_json: + errors = parsed_json.get("errors") + data = parsed_json.get("data") + + # Data is missing if key doesn't exist or value is None + if errors and data is None and isinstance(errors, list) and len(errors) > 0: + first_error = errors[0] + message = first_error.get("message", "unknown error") + return f"error_only: {message}" + + return None + + def extract_operation_name_from_parsed( payload: JSONReturnType | tuple[JSONReturnType, list[dict[str, str]]] | str, ) -> str | None: @@ -1197,6 +1242,18 @@ class Command(BaseCommand): ) operation_name: str | None = extract_operation_name_from_parsed(parsed_json) + # Check for error-only responses first + error_description: str | None = detect_error_only_response(parsed_json) + if error_description: + if not options.get("skip_broken_moves"): + broken_dir: Path | None = move_file_to_broken_subdir( + file_path, + error_description, + operation_name=operation_name, + ) + return {"success": False, "broken_dir": str(broken_dir), "reason": error_description} + return {"success": False, "broken_dir": "(skipped)", "reason": error_description} + matched: str | None = detect_non_campaign_keyword(raw_text) if matched: if not options.get("skip_broken_moves"): @@ -1287,6 +1344,26 @@ class Command(BaseCommand): ) operation_name: str | None = extract_operation_name_from_parsed(parsed_json) + # Check for error-only responses first + error_description: str | None = detect_error_only_response(parsed_json) + if error_description: + if not options.get("skip_broken_moves"): + broken_dir: Path | None = move_file_to_broken_subdir( + file_path, + error_description, + operation_name=operation_name, + ) + progress_bar.write( + f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} → " + f"{broken_dir}/{file_path.name} " + f"({error_description})", + ) + else: + progress_bar.write( + f"{Fore.RED}✗{Style.RESET_ALL} {file_path.name} ({error_description}, move skipped)", + ) + return + matched: str | None = detect_non_campaign_keyword(raw_text) if matched: if not options.get("skip_broken_moves"): diff --git a/twitch/tests/test_better_import_drops.py b/twitch/tests/test_better_import_drops.py index 3ee0c56..960f50d 100644 --- a/twitch/tests/test_better_import_drops.py +++ b/twitch/tests/test_better_import_drops.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING from django.test import TestCase from twitch.management.commands.better_import_drops import Command +from twitch.management.commands.better_import_drops import detect_error_only_response from twitch.models import Channel from twitch.models import DropBenefit from twitch.models import DropCampaign @@ -681,3 +682,154 @@ class ImporterRobustnessTests(TestCase): campaign = DropCampaign.objects.get(twitch_id="campaign-null-image") assert not campaign.image_url + + +class ErrorOnlyResponseDetectionTests(TestCase): + """Tests for detecting responses that only contain GraphQL errors without data.""" + + def test_detects_error_only_response_with_service_timeout(self) -> None: + """Ensure error-only response with service timeout is detected.""" + parsed_json = { + "errors": [ + { + "message": "service timeout", + "path": ["currentUser", "dropCampaigns"], + }, + ], + } + + result = detect_error_only_response(parsed_json) + assert result == "error_only: service timeout" + + def test_detects_error_only_response_with_null_data(self) -> None: + """Ensure error-only response with null data field is detected.""" + parsed_json = { + "errors": [ + { + "message": "internal server error", + "path": ["data"], + }, + ], + "data": None, + } + + result = detect_error_only_response(parsed_json) + assert result == "error_only: internal server error" + + def test_detects_error_only_response_with_empty_data(self) -> None: + """Ensure error-only response with empty data dict is allowed through.""" + parsed_json = { + "errors": [ + { + "message": "unauthorized", + }, + ], + "data": {}, + } + + result = detect_error_only_response(parsed_json) + # Empty dict {} is considered "data exists" so this should pass + assert result is None + + def test_detects_error_only_response_without_data_key(self) -> None: + """Ensure error-only response without data key is detected.""" + parsed_json = { + "errors": [ + { + "message": "missing data", + }, + ], + } + + result = detect_error_only_response(parsed_json) + assert result == "error_only: missing data" + + def test_allows_response_with_both_errors_and_data(self) -> None: + """Ensure responses with both errors and valid data are not flagged.""" + parsed_json = { + "errors": [ + { + "message": "partial failure", + }, + ], + "data": { + "currentUser": { + "dropCampaigns": [], + }, + }, + } + + result = detect_error_only_response(parsed_json) + assert result is None + + def test_allows_response_with_no_errors(self) -> None: + """Ensure normal responses without errors are not flagged.""" + parsed_json = { + "data": { + "currentUser": { + "dropCampaigns": [], + }, + }, + } + + result = detect_error_only_response(parsed_json) + assert result is None + + def test_detects_error_only_in_list_of_responses(self) -> None: + """Ensure error-only detection works with list of responses.""" + parsed_json = [ + { + "errors": [ + { + "message": "rate limit exceeded", + }, + ], + }, + ] + + result = detect_error_only_response(parsed_json) + assert result == "error_only: rate limit exceeded" + + def test_handles_json_repair_tuple_format(self) -> None: + """Ensure error-only detection works with json_repair tuple format.""" + parsed_json = ( + { + "errors": [ + { + "message": "service timeout", + "path": ["currentUser", "dropCampaigns"], + }, + ], + }, + [{"json_repair": "log"}], + ) + + result = detect_error_only_response(parsed_json) + assert result == "error_only: service timeout" + + def test_returns_none_for_non_dict_input(self) -> None: + """Ensure non-dict input is handled gracefully.""" + result = detect_error_only_response("invalid") + assert result is None + + def test_returns_none_for_empty_errors_list(self) -> None: + """Ensure empty errors list is not flagged as error-only.""" + parsed_json = { + "errors": [], + } + + result = detect_error_only_response(parsed_json) + assert result is None + + def test_handles_error_without_message_field(self) -> None: + """Ensure errors without message field use default text.""" + parsed_json = { + "errors": [ + { + "path": ["data"], + }, + ], + } + + result = detect_error_only_response(parsed_json) + assert result == "error_only: unknown error"