meta-ads-mcp 0.4.9__tar.gz → 0.5.0__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 (53) hide show
  1. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/PKG-INFO +1 -1
  2. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/__init__.py +1 -1
  3. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/core/ads.py +47 -4
  4. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/pyproject.toml +1 -1
  5. meta_ads_mcp-0.5.0/tests/test_get_ad_image_regression.py +382 -0
  6. meta_ads_mcp-0.4.9/tests/test_get_ad_image_regression.py +0 -188
  7. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/.github/workflows/publish.yml +0 -0
  8. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/.github/workflows/test.yml +0 -0
  9. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/.gitignore +0 -0
  10. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/CUSTOM_META_APP.md +0 -0
  11. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/Dockerfile +0 -0
  12. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/LICENSE +0 -0
  13. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/LOCAL_INSTALLATION.md +0 -0
  14. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/META_API_NOTES.md +0 -0
  15. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/README.md +0 -0
  16. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/RELEASE.md +0 -0
  17. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/STREAMABLE_HTTP_SETUP.md +0 -0
  18. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/examples/README.md +0 -0
  19. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/examples/example_http_client.py +0 -0
  20. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/future_improvements.md +0 -0
  21. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/images/meta-ads-example.png +0 -0
  22. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_auth.sh +0 -0
  23. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/__main__.py +0 -0
  24. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/core/__init__.py +0 -0
  25. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/core/accounts.py +0 -0
  26. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/core/ads_library.py +0 -0
  27. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/core/adsets.py +0 -0
  28. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/core/api.py +0 -0
  29. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/core/auth.py +0 -0
  30. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/core/authentication.py +0 -0
  31. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/core/budget_schedules.py +0 -0
  32. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/core/callback_server.py +0 -0
  33. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/core/campaigns.py +0 -0
  34. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/core/duplication.py +0 -0
  35. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/core/http_auth_integration.py +0 -0
  36. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/core/insights.py +0 -0
  37. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
  38. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/core/reports.py +0 -0
  39. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/core/resources.py +0 -0
  40. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/core/server.py +0 -0
  41. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/meta_ads_mcp/core/utils.py +0 -0
  42. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/requirements.txt +0 -0
  43. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/setup.py +0 -0
  44. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/smithery.yaml +0 -0
  45. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/tests/README.md +0 -0
  46. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/tests/README_REGRESSION_TESTS.md +0 -0
  47. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/tests/__init__.py +0 -0
  48. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/tests/conftest.py +0 -0
  49. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/tests/test_duplication.py +0 -0
  50. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/tests/test_duplication_regression.py +0 -0
  51. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/tests/test_get_ad_creatives_fix.py +0 -0
  52. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/tests/test_http_transport.py +0 -0
  53. {meta_ads_mcp-0.4.9 → meta_ads_mcp-0.5.0}/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.9
3
+ Version: 0.5.0
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.9"
10
+ __version__ = "0.5.0"
11
11
 
12
12
  __all__ = [
13
13
  'get_ad_accounts',
@@ -250,7 +250,7 @@ async def get_ad_image(access_token: str = None, ad_id: str = None) -> Image:
250
250
 
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
- # Get creative for ad to try to extract hash
253
+ # and also check for direct URLs as fallback
254
254
  creative_json = await get_ad_creatives(access_token=access_token, ad_id=ad_id)
255
255
  creative_data = json.loads(creative_json)
256
256
 
@@ -270,9 +270,52 @@ async def get_ad_image(access_token: str = None, ad_id: str = None) -> Image:
270
270
  images = creative["asset_feed_spec"]["images"]
271
271
  if images and len(images) > 0 and "hash" in images[0]:
272
272
  image_hashes.append(images[0]["hash"])
273
-
274
- if not image_hashes:
275
- return "Error: No image hashes found in creative"
273
+
274
+ # If still no image hashes found, try direct URL fallback approach
275
+ if not image_hashes:
276
+ print("No image hashes found, trying direct URL fallback...")
277
+
278
+ image_url = None
279
+ if "data" in creative_data and creative_data["data"]:
280
+ creative = creative_data["data"][0]
281
+
282
+ # Try image_urls_for_viewing first (usually higher quality)
283
+ if "image_urls_for_viewing" in creative and creative["image_urls_for_viewing"]:
284
+ image_url = creative["image_urls_for_viewing"][0]
285
+ print(f"Using image_urls_for_viewing: {image_url}")
286
+ # Fall back to thumbnail_url
287
+ elif "thumbnail_url" in creative and creative["thumbnail_url"]:
288
+ image_url = creative["thumbnail_url"]
289
+ print(f"Using thumbnail_url: {image_url}")
290
+
291
+ if not image_url:
292
+ return "Error: No image URLs found in creative"
293
+
294
+ # Download the image directly
295
+ print(f"Downloading image from direct URL: {image_url}")
296
+ image_bytes = await download_image(image_url)
297
+
298
+ if not image_bytes:
299
+ return "Error: Failed to download image from direct URL"
300
+
301
+ try:
302
+ # Convert bytes to PIL Image
303
+ img = PILImage.open(io.BytesIO(image_bytes))
304
+
305
+ # Convert to RGB if needed
306
+ if img.mode != "RGB":
307
+ img = img.convert("RGB")
308
+
309
+ # Create a byte stream of the image data
310
+ byte_arr = io.BytesIO()
311
+ img.save(byte_arr, format="JPEG")
312
+ img_bytes = byte_arr.getvalue()
313
+
314
+ # Return as an Image object that LLM can directly analyze
315
+ return Image(data=img_bytes, format="jpeg")
316
+
317
+ except Exception as e:
318
+ return f"Error processing image from direct URL: {str(e)}"
276
319
 
277
320
  print(f"Found image hashes: {image_hashes}")
278
321
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meta-ads-mcp"
7
- version = "0.4.9"
7
+ version = "0.5.0"
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,382 @@
1
+ """Regression tests for get_ad_image function fixes.
2
+
3
+ Tests for multiple issues that were fixed:
4
+
5
+ 1. JSON Parsing Error: 'TypeError: the JSON object must be str, bytes or bytearray, not dict'
6
+ - Caused by wrong parameter order and incorrect JSON parsing
7
+ - Fixed by correcting parameter order and JSON parsing logic
8
+
9
+ 2. Missing Image Hash Support: "Error: No image hashes found in creative"
10
+ - Many modern creatives don't have image_hash but have direct URLs
11
+ - Fixed by adding direct URL fallback using image_urls_for_viewing and thumbnail_url
12
+
13
+ The fixes enable get_ad_image to work with both traditional hash-based and modern URL-based creatives.
14
+ """
15
+
16
+ import pytest
17
+ import json
18
+ from unittest.mock import AsyncMock, patch, MagicMock
19
+ from meta_ads_mcp.core.ads import get_ad_image
20
+
21
+
22
+ @pytest.mark.asyncio
23
+ class TestGetAdImageRegressionFix:
24
+ """Regression test cases for the get_ad_image JSON parsing bug fix."""
25
+
26
+ async def test_get_ad_image_json_parsing_regression_fix(self):
27
+ """Regression test: ensure get_ad_image doesn't throw JSON parsing error."""
28
+
29
+ # Mock responses for the main API flow
30
+ mock_ad_data = {
31
+ "account_id": "act_123456789",
32
+ "creative": {"id": "creative_123456789"}
33
+ }
34
+
35
+ mock_creative_details = {
36
+ "id": "creative_123456789",
37
+ "name": "Test Creative",
38
+ "image_hash": "test_hash_123"
39
+ }
40
+
41
+ mock_image_data = {
42
+ "data": [{
43
+ "hash": "test_hash_123",
44
+ "url": "https://example.com/image.jpg",
45
+ "width": 1200,
46
+ "height": 628,
47
+ "name": "test_image.jpg",
48
+ "status": "ACTIVE"
49
+ }]
50
+ }
51
+
52
+ # Mock PIL Image processing to return a valid Image object
53
+ mock_pil_image = MagicMock()
54
+ mock_pil_image.mode = "RGB"
55
+ mock_pil_image.convert.return_value = mock_pil_image
56
+
57
+ mock_byte_stream = MagicMock()
58
+ mock_byte_stream.getvalue.return_value = b"fake_jpeg_data"
59
+
60
+ with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
61
+ patch('meta_ads_mcp.core.ads.download_image', new_callable=AsyncMock) as mock_download, \
62
+ patch('meta_ads_mcp.core.ads.PILImage.open') as mock_pil_open, \
63
+ patch('meta_ads_mcp.core.ads.io.BytesIO') as mock_bytesio:
64
+
65
+ mock_api.side_effect = [mock_ad_data, mock_creative_details, mock_image_data]
66
+ mock_download.return_value = b"fake_image_bytes"
67
+ mock_pil_open.return_value = mock_pil_image
68
+ mock_bytesio.return_value = mock_byte_stream
69
+
70
+ # This should NOT raise "the JSON object must be str, bytes or bytearray, not dict"
71
+ # Previously this would fail with: TypeError: the JSON object must be str, bytes or bytearray, not dict
72
+ result = await get_ad_image(access_token="test_token", ad_id="120228922871870272")
73
+
74
+ # Verify we get an Image object (success) - the exact test depends on the mocking
75
+ # The key is that we don't get the JSON parsing error
76
+ assert result is not None
77
+
78
+ # The main regression check: if we got here without an exception, the JSON parsing is fixed
79
+ # We might get different results based on mocking, but the critical JSON parsing should work
80
+
81
+ async def test_get_ad_image_fallback_path_json_parsing(self):
82
+ """Test the fallback path that calls get_ad_creatives handles JSON parsing correctly."""
83
+
84
+ # Mock responses that trigger the fallback path (no direct image hash)
85
+ mock_ad_data = {
86
+ "account_id": "act_123456789",
87
+ "creative": {"id": "creative_123456789"}
88
+ }
89
+
90
+ mock_creative_details = {
91
+ "id": "creative_123456789",
92
+ "name": "Test Creative"
93
+ # No image_hash - this will trigger the fallback
94
+ }
95
+
96
+ # Mock get_ad_creatives response (wrapped format that caused the original bug)
97
+ mock_get_ad_creatives_response = json.dumps({
98
+ "data": json.dumps({
99
+ "data": [
100
+ {
101
+ "id": "creative_123456789",
102
+ "name": "Test Creative",
103
+ "object_story_spec": {
104
+ "link_data": {
105
+ "image_hash": "fallback_hash_123"
106
+ }
107
+ }
108
+ }
109
+ ]
110
+ })
111
+ })
112
+
113
+ mock_image_data = {
114
+ "data": [{
115
+ "hash": "fallback_hash_123",
116
+ "url": "https://example.com/fallback_image.jpg",
117
+ "width": 1200,
118
+ "height": 628
119
+ }]
120
+ }
121
+
122
+ # Mock PIL Image processing
123
+ mock_pil_image = MagicMock()
124
+ mock_pil_image.mode = "RGB"
125
+ mock_pil_image.convert.return_value = mock_pil_image
126
+
127
+ mock_byte_stream = MagicMock()
128
+ mock_byte_stream.getvalue.return_value = b"fake_jpeg_data"
129
+
130
+ with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
131
+ patch('meta_ads_mcp.core.ads.get_ad_creatives', new_callable=AsyncMock) as mock_get_creatives, \
132
+ patch('meta_ads_mcp.core.ads.download_image', new_callable=AsyncMock) as mock_download, \
133
+ patch('meta_ads_mcp.core.ads.PILImage.open') as mock_pil_open, \
134
+ patch('meta_ads_mcp.core.ads.io.BytesIO') as mock_bytesio:
135
+
136
+ mock_api.side_effect = [mock_ad_data, mock_creative_details, mock_image_data]
137
+ mock_get_creatives.return_value = mock_get_ad_creatives_response
138
+ mock_download.return_value = b"fake_image_bytes"
139
+ mock_pil_open.return_value = mock_pil_image
140
+ mock_bytesio.return_value = mock_byte_stream
141
+
142
+ # This should handle the wrapped JSON response correctly
143
+ # Previously would fail: TypeError: the JSON object must be str, bytes or bytearray, not dict
144
+ result = await get_ad_image(access_token="test_token", ad_id="120228922871870272")
145
+
146
+ # Verify the fallback path worked - key is no JSON parsing exception
147
+ assert result is not None
148
+ # Verify get_ad_creatives was called (fallback path was triggered)
149
+ mock_get_creatives.assert_called_once()
150
+
151
+ async def test_get_ad_image_no_ad_id(self):
152
+ """Test get_ad_image with no ad_id provided."""
153
+
154
+ result = await get_ad_image(access_token="test_token", ad_id=None)
155
+
156
+ # Should return error string, not throw JSON parsing error
157
+ assert isinstance(result, str)
158
+ assert "Error: No ad ID provided" in result
159
+
160
+ async def test_get_ad_image_parameter_order_regression(self):
161
+ """Regression test: ensure get_ad_creatives is called with correct parameter order."""
162
+
163
+ # This test ensures we don't regress to calling get_ad_creatives(ad_id, "", access_token)
164
+ # which was the original bug
165
+
166
+ mock_ad_data = {
167
+ "account_id": "act_123456789",
168
+ "creative": {"id": "creative_123456789"}
169
+ }
170
+
171
+ mock_creative_details = {
172
+ "id": "creative_123456789",
173
+ "name": "Test Creative"
174
+ # No image_hash to trigger fallback
175
+ }
176
+
177
+ with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
178
+ patch('meta_ads_mcp.core.ads.get_ad_creatives', new_callable=AsyncMock) as mock_get_creatives:
179
+
180
+ mock_api.side_effect = [mock_ad_data, mock_creative_details]
181
+ mock_get_creatives.return_value = json.dumps({"data": json.dumps({"data": []})})
182
+
183
+ # Call get_ad_image - it should reach the fallback path
184
+ result = await get_ad_image(access_token="test_token", ad_id="test_ad_id")
185
+
186
+ # Verify get_ad_creatives was called with correct parameter names (not positional)
187
+ mock_get_creatives.assert_called_once_with(ad_id="test_ad_id", access_token="test_token")
188
+
189
+ # The key regression test: this should not have raised a JSON parsing error
190
+
191
+ async def test_get_ad_image_direct_url_fallback_with_image_urls_for_viewing(self):
192
+ """Test direct URL fallback using image_urls_for_viewing when no image_hash found."""
193
+
194
+ # Mock responses for modern creative without image_hash
195
+ mock_ad_data = {
196
+ "account_id": "act_123456789",
197
+ "creative": {"id": "creative_123456789"}
198
+ }
199
+
200
+ mock_creative_details = {
201
+ "id": "creative_123456789",
202
+ "name": "Modern Creative"
203
+ # No image_hash - this will trigger fallback
204
+ }
205
+
206
+ # Mock get_ad_creatives response with direct URLs
207
+ mock_get_ad_creatives_response = json.dumps({
208
+ "data": [
209
+ {
210
+ "id": "creative_123456789",
211
+ "name": "Modern Creative",
212
+ "status": "ACTIVE",
213
+ "thumbnail_url": "https://example.com/thumb.jpg",
214
+ "image_urls_for_viewing": [
215
+ "https://example.com/full_image.jpg",
216
+ "https://example.com/alt_image.jpg"
217
+ ]
218
+ }
219
+ ]
220
+ })
221
+
222
+ # Mock PIL Image processing
223
+ mock_pil_image = MagicMock()
224
+ mock_pil_image.mode = "RGB"
225
+ mock_pil_image.convert.return_value = mock_pil_image
226
+
227
+ mock_byte_stream = MagicMock()
228
+ mock_byte_stream.getvalue.return_value = b"fake_jpeg_data"
229
+
230
+ with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
231
+ patch('meta_ads_mcp.core.ads.get_ad_creatives', new_callable=AsyncMock) as mock_get_creatives, \
232
+ patch('meta_ads_mcp.core.ads.download_image', new_callable=AsyncMock) as mock_download, \
233
+ patch('meta_ads_mcp.core.ads.PILImage.open') as mock_pil_open, \
234
+ patch('meta_ads_mcp.core.ads.io.BytesIO') as mock_bytesio:
235
+
236
+ mock_api.side_effect = [mock_ad_data, mock_creative_details]
237
+ mock_get_creatives.return_value = mock_get_ad_creatives_response
238
+ mock_download.return_value = b"fake_image_bytes"
239
+ mock_pil_open.return_value = mock_pil_image
240
+ mock_bytesio.return_value = mock_byte_stream
241
+
242
+ # This should use direct URL fallback successfully
243
+ result = await get_ad_image(access_token="test_token", ad_id="test_ad_id")
244
+
245
+ # Verify it used the direct URL approach
246
+ assert result is not None
247
+ mock_get_creatives.assert_called_once()
248
+ mock_download.assert_called_once_with("https://example.com/full_image.jpg")
249
+
250
+ async def test_get_ad_image_direct_url_fallback_with_thumbnail_url_only(self):
251
+ """Test direct URL fallback using thumbnail_url when image_urls_for_viewing not available."""
252
+
253
+ # Mock responses for creative with only thumbnail_url
254
+ mock_ad_data = {
255
+ "account_id": "act_123456789",
256
+ "creative": {"id": "creative_123456789"}
257
+ }
258
+
259
+ mock_creative_details = {
260
+ "id": "creative_123456789",
261
+ "name": "Thumbnail Only Creative"
262
+ # No image_hash
263
+ }
264
+
265
+ # Mock get_ad_creatives response with only thumbnail_url
266
+ mock_get_ad_creatives_response = json.dumps({
267
+ "data": [
268
+ {
269
+ "id": "creative_123456789",
270
+ "name": "Thumbnail Only Creative",
271
+ "status": "ACTIVE",
272
+ "thumbnail_url": "https://example.com/thumb_only.jpg"
273
+ # No image_urls_for_viewing
274
+ }
275
+ ]
276
+ })
277
+
278
+ # Mock PIL Image processing
279
+ mock_pil_image = MagicMock()
280
+ mock_pil_image.mode = "RGB"
281
+ mock_pil_image.convert.return_value = mock_pil_image
282
+
283
+ mock_byte_stream = MagicMock()
284
+ mock_byte_stream.getvalue.return_value = b"fake_jpeg_data"
285
+
286
+ with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
287
+ patch('meta_ads_mcp.core.ads.get_ad_creatives', new_callable=AsyncMock) as mock_get_creatives, \
288
+ patch('meta_ads_mcp.core.ads.download_image', new_callable=AsyncMock) as mock_download, \
289
+ patch('meta_ads_mcp.core.ads.PILImage.open') as mock_pil_open, \
290
+ patch('meta_ads_mcp.core.ads.io.BytesIO') as mock_bytesio:
291
+
292
+ mock_api.side_effect = [mock_ad_data, mock_creative_details]
293
+ mock_get_creatives.return_value = mock_get_ad_creatives_response
294
+ mock_download.return_value = b"fake_image_bytes"
295
+ mock_pil_open.return_value = mock_pil_image
296
+ mock_bytesio.return_value = mock_byte_stream
297
+
298
+ # This should fall back to thumbnail_url
299
+ result = await get_ad_image(access_token="test_token", ad_id="test_ad_id")
300
+
301
+ # Verify it used the thumbnail URL
302
+ assert result is not None
303
+ mock_download.assert_called_once_with("https://example.com/thumb_only.jpg")
304
+
305
+ async def test_get_ad_image_no_direct_urls_available(self):
306
+ """Test error handling when no direct URLs are available."""
307
+
308
+ # Mock responses for creative without any URLs
309
+ mock_ad_data = {
310
+ "account_id": "act_123456789",
311
+ "creative": {"id": "creative_123456789"}
312
+ }
313
+
314
+ mock_creative_details = {
315
+ "id": "creative_123456789",
316
+ "name": "No URLs Creative"
317
+ # No image_hash
318
+ }
319
+
320
+ # Mock get_ad_creatives response without URLs
321
+ mock_get_ad_creatives_response = json.dumps({
322
+ "data": [
323
+ {
324
+ "id": "creative_123456789",
325
+ "name": "No URLs Creative",
326
+ "status": "ACTIVE"
327
+ # No thumbnail_url or image_urls_for_viewing
328
+ }
329
+ ]
330
+ })
331
+
332
+ with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
333
+ patch('meta_ads_mcp.core.ads.get_ad_creatives', new_callable=AsyncMock) as mock_get_creatives:
334
+
335
+ mock_api.side_effect = [mock_ad_data, mock_creative_details]
336
+ mock_get_creatives.return_value = mock_get_ad_creatives_response
337
+
338
+ # This should return appropriate error
339
+ result = await get_ad_image(access_token="test_token", ad_id="test_ad_id")
340
+
341
+ # Should get error about no URLs
342
+ assert isinstance(result, str)
343
+ assert "No image URLs found" in result
344
+
345
+ async def test_get_ad_image_direct_url_download_failure(self):
346
+ """Test error handling when direct URL download fails."""
347
+
348
+ # Mock responses for creative with URLs but download failure
349
+ mock_ad_data = {
350
+ "account_id": "act_123456789",
351
+ "creative": {"id": "creative_123456789"}
352
+ }
353
+
354
+ mock_creative_details = {
355
+ "id": "creative_123456789",
356
+ "name": "Download Fail Creative"
357
+ }
358
+
359
+ mock_get_ad_creatives_response = json.dumps({
360
+ "data": [
361
+ {
362
+ "id": "creative_123456789",
363
+ "name": "Download Fail Creative",
364
+ "image_urls_for_viewing": ["https://example.com/broken_image.jpg"]
365
+ }
366
+ ]
367
+ })
368
+
369
+ with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
370
+ patch('meta_ads_mcp.core.ads.get_ad_creatives', new_callable=AsyncMock) as mock_get_creatives, \
371
+ patch('meta_ads_mcp.core.ads.download_image', new_callable=AsyncMock) as mock_download:
372
+
373
+ mock_api.side_effect = [mock_ad_data, mock_creative_details]
374
+ mock_get_creatives.return_value = mock_get_ad_creatives_response
375
+ mock_download.return_value = None # Simulate download failure
376
+
377
+ # This should return download error
378
+ result = await get_ad_image(access_token="test_token", ad_id="test_ad_id")
379
+
380
+ # Should get error about download failure
381
+ assert isinstance(result, str)
382
+ assert "Failed to download image from direct URL" in result
@@ -1,188 +0,0 @@
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