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>
130 lines
4.3 KiB
Python
130 lines
4.3 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Sync one campaign into the real ad account, then delete it.
|
|
|
|
Opt-in: set MASKANX_LIVE_TESTS=1. Every object is created PAUSED, so the
|
|
test cannot spend money, and the objects are deleted in a finally block
|
|
so the account is left as it was found.
|
|
|
|
This exists because the unit tests all run against a fake transport. They
|
|
prove the code sends what we think it sends; only this proves Meta accepts
|
|
it and that what lands in the account is paused.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import uuid
|
|
|
|
import pytest
|
|
|
|
from adclaw.campaigns.models import CampaignSpec
|
|
from adclaw.campaigns.sync import sync_campaign, unsync_campaign
|
|
from adclaw.envs.store import load_envs_into_environ
|
|
from adclaw.meta.client import CREATE_STATUS, MetaClient, access_token_from_env
|
|
|
|
if sys.platform == "win32":
|
|
import asyncio
|
|
|
|
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
|
|
LIVE = os.environ.get("MASKANX_LIVE_TESTS") == "1"
|
|
AD_ACCOUNT_ENV = "MASKANX_LIVE_AD_ACCOUNT_ID"
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
not LIVE,
|
|
reason=(
|
|
"Live Meta tests are opt-in. Set MASKANX_LIVE_TESTS=1 and "
|
|
f"{AD_ACCOUNT_ENV}=act_... to run them. They create and delete "
|
|
"real (paused) objects in that ad account."
|
|
),
|
|
)
|
|
|
|
|
|
class _MemoryRepo:
|
|
"""Stands in for the database so the live test needs only Meta."""
|
|
|
|
def __init__(self):
|
|
self.events: list[str] = []
|
|
|
|
async def update_campaign_with_event(
|
|
self, spec, event_type, actor=None, reason=None, payload=None,
|
|
):
|
|
self.events.append(event_type)
|
|
return spec
|
|
|
|
|
|
def _live_campaign(ad_account_id: str) -> CampaignSpec:
|
|
return CampaignSpec(
|
|
id=f"live_{uuid.uuid4().hex[:12]}",
|
|
name=f"MaskanX live smoke {uuid.uuid4().hex[:6]}",
|
|
status="approved",
|
|
origin="maskanx",
|
|
objective="OUTCOME_TRAFFIC",
|
|
ad_account_id=ad_account_id,
|
|
approved_by="live-smoke-test",
|
|
# Comfortably above the account minimum; never spent, since the
|
|
# objects stay paused for their whole (short) life.
|
|
budget={"daily_budget": 50000},
|
|
targeting={"countries": ["IN"], "age_min": 25, "age_max": 55},
|
|
advanced={
|
|
"message": "MaskanX live smoke test. Paused; delete on sight.",
|
|
"headline": "MaskanX smoke test",
|
|
"link": "https://maskan.technology",
|
|
},
|
|
channels=["facebook"],
|
|
)
|
|
|
|
|
|
@pytest.fixture()
|
|
def live_account() -> str:
|
|
load_envs_into_environ()
|
|
account = (os.environ.get(AD_ACCOUNT_ENV) or "").strip()
|
|
if not account:
|
|
pytest.skip(f"{AD_ACCOUNT_ENV} is not set.")
|
|
return account
|
|
|
|
|
|
async def test_sync_creates_paused_objects_and_delete_removes_them(live_account):
|
|
meta = MetaClient(access_token=access_token_from_env())
|
|
campaign = _live_campaign(live_account)
|
|
|
|
synced = None
|
|
try:
|
|
synced = await sync_campaign(
|
|
_MemoryRepo(), meta, campaign, actor="live-smoke-test",
|
|
)
|
|
|
|
assert synced.sync_status == "synced"
|
|
assert synced.status == "synced"
|
|
assert synced.meta_campaign_id
|
|
|
|
state = synced.advanced["meta_sync"]
|
|
assert state["adset_id"] and state["creative_id"] and state["ad_id"]
|
|
|
|
# What actually landed in the account is what matters, so read it
|
|
# back from Graph rather than trusting the create responses.
|
|
for object_id in (
|
|
synced.meta_campaign_id,
|
|
state["adset_id"],
|
|
state["ad_id"],
|
|
):
|
|
live = await meta._get(f"/{object_id}", {"fields": "id,status"})
|
|
assert live["status"] == CREATE_STATUS, (
|
|
f"{object_id} is {live['status']}, not {CREATE_STATUS} — "
|
|
f"this object could be spending money."
|
|
)
|
|
finally:
|
|
if synced and synced.meta_campaign_id:
|
|
await unsync_campaign(meta, synced)
|
|
|
|
# Deleting the campaign cascades, so nothing from this test should be
|
|
# left anywhere in the account.
|
|
remaining = {c["id"] for c in await meta.list_campaigns(live_account)}
|
|
assert synced.meta_campaign_id not in remaining
|
|
|
|
ad_sets = {a["id"] for a in await meta.list_ad_sets(live_account)}
|
|
assert synced.advanced["meta_sync"]["adset_id"] not in ad_sets
|
|
|
|
ads = {a["id"] for a in await meta.list_ads(live_account)}
|
|
assert synced.advanced["meta_sync"]["ad_id"] not in ads
|