Files
maskanx_cm_backend/tests/test_campaign_adopt.py
T
AFFAANhandClaude Opus 5 09aee8b621 feat(campaigns): discover and adopt campaigns created in Meta
Campaigns can be authored on either side. Anything MaskanX did not create is
origin="imported": MaskanX reports on it and can pause or stop it, but it was
built elsewhere.

Adoption is keyed on meta_campaign_id, which carries a unique index, so
adopting the same campaign twice returns the existing record instead of
violating the constraint. Discover excludes anything already adopted.

The literal /discover and /adopt routes are declared before /{campaign_id} so
they cannot be captured as campaign ids; a test pins that ordering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:34:32 +05:30

203 lines
6.0 KiB
Python

# -*- coding: utf-8 -*-
"""Discovering and adopting campaigns created directly in Meta Ads Manager."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from adclaw.app.routers import campaigns as campaigns_router
from adclaw.campaigns.adopt import (
adopt_campaign,
discover_campaigns,
local_status_for,
)
from adclaw.campaigns.models import CampaignSpec
class FakeRepo:
def __init__(self, campaigns=None):
self.items: dict[str, CampaignSpec] = {c.id: c for c in (campaigns or [])}
self.events: list[dict] = []
async def list_campaigns(self, company_id=None, status=None):
return list(self.items.values())
async def get_campaign(self, campaign_id):
return self.items.get(campaign_id)
async def create_campaign(self, spec):
self.items[spec.id] = spec
return spec
async def add_event(
self, campaign_id, event_type, actor=None, reason=None, payload=None,
):
self.events.append({"campaign_id": campaign_id, "event_type": event_type})
class FakeMeta:
def __init__(self, campaigns=None):
self.campaigns = campaigns if campaigns is not None else [
{"id": "meta_1", "name": "Remote one", "status": "ACTIVE"},
{"id": "meta_2", "name": "Remote two", "status": "PAUSED"},
]
self.list_calls = 0
async def list_campaigns(self, ad_account_id):
self.list_calls += 1
return self.campaigns
def _local(meta_id: str | None, **overrides) -> CampaignSpec:
data = {
"id": f"camp_{meta_id or 'local'}",
"name": "Local campaign",
"status": "draft",
"ad_account_id": "act_1",
"meta_campaign_id": meta_id,
}
data.update(overrides)
return CampaignSpec(**data)
@pytest.mark.parametrize(
("meta_status", "expected"),
[
("ACTIVE", "live"),
("PAUSED", "paused"),
("DELETED", "archived"),
("ARCHIVED", "archived"),
("active", "live"),
(None, "paused"),
("SOMETHING_NEW", "paused"),
],
)
def test_meta_status_maps_to_local_status(meta_status, expected):
assert local_status_for(meta_status) == expected
@pytest.mark.asyncio
async def test_discover_lists_only_unknown_campaigns():
repo = FakeRepo([_local("meta_1")])
meta = FakeMeta()
found = await discover_campaigns(repo, meta, "act_1")
assert [c["id"] for c in found] == ["meta_2"]
@pytest.mark.asyncio
async def test_discover_ignores_local_campaigns_with_no_meta_id():
repo = FakeRepo([_local(None)])
meta = FakeMeta()
found = await discover_campaigns(repo, meta, "act_1")
assert [c["id"] for c in found] == ["meta_1", "meta_2"]
@pytest.mark.asyncio
async def test_adopt_creates_an_imported_record():
repo, meta = FakeRepo(), FakeMeta()
created = await adopt_campaign(
repo, meta, ad_account_id="act_1", meta_campaign_id="meta_2", actor="owner",
)
assert created.origin == "imported"
assert created.meta_campaign_id == "meta_2"
assert created.sync_status == "synced"
assert created.status == "paused"
assert created.name == "Remote two"
assert repo.events[-1]["event_type"] == "campaign.adopted"
@pytest.mark.asyncio
async def test_adopting_twice_returns_the_existing_record():
"""The unique index on meta_campaign_id must never be violated."""
repo, meta = FakeRepo(), FakeMeta()
first = await adopt_campaign(
repo, meta, ad_account_id="act_1", meta_campaign_id="meta_1", actor="owner",
)
second = await adopt_campaign(
repo, meta, ad_account_id="act_1", meta_campaign_id="meta_1", actor="owner",
)
assert second.id == first.id
assert len(repo.items) == 1
# Only the first adoption writes an event.
assert len(repo.events) == 1
@pytest.mark.asyncio
async def test_adopting_an_unknown_campaign_raises_lookup_error():
repo, meta = FakeRepo(), FakeMeta()
with pytest.raises(LookupError):
await adopt_campaign(
repo, meta, ad_account_id="act_1", meta_campaign_id="nope", actor="owner",
)
@pytest.mark.asyncio
async def test_adopted_record_keeps_meta_provenance():
repo = FakeRepo()
meta = FakeMeta([
{
"id": "meta_9",
"name": "With detail",
"status": "ACTIVE",
"effective_status": "ACTIVE",
"created_time": "2026-07-01T10:00:00+0000",
},
])
created = await adopt_campaign(
repo, meta, ad_account_id="act_1", meta_campaign_id="meta_9", actor="owner",
)
provenance = created.advanced["imported_from_meta"]
assert provenance["effective_status"] == "ACTIVE"
assert provenance["created_time"] == "2026-07-01T10:00:00+0000"
@pytest.fixture()
def client(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")
test_client = TestClient(app)
test_client.repo = repo
return test_client
def test_discover_endpoint_is_not_shadowed_by_the_id_route(client):
"""`/discover` is a literal path and must not be read as a campaign id."""
response = client.get("/api/campaigns/discover?ad_account_id=act_1")
assert response.status_code == 200
assert [c["id"] for c in response.json()] == ["meta_1", "meta_2"]
def test_adopt_endpoint_creates_and_is_idempotent(client):
body = {"ad_account_id": "act_1", "meta_campaign_id": "meta_1"}
first = client.post("/api/campaigns/adopt", json=body)
second = client.post("/api/campaigns/adopt", json=body)
assert first.status_code == 201
assert first.json()["origin"] == "imported"
assert second.json()["id"] == first.json()["id"]
assert len(client.repo.items) == 1
def test_adopt_unknown_campaign_returns_404(client):
response = client.post(
"/api/campaigns/adopt",
json={"ad_account_id": "act_1", "meta_campaign_id": "missing"},
)
assert response.status_code == 404