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).
101 lines
3.4 KiB
Python
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) == []
|