feat(campaigns): add launch, pause and stop
Launch is the only action that starts real spend, so it refuses unless every precondition holds: the campaign is synced, its status allows launching, an identified operator approved it, and the ad account has a payment method. Each refusal names what to do rather than letting Meta fail opaquely later. An approval recorded as "unauthenticated" does not authorise spend. With MASKANX_OPERATOR_TOKENS unset every approval is unattributable, so launch is blocked until operator auth is configured. Stop and pause set the Meta object PAUSED before recording the local change, so a Meta failure cannot leave a campaign that is stopped in MaskanX but still delivering. Replaces the Phase 1 test asserting /launch did not exist with one asserting it is unreachable from draft; the guarded property is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ from ...campaigns.state import (
|
||||
from ...campaigns.sync import SyncConfigurationError, sync_campaign
|
||||
from ...campaigns.validation import validate_campaign
|
||||
from ...meta.client import MetaClient, MetaError, access_token_from_env
|
||||
from ._operator import require_operator
|
||||
from ._operator import UNAUTHENTICATED, require_operator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -272,6 +272,122 @@ async def reject_campaign(
|
||||
return await _transition(campaign_id, "reject", payload or ActorPayload())
|
||||
|
||||
|
||||
async def _assert_launchable(campaign: CampaignSpec) -> None:
|
||||
"""Refuse to launch unless every precondition for spending is met.
|
||||
|
||||
These are the last checks before a campaign can consume real budget, so
|
||||
each failure names what to do rather than letting Meta reject it later.
|
||||
"""
|
||||
if not campaign.meta_campaign_id:
|
||||
raise HTTPException(
|
||||
status_code=http_status.HTTP_409_CONFLICT,
|
||||
detail="This campaign has not been synced to Meta yet.",
|
||||
)
|
||||
|
||||
# An approval that cannot be attributed to a person is not an approval.
|
||||
if not campaign.approved_by or campaign.approved_by == UNAUTHENTICATED:
|
||||
raise HTTPException(
|
||||
status_code=http_status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
"This campaign was approved without an identified operator, "
|
||||
"so it cannot be launched. Set MASKANX_OPERATOR_TOKENS, then "
|
||||
"have an operator approve it again."
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
account = await get_meta_client().get_ad_account(campaign.ad_account_id)
|
||||
except MetaError as exc:
|
||||
raise _meta_http_error(exc) from exc
|
||||
|
||||
if not account.get("funding_source"):
|
||||
raise HTTPException(
|
||||
status_code=http_status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
"This ad account has no payment method, so Meta cannot "
|
||||
"deliver the campaign. Add one in Meta Business Manager; it "
|
||||
"cannot be set from MaskanX."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/launch", response_model=CampaignSpec)
|
||||
async def launch_campaign(
|
||||
campaign_id: str,
|
||||
operator: str = Depends(require_operator),
|
||||
) -> CampaignSpec:
|
||||
"""Set the campaign live on Meta. This starts spending."""
|
||||
campaign = await _load_or_404(campaign_id)
|
||||
if campaign.status not in ("synced", "paused"):
|
||||
raise HTTPException(
|
||||
status_code=http_status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
f"A campaign in status '{campaign.status}' cannot be "
|
||||
"launched. Sync it to Meta first."
|
||||
),
|
||||
)
|
||||
await _assert_launchable(campaign)
|
||||
|
||||
try:
|
||||
await get_meta_client().update_object_status(
|
||||
campaign.meta_campaign_id, "ACTIVE",
|
||||
)
|
||||
except MetaError as exc:
|
||||
raise _meta_http_error(exc) from exc
|
||||
|
||||
return await _transition(campaign_id, "launch", ActorPayload(actor=operator))
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/pause", response_model=CampaignSpec)
|
||||
async def pause_campaign(
|
||||
campaign_id: str,
|
||||
operator: str = Depends(require_operator),
|
||||
) -> CampaignSpec:
|
||||
"""Pause a live campaign on Meta, halting spend."""
|
||||
campaign = await _load_or_404(campaign_id)
|
||||
if campaign.status != "live":
|
||||
raise HTTPException(
|
||||
status_code=http_status.HTTP_409_CONFLICT,
|
||||
detail=f"Only a live campaign can be paused; this one is "
|
||||
f"'{campaign.status}'.",
|
||||
)
|
||||
if campaign.meta_campaign_id:
|
||||
try:
|
||||
await get_meta_client().update_object_status(
|
||||
campaign.meta_campaign_id, "PAUSED",
|
||||
)
|
||||
except MetaError as exc:
|
||||
raise _meta_http_error(exc) from exc
|
||||
|
||||
return await _transition(campaign_id, "pause", ActorPayload(actor=operator))
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/stop", response_model=CampaignSpec)
|
||||
async def stop_campaign(
|
||||
campaign_id: str,
|
||||
operator: str = Depends(require_operator),
|
||||
) -> CampaignSpec:
|
||||
"""Stop a campaign permanently, pausing it on Meta first."""
|
||||
campaign = await _load_or_404(campaign_id)
|
||||
if campaign.status not in ("approved", "synced", "live", "paused"):
|
||||
raise HTTPException(
|
||||
status_code=http_status.HTTP_409_CONFLICT,
|
||||
detail=f"A campaign in status '{campaign.status}' cannot be "
|
||||
f"stopped.",
|
||||
)
|
||||
# Pause on Meta before recording the stop, so a Meta failure cannot leave
|
||||
# a campaign that is stopped locally but still delivering.
|
||||
if campaign.meta_campaign_id:
|
||||
try:
|
||||
await get_meta_client().update_object_status(
|
||||
campaign.meta_campaign_id, "PAUSED",
|
||||
)
|
||||
except MetaError as exc:
|
||||
raise _meta_http_error(exc) from exc
|
||||
|
||||
return await _transition(campaign_id, "stop", ActorPayload(actor=operator))
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/sync", response_model=CampaignSpec)
|
||||
async def sync_campaign_to_meta(
|
||||
campaign_id: str,
|
||||
|
||||
@@ -131,10 +131,17 @@ def test_edit_after_approval_is_rejected(client):
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
def test_launch_endpoint_is_absent_in_phase_1(client):
|
||||
def test_launch_is_refused_on_a_freshly_created_campaign(client):
|
||||
"""Phase 2 added /launch, so it must be unreachable from `draft`.
|
||||
|
||||
This replaces the Phase 1 test that asserted the endpoint did not exist.
|
||||
The property being guarded is the same one: a campaign cannot start
|
||||
spending without passing through approval and sync first.
|
||||
"""
|
||||
created = client.post("/api/campaigns", json=_payload()).json()
|
||||
response = client.post(f"/api/campaigns/{created['id']}/launch")
|
||||
assert response.status_code == 404
|
||||
assert response.status_code == 409
|
||||
assert "cannot be launched" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_preview_returns_iframe_per_format(client):
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# -*- 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
|
||||
|
||||
|
||||
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]] = []
|
||||
|
||||
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):
|
||||
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_sets_campaign_active_on_meta(env):
|
||||
client, repo, meta = env
|
||||
repo.items["camp_1"] = _campaign()
|
||||
|
||||
response = client.post("/api/campaigns/camp_1/launch")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "live"
|
||||
assert meta.status_calls == [("meta_camp_1", "ACTIVE")]
|
||||
assert repo.events[-1]["event_type"] == "campaign.launch"
|
||||
|
||||
|
||||
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 == []
|
||||
Reference in New Issue
Block a user