Files
maskanx_cm_backend/tests/test_campaign_validation.py
T
AFFAANh 3a99f3e001 fix(meta): close review findings on write client and budget validation
Task 4 review: Approved with no Critical findings, but three Important
and two hardening items. All five addressed:

- objects.build_campaign_payload: normalise a bare string
  special_ad_categories value to a single-element list instead of
  exploding it into characters via list() - the field controls
  regulated-advertising compliance and advanced is unvalidated input.
- objects.build_ad_set_payload: validate optimization_goal and
  billing_event against new allowlists before forwarding them, since
  billing_event determines how the ad account is charged and both come
  from client-controlled advanced.
- validation.validate_budget: reject a lifetime-only budget (Meta's
  create_ad_set only accepts daily_budget) so it fails at validation
  time instead of passing approval and only failing at sync.
- client.py: create_campaign/create_ad_set/create_ad now send the
  CREATE_STATUS constant rather than the caller's status object, so a
  str subclass with a lying __ne__ can no longer slip "ACTIVE" past the
  guard.
- client._post: refuse any POST to a campaign/adset/ad creation path
  with a non-PAUSED status before touching the transport, as defence in
  depth if a future caller bypasses the typed create_* guards.

10 new tests (8 in test_meta_writes.py, 2 in test_campaign_validation.py).
2026-08-03 01:17:18 +05:30

101 lines
3.4 KiB
Python

# -*- coding: utf-8 -*-
"""Budget and guardrail validation rules."""
from adclaw.campaigns.validation import (
validate_budget,
validate_campaign,
validate_guardrails,
)
def test_daily_budget_below_account_minimum_is_rejected():
issues = validate_budget({"daily_budget": 5000}, min_daily_budget=9709)
assert [i.field for i in issues] == ["budget.daily_budget"]
assert "97.09" in issues[0].message
def test_daily_budget_at_minimum_is_accepted():
assert validate_budget({"daily_budget": 9709}, min_daily_budget=9709) == []
def test_missing_budget_is_rejected():
issues = validate_budget({}, min_daily_budget=9709)
assert [i.field for i in issues] == ["budget"]
def test_daily_and_lifetime_budget_together_is_rejected():
issues = validate_budget(
{"daily_budget": 10000, "lifetime_budget": 50000},
min_daily_budget=9709,
)
assert [i.field for i in issues] == ["budget"]
def test_lifetime_only_budget_is_rejected_as_unsupported():
# create_ad_set only accepts daily_budget (objects.build_ad_set_payload
# raises ValueError for a lifetime-only spec), so a lifetime-only
# budget must be caught here rather than passing validation/approval
# and only failing once sync tries to create the Meta ad set.
issues = validate_budget({"lifetime_budget": 50000}, min_daily_budget=9709)
assert [i.field for i in issues] == ["budget.lifetime_budget"]
assert "not supported yet" in issues[0].message
def test_lifetime_only_negative_budget_reports_both_issues():
issues = validate_budget({"lifetime_budget": -5}, min_daily_budget=9709)
fields = [i.field for i in issues]
assert fields == ["budget.lifetime_budget", "budget.lifetime_budget"]
messages = " ".join(i.message for i in issues)
assert "greater than zero" in messages
assert "not supported yet" in messages
def test_auto_pause_above_daily_budget_is_rejected():
issues = validate_guardrails(
{"daily_budget": 10000},
{"auto_pause_if_spend_reaches": 20000},
)
assert [i.field for i in issues] == ["guardrails.auto_pause_if_spend_reaches"]
def test_negative_guardrail_is_rejected():
issues = validate_guardrails({"daily_budget": 10000}, {"max_cost_per_lead": -1})
assert [i.field for i in issues] == ["guardrails.max_cost_per_lead"]
def test_valid_guardrails_pass():
assert validate_guardrails(
{"daily_budget": 10000},
{"auto_pause_if_spend_reaches": 9000, "max_cost_per_lead": 150},
) == []
def test_validate_campaign_aggregates_all_issues():
issues = validate_campaign(
{"daily_budget": 100},
{"max_cost_per_click": -5},
min_daily_budget=9709,
)
fields = {i.field for i in issues}
assert "budget.daily_budget" in fields
assert "guardrails.max_cost_per_click" in fields
def test_auto_pause_above_zero_daily_budget_is_rejected():
issues = validate_guardrails(
{"daily_budget": 0},
{"auto_pause_if_spend_reaches": 50},
)
assert [i.field for i in issues] == ["guardrails.auto_pause_if_spend_reaches"]
def test_negative_auto_pause_reports_single_issue():
issues = validate_guardrails(
{"daily_budget": 10000},
{"auto_pause_if_spend_reaches": -1},
)
assert [i.field for i in issues] == ["guardrails.auto_pause_if_spend_reaches"]
def test_no_account_minimum_accepts_any_positive_daily_budget():
assert validate_budget({"daily_budget": 100}, min_daily_budget=None) == []