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>
259 lines
9.0 KiB
Python
259 lines
9.0 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Campaign API behaviour with a faked repository and Meta client."""
|
|
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 list_campaigns(self, company_id=None, status=None):
|
|
return list(self.items.values())
|
|
|
|
async def get_campaign(self, campaign_id):
|
|
return self.items.get(campaign_id)
|
|
|
|
async def create_campaign(self, spec):
|
|
self.items[spec.id] = spec
|
|
return spec
|
|
|
|
async def update_campaign(self, spec):
|
|
self.items[spec.id] = spec
|
|
return spec
|
|
|
|
async def update_campaign_with_event(self, spec, event_type, actor=None,
|
|
reason=None, payload=None):
|
|
if spec.id not in self.items:
|
|
raise LookupError(f"Campaign {spec.id} does not exist")
|
|
self.items[spec.id] = spec
|
|
self.events.append({"campaign_id": spec.id, "event_type": event_type})
|
|
return spec
|
|
|
|
async def delete_campaign(self, campaign_id):
|
|
return self.items.pop(campaign_id, None) is not None
|
|
|
|
async def add_event(self, campaign_id, event_type, actor=None,
|
|
reason=None, payload=None):
|
|
self.events.append({"campaign_id": campaign_id, "event_type": event_type})
|
|
|
|
async def list_events(self, campaign_id):
|
|
return [e for e in self.events if e["campaign_id"] == campaign_id]
|
|
|
|
|
|
class FakeMeta:
|
|
def __init__(self, account=None, previews=None):
|
|
self.account = account or {"min_daily_budget": 9709, "currency": "INR"}
|
|
self.previews = previews or {"DESKTOP_FEED_STANDARD": "<iframe></iframe>"}
|
|
|
|
async def get_ad_account(self, ad_account_id):
|
|
return self.account
|
|
|
|
async def generate_previews(self, ad_account_id, creative, ad_formats=None):
|
|
return self.previews
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(monkeypatch):
|
|
repo = FakeRepo()
|
|
meta = 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")
|
|
test_client = TestClient(app)
|
|
test_client.repo = repo
|
|
return test_client
|
|
|
|
|
|
def _payload(**overrides):
|
|
payload = {
|
|
"name": "Lead gen",
|
|
"objective": "OUTCOME_LEADS",
|
|
"ad_account_id": "act_1",
|
|
"budget": {"daily_budget": 10000},
|
|
"guardrails": {"max_cost_per_lead": 150},
|
|
}
|
|
payload.update(overrides)
|
|
return payload
|
|
|
|
|
|
def test_create_campaign_starts_as_draft(client):
|
|
response = client.post("/api/campaigns", json=_payload())
|
|
assert response.status_code == 201
|
|
body = response.json()
|
|
assert body["status"] == "draft"
|
|
assert body["origin"] == "maskanx"
|
|
|
|
|
|
def test_create_campaign_rejects_budget_below_account_minimum(client):
|
|
response = client.post("/api/campaigns", json=_payload(budget={"daily_budget": 100}))
|
|
assert response.status_code == 422
|
|
assert "minimum" in response.json()["detail"][0]["message"]
|
|
|
|
|
|
def test_submit_then_approve_moves_through_states(client):
|
|
created = client.post("/api/campaigns", json=_payload()).json()
|
|
cid = created["id"]
|
|
|
|
submitted = client.post(f"/api/campaigns/{cid}/submit")
|
|
assert submitted.json()["status"] == "pending_approval"
|
|
|
|
# `actor` in the body is a forged-identity attempt: approval must be
|
|
# attributable to the resolved operator, never to a client-supplied
|
|
# string. With MASKANX_OPERATOR_TOKENS unset (as in this test), the
|
|
# operator dependency resolves to "unauthenticated" rather than trusting
|
|
# the body.
|
|
approved = client.post(f"/api/campaigns/{cid}/approve", json={"actor": "owner"})
|
|
assert approved.json()["status"] == "approved"
|
|
assert approved.json()["approved_by"] == "unauthenticated"
|
|
|
|
|
|
def test_approve_from_draft_is_rejected(client):
|
|
created = client.post("/api/campaigns", json=_payload()).json()
|
|
response = client.post(f"/api/campaigns/{created['id']}/approve", json={})
|
|
assert response.status_code == 409
|
|
|
|
|
|
def test_edit_after_approval_is_rejected(client):
|
|
created = client.post("/api/campaigns", json=_payload()).json()
|
|
cid = created["id"]
|
|
client.post(f"/api/campaigns/{cid}/submit")
|
|
client.post(f"/api/campaigns/{cid}/approve", json={})
|
|
|
|
response = client.put(f"/api/campaigns/{cid}", json=_payload(name="Changed"))
|
|
assert response.status_code == 409
|
|
|
|
|
|
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 == 409
|
|
assert "cannot be launched" in response.json()["detail"]
|
|
|
|
|
|
def test_preview_returns_iframe_per_format(client):
|
|
response = client.post(
|
|
"/api/campaigns/preview",
|
|
json={
|
|
"ad_account_id": "act_1",
|
|
"creative": {"object_story_spec": {}},
|
|
"ad_formats": ["DESKTOP_FEED_STANDARD"],
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["previews"]["DESKTOP_FEED_STANDARD"] == "<iframe></iframe>"
|
|
|
|
|
|
def test_account_endpoint_returns_billing_fields(client):
|
|
response = client.get("/api/campaigns/account/act_1")
|
|
assert response.status_code == 200
|
|
assert response.json()["min_daily_budget"] == 9709
|
|
|
|
|
|
def test_update_cannot_bypass_minimum_by_omitting_account(client):
|
|
created = client.post(
|
|
"/api/campaigns",
|
|
json=_payload(ad_account_id="act_1", budget={"daily_budget": 10000}),
|
|
).json()
|
|
cid = created["id"]
|
|
|
|
response = client.put(
|
|
f"/api/campaigns/{cid}",
|
|
json={"name": "Cheap", "budget": {"daily_budget": 1}},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_update_preserves_unset_fields(client):
|
|
created = client.post(
|
|
"/api/campaigns",
|
|
json=_payload(targeting={"geo": "IN"}, objective="OUTCOME_LEADS"),
|
|
).json()
|
|
cid = created["id"]
|
|
|
|
response = client.put(f"/api/campaigns/{cid}", json={"name": "Changed"})
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["name"] == "Changed"
|
|
assert body["targeting"] == {"geo": "IN"}
|
|
assert body["objective"] == "OUTCOME_LEADS"
|
|
|
|
|
|
def test_create_succeeds_when_meta_account_lookup_fails(client, monkeypatch):
|
|
class FailingMeta(FakeMeta):
|
|
async def get_ad_account(self, ad_account_id):
|
|
raise MetaError("boom")
|
|
|
|
monkeypatch.setattr(campaigns_router, "get_meta_client", lambda: FailingMeta())
|
|
response = client.post("/api/campaigns", json=_payload())
|
|
assert response.status_code == 201
|
|
|
|
|
|
def test_get_ad_account_meta_error_surfaces_code_and_subcode(client, monkeypatch):
|
|
class FailingMeta(FakeMeta):
|
|
async def get_ad_account(self, ad_account_id):
|
|
raise MetaError("boom", code=100, subcode=33)
|
|
|
|
monkeypatch.setattr(campaigns_router, "get_meta_client", lambda: FailingMeta())
|
|
response = client.get("/api/campaigns/account/act_1")
|
|
assert response.status_code == 502
|
|
detail = response.json()["detail"]
|
|
assert detail["message"] == "boom"
|
|
assert detail["code"] == 100
|
|
assert detail["subcode"] == 33
|
|
|
|
|
|
def test_update_missing_campaign_returns_404(client, monkeypatch):
|
|
created = client.post("/api/campaigns", json=_payload()).json()
|
|
cid = created["id"]
|
|
|
|
async def raise_lookup(spec):
|
|
raise LookupError("gone")
|
|
|
|
monkeypatch.setattr(client.repo, "update_campaign", raise_lookup)
|
|
response = client.put(f"/api/campaigns/{cid}", json={"name": "Changed"})
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_submit_missing_campaign_returns_404(client, monkeypatch):
|
|
created = client.post("/api/campaigns", json=_payload()).json()
|
|
cid = created["id"]
|
|
|
|
async def raise_lookup(spec, event_type, actor=None, reason=None, payload=None):
|
|
raise LookupError("gone")
|
|
|
|
monkeypatch.setattr(client.repo, "update_campaign_with_event", raise_lookup)
|
|
response = client.post(f"/api/campaigns/{cid}/submit")
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_reject_moves_pending_approval_to_draft(client):
|
|
created = client.post("/api/campaigns", json=_payload()).json()
|
|
cid = created["id"]
|
|
client.post(f"/api/campaigns/{cid}/submit")
|
|
|
|
rejected = client.post(
|
|
f"/api/campaigns/{cid}/reject",
|
|
json={"reason": "needs changes"},
|
|
)
|
|
assert rejected.status_code == 200
|
|
assert rejected.json()["status"] == "draft"
|
|
|
|
|
|
def test_get_unknown_campaign_returns_404(client):
|
|
response = client.get("/api/campaigns/does-not-exist")
|
|
assert response.status_code == 404
|