Graph requires targeting on every ad set. An empty targeting dict reached Meta and failed opaquely part-way into building the object chain, leaving a campaign and nothing else. This names the missing field before any network call, matching how the module already handles a missing objective and a missing daily budget. The wizard defaults age and countries, so this only fires on campaigns built through the API without targeting. Also fixes the live smoke test, which used Graph's nested geo_locations shape rather than the flat spec.targeting["countries"] the mapper reads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
238 lines
7.7 KiB
Python
238 lines
7.7 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Syncing an approved campaign into Meta.
|
|
|
|
No test here touches the network: the Meta client is always a fake that
|
|
records the calls it was asked to make.
|
|
"""
|
|
import pytest
|
|
|
|
from adclaw.campaigns.models import CampaignSpec
|
|
from adclaw.campaigns.sync import SyncConfigurationError, sync_campaign
|
|
from adclaw.meta.client import MetaError
|
|
|
|
|
|
class FakeRepo:
|
|
"""Records persisted state and the audit events written."""
|
|
|
|
def __init__(self):
|
|
self.events: list[str] = []
|
|
self.saved: CampaignSpec | None = None
|
|
|
|
async def update_campaign_with_event(
|
|
self, spec, event_type, actor=None, reason=None, payload=None,
|
|
):
|
|
self.events.append(event_type)
|
|
self.saved = spec
|
|
return spec
|
|
|
|
|
|
class FakeMeta:
|
|
"""Records every create call, and can fail on a chosen step."""
|
|
|
|
def __init__(self, fail_on: str | None = None):
|
|
self.calls: list[dict] = []
|
|
self.fail_on = fail_on
|
|
|
|
def _maybe_fail(self, step: str):
|
|
if self.fail_on == step:
|
|
raise MetaError("Meta rejected this", code=100, subcode=1487079)
|
|
|
|
async def create_campaign(self, ad_account_id, **kwargs):
|
|
self._maybe_fail("campaign")
|
|
self.calls.append({"step": "campaign", "account": ad_account_id, **kwargs})
|
|
return "meta_camp_1"
|
|
|
|
async def create_ad_set(self, ad_account_id, **kwargs):
|
|
self._maybe_fail("adset")
|
|
self.calls.append({"step": "adset", "account": ad_account_id, **kwargs})
|
|
return "meta_adset_1"
|
|
|
|
async def upload_ad_image(self, ad_account_id, image_path):
|
|
self._maybe_fail("image")
|
|
self.calls.append({"step": "image", "path": image_path})
|
|
return "img_hash_1"
|
|
|
|
async def create_ad_creative(self, ad_account_id, **kwargs):
|
|
self._maybe_fail("creative")
|
|
self.calls.append({"step": "creative", **kwargs})
|
|
return "meta_creative_1"
|
|
|
|
async def create_ad(self, ad_account_id, **kwargs):
|
|
self._maybe_fail("ad")
|
|
self.calls.append({"step": "ad", **kwargs})
|
|
return "meta_ad_1"
|
|
|
|
def steps(self) -> list[str]:
|
|
return [c["step"] for c in self.calls]
|
|
|
|
|
|
def _campaign(**overrides) -> CampaignSpec:
|
|
data = {
|
|
"id": "camp_1",
|
|
"name": "Q3 lead gen",
|
|
"status": "approved",
|
|
"objective": "OUTCOME_LEADS",
|
|
"ad_account_id": "act_1",
|
|
"budget": {"daily_budget": 10000},
|
|
# Graph requires an audience on every ad set, so every campaign that
|
|
# can be synced has one.
|
|
"targeting": {"countries": ["IN"]},
|
|
"advanced": {"page_id": "page_1", "link": "https://example.com"},
|
|
}
|
|
data.update(overrides)
|
|
return CampaignSpec(**data)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_happy_path_creates_full_chain_and_marks_synced():
|
|
repo, meta = FakeRepo(), FakeMeta()
|
|
|
|
result = await sync_campaign(repo, meta, _campaign(), actor="owner")
|
|
|
|
assert meta.steps() == ["campaign", "adset", "creative", "ad"]
|
|
assert result.status == "synced"
|
|
assert result.sync_status == "synced"
|
|
assert result.sync_error is None
|
|
assert result.meta_campaign_id == "meta_camp_1"
|
|
assert result.advanced["meta_sync"] == {
|
|
"adset_id": "meta_adset_1",
|
|
"creative_id": "meta_creative_1",
|
|
"ad_id": "meta_ad_1",
|
|
}
|
|
assert "campaign.sync" in repo.events
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_never_requests_a_non_paused_object():
|
|
"""The money-safety property: nothing in the chain may be created live."""
|
|
repo, meta = FakeRepo(), FakeMeta()
|
|
|
|
await sync_campaign(repo, meta, _campaign(), actor="owner")
|
|
|
|
for call in meta.calls:
|
|
assert call.get("status", "PAUSED") == "PAUSED", call
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_only_approved_campaigns_may_sync():
|
|
repo, meta = FakeRepo(), FakeMeta()
|
|
|
|
with pytest.raises(ValueError):
|
|
await sync_campaign(repo, meta, _campaign(status="draft"), actor="owner")
|
|
|
|
assert meta.calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_page_id_fails_before_any_meta_call(monkeypatch):
|
|
monkeypatch.delenv("META_PAGE_ID", raising=False)
|
|
repo, meta = FakeRepo(), FakeMeta()
|
|
campaign = _campaign(advanced={"link": "https://example.com"})
|
|
|
|
with pytest.raises(SyncConfigurationError):
|
|
await sync_campaign(repo, meta, campaign, actor="owner")
|
|
|
|
assert meta.calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_page_id_falls_back_to_environment(monkeypatch):
|
|
monkeypatch.setenv("META_PAGE_ID", "page_from_env")
|
|
repo, meta = FakeRepo(), FakeMeta()
|
|
campaign = _campaign(advanced={"link": "https://example.com"})
|
|
|
|
await sync_campaign(repo, meta, campaign, actor="owner")
|
|
|
|
creative = next(c for c in meta.calls if c["step"] == "creative")
|
|
assert creative["page_id"] == "page_from_env"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_ad_account_fails_before_any_meta_call():
|
|
repo, meta = FakeRepo(), FakeMeta()
|
|
|
|
with pytest.raises(SyncConfigurationError):
|
|
await sync_campaign(repo, meta, _campaign(ad_account_id=None), actor="owner")
|
|
|
|
assert meta.calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failure_mid_chain_records_error_and_keeps_earlier_ids():
|
|
repo, meta = FakeRepo(), FakeMeta(fail_on="adset")
|
|
campaign = _campaign()
|
|
|
|
with pytest.raises(MetaError):
|
|
await sync_campaign(repo, meta, campaign, actor="owner")
|
|
|
|
assert campaign.sync_status == "failed"
|
|
assert "Meta rejected this" in campaign.sync_error
|
|
assert "code=100" in campaign.sync_error
|
|
assert "subcode=1487079" in campaign.sync_error
|
|
# Status must NOT advance: the chain is incomplete.
|
|
assert campaign.status == "approved"
|
|
# The campaign id obtained before the failure is kept, so the retry
|
|
# reuses it instead of creating a second campaign in the account.
|
|
assert campaign.meta_campaign_id == "meta_camp_1"
|
|
assert "campaign.sync_failed" in repo.events
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_after_partial_failure_does_not_duplicate_objects():
|
|
repo, meta = FakeRepo(), FakeMeta(fail_on="adset")
|
|
campaign = _campaign()
|
|
|
|
with pytest.raises(MetaError):
|
|
await sync_campaign(repo, meta, campaign, actor="owner")
|
|
|
|
# Second attempt with a healthy Meta.
|
|
repo2, meta2 = FakeRepo(), FakeMeta()
|
|
result = await sync_campaign(repo2, meta2, campaign, actor="owner")
|
|
|
|
# create_campaign must NOT be called again — the id already exists.
|
|
assert "campaign" not in meta2.steps()
|
|
assert meta2.steps() == ["adset", "creative", "ad"]
|
|
assert result.status == "synced"
|
|
assert result.meta_campaign_id == "meta_camp_1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_image_is_uploaded_only_when_a_path_is_configured():
|
|
repo, meta = FakeRepo(), FakeMeta()
|
|
campaign = _campaign(
|
|
advanced={"page_id": "page_1", "image_path": "C:/tmp/creative.jpg"},
|
|
)
|
|
|
|
await sync_campaign(repo, meta, campaign, actor="owner")
|
|
|
|
assert "image" in meta.steps()
|
|
creative = next(c for c in meta.calls if c["step"] == "creative")
|
|
assert creative["image_hash"] == "img_hash_1"
|
|
assert campaign.advanced["meta_sync"]["image_hash"] == "img_hash_1"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_image_path_skips_upload():
|
|
repo, meta = FakeRepo(), FakeMeta()
|
|
|
|
await sync_campaign(repo, meta, _campaign(), actor="owner")
|
|
|
|
assert "image" not in meta.steps()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_uploaded_image_hash_is_reused_on_retry():
|
|
repo, meta = FakeRepo(), FakeMeta(fail_on="creative")
|
|
campaign = _campaign(
|
|
advanced={"page_id": "page_1", "image_path": "C:/tmp/creative.jpg"},
|
|
)
|
|
|
|
with pytest.raises(MetaError):
|
|
await sync_campaign(repo, meta, campaign, actor="owner")
|
|
|
|
repo2, meta2 = FakeRepo(), FakeMeta()
|
|
await sync_campaign(repo2, meta2, campaign, actor="owner")
|
|
|
|
# The image must not be uploaded a second time.
|
|
assert "image" not in meta2.steps()
|