meta-ads-mcp 0.4.1__tar.gz → 0.4.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.4.1 → meta_ads_mcp-0.4.2}/PKG-INFO +1 -1
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/__init__.py +1 -1
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/core/duplication.py +38 -6
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/core/http_auth_integration.py +66 -7
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/pyproject.toml +1 -1
- meta_ads_mcp-0.4.2/tests/test_duplication.py +244 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/tests/test_duplication_regression.py +157 -104
- meta_ads_mcp-0.4.1/tests/test_duplication.py +0 -136
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/.github/workflows/publish.yml +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/.github/workflows/test.yml +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/.gitignore +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/CUSTOM_META_APP.md +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/Dockerfile +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/LICENSE +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/LOCAL_INSTALLATION.md +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/META_API_NOTES.md +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/README.md +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/RELEASE.md +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/STREAMABLE_HTTP_SETUP.md +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/examples/README.md +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/examples/example_http_client.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/future_improvements.md +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/images/meta-ads-example.png +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_auth.sh +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/__main__.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/core/__init__.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/core/accounts.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/core/ads.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/core/ads_library.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/core/adsets.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/core/api.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/core/auth.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/core/authentication.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/core/budget_schedules.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/core/callback_server.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/core/campaigns.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/core/insights.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/core/reports.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/core/resources.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/core/server.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/meta_ads_mcp/core/utils.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/requirements.txt +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/setup.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/smithery.yaml +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/tests/README.md +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/tests/README_REGRESSION_TESTS.md +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/tests/__init__.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/tests/conftest.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/tests/test_http_transport.py +0 -0
- {meta_ads_mcp-0.4.1 → meta_ads_mcp-0.4.2}/tests/test_openai.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meta-ads-mcp
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -6,6 +6,8 @@ import httpx
|
|
|
6
6
|
from typing import Optional, Dict, Any, List, Union
|
|
7
7
|
from .server import mcp_server
|
|
8
8
|
from .api import meta_api_tool
|
|
9
|
+
from .auth import get_current_access_token
|
|
10
|
+
from .http_auth_integration import FastMCPAuthIntegration
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
# Only register the duplication functions if the environment variable is set
|
|
@@ -178,21 +180,50 @@ if ENABLE_DUPLICATION:
|
|
|
178
180
|
|
|
179
181
|
async def _forward_duplication_request(resource_type: str, resource_id: str, access_token: str, options: Dict[str, Any]) -> str:
|
|
180
182
|
"""
|
|
181
|
-
Forward duplication request to the cloud-hosted MCP API.
|
|
183
|
+
Forward duplication request to the cloud-hosted MCP API using dual-header authentication.
|
|
184
|
+
|
|
185
|
+
This implements the dual-header authentication pattern for MCP server callbacks:
|
|
186
|
+
- Authorization: Bearer <facebook_token> - Facebook access token for Meta API calls
|
|
187
|
+
- X-Pipeboard-Token: <pipeboard_token> - Pipeboard API token for authentication
|
|
182
188
|
|
|
183
189
|
Args:
|
|
184
190
|
resource_type: Type of resource to duplicate (campaign, adset, ad, creative)
|
|
185
191
|
resource_id: ID of the resource to duplicate
|
|
186
|
-
access_token: Meta API access token
|
|
192
|
+
access_token: Meta API access token (optional, will use context if not provided)
|
|
187
193
|
options: Duplication options
|
|
188
194
|
"""
|
|
189
195
|
try:
|
|
190
|
-
|
|
196
|
+
# Get tokens from the request context that were set by the HTTP auth middleware
|
|
197
|
+
# In the dual-header authentication pattern:
|
|
198
|
+
# - Pipeboard token comes from X-Pipeboard-Token header (for authentication)
|
|
199
|
+
# - Facebook token comes from Authorization header (for Meta API calls)
|
|
200
|
+
|
|
201
|
+
# Get tokens from context set by AuthInjectionMiddleware
|
|
202
|
+
pipeboard_token = FastMCPAuthIntegration.get_pipeboard_token()
|
|
203
|
+
facebook_token = FastMCPAuthIntegration.get_auth_token()
|
|
204
|
+
|
|
205
|
+
# Use provided access_token parameter if no Facebook token found in context
|
|
206
|
+
if not facebook_token:
|
|
207
|
+
facebook_token = access_token if access_token else await get_current_access_token()
|
|
208
|
+
|
|
209
|
+
# Validate we have both required tokens
|
|
210
|
+
if not pipeboard_token:
|
|
211
|
+
return json.dumps({
|
|
212
|
+
"error": "authentication_required",
|
|
213
|
+
"message": "Pipeboard API token not found",
|
|
214
|
+
"details": {
|
|
215
|
+
"required": "Valid Pipeboard token via X-Pipeboard-Token header",
|
|
216
|
+
"received_headers": "Check that the MCP server is forwarding the X-Pipeboard-Token header"
|
|
217
|
+
}
|
|
218
|
+
}, indent=2)
|
|
219
|
+
|
|
220
|
+
if not facebook_token:
|
|
191
221
|
return json.dumps({
|
|
192
222
|
"error": "authentication_required",
|
|
193
223
|
"message": "Meta Ads access token not found",
|
|
194
224
|
"details": {
|
|
195
|
-
"required": "Valid access token from authenticated session"
|
|
225
|
+
"required": "Valid Meta access token from authenticated session",
|
|
226
|
+
"check": "Ensure Facebook account is connected and token is valid"
|
|
196
227
|
}
|
|
197
228
|
}, indent=2)
|
|
198
229
|
|
|
@@ -200,9 +231,10 @@ async def _forward_duplication_request(resource_type: str, resource_id: str, acc
|
|
|
200
231
|
base_url = "https://mcp.pipeboard.co"
|
|
201
232
|
endpoint = f"{base_url}/api/meta/duplicate/{resource_type}/{resource_id}"
|
|
202
233
|
|
|
203
|
-
# Prepare the
|
|
234
|
+
# Prepare the dual-header authentication as per API documentation
|
|
204
235
|
headers = {
|
|
205
|
-
"Authorization": f"Bearer {
|
|
236
|
+
"Authorization": f"Bearer {facebook_token}", # Facebook token for Meta API
|
|
237
|
+
"X-Pipeboard-Token": pipeboard_token, # Pipeboard token for auth
|
|
206
238
|
"Content-Type": "application/json",
|
|
207
239
|
"User-Agent": "meta-ads-mcp/1.0"
|
|
208
240
|
}
|
|
@@ -13,6 +13,7 @@ import json
|
|
|
13
13
|
|
|
14
14
|
# Use context variables instead of thread-local storage for better async support
|
|
15
15
|
_auth_token: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar('auth_token', default=None)
|
|
16
|
+
_pipeboard_token: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar('pipeboard_token', default=None)
|
|
16
17
|
|
|
17
18
|
class FastMCPAuthIntegration:
|
|
18
19
|
"""Direct integration with FastMCP for HTTP authentication"""
|
|
@@ -35,11 +36,34 @@ class FastMCPAuthIntegration:
|
|
|
35
36
|
"""
|
|
36
37
|
return _auth_token.get(None)
|
|
37
38
|
|
|
39
|
+
@staticmethod
|
|
40
|
+
def set_pipeboard_token(token: str) -> None:
|
|
41
|
+
"""Set Pipeboard token for the current context
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
token: Pipeboard API token to use for this request
|
|
45
|
+
"""
|
|
46
|
+
_pipeboard_token.set(token)
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def get_pipeboard_token() -> Optional[str]:
|
|
50
|
+
"""Get Pipeboard token for the current context
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Pipeboard token if set, None otherwise
|
|
54
|
+
"""
|
|
55
|
+
return _pipeboard_token.get(None)
|
|
56
|
+
|
|
38
57
|
@staticmethod
|
|
39
58
|
def clear_auth_token() -> None:
|
|
40
59
|
"""Clear authentication token for the current context"""
|
|
41
60
|
_auth_token.set(None)
|
|
42
61
|
|
|
62
|
+
@staticmethod
|
|
63
|
+
def clear_pipeboard_token() -> None:
|
|
64
|
+
"""Clear Pipeboard token for the current context"""
|
|
65
|
+
_pipeboard_token.set(None)
|
|
66
|
+
|
|
43
67
|
@staticmethod
|
|
44
68
|
def extract_token_from_headers(headers: dict) -> Optional[str]:
|
|
45
69
|
"""Extract token from HTTP headers
|
|
@@ -69,6 +93,30 @@ class FastMCPAuthIntegration:
|
|
|
69
93
|
return pipeboard_token
|
|
70
94
|
|
|
71
95
|
return None
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def extract_pipeboard_token_from_headers(headers: dict) -> Optional[str]:
|
|
99
|
+
"""Extract Pipeboard token from HTTP headers
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
headers: HTTP request headers
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Pipeboard token if found, None otherwise
|
|
106
|
+
"""
|
|
107
|
+
# Check for Pipeboard token in X-Pipeboard-Token header (duplication API pattern)
|
|
108
|
+
pipeboard_token = headers.get('X-Pipeboard-Token') or headers.get('x-pipeboard-token')
|
|
109
|
+
if pipeboard_token:
|
|
110
|
+
logger.debug("Found Pipeboard token in X-Pipeboard-Token header")
|
|
111
|
+
return pipeboard_token
|
|
112
|
+
|
|
113
|
+
# Check for legacy Pipeboard token header
|
|
114
|
+
legacy_token = headers.get('X-PIPEBOARD-API-TOKEN') or headers.get('x-pipeboard-api-token')
|
|
115
|
+
if legacy_token:
|
|
116
|
+
logger.debug("Found Pipeboard token in legacy X-PIPEBOARD-API-TOKEN header")
|
|
117
|
+
return legacy_token
|
|
118
|
+
|
|
119
|
+
return None
|
|
72
120
|
|
|
73
121
|
def patch_fastmcp_server(mcp_server):
|
|
74
122
|
"""Patch FastMCP server to inject authentication from HTTP headers
|
|
@@ -203,21 +251,32 @@ class AuthInjectionMiddleware(BaseHTTPMiddleware):
|
|
|
203
251
|
logger.debug(f"HTTP Auth Middleware: Processing request to {request.url.path}")
|
|
204
252
|
logger.debug(f"HTTP Auth Middleware: Request headers: {list(request.headers.keys())}")
|
|
205
253
|
|
|
206
|
-
|
|
254
|
+
# Extract both types of tokens for dual-header authentication
|
|
255
|
+
auth_token = FastMCPAuthIntegration.extract_token_from_headers(dict(request.headers))
|
|
256
|
+
pipeboard_token = FastMCPAuthIntegration.extract_pipeboard_token_from_headers(dict(request.headers))
|
|
207
257
|
|
|
208
|
-
if
|
|
209
|
-
logger.debug(f"HTTP Auth Middleware: Extracted token: {
|
|
258
|
+
if auth_token:
|
|
259
|
+
logger.debug(f"HTTP Auth Middleware: Extracted auth token: {auth_token[:10]}...")
|
|
210
260
|
logger.debug("Injecting auth token into request context")
|
|
211
|
-
FastMCPAuthIntegration.set_auth_token(
|
|
212
|
-
|
|
213
|
-
|
|
261
|
+
FastMCPAuthIntegration.set_auth_token(auth_token)
|
|
262
|
+
|
|
263
|
+
if pipeboard_token:
|
|
264
|
+
logger.debug(f"HTTP Auth Middleware: Extracted Pipeboard token: {pipeboard_token[:10]}...")
|
|
265
|
+
logger.debug("Injecting Pipeboard token into request context")
|
|
266
|
+
FastMCPAuthIntegration.set_pipeboard_token(pipeboard_token)
|
|
267
|
+
|
|
268
|
+
if not auth_token and not pipeboard_token:
|
|
269
|
+
logger.warning("HTTP Auth Middleware: No authentication tokens found in headers")
|
|
214
270
|
|
|
215
271
|
try:
|
|
216
272
|
response = await call_next(request)
|
|
217
273
|
return response
|
|
218
274
|
finally:
|
|
219
|
-
|
|
275
|
+
# Clear tokens that were set for this request
|
|
276
|
+
if auth_token:
|
|
220
277
|
FastMCPAuthIntegration.clear_auth_token()
|
|
278
|
+
if pipeboard_token:
|
|
279
|
+
FastMCPAuthIntegration.clear_pipeboard_token()
|
|
221
280
|
|
|
222
281
|
def setup_starlette_middleware(app):
|
|
223
282
|
"""Add AuthInjectionMiddleware to the Starlette app if not already present.
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Tests for the duplication module."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import pytest
|
|
6
|
+
from unittest.mock import patch, AsyncMock, Mock
|
|
7
|
+
from meta_ads_mcp.core.duplication import ENABLE_DUPLICATION
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_duplication_disabled_by_default():
|
|
11
|
+
"""Test that duplication is disabled by default."""
|
|
12
|
+
# Test with no environment variable set
|
|
13
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
14
|
+
from meta_ads_mcp.core import duplication
|
|
15
|
+
# When imported fresh, it should be disabled
|
|
16
|
+
assert not duplication.ENABLE_DUPLICATION
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_duplication_enabled_with_env_var():
|
|
20
|
+
"""Test that duplication is enabled when environment variable is set."""
|
|
21
|
+
with patch.dict(os.environ, {"META_ADS_ENABLE_DUPLICATION": "1"}):
|
|
22
|
+
# Need to reload the module to pick up the new environment variable
|
|
23
|
+
import importlib
|
|
24
|
+
from meta_ads_mcp.core import duplication
|
|
25
|
+
importlib.reload(duplication)
|
|
26
|
+
assert duplication.ENABLE_DUPLICATION
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.asyncio
|
|
30
|
+
async def test_forward_duplication_request_no_pipeboard_token():
|
|
31
|
+
"""Test that _forward_duplication_request handles missing Pipeboard token."""
|
|
32
|
+
from meta_ads_mcp.core.duplication import _forward_duplication_request
|
|
33
|
+
|
|
34
|
+
# Mock the auth integration to return no Pipeboard token but a Facebook token
|
|
35
|
+
with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
|
|
36
|
+
mock_auth.get_pipeboard_token.return_value = None # No Pipeboard token
|
|
37
|
+
mock_auth.get_auth_token.return_value = "facebook_token" # Has Facebook token
|
|
38
|
+
|
|
39
|
+
result = await _forward_duplication_request("campaign", "123456789", None, {})
|
|
40
|
+
result_json = json.loads(result)
|
|
41
|
+
|
|
42
|
+
assert result_json["error"] == "authentication_required"
|
|
43
|
+
assert "Pipeboard API token not found" in result_json["message"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.asyncio
|
|
47
|
+
async def test_forward_duplication_request_no_facebook_token():
|
|
48
|
+
"""Test that _forward_duplication_request handles missing Facebook token."""
|
|
49
|
+
from meta_ads_mcp.core.duplication import _forward_duplication_request
|
|
50
|
+
|
|
51
|
+
# Mock the auth integration to return Pipeboard token but no Facebook token
|
|
52
|
+
with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
|
|
53
|
+
mock_auth.get_pipeboard_token.return_value = "pipeboard_token" # Has Pipeboard token
|
|
54
|
+
mock_auth.get_auth_token.return_value = None # No Facebook token
|
|
55
|
+
|
|
56
|
+
# Mock get_current_access_token to also return None
|
|
57
|
+
with patch("meta_ads_mcp.core.duplication.get_current_access_token") as mock_get_token:
|
|
58
|
+
mock_get_token.return_value = None
|
|
59
|
+
|
|
60
|
+
result = await _forward_duplication_request("campaign", "123456789", None, {})
|
|
61
|
+
result_json = json.loads(result)
|
|
62
|
+
|
|
63
|
+
assert result_json["error"] == "authentication_required"
|
|
64
|
+
assert "Meta Ads access token not found" in result_json["message"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.mark.asyncio
|
|
68
|
+
async def test_forward_duplication_request_with_both_tokens():
|
|
69
|
+
"""Test that _forward_duplication_request makes HTTP request with dual headers."""
|
|
70
|
+
from meta_ads_mcp.core.duplication import _forward_duplication_request
|
|
71
|
+
|
|
72
|
+
mock_response = Mock()
|
|
73
|
+
mock_response.status_code = 403
|
|
74
|
+
mock_response.json.return_value = {"error": "premium_feature"}
|
|
75
|
+
|
|
76
|
+
# Mock the auth integration to return both tokens
|
|
77
|
+
with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
|
|
78
|
+
mock_auth.get_pipeboard_token.return_value = "pipeboard_token"
|
|
79
|
+
mock_auth.get_auth_token.return_value = "facebook_token"
|
|
80
|
+
|
|
81
|
+
with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
|
|
82
|
+
mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
|
|
83
|
+
|
|
84
|
+
result = await _forward_duplication_request("campaign", "123456789", None, {
|
|
85
|
+
"name_suffix": " - Test"
|
|
86
|
+
})
|
|
87
|
+
result_json = json.loads(result)
|
|
88
|
+
|
|
89
|
+
# Should return premium feature message for 403 response
|
|
90
|
+
assert result_json["error"] == "premium_feature_required"
|
|
91
|
+
assert "premium feature" in result_json["message"]
|
|
92
|
+
|
|
93
|
+
# Verify the HTTP request was made with correct parameters
|
|
94
|
+
mock_client.return_value.__aenter__.return_value.post.assert_called_once()
|
|
95
|
+
call_args = mock_client.return_value.__aenter__.return_value.post.call_args
|
|
96
|
+
|
|
97
|
+
# Check URL
|
|
98
|
+
assert call_args[0][0] == "https://mcp.pipeboard.co/api/meta/duplicate/campaign/123456789"
|
|
99
|
+
|
|
100
|
+
# Check dual headers (the key change!)
|
|
101
|
+
headers = call_args[1]["headers"]
|
|
102
|
+
assert headers["Authorization"] == "Bearer facebook_token" # Facebook token for Meta API
|
|
103
|
+
assert headers["X-Pipeboard-Token"] == "pipeboard_token" # Pipeboard token for auth
|
|
104
|
+
assert headers["Content-Type"] == "application/json"
|
|
105
|
+
|
|
106
|
+
# Check JSON payload
|
|
107
|
+
json_payload = call_args[1]["json"]
|
|
108
|
+
assert json_payload == {"name_suffix": " - Test"}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@pytest.mark.asyncio
|
|
112
|
+
async def test_forward_duplication_request_with_provided_access_token():
|
|
113
|
+
"""Test that provided access_token parameter is used when available."""
|
|
114
|
+
from meta_ads_mcp.core.duplication import _forward_duplication_request
|
|
115
|
+
|
|
116
|
+
mock_response = Mock()
|
|
117
|
+
mock_response.status_code = 200
|
|
118
|
+
mock_response.json.return_value = {"success": True, "new_campaign_id": "987654321"}
|
|
119
|
+
|
|
120
|
+
# Mock the auth integration to return Pipeboard token but no Facebook token in context
|
|
121
|
+
with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
|
|
122
|
+
mock_auth.get_pipeboard_token.return_value = "pipeboard_token"
|
|
123
|
+
mock_auth.get_auth_token.return_value = None # No Facebook token in context
|
|
124
|
+
|
|
125
|
+
with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
|
|
126
|
+
mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
|
|
127
|
+
|
|
128
|
+
# Provide access_token as parameter
|
|
129
|
+
result = await _forward_duplication_request("campaign", "123456789", "provided_facebook_token", {})
|
|
130
|
+
result_json = json.loads(result)
|
|
131
|
+
|
|
132
|
+
# Should succeed
|
|
133
|
+
assert result_json["success"] is True
|
|
134
|
+
|
|
135
|
+
# Verify the HTTP request used the provided token
|
|
136
|
+
call_args = mock_client.return_value.__aenter__.return_value.post.call_args
|
|
137
|
+
headers = call_args[1]["headers"]
|
|
138
|
+
assert headers["Authorization"] == "Bearer provided_facebook_token"
|
|
139
|
+
assert headers["X-Pipeboard-Token"] == "pipeboard_token"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@pytest.mark.asyncio
|
|
143
|
+
async def test_duplicate_campaign_function_available_when_enabled():
|
|
144
|
+
"""Test that duplicate_campaign function is available when feature is enabled."""
|
|
145
|
+
with patch.dict(os.environ, {"META_ADS_ENABLE_DUPLICATION": "1"}):
|
|
146
|
+
# Reload module to pick up environment variable
|
|
147
|
+
import importlib
|
|
148
|
+
from meta_ads_mcp.core import duplication
|
|
149
|
+
importlib.reload(duplication)
|
|
150
|
+
|
|
151
|
+
# Function should be available
|
|
152
|
+
assert hasattr(duplication, 'duplicate_campaign')
|
|
153
|
+
|
|
154
|
+
# Test that it calls the forwarding function
|
|
155
|
+
with patch("meta_ads_mcp.core.duplication._forward_duplication_request") as mock_forward:
|
|
156
|
+
mock_forward.return_value = '{"success": true}'
|
|
157
|
+
|
|
158
|
+
result = await duplication.duplicate_campaign("123456789", access_token="test_token")
|
|
159
|
+
|
|
160
|
+
mock_forward.assert_called_once_with(
|
|
161
|
+
"campaign",
|
|
162
|
+
"123456789",
|
|
163
|
+
"test_token",
|
|
164
|
+
{
|
|
165
|
+
"name_suffix": " - Copy",
|
|
166
|
+
"include_ad_sets": True,
|
|
167
|
+
"include_ads": True,
|
|
168
|
+
"include_creatives": True,
|
|
169
|
+
"copy_schedule": False,
|
|
170
|
+
"new_daily_budget": None,
|
|
171
|
+
"new_status": "PAUSED"
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_get_estimated_components():
|
|
177
|
+
"""Test the _get_estimated_components helper function."""
|
|
178
|
+
from meta_ads_mcp.core.duplication import _get_estimated_components
|
|
179
|
+
|
|
180
|
+
# Test campaign with all components
|
|
181
|
+
campaign_result = _get_estimated_components("campaign", {
|
|
182
|
+
"include_ad_sets": True,
|
|
183
|
+
"include_ads": True,
|
|
184
|
+
"include_creatives": True
|
|
185
|
+
})
|
|
186
|
+
assert campaign_result["campaigns"] == 1
|
|
187
|
+
assert "ad_sets" in campaign_result
|
|
188
|
+
assert "ads" in campaign_result
|
|
189
|
+
assert "creatives" in campaign_result
|
|
190
|
+
|
|
191
|
+
# Test adset
|
|
192
|
+
adset_result = _get_estimated_components("adset", {"include_ads": True})
|
|
193
|
+
assert adset_result["ad_sets"] == 1
|
|
194
|
+
assert "ads" in adset_result
|
|
195
|
+
|
|
196
|
+
# Test creative only
|
|
197
|
+
creative_result = _get_estimated_components("creative", {})
|
|
198
|
+
assert creative_result == {"creatives": 1}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@pytest.mark.asyncio
|
|
202
|
+
async def test_dual_header_authentication_integration():
|
|
203
|
+
"""Test that the dual-header authentication works end-to-end."""
|
|
204
|
+
from meta_ads_mcp.core.duplication import _forward_duplication_request
|
|
205
|
+
|
|
206
|
+
mock_response = Mock()
|
|
207
|
+
mock_response.status_code = 200
|
|
208
|
+
mock_response.json.return_value = {
|
|
209
|
+
"success": True,
|
|
210
|
+
"new_campaign_id": "987654321",
|
|
211
|
+
"subscription": {"status": "active"}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
# Test the complete dual-header flow
|
|
215
|
+
with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
|
|
216
|
+
mock_auth.get_pipeboard_token.return_value = "pb_token_12345"
|
|
217
|
+
mock_auth.get_auth_token.return_value = "fb_token_67890"
|
|
218
|
+
|
|
219
|
+
with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
|
|
220
|
+
mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
|
|
221
|
+
|
|
222
|
+
result = await _forward_duplication_request("adset", "456789", None, {
|
|
223
|
+
"target_campaign_id": "123456",
|
|
224
|
+
"include_ads": True
|
|
225
|
+
})
|
|
226
|
+
result_json = json.loads(result)
|
|
227
|
+
|
|
228
|
+
# Should succeed
|
|
229
|
+
assert result_json["success"] is True
|
|
230
|
+
assert result_json["new_campaign_id"] == "987654321"
|
|
231
|
+
|
|
232
|
+
# Verify correct endpoint was called
|
|
233
|
+
call_args = mock_client.return_value.__aenter__.return_value.post.call_args
|
|
234
|
+
assert "adset/456789" in call_args[0][0]
|
|
235
|
+
|
|
236
|
+
# Verify dual headers were sent correctly
|
|
237
|
+
headers = call_args[1]["headers"]
|
|
238
|
+
assert headers["Authorization"] == "Bearer fb_token_67890"
|
|
239
|
+
assert headers["X-Pipeboard-Token"] == "pb_token_12345"
|
|
240
|
+
|
|
241
|
+
# Verify payload
|
|
242
|
+
payload = call_args[1]["json"]
|
|
243
|
+
assert payload["target_campaign_id"] == "123456"
|
|
244
|
+
assert payload["include_ads"] is True
|
|
@@ -131,6 +131,36 @@ class TestDuplicationAPIContract:
|
|
|
131
131
|
]
|
|
132
132
|
|
|
133
133
|
for resource_type, resource_id, expected_url in test_cases:
|
|
134
|
+
# Mock dual-header authentication
|
|
135
|
+
with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
|
|
136
|
+
mock_auth.get_pipeboard_token.return_value = "pipeboard_token"
|
|
137
|
+
mock_auth.get_auth_token.return_value = "facebook_token"
|
|
138
|
+
|
|
139
|
+
with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
|
|
140
|
+
mock_response = AsyncMock()
|
|
141
|
+
mock_response.status_code = 200
|
|
142
|
+
mock_response.json.return_value = {"success": True}
|
|
143
|
+
mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
|
|
144
|
+
|
|
145
|
+
await duplication._forward_duplication_request(
|
|
146
|
+
resource_type, resource_id, "test_token", {}
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Verify the correct URL was called
|
|
150
|
+
call_args = mock_client.return_value.__aenter__.return_value.post.call_args
|
|
151
|
+
actual_url = call_args[0][0]
|
|
152
|
+
assert actual_url == expected_url, f"Expected {expected_url}, got {actual_url}"
|
|
153
|
+
|
|
154
|
+
@pytest.mark.asyncio
|
|
155
|
+
async def test_request_headers_format(self, enable_feature):
|
|
156
|
+
"""Test that request headers are formatted correctly."""
|
|
157
|
+
duplication = enable_feature
|
|
158
|
+
|
|
159
|
+
# Mock dual-header authentication
|
|
160
|
+
with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
|
|
161
|
+
mock_auth.get_pipeboard_token.return_value = "pipeboard_token_12345"
|
|
162
|
+
mock_auth.get_auth_token.return_value = "facebook_token_67890"
|
|
163
|
+
|
|
134
164
|
with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
|
|
135
165
|
mock_response = AsyncMock()
|
|
136
166
|
mock_response.status_code = 200
|
|
@@ -138,54 +168,41 @@ class TestDuplicationAPIContract:
|
|
|
138
168
|
mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
|
|
139
169
|
|
|
140
170
|
await duplication._forward_duplication_request(
|
|
141
|
-
|
|
171
|
+
"campaign", "123456789", "test_token_12345", {"name_suffix": " - Test"}
|
|
142
172
|
)
|
|
143
173
|
|
|
144
|
-
# Verify
|
|
174
|
+
# Verify dual headers are sent correctly
|
|
145
175
|
call_args = mock_client.return_value.__aenter__.return_value.post.call_args
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
|
|
155
|
-
mock_response = AsyncMock()
|
|
156
|
-
mock_response.status_code = 200
|
|
157
|
-
mock_response.json.return_value = {"success": True}
|
|
158
|
-
mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
|
|
159
|
-
|
|
160
|
-
await duplication._forward_duplication_request(
|
|
161
|
-
"campaign", "123456789", "test_token_12345", {"name_suffix": " - Test"}
|
|
162
|
-
)
|
|
163
|
-
|
|
164
|
-
# Verify headers
|
|
165
|
-
call_args = mock_client.return_value.__aenter__.return_value.post.call_args
|
|
166
|
-
headers = call_args[1]["headers"]
|
|
167
|
-
|
|
168
|
-
assert headers["Authorization"] == "Bearer test_token_12345"
|
|
169
|
-
assert headers["Content-Type"] == "application/json"
|
|
170
|
-
assert headers["User-Agent"] == "meta-ads-mcp/1.0"
|
|
171
|
-
|
|
176
|
+
headers = call_args[1]["headers"]
|
|
177
|
+
|
|
178
|
+
# Check the dual-header authentication pattern
|
|
179
|
+
assert headers["Authorization"] == "Bearer facebook_token_67890" # Facebook token for Meta API
|
|
180
|
+
assert headers["X-Pipeboard-Token"] == "pipeboard_token_12345" # Pipeboard token for auth
|
|
181
|
+
assert headers["Content-Type"] == "application/json"
|
|
182
|
+
assert headers["User-Agent"] == "meta-ads-mcp/1.0"
|
|
183
|
+
|
|
172
184
|
@pytest.mark.asyncio
|
|
173
185
|
async def test_request_timeout_configuration(self, enable_feature):
|
|
174
186
|
"""Test that request timeout is configured correctly."""
|
|
175
187
|
duplication = enable_feature
|
|
176
188
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
|
|
182
|
-
|
|
183
|
-
await duplication._forward_duplication_request(
|
|
184
|
-
"campaign", "123456789", "test_token", {}
|
|
185
|
-
)
|
|
189
|
+
# Mock dual-header authentication
|
|
190
|
+
with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
|
|
191
|
+
mock_auth.get_pipeboard_token.return_value = "pipeboard_token"
|
|
192
|
+
mock_auth.get_auth_token.return_value = "facebook_token"
|
|
186
193
|
|
|
187
|
-
|
|
188
|
-
|
|
194
|
+
with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
|
|
195
|
+
mock_response = AsyncMock()
|
|
196
|
+
mock_response.status_code = 200
|
|
197
|
+
mock_response.json.return_value = {"success": True}
|
|
198
|
+
mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
|
|
199
|
+
|
|
200
|
+
await duplication._forward_duplication_request(
|
|
201
|
+
"campaign", "123456789", "test_token", {}
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Verify timeout is set to 30 seconds
|
|
205
|
+
mock_client.assert_called_once_with(timeout=30.0)
|
|
189
206
|
|
|
190
207
|
|
|
191
208
|
class TestDuplicationErrorHandling:
|
|
@@ -202,15 +219,33 @@ class TestDuplicationErrorHandling:
|
|
|
202
219
|
|
|
203
220
|
@pytest.mark.asyncio
|
|
204
221
|
async def test_missing_access_token_error(self, enable_feature):
|
|
205
|
-
"""Test error handling when
|
|
222
|
+
"""Test error handling when authentication tokens are missing."""
|
|
206
223
|
duplication = enable_feature
|
|
207
224
|
|
|
208
|
-
|
|
209
|
-
|
|
225
|
+
# Test missing Pipeboard token (primary authentication failure)
|
|
226
|
+
with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
|
|
227
|
+
mock_auth.get_pipeboard_token.return_value = None # No Pipeboard token
|
|
228
|
+
mock_auth.get_auth_token.return_value = "facebook_token" # Has Facebook token
|
|
229
|
+
|
|
230
|
+
result = await duplication._forward_duplication_request("campaign", "123", None, {})
|
|
231
|
+
result_json = json.loads(result)
|
|
232
|
+
|
|
233
|
+
assert result_json["error"] == "authentication_required"
|
|
234
|
+
assert "Pipeboard API token not found" in result_json["message"]
|
|
210
235
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
236
|
+
# Test missing Facebook token (secondary authentication failure)
|
|
237
|
+
with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
|
|
238
|
+
mock_auth.get_pipeboard_token.return_value = "pipeboard_token" # Has Pipeboard token
|
|
239
|
+
mock_auth.get_auth_token.return_value = None # No Facebook token
|
|
240
|
+
|
|
241
|
+
with patch("meta_ads_mcp.core.duplication.get_current_access_token") as mock_get_token:
|
|
242
|
+
mock_get_token.return_value = None # No fallback token
|
|
243
|
+
|
|
244
|
+
result = await duplication._forward_duplication_request("campaign", "123", None, {})
|
|
245
|
+
result_json = json.loads(result)
|
|
246
|
+
|
|
247
|
+
assert result_json["error"] == "authentication_required"
|
|
248
|
+
assert "Meta Ads access token not found" in result_json["message"]
|
|
214
249
|
|
|
215
250
|
@pytest.mark.asyncio
|
|
216
251
|
async def test_http_status_code_handling(self, enable_feature):
|
|
@@ -230,7 +265,9 @@ class TestDuplicationErrorHandling:
|
|
|
230
265
|
]
|
|
231
266
|
|
|
232
267
|
for status_code, expected_error_type, response_type in status_code_tests:
|
|
233
|
-
with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client
|
|
268
|
+
with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client, \
|
|
269
|
+
patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration.get_pipeboard_token", return_value="test_pipeboard_token"), \
|
|
270
|
+
patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration.get_auth_token", return_value="test_facebook_token"):
|
|
234
271
|
# Use MagicMock instead of AsyncMock for more predictable behavior
|
|
235
272
|
mock_response = MagicMock()
|
|
236
273
|
mock_response.status_code = status_code
|
|
@@ -304,7 +341,9 @@ class TestDuplicationErrorHandling:
|
|
|
304
341
|
]
|
|
305
342
|
|
|
306
343
|
for exception, expected_error in network_errors:
|
|
307
|
-
with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client
|
|
344
|
+
with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client, \
|
|
345
|
+
patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration.get_pipeboard_token", return_value="test_pipeboard_token"), \
|
|
346
|
+
patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration.get_auth_token", return_value="test_facebook_token"):
|
|
308
347
|
mock_client.return_value.__aenter__.return_value.post.side_effect = exception
|
|
309
348
|
|
|
310
349
|
result = await duplication._forward_duplication_request(
|
|
@@ -332,33 +371,37 @@ class TestDuplicationParameterHandling:
|
|
|
332
371
|
"""Test that None values are filtered from options."""
|
|
333
372
|
duplication = enable_feature
|
|
334
373
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
|
|
340
|
-
|
|
341
|
-
# Test with options containing None values
|
|
342
|
-
options_with_none = {
|
|
343
|
-
"name_suffix": " - Test",
|
|
344
|
-
"new_daily_budget": None,
|
|
345
|
-
"new_status": "PAUSED",
|
|
346
|
-
"new_headline": None,
|
|
347
|
-
}
|
|
374
|
+
# Mock dual-header authentication
|
|
375
|
+
with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
|
|
376
|
+
mock_auth.get_pipeboard_token.return_value = "pipeboard_token"
|
|
377
|
+
mock_auth.get_auth_token.return_value = "facebook_token"
|
|
348
378
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
379
|
+
with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
|
|
380
|
+
mock_response = AsyncMock()
|
|
381
|
+
mock_response.status_code = 200
|
|
382
|
+
mock_response.json.return_value = {"success": True}
|
|
383
|
+
mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
|
|
384
|
+
|
|
385
|
+
# Test with options containing None values
|
|
386
|
+
options_with_none = {
|
|
387
|
+
"name_suffix": " - Test",
|
|
388
|
+
"new_daily_budget": None,
|
|
389
|
+
"new_status": "PAUSED",
|
|
390
|
+
"new_headline": None,
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
await duplication._forward_duplication_request(
|
|
394
|
+
"campaign", "123", "token", options_with_none
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
# Verify None values were filtered out
|
|
398
|
+
call_args = mock_client.return_value.__aenter__.return_value.post.call_args
|
|
399
|
+
json_payload = call_args[1]["json"]
|
|
400
|
+
|
|
401
|
+
assert "name_suffix" in json_payload
|
|
402
|
+
assert "new_status" in json_payload
|
|
403
|
+
assert "new_daily_budget" not in json_payload
|
|
404
|
+
assert "new_headline" not in json_payload
|
|
362
405
|
|
|
363
406
|
@pytest.mark.asyncio
|
|
364
407
|
async def test_campaign_duplication_parameter_forwarding(self, enable_feature):
|
|
@@ -736,44 +779,54 @@ class TestDuplicationRegressionEdgeCases:
|
|
|
736
779
|
"""Test handling of unicode parameters."""
|
|
737
780
|
duplication = enable_feature
|
|
738
781
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
|
|
782
|
+
# Mock dual-header authentication
|
|
783
|
+
with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
|
|
784
|
+
mock_auth.get_pipeboard_token.return_value = "pipeboard_token"
|
|
785
|
+
mock_auth.get_auth_token.return_value = "facebook_token"
|
|
744
786
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
787
|
+
with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
|
|
788
|
+
mock_response = AsyncMock()
|
|
789
|
+
mock_response.status_code = 200
|
|
790
|
+
mock_response.json.return_value = {"success": True}
|
|
791
|
+
mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
|
|
792
|
+
|
|
793
|
+
# Test with unicode characters
|
|
794
|
+
unicode_suffix = " - 复制版本 🚀"
|
|
795
|
+
await duplication._forward_duplication_request(
|
|
796
|
+
"campaign", "123", "token", {"name_suffix": unicode_suffix}
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
# Verify unicode is preserved in the request
|
|
800
|
+
call_args = mock_client.return_value.__aenter__.return_value.post.call_args
|
|
801
|
+
json_payload = call_args[1]["json"]
|
|
802
|
+
assert json_payload["name_suffix"] == unicode_suffix
|
|
803
|
+
|
|
756
804
|
@pytest.mark.asyncio
|
|
757
805
|
async def test_large_parameter_values(self, enable_feature):
|
|
758
806
|
"""Test handling of large parameter values."""
|
|
759
807
|
duplication = enable_feature
|
|
760
808
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
|
|
766
|
-
|
|
767
|
-
# Test with very large budget value
|
|
768
|
-
large_budget = 999999999.99
|
|
769
|
-
await duplication._forward_duplication_request(
|
|
770
|
-
"campaign", "123", "token", {"new_daily_budget": large_budget}
|
|
771
|
-
)
|
|
809
|
+
# Mock dual-header authentication
|
|
810
|
+
with patch("meta_ads_mcp.core.duplication.FastMCPAuthIntegration") as mock_auth:
|
|
811
|
+
mock_auth.get_pipeboard_token.return_value = "pipeboard_token"
|
|
812
|
+
mock_auth.get_auth_token.return_value = "facebook_token"
|
|
772
813
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
814
|
+
with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
|
|
815
|
+
mock_response = AsyncMock()
|
|
816
|
+
mock_response.status_code = 200
|
|
817
|
+
mock_response.json.return_value = {"success": True}
|
|
818
|
+
mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
|
|
819
|
+
|
|
820
|
+
# Test with very large budget value
|
|
821
|
+
large_budget = 999999999.99
|
|
822
|
+
await duplication._forward_duplication_request(
|
|
823
|
+
"campaign", "123", "token", {"new_daily_budget": large_budget}
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
# Verify large values are preserved
|
|
827
|
+
call_args = mock_client.return_value.__aenter__.return_value.post.call_args
|
|
828
|
+
json_payload = call_args[1]["json"]
|
|
829
|
+
assert json_payload["new_daily_budget"] == large_budget
|
|
777
830
|
|
|
778
831
|
def test_module_reload_safety(self):
|
|
779
832
|
"""Test that module can be safely reloaded without side effects."""
|
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
"""Tests for the duplication module."""
|
|
2
|
-
|
|
3
|
-
import os
|
|
4
|
-
import json
|
|
5
|
-
import pytest
|
|
6
|
-
from unittest.mock import patch, AsyncMock
|
|
7
|
-
from meta_ads_mcp.core.duplication import ENABLE_DUPLICATION
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def test_duplication_disabled_by_default():
|
|
11
|
-
"""Test that duplication is disabled by default."""
|
|
12
|
-
# Test with no environment variable set
|
|
13
|
-
with patch.dict(os.environ, {}, clear=True):
|
|
14
|
-
from meta_ads_mcp.core import duplication
|
|
15
|
-
# When imported fresh, it should be disabled
|
|
16
|
-
assert not duplication.ENABLE_DUPLICATION
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def test_duplication_enabled_with_env_var():
|
|
20
|
-
"""Test that duplication is enabled when environment variable is set."""
|
|
21
|
-
with patch.dict(os.environ, {"META_ADS_ENABLE_DUPLICATION": "1"}):
|
|
22
|
-
# Need to reload the module to pick up the new environment variable
|
|
23
|
-
import importlib
|
|
24
|
-
from meta_ads_mcp.core import duplication
|
|
25
|
-
importlib.reload(duplication)
|
|
26
|
-
assert duplication.ENABLE_DUPLICATION
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@pytest.mark.asyncio
|
|
30
|
-
async def test_forward_duplication_request_no_token():
|
|
31
|
-
"""Test that _forward_duplication_request handles missing access token."""
|
|
32
|
-
from meta_ads_mcp.core.duplication import _forward_duplication_request
|
|
33
|
-
|
|
34
|
-
result = await _forward_duplication_request("campaign", "123456789", None, {})
|
|
35
|
-
result_json = json.loads(result)
|
|
36
|
-
|
|
37
|
-
assert result_json["error"] == "authentication_required"
|
|
38
|
-
assert "access token not found" in result_json["message"]
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
@pytest.mark.asyncio
|
|
42
|
-
async def test_forward_duplication_request_with_token():
|
|
43
|
-
"""Test that _forward_duplication_request makes HTTP request with proper headers."""
|
|
44
|
-
from meta_ads_mcp.core.duplication import _forward_duplication_request
|
|
45
|
-
from unittest.mock import Mock
|
|
46
|
-
|
|
47
|
-
mock_response = Mock()
|
|
48
|
-
mock_response.status_code = 403
|
|
49
|
-
mock_response.json.return_value = {"error": "premium_feature"}
|
|
50
|
-
|
|
51
|
-
with patch("meta_ads_mcp.core.duplication.httpx.AsyncClient") as mock_client:
|
|
52
|
-
mock_client.return_value.__aenter__.return_value.post.return_value = mock_response
|
|
53
|
-
|
|
54
|
-
result = await _forward_duplication_request("campaign", "123456789", "test_token", {
|
|
55
|
-
"name_suffix": " - Test"
|
|
56
|
-
})
|
|
57
|
-
result_json = json.loads(result)
|
|
58
|
-
|
|
59
|
-
# Should return premium feature message for 403 response
|
|
60
|
-
assert result_json["error"] == "premium_feature_required"
|
|
61
|
-
assert "premium feature" in result_json["message"]
|
|
62
|
-
|
|
63
|
-
# Verify the HTTP request was made with correct parameters
|
|
64
|
-
mock_client.return_value.__aenter__.return_value.post.assert_called_once()
|
|
65
|
-
call_args = mock_client.return_value.__aenter__.return_value.post.call_args
|
|
66
|
-
|
|
67
|
-
# Check URL
|
|
68
|
-
assert call_args[0][0] == "https://mcp.pipeboard.co/api/meta/duplicate/campaign/123456789"
|
|
69
|
-
|
|
70
|
-
# Check headers
|
|
71
|
-
headers = call_args[1]["headers"]
|
|
72
|
-
assert headers["Authorization"] == "Bearer test_token"
|
|
73
|
-
assert headers["Content-Type"] == "application/json"
|
|
74
|
-
|
|
75
|
-
# Check JSON payload
|
|
76
|
-
json_payload = call_args[1]["json"]
|
|
77
|
-
assert json_payload == {"name_suffix": " - Test"}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
@pytest.mark.asyncio
|
|
81
|
-
async def test_duplicate_campaign_function_available_when_enabled():
|
|
82
|
-
"""Test that duplicate_campaign function is available when feature is enabled."""
|
|
83
|
-
with patch.dict(os.environ, {"META_ADS_ENABLE_DUPLICATION": "1"}):
|
|
84
|
-
# Reload module to pick up environment variable
|
|
85
|
-
import importlib
|
|
86
|
-
from meta_ads_mcp.core import duplication
|
|
87
|
-
importlib.reload(duplication)
|
|
88
|
-
|
|
89
|
-
# Function should be available
|
|
90
|
-
assert hasattr(duplication, 'duplicate_campaign')
|
|
91
|
-
|
|
92
|
-
# Test that it calls the forwarding function
|
|
93
|
-
with patch("meta_ads_mcp.core.duplication._forward_duplication_request") as mock_forward:
|
|
94
|
-
mock_forward.return_value = '{"success": true}'
|
|
95
|
-
|
|
96
|
-
result = await duplication.duplicate_campaign("123456789", access_token="test_token")
|
|
97
|
-
|
|
98
|
-
mock_forward.assert_called_once_with(
|
|
99
|
-
"campaign",
|
|
100
|
-
"123456789",
|
|
101
|
-
"test_token",
|
|
102
|
-
{
|
|
103
|
-
"name_suffix": " - Copy",
|
|
104
|
-
"include_ad_sets": True,
|
|
105
|
-
"include_ads": True,
|
|
106
|
-
"include_creatives": True,
|
|
107
|
-
"copy_schedule": False,
|
|
108
|
-
"new_daily_budget": None,
|
|
109
|
-
"new_status": "PAUSED"
|
|
110
|
-
}
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def test_get_estimated_components():
|
|
115
|
-
"""Test the _get_estimated_components helper function."""
|
|
116
|
-
from meta_ads_mcp.core.duplication import _get_estimated_components
|
|
117
|
-
|
|
118
|
-
# Test campaign with all components
|
|
119
|
-
campaign_result = _get_estimated_components("campaign", {
|
|
120
|
-
"include_ad_sets": True,
|
|
121
|
-
"include_ads": True,
|
|
122
|
-
"include_creatives": True
|
|
123
|
-
})
|
|
124
|
-
assert campaign_result["campaigns"] == 1
|
|
125
|
-
assert "ad_sets" in campaign_result
|
|
126
|
-
assert "ads" in campaign_result
|
|
127
|
-
assert "creatives" in campaign_result
|
|
128
|
-
|
|
129
|
-
# Test adset
|
|
130
|
-
adset_result = _get_estimated_components("adset", {"include_ads": True})
|
|
131
|
-
assert adset_result["ad_sets"] == 1
|
|
132
|
-
assert "ads" in adset_result
|
|
133
|
-
|
|
134
|
-
# Test creative only
|
|
135
|
-
creative_result = _get_estimated_components("creative", {})
|
|
136
|
-
assert creative_result == {"creatives": 1}
|
|
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
|