Files
maskanx_cm_backend/tests/test_campaign_guardrails.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

179 lines
4.4 KiB
Python

# -*- coding: utf-8 -*-
"""Guardrail evaluation.
These rules are the last automatic defence against a campaign spending more
than intended, so the tests are mostly about the ways they could fail to
fire — or fire when they should not.
"""
import pytest
from adclaw.campaigns.guardrails import evaluate
def _rules(**overrides):
rules = {"stop_loss_enabled": True}
rules.update(overrides)
return rules
def test_no_rules_means_no_action():
assert evaluate({}, today_spend=999999, lifetime_spend=999999) is None
def test_daily_spend_at_the_limit_pauses():
"""'reaches' is inclusive: at the limit is at the limit."""
breach = evaluate(
_rules(auto_pause_if_spend_reaches=45000),
today_spend=45000,
lifetime_spend=45000,
)
assert breach is not None
assert breach.action == "pause"
assert breach.rule == "auto_pause_if_spend_reaches"
assert breach.observed == 45000
def test_daily_spend_below_the_limit_does_nothing():
assert (
evaluate(
_rules(auto_pause_if_spend_reaches=45000),
today_spend=44999,
lifetime_spend=44999,
)
is None
)
def test_lifetime_cap_stops_rather_than_pauses():
breach = evaluate(
_rules(max_campaign_spend=500000),
today_spend=1000,
lifetime_spend=500000,
)
assert breach.action == "stop"
assert breach.rule == "max_campaign_spend"
def test_lifetime_cap_needs_stop_loss_enabled():
"""The switch exists so the permanent rule can be turned off alone."""
assert (
evaluate(
_rules(max_campaign_spend=500000, stop_loss_enabled=False),
today_spend=1000,
lifetime_spend=999999,
)
is None
)
def test_disabling_stop_loss_leaves_the_pause_rules_working():
breach = evaluate(
_rules(
max_campaign_spend=500000,
auto_pause_if_spend_reaches=45000,
stop_loss_enabled=False,
),
today_spend=45000,
lifetime_spend=999999,
)
assert breach.action == "pause"
def test_the_most_severe_breach_wins():
"""Blowing both caps should stop the campaign, not merely pause it."""
breach = evaluate(
_rules(max_campaign_spend=500000, auto_pause_if_spend_reaches=1000),
today_spend=90000,
lifetime_spend=600000,
)
assert breach.action == "stop"
def test_cost_per_lead_over_the_limit_pauses():
breach = evaluate(
_rules(max_cost_per_lead=15000),
today_spend=60000,
lifetime_spend=60000,
cost_per_lead=20000,
)
assert breach.rule == "max_cost_per_lead"
assert breach.action == "pause"
def test_cost_rules_are_skipped_before_the_first_lead():
"""No leads yet is not an infinite cost per lead."""
assert (
evaluate(
_rules(max_cost_per_lead=100),
today_spend=60000,
lifetime_spend=60000,
cost_per_lead=None,
)
is None
)
def test_cost_per_lead_exactly_at_the_limit_is_allowed():
"""'max' is a ceiling that may be reached, unlike 'reaches'."""
assert (
evaluate(
_rules(max_cost_per_lead=15000),
today_spend=15000,
lifetime_spend=15000,
cost_per_lead=15000,
)
is None
)
def test_cost_per_click_over_the_limit_pauses():
breach = evaluate(
_rules(max_cost_per_click=2000),
today_spend=60000,
lifetime_spend=60000,
cost_per_click=2001,
)
assert breach.rule == "max_cost_per_click"
def test_a_limit_of_zero_is_honoured_not_read_as_unset():
"""Someone who caps spend at zero means it."""
breach = evaluate(
_rules(auto_pause_if_spend_reaches=0),
today_spend=0,
lifetime_spend=0,
)
assert breach is not None
assert breach.limit == 0
@pytest.mark.parametrize("value", [None, "not a number", True, False])
def test_an_unusable_limit_is_treated_as_absent(value):
assert (
evaluate(
_rules(auto_pause_if_spend_reaches=value),
today_spend=999999,
lifetime_spend=999999,
)
is None
)
def test_the_reason_names_the_rule_and_both_numbers():
breach = evaluate(
_rules(auto_pause_if_spend_reaches=45000),
today_spend=46000,
lifetime_spend=46000,
)
assert "auto_pause_if_spend_reaches" in breach.reason
assert "46000" in breach.reason
assert "45000" in breach.reason