Files
maskanx_cm_backend/tests/test_campaign_diagnostics.py
AFFAANhandClaude Opus 5 267a0592dc fix(campaigns): stop the billing panel from silently disappearing
VITE_META_AD_ACCOUNT_ID has no default and no .env ships with the repo, so
on a fresh checkout it is empty and the Ad account card on the Campaigns
page just does not render — no error, no placeholder, nothing on screen
saying why the balance and spend are missing.

GET /campaigns/account returns whichever account META_ADS_ACCOUNT_ID names
on the backend, which was already required for every other campaign
feature to work at all. The frontend now asks for that instead of carrying
its own copy of the same setting, so the two cannot disagree silently.
Kept /campaigns/account/{id} for an explicit lookup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:26:47 +05:30

491 lines
16 KiB
Python

# -*- 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)
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"]
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"]
# --- 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."
)
# --- 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"]
# --- 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"]