From 92ce21938e2e957df1afa624ddb93fb76ce6d3a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Sun, 11 Jan 2026 23:58:39 +0100 Subject: [PATCH] Enhance importer robustness by normalizing tuple payloads and handling nullable URLs --- .../commands/better_import_drops.py | 15 ++-- twitch/schemas.py | 16 ++++ twitch/tests/test_better_import_drops.py | 74 +++++++++++++++++++ 3 files changed, 100 insertions(+), 5 deletions(-) diff --git a/twitch/management/commands/better_import_drops.py b/twitch/management/commands/better_import_drops.py index 261505f..24b0308 100644 --- a/twitch/management/commands/better_import_drops.py +++ b/twitch/management/commands/better_import_drops.py @@ -243,6 +243,12 @@ def extract_operation_name_from_parsed( return extract_operation_name_from_parsed(payload[0]) return None + # json_repair can return (data, repair_log) + if isinstance(payload, tuple): + if len(payload) > 0: + return extract_operation_name_from_parsed(payload[0]) + return None + if not isinstance(payload, dict): return None @@ -1038,9 +1044,6 @@ class Command(BaseCommand): Args: parsed_json: The parsed JSON data from the file. - Raises: - TypeError: If the parsed JSON is a tuple, which is unsupported. - Returns: A list of response dictionaries. """ @@ -1049,8 +1052,10 @@ class Command(BaseCommand): if isinstance(parsed_json, list): return [item for item in parsed_json if isinstance(item, dict)] if isinstance(parsed_json, tuple): - msg = "Tuple responses are not supported in this context." - raise TypeError(msg) + # json_repair returns (data, repair_log). Normalize based on the data portion. + if len(parsed_json) > 0: + return self._normalize_responses(parsed_json[0]) + return [] return [] def process_file_worker( diff --git a/twitch/schemas.py b/twitch/schemas.py index 2734af5..15080ef 100644 --- a/twitch/schemas.py +++ b/twitch/schemas.py @@ -232,6 +232,22 @@ class DropCampaignSchema(BaseModel): "populate_by_name": True, } + @field_validator("account_link_url", "details_url", "image_url", mode="before") + @classmethod + def normalize_nullable_urls(cls, v: str | None) -> str: + """Normalize nullable URL-ish fields to empty strings. + + Twitch sometimes returns `null` for URL fields. With `strict=True`, + we normalize these to "" to keep imports resilient. + + Args: + v: The raw URL field value (str or None). + + Returns: + The URL string, or empty string if None. + """ + return v or "" + class InventorySchema(BaseModel): """Schema for the inventory field in Inventory operation responses.""" diff --git a/twitch/tests/test_better_import_drops.py b/twitch/tests/test_better_import_drops.py index 2fb2ec2..3ee0c56 100644 --- a/twitch/tests/test_better_import_drops.py +++ b/twitch/tests/test_better_import_drops.py @@ -607,3 +607,77 @@ class ExampleJsonImportTests(TestCase): benefit.image_asset_url == "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/903496ad-de97-41ff-ad97-12f099e20ea8.jpeg" ) + + +class ImporterRobustnessTests(TestCase): + """Tests for importer resiliency against real-world payload quirks.""" + + def test_normalize_responses_accepts_json_repair_tuple(self) -> None: + """Ensure tuple payloads from json_repair don't crash the importer.""" + command = Command() + + parsed = ( + { + "data": { + "currentUser": { + "id": "123", + "dropCampaigns": [], + "__typename": "User", + }, + }, + "extensions": {"operationName": "ViewerDropsDashboard"}, + }, + [{"json_repair": "log"}], + ) + + normalized = command._normalize_responses(parsed) + assert isinstance(normalized, list) + assert len(normalized) == 1 + assert normalized[0]["extensions"]["operationName"] == "ViewerDropsDashboard" + + def test_allows_null_image_url_and_persists_empty_string(self) -> None: + """Ensure null imageURL doesn't fail validation and results in empty string in DB.""" + command = Command() + command.pre_fill_cache() + + payload: dict[str, object] = { + "data": { + "user": { + "id": "123", + "dropCampaign": { + "id": "campaign-null-image", + "name": "Null Image Campaign", + "description": "", + "startAt": "2025-01-01T00:00:00Z", + "endAt": "2025-01-02T00:00:00Z", + "accountLinkURL": "https://example.com/link", + "detailsURL": "https://example.com/details", + "imageURL": None, + "status": "ACTIVE", + "self": {"isAccountConnected": False, "__typename": "DropCampaignSelfEdge"}, + "game": { + "id": "g-null-image", + "displayName": "Test Game", + "boxArtURL": "https://example.com/box.png", + "__typename": "Game", + }, + "timeBasedDrops": [], + "__typename": "DropCampaign", + }, + "__typename": "User", + }, + }, + "extensions": {"operationName": "DropCampaignDetails"}, + } + + success, broken_dir = command.process_responses( + responses=[payload], + file_path=Path("null_image.json"), + options={}, + ) + + assert success is True + assert broken_dir is None + + campaign = DropCampaign.objects.get(twitch_id="campaign-null-image") + assert not campaign.image_url