Files
maskanx_cm_backend/tests/test_campaign_budget_approval.py
AFFAANhandClaude Opus 5 6c39d42874 feat(campaigns): enforce budget guardrails automatically
Adds the insights reader, a pure guardrail evaluator, and the sweep that
applies them to live campaigns.

Three things carry the weight here:

Units. Meta reports spend in major units ("12.34") while budgets and
guardrails are in minor units (1234). normalise_row converts, rounding
half up rather than using round(), which rounds halves to even and can
record an exact half-unit of spend as nothing.

Leads. Meta has no leads field; leads live in the actions array, under
several action types depending on whether the lead came from a Facebook
form or a pixel. Cost per lead is computed from spend and leads over the
same window rather than read from cost_per_action_type, so the two can
never disagree.

Order. The campaign is paused on Meta before the local record changes. A
campaign recorded as paused but still delivering is the outcome this
exists to prevent. One campaign's failure never aborts the sweep, so a
rate limit on the third does not leave the fourth unguarded.

Cost rules are skipped until the first lead or click: no leads yet is not
an infinite cost per lead, and pausing for that would kill every campaign
in its first hour.

Deliberately a deterministic loop rather than a maskanx_cron_jobs entry.
That scheduler runs prompts through an agent, and asking a language model
whether a budget has been exceeded would make an arithmetic guarantee
probabilistic. Follows reconcile.py's lifespan-task pattern instead, and
warns every cycle if Meta is unconfigured — a safety system that cannot
run should be loud.

require_approval_for_budget_increase is enforced at the API layer, where
the budget is actually edited: raising it while a campaign awaits approval
returns 409, since it would change what the approver is reviewing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:04:25 +05:30

153 lines
4.5 KiB
Python

# -*- coding: utf-8 -*-
"""require_approval_for_budget_increase.
The hazard is narrow but real: an approver signs off on a daily budget, and
the number is raised before the campaign launches. The approval then names
a figure nobody agreed to.
"""
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(self, spec):
self.items[spec.id] = spec
return spec
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})
class FakeMeta:
async def get_ad_account(self, ad_account_id):
return {"min_daily_budget": 1000, "currency": "INR"}
def _campaign(**overrides) -> CampaignSpec:
data = {
"id": "camp_1",
"name": "Q3 lead gen",
"status": "pending_approval",
"ad_account_id": "act_1",
"budget": {"daily_budget": 50000},
"guardrails": {"require_approval_for_budget_increase": True},
}
data.update(overrides)
return CampaignSpec(**data)
@pytest.fixture()
def env(monkeypatch):
repo = FakeRepo()
monkeypatch.setattr(campaigns_router, "get_repository", lambda: repo)
monkeypatch.setattr(campaigns_router, "get_meta_client", lambda: FakeMeta())
app = FastAPI()
app.include_router(campaigns_router.router, prefix="/api")
return TestClient(app), repo
def _put(client, **budget):
return client.put(
"/api/campaigns/camp_1",
json={"name": "Q3 lead gen", "budget": budget},
)
def test_raising_the_budget_while_awaiting_approval_is_refused(env):
client, repo = env
repo.items["camp_1"] = _campaign()
response = _put(client, daily_budget=90000)
assert response.status_code == 409
assert "awaiting approval" in response.json()["detail"]
assert repo.items["camp_1"].budget["daily_budget"] == 50000
def test_the_message_names_both_figures(env):
client, repo = env
repo.items["camp_1"] = _campaign()
detail = _put(client, daily_budget=90000).json()["detail"]
assert "50000" in detail and "90000" in detail
def test_lowering_the_budget_is_allowed(env):
"""A smaller number needs no re-approval."""
client, repo = env
repo.items["camp_1"] = _campaign()
response = _put(client, daily_budget=20000)
assert response.status_code == 200
assert repo.items["camp_1"].budget["daily_budget"] == 20000
def test_an_unchanged_budget_is_allowed(env):
client, repo = env
repo.items["camp_1"] = _campaign()
assert _put(client, daily_budget=50000).status_code == 200
def test_a_draft_has_no_pending_review_to_invalidate(env):
client, repo = env
repo.items["camp_1"] = _campaign(status="draft")
response = _put(client, daily_budget=90000)
assert response.status_code == 200
assert repo.items["camp_1"].budget["daily_budget"] == 90000
def test_without_the_flag_the_budget_can_be_raised(env):
client, repo = env
repo.items["camp_1"] = _campaign(guardrails={})
assert _put(client, daily_budget=90000).status_code == 200
def test_the_lifetime_budget_is_gated_too(env):
client, repo = env
repo.items["camp_1"] = _campaign(budget={"lifetime_budget": 500000})
response = _put(client, lifetime_budget=900000)
assert response.status_code == 409
assert "lifetime_budget" in response.json()["detail"]
def test_a_budget_field_absent_before_is_not_treated_as_an_increase():
"""Setting a budget field for the first time is not a raise.
Checked directly rather than through PUT: setting both a daily and a
lifetime budget is separately rejected by validation, which would mask
what this is testing.
"""
campaign = _campaign(budget={"daily_budget": 50000})
# No exception: lifetime_budget was not previously set.
campaigns_router._assert_budget_increase_allowed(
campaign, {"daily_budget": 50000, "lifetime_budget": 900000},
)
def test_dropping_a_budget_field_from_the_update_is_not_an_increase():
"""A partial update that omits the budget must not read as a change."""
campaign = _campaign(budget={"daily_budget": 50000})
campaigns_router._assert_budget_increase_allowed(campaign, {})