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