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>
259 lines
8.0 KiB
Python
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 == []
|