fix(meta): pick an optimisation goal the campaign objective allows

Third failure in the same chain, one object further each time: "Performance
goal isn't available: You can't use the selected performance goal with your
campaign objective" (code 100, subcode 2490408).

DEFAULT_OPTIMIZATION_GOAL was a single global constant, LEAD_GENERATION,
applied whatever the objective was. That is correct for OUTCOME_LEADS and
wrong for every other objective Meta offers — including OUTCOME_TRAFFIC,
which is what the connection test builds.

Defaults now come from a per-objective table, and an unknown objective
falls back to LINK_CLICKS: valid for the widest range of objectives, where
a lead goal is valid for exactly one. An explicit advanced.optimization_goal
still wins, and is still checked against the allowlist.

Deliberately not validating goal-against-objective beyond the defaults.
Meta's full compatibility matrix is not something this code can assert
confidently, and a wrong matrix would reject setups that actually work —
worse than the failure it would prevent, which sync already surfaces
legibly now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AFFAANh
2026-08-07 16:00:22 +05:30
co-authored by Claude Opus 5
parent a68921d916
commit 6f7aa36500
2 changed files with 91 additions and 3 deletions
+28 -3
View File
@@ -49,7 +49,30 @@ from typing import Any
from ..campaigns.models import CampaignSpec from ..campaigns.models import CampaignSpec
DEFAULT_OPTIMIZATION_GOAL = "LEAD_GENERATION" # Meta pairs an ad set's optimisation goal with its campaign's objective and
# rejects a mismatch with code 100 / subcode 2490408 — at ad-set creation,
# which is *after* the campaign object exists. A single global default is
# therefore wrong for every objective but one: LEAD_GENERATION on an
# OUTCOME_TRAFFIC campaign is what the live connection test hit.
#
# These are the conservative choice per objective — the goal Meta treats as
# that objective's natural optimisation — not the only valid one. A caller
# who wants something else sets advanced.optimization_goal, and owns
# checking Meta accepts it for their objective.
DEFAULT_OPTIMIZATION_GOAL_BY_OBJECTIVE = {
"OUTCOME_TRAFFIC": "LINK_CLICKS",
"OUTCOME_LEADS": "LEAD_GENERATION",
"OUTCOME_ENGAGEMENT": "POST_ENGAGEMENT",
"OUTCOME_AWARENESS": "REACH",
"OUTCOME_SALES": "OFFSITE_CONVERSIONS",
"OUTCOME_APP_PROMOTION": "LINK_CLICKS",
}
# Fallback for an objective not in the table above. LINK_CLICKS is valid for
# the widest range of objectives, so an unknown objective degrades to
# something Meta is most likely to accept rather than to a lead goal that
# only OUTCOME_LEADS takes.
DEFAULT_OPTIMIZATION_GOAL = "LINK_CLICKS"
DEFAULT_BILLING_EVENT = "IMPRESSIONS" DEFAULT_BILLING_EVENT = "IMPRESSIONS"
# Graph requires a bid strategy on every ad set that carries its own budget, # Graph requires a bid strategy on every ad set that carries its own budget,
@@ -145,8 +168,10 @@ def build_ad_set_payload(spec: CampaignSpec, campaign_id: str) -> dict[str, Any]
"Set at least one of countries, age_min, age_max or genders.", "Set at least one of countries, age_min, age_max or genders.",
) )
optimization_goal = spec.advanced.get( optimization_goal = spec.advanced.get("optimization_goal") or (
"optimization_goal", DEFAULT_OPTIMIZATION_GOAL, DEFAULT_OPTIMIZATION_GOAL_BY_OBJECTIVE.get(
spec.objective or "", DEFAULT_OPTIMIZATION_GOAL,
)
) )
if optimization_goal not in ALLOWED_OPTIMIZATION_GOALS: if optimization_goal not in ALLOWED_OPTIMIZATION_GOALS:
raise ValueError( raise ValueError(
+63
View File
@@ -18,6 +18,9 @@ import pytest
from adclaw.meta.client import MetaClient, MetaError from adclaw.meta.client import MetaClient, MetaError
from adclaw.meta.objects import ( from adclaw.meta.objects import (
ALLOWED_OPTIMIZATION_GOALS,
DEFAULT_OPTIMIZATION_GOAL,
DEFAULT_OPTIMIZATION_GOAL_BY_OBJECTIVE,
build_ad_set_payload, build_ad_set_payload,
build_campaign_payload, build_campaign_payload,
build_creative_payload, build_creative_payload,
@@ -651,3 +654,63 @@ async def test_create_ad_set_sends_the_bid_strategy():
sent = transport.calls[0]["params"]["bid_strategy"] sent = transport.calls[0]["params"]["bid_strategy"]
assert sent == "LOWEST_COST_WITHOUT_CAP" assert sent == "LOWEST_COST_WITHOUT_CAP"
# ---------------------------------------------------------------------------
# optimisation goal: Meta pairs it with the campaign objective
# ---------------------------------------------------------------------------
def test_the_default_optimisation_goal_follows_the_objective():
"""A traffic campaign cannot optimise for leads.
Meta rejects the pair with code 100 / subcode 2490408, at ad-set
creation — after the campaign object exists. The default used to be
LEAD_GENERATION for every objective, which is correct for exactly one
of them and is what the live connection test hit.
"""
assert (
build_ad_set_payload(_spec(objective="OUTCOME_TRAFFIC"), campaign_id="c1")[
"optimization_goal"
]
== "LINK_CLICKS"
)
assert (
build_ad_set_payload(_spec(objective="OUTCOME_LEADS"), campaign_id="c1")[
"optimization_goal"
]
== "LEAD_GENERATION"
)
assert (
build_ad_set_payload(_spec(objective="OUTCOME_AWARENESS"), campaign_id="c1")[
"optimization_goal"
]
== "REACH"
)
def test_every_mapped_default_is_one_we_allow():
"""The table and the allowlist have to agree, or a perfectly ordinary
objective raises ValueError on its own default."""
for objective, goal in DEFAULT_OPTIMIZATION_GOAL_BY_OBJECTIVE.items():
assert goal in ALLOWED_OPTIMIZATION_GOALS, objective
assert DEFAULT_OPTIMIZATION_GOAL in ALLOWED_OPTIMIZATION_GOALS
def test_an_unknown_objective_falls_back_to_the_widest_goal():
"""Better a goal most objectives accept than a lead goal only one does."""
payload = build_ad_set_payload(_spec(objective="OUTCOME_SOMETHING_NEW"),
campaign_id="c1")
assert payload["optimization_goal"] == "LINK_CLICKS"
def test_an_explicit_optimisation_goal_still_wins():
spec = _spec(
objective="OUTCOME_TRAFFIC",
advanced={"optimization_goal": "LANDING_PAGE_VIEWS"},
)
payload = build_ad_set_payload(spec, campaign_id="c1")
assert payload["optimization_goal"] == "LANDING_PAGE_VIEWS"