Files
maskanx_cm_backend/tests/test_campaign_diagnostics.py
T
AFFAANhandClaude Opus 5 2479b94edf fix(envs): offer the two Meta keys the setup panel tells you to set
Running the new diagnostics against the live account returned "No ad
account is configured. Set META_ADS_ACCOUNT_ID in Settings >
Environments." That instruction was wrong: the Settings page renders
_KEY_REGISTRY, and neither META_ADS_ACCOUNT_ID nor META_PAGE_ID was in it,
so there was no field to type either one into. Arbitrary keys can be
stored, but only listed ones are shown, which left the operator holding an
instruction they could not follow anywhere but a terminal — the exact
thing the panel exists to avoid.

The test reads the remedy strings out of diagnostics.py and asserts every
env var they name is in the registry, so a future check that mentions a
new key cannot ship pointing at a page that does not offer it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:54:18 +05:30

365 lines
12 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_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."
)