2026-08-03 11:37:35 +05:30
|
|
|
# -*- 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
|
2026-08-03 17:45:07 +05:30
|
|
|
from adclaw.meta.client import MetaError
|
2026-08-03 11:37:35 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
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]] = []
|
2026-08-03 17:45:07 +05:30
|
|
|
# Object id whose status update should fail, for partial-failure tests.
|
|
|
|
|
self.fail_on: str | None = None
|
2026-08-03 11:37:35 +05:30
|
|
|
|
|
|
|
|
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):
|
2026-08-03 17:45:07 +05:30
|
|
|
if object_id == self.fail_on:
|
|
|
|
|
raise MetaError("Meta refused the status change.", code=100)
|
2026-08-03 11:37:35 +05:30
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 17:45:07 +05:30
|
|
|
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.
|
|
|
|
|
"""
|
2026-08-03 11:37:35 +05:30
|
|
|
client, repo, meta = env
|
2026-08-03 17:45:07 +05:30
|
|
|
repo.items["camp_1"] = _campaign(
|
|
|
|
|
advanced={"meta_sync": {"adset_id": "meta_set_1", "ad_id": "meta_ad_1"}},
|
|
|
|
|
)
|
2026-08-03 11:37:35 +05:30
|
|
|
|
|
|
|
|
response = client.post("/api/campaigns/camp_1/launch")
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert response.json()["status"] == "live"
|
2026-08-03 17:45:07 +05:30
|
|
|
assert meta.status_calls == [
|
|
|
|
|
("meta_ad_1", "ACTIVE"),
|
|
|
|
|
("meta_set_1", "ACTIVE"),
|
|
|
|
|
("meta_camp_1", "ACTIVE"),
|
|
|
|
|
]
|
2026-08-03 11:37:35 +05:30
|
|
|
assert repo.events[-1]["event_type"] == "campaign.launch"
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 17:45:07 +05:30
|
|
|
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")]
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 11:37:35 +05:30
|
|
|
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 == []
|