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>
203 lines
6.0 KiB
Python
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
|