diff --git a/src/adclaw/meta/objects.py b/src/adclaw/meta/objects.py index c50973c..7808839 100644 --- a/src/adclaw/meta/objects.py +++ b/src/adclaw/meta/objects.py @@ -159,15 +159,33 @@ def build_ad_set_payload(spec: CampaignSpec, campaign_id: str) -> dict[str, Any] if genders: targeting["genders"] = list(genders) - # Graph requires targeting on every ad set. Catching it here names the - # missing field; letting it through produces an opaque Meta rejection - # part-way into building the object chain. + # Graph requires targeting on every ad set. Checked before + # targeting_automation is added below, which is unconditional and + # would otherwise make `targeting` always non-empty and this guard + # dead code. Catching it here names the missing field; letting it + # through produces an opaque Meta rejection part-way into building the + # object chain. if not targeting: raise ValueError( "CampaignSpec.targeting is empty, so the ad set has no audience. " "Set at least one of countries, age_min, age_max or genders.", ) + # Graph requires this on every ad set — code 100 / subcode 1870227, + # which is what stopped the connection test once optimisation_goal was + # fixed. Advantage+ Audience lets Meta expand delivery beyond the + # countries/age/gender actually specified. Defaulting to 0 (off) is the + # same call already made for bid_strategy: an ad set matches the + # audience it was told to, not whatever Meta additionally guesses. + # advanced.advantage_audience (0 or 1) overrides this per campaign. + advantage_audience = spec.advanced.get("advantage_audience", 0) + if advantage_audience not in (0, 1): + raise ValueError( + f"advanced.advantage_audience must be 0 or 1, got " + f"{advantage_audience!r}.", + ) + targeting["targeting_automation"] = {"advantage_audience": advantage_audience} + optimization_goal = spec.advanced.get("optimization_goal") or ( DEFAULT_OPTIMIZATION_GOAL_BY_OBJECTIVE.get( spec.objective or "", DEFAULT_OPTIMIZATION_GOAL, diff --git a/tests/test_meta_writes.py b/tests/test_meta_writes.py index 03b735f..f04b6b9 100644 --- a/tests/test_meta_writes.py +++ b/tests/test_meta_writes.py @@ -448,6 +448,7 @@ def test_build_ad_set_payload_maps_targeting_and_budget(): "age_min": 25, "age_max": 45, "geo_locations": {"countries": ["US", "CA"]}, + "targeting_automation": {"advantage_audience": 0}, } assert payload["optimization_goal"] == "LEAD_GENERATION" assert payload["billing_event"] == "IMPRESSIONS" @@ -485,7 +486,10 @@ def test_build_ad_set_payload_requires_an_audience(): def test_build_ad_set_payload_accepts_any_single_targeting_field(): payload = build_ad_set_payload(_spec(targeting={"countries": ["IN"]}), "c1") - assert payload["targeting"] == {"geo_locations": {"countries": ["IN"]}} + assert payload["targeting"] == { + "geo_locations": {"countries": ["IN"]}, + "targeting_automation": {"advantage_audience": 0}, + } def test_build_ad_set_payload_accepts_allowlisted_optimization_goal(): @@ -705,6 +709,54 @@ def test_an_unknown_objective_falls_back_to_the_widest_goal(): assert payload["optimization_goal"] == "LINK_CLICKS" +# --------------------------------------------------------------------------- +# Advantage+ Audience: the field Graph requires on every ad set's targeting +# --------------------------------------------------------------------------- + + +def test_targeting_automation_defaults_to_advantage_audience_off(): + """Graph rejects an ad set whose targeting spec omits this — code 100 / + subcode 1870227, the failure that stopped the live connection test + once optimisation_goal was fixed. + + Off by default for the same reason as bid_strategy: an ad set matches + the audience actually specified, not whatever Meta additionally + expands it to. + """ + payload = build_ad_set_payload(_spec(), campaign_id="c1") + + assert payload["targeting"]["targeting_automation"] == { + "advantage_audience": 0, + } + + +def test_advantage_audience_can_be_turned_on(): + spec = _spec(advanced={"advantage_audience": 1}) + + payload = build_ad_set_payload(spec, campaign_id="c1") + + assert payload["targeting"]["targeting_automation"] == { + "advantage_audience": 1, + } + + +def test_advantage_audience_rejects_anything_but_0_or_1(): + spec = _spec(advanced={"advantage_audience": 2}) + + with pytest.raises(ValueError, match="advantage_audience"): + build_ad_set_payload(spec, campaign_id="c1") + + +def test_an_empty_audience_is_still_refused_even_though_targeting_automation_is_always_set(): + """targeting_automation is added unconditionally, so `targeting` is + never an empty dict any more. The no-audience guard has to run before + that, or this refusal silently stops firing.""" + spec = _spec(targeting={}) + + with pytest.raises(ValueError, match="no audience"): + build_ad_set_payload(spec, campaign_id="c1") + + def test_an_explicit_optimisation_goal_still_wins(): spec = _spec( objective="OUTCOME_TRAFFIC",