2026-08-07 11:21:22 +05:30
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
"""Setup diagnostics: what would stop a launch, and what to do about it.
|
|
|
|
|
|
|
|
|
|
The point of these checks is that nobody should need a terminal to find
|
|
|
|
|
out why a campaign will not run, so the tests care as much about whether
|
|
|
|
|
each failure carries a usable remedy as about whether it is detected.
|
|
|
|
|
"""
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from adclaw.campaigns.diagnostics import FAILED, OK, run_diagnostics, run_smoke_test
|
|
|
|
|
from adclaw.meta.client import MetaError, MetaNotConfiguredError
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class FakeMeta:
|
|
|
|
|
def __init__(self, account=None, page=None, me=None):
|
|
|
|
|
self.account = account if account is not None else {
|
|
|
|
|
"id": "act_1",
|
|
|
|
|
"name": "Maskan-MetaAds",
|
|
|
|
|
"currency": "INR",
|
|
|
|
|
"account_status": 1,
|
|
|
|
|
"funding_source": "card_1",
|
|
|
|
|
"min_daily_budget": 9709,
|
|
|
|
|
}
|
|
|
|
|
self.page = page if page is not None else {"id": "page_1", "name": "Maskan"}
|
|
|
|
|
self.me = me or {"id": "sys_1", "name": "maskanXautomation"}
|
|
|
|
|
self.pages = [{"id": "page_1", "name": "Maskan Technologies"}]
|
|
|
|
|
self.fail_page = False
|
|
|
|
|
|
|
|
|
|
async def _get(self, path, params):
|
|
|
|
|
if path == "/me":
|
|
|
|
|
return self.me
|
|
|
|
|
if path == "/me/accounts":
|
|
|
|
|
return {"data": self.pages}
|
|
|
|
|
if self.fail_page:
|
|
|
|
|
raise MetaError("Unsupported get request.", code=100)
|
|
|
|
|
return self.page
|
|
|
|
|
|
|
|
|
|
async def get_ad_account(self, ad_account_id):
|
|
|
|
|
if isinstance(self.account, Exception):
|
|
|
|
|
raise self.account
|
|
|
|
|
return self.account
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _by_name(report, name):
|
|
|
|
|
return next(c for c in report["checks"] if c["name"] == name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture()
|
|
|
|
|
def configured(monkeypatch):
|
|
|
|
|
monkeypatch.setenv("META_ADS_ACCOUNT_ID", "act_1")
|
|
|
|
|
monkeypatch.setenv("META_PAGE_ID", "page_1")
|
|
|
|
|
monkeypatch.setenv("MASKANX_OPERATOR_TOKENS", "farman:secret")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_a_fully_configured_install_is_ready(configured):
|
|
|
|
|
report = await run_diagnostics(lambda: FakeMeta(), None)
|
|
|
|
|
|
|
|
|
|
assert report["ready"] is True
|
|
|
|
|
assert report["blocking"] == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_a_missing_token_stops_the_run_immediately(monkeypatch):
|
|
|
|
|
"""Every later check would fail for the same reason, hiding the cause."""
|
|
|
|
|
|
|
|
|
|
def no_token():
|
|
|
|
|
raise MetaNotConfiguredError("META_ADS_ACCESS_TOKEN is not set.")
|
|
|
|
|
|
|
|
|
|
report = await run_diagnostics(no_token, None)
|
|
|
|
|
|
|
|
|
|
assert report["ready"] is False
|
|
|
|
|
assert report["blocking"] == ["Meta access token"]
|
|
|
|
|
assert len(report["checks"]) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_a_rejected_token_says_to_generate_a_new_one(configured):
|
|
|
|
|
class RejectingMeta(FakeMeta):
|
|
|
|
|
async def _get(self, path, params):
|
|
|
|
|
raise MetaError("Invalid OAuth access token.", code=190)
|
|
|
|
|
|
|
|
|
|
report = await run_diagnostics(lambda: RejectingMeta(), None)
|
|
|
|
|
|
|
|
|
|
check = _by_name(report, "Token is valid")
|
|
|
|
|
assert check["status"] == FAILED
|
|
|
|
|
assert "Business Manager" in check["remedy"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_a_missing_payment_method_is_reported_with_where_to_fix_it(configured):
|
|
|
|
|
"""The most common reason a launch is refused, and the only one that
|
|
|
|
|
genuinely cannot be fixed from MaskanX."""
|
|
|
|
|
meta = FakeMeta()
|
|
|
|
|
del meta.account["funding_source"]
|
|
|
|
|
|
|
|
|
|
report = await run_diagnostics(lambda: meta, None)
|
|
|
|
|
|
|
|
|
|
check = _by_name(report, "Payment method")
|
|
|
|
|
assert check["status"] == FAILED
|
|
|
|
|
assert "Business Manager" in check["remedy"]
|
|
|
|
|
assert "only Launch is" in check["remedy"]
|
|
|
|
|
assert report["ready"] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_a_disabled_ad_account_is_caught(configured):
|
|
|
|
|
meta = FakeMeta()
|
|
|
|
|
meta.account["account_status"] = 2
|
|
|
|
|
|
|
|
|
|
report = await run_diagnostics(lambda: meta, None)
|
|
|
|
|
|
|
|
|
|
assert _by_name(report, "Account status")["status"] == FAILED
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_a_missing_page_suggests_the_ones_the_token_can_reach(monkeypatch):
|
|
|
|
|
"""Turns "set an id" into "use this one"."""
|
|
|
|
|
monkeypatch.setenv("META_ADS_ACCOUNT_ID", "act_1")
|
|
|
|
|
monkeypatch.delenv("META_PAGE_ID", raising=False)
|
|
|
|
|
monkeypatch.setenv("MASKANX_OPERATOR_TOKENS", "farman:secret")
|
|
|
|
|
|
|
|
|
|
report = await run_diagnostics(lambda: FakeMeta(), None)
|
|
|
|
|
|
|
|
|
|
check = _by_name(report, "Facebook Page")
|
|
|
|
|
assert check["status"] == FAILED
|
|
|
|
|
assert "Maskan Technologies (page_1)" in check["remedy"]
|
|
|
|
|
assert check["detail"]["available_pages"][0]["id"] == "page_1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_a_page_the_token_cannot_read_is_reported(configured):
|
|
|
|
|
meta = FakeMeta()
|
|
|
|
|
meta.fail_page = True
|
|
|
|
|
|
|
|
|
|
report = await run_diagnostics(lambda: meta, None)
|
|
|
|
|
|
|
|
|
|
assert _by_name(report, "Facebook Page")["status"] == FAILED
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_missing_operator_tokens_block_readiness(monkeypatch):
|
|
|
|
|
"""Not a Meta problem, but it blocks Launch just as firmly."""
|
|
|
|
|
monkeypatch.setenv("META_ADS_ACCOUNT_ID", "act_1")
|
|
|
|
|
monkeypatch.setenv("META_PAGE_ID", "page_1")
|
|
|
|
|
monkeypatch.delenv("MASKANX_OPERATOR_TOKENS", raising=False)
|
|
|
|
|
|
|
|
|
|
report = await run_diagnostics(lambda: FakeMeta(), None)
|
|
|
|
|
|
|
|
|
|
check = _by_name(report, "Operator identity")
|
|
|
|
|
assert check["status"] == FAILED
|
|
|
|
|
assert "does not authorise spend" in check["remedy"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_meta_error_detail_survives_into_the_report(configured):
|
|
|
|
|
"""The whole reason this exists: "Invalid parameter" alone helps nobody."""
|
|
|
|
|
meta = FakeMeta()
|
|
|
|
|
meta.account = MetaError(
|
|
|
|
|
"Invalid parameter",
|
|
|
|
|
code=100,
|
|
|
|
|
subcode=4834011,
|
|
|
|
|
user_title="Ad Account Not Ready",
|
|
|
|
|
user_message="This ad account has not completed setup.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
report = await run_diagnostics(lambda: meta, None)
|
|
|
|
|
|
|
|
|
|
check = _by_name(report, "Ad account")
|
|
|
|
|
assert "This ad account has not completed setup." in check["summary"]
|
|
|
|
|
assert check["detail"]["subcode"] == 4834011
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- the smoke test ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SmokeMeta:
|
|
|
|
|
"""Records the chain being built, and answers reads about it."""
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.deleted: list[str] = []
|
|
|
|
|
self.statuses = {}
|
|
|
|
|
self.fail_on: str | None = None
|
|
|
|
|
|
|
|
|
|
async def create_campaign(self, ad_account_id, **kwargs):
|
|
|
|
|
if self.fail_on == "campaign":
|
|
|
|
|
raise MetaError(
|
|
|
|
|
"Invalid parameter",
|
|
|
|
|
code=100,
|
|
|
|
|
subcode=4834011,
|
|
|
|
|
user_message="Special ad category is required.",
|
|
|
|
|
)
|
|
|
|
|
self.statuses["camp_1"] = "PAUSED"
|
|
|
|
|
return "camp_1"
|
|
|
|
|
|
|
|
|
|
async def create_ad_set(self, ad_account_id, **kwargs):
|
|
|
|
|
self.statuses["set_1"] = "PAUSED"
|
|
|
|
|
return "set_1"
|
|
|
|
|
|
|
|
|
|
async def create_ad_creative(self, ad_account_id, **kwargs):
|
|
|
|
|
return "creative_1"
|
|
|
|
|
|
|
|
|
|
async def create_ad(self, ad_account_id, **kwargs):
|
|
|
|
|
self.statuses["ad_1"] = "PAUSED"
|
|
|
|
|
return "ad_1"
|
|
|
|
|
|
|
|
|
|
async def _get(self, path, params):
|
|
|
|
|
return {"id": path.strip("/"), "status": self.statuses.get(path.strip("/"))}
|
|
|
|
|
|
|
|
|
|
async def delete_object(self, object_id):
|
|
|
|
|
self.deleted.append(object_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture()
|
|
|
|
|
def page(monkeypatch):
|
|
|
|
|
monkeypatch.setenv("META_PAGE_ID", "page_1")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_the_smoke_test_builds_verifies_and_deletes(page):
|
|
|
|
|
meta = SmokeMeta()
|
|
|
|
|
|
|
|
|
|
result = await run_smoke_test(meta, "act_1", actor="farman")
|
|
|
|
|
|
|
|
|
|
assert result["passed"] is True
|
|
|
|
|
assert meta.deleted == ["camp_1"]
|
|
|
|
|
steps = [s["step"] for s in result["steps"]]
|
|
|
|
|
assert steps == [
|
|
|
|
|
"create", "verify campaign", "verify ad set", "verify ad", "clean up",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_an_object_left_active_is_reported_as_a_failure(page):
|
|
|
|
|
"""The one thing this test exists to catch."""
|
|
|
|
|
meta = SmokeMeta()
|
|
|
|
|
|
|
|
|
|
class Leaky(SmokeMeta):
|
|
|
|
|
async def create_ad(self, ad_account_id, **kwargs):
|
|
|
|
|
self.statuses["ad_1"] = "ACTIVE"
|
|
|
|
|
return "ad_1"
|
|
|
|
|
|
|
|
|
|
result = await run_smoke_test(Leaky(), "act_1", actor="farman")
|
|
|
|
|
|
|
|
|
|
assert result["passed"] is False
|
|
|
|
|
assert any("could spend money" in str(s.get("detail")) for s in result["steps"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_a_failure_still_cleans_up_and_reports_metas_own_words(page):
|
|
|
|
|
meta = SmokeMeta()
|
|
|
|
|
meta.fail_on = "campaign"
|
|
|
|
|
|
|
|
|
|
result = await run_smoke_test(meta, "act_1", actor="farman")
|
|
|
|
|
|
|
|
|
|
assert result["passed"] is False
|
|
|
|
|
create = next(s for s in result["steps"] if s["step"] == "create")
|
|
|
|
|
assert "Special ad category is required." in create["detail"]
|
|
|
|
|
assert create["error"]["subcode"] == 4834011
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_leftovers_are_named_when_cleanup_fails(page):
|
|
|
|
|
"""Better to say what to delete than to abandon it silently."""
|
|
|
|
|
|
|
|
|
|
class StuckMeta(SmokeMeta):
|
|
|
|
|
async def delete_object(self, object_id):
|
|
|
|
|
raise MetaError("Permission denied", code=200)
|
|
|
|
|
|
|
|
|
|
result = await run_smoke_test(StuckMeta(), "act_1", actor="farman")
|
|
|
|
|
|
|
|
|
|
cleanup = next(s for s in result["steps"] if s["step"] == "clean up")
|
|
|
|
|
assert cleanup["status"] == FAILED
|
|
|
|
|
assert cleanup["leftover"]["campaign_id"] == "camp_1"
|
|
|
|
|
assert "not spending" in cleanup["detail"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- the HTTP routes ---
|
|
|
|
|
#
|
|
|
|
|
# The checks above call the functions directly, which says nothing about
|
|
|
|
|
# whether the endpoints are reachable. `/diagnostics` is a single path
|
|
|
|
|
# segment, so `/{campaign_id}` will answer it instead unless it is declared
|
|
|
|
|
# first — the same shadowing that `/discover` and `/adopt` already guard
|
|
|
|
|
# against. These tests fail if the declaration order is ever disturbed.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture()
|
|
|
|
|
def api_client(monkeypatch):
|
|
|
|
|
from fastapi import FastAPI
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
|
|
|
from adclaw.app.routers import campaigns as campaigns_router
|
|
|
|
|
|
|
|
|
|
async def fake_diagnostics(meta_factory, ad_account_id):
|
|
|
|
|
return {"ready": True, "blocking": [], "checks": [], "seen": ad_account_id}
|
|
|
|
|
|
|
|
|
|
async def fake_smoke_test(meta, ad_account_id, actor):
|
|
|
|
|
return {"passed": True, "summary": "ok", "steps": [], "seen": ad_account_id}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(campaigns_router, "run_diagnostics", fake_diagnostics)
|
|
|
|
|
monkeypatch.setattr(campaigns_router, "run_smoke_test", fake_smoke_test)
|
|
|
|
|
monkeypatch.setattr(campaigns_router, "get_meta_client", lambda: FakeMeta())
|
|
|
|
|
monkeypatch.delenv("MASKANX_OPERATOR_TOKENS", raising=False)
|
|
|
|
|
|
|
|
|
|
app = FastAPI()
|
|
|
|
|
app.include_router(campaigns_router.router, prefix="/api")
|
|
|
|
|
return TestClient(app)
|
|
|
|
|
|
|
|
|
|
|
2026-08-07 16:26:47 +05:30
|
|
|
def test_configured_account_endpoint_is_not_shadowed_by_the_id_route(
|
|
|
|
|
api_client, monkeypatch,
|
|
|
|
|
):
|
|
|
|
|
"""`/account` is a literal path, one segment past `/account/{id}`'s
|
|
|
|
|
prefix but a different route. It must resolve to the configured-account
|
|
|
|
|
handler, not be read as an account id."""
|
|
|
|
|
monkeypatch.setenv("META_ADS_ACCOUNT_ID", "act_1")
|
|
|
|
|
|
|
|
|
|
response = api_client.get("/api/campaigns/account")
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert response.json()["name"] == "Maskan-MetaAds"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_configured_account_says_so_when_none_is_set(api_client, monkeypatch):
|
|
|
|
|
"""Silently rendering nothing is what made this hard to diagnose."""
|
|
|
|
|
monkeypatch.delenv("META_ADS_ACCOUNT_ID", raising=False)
|
|
|
|
|
|
|
|
|
|
response = api_client.get("/api/campaigns/account")
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
assert "META_ADS_ACCOUNT_ID" in response.json()["detail"]
|
|
|
|
|
|
|
|
|
|
|
2026-08-07 11:21:22 +05:30
|
|
|
def test_diagnostics_endpoint_is_not_shadowed_by_the_id_route(api_client):
|
|
|
|
|
"""`/diagnostics` is a literal path and must not be read as a campaign id."""
|
|
|
|
|
response = api_client.get("/api/campaigns/diagnostics")
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert response.json()["ready"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_diagnostics_endpoint_passes_the_requested_ad_account(api_client):
|
|
|
|
|
response = api_client.get("/api/campaigns/diagnostics?ad_account_id=act_9")
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert response.json()["seen"] == "act_9"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_smoke_test_endpoint_is_reachable_and_uses_the_configured_account(
|
|
|
|
|
api_client, monkeypatch,
|
|
|
|
|
):
|
|
|
|
|
monkeypatch.setenv("META_ADS_ACCOUNT_ID", "act_1")
|
|
|
|
|
|
|
|
|
|
response = api_client.post("/api/campaigns/diagnostics/smoke-test")
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 200
|
|
|
|
|
assert response.json()["seen"] == "act_1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_smoke_test_refuses_when_no_ad_account_is_configured(
|
|
|
|
|
api_client, monkeypatch,
|
|
|
|
|
):
|
|
|
|
|
"""Better to say so than to let Meta reject an empty account id."""
|
|
|
|
|
monkeypatch.delenv("META_ADS_ACCOUNT_ID", raising=False)
|
|
|
|
|
|
|
|
|
|
response = api_client.post("/api/campaigns/diagnostics/smoke-test")
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 422
|
|
|
|
|
assert "META_ADS_ACCOUNT_ID" in response.json()["detail"]
|
2026-08-07 12:54:18 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- the remedies have to point somewhere real ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_every_env_var_a_remedy_names_is_offered_in_settings():
|
|
|
|
|
"""A remedy saying "set X in Settings > Environments" is a lie if X is
|
|
|
|
|
not in the key registry the Settings page renders.
|
|
|
|
|
|
|
|
|
|
Arbitrary keys can be stored, but only registry entries are shown, so
|
|
|
|
|
an unlisted key leaves the operator with an instruction and no field.
|
|
|
|
|
"""
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
from adclaw.app.routers.envs import _KEY_REGISTRY
|
|
|
|
|
from adclaw.campaigns import diagnostics
|
|
|
|
|
|
|
|
|
|
listed = {entry["key"] for entry in _KEY_REGISTRY}
|
|
|
|
|
source = (diagnostics.__file__ or "")
|
|
|
|
|
with open(source, encoding="utf-8") as handle:
|
|
|
|
|
text = handle.read()
|
|
|
|
|
|
|
|
|
|
# Env var names as they appear in the remedy strings.
|
|
|
|
|
named = set(re.findall(r"\b(META_[A-Z_]+|MASKANX_[A-Z_]+)\b", text))
|
|
|
|
|
named.discard("META_ADS_ACCOUNT_ID_ENV")
|
|
|
|
|
|
|
|
|
|
missing = sorted(named - listed)
|
|
|
|
|
assert not missing, (
|
|
|
|
|
f"Diagnostics tells the operator to set {missing} in Settings > "
|
|
|
|
|
f"Environments, but they are not in _KEY_REGISTRY so no field is "
|
|
|
|
|
f"shown for them."
|
|
|
|
|
)
|
2026-08-07 13:12:23 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- what blocks a launch is not what blocks a connection test ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_a_missing_payment_method_does_not_block_the_connection_test(
|
|
|
|
|
configured,
|
|
|
|
|
):
|
|
|
|
|
"""The smoke test creates PAUSED objects. Meta does not need a funding
|
|
|
|
|
source for that, and someone with an unfunded account is exactly who
|
|
|
|
|
needs to find out whether the connection works at all."""
|
|
|
|
|
meta = FakeMeta(account={
|
|
|
|
|
"id": "act_1", "name": "Maskan-MetaAds", "currency": "INR",
|
|
|
|
|
"account_status": 1, "funding_source": None, "min_daily_budget": 9709,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
report = await run_diagnostics(lambda: meta, None)
|
|
|
|
|
|
|
|
|
|
assert report["ready"] is False
|
|
|
|
|
assert "Payment method" in report["blocking"]
|
|
|
|
|
assert report["can_smoke_test"] is True
|
|
|
|
|
assert report["smoke_test_blocking"] == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_missing_operator_tokens_do_not_block_the_connection_test(
|
|
|
|
|
monkeypatch,
|
|
|
|
|
):
|
|
|
|
|
monkeypatch.setenv("META_ADS_ACCOUNT_ID", "act_1")
|
|
|
|
|
monkeypatch.setenv("META_PAGE_ID", "page_1")
|
|
|
|
|
monkeypatch.delenv("MASKANX_OPERATOR_TOKENS", raising=False)
|
|
|
|
|
|
|
|
|
|
report = await run_diagnostics(lambda: FakeMeta(), None)
|
|
|
|
|
|
|
|
|
|
assert "Operator identity" in report["blocking"]
|
|
|
|
|
assert report["can_smoke_test"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_an_unreachable_page_does_block_the_connection_test(configured):
|
|
|
|
|
"""Sync attaches the creative to a Page, so this one it genuinely needs."""
|
|
|
|
|
meta = FakeMeta()
|
|
|
|
|
meta.fail_page = True
|
|
|
|
|
|
|
|
|
|
report = await run_diagnostics(lambda: meta, None)
|
|
|
|
|
|
|
|
|
|
assert report["can_smoke_test"] is False
|
|
|
|
|
assert report["smoke_test_blocking"] == ["Facebook Page"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_no_token_blocks_the_connection_test(monkeypatch):
|
|
|
|
|
def no_token():
|
|
|
|
|
raise MetaNotConfiguredError("META_ADS_ACCESS_TOKEN is not set.")
|
|
|
|
|
|
|
|
|
|
report = await run_diagnostics(no_token, None)
|
|
|
|
|
|
|
|
|
|
assert report["can_smoke_test"] is False
|
|
|
|
|
assert report["smoke_test_blocking"] == ["Meta access token"]
|
2026-08-07 15:33:39 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- a half-built chain must not be abandoned ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_a_failure_after_the_campaign_exists_still_deletes_it(page):
|
|
|
|
|
"""The real failure mode, reproduced.
|
|
|
|
|
|
|
|
|
|
sync_campaign assigns meta_campaign_id on the spec before it creates
|
|
|
|
|
the ad set. When the ad set is rejected, `synced` is never bound and
|
|
|
|
|
`created` is never populated — so a cleanup keyed only on those two
|
|
|
|
|
leaves a live campaign sitting in the ad account. On the first real run
|
|
|
|
|
that is exactly what happened, and the reconciler then adopted the
|
|
|
|
|
orphan into the database.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
class AdSetRejects(SmokeMeta):
|
|
|
|
|
async def create_ad_set(self, ad_account_id, **kwargs):
|
|
|
|
|
raise MetaError(
|
|
|
|
|
"Invalid parameter",
|
|
|
|
|
code=100,
|
|
|
|
|
subcode=2490487,
|
|
|
|
|
user_message="Bid amount or bid constraints required.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
meta = AdSetRejects()
|
|
|
|
|
|
|
|
|
|
result = await run_smoke_test(meta, "act_1", actor="farman")
|
|
|
|
|
|
|
|
|
|
assert result["passed"] is False
|
|
|
|
|
# The campaign was created, so it must have been deleted again.
|
|
|
|
|
assert meta.deleted == ["camp_1"]
|
|
|
|
|
cleanup = next(s for s in result["steps"] if s["step"] == "clean up")
|
|
|
|
|
assert cleanup["status"] == OK
|
|
|
|
|
assert "camp_1" in cleanup["detail"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def test_nothing_is_deleted_when_the_campaign_was_never_created(page):
|
|
|
|
|
"""No campaign id means no cleanup call — not a delete of None."""
|
|
|
|
|
meta = SmokeMeta()
|
|
|
|
|
meta.fail_on = "campaign"
|
|
|
|
|
|
|
|
|
|
result = await run_smoke_test(meta, "act_1", actor="farman")
|
|
|
|
|
|
|
|
|
|
assert meta.deleted == []
|
|
|
|
|
assert not [s for s in result["steps"] if s["step"] == "clean up"]
|