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:
AFFAANh
2026-08-03 11:37:35 +05:30
co-authored by Claude Opus 5
parent 48a3110beb
commit 8153edfce9
3 changed files with 342 additions and 3 deletions
+117 -1
View File
@@ -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,
+9 -2
View File
@@ -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):
+216
View File
@@ -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 == []