meta-ads-mcp 0.7.5__tar.gz → 0.7.6__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.7.5 → meta_ads_mcp-0.7.6}/PKG-INFO +1 -1
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/__init__.py +1 -1
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/insights.py +1 -1
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/pyproject.toml +1 -1
- meta_ads_mcp-0.7.6/tests/test_insights_actions_and_values.py +488 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/.github/workflows/publish.yml +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/.github/workflows/test.yml +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/.gitignore +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/CUSTOM_META_APP.md +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/Dockerfile +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/LICENSE +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/LOCAL_INSTALLATION.md +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/META_API_NOTES.md +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/README.md +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/RELEASE.md +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/STREAMABLE_HTTP_SETUP.md +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/examples/README.md +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/examples/example_http_client.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/future_improvements.md +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/images/meta-ads-example.png +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_auth.sh +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/__main__.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/__init__.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/accounts.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/ads.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/ads_library.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/adsets.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/api.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/auth.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/authentication.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/budget_schedules.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/callback_server.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/campaigns.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/duplication.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/http_auth_integration.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/openai_deep_research.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/reports.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/resources.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/server.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/targeting.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/meta_ads_mcp/core/utils.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/requirements.txt +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/setup.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/smithery.yaml +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/tests/README.md +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/tests/README_REGRESSION_TESTS.md +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/tests/__init__.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/tests/conftest.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/tests/test_account_search.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/tests/test_budget_update.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/tests/test_budget_update_e2e.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/tests/test_duplication.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/tests/test_duplication_regression.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/tests/test_get_ad_creatives_fix.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/tests/test_get_ad_image_regression.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/tests/test_http_transport.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/tests/test_integration_openai_mcp.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/tests/test_openai.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/tests/test_openai_mcp_deep_research.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/tests/test_targeting.py +0 -0
- {meta_ads_mcp-0.7.5 → meta_ads_mcp-0.7.6}/tests/test_targeting_search_e2e.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: meta-ads-mcp
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.6
|
|
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
|
|
@@ -33,7 +33,7 @@ async def get_insights(access_token: str = None, object_id: str = None,
|
|
|
33
33
|
|
|
34
34
|
endpoint = f"{object_id}/insights"
|
|
35
35
|
params = {
|
|
36
|
-
"fields": "account_id,account_name,campaign_id,campaign_name,adset_id,adset_name,ad_id,ad_name,impressions,clicks,spend,cpc,cpm,ctr,reach,frequency,actions,conversions,unique_clicks,cost_per_action_type",
|
|
36
|
+
"fields": "account_id,account_name,campaign_id,campaign_name,adset_id,adset_name,ad_id,ad_name,impressions,clicks,spend,cpc,cpm,ctr,reach,frequency,actions,action_values,conversions,unique_clicks,cost_per_action_type",
|
|
37
37
|
"level": level
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Unit Tests for Insights Actions and Action Values Functionality
|
|
4
|
+
|
|
5
|
+
This test suite validates the actions and action_values field implementation for the
|
|
6
|
+
get_insights function in meta_ads_mcp/core/insights.py.
|
|
7
|
+
|
|
8
|
+
Test cases cover:
|
|
9
|
+
- Actions and action_values field inclusion in API requests
|
|
10
|
+
- Different levels of aggregation (ad, adset, campaign, account)
|
|
11
|
+
- Time range handling with actions and action_values
|
|
12
|
+
- Error handling and validation
|
|
13
|
+
- Purchase data extraction from actions and action_values
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
import json
|
|
18
|
+
import asyncio
|
|
19
|
+
from unittest.mock import AsyncMock, patch, MagicMock
|
|
20
|
+
from typing import Dict, Any, List
|
|
21
|
+
|
|
22
|
+
# Import the function to test
|
|
23
|
+
from meta_ads_mcp.core.insights import get_insights
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestInsightsActionsAndValues:
|
|
27
|
+
"""Test suite for actions and action_values in insights"""
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def mock_api_request(self):
|
|
31
|
+
"""Mock for the make_api_request function"""
|
|
32
|
+
with patch('meta_ads_mcp.core.insights.make_api_request') as mock:
|
|
33
|
+
mock.return_value = {
|
|
34
|
+
"data": [
|
|
35
|
+
{
|
|
36
|
+
"campaign_id": "test_campaign_id",
|
|
37
|
+
"campaign_name": "Test Campaign",
|
|
38
|
+
"impressions": "1000",
|
|
39
|
+
"clicks": "50",
|
|
40
|
+
"spend": "100.00",
|
|
41
|
+
"actions": [
|
|
42
|
+
{"action_type": "purchase", "value": "5"},
|
|
43
|
+
{"action_type": "lead", "value": "3"},
|
|
44
|
+
{"action_type": "view_content", "value": "20"}
|
|
45
|
+
],
|
|
46
|
+
"action_values": [
|
|
47
|
+
{"action_type": "purchase", "value": "500.00"},
|
|
48
|
+
{"action_type": "lead", "value": "150.00"},
|
|
49
|
+
{"action_type": "view_content", "value": "0.00"}
|
|
50
|
+
],
|
|
51
|
+
"cost_per_action_type": [
|
|
52
|
+
{"action_type": "purchase", "value": "20.00"},
|
|
53
|
+
{"action_type": "lead", "value": "33.33"}
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"paging": {}
|
|
58
|
+
}
|
|
59
|
+
yield mock
|
|
60
|
+
|
|
61
|
+
@pytest.fixture
|
|
62
|
+
def mock_auth_manager(self):
|
|
63
|
+
"""Mock for the authentication manager"""
|
|
64
|
+
with patch('meta_ads_mcp.core.api.auth_manager') as mock, \
|
|
65
|
+
patch('meta_ads_mcp.core.api.get_current_access_token') as mock_get_token:
|
|
66
|
+
# Mock a valid access token
|
|
67
|
+
mock.get_current_access_token.return_value = "test_access_token"
|
|
68
|
+
mock.is_token_valid.return_value = True
|
|
69
|
+
mock.app_id = "test_app_id"
|
|
70
|
+
mock_get_token.return_value = "test_access_token"
|
|
71
|
+
yield mock
|
|
72
|
+
|
|
73
|
+
@pytest.fixture
|
|
74
|
+
def valid_campaign_id(self):
|
|
75
|
+
"""Valid campaign ID for testing"""
|
|
76
|
+
return "123456789"
|
|
77
|
+
|
|
78
|
+
@pytest.fixture
|
|
79
|
+
def valid_account_id(self):
|
|
80
|
+
"""Valid account ID for testing"""
|
|
81
|
+
return "act_701351919139047"
|
|
82
|
+
|
|
83
|
+
@pytest.mark.asyncio
|
|
84
|
+
async def test_actions_and_action_values_included_in_fields(self, mock_api_request, mock_auth_manager, valid_campaign_id):
|
|
85
|
+
"""Test that actions and action_values are included in the fields parameter"""
|
|
86
|
+
|
|
87
|
+
result = await get_insights(
|
|
88
|
+
object_id=valid_campaign_id,
|
|
89
|
+
time_range="last_30d",
|
|
90
|
+
level="campaign"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Parse the result
|
|
94
|
+
result_data = json.loads(result)
|
|
95
|
+
|
|
96
|
+
# Verify the API was called with correct parameters
|
|
97
|
+
mock_api_request.assert_called_once()
|
|
98
|
+
call_args = mock_api_request.call_args
|
|
99
|
+
|
|
100
|
+
# Check that the endpoint is correct (first argument)
|
|
101
|
+
assert call_args[0][0] == f"{valid_campaign_id}/insights"
|
|
102
|
+
|
|
103
|
+
# Check that actions and action_values are included in fields parameter
|
|
104
|
+
params = call_args[0][2] # Third positional argument is params
|
|
105
|
+
assert 'fields' in params
|
|
106
|
+
|
|
107
|
+
fields = params['fields']
|
|
108
|
+
assert 'actions' in fields
|
|
109
|
+
assert 'action_values' in fields
|
|
110
|
+
assert 'cost_per_action_type' in fields
|
|
111
|
+
|
|
112
|
+
# Verify the response structure
|
|
113
|
+
assert 'data' in result_data
|
|
114
|
+
assert len(result_data['data']) > 0
|
|
115
|
+
assert 'actions' in result_data['data'][0]
|
|
116
|
+
assert 'action_values' in result_data['data'][0]
|
|
117
|
+
|
|
118
|
+
@pytest.mark.asyncio
|
|
119
|
+
async def test_purchase_data_extraction(self, mock_api_request, mock_auth_manager, valid_campaign_id):
|
|
120
|
+
"""Test that purchase data can be extracted from actions and action_values"""
|
|
121
|
+
|
|
122
|
+
result = await get_insights(
|
|
123
|
+
object_id=valid_campaign_id,
|
|
124
|
+
time_range="last_30d",
|
|
125
|
+
level="campaign"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Parse the result
|
|
129
|
+
result_data = json.loads(result)
|
|
130
|
+
|
|
131
|
+
# Get the first data point
|
|
132
|
+
data_point = result_data['data'][0]
|
|
133
|
+
|
|
134
|
+
# Extract purchase data from actions
|
|
135
|
+
actions = data_point.get('actions', [])
|
|
136
|
+
purchase_actions = [action for action in actions if action.get('action_type') == 'purchase']
|
|
137
|
+
|
|
138
|
+
# Extract purchase data from action_values
|
|
139
|
+
action_values = data_point.get('action_values', [])
|
|
140
|
+
purchase_values = [action_value for action_value in action_values if action_value.get('action_type') == 'purchase']
|
|
141
|
+
|
|
142
|
+
# Verify purchase data exists
|
|
143
|
+
assert len(purchase_actions) > 0, "No purchase actions found"
|
|
144
|
+
assert len(purchase_values) > 0, "No purchase action_values found"
|
|
145
|
+
|
|
146
|
+
# Verify purchase data values
|
|
147
|
+
purchase_count = purchase_actions[0].get('value')
|
|
148
|
+
purchase_value = purchase_values[0].get('value')
|
|
149
|
+
|
|
150
|
+
assert purchase_count == "5", f"Expected purchase count 5, got {purchase_count}"
|
|
151
|
+
assert purchase_value == "500.00", f"Expected purchase value 500.00, got {purchase_value}"
|
|
152
|
+
|
|
153
|
+
@pytest.mark.asyncio
|
|
154
|
+
async def test_actions_at_adset_level(self, mock_api_request, mock_auth_manager, valid_campaign_id):
|
|
155
|
+
"""Test actions and action_values at adset level"""
|
|
156
|
+
|
|
157
|
+
result = await get_insights(
|
|
158
|
+
object_id=valid_campaign_id,
|
|
159
|
+
time_range="last_30d",
|
|
160
|
+
level="adset"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Parse the result
|
|
164
|
+
result_data = json.loads(result)
|
|
165
|
+
|
|
166
|
+
# Verify the API was called with correct parameters
|
|
167
|
+
mock_api_request.assert_called_once()
|
|
168
|
+
call_args = mock_api_request.call_args
|
|
169
|
+
|
|
170
|
+
# Check that the level parameter is correct
|
|
171
|
+
params = call_args[0][2]
|
|
172
|
+
assert params['level'] == 'adset'
|
|
173
|
+
|
|
174
|
+
# Verify the response structure
|
|
175
|
+
assert 'data' in result_data
|
|
176
|
+
|
|
177
|
+
@pytest.mark.asyncio
|
|
178
|
+
async def test_actions_at_ad_level(self, mock_api_request, mock_auth_manager, valid_campaign_id):
|
|
179
|
+
"""Test actions and action_values at ad level"""
|
|
180
|
+
|
|
181
|
+
result = await get_insights(
|
|
182
|
+
object_id=valid_campaign_id,
|
|
183
|
+
time_range="last_30d",
|
|
184
|
+
level="ad"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Parse the result
|
|
188
|
+
result_data = json.loads(result)
|
|
189
|
+
|
|
190
|
+
# Verify the API was called with correct parameters
|
|
191
|
+
mock_api_request.assert_called_once()
|
|
192
|
+
call_args = mock_api_request.call_args
|
|
193
|
+
|
|
194
|
+
# Check that the level parameter is correct
|
|
195
|
+
params = call_args[0][2]
|
|
196
|
+
assert params['level'] == 'ad'
|
|
197
|
+
|
|
198
|
+
# Verify the response structure
|
|
199
|
+
assert 'data' in result_data
|
|
200
|
+
|
|
201
|
+
@pytest.mark.asyncio
|
|
202
|
+
async def test_actions_with_custom_time_range(self, mock_api_request, mock_auth_manager, valid_campaign_id):
|
|
203
|
+
"""Test actions and action_values with custom time range"""
|
|
204
|
+
|
|
205
|
+
custom_time_range = {"since": "2024-01-01", "until": "2024-01-31"}
|
|
206
|
+
|
|
207
|
+
result = await get_insights(
|
|
208
|
+
object_id=valid_campaign_id,
|
|
209
|
+
time_range=custom_time_range,
|
|
210
|
+
level="campaign"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Parse the result
|
|
214
|
+
result_data = json.loads(result)
|
|
215
|
+
|
|
216
|
+
# Verify the API was called with correct parameters
|
|
217
|
+
mock_api_request.assert_called_once()
|
|
218
|
+
call_args = mock_api_request.call_args
|
|
219
|
+
|
|
220
|
+
# Check that time_range is properly formatted
|
|
221
|
+
params = call_args[0][2]
|
|
222
|
+
assert 'time_range' in params
|
|
223
|
+
assert params['time_range'] == json.dumps(custom_time_range)
|
|
224
|
+
|
|
225
|
+
# Verify the response structure
|
|
226
|
+
assert 'data' in result_data
|
|
227
|
+
|
|
228
|
+
@pytest.mark.asyncio
|
|
229
|
+
async def test_actions_with_breakdown(self, mock_api_request, mock_auth_manager, valid_campaign_id):
|
|
230
|
+
"""Test actions and action_values with breakdown dimension"""
|
|
231
|
+
|
|
232
|
+
result = await get_insights(
|
|
233
|
+
object_id=valid_campaign_id,
|
|
234
|
+
time_range="last_30d",
|
|
235
|
+
level="campaign",
|
|
236
|
+
breakdown="age"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Parse the result
|
|
240
|
+
result_data = json.loads(result)
|
|
241
|
+
|
|
242
|
+
# Verify the API was called with correct parameters
|
|
243
|
+
mock_api_request.assert_called_once()
|
|
244
|
+
call_args = mock_api_request.call_args
|
|
245
|
+
|
|
246
|
+
# Check that breakdown is included
|
|
247
|
+
params = call_args[0][2]
|
|
248
|
+
assert 'breakdowns' in params
|
|
249
|
+
assert params['breakdowns'] == 'age'
|
|
250
|
+
|
|
251
|
+
# Verify the response structure
|
|
252
|
+
assert 'data' in result_data
|
|
253
|
+
|
|
254
|
+
@pytest.mark.asyncio
|
|
255
|
+
async def test_actions_without_object_id(self, mock_api_request, mock_auth_manager):
|
|
256
|
+
"""Test error handling when no object_id is provided"""
|
|
257
|
+
|
|
258
|
+
result = await get_insights(
|
|
259
|
+
time_range="last_30d",
|
|
260
|
+
level="campaign"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Parse the result
|
|
264
|
+
result_data = json.loads(result)
|
|
265
|
+
|
|
266
|
+
# The error response is wrapped in a 'data' field
|
|
267
|
+
if 'data' in result_data:
|
|
268
|
+
error_data = json.loads(result_data['data'])
|
|
269
|
+
assert 'error' in error_data
|
|
270
|
+
assert 'No object ID provided' in error_data['error']
|
|
271
|
+
else:
|
|
272
|
+
assert 'error' in result_data
|
|
273
|
+
assert 'No object ID provided' in result_data['error']
|
|
274
|
+
|
|
275
|
+
# Verify API was not called
|
|
276
|
+
mock_api_request.assert_not_called()
|
|
277
|
+
|
|
278
|
+
@pytest.mark.asyncio
|
|
279
|
+
async def test_actions_with_invalid_time_range(self, mock_api_request, mock_auth_manager, valid_campaign_id):
|
|
280
|
+
"""Test error handling with invalid time range"""
|
|
281
|
+
|
|
282
|
+
invalid_time_range = {"since": "2024-01-01"} # Missing "until"
|
|
283
|
+
|
|
284
|
+
result = await get_insights(
|
|
285
|
+
object_id=valid_campaign_id,
|
|
286
|
+
time_range=invalid_time_range,
|
|
287
|
+
level="campaign"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# Parse the result
|
|
291
|
+
result_data = json.loads(result)
|
|
292
|
+
|
|
293
|
+
# The error response is wrapped in a 'data' field
|
|
294
|
+
if 'data' in result_data:
|
|
295
|
+
error_data = json.loads(result_data['data'])
|
|
296
|
+
assert 'error' in error_data
|
|
297
|
+
assert 'since' in error_data['error'] and 'until' in error_data['error']
|
|
298
|
+
else:
|
|
299
|
+
assert 'error' in result_data
|
|
300
|
+
assert 'since' in result_data['error'] and 'until' in result_data['error']
|
|
301
|
+
|
|
302
|
+
# Verify API was not called
|
|
303
|
+
mock_api_request.assert_not_called()
|
|
304
|
+
|
|
305
|
+
@pytest.mark.asyncio
|
|
306
|
+
async def test_actions_api_error_handling(self, mock_api_request, mock_auth_manager, valid_campaign_id):
|
|
307
|
+
"""Test error handling when API call fails"""
|
|
308
|
+
|
|
309
|
+
# Mock API to raise an exception
|
|
310
|
+
mock_api_request.side_effect = Exception("API Error")
|
|
311
|
+
|
|
312
|
+
result = await get_insights(
|
|
313
|
+
object_id=valid_campaign_id,
|
|
314
|
+
time_range="last_30d",
|
|
315
|
+
level="campaign"
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# Parse the result
|
|
319
|
+
# The API error handling returns a dict directly, not a JSON string
|
|
320
|
+
if isinstance(result, dict):
|
|
321
|
+
result_data = result
|
|
322
|
+
else:
|
|
323
|
+
result_data = json.loads(result)
|
|
324
|
+
|
|
325
|
+
# Verify error response
|
|
326
|
+
assert 'error' in result_data
|
|
327
|
+
assert 'API Error' in result_data['error']
|
|
328
|
+
|
|
329
|
+
@pytest.mark.asyncio
|
|
330
|
+
async def test_actions_fields_completeness(self, mock_api_request, mock_auth_manager, valid_campaign_id):
|
|
331
|
+
"""Test that all required fields are included in the request"""
|
|
332
|
+
|
|
333
|
+
result = await get_insights(
|
|
334
|
+
object_id=valid_campaign_id,
|
|
335
|
+
time_range="last_30d",
|
|
336
|
+
level="campaign"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Verify the API was called with correct parameters
|
|
340
|
+
mock_api_request.assert_called_once()
|
|
341
|
+
call_args = mock_api_request.call_args
|
|
342
|
+
|
|
343
|
+
# Check that all required fields are included
|
|
344
|
+
params = call_args[0][2]
|
|
345
|
+
fields = params['fields']
|
|
346
|
+
|
|
347
|
+
# Required fields for actions and action_values
|
|
348
|
+
required_fields = [
|
|
349
|
+
'account_id', 'account_name', 'campaign_id', 'campaign_name',
|
|
350
|
+
'adset_id', 'adset_name', 'ad_id', 'ad_name',
|
|
351
|
+
'impressions', 'clicks', 'spend', 'cpc', 'cpm', 'ctr',
|
|
352
|
+
'reach', 'frequency', 'actions', 'action_values', 'conversions',
|
|
353
|
+
'unique_clicks', 'cost_per_action_type'
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
for field in required_fields:
|
|
357
|
+
assert field in fields, f"Field '{field}' not found in fields parameter"
|
|
358
|
+
|
|
359
|
+
@pytest.mark.asyncio
|
|
360
|
+
async def test_multiple_action_types(self, mock_api_request, mock_auth_manager, valid_campaign_id):
|
|
361
|
+
"""Test handling of multiple action types in the response"""
|
|
362
|
+
|
|
363
|
+
result = await get_insights(
|
|
364
|
+
object_id=valid_campaign_id,
|
|
365
|
+
time_range="last_30d",
|
|
366
|
+
level="campaign"
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# Parse the result
|
|
370
|
+
result_data = json.loads(result)
|
|
371
|
+
|
|
372
|
+
# Get the first data point
|
|
373
|
+
data_point = result_data['data'][0]
|
|
374
|
+
|
|
375
|
+
# Check that multiple action types are present
|
|
376
|
+
actions = data_point.get('actions', [])
|
|
377
|
+
action_types = [action.get('action_type') for action in actions]
|
|
378
|
+
|
|
379
|
+
assert 'purchase' in action_types, "Purchase action type not found"
|
|
380
|
+
assert 'lead' in action_types, "Lead action type not found"
|
|
381
|
+
assert 'view_content' in action_types, "View content action type not found"
|
|
382
|
+
|
|
383
|
+
# Check action_values has corresponding entries
|
|
384
|
+
action_values = data_point.get('action_values', [])
|
|
385
|
+
action_value_types = [action_value.get('action_type') for action_value in action_values]
|
|
386
|
+
|
|
387
|
+
assert 'purchase' in action_value_types, "Purchase action_value type not found"
|
|
388
|
+
assert 'lead' in action_value_types, "Lead action_value type not found"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class TestInsightsActionsAndValuesIntegration:
|
|
392
|
+
"""Integration tests for actions and action_values functionality"""
|
|
393
|
+
|
|
394
|
+
@pytest.mark.asyncio
|
|
395
|
+
async def test_actions_workflow(self):
|
|
396
|
+
"""Test complete workflow with actions and action_values"""
|
|
397
|
+
|
|
398
|
+
# This test would require actual API credentials and would be skipped in CI
|
|
399
|
+
# It's included for manual testing with real data
|
|
400
|
+
pytest.skip("Integration test requires real API credentials")
|
|
401
|
+
|
|
402
|
+
# Example workflow:
|
|
403
|
+
# 1. Get campaign insights with actions and action_values
|
|
404
|
+
# 2. Verify the data structure
|
|
405
|
+
# 3. Check that purchase data is present in actions and action_values
|
|
406
|
+
# 4. Validate the values make sense
|
|
407
|
+
|
|
408
|
+
pass
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def extract_purchase_data(insights_data):
|
|
412
|
+
"""
|
|
413
|
+
Helper function to extract purchase data from insights response.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
insights_data: The data array from insights response
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
dict: Dictionary with purchase count and value
|
|
420
|
+
"""
|
|
421
|
+
if not insights_data or len(insights_data) == 0:
|
|
422
|
+
return {"purchase_count": 0, "purchase_value": 0.0}
|
|
423
|
+
|
|
424
|
+
data_point = insights_data[0]
|
|
425
|
+
|
|
426
|
+
# Extract purchase count from actions
|
|
427
|
+
actions = data_point.get('actions', [])
|
|
428
|
+
purchase_actions = [action for action in actions if action.get('action_type') == 'purchase']
|
|
429
|
+
purchase_count = int(purchase_actions[0].get('value', 0)) if purchase_actions else 0
|
|
430
|
+
|
|
431
|
+
# Extract purchase value from action_values
|
|
432
|
+
action_values = data_point.get('action_values', [])
|
|
433
|
+
purchase_values = [action_value for action_value in action_values if action_value.get('action_type') == 'purchase']
|
|
434
|
+
purchase_value = float(purchase_values[0].get('value', 0)) if purchase_values else 0.0
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
"purchase_count": purchase_count,
|
|
438
|
+
"purchase_value": purchase_value
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class TestPurchaseDataExtraction:
|
|
443
|
+
"""Test suite for purchase data extraction helper function"""
|
|
444
|
+
|
|
445
|
+
def test_extract_purchase_data_with_purchases(self):
|
|
446
|
+
"""Test extraction when purchase data is present"""
|
|
447
|
+
insights_data = [{
|
|
448
|
+
"actions": [
|
|
449
|
+
{"action_type": "purchase", "value": "5"},
|
|
450
|
+
{"action_type": "lead", "value": "3"}
|
|
451
|
+
],
|
|
452
|
+
"action_values": [
|
|
453
|
+
{"action_type": "purchase", "value": "500.00"},
|
|
454
|
+
{"action_type": "lead", "value": "150.00"}
|
|
455
|
+
]
|
|
456
|
+
}]
|
|
457
|
+
|
|
458
|
+
result = extract_purchase_data(insights_data)
|
|
459
|
+
|
|
460
|
+
assert result["purchase_count"] == 5
|
|
461
|
+
assert result["purchase_value"] == 500.0
|
|
462
|
+
|
|
463
|
+
def test_extract_purchase_data_without_purchases(self):
|
|
464
|
+
"""Test extraction when no purchase data is present"""
|
|
465
|
+
insights_data = [{
|
|
466
|
+
"actions": [
|
|
467
|
+
{"action_type": "lead", "value": "3"},
|
|
468
|
+
{"action_type": "view_content", "value": "20"}
|
|
469
|
+
],
|
|
470
|
+
"action_values": [
|
|
471
|
+
{"action_type": "lead", "value": "150.00"},
|
|
472
|
+
{"action_type": "view_content", "value": "0.00"}
|
|
473
|
+
]
|
|
474
|
+
}]
|
|
475
|
+
|
|
476
|
+
result = extract_purchase_data(insights_data)
|
|
477
|
+
|
|
478
|
+
assert result["purchase_count"] == 0
|
|
479
|
+
assert result["purchase_value"] == 0.0
|
|
480
|
+
|
|
481
|
+
def test_extract_purchase_data_empty_data(self):
|
|
482
|
+
"""Test extraction with empty data"""
|
|
483
|
+
insights_data = []
|
|
484
|
+
|
|
485
|
+
result = extract_purchase_data(insights_data)
|
|
486
|
+
|
|
487
|
+
assert result["purchase_count"] == 0
|
|
488
|
+
assert result["purchase_value"] == 0.0
|
|
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
|