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:
+221
-4
@@ -1,11 +1,18 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- 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
|
Phase 1 added reads: ad account fields and ad previews. Phase 2 adds writes:
|
||||||
from a creative spec and create no objects in Meta.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from typing import Any, Awaitable, Callable
|
from typing import Any, Awaitable, Callable
|
||||||
@@ -39,6 +46,12 @@ DEFAULT_AD_FORMATS = (
|
|||||||
"RIGHT_COLUMN_STANDARD",
|
"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]]]
|
Transport = Callable[[str, str, dict[str, Any]], Awaitable[dict[str, Any]]]
|
||||||
|
|
||||||
|
|
||||||
@@ -90,7 +103,7 @@ async def _httpx_transport(
|
|||||||
|
|
||||||
|
|
||||||
class MetaClient:
|
class MetaClient:
|
||||||
"""Minimal read-only Graph API client."""
|
"""Graph API client: ad account/preview reads plus paused-object writes."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -113,6 +126,25 @@ class MetaClient:
|
|||||||
)
|
)
|
||||||
return result
|
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]:
|
async def get_ad_account(self, ad_account_id: str) -> dict[str, Any]:
|
||||||
"""Return billing and configuration fields for an ad account."""
|
"""Return billing and configuration fields for an ad account."""
|
||||||
return await self._get(
|
return await self._get(
|
||||||
@@ -144,3 +176,188 @@ class MetaClient:
|
|||||||
if entries and entries[0].get("body"):
|
if entries and entries[0].get("body"):
|
||||||
previews[ad_format] = entries[0]["body"]
|
previews[ad_format] = entries[0]["body"]
|
||||||
return previews
|
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 []
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user