Files
maskanx_cm_backend/tests/test_campaign_sync_live.py
T

130 lines
4.3 KiB
Python
Raw Normal View History

# -*- 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