2026-08-03 17:39:05 +05:30
|
|
|
# -*- 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},
|
2026-08-03 17:48:34 +05:30
|
|
|
targeting={"countries": ["IN"], "age_min": 25, "age_max": 55},
|
2026-08-03 17:39:05 +05:30
|
|
|
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
|