meta-ads-mcp 0.7.8__tar.gz → 0.7.9__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 (66) hide show
  1. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/PKG-INFO +1 -1
  2. meta_ads_mcp-0.7.9/RELEASE.md +166 -0
  3. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/__init__.py +1 -1
  4. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/ads.py +16 -4
  5. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/utils.py +18 -6
  6. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/pyproject.toml +1 -1
  7. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_get_ad_creatives_fix.py +3 -3
  8. meta_ads_mcp-0.7.9/tests/test_get_ad_image_quality_improvements.py +391 -0
  9. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_get_ad_image_regression.py +74 -2
  10. meta_ads_mcp-0.7.8/RELEASE.md +0 -97
  11. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/.github/workflows/publish.yml +0 -0
  12. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/.github/workflows/test.yml +0 -0
  13. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/.gitignore +0 -0
  14. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/CUSTOM_META_APP.md +0 -0
  15. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/Dockerfile +0 -0
  16. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/LICENSE +0 -0
  17. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/LOCAL_INSTALLATION.md +0 -0
  18. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/META_API_NOTES.md +0 -0
  19. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/README.md +0 -0
  20. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/STREAMABLE_HTTP_SETUP.md +0 -0
  21. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/examples/README.md +0 -0
  22. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/examples/example_http_client.py +0 -0
  23. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/future_improvements.md +0 -0
  24. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/images/meta-ads-example.png +0 -0
  25. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_auth.sh +0 -0
  26. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/__main__.py +0 -0
  27. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/__init__.py +0 -0
  28. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/accounts.py +0 -0
  29. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/ads_library.py +0 -0
  30. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/adsets.py +0 -0
  31. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/api.py +0 -0
  32. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/auth.py +0 -0
  33. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/authentication.py +0 -0
  34. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/budget_schedules.py +0 -0
  35. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/callback_server.py +0 -0
  36. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/campaigns.py +0 -0
  37. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/duplication.py +0 -0
  38. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/http_auth_integration.py +0 -0
  39. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/insights.py +0 -0
  40. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/openai_deep_research.py +0 -0
  41. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
  42. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/reports.py +0 -0
  43. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/resources.py +0 -0
  44. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/server.py +0 -0
  45. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/meta_ads_mcp/core/targeting.py +0 -0
  46. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/requirements.txt +0 -0
  47. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/setup.py +0 -0
  48. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/smithery.yaml +0 -0
  49. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/README.md +0 -0
  50. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/README_REGRESSION_TESTS.md +0 -0
  51. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/__init__.py +0 -0
  52. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/conftest.py +0 -0
  53. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_account_search.py +0 -0
  54. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_budget_update.py +0 -0
  55. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_budget_update_e2e.py +0 -0
  56. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_dsa_beneficiary.py +0 -0
  57. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_dsa_integration.py +0 -0
  58. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_duplication.py +0 -0
  59. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_duplication_regression.py +0 -0
  60. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_http_transport.py +0 -0
  61. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_insights_actions_and_values.py +0 -0
  62. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_integration_openai_mcp.py +0 -0
  63. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_openai.py +0 -0
  64. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_openai_mcp_deep_research.py +0 -0
  65. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_targeting.py +0 -0
  66. {meta_ads_mcp-0.7.8 → meta_ads_mcp-0.7.9}/tests/test_targeting_search_e2e.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meta-ads-mcp
3
- Version: 0.7.8
3
+ Version: 0.7.9
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
@@ -0,0 +1,166 @@
1
+ # Release Process
2
+
3
+ This repository uses GitHub Actions to automatically publish releases to PyPI. Here's the optimized release process:
4
+
5
+ ## 🚀 Quick Release (Recommended)
6
+
7
+ ### Prerequisites
8
+ - ✅ **Trusted Publishing Configured**: Repository uses PyPI trusted publishing with OIDC tokens
9
+ - ✅ **GitHub CLI installed**: `gh` command available for streamlined releases
10
+ - ✅ **Clean working directory**: No uncommitted changes
11
+
12
+ ### Optimal Release Process
13
+
14
+ 1. **Update version in both files** (use consistent versioning):
15
+
16
+ ```bash
17
+ # Update pyproject.toml
18
+ sed -i '' 's/version = "0.7.7"/version = "0.7.8"/' pyproject.toml
19
+
20
+ # Update __init__.py
21
+ sed -i '' 's/__version__ = "0.7.7"/__version__ = "0.7.8"/' meta_ads_mcp/__init__.py
22
+ ```
23
+
24
+ Or manually edit:
25
+ - `pyproject.toml`: `version = "0.7.8"`
26
+ - `meta_ads_mcp/__init__.py`: `__version__ = "0.7.8"`
27
+
28
+ 2. **Commit and push version changes**:
29
+ ```bash
30
+ git add pyproject.toml meta_ads_mcp/__init__.py
31
+ git commit -m "Bump version to 0.7.8"
32
+ git push origin main
33
+ ```
34
+
35
+ 3. **Create GitHub release** (triggers automatic PyPI publishing):
36
+ ```bash
37
+ # Use bash wrapper if gh has issues in Cursor
38
+ bash -c "gh release create 0.7.8 --title '0.7.8' --generate-notes"
39
+ ```
40
+
41
+ 4. **Verify release** (optional):
42
+ ```bash
43
+ # Check GitHub release
44
+ curl -s "https://api.github.com/repos/pipeboard-co/meta-ads-mcp/releases/latest" | grep -E '"tag_name"|"name"'
45
+
46
+ # Check PyPI availability (wait 2-3 minutes)
47
+ curl -s "https://pypi.org/pypi/meta-ads-mcp/json" | grep -E '"version"|"0.7.8"'
48
+ ```
49
+
50
+ ## 📋 Detailed Release Process
51
+
52
+ ### Version Management Best Practices
53
+
54
+ - **Semantic Versioning**: Follow `MAJOR.MINOR.PATCH` (e.g., 0.7.8)
55
+ - **Synchronized Files**: Always update BOTH version files
56
+ - **Commit Convention**: Use `"Bump version to X.Y.Z"` format
57
+ - **Release Tag**: GitHub release tag matches version (no "v" prefix)
58
+
59
+ ### Pre-Release Checklist
60
+
61
+ ```bash
62
+ # 1. Ensure clean working directory
63
+ git status
64
+
65
+ # 2. Run tests locally (optional but recommended)
66
+ uv run python -m pytest tests/ -v
67
+
68
+ # 3. Check current version
69
+ grep -E "version =|__version__" pyproject.toml meta_ads_mcp/__init__.py
70
+ ```
71
+
72
+ ### Release Commands (One-liner)
73
+
74
+ ```bash
75
+ # Complete release in one sequence
76
+ VERSION="0.7.8" && \
77
+ sed -i '' "s/version = \"0.7.7\"/version = \"$VERSION\"/" pyproject.toml && \
78
+ sed -i '' "s/__version__ = \"0.7.7\"/__version__ = \"$VERSION\"/" meta_ads_mcp/__init__.py && \
79
+ git add pyproject.toml meta_ads_mcp/__init__.py && \
80
+ git commit -m "Bump version to $VERSION" && \
81
+ git push origin main && \
82
+ bash -c "gh release create $VERSION --title '$VERSION' --generate-notes"
83
+ ```
84
+
85
+ ## 🔄 Workflows
86
+
87
+ ### `publish.yml` (Automatic)
88
+ - **Trigger**: GitHub release creation
89
+ - **Purpose**: Build and publish to PyPI
90
+ - **Security**: OIDC tokens (no API keys)
91
+ - **Status**: ✅ Fully automated
92
+
93
+ ### `test.yml` (Validation)
94
+ - **Trigger**: Push to main/master
95
+ - **Purpose**: Package structure validation
96
+ - **Matrix**: Python 3.10, 3.11, 3.12
97
+ - **Note**: Build tests only, not pytest
98
+
99
+ ## 🛠️ Troubleshooting
100
+
101
+ ### Common Issues
102
+
103
+ 1. **gh command issues in Cursor**:
104
+ ```bash
105
+ # Use bash wrapper
106
+ bash -c "gh release create 0.7.8 --title '0.7.8' --generate-notes"
107
+ ```
108
+
109
+ 2. **Version mismatch**:
110
+ ```bash
111
+ # Verify both files have same version
112
+ grep -E "version =|__version__" pyproject.toml meta_ads_mcp/__init__.py
113
+ ```
114
+
115
+ 3. **PyPI not updated**:
116
+ ```bash
117
+ # Check if package is available (wait 2-3 minutes)
118
+ curl -s "https://pypi.org/pypi/meta-ads-mcp/json" | grep '"version"'
119
+ ```
120
+
121
+ ### Manual Deployment (Fallback)
122
+
123
+ ```bash
124
+ # Install build tools
125
+ pip install build twine
126
+
127
+ # Build package
128
+ python -m build
129
+
130
+ # Upload to PyPI (requires API token)
131
+ python -m twine upload dist/*
132
+ ```
133
+
134
+ ## 📊 Release Verification
135
+
136
+ ### GitHub Release
137
+ - ✅ Release created with correct tag
138
+ - ✅ Auto-generated notes from commits
139
+ - ✅ Actions tab shows successful workflow
140
+
141
+ ### PyPI Package
142
+ - ✅ Package available for installation
143
+ - ✅ Correct version displayed
144
+ - ✅ All dependencies listed
145
+
146
+ ### Installation Test
147
+ ```bash
148
+ # Test new version installation
149
+ pip install meta-ads-mcp==0.7.8
150
+ # or
151
+ uvx meta-ads-mcp@0.7.8
152
+ ```
153
+
154
+ ## 🔒 Security Notes
155
+
156
+ - **Trusted Publishing**: Uses GitHub OIDC tokens (no API keys needed)
157
+ - **Isolated Builds**: All builds run in GitHub-hosted runners
158
+ - **Access Control**: Only maintainers can create releases
159
+ - **Audit Trail**: All releases tracked in GitHub Actions
160
+
161
+ ## 📈 Release Metrics
162
+
163
+ Track successful releases:
164
+ - **GitHub Releases**: https://github.com/pipeboard-co/meta-ads-mcp/releases
165
+ - **PyPI Package**: https://pypi.org/project/meta-ads-mcp/
166
+ - **Actions History**: https://github.com/pipeboard-co/meta-ads-mcp/actions
@@ -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.7.8"
10
+ __version__ = "0.7.9"
11
11
 
12
12
  __all__ = [
13
13
  'get_ad_accounts',
@@ -171,7 +171,7 @@ async def get_ad_creatives(access_token: str = None, ad_id: str = None) -> str:
171
171
 
172
172
  endpoint = f"{ad_id}/adcreatives"
173
173
  params = {
174
- "fields": "id,name,status,thumbnail_url,image_url,image_hash,object_story_spec" # Added image_hash
174
+ "fields": "id,name,status,thumbnail_url,image_url,image_hash,object_story_spec,asset_feed_spec,image_urls_for_viewing"
175
175
  }
176
176
 
177
177
  data = await make_api_request(endpoint, access_token, params)
@@ -279,14 +279,26 @@ async def get_ad_image(access_token: str = None, ad_id: str = None) -> Image:
279
279
  if "data" in creative_data and creative_data["data"]:
280
280
  creative = creative_data["data"][0]
281
281
 
282
- # Try image_urls_for_viewing first (usually higher quality)
282
+ # Prioritize higher quality image URLs in this order:
283
+ # 1. image_urls_for_viewing (usually highest quality)
284
+ # 2. image_url (direct field)
285
+ # 3. object_story_spec.link_data.picture (usually full size)
286
+ # 4. thumbnail_url (last resort - often profile thumbnail)
287
+
283
288
  if "image_urls_for_viewing" in creative and creative["image_urls_for_viewing"]:
284
289
  image_url = creative["image_urls_for_viewing"][0]
285
290
  print(f"Using image_urls_for_viewing: {image_url}")
286
- # Fall back to thumbnail_url
291
+ elif "image_url" in creative and creative["image_url"]:
292
+ image_url = creative["image_url"]
293
+ print(f"Using image_url: {image_url}")
294
+ elif "object_story_spec" in creative and "link_data" in creative["object_story_spec"]:
295
+ link_data = creative["object_story_spec"]["link_data"]
296
+ if "picture" in link_data and link_data["picture"]:
297
+ image_url = link_data["picture"]
298
+ print(f"Using object_story_spec.link_data.picture: {image_url}")
287
299
  elif "thumbnail_url" in creative and creative["thumbnail_url"]:
288
300
  image_url = creative["thumbnail_url"]
289
- print(f"Using thumbnail_url: {image_url}")
301
+ print(f"Using thumbnail_url (fallback): {image_url}")
290
302
 
291
303
  if not image_url:
292
304
  return "Error: No image URLs found in creative"
@@ -78,23 +78,31 @@ ad_creative_images = {}
78
78
  def extract_creative_image_urls(creative: Dict[str, Any]) -> List[str]:
79
79
  """
80
80
  Extract image URLs from a creative object for direct viewing.
81
+ Prioritizes higher quality images over thumbnails.
81
82
 
82
83
  Args:
83
84
  creative: Meta Ads creative object
84
85
 
85
86
  Returns:
86
- List of image URLs found in the creative
87
+ List of image URLs found in the creative, prioritized by quality
87
88
  """
88
89
  image_urls = []
89
90
 
91
+ # Prioritize higher quality image URLs in this order:
92
+ # 1. image_urls_for_viewing (usually highest quality)
93
+ # 2. image_url (direct field)
94
+ # 3. object_story_spec.link_data.picture (usually full size)
95
+ # 4. asset_feed_spec images (multiple high-quality images)
96
+ # 5. thumbnail_url (last resort - often profile thumbnail)
97
+
98
+ # Check for image_urls_for_viewing (highest priority)
99
+ if "image_urls_for_viewing" in creative and creative["image_urls_for_viewing"]:
100
+ image_urls.extend(creative["image_urls_for_viewing"])
101
+
90
102
  # Check for direct image_url field
91
103
  if "image_url" in creative and creative["image_url"]:
92
104
  image_urls.append(creative["image_url"])
93
105
 
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
106
  # Check object_story_spec for image URLs
99
107
  if "object_story_spec" in creative:
100
108
  story_spec = creative["object_story_spec"]
@@ -103,7 +111,7 @@ def extract_creative_image_urls(creative: Dict[str, Any]) -> List[str]:
103
111
  if "link_data" in story_spec:
104
112
  link_data = story_spec["link_data"]
105
113
 
106
- # Check for picture field
114
+ # Check for picture field (usually full size)
107
115
  if "picture" in link_data and link_data["picture"]:
108
116
  image_urls.append(link_data["picture"])
109
117
 
@@ -121,6 +129,10 @@ def extract_creative_image_urls(creative: Dict[str, Any]) -> List[str]:
121
129
  if "url" in image and image["url"]:
122
130
  image_urls.append(image["url"])
123
131
 
132
+ # Check for thumbnail_url field (lowest priority)
133
+ if "thumbnail_url" in creative and creative["thumbnail_url"]:
134
+ image_urls.append(creative["thumbnail_url"])
135
+
124
136
  # Remove duplicates while preserving order
125
137
  seen = set()
126
138
  unique_urls = []
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meta-ads-mcp"
7
- version = "0.7.8"
7
+ version = "0.7.9"
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"
@@ -90,11 +90,11 @@ class TestGetAdCreativesBugFix:
90
90
 
91
91
  urls = extract_creative_image_urls(test_creative)
92
92
 
93
- # Should extract URLs in order: image_url, thumbnail_url, picture
93
+ # Should extract URLs in order: image_url, picture, thumbnail_url (new priority order)
94
94
  expected_urls = [
95
95
  "https://example.com/image.jpg",
96
- "https://example.com/thumb.jpg",
97
- "https://example.com/picture.jpg"
96
+ "https://example.com/picture.jpg",
97
+ "https://example.com/thumb.jpg"
98
98
  ]
99
99
 
100
100
  assert urls == expected_urls
@@ -0,0 +1,391 @@
1
+ """Tests for get_ad_image quality improvements.
2
+
3
+ These tests verify that the get_ad_image function now correctly prioritizes
4
+ high-quality ad creative images over profile thumbnails.
5
+
6
+ Key improvements tested:
7
+ 1. Prioritizes image_urls_for_viewing over thumbnail_url
8
+ 2. Uses image_url as second priority
9
+ 3. Uses object_story_spec.link_data.picture as third priority
10
+ 4. Only uses thumbnail_url as last resort
11
+ 5. Better logging to show which image source is being used
12
+ """
13
+
14
+ import pytest
15
+ import json
16
+ from unittest.mock import AsyncMock, patch, MagicMock
17
+ from meta_ads_mcp.core.ads import get_ad_image
18
+ from meta_ads_mcp.core.utils import extract_creative_image_urls
19
+
20
+
21
+ class TestGetAdImageQualityImprovements:
22
+ """Test cases for image quality improvements in get_ad_image function."""
23
+
24
+ @pytest.mark.asyncio
25
+ async def test_prioritizes_image_urls_for_viewing_over_thumbnail(self):
26
+ """Test that image_urls_for_viewing is prioritized over thumbnail_url."""
27
+
28
+ # Mock responses for creative with both high-quality and thumbnail URLs
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
+ # No image_hash - triggers fallback
38
+ }
39
+
40
+ # Mock get_ad_creatives response with both URLs
41
+ mock_get_ad_creatives_response = json.dumps({
42
+ "data": [
43
+ {
44
+ "id": "creative_123456789",
45
+ "name": "Test Creative",
46
+ "status": "ACTIVE",
47
+ "thumbnail_url": "https://example.com/thumbnail_64x64.jpg", # Low quality
48
+ "image_url": "https://example.com/full_image.jpg", # Medium quality
49
+ "image_urls_for_viewing": [
50
+ "https://example.com/high_quality_image.jpg", # Highest quality
51
+ "https://example.com/alt_high_quality.jpg"
52
+ ],
53
+ "object_story_spec": {
54
+ "link_data": {
55
+ "picture": "https://example.com/object_story_picture.jpg"
56
+ }
57
+ }
58
+ }
59
+ ]
60
+ })
61
+
62
+ # Mock PIL Image processing
63
+ mock_pil_image = MagicMock()
64
+ mock_pil_image.mode = "RGB"
65
+ mock_pil_image.convert.return_value = mock_pil_image
66
+
67
+ mock_byte_stream = MagicMock()
68
+ mock_byte_stream.getvalue.return_value = b"fake_jpeg_data"
69
+
70
+ with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
71
+ patch('meta_ads_mcp.core.ads.get_ad_creatives', new_callable=AsyncMock) as mock_get_creatives, \
72
+ patch('meta_ads_mcp.core.ads.download_image', new_callable=AsyncMock) as mock_download, \
73
+ patch('meta_ads_mcp.core.ads.PILImage.open') as mock_pil_open, \
74
+ patch('meta_ads_mcp.core.ads.io.BytesIO') as mock_bytesio:
75
+
76
+ mock_api.side_effect = [mock_ad_data, mock_creative_details]
77
+ mock_get_creatives.return_value = mock_get_ad_creatives_response
78
+ mock_download.return_value = b"fake_image_bytes"
79
+ mock_pil_open.return_value = mock_pil_image
80
+ mock_bytesio.return_value = mock_byte_stream
81
+
82
+ # This should prioritize image_urls_for_viewing[0]
83
+ result = await get_ad_image(access_token="test_token", ad_id="test_ad_id")
84
+
85
+ # Verify it used the highest quality URL
86
+ assert result is not None
87
+ mock_download.assert_called_once_with("https://example.com/high_quality_image.jpg")
88
+
89
+ @pytest.mark.asyncio
90
+ async def test_falls_back_to_image_url_when_image_urls_for_viewing_unavailable(self):
91
+ """Test fallback to image_url when image_urls_for_viewing is not available."""
92
+
93
+ # Mock responses for creative without image_urls_for_viewing
94
+ mock_ad_data = {
95
+ "account_id": "act_123456789",
96
+ "creative": {"id": "creative_123456789"}
97
+ }
98
+
99
+ mock_creative_details = {
100
+ "id": "creative_123456789",
101
+ "name": "Test Creative"
102
+ }
103
+
104
+ # Mock get_ad_creatives response without image_urls_for_viewing
105
+ mock_get_ad_creatives_response = json.dumps({
106
+ "data": [
107
+ {
108
+ "id": "creative_123456789",
109
+ "name": "Test Creative",
110
+ "status": "ACTIVE",
111
+ "thumbnail_url": "https://example.com/thumbnail.jpg",
112
+ "image_url": "https://example.com/full_image.jpg", # Should be used
113
+ "object_story_spec": {
114
+ "link_data": {
115
+ "picture": "https://example.com/object_story_picture.jpg"
116
+ }
117
+ }
118
+ }
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]
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 fall back to image_url
143
+ result = await get_ad_image(access_token="test_token", ad_id="test_ad_id")
144
+
145
+ # Verify it used image_url
146
+ assert result is not None
147
+ mock_download.assert_called_once_with("https://example.com/full_image.jpg")
148
+
149
+ @pytest.mark.asyncio
150
+ async def test_falls_back_to_object_story_spec_picture_when_image_url_unavailable(self):
151
+ """Test fallback to object_story_spec.link_data.picture when image_url is not available."""
152
+
153
+ # Mock responses for creative without image_url
154
+ mock_ad_data = {
155
+ "account_id": "act_123456789",
156
+ "creative": {"id": "creative_123456789"}
157
+ }
158
+
159
+ mock_creative_details = {
160
+ "id": "creative_123456789",
161
+ "name": "Test Creative"
162
+ }
163
+
164
+ # Mock get_ad_creatives response without image_url
165
+ mock_get_ad_creatives_response = json.dumps({
166
+ "data": [
167
+ {
168
+ "id": "creative_123456789",
169
+ "name": "Test Creative",
170
+ "status": "ACTIVE",
171
+ "thumbnail_url": "https://example.com/thumbnail.jpg",
172
+ "object_story_spec": {
173
+ "link_data": {
174
+ "picture": "https://example.com/object_story_picture.jpg" # Should be used
175
+ }
176
+ }
177
+ }
178
+ ]
179
+ })
180
+
181
+ # Mock PIL Image processing
182
+ mock_pil_image = MagicMock()
183
+ mock_pil_image.mode = "RGB"
184
+ mock_pil_image.convert.return_value = mock_pil_image
185
+
186
+ mock_byte_stream = MagicMock()
187
+ mock_byte_stream.getvalue.return_value = b"fake_jpeg_data"
188
+
189
+ with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
190
+ patch('meta_ads_mcp.core.ads.get_ad_creatives', new_callable=AsyncMock) as mock_get_creatives, \
191
+ patch('meta_ads_mcp.core.ads.download_image', new_callable=AsyncMock) as mock_download, \
192
+ patch('meta_ads_mcp.core.ads.PILImage.open') as mock_pil_open, \
193
+ patch('meta_ads_mcp.core.ads.io.BytesIO') as mock_bytesio:
194
+
195
+ mock_api.side_effect = [mock_ad_data, mock_creative_details]
196
+ mock_get_creatives.return_value = mock_get_ad_creatives_response
197
+ mock_download.return_value = b"fake_image_bytes"
198
+ mock_pil_open.return_value = mock_pil_image
199
+ mock_bytesio.return_value = mock_byte_stream
200
+
201
+ # This should fall back to object_story_spec.link_data.picture
202
+ result = await get_ad_image(access_token="test_token", ad_id="test_ad_id")
203
+
204
+ # Verify it used object_story_spec.link_data.picture
205
+ assert result is not None
206
+ mock_download.assert_called_once_with("https://example.com/object_story_picture.jpg")
207
+
208
+ @pytest.mark.asyncio
209
+ async def test_uses_thumbnail_url_only_as_last_resort(self):
210
+ """Test that thumbnail_url is only used when no other options are available."""
211
+
212
+ # Mock responses for creative with only thumbnail_url
213
+ mock_ad_data = {
214
+ "account_id": "act_123456789",
215
+ "creative": {"id": "creative_123456789"}
216
+ }
217
+
218
+ mock_creative_details = {
219
+ "id": "creative_123456789",
220
+ "name": "Test Creative"
221
+ }
222
+
223
+ # Mock get_ad_creatives response with only thumbnail_url
224
+ mock_get_ad_creatives_response = json.dumps({
225
+ "data": [
226
+ {
227
+ "id": "creative_123456789",
228
+ "name": "Test Creative",
229
+ "status": "ACTIVE",
230
+ "thumbnail_url": "https://example.com/thumbnail_only.jpg" # Only option
231
+ }
232
+ ]
233
+ })
234
+
235
+ # Mock PIL Image processing
236
+ mock_pil_image = MagicMock()
237
+ mock_pil_image.mode = "RGB"
238
+ mock_pil_image.convert.return_value = mock_pil_image
239
+
240
+ mock_byte_stream = MagicMock()
241
+ mock_byte_stream.getvalue.return_value = b"fake_jpeg_data"
242
+
243
+ with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
244
+ patch('meta_ads_mcp.core.ads.get_ad_creatives', new_callable=AsyncMock) as mock_get_creatives, \
245
+ patch('meta_ads_mcp.core.ads.download_image', new_callable=AsyncMock) as mock_download, \
246
+ patch('meta_ads_mcp.core.ads.PILImage.open') as mock_pil_open, \
247
+ patch('meta_ads_mcp.core.ads.io.BytesIO') as mock_bytesio:
248
+
249
+ mock_api.side_effect = [mock_ad_data, mock_creative_details]
250
+ mock_get_creatives.return_value = mock_get_ad_creatives_response
251
+ mock_download.return_value = b"fake_image_bytes"
252
+ mock_pil_open.return_value = mock_pil_image
253
+ mock_bytesio.return_value = mock_byte_stream
254
+
255
+ # This should use thumbnail_url as last resort
256
+ result = await get_ad_image(access_token="test_token", ad_id="test_ad_id")
257
+
258
+ # Verify it used thumbnail_url
259
+ assert result is not None
260
+ mock_download.assert_called_once_with("https://example.com/thumbnail_only.jpg")
261
+
262
+ def test_extract_creative_image_urls_prioritizes_quality(self):
263
+ """Test that extract_creative_image_urls correctly prioritizes image quality."""
264
+
265
+ # Test creative with multiple image URLs
266
+ test_creative = {
267
+ "id": "creative_123456789",
268
+ "name": "Test Creative",
269
+ "thumbnail_url": "https://example.com/thumbnail.jpg", # Lowest priority
270
+ "image_url": "https://example.com/image.jpg", # Medium priority
271
+ "image_urls_for_viewing": [
272
+ "https://example.com/high_quality_1.jpg", # Highest priority
273
+ "https://example.com/high_quality_2.jpg"
274
+ ],
275
+ "object_story_spec": {
276
+ "link_data": {
277
+ "picture": "https://example.com/object_story_picture.jpg" # High priority
278
+ }
279
+ }
280
+ }
281
+
282
+ # Extract URLs
283
+ urls = extract_creative_image_urls(test_creative)
284
+
285
+ # Verify correct priority order
286
+ assert len(urls) >= 4
287
+ assert urls[0] == "https://example.com/high_quality_1.jpg" # First priority
288
+ assert urls[1] == "https://example.com/high_quality_2.jpg" # Second priority
289
+ assert "https://example.com/image.jpg" in urls # Medium priority
290
+ assert "https://example.com/object_story_picture.jpg" in urls # High priority
291
+ assert urls[-1] == "https://example.com/thumbnail.jpg" # Last priority
292
+
293
+ def test_extract_creative_image_urls_handles_missing_fields(self):
294
+ """Test that extract_creative_image_urls handles missing fields gracefully."""
295
+
296
+ # Test creative with minimal fields
297
+ test_creative = {
298
+ "id": "creative_123456789",
299
+ "name": "Minimal Creative",
300
+ "thumbnail_url": "https://example.com/thumbnail.jpg"
301
+ }
302
+
303
+ # Extract URLs
304
+ urls = extract_creative_image_urls(test_creative)
305
+
306
+ # Should still work with only thumbnail_url
307
+ assert len(urls) == 1
308
+ assert urls[0] == "https://example.com/thumbnail.jpg"
309
+
310
+ def test_extract_creative_image_urls_removes_duplicates(self):
311
+ """Test that extract_creative_image_urls removes duplicate URLs."""
312
+
313
+ # Test creative with duplicate URLs
314
+ test_creative = {
315
+ "id": "creative_123456789",
316
+ "name": "Duplicate URLs Creative",
317
+ "thumbnail_url": "https://example.com/same_url.jpg",
318
+ "image_url": "https://example.com/same_url.jpg", # Duplicate
319
+ "image_urls_for_viewing": [
320
+ "https://example.com/same_url.jpg", # Duplicate
321
+ "https://example.com/unique_url.jpg"
322
+ ]
323
+ }
324
+
325
+ # Extract URLs
326
+ urls = extract_creative_image_urls(test_creative)
327
+
328
+ # Should remove duplicates while preserving order
329
+ assert len(urls) == 2
330
+ assert urls[0] == "https://example.com/same_url.jpg" # First occurrence
331
+ assert urls[1] == "https://example.com/unique_url.jpg"
332
+
333
+ @pytest.mark.asyncio
334
+ async def test_get_ad_image_with_real_world_example(self):
335
+ """Test with a real-world example that mimics the actual API response structure."""
336
+
337
+ # Mock responses based on real API data
338
+ mock_ad_data = {
339
+ "account_id": "act_15975950",
340
+ "creative": {"id": "606995022142818"}
341
+ }
342
+
343
+ mock_creative_details = {
344
+ "id": "606995022142818",
345
+ "name": "Test Creative"
346
+ }
347
+
348
+ # Mock get_ad_creatives response based on real data
349
+ mock_get_ad_creatives_response = json.dumps({
350
+ "data": [
351
+ {
352
+ "id": "606995022142818",
353
+ "name": "Test Creative",
354
+ "status": "ACTIVE",
355
+ "thumbnail_url": "https://external.fbsb6-1.fna.fbcdn.net/emg1/v/t13/13476424677788553381?url=https%3A%2F%2Fwww.facebook.com%2Fads%2Fimage%2F%3Fd%3DAQLuJ5l4AROBvIUchp4g4JXxIT5uAZiAsgHQkD8Iw7BeVtkXNUUfs3leWpqQplJCJdixVIg3mq9KichJ64eRfM-r8aY4GtVQp8TvS_HBByJ8fGg_Cs7Kb8YkN4IDwJ4iQIIkMx30LycCKzuYtp9M-vOk&fb_obo=1&utld=facebook.com&stp=c0.5000x0.5000f_dst-emg0_p64x64_q75_tt6&edm=AEuWsiQEAAAA&_nc_gid=_QBCRbZxDq-i1ZiGEXxW2w&_nc_eui2=AeEbQXzmAdoqWLIXjuTDJ0xAoThZu47BlQqhOFm7jsGVCloP48Ep6Y_qIA5tcqrcSDff5f_k8xGzFIpD7PnUws8c&_nc_oc=Adn3GeYlXxbfEeY0wCBSgNdlwO80wXt5R5bgY2NozdroZ6CRSaXIaOSjVSK9S1LsqsY4GL_0dVzU80RY8QMucEkZ&ccb=13-1&oh=06_Q3-1AcBKUD0rfLGATAveIM5hMSWG9c7DsJzq2arvOl8W4Bpn&oe=688C87B2&_nc_sid=58080a",
356
+ "image_url": "https://scontent.fbsb6-1.fna.fbcdn.net/v/t45.1600-4/518574136_1116014047008737_2492837958169838537_n.png?stp=dst-jpg_tt6&_nc_cat=109&ccb=1-7&_nc_sid=890911&_nc_eui2=AeHbHqoiAUgF0QeX-tvUoDjYeTyJad_QEPF5PIlp39AQ8dP8cvOlHwiJjny8AUv7xxAlYyy5BGCqFU_oVM9CI7ln&_nc_ohc=VTTYlMOAWZoQ7kNvwGjLMW5&_nc_oc=AdnYDrpNrLovWZC_RG4tvoICGPjBNfzNJimhx-4SKW4BU2i_yzL00dX0-OiYEYokq394g8xR-1a-OuVDAm4HsSJy&_nc_zt=1&_nc_ht=scontent.fbsb6-1.fna&edm=AEuWsiQEAAAA&_nc_gid=_QBCRbZxDq-i1ZiGEXxW2w&oh=00_AfTujKmF365FnGgcokkkdWnK-vmnzQK8Icvlk0kB8SKM3g&oe=68906FC4",
357
+ "image_urls_for_viewing": [
358
+ "https://scontent.fbsb6-1.fna.fbcdn.net/v/t45.1600-4/518574136_1116014047008737_2492837958169838537_n.png?stp=dst-jpg_tt6&_nc_cat=109&ccb=1-7&_nc_sid=890911&_nc_eui2=AeHbHqoiAUgF0QeX-tvUoDjYeTyJad_QEPF5PIlp39AQ8dP8cvOlHwiJjny8AUv7xxAlYyy5BGCqFU_oVM9CI7ln&_nc_ohc=VTTYlMOAWZoQ7kNvwGjLMW5&_nc_oc=AdnYDrpNrLovWZC_RG4tvoICGPjBNfzNJimhx-4SKW4BU2i_yzL00dX0-OiYEYokq394g8xR-1a-OuVDAm4HsSJy&_nc_zt=1&_nc_ht=scontent.fbsb6-1.fna&edm=AEuWsiQEAAAA&_nc_gid=_QBCRbZxDq-i1ZiGEXxW2w&oh=00_AfTujKmF365FnGgcokkkdWnK-vmnzQK8Icvlk0kB8SKM3g&oe=68906FC4",
359
+ "https://external.fbsb6-1.fna.fbcdn.net/emg1/v/t13/13476424677788553381?url=https%3A%2F%2Fwww.facebook.com%2Fads%2Fimage%2F%3Fd%3DAQLuJ5l4AROBvIUchp4g4JXxIT5uAZiAsgHQkD8Iw7BeVtkXNUUfs3leWpqQplJCJdixVIg3mq9KichJ64eRfM-r8aY4GtVQp8TvS_HBByJ8fGg_Cs7Kb8YkN4IDwJ4iQIIkMx30LycCKzuYtp9M-vOk&fb_obo=1&utld=facebook.com&stp=c0.5000x0.5000f_dst-emg0_p64x64_q75_tt6&edm=AEuWsiQEAAAA&_nc_gid=_QBCRbZxDq-i1ZiGEXxW2w&_nc_eui2=AeEbQXzmAdoqWLIXjuTDJ0xAoThZu47BlQqhOFm7jsGVCloP48Ep6Y_qIA5tcqrcSDff5f_k8xGzFIpD7PnUws8c&_nc_oc=Adn3GeYlXxbfEeY0wCBSgNdlwO80wXt5R5bgY2NozdroZ6CRSaXIaOSjVSK9S1LsqsY4GL_0dVzU80RY8QMucEkZ&ccb=13-1&oh=06_Q3-1AcBKUD0rfLGATAveIM5hMSWG9c7DsJzq2arvOl8W4Bpn&oe=688C87B2&_nc_sid=58080a"
360
+ ]
361
+ }
362
+ ]
363
+ })
364
+
365
+ # Mock PIL Image processing
366
+ mock_pil_image = MagicMock()
367
+ mock_pil_image.mode = "RGB"
368
+ mock_pil_image.convert.return_value = mock_pil_image
369
+
370
+ mock_byte_stream = MagicMock()
371
+ mock_byte_stream.getvalue.return_value = b"fake_jpeg_data"
372
+
373
+ with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
374
+ patch('meta_ads_mcp.core.ads.get_ad_creatives', new_callable=AsyncMock) as mock_get_creatives, \
375
+ patch('meta_ads_mcp.core.ads.download_image', new_callable=AsyncMock) as mock_download, \
376
+ patch('meta_ads_mcp.core.ads.PILImage.open') as mock_pil_open, \
377
+ patch('meta_ads_mcp.core.ads.io.BytesIO') as mock_bytesio:
378
+
379
+ mock_api.side_effect = [mock_ad_data, mock_creative_details]
380
+ mock_get_creatives.return_value = mock_get_ad_creatives_response
381
+ mock_download.return_value = b"fake_image_bytes"
382
+ mock_pil_open.return_value = mock_pil_image
383
+ mock_bytesio.return_value = mock_byte_stream
384
+
385
+ # This should use the first image_urls_for_viewing URL (high quality)
386
+ result = await get_ad_image(access_token="test_token", ad_id="test_ad_id")
387
+
388
+ # Verify it used the high-quality URL (not the thumbnail)
389
+ assert result is not None
390
+ expected_url = "https://scontent.fbsb6-1.fna.fbcdn.net/v/t45.1600-4/518574136_1116014047008737_2492837958169838537_n.png?stp=dst-jpg_tt6&_nc_cat=109&ccb=1-7&_nc_sid=890911&_nc_eui2=AeHbHqoiAUgF0QeX-tvUoDjYeTyJad_QEPF5PIlp39AQ8dP8cvOlHwiJjny8AUv7xxAlYyy5BGCqFU_oVM9CI7ln&_nc_ohc=VTTYlMOAWZoQ7kNvwGjLMW5&_nc_oc=AdnYDrpNrLovWZC_RG4tvoICGPjBNfzNJimhx-4SKW4BU2i_yzL00dX0-OiYEYokq394g8xR-1a-OuVDAm4HsSJy&_nc_zt=1&_nc_ht=scontent.fbsb6-1.fna&edm=AEuWsiQEAAAA&_nc_gid=_QBCRbZxDq-i1ZiGEXxW2w&oh=00_AfTujKmF365FnGgcokkkdWnK-vmnzQK8Icvlk0kB8SKM3g&oe=68906FC4"
391
+ mock_download.assert_called_once_with(expected_url)
@@ -10,7 +10,12 @@ Tests for multiple issues that were fixed:
10
10
  - Many modern creatives don't have image_hash but have direct URLs
11
11
  - Fixed by adding direct URL fallback using image_urls_for_viewing and thumbnail_url
12
12
 
13
- The fixes enable get_ad_image to work with both traditional hash-based and modern URL-based creatives.
13
+ 3. Image Quality Issue: Function was returning profile thumbnails instead of ad creative images
14
+ - Fixed by prioritizing image_urls_for_viewing over thumbnail_url
15
+ - Added proper fallback hierarchy: image_urls_for_viewing > image_url > object_story_spec.picture > thumbnail_url
16
+
17
+ The fixes enable get_ad_image to work with both traditional hash-based and modern URL-based creatives,
18
+ and ensure high-quality images are returned instead of thumbnails.
14
19
  """
15
20
 
16
21
  import pytest
@@ -379,4 +384,71 @@ class TestGetAdImageRegressionFix:
379
384
 
380
385
  # Should get error about download failure
381
386
  assert isinstance(result, str)
382
- assert "Failed to download image from direct URL" in result
387
+ assert "Failed to download image from direct URL" in result
388
+
389
+ async def test_get_ad_image_quality_improvement_prioritizes_high_quality(self):
390
+ """Test that the image quality improvement correctly prioritizes high-quality images over thumbnails."""
391
+
392
+ # Mock responses for creative with both high-quality and thumbnail URLs
393
+ mock_ad_data = {
394
+ "account_id": "act_123456789",
395
+ "creative": {"id": "creative_123456789"}
396
+ }
397
+
398
+ mock_creative_details = {
399
+ "id": "creative_123456789",
400
+ "name": "Quality Test Creative"
401
+ }
402
+
403
+ # Mock get_ad_creatives response with both URLs
404
+ mock_get_ad_creatives_response = json.dumps({
405
+ "data": [
406
+ {
407
+ "id": "creative_123456789",
408
+ "name": "Quality Test Creative",
409
+ "status": "ACTIVE",
410
+ "thumbnail_url": "https://example.com/thumbnail_64x64.jpg", # Low quality thumbnail
411
+ "image_url": "https://example.com/full_image.jpg", # Medium quality
412
+ "image_urls_for_viewing": [
413
+ "https://example.com/high_quality_image.jpg", # Highest quality
414
+ "https://example.com/alt_high_quality.jpg"
415
+ ],
416
+ "object_story_spec": {
417
+ "link_data": {
418
+ "picture": "https://example.com/object_story_picture.jpg"
419
+ }
420
+ }
421
+ }
422
+ ]
423
+ })
424
+
425
+ # Mock PIL Image processing
426
+ mock_pil_image = MagicMock()
427
+ mock_pil_image.mode = "RGB"
428
+ mock_pil_image.convert.return_value = mock_pil_image
429
+
430
+ mock_byte_stream = MagicMock()
431
+ mock_byte_stream.getvalue.return_value = b"fake_jpeg_data"
432
+
433
+ with patch('meta_ads_mcp.core.ads.make_api_request', new_callable=AsyncMock) as mock_api, \
434
+ patch('meta_ads_mcp.core.ads.get_ad_creatives', new_callable=AsyncMock) as mock_get_creatives, \
435
+ patch('meta_ads_mcp.core.ads.download_image', new_callable=AsyncMock) as mock_download, \
436
+ patch('meta_ads_mcp.core.ads.PILImage.open') as mock_pil_open, \
437
+ patch('meta_ads_mcp.core.ads.io.BytesIO') as mock_bytesio:
438
+
439
+ mock_api.side_effect = [mock_ad_data, mock_creative_details]
440
+ mock_get_creatives.return_value = mock_get_ad_creatives_response
441
+ mock_download.return_value = b"fake_image_bytes"
442
+ mock_pil_open.return_value = mock_pil_image
443
+ mock_bytesio.return_value = mock_byte_stream
444
+
445
+ # This should prioritize image_urls_for_viewing[0] over thumbnail_url
446
+ result = await get_ad_image(access_token="test_token", ad_id="test_ad_id")
447
+
448
+ # Verify it used the highest quality URL, not the thumbnail
449
+ assert result is not None
450
+ mock_download.assert_called_once_with("https://example.com/high_quality_image.jpg")
451
+
452
+ # Verify it did NOT use the thumbnail URL
453
+ # Check that the call was made with the high-quality URL, not the thumbnail
454
+ mock_download.assert_called_once_with("https://example.com/high_quality_image.jpg")
@@ -1,97 +0,0 @@
1
- # Release Process
2
-
3
- This repository uses GitHub Actions to automatically publish releases to PyPI. Here's how it works:
4
-
5
- ## Automated Publishing
6
-
7
- ### Setup Status
8
-
9
- ✅ **Trusted Publishing Configured**: The repository is already set up with PyPI trusted publishing using the `release` environment.
10
-
11
- ### Creating a Release
12
-
13
- 1. **Update the version** in both files:
14
-
15
- In `pyproject.toml`:
16
- ```toml
17
- version = "0.3.8" # Increment as needed
18
- ```
19
-
20
- In `meta_ads_mcp/__init__.py`:
21
- ```python
22
- __version__ = "0.3.8" # Must match pyproject.toml
23
- ```
24
-
25
- 2. **Commit and push** the version changes:
26
- ```bash
27
- git add pyproject.toml meta_ads_mcp/__init__.py
28
- git commit -m "Bump version to 0.3.8"
29
- git push origin main
30
- ```
31
-
32
- 3. **Wait for build tests to pass** (optional):
33
- ```bash
34
- # Check the latest test workflow run
35
- gh run list --workflow=test.yml --limit 1
36
-
37
- # Get the run ID and wait for completion
38
- RUN_ID=$(gh run list --workflow=test.yml --limit 1 --json databaseId --jq '.[0].databaseId')
39
- gh run watch $RUN_ID
40
- ```
41
- Note: This only tests package building and installation, not the actual pytest tests.
42
-
43
- 4. **Create a GitHub release**:
44
- ```bash
45
- gh release create 0.3.8 --title "0.3.8" --generate-notes
46
- ```
47
- This command will:
48
- - Create a release with the specified version (no "v" prefix)
49
- - Auto-generate release notes from commits
50
- - Automatically trigger the GitHub Action for PyPI publishing
51
-
52
- 5. **Automatic deployment**:
53
- - The GitHub Action will automatically trigger
54
- - It will build the package and publish to PyPI
55
- - Check the "Actions" tab to monitor progress
56
-
57
- ## Workflows
58
-
59
- ### `publish.yml`
60
- - **Triggers**: When a GitHub release is published, or manual workflow dispatch
61
- - **Purpose**: Builds and publishes the package to PyPI
62
- - **Security**: Uses trusted publishing with OIDC tokens (no API keys needed)
63
-
64
- ### `test.yml`
65
- - **Triggers**: On pushes and pull requests to main/master
66
- - **Purpose**: Tests package building and installation across Python versions
67
- - **Matrix**: Tests Python 3.10, 3.11, and 3.12
68
- - **Note**: Does not run pytest tests, only validates package structure
69
-
70
- ## Manual Deployment
71
-
72
- If you need to deploy manually:
73
-
74
- ```bash
75
- # Install build tools
76
- pip install build twine
77
-
78
- # Build the package
79
- python -m build
80
-
81
- # Upload to PyPI (requires API token or configured credentials)
82
- python -m twine upload dist/*
83
- ```
84
-
85
- ## Version Management
86
-
87
- - Follow semantic versioning (SemVer): `MAJOR.MINOR.PATCH`
88
- - **Important**: Update version in BOTH `pyproject.toml` and `meta_ads_mcp/__init__.py`
89
- - The git tag should match the version (e.g., `v0.3.8` for version `0.3.8`)
90
- - Keep versions synchronized between the two files
91
-
92
- ## Security Notes
93
-
94
- - Trusted publishing is preferred over API tokens
95
- - Uses GitHub's OIDC tokens for secure authentication to PyPI
96
- - Only maintainers should be able to create releases
97
- - All builds run in isolated GitHub-hosted runners
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes