Files
maskanx_cm_backend/tests/test_campaign_launch.py
AFFAANhandClaude Opus 5 9dba7b81d4 fix(campaigns): launch the whole object chain, not just the campaign
Launch set only the campaign to ACTIVE. Sync creates the ad set and ad
PAUSED, and Meta delivers only when the ad, its ad set and its campaign
are all active — so Launch reported "live" while nothing ran. This was
the central promise of Phase 2 and it did not work.

The chain is now activated children-first, campaign last. Nothing under a
paused campaign delivers, so a failure part-way leaves the campaign unable
to spend. That ordering is also why pause and stop only flip the campaign.

An imported campaign has no stored ad set or ad ids, so launching it still
touches only the campaign and its children keep the statuses set in Ads
Manager.

Deleting an imported campaign is now refused rather than silently
pointless: deleting it on Meta would destroy work MaskanX did not author,
and deleting only the local row achieved nothing because the reconciler
re-imported it on the next cycle. Deleting it in Ads Manager is what
sticks, after which the reconciler archives the record.

Also corrects the README's reconciler interval: it is 120s, not 300s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:45:07 +05:30

259 lines
8.0 KiB
Python

# -*- coding: utf-8 -*-
"""Launch, pause and stop.
Launch is the only action that starts real spend, so most of these tests are
about the guards that must refuse it.
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from adclaw.app.routers import campaigns as campaigns_router
from adclaw.campaigns.models import CampaignSpec
from adclaw.meta.client import MetaError
class FakeRepo:
def __init__(self):
self.items: dict[str, CampaignSpec] = {}
self.events: list[dict] = []
async def get_campaign(self, campaign_id):
return self.items.get(campaign_id)
async def update_campaign_with_event(
self, spec, event_type, actor=None, reason=None, payload=None,
):
self.items[spec.id] = spec
self.events.append({"event_type": event_type, "actor": actor})
return spec
class FakeMeta:
def __init__(self, funding="card_1"):
self.funding = funding
self.status_calls: list[tuple[str, str]] = []
# Object id whose status update should fail, for partial-failure tests.
self.fail_on: str | None = None
async def get_ad_account(self, ad_account_id):
account = {"id": ad_account_id, "min_daily_budget": 9709}
if self.funding is not None:
account["funding_source"] = self.funding
return account
async def update_object_status(self, object_id, status):
if object_id == self.fail_on:
raise MetaError("Meta refused the status change.", code=100)
self.status_calls.append((object_id, status))
def _campaign(**overrides) -> CampaignSpec:
data = {
"id": "camp_1",
"name": "Q3 lead gen",
"status": "synced",
"ad_account_id": "act_1",
"meta_campaign_id": "meta_camp_1",
"approved_by": "owner",
"budget": {"daily_budget": 10000},
}
data.update(overrides)
return CampaignSpec(**data)
@pytest.fixture()
def env(monkeypatch):
repo, meta = FakeRepo(), FakeMeta()
monkeypatch.setattr(campaigns_router, "get_repository", lambda: repo)
monkeypatch.setattr(campaigns_router, "get_meta_client", lambda: meta)
app = FastAPI()
app.include_router(campaigns_router.router, prefix="/api")
return TestClient(app), repo, meta
def test_launch_activates_the_whole_chain_campaign_last(env):
"""Meta only delivers when ad, ad set and campaign are all active.
The campaign goes last so a failure part-way leaves it paused, unable
to spend.
"""
client, repo, meta = env
repo.items["camp_1"] = _campaign(
advanced={"meta_sync": {"adset_id": "meta_set_1", "ad_id": "meta_ad_1"}},
)
response = client.post("/api/campaigns/camp_1/launch")
assert response.status_code == 200
assert response.json()["status"] == "live"
assert meta.status_calls == [
("meta_ad_1", "ACTIVE"),
("meta_set_1", "ACTIVE"),
("meta_camp_1", "ACTIVE"),
]
assert repo.events[-1]["event_type"] == "campaign.launch"
def test_launch_leaves_the_campaign_paused_when_a_child_fails(env):
"""A half-activated chain must not be able to spend."""
client, repo, meta = env
meta.fail_on = "meta_set_1"
repo.items["camp_1"] = _campaign(
advanced={"meta_sync": {"adset_id": "meta_set_1", "ad_id": "meta_ad_1"}},
)
response = client.post("/api/campaigns/camp_1/launch")
assert response.status_code >= 400
assert ("meta_camp_1", "ACTIVE") not in meta.status_calls
assert repo.items["camp_1"].status == "synced"
def test_launch_of_an_imported_campaign_only_touches_the_campaign(env):
"""Its ad sets and ads keep the status the operator set in Ads Manager."""
client, repo, meta = env
repo.items["camp_1"] = _campaign(origin="imported")
response = client.post("/api/campaigns/camp_1/launch")
assert response.status_code == 200
assert meta.status_calls == [("meta_camp_1", "ACTIVE")]
def test_launch_is_refused_without_a_payment_method(env):
"""The account cannot deliver, so refuse rather than let Meta fail."""
client, repo, meta = env
meta.funding = None
repo.items["camp_1"] = _campaign()
response = client.post("/api/campaigns/camp_1/launch")
assert response.status_code == 409
assert "Meta Business Manager" in response.json()["detail"]
assert meta.status_calls == []
def test_launch_is_refused_when_approval_was_not_attributable(env):
"""An approval nobody can be named for must not authorise spend."""
client, repo, meta = env
repo.items["camp_1"] = _campaign(approved_by="unauthenticated")
response = client.post("/api/campaigns/camp_1/launch")
assert response.status_code == 409
assert "identified operator" in response.json()["detail"]
assert meta.status_calls == []
def test_launch_is_refused_when_never_approved(env):
client, repo, meta = env
repo.items["camp_1"] = _campaign(approved_by=None)
response = client.post("/api/campaigns/camp_1/launch")
assert response.status_code == 409
assert meta.status_calls == []
@pytest.mark.parametrize("status", ["draft", "pending_approval", "approved", "stopped"])
def test_launch_is_refused_from_a_non_launchable_status(env, status):
client, repo, meta = env
repo.items["camp_1"] = _campaign(status=status)
response = client.post("/api/campaigns/camp_1/launch")
assert response.status_code == 409
assert meta.status_calls == []
def test_launch_is_refused_when_not_synced(env):
client, repo, meta = env
repo.items["camp_1"] = _campaign(meta_campaign_id=None)
response = client.post("/api/campaigns/camp_1/launch")
assert response.status_code == 409
assert "not been synced" in response.json()["detail"]
assert meta.status_calls == []
def test_launch_requires_a_valid_operator_token(env, monkeypatch):
monkeypatch.setenv("MASKANX_OPERATOR_TOKENS", "owner:s3cr3t")
client, repo, meta = env
repo.items["camp_1"] = _campaign()
response = client.post("/api/campaigns/camp_1/launch")
assert response.status_code == 401
assert meta.status_calls == []
def test_launch_records_the_operator_not_a_client_string(env, monkeypatch):
monkeypatch.setenv("MASKANX_OPERATOR_TOKENS", "owner:s3cr3t")
client, repo, meta = env
repo.items["camp_1"] = _campaign()
response = client.post(
"/api/campaigns/camp_1/launch",
headers={"X-MaskanX-Operator": "s3cr3t"},
)
assert response.status_code == 200
assert repo.events[-1]["actor"] == "owner"
def test_paused_campaign_can_be_relaunched(env):
client, repo, meta = env
repo.items["camp_1"] = _campaign(status="paused")
response = client.post("/api/campaigns/camp_1/launch")
assert response.status_code == 200
assert response.json()["status"] == "live"
def test_pause_halts_spend_on_meta(env):
client, repo, meta = env
repo.items["camp_1"] = _campaign(status="live")
response = client.post("/api/campaigns/camp_1/pause")
assert response.status_code == 200
assert response.json()["status"] == "paused"
assert meta.status_calls == [("meta_camp_1", "PAUSED")]
def test_pause_is_refused_when_not_live(env):
client, repo, meta = env
repo.items["camp_1"] = _campaign(status="synced")
response = client.post("/api/campaigns/camp_1/pause")
assert response.status_code == 409
assert meta.status_calls == []
def test_stop_pauses_on_meta_before_recording(env):
client, repo, meta = env
repo.items["camp_1"] = _campaign(status="live")
response = client.post("/api/campaigns/camp_1/stop")
assert response.status_code == 200
assert response.json()["status"] == "stopped"
# Meta must be paused, otherwise a campaign stopped locally would keep
# delivering.
assert meta.status_calls == [("meta_camp_1", "PAUSED")]
def test_stop_works_on_an_unsynced_campaign(env):
client, repo, meta = env
repo.items["camp_1"] = _campaign(status="approved", meta_campaign_id=None)
response = client.post("/api/campaigns/camp_1/stop")
assert response.status_code == 200
assert response.json()["status"] == "stopped"
assert meta.status_calls == []