# -*- coding: utf-8 -*- """Launch, pause and stop. Launch is the only action that starts real spend, so most of these tests are about the guards that must refuse it. """ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from adclaw.app.routers import campaigns as campaigns_router from adclaw.campaigns.models import CampaignSpec from adclaw.meta.client import MetaError class FakeRepo: def __init__(self): self.items: dict[str, CampaignSpec] = {} self.events: list[dict] = [] async def get_campaign(self, campaign_id): return self.items.get(campaign_id) async def update_campaign_with_event( self, spec, event_type, actor=None, reason=None, payload=None, ): self.items[spec.id] = spec self.events.append({"event_type": event_type, "actor": actor}) return spec class FakeMeta: def __init__(self, funding="card_1"): self.funding = funding self.status_calls: list[tuple[str, str]] = [] # Object id whose status update should fail, for partial-failure tests. self.fail_on: str | None = None async def get_ad_account(self, ad_account_id): account = {"id": ad_account_id, "min_daily_budget": 9709} if self.funding is not None: account["funding_source"] = self.funding return account async def update_object_status(self, object_id, status): if object_id == self.fail_on: raise MetaError("Meta refused the status change.", code=100) self.status_calls.append((object_id, status)) def _campaign(**overrides) -> CampaignSpec: data = { "id": "camp_1", "name": "Q3 lead gen", "status": "synced", "ad_account_id": "act_1", "meta_campaign_id": "meta_camp_1", "approved_by": "owner", "budget": {"daily_budget": 10000}, } data.update(overrides) return CampaignSpec(**data) @pytest.fixture() def env(monkeypatch): repo, meta = FakeRepo(), FakeMeta() monkeypatch.setattr(campaigns_router, "get_repository", lambda: repo) monkeypatch.setattr(campaigns_router, "get_meta_client", lambda: meta) app = FastAPI() app.include_router(campaigns_router.router, prefix="/api") return TestClient(app), repo, meta def test_launch_activates_the_whole_chain_campaign_last(env): """Meta only delivers when ad, ad set and campaign are all active. The campaign goes last so a failure part-way leaves it paused, unable to spend. """ client, repo, meta = env repo.items["camp_1"] = _campaign( advanced={"meta_sync": {"adset_id": "meta_set_1", "ad_id": "meta_ad_1"}}, ) response = client.post("/api/campaigns/camp_1/launch") assert response.status_code == 200 assert response.json()["status"] == "live" assert meta.status_calls == [ ("meta_ad_1", "ACTIVE"), ("meta_set_1", "ACTIVE"), ("meta_camp_1", "ACTIVE"), ] assert repo.events[-1]["event_type"] == "campaign.launch" def test_launch_leaves_the_campaign_paused_when_a_child_fails(env): """A half-activated chain must not be able to spend.""" client, repo, meta = env meta.fail_on = "meta_set_1" repo.items["camp_1"] = _campaign( advanced={"meta_sync": {"adset_id": "meta_set_1", "ad_id": "meta_ad_1"}}, ) response = client.post("/api/campaigns/camp_1/launch") assert response.status_code >= 400 assert ("meta_camp_1", "ACTIVE") not in meta.status_calls assert repo.items["camp_1"].status == "synced" def test_launch_of_an_imported_campaign_only_touches_the_campaign(env): """Its ad sets and ads keep the status the operator set in Ads Manager.""" client, repo, meta = env repo.items["camp_1"] = _campaign(origin="imported") response = client.post("/api/campaigns/camp_1/launch") assert response.status_code == 200 assert meta.status_calls == [("meta_camp_1", "ACTIVE")] def test_launch_is_refused_without_a_payment_method(env): """The account cannot deliver, so refuse rather than let Meta fail.""" client, repo, meta = env meta.funding = None repo.items["camp_1"] = _campaign() response = client.post("/api/campaigns/camp_1/launch") assert response.status_code == 409 assert "Meta Business Manager" in response.json()["detail"] assert meta.status_calls == [] def test_launch_is_refused_when_approval_was_not_attributable(env): """An approval nobody can be named for must not authorise spend.""" client, repo, meta = env repo.items["camp_1"] = _campaign(approved_by="unauthenticated") response = client.post("/api/campaigns/camp_1/launch") assert response.status_code == 409 assert "identified operator" in response.json()["detail"] assert meta.status_calls == [] def test_launch_is_refused_when_never_approved(env): client, repo, meta = env repo.items["camp_1"] = _campaign(approved_by=None) response = client.post("/api/campaigns/camp_1/launch") assert response.status_code == 409 assert meta.status_calls == [] @pytest.mark.parametrize("status", ["draft", "pending_approval", "approved", "stopped"]) def test_launch_is_refused_from_a_non_launchable_status(env, status): client, repo, meta = env repo.items["camp_1"] = _campaign(status=status) response = client.post("/api/campaigns/camp_1/launch") assert response.status_code == 409 assert meta.status_calls == [] def test_launch_is_refused_when_not_synced(env): client, repo, meta = env repo.items["camp_1"] = _campaign(meta_campaign_id=None) response = client.post("/api/campaigns/camp_1/launch") assert response.status_code == 409 assert "not been synced" in response.json()["detail"] assert meta.status_calls == [] def test_launch_requires_a_valid_operator_token(env, monkeypatch): monkeypatch.setenv("MASKANX_OPERATOR_TOKENS", "owner:s3cr3t") client, repo, meta = env repo.items["camp_1"] = _campaign() response = client.post("/api/campaigns/camp_1/launch") assert response.status_code == 401 assert meta.status_calls == [] def test_launch_records_the_operator_not_a_client_string(env, monkeypatch): monkeypatch.setenv("MASKANX_OPERATOR_TOKENS", "owner:s3cr3t") client, repo, meta = env repo.items["camp_1"] = _campaign() response = client.post( "/api/campaigns/camp_1/launch", headers={"X-MaskanX-Operator": "s3cr3t"}, ) assert response.status_code == 200 assert repo.events[-1]["actor"] == "owner" def test_paused_campaign_can_be_relaunched(env): client, repo, meta = env repo.items["camp_1"] = _campaign(status="paused") response = client.post("/api/campaigns/camp_1/launch") assert response.status_code == 200 assert response.json()["status"] == "live" def test_pause_halts_spend_on_meta(env): client, repo, meta = env repo.items["camp_1"] = _campaign(status="live") response = client.post("/api/campaigns/camp_1/pause") assert response.status_code == 200 assert response.json()["status"] == "paused" assert meta.status_calls == [("meta_camp_1", "PAUSED")] def test_pause_is_refused_when_not_live(env): client, repo, meta = env repo.items["camp_1"] = _campaign(status="synced") response = client.post("/api/campaigns/camp_1/pause") assert response.status_code == 409 assert meta.status_calls == [] def test_stop_pauses_on_meta_before_recording(env): client, repo, meta = env repo.items["camp_1"] = _campaign(status="live") response = client.post("/api/campaigns/camp_1/stop") assert response.status_code == 200 assert response.json()["status"] == "stopped" # Meta must be paused, otherwise a campaign stopped locally would keep # delivering. assert meta.status_calls == [("meta_camp_1", "PAUSED")] def test_stop_works_on_an_unsynced_campaign(env): client, repo, meta = env repo.items["camp_1"] = _campaign(status="approved", meta_campaign_id=None) response = client.post("/api/campaigns/camp_1/stop") assert response.status_code == 200 assert response.json()["status"] == "stopped" assert meta.status_calls == []