meta-ads-mcp 0.9.2__tar.gz → 0.9.3__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.2 → meta_ads_mcp-0.9.3}/PKG-INFO +1 -1
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/__init__.py +1 -1
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/accounts.py +27 -26
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/openai_deep_research.py +2 -2
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/pyproject.toml +1 -1
- meta_ads_mcp-0.9.3/tests/e2e_account_info_search_issue.py +320 -0
- meta_ads_mcp-0.9.3/tests/test_account_info_access_fix.py +317 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_dsa_beneficiary.py +12 -2
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_dsa_integration.py +12 -2
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/.github/workflows/publish.yml +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/.github/workflows/test.yml +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/.gitignore +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/CUSTOM_META_APP.md +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/Dockerfile +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/LICENSE +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/LOCAL_INSTALLATION.md +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/META_API_NOTES.md +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/README.md +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/RELEASE.md +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/STREAMABLE_HTTP_SETUP.md +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/examples/README.md +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/examples/example_http_client.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/future_improvements.md +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/images/meta-ads-example.png +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_auth.sh +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/__main__.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/__init__.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/ads.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/ads_library.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/adsets.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/api.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/auth.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/authentication.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/budget_schedules.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/callback_server.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/campaigns.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/duplication.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/http_auth_integration.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/insights.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/reports.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/resources.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/server.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/targeting.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/meta_ads_mcp/core/utils.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/requirements.txt +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/setup.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/smithery.yaml +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/README.md +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/README_REGRESSION_TESTS.md +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/__init__.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/conftest.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_account_search.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_budget_update.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_budget_update_e2e.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_duplication.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_duplication_regression.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_dynamic_creatives.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_get_account_pages.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_get_ad_creatives_fix.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_get_ad_image_quality_improvements.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_get_ad_image_regression.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_http_transport.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_insights_actions_and_values.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_integration_openai_mcp.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_mobile_app_adset_creation.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_mobile_app_adset_issue.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_openai.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_openai_mcp_deep_research.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_page_discovery.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_page_discovery_integration.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_targeting.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/tests/test_targeting_search_e2e.py +0 -0
- {meta_ads_mcp-0.9.2 → meta_ads_mcp-0.9.3}/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.3
|
|
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
|
|
@@ -51,32 +51,7 @@ async def get_account_info(access_token: str = None, account_id: str = None) ->
|
|
|
51
51
|
if not account_id.startswith("act_"):
|
|
52
52
|
account_id = f"act_{account_id}"
|
|
53
53
|
|
|
54
|
-
#
|
|
55
|
-
endpoint = "me/adaccounts"
|
|
56
|
-
params = {
|
|
57
|
-
"fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,business_city,business_country_code",
|
|
58
|
-
"limit": 50
|
|
59
|
-
}
|
|
60
|
-
accessible_accounts_data = await make_api_request(endpoint, access_token, params)
|
|
61
|
-
|
|
62
|
-
if "data" in accessible_accounts_data:
|
|
63
|
-
accessible_account_ids = [acc["id"] for acc in accessible_accounts_data["data"]]
|
|
64
|
-
if account_id not in accessible_account_ids:
|
|
65
|
-
# Provide a helpful error message with accessible accounts
|
|
66
|
-
accessible_accounts = [
|
|
67
|
-
{"id": acc["id"], "name": acc["name"]}
|
|
68
|
-
for acc in accessible_accounts_data["data"][:10] # Show first 10
|
|
69
|
-
]
|
|
70
|
-
return {
|
|
71
|
-
"error": {
|
|
72
|
-
"message": f"Account {account_id} is not accessible to your user account",
|
|
73
|
-
"details": "This account either doesn't exist or you don't have permission to access it",
|
|
74
|
-
"accessible_accounts": accessible_accounts,
|
|
75
|
-
"total_accessible_accounts": len(accessible_accounts_data["data"]),
|
|
76
|
-
"suggestion": "Try using one of the accessible account IDs listed above"
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
54
|
+
# Try to get the account info directly first
|
|
80
55
|
endpoint = f"{account_id}"
|
|
81
56
|
params = {
|
|
82
57
|
"fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,business_city,business_country_code,timezone_name"
|
|
@@ -86,6 +61,32 @@ async def get_account_info(access_token: str = None, account_id: str = None) ->
|
|
|
86
61
|
|
|
87
62
|
# Check if the API request returned an error
|
|
88
63
|
if "error" in data:
|
|
64
|
+
# If access was denied, provide helpful error message with accessible accounts
|
|
65
|
+
if "access" in str(data.get("error", {})).lower() or "permission" in str(data.get("error", {})).lower():
|
|
66
|
+
# Get list of accessible accounts for helpful error message
|
|
67
|
+
accessible_endpoint = "me/adaccounts"
|
|
68
|
+
accessible_params = {
|
|
69
|
+
"fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,business_city,business_country_code",
|
|
70
|
+
"limit": 50
|
|
71
|
+
}
|
|
72
|
+
accessible_accounts_data = await make_api_request(accessible_endpoint, access_token, accessible_params)
|
|
73
|
+
|
|
74
|
+
if "data" in accessible_accounts_data:
|
|
75
|
+
accessible_accounts = [
|
|
76
|
+
{"id": acc["id"], "name": acc["name"]}
|
|
77
|
+
for acc in accessible_accounts_data["data"][:10] # Show first 10
|
|
78
|
+
]
|
|
79
|
+
return {
|
|
80
|
+
"error": {
|
|
81
|
+
"message": f"Account {account_id} is not accessible to your user account",
|
|
82
|
+
"details": "This account either doesn't exist or you don't have permission to access it",
|
|
83
|
+
"accessible_accounts": accessible_accounts,
|
|
84
|
+
"total_accessible_accounts": len(accessible_accounts_data["data"]),
|
|
85
|
+
"suggestion": "Try using one of the accessible account IDs listed above"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# Return the original error for non-permission related issues
|
|
89
90
|
return data
|
|
90
91
|
|
|
91
92
|
# Add DSA requirement detection
|
|
@@ -23,7 +23,7 @@ class MetaAdsDataManager:
|
|
|
23
23
|
self._cache = {}
|
|
24
24
|
logger.debug("MetaAdsDataManager initialized")
|
|
25
25
|
|
|
26
|
-
async def _get_ad_accounts(self, access_token: str, limit: int =
|
|
26
|
+
async def _get_ad_accounts(self, access_token: str, limit: int = 200) -> List[Dict[str, Any]]:
|
|
27
27
|
"""Get ad accounts data"""
|
|
28
28
|
try:
|
|
29
29
|
endpoint = "me/adaccounts"
|
|
@@ -141,7 +141,7 @@ class MetaAdsDataManager:
|
|
|
141
141
|
|
|
142
142
|
try:
|
|
143
143
|
# Search ad accounts
|
|
144
|
-
accounts = await self._get_ad_accounts(access_token, limit=
|
|
144
|
+
accounts = await self._get_ad_accounts(access_token, limit=200)
|
|
145
145
|
for account in accounts:
|
|
146
146
|
account_text = f"{account.get('name', '')} {account.get('id', '')} {account.get('account_status', '')} {account.get('business_city', '')} {account.get('business_country_code', '')}".lower()
|
|
147
147
|
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
E2E Test for Account Info Search Issue
|
|
4
|
+
|
|
5
|
+
This test reproduces the issue reported by a user where get_account_info
|
|
6
|
+
cannot find account ID 414174661097171, while get_ad_accounts can see it.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
1. Start the server: uv run python -m meta_ads_mcp --transport streamable-http --port 8080
|
|
10
|
+
2. Run test: uv run python tests/e2e_account_info_search_issue.py
|
|
11
|
+
|
|
12
|
+
Or with pytest (manual only):
|
|
13
|
+
uv run python -m pytest tests/e2e_account_info_search_issue.py -v -m e2e
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
import requests
|
|
18
|
+
import json
|
|
19
|
+
from typing import Dict, Any
|
|
20
|
+
|
|
21
|
+
@pytest.mark.e2e
|
|
22
|
+
@pytest.mark.skip(reason="E2E test - run manually only")
|
|
23
|
+
class TestAccountInfoSearchIssue:
|
|
24
|
+
"""E2E test for account info search issue"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, base_url: str = "http://localhost:8080"):
|
|
27
|
+
self.base_url = base_url.rstrip('/')
|
|
28
|
+
self.endpoint = f"{self.base_url}/mcp/"
|
|
29
|
+
self.request_id = 1
|
|
30
|
+
self.target_account_id = "414174661097171"
|
|
31
|
+
|
|
32
|
+
def test_get_ad_accounts_can_see_target_account(self):
|
|
33
|
+
"""Verify get_ad_accounts can see account 414174661097171"""
|
|
34
|
+
print(f"\n🔍 Testing if get_ad_accounts can see account {self.target_account_id}")
|
|
35
|
+
|
|
36
|
+
params = {
|
|
37
|
+
"name": "get_ad_accounts",
|
|
38
|
+
"arguments": {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
result = self._make_request("tools/call", params)
|
|
42
|
+
|
|
43
|
+
if not result["success"]:
|
|
44
|
+
return {
|
|
45
|
+
"success": False,
|
|
46
|
+
"error": f"Request failed: {result.get('error', 'Unknown error')}",
|
|
47
|
+
"status_code": result["status_code"]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
response_data = result["json"]["result"]
|
|
52
|
+
content = response_data.get("content", [{}])[0].get("text", "")
|
|
53
|
+
parsed_content = json.loads(content)
|
|
54
|
+
|
|
55
|
+
# Check for errors first
|
|
56
|
+
error_info = self._check_for_errors(parsed_content)
|
|
57
|
+
if error_info["has_error"]:
|
|
58
|
+
return {
|
|
59
|
+
"success": False,
|
|
60
|
+
"error": f"get_ad_accounts returned error: {error_info['error_message']}",
|
|
61
|
+
"error_format": error_info["format"]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Extract accounts data
|
|
65
|
+
accounts = []
|
|
66
|
+
if "data" in parsed_content:
|
|
67
|
+
data = parsed_content["data"]
|
|
68
|
+
|
|
69
|
+
# Handle case where data is already parsed (list/dict)
|
|
70
|
+
if isinstance(data, list):
|
|
71
|
+
accounts = data
|
|
72
|
+
elif isinstance(data, dict) and "data" in data:
|
|
73
|
+
accounts = data["data"]
|
|
74
|
+
|
|
75
|
+
# Handle case where data is a JSON string that needs parsing
|
|
76
|
+
elif isinstance(data, str):
|
|
77
|
+
try:
|
|
78
|
+
parsed_data = json.loads(data)
|
|
79
|
+
if isinstance(parsed_data, list):
|
|
80
|
+
accounts = parsed_data
|
|
81
|
+
elif isinstance(parsed_data, dict) and "data" in parsed_data:
|
|
82
|
+
accounts = parsed_data["data"]
|
|
83
|
+
except json.JSONDecodeError:
|
|
84
|
+
pass
|
|
85
|
+
elif isinstance(parsed_content, list):
|
|
86
|
+
accounts = parsed_content
|
|
87
|
+
|
|
88
|
+
# Search for target account
|
|
89
|
+
found_account = None
|
|
90
|
+
for account in accounts:
|
|
91
|
+
if isinstance(account, dict):
|
|
92
|
+
account_id = account.get("id", "").replace("act_", "")
|
|
93
|
+
if account_id == self.target_account_id:
|
|
94
|
+
found_account = account
|
|
95
|
+
break
|
|
96
|
+
|
|
97
|
+
result_data = {
|
|
98
|
+
"success": True,
|
|
99
|
+
"total_accounts": len(accounts),
|
|
100
|
+
"target_account_found": found_account is not None,
|
|
101
|
+
"found_account_details": found_account if found_account else None,
|
|
102
|
+
"all_account_ids": [
|
|
103
|
+
acc.get("id", "").replace("act_", "")
|
|
104
|
+
for acc in accounts
|
|
105
|
+
if isinstance(acc, dict) and acc.get("id")
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
print(f"✅ get_ad_accounts results:")
|
|
110
|
+
print(f" Total accounts found: {result_data['total_accounts']}")
|
|
111
|
+
print(f" Target account {self.target_account_id} found: {result_data['target_account_found']}")
|
|
112
|
+
if found_account:
|
|
113
|
+
print(f" Account details: {found_account}")
|
|
114
|
+
|
|
115
|
+
return result_data
|
|
116
|
+
|
|
117
|
+
except json.JSONDecodeError as e:
|
|
118
|
+
return {
|
|
119
|
+
"success": False,
|
|
120
|
+
"error": f"Could not parse get_ad_accounts response: {str(e)}",
|
|
121
|
+
"raw_content": content
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
def test_get_account_info_cannot_find_target_account(self):
|
|
125
|
+
"""Verify get_account_info cannot find account 414174661097171"""
|
|
126
|
+
print(f"\n🔍 Testing if get_account_info can find account {self.target_account_id}")
|
|
127
|
+
|
|
128
|
+
params = {
|
|
129
|
+
"name": "get_account_info",
|
|
130
|
+
"arguments": {
|
|
131
|
+
"account_id": self.target_account_id
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
result = self._make_request("tools/call", params)
|
|
136
|
+
|
|
137
|
+
if not result["success"]:
|
|
138
|
+
return {
|
|
139
|
+
"success": False,
|
|
140
|
+
"error": f"Request failed: {result.get('error', 'Unknown error')}",
|
|
141
|
+
"status_code": result["status_code"]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
response_data = result["json"]["result"]
|
|
146
|
+
content = response_data.get("content", [{}])[0].get("text", "")
|
|
147
|
+
parsed_content = json.loads(content)
|
|
148
|
+
|
|
149
|
+
# Check for errors
|
|
150
|
+
error_info = self._check_for_errors(parsed_content)
|
|
151
|
+
|
|
152
|
+
result_data = {
|
|
153
|
+
"success": True,
|
|
154
|
+
"has_error": error_info["has_error"],
|
|
155
|
+
"error_message": error_info.get("error_message", ""),
|
|
156
|
+
"error_format": error_info.get("format", ""),
|
|
157
|
+
"raw_response": parsed_content
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
print(f"✅ get_account_info results:")
|
|
161
|
+
print(f" Has error: {result_data['has_error']}")
|
|
162
|
+
if result_data['has_error']:
|
|
163
|
+
print(f" Error message: {result_data['error_message']}")
|
|
164
|
+
print(f" Error format: {result_data['error_format']}")
|
|
165
|
+
else:
|
|
166
|
+
print(f" Unexpected success: {parsed_content}")
|
|
167
|
+
|
|
168
|
+
return result_data
|
|
169
|
+
|
|
170
|
+
except json.JSONDecodeError as e:
|
|
171
|
+
return {
|
|
172
|
+
"success": False,
|
|
173
|
+
"error": f"Could not parse get_account_info response: {str(e)}",
|
|
174
|
+
"raw_content": content
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
def _check_for_errors(self, parsed_content: Dict[str, Any]) -> Dict[str, Any]:
|
|
178
|
+
"""Properly handle both wrapped and direct error formats"""
|
|
179
|
+
|
|
180
|
+
# Check for data wrapper format first
|
|
181
|
+
if "data" in parsed_content:
|
|
182
|
+
data = parsed_content["data"]
|
|
183
|
+
|
|
184
|
+
# Handle case where data is already parsed (dict/list)
|
|
185
|
+
if isinstance(data, dict) and 'error' in data:
|
|
186
|
+
return {
|
|
187
|
+
"has_error": True,
|
|
188
|
+
"error_message": data['error'],
|
|
189
|
+
"error_details": data.get('details', ''),
|
|
190
|
+
"format": "wrapped_dict"
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# Handle case where data is a JSON string that needs parsing
|
|
194
|
+
if isinstance(data, str):
|
|
195
|
+
try:
|
|
196
|
+
error_data = json.loads(data)
|
|
197
|
+
if 'error' in error_data:
|
|
198
|
+
return {
|
|
199
|
+
"has_error": True,
|
|
200
|
+
"error_message": error_data['error'],
|
|
201
|
+
"error_details": error_data.get('details', ''),
|
|
202
|
+
"format": "wrapped_json"
|
|
203
|
+
}
|
|
204
|
+
except json.JSONDecodeError:
|
|
205
|
+
# Data field exists but isn't valid JSON
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
# Check for direct error format
|
|
209
|
+
if 'error' in parsed_content:
|
|
210
|
+
return {
|
|
211
|
+
"has_error": True,
|
|
212
|
+
"error_message": parsed_content['error'],
|
|
213
|
+
"error_details": parsed_content.get('details', ''),
|
|
214
|
+
"format": "direct"
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {"has_error": False}
|
|
218
|
+
|
|
219
|
+
def _make_request(self, method: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
|
|
220
|
+
"""Make a JSON-RPC request to the MCP server"""
|
|
221
|
+
|
|
222
|
+
headers = {
|
|
223
|
+
"Content-Type": "application/json",
|
|
224
|
+
"Accept": "application/json, text/event-stream",
|
|
225
|
+
"User-Agent": "E2E-Test-Client/1.0"
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
payload = {
|
|
229
|
+
"jsonrpc": "2.0",
|
|
230
|
+
"method": method,
|
|
231
|
+
"id": self.request_id
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if params:
|
|
235
|
+
payload["params"] = params
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
response = requests.post(
|
|
239
|
+
self.endpoint,
|
|
240
|
+
headers=headers,
|
|
241
|
+
json=payload,
|
|
242
|
+
timeout=30
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
self.request_id += 1
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
"status_code": response.status_code,
|
|
249
|
+
"json": response.json() if response.status_code == 200 else None,
|
|
250
|
+
"text": response.text,
|
|
251
|
+
"success": response.status_code == 200
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
except requests.exceptions.RequestException as e:
|
|
255
|
+
return {
|
|
256
|
+
"status_code": 0,
|
|
257
|
+
"json": None,
|
|
258
|
+
"text": str(e),
|
|
259
|
+
"success": False,
|
|
260
|
+
"error": str(e)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
def run_validation():
|
|
264
|
+
"""Run the validation tests"""
|
|
265
|
+
print("🚀 Starting Account Info Search Issue Validation")
|
|
266
|
+
print(f"Target Account ID: 414174661097171")
|
|
267
|
+
print("="*60)
|
|
268
|
+
|
|
269
|
+
test_instance = TestAccountInfoSearchIssue()
|
|
270
|
+
|
|
271
|
+
# Test 1: Check if get_ad_accounts can see the account
|
|
272
|
+
accounts_result = test_instance.test_get_ad_accounts_can_see_target_account()
|
|
273
|
+
|
|
274
|
+
# Test 2: Check if get_account_info can find the account
|
|
275
|
+
account_info_result = test_instance.test_get_account_info_cannot_find_target_account()
|
|
276
|
+
|
|
277
|
+
print("\n" + "="*60)
|
|
278
|
+
print("📊 VALIDATION SUMMARY")
|
|
279
|
+
print("="*60)
|
|
280
|
+
|
|
281
|
+
if accounts_result["success"]:
|
|
282
|
+
print(f"✅ get_ad_accounts: Found {accounts_result['total_accounts']} total accounts")
|
|
283
|
+
if accounts_result["target_account_found"]:
|
|
284
|
+
print(f"✅ get_ad_accounts: Target account 414174661097171 IS visible")
|
|
285
|
+
else:
|
|
286
|
+
print(f"❌ get_ad_accounts: Target account 414174661097171 is NOT visible")
|
|
287
|
+
print(f" Available account IDs: {accounts_result.get('all_account_ids', [])}")
|
|
288
|
+
else:
|
|
289
|
+
print(f"❌ get_ad_accounts: Failed - {accounts_result['error']}")
|
|
290
|
+
|
|
291
|
+
if account_info_result["success"]:
|
|
292
|
+
if account_info_result["has_error"]:
|
|
293
|
+
print(f"❌ get_account_info: Cannot find account (Error: {account_info_result['error_message']})")
|
|
294
|
+
else:
|
|
295
|
+
print(f"✅ get_account_info: Successfully found account")
|
|
296
|
+
else:
|
|
297
|
+
print(f"❌ get_account_info: Test failed - {account_info_result['error']}")
|
|
298
|
+
|
|
299
|
+
# Determine if issue is confirmed
|
|
300
|
+
issue_confirmed = (
|
|
301
|
+
accounts_result.get("success", False) and
|
|
302
|
+
accounts_result.get("target_account_found", False) and
|
|
303
|
+
account_info_result.get("success", False) and
|
|
304
|
+
account_info_result.get("has_error", False)
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
print("\n" + "="*60)
|
|
308
|
+
if issue_confirmed:
|
|
309
|
+
print("🐛 ISSUE CONFIRMED:")
|
|
310
|
+
print(" - get_ad_accounts CAN see the account")
|
|
311
|
+
print(" - get_account_info CANNOT find the account")
|
|
312
|
+
print(" - This validates the user's complaint")
|
|
313
|
+
else:
|
|
314
|
+
print("🤔 ISSUE NOT CONFIRMED:")
|
|
315
|
+
print(" - The behavior may be different than reported")
|
|
316
|
+
print(" - Check individual test results above")
|
|
317
|
+
print("="*60)
|
|
318
|
+
|
|
319
|
+
if __name__ == "__main__":
|
|
320
|
+
run_validation()
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Unit tests for get_account_info accessibility fix.
|
|
4
|
+
|
|
5
|
+
This module tests the fix for the issue where get_account_info couldn't access
|
|
6
|
+
accounts that were visible in get_ad_accounts but not in the limited direct
|
|
7
|
+
accessibility list.
|
|
8
|
+
|
|
9
|
+
The fix changes the logic to try fetching account info directly first,
|
|
10
|
+
rather than pre-checking against a limited accessibility list.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
import json
|
|
15
|
+
from unittest.mock import AsyncMock, patch
|
|
16
|
+
|
|
17
|
+
from meta_ads_mcp.core.accounts import get_account_info
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestAccountInfoAccessFix:
|
|
21
|
+
"""Test cases for the get_account_info accessibility fix"""
|
|
22
|
+
|
|
23
|
+
@pytest.mark.asyncio
|
|
24
|
+
async def test_account_info_direct_access_success(self):
|
|
25
|
+
"""Test that get_account_info works when direct API call succeeds"""
|
|
26
|
+
|
|
27
|
+
# Mock the direct account info API response
|
|
28
|
+
mock_account_response = {
|
|
29
|
+
"id": "act_414174661097171",
|
|
30
|
+
"name": "Venture Hunting & Outdoors",
|
|
31
|
+
"account_id": "414174661097171",
|
|
32
|
+
"account_status": 1,
|
|
33
|
+
"amount_spent": "5818510",
|
|
34
|
+
"balance": "97677",
|
|
35
|
+
"currency": "AUD",
|
|
36
|
+
"timezone_name": "Australia/Brisbane",
|
|
37
|
+
"business_country_code": "AU"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
with patch('meta_ads_mcp.core.accounts.make_api_request', new_callable=AsyncMock) as mock_api:
|
|
41
|
+
with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
|
|
42
|
+
mock_auth.return_value = "test_access_token"
|
|
43
|
+
mock_api.return_value = mock_account_response
|
|
44
|
+
|
|
45
|
+
result = await get_account_info(account_id="414174661097171")
|
|
46
|
+
|
|
47
|
+
# Handle both string and dict return formats
|
|
48
|
+
if isinstance(result, str):
|
|
49
|
+
result_data = json.loads(result)
|
|
50
|
+
else:
|
|
51
|
+
result_data = result
|
|
52
|
+
|
|
53
|
+
# Verify the account info was returned successfully
|
|
54
|
+
assert "error" not in result_data
|
|
55
|
+
assert result_data["id"] == "act_414174661097171"
|
|
56
|
+
assert result_data["name"] == "Venture Hunting & Outdoors"
|
|
57
|
+
assert result_data["account_id"] == "414174661097171"
|
|
58
|
+
assert result_data["currency"] == "AUD"
|
|
59
|
+
assert result_data["timezone_name"] == "Australia/Brisbane"
|
|
60
|
+
|
|
61
|
+
# Verify DSA compliance detection was added
|
|
62
|
+
assert "dsa_required" in result_data
|
|
63
|
+
assert result_data["dsa_required"] is False # AU is not European
|
|
64
|
+
assert "dsa_compliance_note" in result_data
|
|
65
|
+
|
|
66
|
+
# Verify the API was called with correct parameters
|
|
67
|
+
mock_api.assert_called_once_with(
|
|
68
|
+
"act_414174661097171",
|
|
69
|
+
"test_access_token",
|
|
70
|
+
{
|
|
71
|
+
"fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,business_city,business_country_code,timezone_name"
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@pytest.mark.asyncio
|
|
76
|
+
async def test_account_info_permission_error_with_helpful_message(self):
|
|
77
|
+
"""Test that permission errors provide helpful error messages with accessible accounts"""
|
|
78
|
+
|
|
79
|
+
# Mock the permission error response from direct API call
|
|
80
|
+
mock_permission_error = {
|
|
81
|
+
"error": {
|
|
82
|
+
"message": "Insufficient privileges to access the object",
|
|
83
|
+
"type": "OAuthException",
|
|
84
|
+
"code": 200
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Mock accessible accounts response for helpful error message
|
|
89
|
+
mock_accessible_accounts = {
|
|
90
|
+
"data": [
|
|
91
|
+
{"id": "act_123456", "name": "Accessible Account 1"},
|
|
92
|
+
{"id": "act_789012", "name": "Accessible Account 2"},
|
|
93
|
+
{"id": "act_345678", "name": "Accessible Account 3"}
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
with patch('meta_ads_mcp.core.accounts.make_api_request', new_callable=AsyncMock) as mock_api:
|
|
98
|
+
with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
|
|
99
|
+
mock_auth.return_value = "test_access_token"
|
|
100
|
+
|
|
101
|
+
# First call returns permission error, second call returns accessible accounts
|
|
102
|
+
mock_api.side_effect = [mock_permission_error, mock_accessible_accounts]
|
|
103
|
+
|
|
104
|
+
result = await get_account_info(account_id="414174661097171")
|
|
105
|
+
|
|
106
|
+
# Handle both string and dict return formats
|
|
107
|
+
if isinstance(result, str):
|
|
108
|
+
result_data = json.loads(result)
|
|
109
|
+
else:
|
|
110
|
+
result_data = result
|
|
111
|
+
|
|
112
|
+
# Verify helpful error message
|
|
113
|
+
assert "error" in result_data
|
|
114
|
+
assert "not accessible to your user account" in result_data["error"]["message"]
|
|
115
|
+
assert "accessible_accounts" in result_data["error"]
|
|
116
|
+
assert "suggestion" in result_data["error"]
|
|
117
|
+
assert result_data["error"]["total_accessible_accounts"] == 3
|
|
118
|
+
|
|
119
|
+
# Verify accessible accounts list
|
|
120
|
+
accessible_accounts = result_data["error"]["accessible_accounts"]
|
|
121
|
+
assert len(accessible_accounts) == 3
|
|
122
|
+
assert accessible_accounts[0]["id"] == "act_123456"
|
|
123
|
+
assert accessible_accounts[0]["name"] == "Accessible Account 1"
|
|
124
|
+
|
|
125
|
+
# Verify API calls were made
|
|
126
|
+
assert mock_api.call_count == 2
|
|
127
|
+
|
|
128
|
+
# First call: direct account access attempt
|
|
129
|
+
mock_api.assert_any_call(
|
|
130
|
+
"act_414174661097171",
|
|
131
|
+
"test_access_token",
|
|
132
|
+
{
|
|
133
|
+
"fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,business_city,business_country_code,timezone_name"
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Second call: get accessible accounts for error message
|
|
138
|
+
mock_api.assert_any_call(
|
|
139
|
+
"me/adaccounts",
|
|
140
|
+
"test_access_token",
|
|
141
|
+
{
|
|
142
|
+
"fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,business_city,business_country_code",
|
|
143
|
+
"limit": 50
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
@pytest.mark.asyncio
|
|
148
|
+
async def test_account_info_non_permission_error_passthrough(self):
|
|
149
|
+
"""Test that non-permission errors are passed through unchanged"""
|
|
150
|
+
|
|
151
|
+
# Mock a non-permission error (e.g., invalid account ID)
|
|
152
|
+
mock_error_response = {
|
|
153
|
+
"error": {
|
|
154
|
+
"message": "Invalid account ID format",
|
|
155
|
+
"type": "GraphAPIException",
|
|
156
|
+
"code": 100
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
with patch('meta_ads_mcp.core.accounts.make_api_request', new_callable=AsyncMock) as mock_api:
|
|
161
|
+
with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
|
|
162
|
+
mock_auth.return_value = "test_access_token"
|
|
163
|
+
mock_api.return_value = mock_error_response
|
|
164
|
+
|
|
165
|
+
result = await get_account_info(account_id="invalid_id")
|
|
166
|
+
|
|
167
|
+
# Handle both string and dict return formats
|
|
168
|
+
if isinstance(result, str):
|
|
169
|
+
result_data = json.loads(result)
|
|
170
|
+
else:
|
|
171
|
+
result_data = result
|
|
172
|
+
|
|
173
|
+
# Verify the original error is returned unchanged
|
|
174
|
+
assert result_data == mock_error_response
|
|
175
|
+
|
|
176
|
+
# Verify only one API call was made (no attempt to get accessible accounts)
|
|
177
|
+
mock_api.assert_called_once()
|
|
178
|
+
|
|
179
|
+
@pytest.mark.asyncio
|
|
180
|
+
async def test_account_info_missing_account_id_error(self):
|
|
181
|
+
"""Test that missing account_id parameter returns appropriate error"""
|
|
182
|
+
|
|
183
|
+
with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
|
|
184
|
+
mock_auth.return_value = "test_access_token"
|
|
185
|
+
|
|
186
|
+
result = await get_account_info(account_id=None)
|
|
187
|
+
|
|
188
|
+
# Handle both string and dict return formats
|
|
189
|
+
if isinstance(result, str):
|
|
190
|
+
result_data = json.loads(result)
|
|
191
|
+
else:
|
|
192
|
+
result_data = result
|
|
193
|
+
|
|
194
|
+
# Verify error message
|
|
195
|
+
assert "error" in result_data
|
|
196
|
+
assert "Account ID is required" in result_data["error"]["message"]
|
|
197
|
+
assert "Please specify an account_id parameter" in result_data["error"]["details"]
|
|
198
|
+
|
|
199
|
+
@pytest.mark.asyncio
|
|
200
|
+
async def test_account_info_act_prefix_handling(self):
|
|
201
|
+
"""Test that account_id prefix handling works correctly"""
|
|
202
|
+
|
|
203
|
+
mock_account_response = {
|
|
204
|
+
"id": "act_123456789",
|
|
205
|
+
"name": "Test Account",
|
|
206
|
+
"account_id": "123456789"
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
with patch('meta_ads_mcp.core.accounts.make_api_request', new_callable=AsyncMock) as mock_api:
|
|
210
|
+
with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
|
|
211
|
+
mock_auth.return_value = "test_access_token"
|
|
212
|
+
mock_api.return_value = mock_account_response
|
|
213
|
+
|
|
214
|
+
# Test with account ID without act_ prefix
|
|
215
|
+
result = await get_account_info(account_id="123456789")
|
|
216
|
+
|
|
217
|
+
# Verify the API was called with the act_ prefix added
|
|
218
|
+
mock_api.assert_called_once_with(
|
|
219
|
+
"act_123456789", # Should have act_ prefix added
|
|
220
|
+
"test_access_token",
|
|
221
|
+
{
|
|
222
|
+
"fields": "id,name,account_id,account_status,amount_spent,balance,currency,age,business_city,business_country_code,timezone_name"
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
@pytest.mark.asyncio
|
|
227
|
+
async def test_account_info_european_dsa_detection(self):
|
|
228
|
+
"""Test that DSA requirements are properly detected for European accounts"""
|
|
229
|
+
|
|
230
|
+
# Mock account response for German business
|
|
231
|
+
mock_account_response = {
|
|
232
|
+
"id": "act_999888777",
|
|
233
|
+
"name": "German Test Account",
|
|
234
|
+
"account_id": "999888777",
|
|
235
|
+
"business_country_code": "DE" # Germany - should trigger DSA
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
with patch('meta_ads_mcp.core.accounts.make_api_request', new_callable=AsyncMock) as mock_api:
|
|
239
|
+
with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
|
|
240
|
+
mock_auth.return_value = "test_access_token"
|
|
241
|
+
mock_api.return_value = mock_account_response
|
|
242
|
+
|
|
243
|
+
result = await get_account_info(account_id="999888777")
|
|
244
|
+
|
|
245
|
+
# Handle both string and dict return formats
|
|
246
|
+
if isinstance(result, str):
|
|
247
|
+
result_data = json.loads(result)
|
|
248
|
+
else:
|
|
249
|
+
result_data = result
|
|
250
|
+
|
|
251
|
+
# Verify DSA requirements were properly detected
|
|
252
|
+
assert "dsa_required" in result_data
|
|
253
|
+
assert result_data["dsa_required"] is True # DE is European
|
|
254
|
+
assert "dsa_compliance_note" in result_data
|
|
255
|
+
assert "European DSA" in result_data["dsa_compliance_note"]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class TestAccountInfoAccessRegression:
|
|
259
|
+
"""Regression tests to ensure the fix doesn't break existing functionality"""
|
|
260
|
+
|
|
261
|
+
@pytest.mark.asyncio
|
|
262
|
+
async def test_regression_basic_account_info_still_works(self):
|
|
263
|
+
"""Regression test: ensure basic account info functionality still works"""
|
|
264
|
+
|
|
265
|
+
mock_account_response = {
|
|
266
|
+
"id": "act_123456789",
|
|
267
|
+
"name": "Basic Test Account",
|
|
268
|
+
"account_id": "123456789",
|
|
269
|
+
"account_status": 1,
|
|
270
|
+
"currency": "USD",
|
|
271
|
+
"business_country_code": "US"
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
with patch('meta_ads_mcp.core.accounts.make_api_request', new_callable=AsyncMock) as mock_api:
|
|
275
|
+
with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
|
|
276
|
+
mock_auth.return_value = "test_access_token"
|
|
277
|
+
mock_api.return_value = mock_account_response
|
|
278
|
+
|
|
279
|
+
result = await get_account_info(account_id="act_123456789")
|
|
280
|
+
|
|
281
|
+
# Handle both string and dict return formats
|
|
282
|
+
if isinstance(result, str):
|
|
283
|
+
result_data = json.loads(result)
|
|
284
|
+
else:
|
|
285
|
+
result_data = result
|
|
286
|
+
|
|
287
|
+
# Verify basic functionality works
|
|
288
|
+
assert "error" not in result_data
|
|
289
|
+
assert result_data["id"] == "act_123456789"
|
|
290
|
+
assert result_data["name"] == "Basic Test Account"
|
|
291
|
+
|
|
292
|
+
def test_account_info_fix_comparison(self):
|
|
293
|
+
"""
|
|
294
|
+
Documentation test: explains what the fix changed
|
|
295
|
+
|
|
296
|
+
BEFORE: get_account_info checked accessibility first against limited list (50 accounts)
|
|
297
|
+
AFTER: get_account_info tries direct API call first, only shows error if API fails
|
|
298
|
+
|
|
299
|
+
This allows accounts visible through business manager (like 414174661097171)
|
|
300
|
+
to work properly even if they're not in the limited direct accessibility list.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
# This is a documentation test - no actual code execution
|
|
304
|
+
old_behavior = "Pre-check accessibility against limited 50 account list"
|
|
305
|
+
new_behavior = "Try direct API call first, handle permission errors gracefully"
|
|
306
|
+
|
|
307
|
+
assert old_behavior != new_behavior
|
|
308
|
+
|
|
309
|
+
# The key insight: get_ad_accounts shows 107 accounts through business manager,
|
|
310
|
+
# but "me/adaccounts" only shows 50 directly accessible accounts
|
|
311
|
+
total_visible_accounts = 107
|
|
312
|
+
directly_accessible_accounts = 50
|
|
313
|
+
|
|
314
|
+
assert total_visible_accounts > directly_accessible_accounts
|
|
315
|
+
|
|
316
|
+
# Account 414174661097171 was in the 107 but not in the 50
|
|
317
|
+
# The fix allows get_account_info to work for such accounts
|
|
@@ -122,7 +122,16 @@ class TestDSABeneficiaryDetection:
|
|
|
122
122
|
async def test_account_info_inaccessible_account_error(self):
|
|
123
123
|
"""Test that get_account_info provides helpful error for inaccessible accounts"""
|
|
124
124
|
|
|
125
|
-
# Mock
|
|
125
|
+
# Mock permission error for direct account access (first API call)
|
|
126
|
+
mock_permission_error = {
|
|
127
|
+
"error": {
|
|
128
|
+
"message": "Insufficient access privileges",
|
|
129
|
+
"type": "OAuthException",
|
|
130
|
+
"code": 200
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
# Mock accessible accounts response (second API call)
|
|
126
135
|
mock_accessible_accounts = {
|
|
127
136
|
"data": [
|
|
128
137
|
{"id": "act_123", "name": "Test Account 1"},
|
|
@@ -133,7 +142,8 @@ class TestDSABeneficiaryDetection:
|
|
|
133
142
|
with patch('meta_ads_mcp.core.accounts.make_api_request', new_callable=AsyncMock) as mock_api:
|
|
134
143
|
with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
|
|
135
144
|
mock_auth.return_value = "test_access_token"
|
|
136
|
-
|
|
145
|
+
# First call returns permission error, second call returns accessible accounts
|
|
146
|
+
mock_api.side_effect = [mock_permission_error, mock_accessible_accounts]
|
|
137
147
|
|
|
138
148
|
result = await get_account_info(account_id="act_inaccessible")
|
|
139
149
|
|
|
@@ -420,7 +420,16 @@ class TestDSAIntegration:
|
|
|
420
420
|
async def test_account_info_inaccessible_account_error(self):
|
|
421
421
|
"""Test that get_account_info provides helpful error for inaccessible accounts"""
|
|
422
422
|
|
|
423
|
-
# Mock
|
|
423
|
+
# Mock permission error for direct account access (first API call)
|
|
424
|
+
mock_permission_error = {
|
|
425
|
+
"error": {
|
|
426
|
+
"message": "Insufficient access privileges",
|
|
427
|
+
"type": "OAuthException",
|
|
428
|
+
"code": 200
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
# Mock accessible accounts response (second API call)
|
|
424
433
|
mock_accessible_accounts = {
|
|
425
434
|
"data": [
|
|
426
435
|
{"id": "act_123", "name": "Test Account 1"},
|
|
@@ -431,7 +440,8 @@ class TestDSAIntegration:
|
|
|
431
440
|
with patch('meta_ads_mcp.core.accounts.make_api_request', new_callable=AsyncMock) as mock_api:
|
|
432
441
|
with patch('meta_ads_mcp.core.api.get_current_access_token', new_callable=AsyncMock) as mock_auth:
|
|
433
442
|
mock_auth.return_value = "test_access_token"
|
|
434
|
-
|
|
443
|
+
# First call returns permission error, second call returns accessible accounts
|
|
444
|
+
mock_api.side_effect = [mock_permission_error, mock_accessible_accounts]
|
|
435
445
|
|
|
436
446
|
result = await get_account_info(account_id="act_inaccessible")
|
|
437
447
|
|
|
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
|
|
File without changes
|