2026-08-03 01:03:39 +05:30
|
|
|
# -*- 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
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 01:17:18 +05:30
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# _post defence in depth - reachable directly, still refuses non-PAUSED
|
|
|
|
|
# creates on campaign/adset/ad paths, before any transport call
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_post_refuses_non_paused_status_on_campaign_create_path():
|
|
|
|
|
transport = FakeTransport([{"id": "c1"}])
|
|
|
|
|
client = MetaClient(access_token="tok", transport=transport)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
await client._post(
|
|
|
|
|
"/act_1/campaigns",
|
|
|
|
|
{"name": "Q3", "objective": "OUTCOME_LEADS", "status": "ACTIVE"},
|
|
|
|
|
)
|
|
|
|
|
assert transport.calls == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_post_still_allows_update_object_status_to_send_active():
|
|
|
|
|
"""update_object_status posts to /{object_id}, which never matches the
|
|
|
|
|
campaign/adset/ad creation suffixes, so the _post guard above must not
|
|
|
|
|
block Launch."""
|
|
|
|
|
transport = FakeTransport([{"success": True}])
|
|
|
|
|
client = MetaClient(access_token="tok", transport=transport)
|
|
|
|
|
|
|
|
|
|
await client.update_object_status("c1", "ACTIVE")
|
|
|
|
|
|
|
|
|
|
assert transport.calls[0]["params"]["status"] == "ACTIVE"
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 01:03:39 +05:30
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 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)
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 01:17:18 +05:30
|
|
|
def test_build_campaign_payload_normalises_bare_string_special_ad_category():
|
|
|
|
|
# advanced is unvalidated free-form JSON, so a bare string is a
|
|
|
|
|
# plausible client input for this compliance field. It must become a
|
|
|
|
|
# single-element list, not be exploded into characters by list().
|
|
|
|
|
spec = _spec(advanced={"special_ad_categories": "HOUSING"})
|
|
|
|
|
|
|
|
|
|
payload = build_campaign_payload(spec)
|
|
|
|
|
|
|
|
|
|
assert payload["special_ad_categories"] == ["HOUSING"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_campaign_payload_passes_through_a_list_unchanged():
|
|
|
|
|
spec = _spec(advanced={"special_ad_categories": ["HOUSING", "EMPLOYMENT"]})
|
|
|
|
|
|
|
|
|
|
payload = build_campaign_payload(spec)
|
|
|
|
|
|
|
|
|
|
assert payload["special_ad_categories"] == ["HOUSING", "EMPLOYMENT"]
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 01:03:39 +05:30
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 01:17:18 +05:30
|
|
|
def test_build_ad_set_payload_accepts_allowlisted_optimization_goal():
|
|
|
|
|
spec = _spec(advanced={"optimization_goal": "LINK_CLICKS"})
|
|
|
|
|
|
|
|
|
|
payload = build_ad_set_payload(spec, campaign_id="c1")
|
|
|
|
|
|
|
|
|
|
assert payload["optimization_goal"] == "LINK_CLICKS"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_ad_set_payload_rejects_unknown_optimization_goal():
|
|
|
|
|
spec = _spec(advanced={"optimization_goal": "SOMETHING_MADE_UP"})
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
build_ad_set_payload(spec, campaign_id="c1")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_ad_set_payload_accepts_allowlisted_billing_event():
|
|
|
|
|
spec = _spec(advanced={"billing_event": "LINK_CLICKS"})
|
|
|
|
|
|
|
|
|
|
payload = build_ad_set_payload(spec, campaign_id="c1")
|
|
|
|
|
|
|
|
|
|
assert payload["billing_event"] == "LINK_CLICKS"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_build_ad_set_payload_rejects_unknown_billing_event():
|
|
|
|
|
# billing_event determines how the ad account is charged, so an
|
|
|
|
|
# unrecognised value from client-controlled `advanced` must never reach
|
|
|
|
|
# Meta - a spending-relevant field with no allowlist is the finding
|
|
|
|
|
# this guards against.
|
|
|
|
|
spec = _spec(advanced={"billing_event": "SOMETHING_MADE_UP"})
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
|
build_ad_set_payload(spec, campaign_id="c1")
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 01:03:39 +05:30
|
|
|
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"
|