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.
Files changed (52) hide show
  1. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/PKG-INFO +1 -1
  2. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/__init__.py +1 -1
  3. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/ads.py +26 -11
  4. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/utils.py +59 -1
  5. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/pyproject.toml +1 -1
  6. meta_ads_mcp-0.4.8/tests/test_get_ad_creatives_fix.py +154 -0
  7. meta_ads_mcp-0.4.8/tests/test_get_ad_image_regression.py +188 -0
  8. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/.github/workflows/publish.yml +0 -0
  9. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/.github/workflows/test.yml +0 -0
  10. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/.gitignore +0 -0
  11. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/CUSTOM_META_APP.md +0 -0
  12. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/Dockerfile +0 -0
  13. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/LICENSE +0 -0
  14. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/LOCAL_INSTALLATION.md +0 -0
  15. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/META_API_NOTES.md +0 -0
  16. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/README.md +0 -0
  17. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/RELEASE.md +0 -0
  18. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/STREAMABLE_HTTP_SETUP.md +0 -0
  19. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/examples/README.md +0 -0
  20. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/examples/example_http_client.py +0 -0
  21. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/future_improvements.md +0 -0
  22. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/images/meta-ads-example.png +0 -0
  23. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_auth.sh +0 -0
  24. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/__main__.py +0 -0
  25. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/__init__.py +0 -0
  26. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/accounts.py +0 -0
  27. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/ads_library.py +0 -0
  28. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/adsets.py +0 -0
  29. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/api.py +0 -0
  30. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/auth.py +0 -0
  31. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/authentication.py +0 -0
  32. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/budget_schedules.py +0 -0
  33. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/callback_server.py +0 -0
  34. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/campaigns.py +0 -0
  35. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/duplication.py +0 -0
  36. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/http_auth_integration.py +0 -0
  37. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/insights.py +0 -0
  38. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
  39. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/reports.py +0 -0
  40. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/resources.py +0 -0
  41. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/meta_ads_mcp/core/server.py +0 -0
  42. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/requirements.txt +0 -0
  43. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/setup.py +0 -0
  44. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/smithery.yaml +0 -0
  45. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/tests/README.md +0 -0
  46. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/tests/README_REGRESSION_TESTS.md +0 -0
  47. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/tests/__init__.py +0 -0
  48. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/tests/conftest.py +0 -0
  49. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/tests/test_duplication.py +0 -0
  50. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/tests/test_duplication_regression.py +0 -0
  51. {meta_ads_mcp-0.4.6 → meta_ads_mcp-0.4.8}/tests/test_http_transport.py +0 -0
  52. {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.6
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
@@ -7,7 +7,7 @@ with the Claude LLM.
7
7
 
8
8
  from meta_ads_mcp.core.server import main
9
9
 
10
- __version__ = "0.4.6"
10
+ __version__ = "0.4.8"
11
11
 
12
12
  __all__ = [
13
13
  'get_ad_accounts',
@@ -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'] = ad_creative_images(creative)
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(ad_id, "", access_token)
255
- creative_data = json.loads(creative_json)
256
-
257
- # Try to extract hash from asset_feed_spec
258
- if "asset_feed_spec" in creative_data and "images" in creative_data["asset_feed_spec"]:
259
- images = creative_data["asset_feed_spec"]["images"]
260
- if images and len(images) > 0 and "hash" in images[0]:
261
- image_hashes.append(images[0]["hash"])
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
- creative_data_list = json.loads(creative_json)
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meta-ads-mcp"
7
- version = "0.4.6"
7
+ version = "0.4.8"
8
8
  description = "Model Context Protocol (MCP) plugin for interacting with Meta Ads API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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