Skip to content

Commit db8b9b8

Browse files
authored
fix(targeting): accept custom_locations/places/electoral_districts in estimate_audience_size preflight check (#147)
The preflight validator in estimate_audience_size only recognized countries/regions/cities/zips/geo_markets/country_groups as valid geo_locations keys. A targeting spec using radius-based custom_locations (or the less common places/electoral_districts keys) was rejected with Missing target audience location before ever reaching Meta's reachestimate endpoint, even though create_adset/update_adset already accept and persist custom_locations correctly. Adds custom_locations, places, and electoral_districts to the recognized key list and updates the corresponding error messages. Adds regression tests covering each newly-accepted key plus a still-rejected case with no location at all.
1 parent 225f46f commit db8b9b8

2 files changed

Lines changed: 153 additions & 3 deletions

File tree

‎meta_ads_mcp/core/targeting.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,10 @@ def _has_location_or_custom_audience(t: Dict[str, Any]) -> bool:
164164
"cities",
165165
"zips",
166166
"geo_markets",
167-
"country_groups"
167+
"country_groups",
168+
"custom_locations",
169+
"places",
170+
"electoral_districts",
168171
]:
169172
val = geo.get(key)
170173
if isinstance(val, list) and len(val) > 0:
@@ -187,7 +190,7 @@ def _has_location_or_custom_audience(t: Dict[str, Any]) -> bool:
187190
return json.dumps({
188191
"error": "Missing target audience location",
189192
"details": "Select at least one location in targeting.geo_locations or include a custom audience.",
190-
"action_required": "Add geo_locations with countries/regions/cities/zips or include custom_audiences.",
193+
"action_required": "Add geo_locations with countries/regions/cities/zips/geo_markets/custom_locations/places/electoral_districts, or include custom_audiences.",
191194
"example": {
192195
"geo_locations": {"countries": ["US"]},
193196
"age_min": 25,
@@ -224,7 +227,7 @@ def _has_location_or_custom_audience(t: Dict[str, Any]) -> bool:
224227
"error": "Missing target audience location",
225228
"details": raw_err.get("error_user_msg") or "Select at least one location, or choose a custom audience.",
226229
"endpoint_used": f"{account_id}/reachestimate",
227-
"action_required": "Add geo_locations with at least one of countries/regions/cities/zips or include custom_audiences.",
230+
"action_required": "Add geo_locations with at least one of countries/regions/cities/zips/geo_markets/custom_locations/places/electoral_districts or include custom_audiences.",
228231
"blame_field_specs": raw_err.get("error_data", {}).get("blame_field_specs") if isinstance(raw_err.get("error_data"), dict) else None
229232
}, indent=2)
230233
except Exception:

‎tests/test_estimate_audience_size.py‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,153 @@ async def test_error_no_targeting_for_comprehensive(self):
275275
assert "targeting specification is required" in nested_data["error"]
276276
assert "example" in nested_data
277277

278+
@pytest.mark.asyncio
279+
async def test_missing_location_rejected_by_preflight(self):
280+
"""Targeting with no location keys and no custom audience should be rejected before
281+
ever reaching the reachestimate endpoint."""
282+
targeting_spec = {
283+
"age_min": 22,
284+
"age_max": 55
285+
}
286+
287+
with patch('meta_ads_mcp.core.targeting.make_api_request', new_callable=AsyncMock) as mock_api:
288+
result = await estimate_audience_size(
289+
access_token="test_token",
290+
account_id="act_123456789",
291+
targeting=targeting_spec
292+
)
293+
294+
# Preflight check should short-circuit before calling the Graph API
295+
mock_api.assert_not_called()
296+
297+
result_data = json.loads(result)
298+
assert "data" in result_data
299+
nested_data = json.loads(result_data["data"])
300+
assert nested_data["error"] == "Missing target audience location"
301+
assert "action_required" in nested_data
302+
303+
@pytest.mark.asyncio
304+
async def test_custom_locations_only_passes_preflight(self):
305+
"""Regression test: a targeting spec whose only location signal is
306+
geo_locations.custom_locations (radius targeting) must pass Pipeboard's preflight
307+
check and reach the reachestimate endpoint. Previously this was incorrectly
308+
rejected with "Missing target audience location" (custom_locations was missing
309+
from the recognized geo_locations keys)."""
310+
mock_response = {
311+
"data": [
312+
{
313+
"estimate_mau": 42000,
314+
"estimate_dau": [],
315+
"bid_estimates": {},
316+
"unsupported_targeting": []
317+
}
318+
]
319+
}
320+
321+
targeting_spec = {
322+
"age_min": 22,
323+
"age_max": 55,
324+
"geo_locations": {
325+
"custom_locations": [
326+
{"latitude": 40.759, "longitude": -73.9895, "radius": 1.5, "distance_unit": "mile"}
327+
],
328+
"location_types": ["home"]
329+
},
330+
"targeting_automation": {"advantage_audience": 0}
331+
}
332+
333+
with patch('meta_ads_mcp.core.targeting.make_api_request', new_callable=AsyncMock) as mock_api:
334+
mock_api.return_value = mock_response
335+
336+
result = await estimate_audience_size(
337+
access_token="test_token",
338+
account_id="act_123456789",
339+
targeting=targeting_spec
340+
)
341+
342+
mock_api.assert_called_once_with(
343+
"act_123456789/reachestimate",
344+
"test_token",
345+
{"targeting_spec": targeting_spec},
346+
method="GET"
347+
)
348+
349+
result_data = json.loads(result)
350+
assert result_data["success"] is True
351+
assert result_data["estimated_audience_size"] == 42000
352+
353+
@pytest.mark.asyncio
354+
async def test_places_only_passes_preflight(self):
355+
"""A targeting spec using only geo_locations.places (e.g. targeting around a named
356+
place/landmark) should also pass the preflight location check."""
357+
mock_response = {
358+
"data": [
359+
{
360+
"estimate_mau": 15000,
361+
"estimate_dau": [],
362+
"bid_estimates": {},
363+
"unsupported_targeting": []
364+
}
365+
]
366+
}
367+
368+
targeting_spec = {
369+
"age_min": 18,
370+
"age_max": 65,
371+
"geo_locations": {
372+
"places": [{"key": 129672430416115, "name": "SFO", "radius": 10, "distance_unit": "mile"}]
373+
}
374+
}
375+
376+
with patch('meta_ads_mcp.core.targeting.make_api_request', new_callable=AsyncMock) as mock_api:
377+
mock_api.return_value = mock_response
378+
379+
result = await estimate_audience_size(
380+
access_token="test_token",
381+
account_id="act_123456789",
382+
targeting=targeting_spec
383+
)
384+
385+
mock_api.assert_called_once()
386+
result_data = json.loads(result)
387+
assert result_data["success"] is True
388+
389+
@pytest.mark.asyncio
390+
async def test_electoral_districts_only_passes_preflight(self):
391+
"""A targeting spec using only geo_locations.electoral_districts should also pass
392+
the preflight location check."""
393+
mock_response = {
394+
"data": [
395+
{
396+
"estimate_mau": 8000,
397+
"estimate_dau": [],
398+
"bid_estimates": {},
399+
"unsupported_targeting": []
400+
}
401+
]
402+
}
403+
404+
targeting_spec = {
405+
"age_min": 18,
406+
"age_max": 65,
407+
"geo_locations": {
408+
"electoral_districts": [{"key": "US:CA14"}]
409+
}
410+
}
411+
412+
with patch('meta_ads_mcp.core.targeting.make_api_request', new_callable=AsyncMock) as mock_api:
413+
mock_api.return_value = mock_response
414+
415+
result = await estimate_audience_size(
416+
access_token="test_token",
417+
account_id="act_123456789",
418+
targeting=targeting_spec
419+
)
420+
421+
mock_api.assert_called_once()
422+
result_data = json.loads(result)
423+
assert result_data["success"] is True
424+
278425
@pytest.mark.asyncio
279426
async def test_error_no_parameters(self):
280427
"""Test error when no parameters provided"""

0 commit comments

Comments
 (0)