feat(meta): add write helpers that always create paused objects

Adds the write half of the Meta Graph API client: create_campaign,
create_ad_set, upload_ad_image, create_ad_creative, create_ad,
update_object_status, and list_campaigns/list_ad_sets/list_ads. Every
create_* helper hard-codes status="PAUSED" and raises ValueError with
zero network calls if asked for anything else; update_object_status is
the only method allowed to send ACTIVE. Dict params Graph expects as
JSON strings (targeting, object_story_spec, special_ad_categories) are
json.dumps-encoded before being sent.

Also adds src/adclaw/meta/objects.py with pure functions mapping a
CampaignSpec's budget/targeting/advanced fields onto Graph parameter
names, keeping that mapping unit-testable without a transport.

26 new tests in tests/test_meta_writes.py, all against a FakeTransport
(no live network calls, no real Meta objects created).
This commit is contained in:
AFFAANh
2026-08-03 01:03:39 +05:30
parent 04ad516fe2
commit a5e58f2eb3
3 changed files with 802 additions and 4 deletions
+221 -4
View File
@@ -1,11 +1,18 @@
# -*- coding: utf-8 -*-
"""Read-only Meta Graph API client.
"""Meta Graph API client.
Phase 1 only reads: ad account fields and ad previews. Previews are generated
from a creative spec and create no objects in Meta.
Phase 1 added reads: ad account fields and ad previews. Phase 2 adds writes:
creating campaigns, ad sets, creatives and ads, and updating object status.
Money-safety contract for the write half: every `create_*` helper is hard
-coded to create its object with status="PAUSED" and raises `ValueError`
(with no network call at all) if the caller asks for anything else.
`update_object_status` is the only method allowed to send "ACTIVE"; it is
what the Launch endpoint (Task 6) calls once a human has approved spend.
"""
from __future__ import annotations
import base64
import json
import os
from typing import Any, Awaitable, Callable
@@ -39,6 +46,12 @@ DEFAULT_AD_FORMATS = (
"RIGHT_COLUMN_STANDARD",
)
# The only status any create_* helper is allowed to send. Launching (setting
# ACTIVE) happens exclusively through update_object_status.
CREATE_STATUS = "PAUSED"
LIST_FIELDS = "id,name,status,effective_status,created_time"
Transport = Callable[[str, str, dict[str, Any]], Awaitable[dict[str, Any]]]
@@ -90,7 +103,7 @@ async def _httpx_transport(
class MetaClient:
"""Minimal read-only Graph API client."""
"""Graph API client: ad account/preview reads plus paused-object writes."""
def __init__(
self,
@@ -113,6 +126,25 @@ class MetaClient:
)
return result
async def _post(self, path: str, data: dict[str, Any]) -> dict[str, Any]:
"""POST to Graph, mirroring `_get`'s body-based error detection.
Graph returns errors with HTTP 200, so both `_get` and `_post` must
inspect the response body for an `"error"` key rather than trust the
HTTP status code.
"""
payload = dict(data)
payload["access_token"] = self._token
result = await self._transport("POST", f"{GRAPH_BASE_URL}{path}", payload)
error = result.get("error") if isinstance(result, dict) else None
if error:
raise MetaError(
error.get("message") or "Meta request failed.",
code=error.get("code"),
subcode=error.get("error_subcode"),
)
return result
async def get_ad_account(self, ad_account_id: str) -> dict[str, Any]:
"""Return billing and configuration fields for an ad account."""
return await self._get(
@@ -144,3 +176,188 @@ class MetaClient:
if entries and entries[0].get("body"):
previews[ad_format] = entries[0]["body"]
return previews
async def create_campaign(
self,
ad_account_id: str,
name: str,
objective: str,
status: str = CREATE_STATUS,
special_ad_categories: list[str] | None = None,
) -> str:
"""Create a paused campaign and return its id.
Raises `ValueError` (making no network call) if `status` is
anything other than "PAUSED" — campaigns are always created paused;
use `update_object_status` to launch.
"""
if status != CREATE_STATUS:
raise ValueError(
"Campaigns are always created PAUSED. Use "
"update_object_status to launch.",
)
result = await self._post(
f"/{ad_account_id}/campaigns",
{
"name": name,
"objective": objective,
"status": status,
"special_ad_categories": json.dumps(special_ad_categories or []),
},
)
return result["id"]
async def create_ad_set(
self,
ad_account_id: str,
campaign_id: str,
name: str,
daily_budget: int,
targeting: dict[str, Any],
optimization_goal: str,
billing_event: str,
status: str = CREATE_STATUS,
) -> str:
"""Create a paused ad set and return its id.
`targeting` is JSON-encoded before being sent, since Graph expects
it as a JSON string rather than a nested object in form/query
params. Raises `ValueError` (making no network call) if `status`
is anything other than "PAUSED".
"""
if status != CREATE_STATUS:
raise ValueError(
"Ad sets are always created PAUSED. Use "
"update_object_status to launch.",
)
result = await self._post(
f"/{ad_account_id}/adsets",
{
"name": name,
"campaign_id": campaign_id,
"daily_budget": daily_budget,
"targeting": json.dumps(targeting),
"optimization_goal": optimization_goal,
"billing_event": billing_event,
"status": status,
},
)
return result["id"]
async def upload_ad_image(self, ad_account_id: str, image_path: str) -> str:
"""Upload an image file and return the hash Meta assigns it.
The file's bytes are base64-encoded and posted under `bytes`, per
Graph's `/adimages` contract. The response shape is
`{"images": {"<key>": {"hash": "...", ...}}}`; this returns the
first hash found and raises `MetaError` if none is present.
"""
with open(image_path, "rb") as image_file:
encoded = base64.b64encode(image_file.read()).decode("ascii")
result = await self._post(
f"/{ad_account_id}/adimages",
{"bytes": encoded},
)
images = result.get("images") or {}
for entry in images.values():
image_hash = entry.get("hash") if isinstance(entry, dict) else None
if image_hash:
return image_hash
raise MetaError("Meta did not return an image hash for the upload.")
async def create_ad_creative(
self,
ad_account_id: str,
name: str,
page_id: str,
message: str,
headline: str,
description: str,
link: str,
image_hash: str,
) -> str:
"""Create an ad creative and return its id.
Ad creatives have no status of their own in Graph (only the ad
that references one does), so there is no PAUSED guard here.
`object_story_spec` is JSON-encoded before being sent.
"""
object_story_spec = {
"page_id": page_id,
"link_data": {
"message": message,
"name": headline,
"description": description,
"link": link,
"image_hash": image_hash,
},
}
result = await self._post(
f"/{ad_account_id}/adcreatives",
{
"name": name,
"object_story_spec": json.dumps(object_story_spec),
},
)
return result["id"]
async def create_ad(
self,
ad_account_id: str,
name: str,
adset_id: str,
creative_id: str,
status: str = CREATE_STATUS,
) -> str:
"""Create a paused ad linking an ad set to a creative, return its id.
Raises `ValueError` (making no network call) if `status` is
anything other than "PAUSED".
"""
if status != CREATE_STATUS:
raise ValueError(
"Ads are always created PAUSED. Use "
"update_object_status to launch.",
)
result = await self._post(
f"/{ad_account_id}/ads",
{
"name": name,
"adset_id": adset_id,
"creative": json.dumps({"creative_id": creative_id}),
"status": status,
},
)
return result["id"]
async def update_object_status(self, object_id: str, status: str) -> None:
"""Update any campaign/ad set/ad's status.
This is the ONLY method permitted to send "ACTIVE" — it is what
the Launch endpoint (Task 6) calls once a human has approved spend.
"""
await self._post(f"/{object_id}", {"status": status})
async def list_campaigns(self, ad_account_id: str) -> list[dict[str, Any]]:
"""Return this ad account's campaigns."""
result = await self._get(
f"/{ad_account_id}/campaigns",
{"fields": LIST_FIELDS},
)
return result.get("data") or []
async def list_ad_sets(self, ad_account_id: str) -> list[dict[str, Any]]:
"""Return this ad account's ad sets."""
result = await self._get(
f"/{ad_account_id}/adsets",
{"fields": LIST_FIELDS},
)
return result.get("data") or []
async def list_ads(self, ad_account_id: str) -> list[dict[str, Any]]:
"""Return this ad account's ads."""
result = await self._get(
f"/{ad_account_id}/ads",
{"fields": LIST_FIELDS},
)
return result.get("data") or []
+119
View File
@@ -0,0 +1,119 @@
# -*- coding: utf-8 -*-
"""Pure mappers from a CampaignSpec's stored JSON blobs to Graph API params.
These functions do no I/O and make no Meta calls; they only reshape data so
`MetaClient`'s write methods stay transport-only and this mapping logic is
unit-testable without a fake transport at all. Callers spread the returned
dict as keyword arguments into the matching `MetaClient` method, e.g.:
await client.create_campaign(ad_account_id, **build_campaign_payload(spec))
Field mapping decisions
------------------------
Phase 2 does not yet have separate AdSet/Ad spec models — `CampaignSpec` is
the only spec in play — so ad-set- and ad-level fields that don't fit
`CampaignSpec`'s campaign-level columns are read out of `spec.advanced`.
This is a deliberate, documented choice rather than an oversight:
- `spec.budget["daily_budget"]` -> ad set `daily_budget`, passed through
unchanged (already minor currency units / cents, matching
`adclaw.campaigns.validation` and Graph's own expectation). Lifetime
budgets are out of scope for this mapping: `daily_budget` is required and
`build_ad_set_payload` raises `ValueError` if it is missing.
- `spec.targeting["age_min"]` / `["age_max"]` -> Graph
`targeting.age_min` / `targeting.age_max`.
- `spec.targeting["countries"]` -> Graph
`targeting.geo_locations.countries`.
- `spec.targeting["genders"]` -> Graph `targeting.genders`, if present.
- `spec.advanced["special_ad_categories"]` -> campaign
`special_ad_categories` (defaults to an empty list).
- `spec.advanced["optimization_goal"]` / `["billing_event"]` -> ad set
fields of the same name, defaulting to `LEAD_GENERATION` /
`IMPRESSIONS` (MaskanX's default lead-gen objective).
- `spec.advanced["message"]` / `["headline"]` / `["description"]` /
`["link"]` -> ad creative `object_story_spec.link_data` fields of the
same purpose (headline defaults to the campaign name if unset).
"""
from __future__ import annotations
from typing import Any
from ..campaigns.models import CampaignSpec
DEFAULT_OPTIMIZATION_GOAL = "LEAD_GENERATION"
DEFAULT_BILLING_EVENT = "IMPRESSIONS"
def build_campaign_payload(spec: CampaignSpec) -> dict[str, Any]:
"""Map a CampaignSpec onto `MetaClient.create_campaign` kwargs.
Raises `ValueError` if `spec.objective` is unset — Graph requires an
objective on every campaign and there is no sensible default to guess.
"""
if not spec.objective:
raise ValueError(
"CampaignSpec.objective is required to create a Meta campaign.",
)
return {
"name": spec.name,
"objective": spec.objective,
"special_ad_categories": list(
spec.advanced.get("special_ad_categories") or [],
),
}
def build_ad_set_payload(spec: CampaignSpec, campaign_id: str) -> dict[str, Any]:
"""Map a CampaignSpec onto `MetaClient.create_ad_set` kwargs.
Raises `ValueError` if `spec.budget["daily_budget"]` is unset — this
mapping does not yet support lifetime-budget campaigns.
"""
daily_budget = spec.budget.get("daily_budget")
if daily_budget is None:
raise ValueError(
"CampaignSpec.budget.daily_budget is required to create an ad "
"set (lifetime budgets are not yet supported).",
)
targeting: dict[str, Any] = {}
age_min = spec.targeting.get("age_min")
age_max = spec.targeting.get("age_max")
if age_min is not None:
targeting["age_min"] = age_min
if age_max is not None:
targeting["age_max"] = age_max
countries = spec.targeting.get("countries")
if countries:
targeting["geo_locations"] = {"countries": list(countries)}
genders = spec.targeting.get("genders")
if genders:
targeting["genders"] = list(genders)
return {
"campaign_id": campaign_id,
"name": f"{spec.name} - Ad Set",
"daily_budget": daily_budget,
"targeting": targeting,
"optimization_goal": spec.advanced.get(
"optimization_goal", DEFAULT_OPTIMIZATION_GOAL,
),
"billing_event": spec.advanced.get("billing_event", DEFAULT_BILLING_EVENT),
}
def build_creative_payload(
spec: CampaignSpec,
page_id: str,
image_hash: str,
) -> dict[str, Any]:
"""Map a CampaignSpec onto `MetaClient.create_ad_creative` kwargs."""
return {
"name": f"{spec.name} - Creative",
"page_id": page_id,
"message": spec.advanced.get("message", ""),
"headline": spec.advanced.get("headline") or spec.name,
"description": spec.advanced.get("description", ""),
"link": spec.advanced.get("link", ""),
"image_hash": image_hash,
}
+462
View File
@@ -0,0 +1,462 @@
# -*- coding: utf-8 -*-
"""Meta write helpers. No live calls: the transport is injected.
Covers the money-safety properties Task 4 exists to enforce:
1. Every create_* helper hard-codes status="PAUSED" and raises ValueError
(with zero network calls) if asked for anything else.
2. update_object_status is the only method allowed to send ACTIVE.
3. Dict params Graph expects as JSON strings (targeting,
object_story_spec, special_ad_categories) are json.dumps-encoded.
4. Meta's code/error_subcode survive on MetaError.
5. No test makes a live network call or creates a real Meta object -
every client here is constructed with a FakeTransport.
"""
import base64
import json
import pytest
from adclaw.meta.client import MetaClient, MetaError
from adclaw.meta.objects import (
build_ad_set_payload,
build_campaign_payload,
build_creative_payload,
)
from adclaw.campaigns.models import CampaignSpec
class FakeTransport:
"""Records calls and returns queued responses. Never touches a network."""
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
async def __call__(self, method, url, params):
self.calls.append({"method": method, "url": url, "params": params})
return self.responses.pop(0)
# ---------------------------------------------------------------------------
# create_campaign
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_campaign_is_always_paused():
transport = FakeTransport([{"id": "23851234567890"}])
client = MetaClient(access_token="tok", transport=transport)
campaign_id = await client.create_campaign(
"act_1", name="Q3", objective="OUTCOME_LEADS",
)
assert campaign_id == "23851234567890"
call = transport.calls[0]
assert call["method"] == "POST"
assert call["url"].endswith("/act_1/campaigns")
assert call["params"]["status"] == "PAUSED"
assert call["params"]["objective"] == "OUTCOME_LEADS"
@pytest.mark.asyncio
async def test_create_campaign_refuses_active_status():
transport = FakeTransport([{"id": "1"}])
client = MetaClient(access_token="tok", transport=transport)
with pytest.raises(ValueError):
await client.create_campaign(
"act_1", name="Q3", objective="OUTCOME_LEADS", status="ACTIVE",
)
assert transport.calls == []
@pytest.mark.asyncio
async def test_create_campaign_serialises_special_ad_categories():
transport = FakeTransport([{"id": "c1"}])
client = MetaClient(access_token="tok", transport=transport)
await client.create_campaign(
"act_1",
name="Q3",
objective="OUTCOME_LEADS",
special_ad_categories=["HOUSING"],
)
sent = transport.calls[0]["params"]["special_ad_categories"]
assert isinstance(sent, str)
assert json.loads(sent) == ["HOUSING"]
# ---------------------------------------------------------------------------
# create_ad_set
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_ad_set_sends_paused_and_serialises_targeting():
transport = FakeTransport([{"id": "adset_1"}])
client = MetaClient(access_token="tok", transport=transport)
await client.create_ad_set(
"act_1",
campaign_id="c1",
name="Ad set",
daily_budget=10000,
targeting={"geo_locations": {"countries": ["IN"]}},
optimization_goal="LEAD_GENERATION",
billing_event="IMPRESSIONS",
)
params = transport.calls[0]["params"]
assert params["status"] == "PAUSED"
assert params["daily_budget"] == 10000
assert isinstance(params["targeting"], str)
assert json.loads(params["targeting"]) == {
"geo_locations": {"countries": ["IN"]},
}
@pytest.mark.asyncio
async def test_create_ad_set_refuses_active_status():
transport = FakeTransport([{"id": "adset_1"}])
client = MetaClient(access_token="tok", transport=transport)
with pytest.raises(ValueError):
await client.create_ad_set(
"act_1",
campaign_id="c1",
name="Ad set",
daily_budget=10000,
targeting={},
optimization_goal="LEAD_GENERATION",
billing_event="IMPRESSIONS",
status="ACTIVE",
)
assert transport.calls == []
# ---------------------------------------------------------------------------
# create_ad
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_ad_is_always_paused():
transport = FakeTransport([{"id": "ad_1"}])
client = MetaClient(access_token="tok", transport=transport)
ad_id = await client.create_ad(
"act_1", name="Ad", adset_id="adset_1", creative_id="creative_1",
)
assert ad_id == "ad_1"
params = transport.calls[0]["params"]
assert params["status"] == "PAUSED"
assert params["adset_id"] == "adset_1"
assert json.loads(params["creative"]) == {"creative_id": "creative_1"}
@pytest.mark.asyncio
async def test_create_ad_refuses_active_status():
transport = FakeTransport([{"id": "ad_1"}])
client = MetaClient(access_token="tok", transport=transport)
with pytest.raises(ValueError):
await client.create_ad(
"act_1",
name="Ad",
adset_id="adset_1",
creative_id="creative_1",
status="ACTIVE",
)
assert transport.calls == []
# ---------------------------------------------------------------------------
# create_ad_creative
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_ad_creative_serialises_object_story_spec():
transport = FakeTransport([{"id": "creative_1"}])
client = MetaClient(access_token="tok", transport=transport)
creative_id = await client.create_ad_creative(
"act_1",
name="Creative",
page_id="page_1",
message="Hello",
headline="Headline",
description="Description",
link="https://example.com",
image_hash="hash123",
)
assert creative_id == "creative_1"
sent = transport.calls[0]["params"]["object_story_spec"]
assert isinstance(sent, str)
decoded = json.loads(sent)
assert decoded["page_id"] == "page_1"
assert decoded["link_data"]["image_hash"] == "hash123"
assert decoded["link_data"]["message"] == "Hello"
# ---------------------------------------------------------------------------
# upload_ad_image
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_upload_ad_image_encodes_bytes_and_returns_hash(tmp_path):
image_path = tmp_path / "creative.png"
raw_bytes = b"\x89PNG\r\n\x1a\nfake-image-bytes"
image_path.write_bytes(raw_bytes)
transport = FakeTransport(
[{"images": {"creative.png": {"hash": "abc123", "url": "https://x"}}}],
)
client = MetaClient(access_token="tok", transport=transport)
image_hash = await client.upload_ad_image("act_1", str(image_path))
assert image_hash == "abc123"
call = transport.calls[0]
assert call["method"] == "POST"
assert call["url"].endswith("/act_1/adimages")
assert base64.b64decode(call["params"]["bytes"]) == raw_bytes
@pytest.mark.asyncio
async def test_upload_ad_image_raises_meta_error_when_no_hash(tmp_path):
image_path = tmp_path / "creative.png"
image_path.write_bytes(b"data")
transport = FakeTransport([{"images": {}}])
client = MetaClient(access_token="tok", transport=transport)
with pytest.raises(MetaError):
await client.upload_ad_image("act_1", str(image_path))
# ---------------------------------------------------------------------------
# _post error handling
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_post_error_preserves_meta_code_and_subcode():
transport = FakeTransport([
{"error": {"message": "Invalid budget", "code": 100, "error_subcode": 1487079}},
])
client = MetaClient(access_token="tok", transport=transport)
with pytest.raises(MetaError) as excinfo:
await client.create_campaign("act_1", name="Q3", objective="OUTCOME_LEADS")
assert excinfo.value.code == 100
assert excinfo.value.subcode == 1487079
assert "Invalid budget" in str(excinfo.value)
# ---------------------------------------------------------------------------
# update_object_status - the only path allowed to send ACTIVE
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_update_object_status_accepts_active():
"""Launch is the one path allowed to send ACTIVE."""
transport = FakeTransport([{"success": True}])
client = MetaClient(access_token="tok", transport=transport)
await client.update_object_status("c1", "ACTIVE")
call = transport.calls[0]
assert call["method"] == "POST"
assert call["url"].endswith("/c1")
assert call["params"]["status"] == "ACTIVE"
@pytest.mark.asyncio
async def test_update_object_status_returns_none():
transport = FakeTransport([{"success": True}])
client = MetaClient(access_token="tok", transport=transport)
result = await client.update_object_status("c1", "PAUSED")
assert result is None
# ---------------------------------------------------------------------------
# list_campaigns / list_ad_sets / list_ads
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_campaigns_returns_data_array():
transport = FakeTransport([{"data": [{"id": "c1", "name": "X", "status": "PAUSED"}]}])
client = MetaClient(access_token="tok", transport=transport)
result = await client.list_campaigns("act_1")
assert result[0]["id"] == "c1"
assert transport.calls[0]["method"] == "GET"
assert transport.calls[0]["url"].endswith("/act_1/campaigns")
@pytest.mark.asyncio
async def test_list_campaigns_returns_empty_list_when_no_data():
transport = FakeTransport([{}])
client = MetaClient(access_token="tok", transport=transport)
assert await client.list_campaigns("act_1") == []
@pytest.mark.asyncio
async def test_list_ad_sets_returns_data_array():
transport = FakeTransport([{"data": [{"id": "as1"}]}])
client = MetaClient(access_token="tok", transport=transport)
result = await client.list_ad_sets("act_1")
assert result == [{"id": "as1"}]
assert transport.calls[0]["method"] == "GET"
assert transport.calls[0]["url"].endswith("/act_1/adsets")
@pytest.mark.asyncio
async def test_list_ads_returns_data_array():
transport = FakeTransport([{"data": [{"id": "ad1"}]}])
client = MetaClient(access_token="tok", transport=transport)
result = await client.list_ads("act_1")
assert result == [{"id": "ad1"}]
assert transport.calls[0]["method"] == "GET"
assert transport.calls[0]["url"].endswith("/act_1/ads")
# ---------------------------------------------------------------------------
# objects.py - pure payload builders, no transport at all
# ---------------------------------------------------------------------------
def _spec(**overrides) -> CampaignSpec:
defaults = dict(
id="camp_1",
name="Test Campaign",
objective="OUTCOME_LEADS",
budget={"daily_budget": 5000},
targeting={"age_min": 25, "age_max": 45, "countries": ["US", "CA"]},
advanced={},
)
defaults.update(overrides)
return CampaignSpec(**defaults)
def test_build_campaign_payload_maps_fields():
spec = _spec(advanced={"special_ad_categories": ["HOUSING"]})
payload = build_campaign_payload(spec)
assert payload == {
"name": "Test Campaign",
"objective": "OUTCOME_LEADS",
"special_ad_categories": ["HOUSING"],
}
def test_build_campaign_payload_defaults_special_ad_categories_to_empty():
spec = _spec()
payload = build_campaign_payload(spec)
assert payload["special_ad_categories"] == []
def test_build_campaign_payload_requires_objective():
spec = _spec(objective=None)
with pytest.raises(ValueError):
build_campaign_payload(spec)
def test_build_ad_set_payload_maps_targeting_and_budget():
spec = _spec()
payload = build_ad_set_payload(spec, campaign_id="c1")
assert payload["campaign_id"] == "c1"
assert payload["daily_budget"] == 5000
assert payload["targeting"] == {
"age_min": 25,
"age_max": 45,
"geo_locations": {"countries": ["US", "CA"]},
}
assert payload["optimization_goal"] == "LEAD_GENERATION"
assert payload["billing_event"] == "IMPRESSIONS"
def test_build_ad_set_payload_honours_advanced_overrides():
spec = _spec(
advanced={
"optimization_goal": "OFFSITE_CONVERSIONS",
"billing_event": "LINK_CLICKS",
},
)
payload = build_ad_set_payload(spec, campaign_id="c1")
assert payload["optimization_goal"] == "OFFSITE_CONVERSIONS"
assert payload["billing_event"] == "LINK_CLICKS"
def test_build_ad_set_payload_requires_daily_budget():
spec = _spec(budget={})
with pytest.raises(ValueError):
build_ad_set_payload(spec, campaign_id="c1")
def test_build_ad_set_payload_targeting_can_produce_json_dumpable_dict():
# This is what create_ad_set will json.dumps() before sending - make
# sure the mapping never emits anything that would break that step.
spec = _spec()
payload = build_ad_set_payload(spec, campaign_id="c1")
encoded = json.dumps(payload["targeting"])
assert json.loads(encoded) == payload["targeting"]
def test_build_creative_payload_maps_fields():
spec = _spec(
advanced={
"message": "Come see our homes",
"headline": "New Listings",
"description": "Fresh inventory weekly",
"link": "https://example.com/listings",
},
)
payload = build_creative_payload(spec, page_id="page_1", image_hash="hash123")
assert payload == {
"name": "Test Campaign - Creative",
"page_id": "page_1",
"message": "Come see our homes",
"headline": "New Listings",
"description": "Fresh inventory weekly",
"link": "https://example.com/listings",
"image_hash": "hash123",
}
def test_build_creative_payload_falls_back_to_campaign_name_for_headline():
spec = _spec(advanced={})
payload = build_creative_payload(spec, page_id="page_1", image_hash="hash123")
assert payload["headline"] == "Test Campaign"