meta-ads-mcp 0.7.9__tar.gz → 0.7.10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/PKG-INFO +1 -1
  2. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/__init__.py +1 -1
  3. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/ads.py +201 -16
  4. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/pyproject.toml +1 -1
  5. meta_ads_mcp-0.7.10/tests/test_page_discovery.py +377 -0
  6. meta_ads_mcp-0.7.10/tests/test_page_discovery_integration.py +245 -0
  7. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/.github/workflows/publish.yml +0 -0
  8. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/.github/workflows/test.yml +0 -0
  9. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/.gitignore +0 -0
  10. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/CUSTOM_META_APP.md +0 -0
  11. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/Dockerfile +0 -0
  12. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/LICENSE +0 -0
  13. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/LOCAL_INSTALLATION.md +0 -0
  14. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/META_API_NOTES.md +0 -0
  15. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/README.md +0 -0
  16. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/RELEASE.md +0 -0
  17. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/STREAMABLE_HTTP_SETUP.md +0 -0
  18. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/examples/README.md +0 -0
  19. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/examples/example_http_client.py +0 -0
  20. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/future_improvements.md +0 -0
  21. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/images/meta-ads-example.png +0 -0
  22. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_auth.sh +0 -0
  23. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/__main__.py +0 -0
  24. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/__init__.py +0 -0
  25. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/accounts.py +0 -0
  26. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/ads_library.py +0 -0
  27. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/adsets.py +0 -0
  28. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/api.py +0 -0
  29. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/auth.py +0 -0
  30. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/authentication.py +0 -0
  31. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/budget_schedules.py +0 -0
  32. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/callback_server.py +0 -0
  33. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/campaigns.py +0 -0
  34. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/duplication.py +0 -0
  35. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/http_auth_integration.py +0 -0
  36. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/insights.py +0 -0
  37. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/openai_deep_research.py +0 -0
  38. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/pipeboard_auth.py +0 -0
  39. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/reports.py +0 -0
  40. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/resources.py +0 -0
  41. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/server.py +0 -0
  42. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/targeting.py +0 -0
  43. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/meta_ads_mcp/core/utils.py +0 -0
  44. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/requirements.txt +0 -0
  45. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/setup.py +0 -0
  46. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/smithery.yaml +0 -0
  47. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/README.md +0 -0
  48. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/README_REGRESSION_TESTS.md +0 -0
  49. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/__init__.py +0 -0
  50. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/conftest.py +0 -0
  51. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/test_account_search.py +0 -0
  52. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/test_budget_update.py +0 -0
  53. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/test_budget_update_e2e.py +0 -0
  54. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/test_dsa_beneficiary.py +0 -0
  55. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/test_dsa_integration.py +0 -0
  56. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/test_duplication.py +0 -0
  57. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/test_duplication_regression.py +0 -0
  58. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/test_get_ad_creatives_fix.py +0 -0
  59. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/test_get_ad_image_quality_improvements.py +0 -0
  60. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/test_get_ad_image_regression.py +0 -0
  61. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/test_http_transport.py +0 -0
  62. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/test_insights_actions_and_values.py +0 -0
  63. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/test_integration_openai_mcp.py +0 -0
  64. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/test_openai.py +0 -0
  65. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/test_openai_mcp_deep_research.py +0 -0
  66. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/tests/test_targeting.py +0 -0
  67. {meta_ads_mcp-0.7.9 → meta_ads_mcp-0.7.10}/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.9
3
+ Version: 0.7.10
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
@@ -7,7 +7,7 @@ with the Claude LLM.
7
7
 
8
8
  from meta_ads_mcp.core.server import main
9
9
 
10
- __version__ = "0.7.9"
10
+ __version__ = "0.7.10"
11
11
 
12
12
  __all__ = [
13
13
  'get_ad_accounts',
@@ -670,31 +670,31 @@ async def create_ad_creative(
670
670
  if not account_id.startswith("act_"):
671
671
  account_id = f"act_{account_id}"
672
672
 
673
- # If no page ID is provided, try to find a page associated with the account
673
+ # Enhanced page discovery: If no page ID is provided, use robust discovery methods
674
674
  if not page_id:
675
675
  try:
676
- # Query to get pages associated with the account
677
- pages_endpoint = f"{account_id}/assigned_pages"
678
- pages_params = {
679
- "fields": "id,name",
680
- "limit": 1
681
- }
682
-
683
- pages_data = await make_api_request(pages_endpoint, access_token, pages_params)
676
+ # Use the comprehensive page discovery logic from get_account_pages
677
+ page_discovery_result = await _discover_pages_for_account(account_id, access_token)
684
678
 
685
- if "data" in pages_data and pages_data["data"]:
686
- page_id = pages_data["data"][0]["id"]
687
- print(f"Using page ID: {page_id} ({pages_data['data'][0].get('name', 'Unknown')})")
679
+ if page_discovery_result.get("success"):
680
+ page_id = page_discovery_result["page_id"]
681
+ page_name = page_discovery_result.get("page_name", "Unknown")
682
+ print(f"Auto-discovered page ID: {page_id} ({page_name})")
688
683
  else:
689
684
  return json.dumps({
690
- "error": "No page ID provided and no pages found for this account",
691
- "suggestion": "Please provide a page_id parameter"
685
+ "error": "No page ID provided and no suitable pages found for this account",
686
+ "details": page_discovery_result.get("message", "Page discovery failed"),
687
+ "suggestions": [
688
+ "Use get_account_pages to see available pages",
689
+ "Use search_pages_by_name to find specific pages",
690
+ "Provide a page_id parameter manually"
691
+ ]
692
692
  }, indent=2)
693
693
  except Exception as e:
694
694
  return json.dumps({
695
- "error": "Error finding page for account",
695
+ "error": "Error during page discovery",
696
696
  "details": str(e),
697
- "suggestion": "Please provide a page_id parameter"
697
+ "suggestion": "Please provide a page_id parameter or use get_account_pages to find available pages"
698
698
  }, indent=2)
699
699
 
700
700
  # Prepare the creative data
@@ -759,6 +759,191 @@ async def create_ad_creative(
759
759
  }, indent=2)
760
760
 
761
761
 
762
+ async def _discover_pages_for_account(account_id: str, access_token: str) -> dict:
763
+ """
764
+ Internal function to discover pages for an account using multiple approaches.
765
+ Returns the best available page ID for ad creation.
766
+ """
767
+ try:
768
+ # Approach 1: Extract page IDs from tracking_specs in ads (most reliable)
769
+ endpoint = f"{account_id}/ads"
770
+ params = {
771
+ "fields": "id,name,adset_id,campaign_id,status,creative,created_time,updated_time,bid_amount,conversion_domain,tracking_specs",
772
+ "limit": 100
773
+ }
774
+
775
+ tracking_ads_data = await make_api_request(endpoint, access_token, params)
776
+
777
+ tracking_page_ids = set()
778
+ if "data" in tracking_ads_data:
779
+ for ad in tracking_ads_data.get("data", []):
780
+ tracking_specs = ad.get("tracking_specs", [])
781
+ if isinstance(tracking_specs, list):
782
+ for spec in tracking_specs:
783
+ if isinstance(spec, dict) and "page" in spec:
784
+ page_list = spec["page"]
785
+ if isinstance(page_list, list):
786
+ for page_id in page_list:
787
+ if isinstance(page_id, (str, int)) and str(page_id).isdigit():
788
+ tracking_page_ids.add(str(page_id))
789
+
790
+ if tracking_page_ids:
791
+ # Get details for the first page found
792
+ page_id = list(tracking_page_ids)[0]
793
+ page_endpoint = f"{page_id}"
794
+ page_params = {
795
+ "fields": "id,name,username,category,fan_count,link,verification_status,picture"
796
+ }
797
+
798
+ page_data = await make_api_request(page_endpoint, access_token, page_params)
799
+ if "id" in page_data:
800
+ return {
801
+ "success": True,
802
+ "page_id": page_id,
803
+ "page_name": page_data.get("name", "Unknown"),
804
+ "source": "tracking_specs",
805
+ "note": "Page ID extracted from existing ads - most reliable for ad creation"
806
+ }
807
+
808
+ # Approach 2: Try client_pages endpoint
809
+ endpoint = f"{account_id}/client_pages"
810
+ params = {
811
+ "fields": "id,name,username,category,fan_count,link,verification_status,picture"
812
+ }
813
+
814
+ client_pages_data = await make_api_request(endpoint, access_token, params)
815
+
816
+ if "data" in client_pages_data and client_pages_data["data"]:
817
+ page = client_pages_data["data"][0]
818
+ return {
819
+ "success": True,
820
+ "page_id": page["id"],
821
+ "page_name": page.get("name", "Unknown"),
822
+ "source": "client_pages"
823
+ }
824
+
825
+ # Approach 3: Try assigned_pages endpoint
826
+ pages_endpoint = f"{account_id}/assigned_pages"
827
+ pages_params = {
828
+ "fields": "id,name",
829
+ "limit": 1
830
+ }
831
+
832
+ pages_data = await make_api_request(pages_endpoint, access_token, pages_params)
833
+
834
+ if "data" in pages_data and pages_data["data"]:
835
+ page = pages_data["data"][0]
836
+ return {
837
+ "success": True,
838
+ "page_id": page["id"],
839
+ "page_name": page.get("name", "Unknown"),
840
+ "source": "assigned_pages"
841
+ }
842
+
843
+ # If all approaches failed
844
+ return {
845
+ "success": False,
846
+ "message": "No suitable pages found for this account",
847
+ "note": "Try using get_account_pages to see all available pages or provide page_id manually"
848
+ }
849
+
850
+ except Exception as e:
851
+ return {
852
+ "success": False,
853
+ "message": f"Error during page discovery: {str(e)}"
854
+ }
855
+
856
+
857
+ async def _search_pages_by_name_core(access_token: str, account_id: str, search_term: str = None) -> str:
858
+ """
859
+ Core logic for searching pages by name.
860
+
861
+ Args:
862
+ access_token: Meta API access token
863
+ account_id: Meta Ads account ID (format: act_XXXXXXXXX)
864
+ search_term: Search term to find pages by name (optional - returns all pages if not provided)
865
+
866
+ Returns:
867
+ JSON string with search results
868
+ """
869
+ # Ensure account_id has the 'act_' prefix
870
+ if not account_id.startswith("act_"):
871
+ account_id = f"act_{account_id}"
872
+
873
+ try:
874
+ # Use the internal discovery function directly
875
+ page_discovery_result = await _discover_pages_for_account(account_id, access_token)
876
+
877
+ if not page_discovery_result.get("success"):
878
+ return json.dumps({
879
+ "data": [],
880
+ "message": "No pages found for this account",
881
+ "details": page_discovery_result.get("message", "Page discovery failed")
882
+ }, indent=2)
883
+
884
+ # Create a single page result
885
+ page_data = {
886
+ "id": page_discovery_result["page_id"],
887
+ "name": page_discovery_result.get("page_name", "Unknown"),
888
+ "source": page_discovery_result.get("source", "unknown")
889
+ }
890
+
891
+ all_pages_data = {"data": [page_data]}
892
+
893
+ # Filter pages by search term if provided
894
+ if search_term:
895
+ search_term_lower = search_term.lower()
896
+ filtered_pages = []
897
+
898
+ for page in all_pages_data["data"]:
899
+ page_name = page.get("name", "").lower()
900
+ if search_term_lower in page_name:
901
+ filtered_pages.append(page)
902
+
903
+ return json.dumps({
904
+ "data": filtered_pages,
905
+ "search_term": search_term,
906
+ "total_found": len(filtered_pages),
907
+ "total_available": len(all_pages_data["data"])
908
+ }, indent=2)
909
+ else:
910
+ # Return all pages if no search term provided
911
+ return json.dumps({
912
+ "data": all_pages_data["data"],
913
+ "total_available": len(all_pages_data["data"]),
914
+ "note": "Use search_term parameter to filter pages by name"
915
+ }, indent=2)
916
+
917
+ except Exception as e:
918
+ return json.dumps({
919
+ "error": "Failed to search pages by name",
920
+ "details": str(e)
921
+ }, indent=2)
922
+
923
+
924
+ @mcp_server.tool()
925
+ @meta_api_tool
926
+ async def search_pages_by_name(access_token: str = None, account_id: str = None, search_term: str = None) -> str:
927
+ """
928
+ Search for pages by name within an account.
929
+
930
+ Args:
931
+ access_token: Meta API access token (optional - will use cached token if not provided)
932
+ account_id: Meta Ads account ID (format: act_XXXXXXXXX)
933
+ search_term: Search term to find pages by name (optional - returns all pages if not provided)
934
+
935
+ Returns:
936
+ JSON response with matching pages
937
+ """
938
+ # Check required parameters
939
+ if not account_id:
940
+ return json.dumps({"error": "No account ID provided"}, indent=2)
941
+
942
+ # Call the core function
943
+ result = await _search_pages_by_name_core(access_token, account_id, search_term)
944
+ return result
945
+
946
+
762
947
  @mcp_server.tool()
763
948
  @meta_api_tool
764
949
  async def get_account_pages(access_token: str = None, account_id: str = None) -> str:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "meta-ads-mcp"
7
- version = "0.7.9"
7
+ version = "0.7.10"
8
8
  description = "Model Context Protocol (MCP) plugin for interacting with Meta Ads API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,377 @@
1
+ """
2
+ Test page discovery functionality for Meta Ads MCP.
3
+ """
4
+
5
+ import pytest
6
+ import json
7
+ from unittest.mock import AsyncMock, patch
8
+ from meta_ads_mcp.core.ads import _discover_pages_for_account, search_pages_by_name, _search_pages_by_name_core
9
+
10
+
11
+ class TestPageDiscovery:
12
+ """Test page discovery functionality."""
13
+
14
+ @pytest.mark.asyncio
15
+ async def test_discover_pages_from_tracking_specs(self):
16
+ """Test page discovery from tracking specs (most reliable method)."""
17
+ mock_ads_data = {
18
+ "data": [
19
+ {
20
+ "id": "123456789",
21
+ "tracking_specs": [
22
+ {
23
+ "page": ["987654321", "111222333"]
24
+ }
25
+ ]
26
+ }
27
+ ]
28
+ }
29
+
30
+ mock_page_data = {
31
+ "id": "987654321",
32
+ "name": "Test Page",
33
+ "username": "testpage",
34
+ "category": "Test Category"
35
+ }
36
+
37
+ with patch('meta_ads_mcp.core.ads.make_api_request') as mock_api:
38
+ # Mock the ads endpoint call
39
+ mock_api.side_effect = [
40
+ mock_ads_data, # First call for ads
41
+ mock_page_data # Second call for page details
42
+ ]
43
+
44
+ result = await _discover_pages_for_account("act_123456789", "test_token")
45
+
46
+ assert result["success"] is True
47
+ # Check that we got one of the expected page IDs (set order is not guaranteed)
48
+ assert result["page_id"] in ["987654321", "111222333"]
49
+ assert result["page_name"] == "Test Page"
50
+ assert result["source"] == "tracking_specs"
51
+
52
+ @pytest.mark.asyncio
53
+ async def test_discover_pages_from_client_pages(self):
54
+ """Test page discovery from client_pages endpoint."""
55
+ mock_client_pages_data = {
56
+ "data": [
57
+ {
58
+ "id": "555666777",
59
+ "name": "Client Page",
60
+ "username": "clientpage"
61
+ }
62
+ ]
63
+ }
64
+
65
+ # Mock empty ads data, then client_pages data
66
+ with patch('meta_ads_mcp.core.ads.make_api_request') as mock_api:
67
+ mock_api.side_effect = [
68
+ {"data": []}, # No ads found
69
+ mock_client_pages_data # Client pages found
70
+ ]
71
+
72
+ result = await _discover_pages_for_account("act_123456789", "test_token")
73
+
74
+ assert result["success"] is True
75
+ assert result["page_id"] == "555666777"
76
+ assert result["page_name"] == "Client Page"
77
+ assert result["source"] == "client_pages"
78
+
79
+ @pytest.mark.asyncio
80
+ async def test_discover_pages_from_assigned_pages(self):
81
+ """Test page discovery from assigned_pages endpoint."""
82
+ mock_assigned_pages_data = {
83
+ "data": [
84
+ {
85
+ "id": "888999000",
86
+ "name": "Assigned Page"
87
+ }
88
+ ]
89
+ }
90
+
91
+ # Mock empty responses for first two methods, then assigned_pages
92
+ with patch('meta_ads_mcp.core.ads.make_api_request') as mock_api:
93
+ mock_api.side_effect = [
94
+ {"data": []}, # No ads found
95
+ {"data": []}, # No client pages found
96
+ mock_assigned_pages_data # Assigned pages found
97
+ ]
98
+
99
+ result = await _discover_pages_for_account("act_123456789", "test_token")
100
+
101
+ assert result["success"] is True
102
+ assert result["page_id"] == "888999000"
103
+ assert result["page_name"] == "Assigned Page"
104
+ assert result["source"] == "assigned_pages"
105
+
106
+ @pytest.mark.asyncio
107
+ async def test_discover_pages_no_pages_found(self):
108
+ """Test page discovery when no pages are found."""
109
+ with patch('meta_ads_mcp.core.ads.make_api_request') as mock_api:
110
+ mock_api.side_effect = [
111
+ {"data": []}, # No ads found
112
+ {"data": []}, # No client pages found
113
+ {"data": []} # No assigned pages found
114
+ ]
115
+
116
+ result = await _discover_pages_for_account("act_123456789", "test_token")
117
+
118
+ assert result["success"] is False
119
+ assert "No suitable pages found" in result["message"]
120
+
121
+ @pytest.mark.asyncio
122
+ async def test_discover_pages_with_invalid_page_ids(self):
123
+ """Test page discovery with invalid page IDs in tracking_specs."""
124
+ mock_ads_data = {
125
+ "data": [
126
+ {
127
+ "id": "123456789",
128
+ "tracking_specs": [
129
+ {
130
+ "page": ["invalid_id", "not_numeric", "123abc"]
131
+ }
132
+ ]
133
+ }
134
+ ]
135
+ }
136
+
137
+ with patch('meta_ads_mcp.core.ads.make_api_request') as mock_api:
138
+ mock_api.side_effect = [
139
+ mock_ads_data, # Ads with invalid page IDs
140
+ {"data": []}, # No client pages
141
+ {"data": []} # No assigned pages
142
+ ]
143
+
144
+ result = await _discover_pages_for_account("act_123456789", "test_token")
145
+
146
+ assert result["success"] is False
147
+ assert "No suitable pages found" in result["message"]
148
+
149
+ @pytest.mark.asyncio
150
+ async def test_discover_pages_api_error_handling(self):
151
+ """Test page discovery with API errors."""
152
+ with patch('meta_ads_mcp.core.ads.make_api_request') as mock_api:
153
+ mock_api.side_effect = Exception("API Error")
154
+
155
+ result = await _discover_pages_for_account("act_123456789", "test_token")
156
+
157
+ assert result["success"] is False
158
+ assert "Error during page discovery" in result["message"]
159
+
160
+ @pytest.mark.asyncio
161
+ async def test_search_pages_by_name_logic(self):
162
+ """Test the core search logic without authentication interference."""
163
+ # Test the filtering logic directly
164
+ mock_pages_data = {
165
+ "data": [
166
+ {"id": "111", "name": "Test Page 1"},
167
+ {"id": "222", "name": "Another Test Page"},
168
+ {"id": "333", "name": "Different Page"}
169
+ ]
170
+ }
171
+
172
+ # Test filtering with search term
173
+ search_term_lower = "test"
174
+ filtered_pages = []
175
+
176
+ for page in mock_pages_data["data"]:
177
+ page_name = page.get("name", "").lower()
178
+ if search_term_lower in page_name:
179
+ filtered_pages.append(page)
180
+
181
+ assert len(filtered_pages) == 2
182
+ assert filtered_pages[0]["name"] == "Test Page 1"
183
+ assert filtered_pages[1]["name"] == "Another Test Page"
184
+
185
+ @pytest.mark.asyncio
186
+ async def test_search_pages_by_name_no_matches(self):
187
+ """Test search logic with no matching results."""
188
+ mock_pages_data = {
189
+ "data": [
190
+ {"id": "111", "name": "Test Page 1"},
191
+ {"id": "222", "name": "Another Test Page"}
192
+ ]
193
+ }
194
+
195
+ # Test filtering with non-matching search term
196
+ search_term_lower = "nonexistent"
197
+ filtered_pages = []
198
+
199
+ for page in mock_pages_data["data"]:
200
+ page_name = page.get("name", "").lower()
201
+ if search_term_lower in page_name:
202
+ filtered_pages.append(page)
203
+
204
+ assert len(filtered_pages) == 0
205
+
206
+ @pytest.mark.asyncio
207
+ async def test_search_pages_by_name_core_success(self):
208
+ """Test the core search function with successful page discovery."""
209
+ mock_discovery_result = {
210
+ "success": True,
211
+ "page_id": "123456789",
212
+ "page_name": "Test Page",
213
+ "source": "tracking_specs"
214
+ }
215
+
216
+ with patch('meta_ads_mcp.core.ads._discover_pages_for_account') as mock_discover:
217
+ mock_discover.return_value = mock_discovery_result
218
+
219
+ result = await _search_pages_by_name_core("test_token", "act_123456789", "test")
220
+ result_data = json.loads(result)
221
+
222
+ assert len(result_data["data"]) == 1
223
+ assert result_data["data"][0]["id"] == "123456789"
224
+ assert result_data["data"][0]["name"] == "Test Page"
225
+ assert result_data["search_term"] == "test"
226
+ assert result_data["total_found"] == 1
227
+ assert result_data["total_available"] == 1
228
+
229
+ @pytest.mark.asyncio
230
+ async def test_search_pages_by_name_core_no_pages(self):
231
+ """Test the core search function when no pages are found."""
232
+ mock_discovery_result = {
233
+ "success": False,
234
+ "message": "No pages found"
235
+ }
236
+
237
+ with patch('meta_ads_mcp.core.ads._discover_pages_for_account') as mock_discover:
238
+ mock_discover.return_value = mock_discovery_result
239
+
240
+ result = await _search_pages_by_name_core("test_token", "act_123456789", "test")
241
+ result_data = json.loads(result)
242
+
243
+ assert len(result_data["data"]) == 0
244
+ assert "No pages found" in result_data["message"]
245
+
246
+ @pytest.mark.asyncio
247
+ async def test_search_pages_by_name_core_no_search_term(self):
248
+ """Test the core search function without search term."""
249
+ mock_discovery_result = {
250
+ "success": True,
251
+ "page_id": "123456789",
252
+ "page_name": "Test Page",
253
+ "source": "tracking_specs"
254
+ }
255
+
256
+ with patch('meta_ads_mcp.core.ads._discover_pages_for_account') as mock_discover:
257
+ mock_discover.return_value = mock_discovery_result
258
+
259
+ result = await _search_pages_by_name_core("test_token", "act_123456789")
260
+ result_data = json.loads(result)
261
+
262
+ assert len(result_data["data"]) == 1
263
+ assert result_data["total_available"] == 1
264
+ assert "note" in result_data
265
+
266
+ @pytest.mark.asyncio
267
+ async def test_search_pages_by_name_core_exception_handling(self):
268
+ """Test the core search function with exception handling."""
269
+ with patch('meta_ads_mcp.core.ads._discover_pages_for_account') as mock_discover:
270
+ mock_discover.side_effect = Exception("Test exception")
271
+
272
+ result = await _search_pages_by_name_core("test_token", "act_123456789", "test")
273
+ result_data = json.loads(result)
274
+
275
+ assert "error" in result_data
276
+ assert "Failed to search pages by name" in result_data["error"]
277
+
278
+ @pytest.mark.asyncio
279
+ async def test_discover_pages_with_multiple_page_ids(self):
280
+ """Test page discovery with multiple page IDs in tracking_specs."""
281
+ mock_ads_data = {
282
+ "data": [
283
+ {
284
+ "id": "123456789",
285
+ "tracking_specs": [
286
+ {
287
+ "page": ["111222333", "444555666", "777888999"]
288
+ }
289
+ ]
290
+ }
291
+ ]
292
+ }
293
+
294
+ mock_page_data = {
295
+ "id": "111222333",
296
+ "name": "First Page",
297
+ "username": "firstpage"
298
+ }
299
+
300
+ with patch('meta_ads_mcp.core.ads.make_api_request') as mock_api:
301
+ mock_api.side_effect = [
302
+ mock_ads_data, # First call for ads
303
+ mock_page_data # Second call for page details
304
+ ]
305
+
306
+ result = await _discover_pages_for_account("act_123456789", "test_token")
307
+
308
+ assert result["success"] is True
309
+ # Should get the first page ID from the set
310
+ assert result["page_id"] in ["111222333", "444555666", "777888999"]
311
+ assert result["page_name"] == "First Page"
312
+
313
+ @pytest.mark.asyncio
314
+ async def test_discover_pages_with_mixed_valid_invalid_ids(self):
315
+ """Test page discovery with mixed valid and invalid page IDs."""
316
+ mock_ads_data = {
317
+ "data": [
318
+ {
319
+ "id": "123456789",
320
+ "tracking_specs": [
321
+ {
322
+ "page": ["invalid", "123456789", "not_numeric", "987654321"]
323
+ }
324
+ ]
325
+ }
326
+ ]
327
+ }
328
+
329
+ mock_page_data = {
330
+ "id": "123456789",
331
+ "name": "Valid Page",
332
+ "username": "validpage"
333
+ }
334
+
335
+ with patch('meta_ads_mcp.core.ads.make_api_request') as mock_api:
336
+ mock_api.side_effect = [
337
+ mock_ads_data, # First call for ads
338
+ mock_page_data # Second call for page details
339
+ ]
340
+
341
+ result = await _discover_pages_for_account("act_123456789", "test_token")
342
+
343
+ assert result["success"] is True
344
+ # Should get one of the valid numeric IDs
345
+ assert result["page_id"] in ["123456789", "987654321"]
346
+ assert result["page_name"] == "Valid Page"
347
+
348
+ @pytest.mark.asyncio
349
+ async def test_search_pages_by_name_case_insensitive(self):
350
+ """Test search function with case insensitive matching."""
351
+ mock_discovery_result = {
352
+ "success": True,
353
+ "page_id": "123456789",
354
+ "page_name": "Test Page",
355
+ "source": "tracking_specs"
356
+ }
357
+
358
+ with patch('meta_ads_mcp.core.ads._discover_pages_for_account') as mock_discover:
359
+ mock_discover.return_value = mock_discovery_result
360
+
361
+ # Test with uppercase search term
362
+ result = await _search_pages_by_name_core("test_token", "act_123456789", "TEST")
363
+ result_data = json.loads(result)
364
+
365
+ assert len(result_data["data"]) == 1
366
+ assert result_data["total_found"] == 1
367
+
368
+ # Test with lowercase search term
369
+ result = await _search_pages_by_name_core("test_token", "act_123456789", "test")
370
+ result_data = json.loads(result)
371
+
372
+ assert len(result_data["data"]) == 1
373
+ assert result_data["total_found"] == 1
374
+
375
+
376
+ if __name__ == "__main__":
377
+ pytest.main([__file__])
@@ -0,0 +1,245 @@
1
+ """
2
+ Integration tests for page discovery functionality.
3
+ Tests the complete workflow from page discovery to ad creative creation.
4
+ """
5
+
6
+ import pytest
7
+ import json
8
+ from unittest.mock import AsyncMock, patch
9
+ from meta_ads_mcp.core.ads import create_ad_creative, search_pages_by_name, get_account_pages
10
+
11
+
12
+ class TestPageDiscoveryIntegration:
13
+ """Integration tests for page discovery functionality."""
14
+
15
+ @pytest.mark.asyncio
16
+ async def test_end_to_end_page_discovery_in_create_ad_creative(self):
17
+ """Test that create_ad_creative automatically discovers pages when no page_id is provided."""
18
+ # Mock the page discovery to return a successful result
19
+ mock_discovery_result = {
20
+ "success": True,
21
+ "page_id": "123456789",
22
+ "page_name": "Test Page",
23
+ "source": "tracking_specs"
24
+ }
25
+
26
+ # Mock the API request for creating the creative (will fail due to invalid image, but that's expected)
27
+ mock_creative_response = {
28
+ "error": {
29
+ "message": "Invalid parameter",
30
+ "type": "OAuthException",
31
+ "code": 100,
32
+ "error_subcode": 2446386,
33
+ "error_user_title": "Image Not Found",
34
+ "error_user_msg": "The image you selected is not available."
35
+ }
36
+ }
37
+
38
+ with patch('meta_ads_mcp.core.ads._discover_pages_for_account') as mock_discover, \
39
+ patch('meta_ads_mcp.core.ads.make_api_request') as mock_api, \
40
+ patch('meta_ads_mcp.core.api.meta_api_tool') as mock_decorator:
41
+
42
+ # Make the decorator just return the function
43
+ mock_decorator.side_effect = lambda func: func
44
+
45
+ mock_discover.return_value = mock_discovery_result
46
+ mock_api.return_value = mock_creative_response
47
+
48
+ # Call create_ad_creative without providing page_id
49
+ result = await create_ad_creative(
50
+ account_id="act_123456789",
51
+ name="Test Creative",
52
+ image_hash="test_hash_123",
53
+ message="Test message",
54
+ headline="Test Headline",
55
+ description="Test description"
56
+ )
57
+
58
+ result_data = json.loads(result)
59
+
60
+ # Verify that the function attempted to create a creative (even though it failed due to invalid image)
61
+ assert "error" in result_data["data"]
62
+ assert "Image Not Found" in result_data["data"]["error"]["error_user_title"]
63
+
64
+ # Verify that page discovery was called
65
+ mock_discover.assert_called_once_with("act_123456789", None)
66
+
67
+ @pytest.mark.asyncio
68
+ async def test_search_pages_by_name_integration(self):
69
+ """Test the complete search_pages_by_name function with real-like data."""
70
+ # Mock the page discovery to return a successful result
71
+ mock_discovery_result = {
72
+ "success": True,
73
+ "page_id": "123456789",
74
+ "page_name": "Injury Payouts",
75
+ "source": "tracking_specs"
76
+ }
77
+
78
+ with patch('meta_ads_mcp.core.ads._discover_pages_for_account') as mock_discover, \
79
+ patch('meta_ads_mcp.core.api.meta_api_tool') as mock_decorator:
80
+
81
+ # Make the decorator just return the function
82
+ mock_decorator.side_effect = lambda func: func
83
+
84
+ mock_discover.return_value = mock_discovery_result
85
+
86
+ # Test searching for pages
87
+ result = await search_pages_by_name(
88
+ account_id="act_123456789",
89
+ search_term="Injury"
90
+ )
91
+
92
+ result_data = json.loads(result)
93
+
94
+ # Verify the search results
95
+ assert len(result_data["data"]) == 1
96
+ assert result_data["data"][0]["id"] == "123456789"
97
+ assert result_data["data"][0]["name"] == "Injury Payouts"
98
+ assert result_data["search_term"] == "Injury"
99
+ assert result_data["total_found"] == 1
100
+ assert result_data["total_available"] == 1
101
+
102
+ @pytest.mark.asyncio
103
+ async def test_create_ad_creative_with_manual_page_id(self):
104
+ """Test that create_ad_creative works with manually provided page_id."""
105
+ # Mock the API request for creating the creative
106
+ mock_creative_response = {
107
+ "error": {
108
+ "message": "Invalid parameter",
109
+ "type": "OAuthException",
110
+ "code": 100,
111
+ "error_subcode": 2446386,
112
+ "error_user_title": "Image Not Found",
113
+ "error_user_msg": "The image you selected is not available."
114
+ }
115
+ }
116
+
117
+ with patch('meta_ads_mcp.core.ads.make_api_request') as mock_api, \
118
+ patch('meta_ads_mcp.core.api.meta_api_tool') as mock_decorator:
119
+
120
+ # Make the decorator just return the function
121
+ mock_decorator.side_effect = lambda func: func
122
+
123
+ mock_api.return_value = mock_creative_response
124
+
125
+ # Call create_ad_creative with a manual page_id
126
+ result = await create_ad_creative(
127
+ account_id="act_123456789",
128
+ name="Test Creative",
129
+ image_hash="test_hash_123",
130
+ page_id="123456789", # Manual page ID
131
+ message="Test message",
132
+ headline="Test Headline",
133
+ description="Test description"
134
+ )
135
+
136
+ result_data = json.loads(result)
137
+
138
+ # Verify that the function attempted to create a creative
139
+ assert "error" in result_data["data"]
140
+ assert "Image Not Found" in result_data["data"]["error"]["error_user_title"]
141
+
142
+ # Verify that make_api_request was called for creating the creative
143
+ mock_api.assert_called_once()
144
+
145
+ @pytest.mark.asyncio
146
+ async def test_create_ad_creative_no_pages_found(self):
147
+ """Test create_ad_creative when no pages are found."""
148
+ # Mock the page discovery to return no pages
149
+ mock_discovery_result = {
150
+ "success": False,
151
+ "message": "No suitable pages found for this account"
152
+ }
153
+
154
+ with patch('meta_ads_mcp.core.ads._discover_pages_for_account') as mock_discover, \
155
+ patch('meta_ads_mcp.core.api.meta_api_tool') as mock_decorator:
156
+
157
+ # Make the decorator just return the function
158
+ mock_decorator.side_effect = lambda func: func
159
+
160
+ mock_discover.return_value = mock_discovery_result
161
+
162
+ # Call create_ad_creative without providing page_id
163
+ result = await create_ad_creative(
164
+ account_id="act_123456789",
165
+ name="Test Creative",
166
+ image_hash="test_hash_123",
167
+ message="Test message"
168
+ )
169
+
170
+ result_data = json.loads(result)
171
+
172
+ # Verify that the function returned an error about no pages found
173
+ assert "error" in result_data["data"]
174
+ assert "No page ID provided and no suitable pages found" in result_data["data"]["error"]
175
+ assert "suggestions" in result_data["data"]["error"]
176
+
177
+ @pytest.mark.asyncio
178
+ async def test_search_pages_by_name_no_search_term(self):
179
+ """Test search_pages_by_name without a search term (should return all pages)."""
180
+ # Mock the page discovery to return a successful result
181
+ mock_discovery_result = {
182
+ "success": True,
183
+ "page_id": "123456789",
184
+ "page_name": "Test Page",
185
+ "source": "tracking_specs"
186
+ }
187
+
188
+ with patch('meta_ads_mcp.core.ads._discover_pages_for_account') as mock_discover, \
189
+ patch('meta_ads_mcp.core.api.meta_api_tool') as mock_decorator:
190
+
191
+ # Make the decorator just return the function
192
+ mock_decorator.side_effect = lambda func: func
193
+
194
+ mock_discover.return_value = mock_discovery_result
195
+
196
+ # Test searching without a search term
197
+ result = await search_pages_by_name(
198
+ account_id="act_123456789"
199
+ )
200
+
201
+ result_data = json.loads(result)
202
+
203
+ # Verify the results
204
+ assert len(result_data["data"]) == 1
205
+ assert result_data["data"][0]["id"] == "123456789"
206
+ assert result_data["data"][0]["name"] == "Test Page"
207
+ assert result_data["total_available"] == 1
208
+ assert "note" in result_data
209
+
210
+ @pytest.mark.asyncio
211
+ async def test_search_pages_by_name_no_matches(self):
212
+ """Test search_pages_by_name when no pages match the search term."""
213
+ # Mock the page discovery to return a successful result
214
+ mock_discovery_result = {
215
+ "success": True,
216
+ "page_id": "123456789",
217
+ "page_name": "Test Page",
218
+ "source": "tracking_specs"
219
+ }
220
+
221
+ with patch('meta_ads_mcp.core.ads._discover_pages_for_account') as mock_discover, \
222
+ patch('meta_ads_mcp.core.api.meta_api_tool') as mock_decorator:
223
+
224
+ # Make the decorator just return the function
225
+ mock_decorator.side_effect = lambda func: func
226
+
227
+ mock_discover.return_value = mock_discovery_result
228
+
229
+ # Test searching for a term that doesn't match
230
+ result = await search_pages_by_name(
231
+ account_id="act_123456789",
232
+ search_term="Nonexistent"
233
+ )
234
+
235
+ result_data = json.loads(result)
236
+
237
+ # Verify that no pages were found
238
+ assert len(result_data["data"]) == 0
239
+ assert result_data["search_term"] == "Nonexistent"
240
+ assert result_data["total_found"] == 0
241
+ assert result_data["total_available"] == 1
242
+
243
+
244
+ if __name__ == "__main__":
245
+ pytest.main([__file__])
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes