diff --git a/docs/CAMPAIGN_MANAGEMENT_GUIDE.md b/docs/CAMPAIGN_MANAGEMENT_GUIDE.md index d7e5871..181e263 100644 --- a/docs/CAMPAIGN_MANAGEMENT_GUIDE.md +++ b/docs/CAMPAIGN_MANAGEMENT_GUIDE.md @@ -126,6 +126,33 @@ cd maskanx-backend; npm run local # :8088 cd maskanx-frontend; npm run dev ``` +### 1.6 Check the setup from the browser + +Open the **Campaigns** page. The **Setup** panel at the top runs every check +that can stop a launch and, for each failure, says what to do about it: + +| Check | What it means when it fails | +|---|---| +| Meta access token | `META_ADS_ACCESS_TOKEN` is missing — add it in Settings → Environments | +| Token is valid | Meta rejected the token; generate a new system user token | +| Ad account | `META_ADS_ACCOUNT_ID` is unset, or the system user cannot reach it | +| Account status | The ad account is disabled or unsettled — only fixable in Business Manager | +| Payment method | No funding source. Add one in Business Manager → Billing | +| Facebook Page | `META_PAGE_ID` is unset or unreachable. The panel lists the Pages your token *can* reach, so you can copy the right id | +| Operator identity | `MASKANX_OPERATOR_TOKENS` is unset, so approvals cannot be attributed and launch stays blocked | + +The panel is read-only — it creates nothing in Meta. Press **Re-check** after +changing an environment variable. + +Once every check is green, **Run connection test** becomes available. It +builds a real campaign, ad set and ad in your account, reads back from Meta +that all three are `PAUSED`, then deletes them. It is the only check that +proves Meta accepts what MaskanX sends. Nothing can be spent — the objects +are paused for their whole, few-second life — and if deletion ever fails, +the ids are shown on screen so you can remove them in Ads Manager. + +Nobody needs a terminal for any of this. + --- ## Part 2 — Your first campaign @@ -310,6 +337,12 @@ Worth knowing, because the design gives up some convenience for it: ## Verifying it end to end +**If you are running the system, use the browser.** Campaigns page → +**Setup** → **Run connection test** does everything described below, reports +each step on screen, and needs no terminal. See §1.6. + +The rest of this section is for developers working on the code. + The unit suite runs entirely against a fake transport: it proves the code sends what we think it sends, not that Meta accepts it. One opt-in test closes that gap by syncing a real campaign, reading back from Graph that diff --git a/src/adclaw/app/routers/campaigns.py b/src/adclaw/app/routers/campaigns.py index 85c7dfc..a3d67c5 100644 --- a/src/adclaw/app/routers/campaigns.py +++ b/src/adclaw/app/routers/campaigns.py @@ -7,6 +7,7 @@ Phase 1 owns campaign records, the approval workflow and read-only Meta calls from __future__ import annotations import logging +import os import uuid from datetime import date, timedelta from typing import Any @@ -29,6 +30,7 @@ from ...campaigns.sync import ( unsync_campaign, ) from ...campaigns.analytics import dashboard as build_dashboard, summarise +from ...campaigns.diagnostics import run_diagnostics, run_smoke_test from ...campaigns.insights_repo import InsightsRepository from ...campaigns.insights_sync import ( SUPPORTED_BREAKDOWNS, @@ -93,13 +95,15 @@ class PreviewResponse(BaseModel): def _meta_http_error(exc: MetaError) -> HTTPException: + """Pass Meta's failure on in full. + + `as_dict` carries `error_user_msg` and `error_user_title` as well as + the codes, so the UI can show the sentence Meta wrote for a person + instead of the bare "Invalid parameter" that its `message` often is. + """ return HTTPException( status_code=http_status.HTTP_502_BAD_GATEWAY, - detail={ - "message": exc.message, - "code": exc.code, - "subcode": exc.subcode, - }, + detail=exc.as_dict(), ) @@ -262,6 +266,44 @@ async def get_ad_account(ad_account_id: str) -> dict[str, Any]: raise _meta_http_error(exc) from exc +@router.get("/diagnostics") +async def campaign_diagnostics(ad_account_id: str | None = None) -> dict[str, Any]: + """Report whether this install can run a campaign, and what is blocking it. + + Read-only: it creates nothing in Meta. This is what the Campaigns page + calls so setup can be checked without a terminal. + """ + return await run_diagnostics(get_meta_client, ad_account_id) + + +@router.post("/diagnostics/smoke-test") +async def campaign_smoke_test( + ad_account_id: str | None = None, + operator: str = Depends(require_operator), +) -> dict[str, Any]: + """Create a real, paused campaign chain in Meta, then delete it. + + This is the only check that proves Meta accepts what MaskanX sends — + everything else is either read-only or runs against a fake transport. + It exists as an endpoint rather than only a test so the person setting + the system up never needs a terminal. + + Every object is created PAUSED, so it cannot spend, and cleanup runs + whether or not the run succeeded. If cleanup itself fails, the ids are + returned so the objects can be removed by hand rather than silently + left behind. + """ + account = ( + ad_account_id or os.environ.get("META_ADS_ACCOUNT_ID") or "" + ).strip() + if not account: + raise HTTPException( + status_code=http_status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="No ad account is configured. Set META_ADS_ACCOUNT_ID.", + ) + return await run_smoke_test(get_meta_client(), account, actor=operator) + + @router.get("/{campaign_id}", response_model=CampaignSpec) async def get_campaign(campaign_id: str) -> CampaignSpec: return await _load_or_404(campaign_id) diff --git a/src/adclaw/campaigns/diagnostics.py b/src/adclaw/campaigns/diagnostics.py new file mode 100644 index 0000000..dcaedaa --- /dev/null +++ b/src/adclaw/campaigns/diagnostics.py @@ -0,0 +1,379 @@ +# -*- coding: utf-8 -*- +"""Check whether this install can actually run a campaign, and say why not. + +Everything here answers one question: if the operator pressed Launch right +now, what would stop it? Each check reports a state and, when it fails, +what to do about it — because the person reading this is not going to open +a terminal, and a red cross with no sentence next to it is useless to them. + +Checks are ordered by dependency and stop at the first hard failure: with +no token, every later check would fail for the same reason, and five +identical errors hide the one that matters. +""" +from __future__ import annotations + +import logging +import os +from dataclasses import asdict, dataclass, field +from typing import Any + +from ..meta.client import MetaError, MetaNotConfiguredError +from .sync import PAGE_ID_ENV + +logger = logging.getLogger(__name__) + +OK = "ok" +WARNING = "warning" +FAILED = "failed" +SKIPPED = "skipped" + + +@dataclass +class Check: + name: str + status: str + summary: str + # What to do about it. Empty when the check passed. + remedy: str = "" + detail: dict[str, Any] = field(default_factory=dict) + + +def _check(name: str, status: str, summary: str, remedy: str = "", **detail) -> Check: + return Check(name=name, status=status, summary=summary, remedy=remedy, detail=detail) + + +async def run_diagnostics(meta_factory, ad_account_id: str | None) -> dict[str, Any]: + """Run every readiness check. Makes only read calls to Meta.""" + checks: list[Check] = [] + + # 1. Access token. + try: + meta = meta_factory() + except MetaNotConfiguredError as exc: + checks.append( + _check( + "Meta access token", + FAILED, + str(exc), + "Add META_ADS_ACCESS_TOKEN in Settings > Environments.", + ), + ) + return _summarise(checks) + checks.append(_check("Meta access token", OK, "A token is configured.")) + + # 2. The token reaches Meta at all, and we can name who it belongs to. + try: + me = await meta._get("/me", {"fields": "id,name"}) + checks.append( + _check( + "Token is valid", + OK, + f"Authenticated as {me.get('name') or me.get('id')}.", + meta_user_id=me.get("id"), + ), + ) + except MetaError as exc: + checks.append( + _check( + "Token is valid", + FAILED, + exc.describe(), + "The token is rejected by Meta. Generate a new system user " + "token in Business Manager and save it in Settings > " + "Environments.", + **exc.as_dict(), + ), + ) + return _summarise(checks) + + # 3. Which ad account to use. + account_id = (ad_account_id or os.environ.get("META_ADS_ACCOUNT_ID") or "").strip() + if not account_id: + checks.append( + _check( + "Ad account", + FAILED, + "No ad account is configured.", + "Set META_ADS_ACCOUNT_ID (it looks like act_1234567890) in " + "Settings > Environments.", + ), + ) + return _summarise(checks) + + try: + account = await meta.get_ad_account(account_id) + except MetaError as exc: + checks.append( + _check( + "Ad account", + FAILED, + exc.describe(), + f"The token cannot read {account_id}. Check the id, and that " + f"the system user has access to this ad account.", + **exc.as_dict(), + ), + ) + return _summarise(checks) + + checks.append( + _check( + "Ad account", + OK, + f"{account.get('name') or account_id} ({account.get('currency')}).", + ad_account_id=account_id, + currency=account.get("currency"), + min_daily_budget=account.get("min_daily_budget"), + ), + ) + + # 4. Account status. 1 is active; anything else will refuse delivery. + status_code = account.get("account_status") + if status_code == 1: + checks.append(_check("Account status", OK, "Active.")) + else: + checks.append( + _check( + "Account status", + FAILED, + f"Meta reports account_status {status_code}, not 1 (active).", + "The ad account is disabled or unsettled. Open Meta Business " + "Manager to see why; this cannot be fixed from MaskanX.", + account_status=status_code, + ), + ) + + # 5. Payment method. The single most common reason a launch is refused, + # and the only one that genuinely cannot be fixed from here. + if account.get("funding_source"): + checks.append(_check("Payment method", OK, "A funding source is attached.")) + else: + checks.append( + _check( + "Payment method", + FAILED, + "No payment method is attached to this ad account.", + "Add one in Meta Business Manager > Billing > Payment " + "settings. The Marketing API cannot create payment methods, " + "so this step has to happen there. Campaigns can still be " + "built, approved and synced meanwhile — only Launch is " + "blocked.", + ), + ) + + # 6. Facebook Page. Sync refuses without it, before creating anything. + page_id = (os.environ.get(PAGE_ID_ENV) or "").strip() + if not page_id: + pages = [] + try: + result = await meta._get("/me/accounts", {"fields": "id,name", "limit": 25}) + pages = result.get("data") or [] + except MetaError: + # Not fatal: the operator can still set the id by hand, and + # failing the whole run over a suggestion would be unhelpful. + logger.debug("Could not list Pages while diagnosing", exc_info=True) + # Naming the Pages the token can already reach turns "set an id" + # into "use this one", which is the difference between the operator + # finishing setup and going to look for it. + names = ", ".join( + f"{p.get('name')} ({p.get('id')})" for p in pages if p.get("id") + ) + suggestion = f" The token can reach: {names}." if names else "" + checks.append( + _check( + "Facebook Page", + FAILED, + "No Facebook Page is configured.", + "Set META_PAGE_ID in Settings > Environments. Ads are " + "published from a Page, and sync refuses to start without " + "one." + suggestion, + available_pages=[ + {"id": p.get("id"), "name": p.get("name")} for p in pages + ], + ), + ) + else: + try: + page = await meta._get(f"/{page_id}", {"fields": "id,name"}) + checks.append( + _check( + "Facebook Page", + OK, + f"{page.get('name') or page_id}.", + page_id=page_id, + ), + ) + except MetaError as exc: + checks.append( + _check( + "Facebook Page", + FAILED, + exc.describe(), + f"META_PAGE_ID is set to {page_id}, but the token cannot " + f"read it. Check the id, and that the system user has " + f"access to the Page.", + **exc.as_dict(), + ), + ) + + # 7. Operator tokens. Not a Meta question, but it blocks Launch just as + # firmly, so it belongs in the same list. + if (os.environ.get("MASKANX_OPERATOR_TOKENS") or "").strip(): + checks.append( + _check("Operator identity", OK, "Operator tokens are configured."), + ) + else: + checks.append( + _check( + "Operator identity", + FAILED, + "No operator tokens are configured, so approvals cannot be " + "attributed to anyone.", + "Set MASKANX_OPERATOR_TOKENS to name:token pairs in " + "Settings > Environments, then enter the token on the " + "Campaigns page. An approval nobody can be named for does " + "not authorise spend, so Launch stays blocked without it.", + ), + ) + + return _summarise(checks) + + +def _summarise(checks: list[Check]) -> dict[str, Any]: + failed = [c for c in checks if c.status == FAILED] + return { + "ready": not failed, + "blocking": [c.name for c in failed], + "checks": [asdict(c) for c in checks], + } + + +async def run_smoke_test(meta, ad_account_id: str, actor: str) -> dict[str, Any]: + """Build the full Meta object chain, verify it is paused, then delete it. + + The unit suite runs against a fake transport: it proves MaskanX sends + what we think it sends, not that Meta accepts it. This is the only + check that closes that gap, which is why it is worth creating real + objects for. + + Nothing here can spend money — every object is created PAUSED and the + campaign is deleted immediately. Cleanup runs even when the run fails, + and if cleanup itself fails the ids come back in the response so the + leftovers can be removed by hand instead of being silently abandoned in + a real ad account. + """ + from datetime import datetime, timezone + + from ..meta.client import CREATE_STATUS + from .models import CampaignSpec + from .sync import sync_campaign, unsync_campaign + + class _MemoryRepo: + """Stands in for the database: the smoke test must not persist.""" + + async def update_campaign_with_event( + self, spec, event_type, actor=None, reason=None, payload=None, + ): + return spec + + stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + campaign = CampaignSpec( + id=f"smoketest_{stamp}", + name=f"MaskanX connection test {stamp}", + status="approved", + origin="maskanx", + objective="OUTCOME_TRAFFIC", + ad_account_id=ad_account_id, + approved_by=actor, + # Comfortably above any account minimum. Never spent: the objects + # are paused for their whole, very short life. + budget={"daily_budget": 50000}, + targeting={"countries": ["IN"], "age_min": 25, "age_max": 55}, + advanced={ + "message": "MaskanX connection test. Paused, and deleted immediately.", + "headline": "MaskanX connection test", + "link": "https://maskan.technology", + }, + channels=["facebook"], + ) + + created: dict[str, Any] = {} + steps: list[dict[str, Any]] = [] + synced = None + try: + synced = await sync_campaign(_MemoryRepo(), meta, campaign, actor=actor) + state = synced.advanced.get("meta_sync") or {} + created = { + "campaign_id": synced.meta_campaign_id, + "adset_id": state.get("adset_id"), + "creative_id": state.get("creative_id"), + "ad_id": state.get("ad_id"), + } + steps.append({"step": "create", "status": OK, "detail": created}) + + # Read back from Graph rather than trusting the create responses: + # what actually landed in the account is what matters. + for label, object_id in ( + ("campaign", synced.meta_campaign_id), + ("ad set", state.get("adset_id")), + ("ad", state.get("ad_id")), + ): + if not object_id: + continue + live = await meta._get(f"/{object_id}", {"fields": "id,status"}) + if live.get("status") != CREATE_STATUS: + steps.append({ + "step": f"verify {label}", + "status": FAILED, + "detail": ( + f"{object_id} is {live.get('status')}, not " + f"{CREATE_STATUS} — this object could spend money." + ), + }) + else: + steps.append({ + "step": f"verify {label}", + "status": OK, + "detail": f"{object_id} is paused.", + }) + except MetaError as exc: + steps.append({ + "step": "create", + "status": FAILED, + "detail": exc.describe(), + "error": exc.as_dict(), + }) + except Exception as exc: + steps.append({"step": "create", "status": FAILED, "detail": str(exc)}) + finally: + target = (synced.meta_campaign_id if synced else None) or created.get( + "campaign_id", + ) + if target: + try: + await unsync_campaign(meta, synced or campaign) + steps.append({ + "step": "clean up", + "status": OK, + "detail": f"Deleted {target} and everything under it.", + }) + except Exception as exc: + steps.append({ + "step": "clean up", + "status": FAILED, + "detail": ( + f"Could not delete {target}: {exc}. Delete it in Ads " + f"Manager — it is paused, so it is not spending." + ), + "leftover": created, + }) + + failed = [s for s in steps if s["status"] == FAILED] + return { + "passed": bool(steps) and not failed, + "summary": ( + "MaskanX can create and delete campaigns in this ad account." + if steps and not failed + else "The connection test did not complete. See the steps below." + ), + "steps": steps, + } diff --git a/src/adclaw/campaigns/sync.py b/src/adclaw/campaigns/sync.py index 0898e8f..90c79b3 100644 --- a/src/adclaw/campaigns/sync.py +++ b/src/adclaw/campaigns/sync.py @@ -54,12 +54,14 @@ def _resolve_page_id(campaign: CampaignSpec) -> str: def _meta_error_text(exc: MetaError) -> str: - parts = [exc.message] - if exc.code is not None: - parts.append(f"code={exc.code}") - if exc.subcode is not None: - parts.append(f"subcode={exc.subcode}") - return " ".join(parts) + """The stored sync_error, which is what the operator ends up reading. + + Delegates to `MetaError.describe`, which prefers the explanation Meta + wrote for people over its own terse `message` — Graph answers a whole + class of rejections with the literal string "Invalid parameter", and + storing that alone tells nobody what to fix. + """ + return exc.describe() async def sync_campaign(repo, meta, campaign: CampaignSpec, actor: str) -> CampaignSpec: diff --git a/src/adclaw/meta/client.py b/src/adclaw/meta/client.py index 736a842..4a3e262 100644 --- a/src/adclaw/meta/client.py +++ b/src/adclaw/meta/client.py @@ -81,18 +81,82 @@ Transport = Callable[[str, str, dict[str, Any]], Awaitable[dict[str, Any]]] class MetaError(Exception): - """A Graph API error, preserving Meta's own code and message.""" + """A Graph API error, preserving everything Meta said about it. + + `message` is usually near-useless on its own — Graph answers a whole + class of rejections with the literal string "Invalid parameter" and + puts the actual explanation in `error_user_title` / `error_user_msg`, + which are written for people to read. Capturing only `message` throws + away the one field that says what to fix, so all of them are kept and + `describe()` prefers the readable ones. + + `fbtrace_id` is what Meta support asks for when nothing else explains + a failure, so it is worth carrying even though it means nothing to us. + """ def __init__( self, message: str, code: int | None = None, subcode: int | None = None, + user_title: str | None = None, + user_message: str | None = None, + error_data: Any = None, + fbtrace_id: str | None = None, ) -> None: super().__init__(message) self.message = message self.code = code self.subcode = subcode + self.user_title = user_title + self.user_message = user_message + self.error_data = error_data + self.fbtrace_id = fbtrace_id + + @classmethod + def from_payload(cls, error: dict[str, Any]) -> MetaError: + """Build from Graph's `error` object, keeping every explanatory field.""" + return cls( + error.get("message") or "Meta request failed.", + code=error.get("code"), + subcode=error.get("error_subcode"), + user_title=error.get("error_user_title"), + user_message=error.get("error_user_msg"), + error_data=error.get("error_data"), + fbtrace_id=error.get("fbtrace_id"), + ) + + def describe(self) -> str: + """The most useful single line Meta gave us. + + Prefers what Meta wrote for a person over its own terse + `message`, and always names the codes so a subcode can be looked + up when even the readable text is vague. + """ + headline = self.user_message or self.message + if self.user_title and self.user_title not in headline: + headline = f"{self.user_title}: {headline}" + codes = [] + if self.code is not None: + codes.append(f"code={self.code}") + if self.subcode is not None: + codes.append(f"subcode={self.subcode}") + if self.fbtrace_id: + codes.append(f"trace={self.fbtrace_id}") + return f"{headline} ({', '.join(codes)})" if codes else headline + + def as_dict(self) -> dict[str, Any]: + """Everything Meta said, for an API response or a log.""" + return { + "message": self.message, + "code": self.code, + "subcode": self.subcode, + "user_title": self.user_title, + "user_message": self.user_message, + "error_data": self.error_data, + "fbtrace_id": self.fbtrace_id, + "description": self.describe(), + } class MetaNotConfiguredError(MetaError): @@ -158,11 +222,7 @@ class MetaClient: result = await self._transport("GET", f"{GRAPH_BASE_URL}{path}", payload) error = result.get("error") if isinstance(result, dict) else None if error: - raise MetaError( - error.get("message") or "Meta request failed.", - code=error.get("code"), - subcode=error.get("error_subcode"), - ) + raise MetaError.from_payload(error) return result async def _post(self, path: str, data: dict[str, Any]) -> dict[str, Any]: @@ -192,11 +252,7 @@ class MetaClient: result = await self._transport("POST", f"{GRAPH_BASE_URL}{path}", payload) error = result.get("error") if isinstance(result, dict) else None if error: - raise MetaError( - error.get("message") or "Meta request failed.", - code=error.get("code"), - subcode=error.get("error_subcode"), - ) + raise MetaError.from_payload(error) return result async def _delete(self, path: str) -> dict[str, Any]: @@ -209,11 +265,7 @@ class MetaClient: result = await self._transport("DELETE", f"{GRAPH_BASE_URL}{path}", payload) error = result.get("error") if isinstance(result, dict) else None if error: - raise MetaError( - error.get("message") or "Meta request failed.", - code=error.get("code"), - subcode=error.get("error_subcode"), - ) + raise MetaError.from_payload(error) return result async def get_ad_account(self, ad_account_id: str) -> dict[str, Any]: diff --git a/tests/test_campaign_diagnostics.py b/tests/test_campaign_diagnostics.py new file mode 100644 index 0000000..47302b7 --- /dev/null +++ b/tests/test_campaign_diagnostics.py @@ -0,0 +1,332 @@ +# -*- 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"]