meta-ads-mcp 0.9.1__tar.gz → 0.9.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/PKG-INFO +2 -2
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/README.md +1 -1
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/__init__.py +1 -1
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/accounts.py +2 -2
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/adsets.py +68 -1
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/pyproject.toml +9 -1
- meta_ads_mcp-0.9.2/tests/test_mobile_app_adset_creation.py +501 -0
- meta_ads_mcp-0.9.2/tests/test_mobile_app_adset_issue.py +315 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/.github/workflows/publish.yml +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/.github/workflows/test.yml +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/.gitignore +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/CUSTOM_META_APP.md +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/Dockerfile +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/LICENSE +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/LOCAL_INSTALLATION.md +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/META_API_NOTES.md +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/RELEASE.md +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/STREAMABLE_HTTP_SETUP.md +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/examples/README.md +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/examples/example_http_client.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/future_improvements.md +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/images/meta-ads-example.png +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_auth.sh +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/__main__.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/__init__.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/ads.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/ads_library.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/api.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/auth.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/authentication.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/budget_schedules.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/callback_server.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/campaigns.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/duplication.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/http_auth_integration.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/insights.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/openai_deep_research.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/reports.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/resources.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/server.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/targeting.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/meta_ads_mcp/core/utils.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/requirements.txt +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/setup.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/smithery.yaml +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/README.md +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/README_REGRESSION_TESTS.md +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/__init__.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/conftest.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_account_search.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_budget_update.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_budget_update_e2e.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_dsa_beneficiary.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_dsa_integration.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_duplication.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_duplication_regression.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_dynamic_creatives.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_get_account_pages.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_get_ad_creatives_fix.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_get_ad_image_quality_improvements.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_get_ad_image_regression.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_http_transport.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_insights_actions_and_values.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_integration_openai_mcp.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_openai.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_openai_mcp_deep_research.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_page_discovery.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_page_discovery_integration.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_targeting.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_targeting_search_e2e.py +0 -0
- {meta_ads_mcp-0.9.1 → meta_ads_mcp-0.9.2}/tests/test_update_ad_creative_id.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meta-ads-mcp
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.2
|
|
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
|
|
@@ -136,7 +136,7 @@ For local installation configuration, authentication options, and advanced techn
|
|
|
136
136
|
- Inputs:
|
|
137
137
|
- `access_token` (optional): Meta API access token (will use cached token if not provided)
|
|
138
138
|
- `user_id`: Meta user ID or "me" for the current user
|
|
139
|
-
- `limit`: Maximum number of accounts to return (default:
|
|
139
|
+
- `limit`: Maximum number of accounts to return (default: 200)
|
|
140
140
|
- Returns: List of accessible ad accounts with their details
|
|
141
141
|
|
|
142
142
|
2. `mcp_meta_ads_get_account_info`
|
|
@@ -111,7 +111,7 @@ For local installation configuration, authentication options, and advanced techn
|
|
|
111
111
|
- Inputs:
|
|
112
112
|
- `access_token` (optional): Meta API access token (will use cached token if not provided)
|
|
113
113
|
- `user_id`: Meta user ID or "me" for the current user
|
|
114
|
-
- `limit`: Maximum number of accounts to return (default:
|
|
114
|
+
- `limit`: Maximum number of accounts to return (default: 200)
|
|
115
115
|
- Returns: List of accessible ad accounts with their details
|
|
116
116
|
|
|
117
117
|
2. `mcp_meta_ads_get_account_info`
|
|
@@ -8,14 +8,14 @@ from .server import mcp_server
|
|
|
8
8
|
|
|
9
9
|
@mcp_server.tool()
|
|
10
10
|
@meta_api_tool
|
|
11
|
-
async def get_ad_accounts(access_token: str = None, user_id: str = "me", limit: int =
|
|
11
|
+
async def get_ad_accounts(access_token: str = None, user_id: str = "me", limit: int = 200) -> str:
|
|
12
12
|
"""
|
|
13
13
|
Get ad accounts accessible by a user.
|
|
14
14
|
|
|
15
15
|
Args:
|
|
16
16
|
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
17
17
|
user_id: Meta user ID or "me" for the current user
|
|
18
|
-
limit: Maximum number of accounts to return (default:
|
|
18
|
+
limit: Maximum number of accounts to return (default: 200)
|
|
19
19
|
"""
|
|
20
20
|
endpoint = f"{user_id}/adaccounts"
|
|
21
21
|
params = {
|
|
@@ -104,6 +104,8 @@ async def create_adset(
|
|
|
104
104
|
start_time: str = None,
|
|
105
105
|
end_time: str = None,
|
|
106
106
|
dsa_beneficiary: str = None,
|
|
107
|
+
promoted_object: Dict[str, Any] = None,
|
|
108
|
+
destination_type: str = None,
|
|
107
109
|
access_token: str = None
|
|
108
110
|
) -> str:
|
|
109
111
|
"""
|
|
@@ -118,13 +120,18 @@ async def create_adset(
|
|
|
118
120
|
lifetime_budget: Lifetime budget in account currency (in cents) as a string
|
|
119
121
|
targeting: Targeting specifications including age, location, interests, etc.
|
|
120
122
|
Use targeting_automation.advantage_audience=1 for automatic audience finding
|
|
121
|
-
optimization_goal: Conversion optimization goal (e.g., 'LINK_CLICKS', 'REACH', 'CONVERSIONS')
|
|
123
|
+
optimization_goal: Conversion optimization goal (e.g., 'LINK_CLICKS', 'REACH', 'CONVERSIONS', 'APP_INSTALLS')
|
|
122
124
|
billing_event: How you're charged (e.g., 'IMPRESSIONS', 'LINK_CLICKS')
|
|
123
125
|
bid_amount: Bid amount in account currency (in cents)
|
|
124
126
|
bid_strategy: Bid strategy (e.g., 'LOWEST_COST', 'LOWEST_COST_WITH_BID_CAP')
|
|
125
127
|
start_time: Start time in ISO 8601 format (e.g., '2023-12-01T12:00:00-0800')
|
|
126
128
|
end_time: End time in ISO 8601 format
|
|
127
129
|
dsa_beneficiary: DSA beneficiary (person/organization benefiting from ads) for European compliance
|
|
130
|
+
promoted_object: Mobile app configuration for APP_INSTALLS campaigns. Required fields: application_id, object_store_url.
|
|
131
|
+
Optional fields: custom_event_type, pixel_id, page_id.
|
|
132
|
+
Example: {"application_id": "123456789012345", "object_store_url": "https://apps.apple.com/app/id123456789"}
|
|
133
|
+
destination_type: Where users are directed after clicking the ad (e.g., 'APP_STORE', 'DEEPLINK', 'APP_INSTALL').
|
|
134
|
+
Required for mobile app campaigns.
|
|
128
135
|
access_token: Meta API access token (optional - will use cached token if not provided)
|
|
129
136
|
"""
|
|
130
137
|
# Check required parameters
|
|
@@ -143,6 +150,59 @@ async def create_adset(
|
|
|
143
150
|
if not billing_event:
|
|
144
151
|
return json.dumps({"error": "No billing event provided"}, indent=2)
|
|
145
152
|
|
|
153
|
+
# Validate mobile app parameters for APP_INSTALLS campaigns
|
|
154
|
+
if optimization_goal == "APP_INSTALLS":
|
|
155
|
+
if not promoted_object:
|
|
156
|
+
return json.dumps({
|
|
157
|
+
"error": "promoted_object is required for APP_INSTALLS optimization goal",
|
|
158
|
+
"details": "Mobile app campaigns must specify which app is being promoted",
|
|
159
|
+
"required_fields": ["application_id", "object_store_url"]
|
|
160
|
+
}, indent=2)
|
|
161
|
+
|
|
162
|
+
# Validate promoted_object structure
|
|
163
|
+
if not isinstance(promoted_object, dict):
|
|
164
|
+
return json.dumps({
|
|
165
|
+
"error": "promoted_object must be a dictionary",
|
|
166
|
+
"example": {"application_id": "123456789012345", "object_store_url": "https://apps.apple.com/app/id123456789"}
|
|
167
|
+
}, indent=2)
|
|
168
|
+
|
|
169
|
+
# Validate required promoted_object fields
|
|
170
|
+
if "application_id" not in promoted_object:
|
|
171
|
+
return json.dumps({
|
|
172
|
+
"error": "promoted_object missing required field: application_id",
|
|
173
|
+
"details": "application_id is the Facebook app ID for your mobile app"
|
|
174
|
+
}, indent=2)
|
|
175
|
+
|
|
176
|
+
if "object_store_url" not in promoted_object:
|
|
177
|
+
return json.dumps({
|
|
178
|
+
"error": "promoted_object missing required field: object_store_url",
|
|
179
|
+
"details": "object_store_url should be the App Store or Google Play URL for your app"
|
|
180
|
+
}, indent=2)
|
|
181
|
+
|
|
182
|
+
# Validate store URL format
|
|
183
|
+
store_url = promoted_object["object_store_url"]
|
|
184
|
+
valid_store_patterns = [
|
|
185
|
+
"apps.apple.com", # iOS App Store
|
|
186
|
+
"play.google.com", # Google Play Store
|
|
187
|
+
"itunes.apple.com" # Alternative iOS format
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
if not any(pattern in store_url for pattern in valid_store_patterns):
|
|
191
|
+
return json.dumps({
|
|
192
|
+
"error": "Invalid object_store_url format",
|
|
193
|
+
"details": "URL must be from App Store (apps.apple.com) or Google Play (play.google.com)",
|
|
194
|
+
"provided_url": store_url
|
|
195
|
+
}, indent=2)
|
|
196
|
+
|
|
197
|
+
# Validate destination_type if provided
|
|
198
|
+
if destination_type:
|
|
199
|
+
valid_destination_types = ["APP_STORE", "DEEPLINK", "APP_INSTALL"]
|
|
200
|
+
if destination_type not in valid_destination_types:
|
|
201
|
+
return json.dumps({
|
|
202
|
+
"error": f"Invalid destination_type: {destination_type}",
|
|
203
|
+
"valid_values": valid_destination_types
|
|
204
|
+
}, indent=2)
|
|
205
|
+
|
|
146
206
|
# Basic targeting is required if not provided
|
|
147
207
|
if not targeting:
|
|
148
208
|
targeting = {
|
|
@@ -187,6 +247,13 @@ async def create_adset(
|
|
|
187
247
|
if dsa_beneficiary:
|
|
188
248
|
params["dsa_beneficiary"] = dsa_beneficiary
|
|
189
249
|
|
|
250
|
+
# Add mobile app parameters if provided
|
|
251
|
+
if promoted_object:
|
|
252
|
+
params["promoted_object"] = json.dumps(promoted_object)
|
|
253
|
+
|
|
254
|
+
if destination_type:
|
|
255
|
+
params["destination_type"] = destination_type
|
|
256
|
+
|
|
190
257
|
try:
|
|
191
258
|
data = await make_api_request(endpoint, access_token, params, method="POST")
|
|
192
259
|
return json.dumps(data, indent=2)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "meta-ads-mcp"
|
|
7
|
-
version = "0.9.
|
|
7
|
+
version = "0.9.2"
|
|
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"
|
|
@@ -39,3 +39,11 @@ meta-ads-mcp = "meta_ads_mcp:entrypoint"
|
|
|
39
39
|
|
|
40
40
|
[tool.hatch.build.targets.wheel]
|
|
41
41
|
packages = ["meta_ads_mcp"]
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
markers = [
|
|
45
|
+
"e2e: marks tests as end-to-end (requires running MCP server) - excluded from default runs",
|
|
46
|
+
]
|
|
47
|
+
addopts = "-v --strict-markers -m 'not e2e'"
|
|
48
|
+
testpaths = ["tests"]
|
|
49
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Unit Tests for Mobile App Adset Creation Functionality
|
|
4
|
+
|
|
5
|
+
This test suite validates the mobile app parameters implementation for the
|
|
6
|
+
create_adset function in meta_ads_mcp/core/adsets.py.
|
|
7
|
+
|
|
8
|
+
Test cases cover:
|
|
9
|
+
- Mobile app adset creation success scenarios
|
|
10
|
+
- promoted_object parameter validation and formatting
|
|
11
|
+
- destination_type parameter validation
|
|
12
|
+
- Mobile app specific error handling
|
|
13
|
+
- Cross-platform mobile app support (iOS, Android)
|
|
14
|
+
- Integration with APP_INSTALLS optimization goal
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
uv run python -m pytest tests/test_mobile_app_adset_creation.py -v
|
|
18
|
+
|
|
19
|
+
Related to Issue #008: Missing Mobile App Parameters in create_adset Function
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import pytest
|
|
23
|
+
import json
|
|
24
|
+
import asyncio
|
|
25
|
+
from unittest.mock import AsyncMock, patch, MagicMock
|
|
26
|
+
from typing import Dict, Any, List
|
|
27
|
+
|
|
28
|
+
# Import the function to test
|
|
29
|
+
from meta_ads_mcp.core.adsets import create_adset
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestMobileAppAdsetCreation:
|
|
33
|
+
"""Test suite for mobile app adset creation functionality"""
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
def mock_api_request(self):
|
|
37
|
+
"""Mock for the make_api_request function"""
|
|
38
|
+
with patch('meta_ads_mcp.core.adsets.make_api_request') as mock:
|
|
39
|
+
mock.return_value = {
|
|
40
|
+
"id": "test_mobile_adset_id",
|
|
41
|
+
"name": "Test Mobile App Adset",
|
|
42
|
+
"optimization_goal": "APP_INSTALLS",
|
|
43
|
+
"promoted_object": {
|
|
44
|
+
"application_id": "123456789012345",
|
|
45
|
+
"object_store_url": "https://apps.apple.com/app/id123456789"
|
|
46
|
+
},
|
|
47
|
+
"destination_type": "APP_STORE"
|
|
48
|
+
}
|
|
49
|
+
yield mock
|
|
50
|
+
|
|
51
|
+
@pytest.fixture
|
|
52
|
+
def mock_auth_manager(self):
|
|
53
|
+
"""Mock for the authentication manager"""
|
|
54
|
+
with patch('meta_ads_mcp.core.api.auth_manager') as mock, \
|
|
55
|
+
patch('meta_ads_mcp.core.api.get_current_access_token') as mock_get_token:
|
|
56
|
+
# Mock a valid access token
|
|
57
|
+
mock.get_current_access_token.return_value = "test_access_token"
|
|
58
|
+
mock.is_token_valid.return_value = True
|
|
59
|
+
mock.app_id = "test_app_id"
|
|
60
|
+
mock_get_token.return_value = "test_access_token"
|
|
61
|
+
yield mock
|
|
62
|
+
|
|
63
|
+
@pytest.fixture
|
|
64
|
+
def valid_mobile_app_params(self):
|
|
65
|
+
"""Valid mobile app parameters for testing"""
|
|
66
|
+
return {
|
|
67
|
+
"account_id": "act_123456789",
|
|
68
|
+
"campaign_id": "campaign_123456789",
|
|
69
|
+
"name": "Test Mobile App Adset",
|
|
70
|
+
"optimization_goal": "APP_INSTALLS",
|
|
71
|
+
"billing_event": "IMPRESSIONS",
|
|
72
|
+
"targeting": {
|
|
73
|
+
"age_min": 18,
|
|
74
|
+
"age_max": 65,
|
|
75
|
+
"app_install_state": "not_installed",
|
|
76
|
+
"geo_locations": {"countries": ["US"]},
|
|
77
|
+
"user_device": ["Android_Smartphone", "iPhone"],
|
|
78
|
+
"user_os": ["Android", "iOS"]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@pytest.fixture
|
|
83
|
+
def ios_promoted_object(self):
|
|
84
|
+
"""Valid iOS app promoted object"""
|
|
85
|
+
return {
|
|
86
|
+
"application_id": "123456789012345",
|
|
87
|
+
"object_store_url": "https://apps.apple.com/app/id123456789",
|
|
88
|
+
"custom_event_type": "APP_INSTALL"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@pytest.fixture
|
|
92
|
+
def android_promoted_object(self):
|
|
93
|
+
"""Valid Android app promoted object"""
|
|
94
|
+
return {
|
|
95
|
+
"application_id": "987654321098765",
|
|
96
|
+
"object_store_url": "https://play.google.com/store/apps/details?id=com.example.app",
|
|
97
|
+
"custom_event_type": "APP_INSTALL"
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@pytest.fixture
|
|
101
|
+
def promoted_object_with_pixel(self):
|
|
102
|
+
"""Promoted object with Facebook pixel for tracking"""
|
|
103
|
+
return {
|
|
104
|
+
"application_id": "123456789012345",
|
|
105
|
+
"object_store_url": "https://apps.apple.com/app/id123456789",
|
|
106
|
+
"custom_event_type": "APP_INSTALL",
|
|
107
|
+
"pixel_id": "pixel_123456789"
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Test: Mobile App Adset Creation Success
|
|
111
|
+
@pytest.mark.asyncio
|
|
112
|
+
async def test_mobile_app_adset_creation_success_ios(
|
|
113
|
+
self, mock_api_request, mock_auth_manager, valid_mobile_app_params, ios_promoted_object
|
|
114
|
+
):
|
|
115
|
+
"""Test successful iOS mobile app adset creation"""
|
|
116
|
+
|
|
117
|
+
result = await create_adset(
|
|
118
|
+
**valid_mobile_app_params,
|
|
119
|
+
promoted_object=ios_promoted_object,
|
|
120
|
+
destination_type="APP_STORE"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Parse the result
|
|
124
|
+
result_data = json.loads(result)
|
|
125
|
+
|
|
126
|
+
# Verify the API was called with correct parameters
|
|
127
|
+
mock_api_request.assert_called_once()
|
|
128
|
+
call_args = mock_api_request.call_args
|
|
129
|
+
|
|
130
|
+
# Check endpoint (first argument)
|
|
131
|
+
assert call_args[0][0] == f"{valid_mobile_app_params['account_id']}/adsets"
|
|
132
|
+
|
|
133
|
+
# Check parameters (third argument)
|
|
134
|
+
params = call_args[0][2]
|
|
135
|
+
assert 'promoted_object' in params
|
|
136
|
+
assert 'destination_type' in params
|
|
137
|
+
|
|
138
|
+
# Verify promoted_object is properly JSON-encoded
|
|
139
|
+
promoted_obj_param = json.loads(params['promoted_object']) if isinstance(params['promoted_object'], str) else params['promoted_object']
|
|
140
|
+
assert promoted_obj_param['application_id'] == ios_promoted_object['application_id']
|
|
141
|
+
assert promoted_obj_param['object_store_url'] == ios_promoted_object['object_store_url']
|
|
142
|
+
|
|
143
|
+
# Verify destination_type
|
|
144
|
+
assert params['destination_type'] == "APP_STORE"
|
|
145
|
+
|
|
146
|
+
# Verify response structure
|
|
147
|
+
assert 'id' in result_data
|
|
148
|
+
assert result_data['optimization_goal'] == "APP_INSTALLS"
|
|
149
|
+
|
|
150
|
+
@pytest.mark.asyncio
|
|
151
|
+
async def test_mobile_app_adset_creation_success_android(
|
|
152
|
+
self, mock_api_request, mock_auth_manager, valid_mobile_app_params, android_promoted_object
|
|
153
|
+
):
|
|
154
|
+
"""Test successful Android mobile app adset creation"""
|
|
155
|
+
|
|
156
|
+
result = await create_adset(
|
|
157
|
+
**valid_mobile_app_params,
|
|
158
|
+
promoted_object=android_promoted_object,
|
|
159
|
+
destination_type="APP_STORE"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Parse the result
|
|
163
|
+
result_data = json.loads(result)
|
|
164
|
+
|
|
165
|
+
# Verify the API was called
|
|
166
|
+
mock_api_request.assert_called_once()
|
|
167
|
+
call_args = mock_api_request.call_args
|
|
168
|
+
params = call_args[0][2]
|
|
169
|
+
|
|
170
|
+
# Verify Android-specific promoted_object
|
|
171
|
+
promoted_obj_param = json.loads(params['promoted_object']) if isinstance(params['promoted_object'], str) else params['promoted_object']
|
|
172
|
+
assert promoted_obj_param['application_id'] == android_promoted_object['application_id']
|
|
173
|
+
assert "play.google.com" in promoted_obj_param['object_store_url']
|
|
174
|
+
|
|
175
|
+
# Verify response
|
|
176
|
+
assert 'id' in result_data
|
|
177
|
+
|
|
178
|
+
@pytest.mark.asyncio
|
|
179
|
+
async def test_mobile_app_adset_with_pixel_tracking(
|
|
180
|
+
self, mock_api_request, mock_auth_manager, valid_mobile_app_params, promoted_object_with_pixel
|
|
181
|
+
):
|
|
182
|
+
"""Test mobile app adset creation with Facebook pixel tracking"""
|
|
183
|
+
|
|
184
|
+
result = await create_adset(
|
|
185
|
+
**valid_mobile_app_params,
|
|
186
|
+
promoted_object=promoted_object_with_pixel,
|
|
187
|
+
destination_type="APP_STORE"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Verify pixel_id is included
|
|
191
|
+
call_args = mock_api_request.call_args
|
|
192
|
+
params = call_args[0][2]
|
|
193
|
+
promoted_obj_param = json.loads(params['promoted_object']) if isinstance(params['promoted_object'], str) else params['promoted_object']
|
|
194
|
+
|
|
195
|
+
assert 'pixel_id' in promoted_obj_param
|
|
196
|
+
assert promoted_obj_param['pixel_id'] == promoted_object_with_pixel['pixel_id']
|
|
197
|
+
|
|
198
|
+
# Test: Parameter Validation
|
|
199
|
+
@pytest.mark.asyncio
|
|
200
|
+
async def test_invalid_promoted_object_missing_application_id(
|
|
201
|
+
self, mock_api_request, mock_auth_manager, valid_mobile_app_params
|
|
202
|
+
):
|
|
203
|
+
"""Test validation error for promoted_object missing application_id"""
|
|
204
|
+
|
|
205
|
+
invalid_promoted_object = {
|
|
206
|
+
"object_store_url": "https://apps.apple.com/app/id123456789",
|
|
207
|
+
"custom_event_type": "APP_INSTALL"
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
result = await create_adset(
|
|
211
|
+
**valid_mobile_app_params,
|
|
212
|
+
promoted_object=invalid_promoted_object,
|
|
213
|
+
destination_type="APP_STORE"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
result_data = json.loads(result)
|
|
217
|
+
|
|
218
|
+
# Should return validation error - check for data wrapper format
|
|
219
|
+
if "data" in result_data:
|
|
220
|
+
error_data = json.loads(result_data["data"])
|
|
221
|
+
assert 'error' in error_data
|
|
222
|
+
assert 'application_id' in error_data['error'].lower()
|
|
223
|
+
else:
|
|
224
|
+
assert 'error' in result_data
|
|
225
|
+
assert 'application_id' in result_data['error'].lower()
|
|
226
|
+
|
|
227
|
+
@pytest.mark.asyncio
|
|
228
|
+
async def test_invalid_promoted_object_missing_store_url(
|
|
229
|
+
self, mock_api_request, mock_auth_manager, valid_mobile_app_params
|
|
230
|
+
):
|
|
231
|
+
"""Test validation error for promoted_object missing object_store_url"""
|
|
232
|
+
|
|
233
|
+
invalid_promoted_object = {
|
|
234
|
+
"application_id": "123456789012345",
|
|
235
|
+
"custom_event_type": "APP_INSTALL"
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
result = await create_adset(
|
|
239
|
+
**valid_mobile_app_params,
|
|
240
|
+
promoted_object=invalid_promoted_object,
|
|
241
|
+
destination_type="APP_STORE"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
result_data = json.loads(result)
|
|
245
|
+
|
|
246
|
+
# Should return validation error - check for data wrapper format
|
|
247
|
+
if "data" in result_data:
|
|
248
|
+
error_data = json.loads(result_data["data"])
|
|
249
|
+
assert 'error' in error_data
|
|
250
|
+
assert 'object_store_url' in error_data['error'].lower()
|
|
251
|
+
else:
|
|
252
|
+
assert 'error' in result_data
|
|
253
|
+
assert 'object_store_url' in result_data['error'].lower()
|
|
254
|
+
|
|
255
|
+
@pytest.mark.asyncio
|
|
256
|
+
async def test_invalid_destination_type(
|
|
257
|
+
self, mock_api_request, mock_auth_manager, valid_mobile_app_params, ios_promoted_object
|
|
258
|
+
):
|
|
259
|
+
"""Test validation error for invalid destination_type value"""
|
|
260
|
+
|
|
261
|
+
result = await create_adset(
|
|
262
|
+
**valid_mobile_app_params,
|
|
263
|
+
promoted_object=ios_promoted_object,
|
|
264
|
+
destination_type="INVALID_TYPE"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
result_data = json.loads(result)
|
|
268
|
+
|
|
269
|
+
# Should return validation error - check for data wrapper format
|
|
270
|
+
if "data" in result_data:
|
|
271
|
+
error_data = json.loads(result_data["data"])
|
|
272
|
+
assert 'error' in error_data
|
|
273
|
+
assert 'destination_type' in error_data['error'].lower()
|
|
274
|
+
else:
|
|
275
|
+
assert 'error' in result_data
|
|
276
|
+
assert 'destination_type' in result_data['error'].lower()
|
|
277
|
+
|
|
278
|
+
@pytest.mark.asyncio
|
|
279
|
+
async def test_app_installs_requires_promoted_object(
|
|
280
|
+
self, mock_api_request, mock_auth_manager, valid_mobile_app_params
|
|
281
|
+
):
|
|
282
|
+
"""Test that APP_INSTALLS optimization goal requires promoted_object"""
|
|
283
|
+
|
|
284
|
+
result = await create_adset(
|
|
285
|
+
**valid_mobile_app_params,
|
|
286
|
+
# Missing promoted_object
|
|
287
|
+
destination_type="APP_STORE"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
result_data = json.loads(result)
|
|
291
|
+
|
|
292
|
+
# Should return validation error - check for data wrapper format
|
|
293
|
+
if "data" in result_data:
|
|
294
|
+
error_data = json.loads(result_data["data"])
|
|
295
|
+
assert 'error' in error_data
|
|
296
|
+
assert 'promoted_object' in error_data['error'].lower()
|
|
297
|
+
assert 'app_installs' in error_data['error'].lower()
|
|
298
|
+
else:
|
|
299
|
+
assert 'error' in result_data
|
|
300
|
+
assert 'promoted_object' in result_data['error'].lower()
|
|
301
|
+
assert 'app_installs' in result_data['error'].lower()
|
|
302
|
+
|
|
303
|
+
# Test: Cross-platform Support
|
|
304
|
+
@pytest.mark.asyncio
|
|
305
|
+
async def test_ios_app_store_url_validation(
|
|
306
|
+
self, mock_api_request, mock_auth_manager, valid_mobile_app_params
|
|
307
|
+
):
|
|
308
|
+
"""Test iOS App Store URL format validation"""
|
|
309
|
+
|
|
310
|
+
ios_promoted_object = {
|
|
311
|
+
"application_id": "123456789012345",
|
|
312
|
+
"object_store_url": "https://apps.apple.com/app/id123456789",
|
|
313
|
+
"custom_event_type": "APP_INSTALL"
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
result = await create_adset(
|
|
317
|
+
**valid_mobile_app_params,
|
|
318
|
+
promoted_object=ios_promoted_object,
|
|
319
|
+
destination_type="APP_STORE"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
result_data = json.loads(result)
|
|
323
|
+
|
|
324
|
+
# Should succeed for valid iOS URL
|
|
325
|
+
assert 'error' not in result_data or result_data.get('error') is None
|
|
326
|
+
|
|
327
|
+
@pytest.mark.asyncio
|
|
328
|
+
async def test_google_play_url_validation(
|
|
329
|
+
self, mock_api_request, mock_auth_manager, valid_mobile_app_params
|
|
330
|
+
):
|
|
331
|
+
"""Test Google Play Store URL format validation"""
|
|
332
|
+
|
|
333
|
+
android_promoted_object = {
|
|
334
|
+
"application_id": "987654321098765",
|
|
335
|
+
"object_store_url": "https://play.google.com/store/apps/details?id=com.example.app",
|
|
336
|
+
"custom_event_type": "APP_INSTALL"
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
result = await create_adset(
|
|
340
|
+
**valid_mobile_app_params,
|
|
341
|
+
promoted_object=android_promoted_object,
|
|
342
|
+
destination_type="APP_STORE"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
result_data = json.loads(result)
|
|
346
|
+
|
|
347
|
+
# Should succeed for valid Google Play URL
|
|
348
|
+
assert 'error' not in result_data or result_data.get('error') is None
|
|
349
|
+
|
|
350
|
+
@pytest.mark.asyncio
|
|
351
|
+
async def test_invalid_store_url_format(
|
|
352
|
+
self, mock_api_request, mock_auth_manager, valid_mobile_app_params
|
|
353
|
+
):
|
|
354
|
+
"""Test validation error for invalid app store URL format"""
|
|
355
|
+
|
|
356
|
+
invalid_promoted_object = {
|
|
357
|
+
"application_id": "123456789012345",
|
|
358
|
+
"object_store_url": "https://example.com/invalid-url",
|
|
359
|
+
"custom_event_type": "APP_INSTALL"
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
result = await create_adset(
|
|
363
|
+
**valid_mobile_app_params,
|
|
364
|
+
promoted_object=invalid_promoted_object,
|
|
365
|
+
destination_type="APP_STORE"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
result_data = json.loads(result)
|
|
369
|
+
|
|
370
|
+
# Should return validation error for invalid URL - check for data wrapper format
|
|
371
|
+
if "data" in result_data:
|
|
372
|
+
error_data = json.loads(result_data["data"])
|
|
373
|
+
assert 'error' in error_data
|
|
374
|
+
assert 'store url' in error_data['error'].lower() or 'object_store_url' in error_data['error'].lower()
|
|
375
|
+
else:
|
|
376
|
+
assert 'error' in result_data
|
|
377
|
+
assert 'store url' in result_data['error'].lower() or 'object_store_url' in result_data['error'].lower()
|
|
378
|
+
|
|
379
|
+
# Test: Destination Type Variations
|
|
380
|
+
@pytest.mark.asyncio
|
|
381
|
+
async def test_deeplink_destination_type(
|
|
382
|
+
self, mock_api_request, mock_auth_manager, valid_mobile_app_params, ios_promoted_object
|
|
383
|
+
):
|
|
384
|
+
"""Test DEEPLINK destination_type"""
|
|
385
|
+
|
|
386
|
+
result = await create_adset(
|
|
387
|
+
**valid_mobile_app_params,
|
|
388
|
+
promoted_object=ios_promoted_object,
|
|
389
|
+
destination_type="DEEPLINK"
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
call_args = mock_api_request.call_args
|
|
393
|
+
params = call_args[0][2]
|
|
394
|
+
assert params['destination_type'] == "DEEPLINK"
|
|
395
|
+
|
|
396
|
+
@pytest.mark.asyncio
|
|
397
|
+
async def test_app_install_destination_type(
|
|
398
|
+
self, mock_api_request, mock_auth_manager, valid_mobile_app_params, ios_promoted_object
|
|
399
|
+
):
|
|
400
|
+
"""Test APP_INSTALL destination_type"""
|
|
401
|
+
|
|
402
|
+
result = await create_adset(
|
|
403
|
+
**valid_mobile_app_params,
|
|
404
|
+
promoted_object=ios_promoted_object,
|
|
405
|
+
destination_type="APP_INSTALL"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
call_args = mock_api_request.call_args
|
|
409
|
+
params = call_args[0][2]
|
|
410
|
+
assert params['destination_type'] == "APP_INSTALL"
|
|
411
|
+
|
|
412
|
+
# Test: Error Handling
|
|
413
|
+
@pytest.mark.asyncio
|
|
414
|
+
async def test_meta_api_error_handling(
|
|
415
|
+
self, mock_auth_manager, valid_mobile_app_params, ios_promoted_object
|
|
416
|
+
):
|
|
417
|
+
"""Test handling of Meta API errors for mobile app adsets"""
|
|
418
|
+
|
|
419
|
+
with patch('meta_ads_mcp.core.adsets.make_api_request') as mock_api:
|
|
420
|
+
# Mock Meta API error response
|
|
421
|
+
mock_api.side_effect = Exception("HTTP Error: 400 - Select a dataset and conversion event for your ad set")
|
|
422
|
+
|
|
423
|
+
result = await create_adset(
|
|
424
|
+
**valid_mobile_app_params,
|
|
425
|
+
promoted_object=ios_promoted_object,
|
|
426
|
+
destination_type="APP_STORE"
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
result_data = json.loads(result)
|
|
430
|
+
|
|
431
|
+
# Should handle the error gracefully - check for data wrapper format
|
|
432
|
+
if "data" in result_data:
|
|
433
|
+
error_data = json.loads(result_data["data"])
|
|
434
|
+
assert 'error' in error_data
|
|
435
|
+
# Check for error text in either error message or details
|
|
436
|
+
error_text = error_data.get('error', '').lower()
|
|
437
|
+
details_text = error_data.get('details', '').lower()
|
|
438
|
+
assert 'dataset' in error_text or 'conversion event' in error_text or \
|
|
439
|
+
'dataset' in details_text or 'conversion event' in details_text
|
|
440
|
+
else:
|
|
441
|
+
assert 'error' in result_data
|
|
442
|
+
error_text = result_data.get('error', '').lower()
|
|
443
|
+
details_text = result_data.get('details', '').lower()
|
|
444
|
+
assert 'dataset' in error_text or 'conversion event' in error_text or \
|
|
445
|
+
'dataset' in details_text or 'conversion event' in details_text
|
|
446
|
+
|
|
447
|
+
# Test: Backward Compatibility
|
|
448
|
+
@pytest.mark.asyncio
|
|
449
|
+
async def test_backward_compatibility_non_mobile_campaigns(
|
|
450
|
+
self, mock_api_request, mock_auth_manager
|
|
451
|
+
):
|
|
452
|
+
"""Test that non-mobile campaigns still work without mobile app parameters"""
|
|
453
|
+
|
|
454
|
+
non_mobile_params = {
|
|
455
|
+
"account_id": "act_123456789",
|
|
456
|
+
"campaign_id": "campaign_123456789",
|
|
457
|
+
"name": "Test Web Adset",
|
|
458
|
+
"optimization_goal": "LINK_CLICKS",
|
|
459
|
+
"billing_event": "LINK_CLICKS",
|
|
460
|
+
"targeting": {
|
|
461
|
+
"age_min": 18,
|
|
462
|
+
"age_max": 65,
|
|
463
|
+
"geo_locations": {"countries": ["US"]}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
result = await create_adset(**non_mobile_params)
|
|
468
|
+
|
|
469
|
+
# Should work without mobile app parameters
|
|
470
|
+
mock_api_request.assert_called_once()
|
|
471
|
+
call_args = mock_api_request.call_args
|
|
472
|
+
params = call_args[0][2]
|
|
473
|
+
|
|
474
|
+
# Should not include mobile app parameters
|
|
475
|
+
assert 'promoted_object' not in params
|
|
476
|
+
assert 'destination_type' not in params
|
|
477
|
+
|
|
478
|
+
@pytest.mark.asyncio
|
|
479
|
+
async def test_optional_mobile_parameters(
|
|
480
|
+
self, mock_api_request, mock_auth_manager, valid_mobile_app_params, ios_promoted_object
|
|
481
|
+
):
|
|
482
|
+
"""Test that mobile app parameters are optional for non-APP_INSTALLS campaigns"""
|
|
483
|
+
|
|
484
|
+
non_app_install_params = valid_mobile_app_params.copy()
|
|
485
|
+
non_app_install_params['optimization_goal'] = "REACH"
|
|
486
|
+
|
|
487
|
+
result = await create_adset(
|
|
488
|
+
**non_app_install_params,
|
|
489
|
+
# Mobile app parameters should be optional for non-APP_INSTALLS
|
|
490
|
+
promoted_object=ios_promoted_object,
|
|
491
|
+
destination_type="APP_STORE"
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
# Should work and include mobile parameters if provided
|
|
495
|
+
mock_api_request.assert_called_once()
|
|
496
|
+
call_args = mock_api_request.call_args
|
|
497
|
+
params = call_args[0][2]
|
|
498
|
+
|
|
499
|
+
# Mobile parameters should be included if provided
|
|
500
|
+
assert 'promoted_object' in params
|
|
501
|
+
assert 'destination_type' in params
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
E2E Test for mobile app adset creation issue (Issue #008)
|
|
4
|
+
|
|
5
|
+
This test validates that the create_adset tool supports required parameters
|
|
6
|
+
for mobile app campaigns:
|
|
7
|
+
- promoted_object configuration
|
|
8
|
+
- destination_type settings
|
|
9
|
+
- Conversion event dataset linking
|
|
10
|
+
- Custom event type specification
|
|
11
|
+
|
|
12
|
+
Expected Meta API error when parameters are missing:
|
|
13
|
+
"Select a dataset and conversion event for your ad set (Code 100)"
|
|
14
|
+
|
|
15
|
+
Usage (Manual execution only):
|
|
16
|
+
1. Start the server: uv run python -m meta_ads_mcp --transport streamable-http --port 8080
|
|
17
|
+
2. Run test: uv run python tests/test_mobile_app_adset_issue.py
|
|
18
|
+
|
|
19
|
+
Or with pytest (explicit E2E flag required):
|
|
20
|
+
uv run python -m pytest tests/test_mobile_app_adset_issue.py -v -m e2e
|
|
21
|
+
|
|
22
|
+
Note: This test is marked as E2E and will NOT run automatically in CI.
|
|
23
|
+
It must be executed manually to validate mobile app campaign functionality.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import pytest
|
|
27
|
+
import requests
|
|
28
|
+
import json
|
|
29
|
+
import time
|
|
30
|
+
import sys
|
|
31
|
+
import os
|
|
32
|
+
from typing import Dict, Any
|
|
33
|
+
|
|
34
|
+
# Add project root to path for imports
|
|
35
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
36
|
+
|
|
37
|
+
class MobileAppAdsetTester:
|
|
38
|
+
"""Test suite for mobile app adset creation functionality"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, base_url: str = "http://localhost:8080"):
|
|
41
|
+
self.base_url = base_url.rstrip('/')
|
|
42
|
+
self.endpoint = f"{self.base_url}/mcp/"
|
|
43
|
+
self.request_id = 1
|
|
44
|
+
|
|
45
|
+
def _make_request(self, method: str, params: Dict[str, Any] = None,
|
|
46
|
+
headers: Dict[str, str] = None) -> Dict[str, Any]:
|
|
47
|
+
"""Make a JSON-RPC request to the MCP server"""
|
|
48
|
+
|
|
49
|
+
# Default headers for MCP protocol with streamable HTTP transport
|
|
50
|
+
default_headers = {
|
|
51
|
+
"Content-Type": "application/json",
|
|
52
|
+
"Accept": "application/json, text/event-stream",
|
|
53
|
+
"User-Agent": "MobileApp-Test-Client/1.0"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if headers:
|
|
57
|
+
default_headers.update(headers)
|
|
58
|
+
|
|
59
|
+
payload = {
|
|
60
|
+
"jsonrpc": "2.0",
|
|
61
|
+
"method": method,
|
|
62
|
+
"id": self.request_id
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if params:
|
|
66
|
+
payload["params"] = params
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
response = requests.post(
|
|
70
|
+
self.endpoint,
|
|
71
|
+
headers=default_headers,
|
|
72
|
+
json=payload,
|
|
73
|
+
timeout=30 # Increased timeout for API calls
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
self.request_id += 1
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
"status_code": response.status_code,
|
|
80
|
+
"headers": dict(response.headers),
|
|
81
|
+
"json": response.json() if response.status_code == 200 else None,
|
|
82
|
+
"text": response.text,
|
|
83
|
+
"success": response.status_code == 200
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
except requests.exceptions.RequestException as e:
|
|
87
|
+
return {
|
|
88
|
+
"status_code": 0,
|
|
89
|
+
"headers": {},
|
|
90
|
+
"json": None,
|
|
91
|
+
"text": str(e),
|
|
92
|
+
"success": False,
|
|
93
|
+
"error": str(e)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
def test_create_adset_tool_exists(self) -> Dict[str, Any]:
|
|
97
|
+
"""Test that create_adset tool exists and check its parameters"""
|
|
98
|
+
result = self._make_request("tools/list", {})
|
|
99
|
+
|
|
100
|
+
if not result["success"]:
|
|
101
|
+
return {"success": False, "error": "Failed to get tools list"}
|
|
102
|
+
|
|
103
|
+
tools = result["json"]["result"].get("tools", [])
|
|
104
|
+
create_adset_tool = next((tool for tool in tools if tool["name"] == "create_adset"), None)
|
|
105
|
+
|
|
106
|
+
if not create_adset_tool:
|
|
107
|
+
return {"success": False, "error": "create_adset tool not found"}
|
|
108
|
+
|
|
109
|
+
# Check if mobile app specific parameters are supported
|
|
110
|
+
input_schema = create_adset_tool.get("inputSchema", {})
|
|
111
|
+
properties = input_schema.get("properties", {})
|
|
112
|
+
|
|
113
|
+
mobile_app_params = ["promoted_object", "destination_type"]
|
|
114
|
+
missing_params = []
|
|
115
|
+
|
|
116
|
+
for param in mobile_app_params:
|
|
117
|
+
if param not in properties:
|
|
118
|
+
missing_params.append(param)
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
"success": True,
|
|
122
|
+
"tool": create_adset_tool,
|
|
123
|
+
"missing_mobile_app_params": missing_params,
|
|
124
|
+
"has_mobile_app_support": len(missing_params) == 0
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
def test_reproduce_mobile_app_error(self) -> Dict[str, Any]:
|
|
128
|
+
"""Reproduce mobile app adset creation error scenario"""
|
|
129
|
+
|
|
130
|
+
# Test parameters for mobile app campaign
|
|
131
|
+
test_params = {
|
|
132
|
+
"name": "create_adset",
|
|
133
|
+
"arguments": {
|
|
134
|
+
"account_id": "act_123456789012345", # Generic test account
|
|
135
|
+
"campaign_id": "120230566078340163", # This will likely be invalid but that's OK for testing
|
|
136
|
+
"name": "test mobile app ad set",
|
|
137
|
+
"status": "PAUSED",
|
|
138
|
+
"targeting": {
|
|
139
|
+
"age_max": 65,
|
|
140
|
+
"age_min": 18,
|
|
141
|
+
"app_install_state": "not_installed",
|
|
142
|
+
"geo_locations": {
|
|
143
|
+
"countries": ["DE"],
|
|
144
|
+
"location_types": ["home", "recent"]
|
|
145
|
+
},
|
|
146
|
+
"user_device": ["Android_Smartphone", "Android_Tablet"],
|
|
147
|
+
"user_os": ["Android"],
|
|
148
|
+
"brand_safety_content_filter_levels": ["FACEBOOK_STANDARD", "AN_STANDARD"],
|
|
149
|
+
"targeting_automation": {"advantage_audience": 1}
|
|
150
|
+
},
|
|
151
|
+
"optimization_goal": "APP_INSTALLS",
|
|
152
|
+
"billing_event": "IMPRESSIONS"
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
result = self._make_request("tools/call", test_params)
|
|
157
|
+
|
|
158
|
+
if not result["success"]:
|
|
159
|
+
return {
|
|
160
|
+
"success": False,
|
|
161
|
+
"error": f"MCP call failed: {result.get('text', 'Unknown error')}"
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
# Parse the response
|
|
165
|
+
response_data = result["json"]["result"]
|
|
166
|
+
content = response_data.get("content", [{}])[0].get("text", "")
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
parsed_content = json.loads(content)
|
|
170
|
+
|
|
171
|
+
# Check if this is an error response
|
|
172
|
+
if "error" in parsed_content:
|
|
173
|
+
error_details = parsed_content["error"]
|
|
174
|
+
if isinstance(error_details, dict) and "details" in error_details:
|
|
175
|
+
meta_error = error_details["details"]
|
|
176
|
+
|
|
177
|
+
# Check for the specific error we're looking for
|
|
178
|
+
if isinstance(meta_error, dict) and "error" in meta_error:
|
|
179
|
+
error_code = meta_error["error"].get("code")
|
|
180
|
+
error_message = meta_error["error"].get("error_user_msg", "")
|
|
181
|
+
|
|
182
|
+
is_dataset_error = (
|
|
183
|
+
error_code == 100 and
|
|
184
|
+
"conversion event" in error_message.lower()
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
"success": True,
|
|
189
|
+
"reproduced_error": is_dataset_error,
|
|
190
|
+
"error_code": error_code,
|
|
191
|
+
"error_message": error_message,
|
|
192
|
+
"full_response": parsed_content
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
"success": True,
|
|
197
|
+
"reproduced_error": False,
|
|
198
|
+
"unexpected_response": parsed_content
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
except json.JSONDecodeError as e:
|
|
202
|
+
return {
|
|
203
|
+
"success": False,
|
|
204
|
+
"error": f"Failed to parse response: {e}",
|
|
205
|
+
"raw_content": content
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# Pytest E2E test class - marked to prevent automatic execution
|
|
209
|
+
@pytest.mark.e2e
|
|
210
|
+
@pytest.mark.skip(reason="E2E test - requires running MCP server - execute manually only")
|
|
211
|
+
class TestMobileAppAdsetIssueE2E:
|
|
212
|
+
"""E2E test for mobile app adset creation functionality (Issue #008)"""
|
|
213
|
+
|
|
214
|
+
def setup_method(self):
|
|
215
|
+
"""Set up test instance"""
|
|
216
|
+
self.tester = MobileAppAdsetTester()
|
|
217
|
+
|
|
218
|
+
def test_create_adset_tool_has_mobile_app_params(self):
|
|
219
|
+
"""Test that create_adset tool exists and has mobile app parameters"""
|
|
220
|
+
result = self.tester.test_create_adset_tool_exists()
|
|
221
|
+
|
|
222
|
+
assert result["success"], f"Tool test failed: {result.get('error', 'Unknown error')}"
|
|
223
|
+
|
|
224
|
+
missing_params = result["missing_mobile_app_params"]
|
|
225
|
+
has_mobile_support = result["has_mobile_app_support"]
|
|
226
|
+
|
|
227
|
+
# Report results but don't fail if parameters are missing (this is what we're testing)
|
|
228
|
+
if missing_params:
|
|
229
|
+
pytest.skip(f"Missing mobile app parameters: {missing_params}")
|
|
230
|
+
else:
|
|
231
|
+
# Parameters are present - mobile app support is available
|
|
232
|
+
assert has_mobile_support, "Tool should have mobile app support when parameters are present"
|
|
233
|
+
|
|
234
|
+
def test_reproduce_mobile_app_error_scenario(self):
|
|
235
|
+
"""Test reproducing mobile app adset creation error scenario"""
|
|
236
|
+
result = self.tester.test_reproduce_mobile_app_error()
|
|
237
|
+
|
|
238
|
+
assert result["success"], f"Error reproduction test failed: {result.get('error', 'Unknown error')}"
|
|
239
|
+
|
|
240
|
+
# This test is mainly for validation, not assertion
|
|
241
|
+
# The actual error depends on authentication and server state
|
|
242
|
+
if result.get("reproduced_error"):
|
|
243
|
+
print(f"Reproduced error - Code: {result.get('error_code')}, Message: {result.get('error_message')}")
|
|
244
|
+
else:
|
|
245
|
+
print("Different response received (may indicate parameters are working or auth issues)")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def main():
|
|
249
|
+
"""Run mobile app adset creation tests (manual execution)"""
|
|
250
|
+
print("🚀 Mobile App Adset Creation E2E Test")
|
|
251
|
+
print("=" * 50)
|
|
252
|
+
print("⚠️ This is an E2E test - requires MCP server running on localhost:8080")
|
|
253
|
+
print(" Start server with: uv run python -m meta_ads_mcp --transport streamable-http --port 8080")
|
|
254
|
+
print()
|
|
255
|
+
|
|
256
|
+
tester = MobileAppAdsetTester()
|
|
257
|
+
|
|
258
|
+
# Test 1: Check if create_adset tool exists and has mobile app parameters
|
|
259
|
+
print("\n🧪 Test 1: Checking create_adset tool parameters...")
|
|
260
|
+
tool_test = tester.test_create_adset_tool_exists()
|
|
261
|
+
|
|
262
|
+
if tool_test["success"]:
|
|
263
|
+
missing_params = tool_test["missing_mobile_app_params"]
|
|
264
|
+
if missing_params:
|
|
265
|
+
print(f"❌ Missing mobile app parameters: {missing_params}")
|
|
266
|
+
print("⚠️ Mobile app campaigns may not work without these parameters")
|
|
267
|
+
else:
|
|
268
|
+
print("✅ All mobile app parameters are present")
|
|
269
|
+
else:
|
|
270
|
+
print(f"❌ Tool test failed: {tool_test['error']}")
|
|
271
|
+
|
|
272
|
+
# Test 2: Try to reproduce mobile app error scenario
|
|
273
|
+
print("\n🧪 Test 2: Testing mobile app campaign creation...")
|
|
274
|
+
error_test = tester.test_reproduce_mobile_app_error()
|
|
275
|
+
|
|
276
|
+
if error_test["success"]:
|
|
277
|
+
if error_test.get("reproduced_error"):
|
|
278
|
+
print("✅ Successfully reproduced the error!")
|
|
279
|
+
print(f" Error Code: {error_test['error_code']}")
|
|
280
|
+
print(f" Error Message: {error_test['error_message']}")
|
|
281
|
+
else:
|
|
282
|
+
print("⚠️ Error not reproduced - different response received")
|
|
283
|
+
if "unexpected_response" in error_test:
|
|
284
|
+
print(f" Response: {json.dumps(error_test['unexpected_response'], indent=2)}")
|
|
285
|
+
else:
|
|
286
|
+
print(f"❌ Error reproduction test failed: {error_test['error']}")
|
|
287
|
+
|
|
288
|
+
# Summary
|
|
289
|
+
print("\n🏁 TEST SUMMARY")
|
|
290
|
+
print("=" * 30)
|
|
291
|
+
|
|
292
|
+
if tool_test["success"]:
|
|
293
|
+
missing_params = tool_test["missing_mobile_app_params"]
|
|
294
|
+
issue_confirmed = len(missing_params) > 0
|
|
295
|
+
fix_validated = len(missing_params) == 0
|
|
296
|
+
|
|
297
|
+
if fix_validated:
|
|
298
|
+
print("✅ MOBILE APP SUPPORT VALIDATED")
|
|
299
|
+
print(" All required mobile app parameters are present")
|
|
300
|
+
print(" Mobile app campaigns should work correctly!")
|
|
301
|
+
elif issue_confirmed:
|
|
302
|
+
print("❌ MOBILE APP SUPPORT INCOMPLETE")
|
|
303
|
+
print(f" Missing parameters: {missing_params}")
|
|
304
|
+
print(" Mobile app campaigns may fail without these parameters")
|
|
305
|
+
else:
|
|
306
|
+
print("❓ STATUS UNCLEAR")
|
|
307
|
+
print(" Could not determine mobile app parameter status")
|
|
308
|
+
else:
|
|
309
|
+
print("❌ TEST FAILED")
|
|
310
|
+
print(" Could not connect to MCP server or validate tools")
|
|
311
|
+
|
|
312
|
+
return 0
|
|
313
|
+
|
|
314
|
+
if __name__ == "__main__":
|
|
315
|
+
sys.exit(main())
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|