meta-ads-mcp 0.4.6__tar.gz → 0.4.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/PKG-INFO +1 -1
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/__init__.py +1 -1
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/ads.py +26 -11
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/utils.py +59 -1
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/pyproject.toml +1 -1
- meta_ads_mcp-0.4.8/tests/test_get_ad_creatives_fix.py +154 -0
- meta_ads_mcp-0.4.8/tests/test_get_ad_image_regression.py +188 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/.github/workflows/publish.yml +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/.github/workflows/test.yml +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/.gitignore +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/CUSTOM_META_APP.md +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/Dockerfile +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/LICENSE +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/LOCAL_INSTALLATION.md +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/META_API_NOTES.md +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/README.md +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/RELEASE.md +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/STREAMABLE_HTTP_SETUP.md +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/examples/README.md +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/examples/example_http_client.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/future_improvements.md +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/images/meta-ads-example.png +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_auth.sh +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/__main__.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/__init__.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/accounts.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/ads_library.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/adsets.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/api.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/auth.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/authentication.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/budget_schedules.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/callback_server.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/campaigns.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/duplication.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/http_auth_integration.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/insights.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/reports.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/resources.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/server.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/requirements.txt +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/setup.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/smithery.yaml +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/tests/README.md +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/tests/README_REGRESSION_TESTS.md +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/tests/__init__.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/tests/conftest.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/tests/test_duplication.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/tests/test_duplication_regression.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/tests/test_http_transport.py +0 -0
- {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/tests/test_openai.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meta-ads-mcp
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.8
|
|
4
4
|
Summary: Model Context Protocol (MCP) plugin for interacting with Meta Ads API
|
|
5
5
|
Project-URL: Homepage, https://github.com/pipeboard-co/meta-ads-mcp
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/pipeboard-co/meta-ads-mcp/issues
|
|
@@ -10,7 +10,7 @@ import time
|
|
|
10
10
|
|
|
11
11
|
from .api import meta_api_tool, make_api_request
|
|
12
12
|
from .accounts import get_ad_accounts
|
|
13
|
-
from .utils import download_image, try_multiple_download_methods, ad_creative_images
|
|
13
|
+
from .utils import download_image, try_multiple_download_methods, ad_creative_images, extract_creative_image_urls
|
|
14
14
|
from .server import mcp_server
|
|
15
15
|
|
|
16
16
|
|
|
@@ -179,7 +179,7 @@ async def get_ad_creatives(access_token: str = None, ad_id: str = None) -> str:
|
|
|
179
179
|
# Add image URLs for direct viewing if available
|
|
180
180
|
if 'data' in data:
|
|
181
181
|
for creative in data['data']:
|
|
182
|
-
creative['image_urls_for_viewing'] =
|
|
182
|
+
creative['image_urls_for_viewing'] = extract_creative_image_urls(creative)
|
|
183
183
|
|
|
184
184
|
return json.dumps(data, indent=2)
|
|
185
185
|
|
|
@@ -251,14 +251,27 @@ async def get_ad_image(access_token: str = None, ad_id: str = None) -> Image:
|
|
|
251
251
|
if not image_hashes:
|
|
252
252
|
# If no hashes found, try to extract from the first creative we found in the API
|
|
253
253
|
# Get creative for ad to try to extract hash
|
|
254
|
-
creative_json = await get_ad_creatives(
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
254
|
+
creative_json = await get_ad_creatives(access_token=access_token, ad_id=ad_id)
|
|
255
|
+
# The result is wrapped by @meta_api_tool, so we need to extract the data
|
|
256
|
+
creative_wrapper = json.loads(creative_json)
|
|
257
|
+
creative_data = json.loads(creative_wrapper["data"])
|
|
258
|
+
|
|
259
|
+
# Try to extract hash from data array
|
|
260
|
+
if "data" in creative_data and creative_data["data"]:
|
|
261
|
+
for creative in creative_data["data"]:
|
|
262
|
+
# Check object_story_spec for image hash
|
|
263
|
+
if "object_story_spec" in creative and "link_data" in creative["object_story_spec"]:
|
|
264
|
+
link_data = creative["object_story_spec"]["link_data"]
|
|
265
|
+
if "image_hash" in link_data:
|
|
266
|
+
image_hashes.append(link_data["image_hash"])
|
|
267
|
+
# Check direct image_hash on creative
|
|
268
|
+
elif "image_hash" in creative:
|
|
269
|
+
image_hashes.append(creative["image_hash"])
|
|
270
|
+
# Check asset_feed_spec for image hashes
|
|
271
|
+
elif "asset_feed_spec" in creative and "images" in creative["asset_feed_spec"]:
|
|
272
|
+
images = creative["asset_feed_spec"]["images"]
|
|
273
|
+
if images and len(images) > 0 and "hash" in images[0]:
|
|
274
|
+
image_hashes.append(images[0]["hash"])
|
|
262
275
|
|
|
263
276
|
if not image_hashes:
|
|
264
277
|
return "Error: No image hashes found in creative"
|
|
@@ -380,7 +393,9 @@ async def save_ad_image_locally(access_token: str = None, ad_id: str = None, out
|
|
|
380
393
|
if not image_hashes:
|
|
381
394
|
# Fallback attempt (as in get_ad_image)
|
|
382
395
|
creative_json = await get_ad_creatives(ad_id=ad_id, access_token=access_token) # Ensure ad_id is passed correctly
|
|
383
|
-
|
|
396
|
+
# The result is wrapped by @meta_api_tool, so we need to extract the data
|
|
397
|
+
creative_wrapper = json.loads(creative_json)
|
|
398
|
+
creative_data_list = json.loads(creative_wrapper["data"])
|
|
384
399
|
if 'data' in creative_data_list and creative_data_list['data']:
|
|
385
400
|
first_creative = creative_data_list['data'][0]
|
|
386
401
|
if 'object_story_spec' in first_creative and 'link_data' in first_creative['object_story_spec'] and 'image_hash' in first_creative['object_story_spec']['link_data']:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Utility functions for Meta Ads API."""
|
|
2
2
|
|
|
3
|
-
from typing import Optional, Dict, Any
|
|
3
|
+
from typing import Optional, Dict, Any, List
|
|
4
4
|
import httpx
|
|
5
5
|
import io
|
|
6
6
|
from PIL import Image as PILImage
|
|
@@ -74,6 +74,64 @@ logger = setup_logging()
|
|
|
74
74
|
# Global store for ad creative images
|
|
75
75
|
ad_creative_images = {}
|
|
76
76
|
|
|
77
|
+
|
|
78
|
+
def extract_creative_image_urls(creative: Dict[str, Any]) -> List[str]:
|
|
79
|
+
"""
|
|
80
|
+
Extract image URLs from a creative object for direct viewing.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
creative: Meta Ads creative object
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
List of image URLs found in the creative
|
|
87
|
+
"""
|
|
88
|
+
image_urls = []
|
|
89
|
+
|
|
90
|
+
# Check for direct image_url field
|
|
91
|
+
if "image_url" in creative and creative["image_url"]:
|
|
92
|
+
image_urls.append(creative["image_url"])
|
|
93
|
+
|
|
94
|
+
# Check for thumbnail_url field
|
|
95
|
+
if "thumbnail_url" in creative and creative["thumbnail_url"]:
|
|
96
|
+
image_urls.append(creative["thumbnail_url"])
|
|
97
|
+
|
|
98
|
+
# Check object_story_spec for image URLs
|
|
99
|
+
if "object_story_spec" in creative:
|
|
100
|
+
story_spec = creative["object_story_spec"]
|
|
101
|
+
|
|
102
|
+
# Check link_data for image fields
|
|
103
|
+
if "link_data" in story_spec:
|
|
104
|
+
link_data = story_spec["link_data"]
|
|
105
|
+
|
|
106
|
+
# Check for picture field
|
|
107
|
+
if "picture" in link_data and link_data["picture"]:
|
|
108
|
+
image_urls.append(link_data["picture"])
|
|
109
|
+
|
|
110
|
+
# Check for image_url field in link_data
|
|
111
|
+
if "image_url" in link_data and link_data["image_url"]:
|
|
112
|
+
image_urls.append(link_data["image_url"])
|
|
113
|
+
|
|
114
|
+
# Check video_data for thumbnail (if present)
|
|
115
|
+
if "video_data" in story_spec and "image_url" in story_spec["video_data"]:
|
|
116
|
+
image_urls.append(story_spec["video_data"]["image_url"])
|
|
117
|
+
|
|
118
|
+
# Check asset_feed_spec for multiple images
|
|
119
|
+
if "asset_feed_spec" in creative and "images" in creative["asset_feed_spec"]:
|
|
120
|
+
for image in creative["asset_feed_spec"]["images"]:
|
|
121
|
+
if "url" in image and image["url"]:
|
|
122
|
+
image_urls.append(image["url"])
|
|
123
|
+
|
|
124
|
+
# Remove duplicates while preserving order
|
|
125
|
+
seen = set()
|
|
126
|
+
unique_urls = []
|
|
127
|
+
for url in image_urls:
|
|
128
|
+
if url not in seen:
|
|
129
|
+
seen.add(url)
|
|
130
|
+
unique_urls.append(url)
|
|
131
|
+
|
|
132
|
+
return unique_urls
|
|
133
|
+
|
|
134
|
+
|
|
77
135
|
async def download_image(url: str) -> Optional[bytes]:
|
|
78
136
|
"""
|
|
79
137
|
Download an image from a URL.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Regression tests for get_ad_creatives function bug fix.
|
|
2
|
+
|
|
3
|
+
Tests for issue where get_ad_creatives would throw:
|
|
4
|
+
'TypeError: 'dict' object is not callable'
|
|
5
|
+
|
|
6
|
+
This was caused by trying to call ad_creative_images(creative) where
|
|
7
|
+
ad_creative_images is a dictionary, not a function.
|
|
8
|
+
|
|
9
|
+
The fix was to create extract_creative_image_urls() function and use that instead.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
import json
|
|
14
|
+
from unittest.mock import AsyncMock, patch
|
|
15
|
+
from meta_ads_mcp.core.ads import get_ad_creatives
|
|
16
|
+
from meta_ads_mcp.core.utils import ad_creative_images
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.mark.asyncio
|
|
20
|
+
class TestGetAdCreativesBugFix:
|
|
21
|
+
"""Regression test cases for the get_ad_creatives function bug fix."""
|
|
22
|
+
|
|
23
|
+
async def test_get_ad_creatives_regression_fix(self):
|
|
24
|
+
"""Regression test: ensure get_ad_creatives works correctly and doesn't throw 'dict' object is not callable."""
|
|
25
|
+
|
|
26
|
+
# Mock the make_api_request to return sample creative data
|
|
27
|
+
sample_creative_data = {
|
|
28
|
+
"data": [
|
|
29
|
+
{
|
|
30
|
+
"id": "123456789",
|
|
31
|
+
"name": "Test Creative",
|
|
32
|
+
"status": "ACTIVE",
|
|
33
|
+
"thumbnail_url": "https://example.com/thumb.jpg",
|
|
34
|
+
"image_url": "https://example.com/image.jpg",
|
|
35
|
+
"image_hash": "abc123",
|
|
36
|
+
"object_story_spec": {
|
|
37
|
+
"page_id": "987654321",
|
|
38
|
+
"link_data": {
|
|
39
|
+
"image_hash": "abc123",
|
|
40
|
+
"link": "https://example.com",
|
|
41
|
+
"name": "Test Ad"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api:
|
|
49
|
+
mock_api.return_value = sample_creative_data
|
|
50
|
+
|
|
51
|
+
# This should NOT raise a TypeError anymore
|
|
52
|
+
# Previously this would fail with: TypeError: 'dict' object is not callable
|
|
53
|
+
result = await get_ad_creatives(access_token="test_token", ad_id="120228922933270272")
|
|
54
|
+
|
|
55
|
+
# Parse the result
|
|
56
|
+
result_data = json.loads(result)
|
|
57
|
+
|
|
58
|
+
# Verify the structure is correct
|
|
59
|
+
assert "data" in result_data
|
|
60
|
+
assert len(result_data["data"]) == 1
|
|
61
|
+
|
|
62
|
+
creative = result_data["data"][0]
|
|
63
|
+
assert creative["id"] == "123456789"
|
|
64
|
+
assert creative["name"] == "Test Creative"
|
|
65
|
+
assert "image_urls_for_viewing" in creative
|
|
66
|
+
assert isinstance(creative["image_urls_for_viewing"], list)
|
|
67
|
+
|
|
68
|
+
async def test_extract_creative_image_urls_function(self):
|
|
69
|
+
"""Test the extract_creative_image_urls function works correctly."""
|
|
70
|
+
from meta_ads_mcp.core.utils import extract_creative_image_urls
|
|
71
|
+
|
|
72
|
+
# Test creative with various image URL fields
|
|
73
|
+
test_creative = {
|
|
74
|
+
"id": "123456789",
|
|
75
|
+
"name": "Test Creative",
|
|
76
|
+
"status": "ACTIVE",
|
|
77
|
+
"thumbnail_url": "https://example.com/thumb.jpg",
|
|
78
|
+
"image_url": "https://example.com/image.jpg",
|
|
79
|
+
"image_hash": "abc123",
|
|
80
|
+
"object_story_spec": {
|
|
81
|
+
"page_id": "987654321",
|
|
82
|
+
"link_data": {
|
|
83
|
+
"image_hash": "abc123",
|
|
84
|
+
"link": "https://example.com",
|
|
85
|
+
"name": "Test Ad",
|
|
86
|
+
"picture": "https://example.com/picture.jpg"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
urls = extract_creative_image_urls(test_creative)
|
|
92
|
+
|
|
93
|
+
# Should extract URLs in order: image_url, thumbnail_url, picture
|
|
94
|
+
expected_urls = [
|
|
95
|
+
"https://example.com/image.jpg",
|
|
96
|
+
"https://example.com/thumb.jpg",
|
|
97
|
+
"https://example.com/picture.jpg"
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
assert urls == expected_urls
|
|
101
|
+
|
|
102
|
+
# Test with empty creative
|
|
103
|
+
empty_urls = extract_creative_image_urls({})
|
|
104
|
+
assert empty_urls == []
|
|
105
|
+
|
|
106
|
+
# Test with duplicates (should remove them)
|
|
107
|
+
duplicate_creative = {
|
|
108
|
+
"image_url": "https://example.com/same.jpg",
|
|
109
|
+
"thumbnail_url": "https://example.com/same.jpg", # Same URL
|
|
110
|
+
}
|
|
111
|
+
unique_urls = extract_creative_image_urls(duplicate_creative)
|
|
112
|
+
assert unique_urls == ["https://example.com/same.jpg"]
|
|
113
|
+
|
|
114
|
+
async def test_get_ad_creatives_no_ad_id(self):
|
|
115
|
+
"""Test get_ad_creatives with no ad_id provided."""
|
|
116
|
+
|
|
117
|
+
result = await get_ad_creatives(access_token="test_token", ad_id=None)
|
|
118
|
+
result_data = json.loads(result)
|
|
119
|
+
|
|
120
|
+
# The @meta_api_tool decorator wraps the result in a data field
|
|
121
|
+
assert "data" in result_data
|
|
122
|
+
error_data = json.loads(result_data["data"])
|
|
123
|
+
assert "error" in error_data
|
|
124
|
+
assert error_data["error"] == "No ad ID provided"
|
|
125
|
+
|
|
126
|
+
async def test_get_ad_creatives_empty_data(self):
|
|
127
|
+
"""Test get_ad_creatives when API returns empty data."""
|
|
128
|
+
|
|
129
|
+
empty_data = {"data": []}
|
|
130
|
+
|
|
131
|
+
with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api:
|
|
132
|
+
mock_api.return_value = empty_data
|
|
133
|
+
|
|
134
|
+
result = await get_ad_creatives(access_token="test_token", ad_id="120228922933270272")
|
|
135
|
+
result_data = json.loads(result)
|
|
136
|
+
|
|
137
|
+
assert "data" in result_data
|
|
138
|
+
assert len(result_data["data"]) == 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_ad_creative_images_is_dict():
|
|
142
|
+
"""Test that ad_creative_images is a dictionary, not a function.
|
|
143
|
+
|
|
144
|
+
This confirms the original issue: ad_creative_images is a dict for storing image data,
|
|
145
|
+
but was being called as a function ad_creative_images(creative), which would fail.
|
|
146
|
+
This test ensures we don't accidentally change ad_creative_images to a function
|
|
147
|
+
and break its intended purpose as a storage dictionary.
|
|
148
|
+
"""
|
|
149
|
+
assert isinstance(ad_creative_images, dict)
|
|
150
|
+
|
|
151
|
+
# This would raise TypeError: 'dict' object is not callable
|
|
152
|
+
# This is the original bug - trying to call a dict as a function
|
|
153
|
+
with pytest.raises(TypeError, match="'dict' object is not callable"):
|
|
154
|
+
ad_creative_images({"test": "data"})
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Regression tests for get_ad_image function JSON parsing fix.
|
|
2
|
+
|
|
3
|
+
Tests for issue where get_ad_image would throw:
|
|
4
|
+
'TypeError: the JSON object must be str, bytes or bytearray, not dict'
|
|
5
|
+
|
|
6
|
+
This was caused by:
|
|
7
|
+
1. Wrong parameter order when calling get_ad_creatives (ad_id, "", access_token instead of access_token=x, ad_id=y)
|
|
8
|
+
2. Incorrect JSON parsing of the @meta_api_tool decorator wrapped response
|
|
9
|
+
|
|
10
|
+
The fix:
|
|
11
|
+
1. Corrected the parameter order in get_ad_creatives calls
|
|
12
|
+
2. Updated JSON parsing to handle the wrapped response format: {"data": "JSON_STRING"}
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
import json
|
|
17
|
+
from unittest.mock import AsyncMock, patch, MagicMock
|
|
18
|
+
from meta_ads_mcp.core.ads import get_ad_image
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.mark.asyncio
|
|
22
|
+
class TestGetAdImageRegressionFix:
|
|
23
|
+
"""Regression test cases for the get_ad_image JSON parsing bug fix."""
|
|
24
|
+
|
|
25
|
+
async def test_get_ad_image_json_parsing_regression_fix(self):
|
|
26
|
+
"""Regression test: ensure get_ad_image doesn't throw JSON parsing error."""
|
|
27
|
+
|
|
28
|
+
# Mock responses for the main API flow
|
|
29
|
+
mock_ad_data = {
|
|
30
|
+
"account_id": "act_123456789",
|
|
31
|
+
"creative": {"id": "creative_123456789"}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
mock_creative_details = {
|
|
35
|
+
"id": "creative_123456789",
|
|
36
|
+
"name": "Test Creative",
|
|
37
|
+
"image_hash": "test_hash_123"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
mock_image_data = {
|
|
41
|
+
"data": [{
|
|
42
|
+
"hash": "test_hash_123",
|
|
43
|
+
"url": "https://example.com/image.jpg",
|
|
44
|
+
"width": 1200,
|
|
45
|
+
"height": 628,
|
|
46
|
+
"name": "test_image.jpg",
|
|
47
|
+
"status": "ACTIVE"
|
|
48
|
+
}]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Mock PIL Image processing to return a valid Image object
|
|
52
|
+
mock_pil_image = MagicMock()
|
|
53
|
+
mock_pil_image.mode = "RGB"
|
|
54
|
+
mock_pil_image.convert.return_value = mock_pil_image
|
|
55
|
+
|
|
56
|
+
mock_byte_stream = MagicMock()
|
|
57
|
+
mock_byte_stream.getvalue.return_value = b"fake_jpeg_data"
|
|
58
|
+
|
|
59
|
+
with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
|
|
60
|
+
patch('meta_ads_mcp.core.ads.download_image', new_callable=AsyncMock) as mock_download, \
|
|
61
|
+
patch('meta_ads_mcp.core.ads.PILImage.open') as mock_pil_open, \
|
|
62
|
+
patch('meta_ads_mcp.core.ads.io.BytesIO') as mock_bytesio:
|
|
63
|
+
|
|
64
|
+
mock_api.side_effect = [mock_ad_data, mock_creative_details, mock_image_data]
|
|
65
|
+
mock_download.return_value = b"fake_image_bytes"
|
|
66
|
+
mock_pil_open.return_value = mock_pil_image
|
|
67
|
+
mock_bytesio.return_value = mock_byte_stream
|
|
68
|
+
|
|
69
|
+
# This should NOT raise "the JSON object must be str, bytes or bytearray, not dict"
|
|
70
|
+
# Previously this would fail with: TypeError: the JSON object must be str, bytes or bytearray, not dict
|
|
71
|
+
result = await get_ad_image(access_token="test_token", ad_id="120228922871870272")
|
|
72
|
+
|
|
73
|
+
# Verify we get an Image object (success) - the exact test depends on the mocking
|
|
74
|
+
# The key is that we don't get the JSON parsing error
|
|
75
|
+
assert result is not None
|
|
76
|
+
|
|
77
|
+
# The main regression check: if we got here without an exception, the JSON parsing is fixed
|
|
78
|
+
# We might get different results based on mocking, but the critical JSON parsing should work
|
|
79
|
+
|
|
80
|
+
async def test_get_ad_image_fallback_path_json_parsing(self):
|
|
81
|
+
"""Test the fallback path that calls get_ad_creatives handles JSON parsing correctly."""
|
|
82
|
+
|
|
83
|
+
# Mock responses that trigger the fallback path (no direct image hash)
|
|
84
|
+
mock_ad_data = {
|
|
85
|
+
"account_id": "act_123456789",
|
|
86
|
+
"creative": {"id": "creative_123456789"}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
mock_creative_details = {
|
|
90
|
+
"id": "creative_123456789",
|
|
91
|
+
"name": "Test Creative"
|
|
92
|
+
# No image_hash - this will trigger the fallback
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Mock get_ad_creatives response (wrapped format that caused the original bug)
|
|
96
|
+
mock_get_ad_creatives_response = json.dumps({
|
|
97
|
+
"data": json.dumps({
|
|
98
|
+
"data": [
|
|
99
|
+
{
|
|
100
|
+
"id": "creative_123456789",
|
|
101
|
+
"name": "Test Creative",
|
|
102
|
+
"object_story_spec": {
|
|
103
|
+
"link_data": {
|
|
104
|
+
"image_hash": "fallback_hash_123"
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
mock_image_data = {
|
|
113
|
+
"data": [{
|
|
114
|
+
"hash": "fallback_hash_123",
|
|
115
|
+
"url": "https://example.com/fallback_image.jpg",
|
|
116
|
+
"width": 1200,
|
|
117
|
+
"height": 628
|
|
118
|
+
}]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# Mock PIL Image processing
|
|
122
|
+
mock_pil_image = MagicMock()
|
|
123
|
+
mock_pil_image.mode = "RGB"
|
|
124
|
+
mock_pil_image.convert.return_value = mock_pil_image
|
|
125
|
+
|
|
126
|
+
mock_byte_stream = MagicMock()
|
|
127
|
+
mock_byte_stream.getvalue.return_value = b"fake_jpeg_data"
|
|
128
|
+
|
|
129
|
+
with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
|
|
130
|
+
patch('meta_ads_mcp.core.ads.get_ad_creatives', new_callable=AsyncMock) as mock_get_creatives, \
|
|
131
|
+
patch('meta_ads_mcp.core.ads.download_image', new_callable=AsyncMock) as mock_download, \
|
|
132
|
+
patch('meta_ads_mcp.core.ads.PILImage.open') as mock_pil_open, \
|
|
133
|
+
patch('meta_ads_mcp.core.ads.io.BytesIO') as mock_bytesio:
|
|
134
|
+
|
|
135
|
+
mock_api.side_effect = [mock_ad_data, mock_creative_details, mock_image_data]
|
|
136
|
+
mock_get_creatives.return_value = mock_get_ad_creatives_response
|
|
137
|
+
mock_download.return_value = b"fake_image_bytes"
|
|
138
|
+
mock_pil_open.return_value = mock_pil_image
|
|
139
|
+
mock_bytesio.return_value = mock_byte_stream
|
|
140
|
+
|
|
141
|
+
# This should handle the wrapped JSON response correctly
|
|
142
|
+
# Previously would fail: TypeError: the JSON object must be str, bytes or bytearray, not dict
|
|
143
|
+
result = await get_ad_image(access_token="test_token", ad_id="120228922871870272")
|
|
144
|
+
|
|
145
|
+
# Verify the fallback path worked - key is no JSON parsing exception
|
|
146
|
+
assert result is not None
|
|
147
|
+
# Verify get_ad_creatives was called (fallback path was triggered)
|
|
148
|
+
mock_get_creatives.assert_called_once()
|
|
149
|
+
|
|
150
|
+
async def test_get_ad_image_no_ad_id(self):
|
|
151
|
+
"""Test get_ad_image with no ad_id provided."""
|
|
152
|
+
|
|
153
|
+
result = await get_ad_image(access_token="test_token", ad_id=None)
|
|
154
|
+
|
|
155
|
+
# Should return error string, not throw JSON parsing error
|
|
156
|
+
assert isinstance(result, str)
|
|
157
|
+
assert "Error: No ad ID provided" in result
|
|
158
|
+
|
|
159
|
+
async def test_get_ad_image_parameter_order_regression(self):
|
|
160
|
+
"""Regression test: ensure get_ad_creatives is called with correct parameter order."""
|
|
161
|
+
|
|
162
|
+
# This test ensures we don't regress to calling get_ad_creatives(ad_id, "", access_token)
|
|
163
|
+
# which was the original bug
|
|
164
|
+
|
|
165
|
+
mock_ad_data = {
|
|
166
|
+
"account_id": "act_123456789",
|
|
167
|
+
"creative": {"id": "creative_123456789"}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
mock_creative_details = {
|
|
171
|
+
"id": "creative_123456789",
|
|
172
|
+
"name": "Test Creative"
|
|
173
|
+
# No image_hash to trigger fallback
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
|
|
177
|
+
patch('meta_ads_mcp.core.ads.get_ad_creatives', new_callable=AsyncMock) as mock_get_creatives:
|
|
178
|
+
|
|
179
|
+
mock_api.side_effect = [mock_ad_data, mock_creative_details]
|
|
180
|
+
mock_get_creatives.return_value = json.dumps({"data": json.dumps({"data": []})})
|
|
181
|
+
|
|
182
|
+
# Call get_ad_image - it should reach the fallback path
|
|
183
|
+
result = await get_ad_image(access_token="test_token", ad_id="test_ad_id")
|
|
184
|
+
|
|
185
|
+
# Verify get_ad_creatives was called with correct parameter names (not positional)
|
|
186
|
+
mock_get_creatives.assert_called_once_with(ad_id="test_ad_id", access_token="test_token")
|
|
187
|
+
|
|
188
|
+
# The key regression test: this should not have raised a JSON parsing error
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|