Enhance importer robustness by normalizing tuple payloads and handling nullable URLs
This commit is contained in:
parent
9f34641e9b
commit
92ce21938e
3 changed files with 100 additions and 5 deletions
|
|
@ -243,6 +243,12 @@ def extract_operation_name_from_parsed(
|
||||||
return extract_operation_name_from_parsed(payload[0])
|
return extract_operation_name_from_parsed(payload[0])
|
||||||
return None
|
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):
|
if not isinstance(payload, dict):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -1038,9 +1044,6 @@ class Command(BaseCommand):
|
||||||
Args:
|
Args:
|
||||||
parsed_json: The parsed JSON data from the file.
|
parsed_json: The parsed JSON data from the file.
|
||||||
|
|
||||||
Raises:
|
|
||||||
TypeError: If the parsed JSON is a tuple, which is unsupported.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A list of response dictionaries.
|
A list of response dictionaries.
|
||||||
"""
|
"""
|
||||||
|
|
@ -1049,8 +1052,10 @@ class Command(BaseCommand):
|
||||||
if isinstance(parsed_json, list):
|
if isinstance(parsed_json, list):
|
||||||
return [item for item in parsed_json if isinstance(item, dict)]
|
return [item for item in parsed_json if isinstance(item, dict)]
|
||||||
if isinstance(parsed_json, tuple):
|
if isinstance(parsed_json, tuple):
|
||||||
msg = "Tuple responses are not supported in this context."
|
# json_repair returns (data, repair_log). Normalize based on the data portion.
|
||||||
raise TypeError(msg)
|
if len(parsed_json) > 0:
|
||||||
|
return self._normalize_responses(parsed_json[0])
|
||||||
|
return []
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def process_file_worker(
|
def process_file_worker(
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,22 @@ class DropCampaignSchema(BaseModel):
|
||||||
"populate_by_name": True,
|
"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):
|
class InventorySchema(BaseModel):
|
||||||
"""Schema for the inventory field in Inventory operation responses."""
|
"""Schema for the inventory field in Inventory operation responses."""
|
||||||
|
|
|
||||||
|
|
@ -607,3 +607,77 @@ class ExampleJsonImportTests(TestCase):
|
||||||
benefit.image_asset_url
|
benefit.image_asset_url
|
||||||
== "https://static-cdn.jtvnw.net/twitch-quests-assets/REWARD/903496ad-de97-41ff-ad97-12f099e20ea8.jpeg"
|
== "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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue