Files
maskanx_cm_backend/tests/test_campaign_sync_live.py
AFFAANhandClaude Opus 5 4022c8a1f6 fix(meta): refuse an ad set with no audience instead of letting Meta reject it
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>
2026-08-03 17:48:34 +05:30

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