Meta publishes no webhooks for campaign create or delete, so this polls every 120s by default (MASKANX_CAMPAIGN_RECONCILE_SECONDS; 0 disables). Meta is authoritative for delivery state; MaskanX keeps its own metadata. Guardrails, approvals and audit history are never touched by reconciliation, and campaigns still in a local-only status are skipped entirely so a draft that has never reached Meta cannot be overwritten. A campaign that disappears from Meta is archived, not deleted: its spend history has to stay reportable. The loop swallows and logs a failed cycle rather than dying, and is cancelled on shutdown alongside the watchdog. It only starts when META_ADS_ACCOUNT_ID is set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
174 lines
5.4 KiB
Python
174 lines
5.4 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Two-way reconciliation between local records and Meta."""
|
|
import pytest
|
|
|
|
from adclaw.campaigns.models import CampaignSpec
|
|
from adclaw.campaigns.reconcile import (
|
|
DEFAULT_INTERVAL_SECONDS,
|
|
reconcile_interval_seconds,
|
|
reconcile_once,
|
|
)
|
|
|
|
|
|
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 create_campaign(self, spec):
|
|
self.items[spec.id] = spec
|
|
return spec
|
|
|
|
async def update_campaign_with_event(
|
|
self, spec, event_type, actor=None, reason=None, payload=None,
|
|
):
|
|
self.items[spec.id] = spec
|
|
self.events.append({"id": spec.id, "event_type": event_type})
|
|
return spec
|
|
|
|
async def add_event(
|
|
self, campaign_id, event_type, actor=None, reason=None, payload=None,
|
|
):
|
|
self.events.append({"id": campaign_id, "event_type": event_type})
|
|
|
|
|
|
class FakeMeta:
|
|
def __init__(self, campaigns):
|
|
self.campaigns = campaigns
|
|
|
|
async def list_campaigns(self, ad_account_id):
|
|
return self.campaigns
|
|
|
|
|
|
def _local(**overrides) -> CampaignSpec:
|
|
data = {
|
|
"id": "camp_1",
|
|
"name": "Local",
|
|
"status": "live",
|
|
"origin": "maskanx",
|
|
"ad_account_id": "act_1",
|
|
"meta_campaign_id": "meta_1",
|
|
"guardrails": {"max_cost_per_lead": 150},
|
|
"approved_by": "owner",
|
|
}
|
|
data.update(overrides)
|
|
return CampaignSpec(**data)
|
|
|
|
|
|
def test_interval_defaults_when_unset(monkeypatch):
|
|
monkeypatch.delenv("MASKANX_CAMPAIGN_RECONCILE_SECONDS", raising=False)
|
|
assert reconcile_interval_seconds() == DEFAULT_INTERVAL_SECONDS
|
|
|
|
|
|
def test_interval_zero_disables_the_loop(monkeypatch):
|
|
monkeypatch.setenv("MASKANX_CAMPAIGN_RECONCILE_SECONDS", "0")
|
|
assert reconcile_interval_seconds() == 0
|
|
|
|
|
|
def test_interval_falls_back_on_a_bad_value(monkeypatch):
|
|
monkeypatch.setenv("MASKANX_CAMPAIGN_RECONCILE_SECONDS", "not-a-number")
|
|
assert reconcile_interval_seconds() == DEFAULT_INTERVAL_SECONDS
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_meta_campaign_is_imported():
|
|
repo = FakeRepo()
|
|
meta = FakeMeta([{"id": "meta_9", "name": "Remote", "status": "ACTIVE"}])
|
|
|
|
result = await reconcile_once(repo, meta, "act_1")
|
|
|
|
assert len(result.imported) == 1
|
|
imported = repo.items[result.imported[0]]
|
|
assert imported.origin == "imported"
|
|
assert imported.meta_campaign_id == "meta_9"
|
|
assert imported.status == "live"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_change_in_meta_updates_the_local_record():
|
|
repo = FakeRepo([_local(status="live")])
|
|
meta = FakeMeta([{"id": "meta_1", "name": "Local", "status": "PAUSED"}])
|
|
|
|
result = await reconcile_once(repo, meta, "act_1")
|
|
|
|
assert result.status_changed == ["camp_1"]
|
|
assert repo.items["camp_1"].status == "paused"
|
|
assert repo.events[-1]["event_type"] == "campaign.status_reconciled"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_campaign_deleted_in_meta_is_archived_not_removed():
|
|
"""Spend history must stay reportable, so the record survives."""
|
|
repo = FakeRepo([_local(status="live")])
|
|
meta = FakeMeta([])
|
|
|
|
result = await reconcile_once(repo, meta, "act_1")
|
|
|
|
assert result.archived == ["camp_1"]
|
|
assert "camp_1" in repo.items
|
|
assert repo.items["camp_1"].status == "archived"
|
|
assert repo.items["camp_1"].advanced["deleted_in_meta_at"]
|
|
assert repo.events[-1]["event_type"] == "campaign.deleted_in_meta"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_already_archived_campaign_is_not_rearchived():
|
|
repo = FakeRepo([_local(status="archived")])
|
|
meta = FakeMeta([])
|
|
|
|
result = await reconcile_once(repo, meta, "act_1")
|
|
|
|
assert result.archived == []
|
|
assert repo.events == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reconciliation_never_touches_maskanx_owned_metadata():
|
|
"""Meta owns delivery state; guardrails and approvals stay ours."""
|
|
repo = FakeRepo([_local(status="live")])
|
|
meta = FakeMeta([{"id": "meta_1", "name": "Renamed in Meta", "status": "PAUSED"}])
|
|
|
|
await reconcile_once(repo, meta, "act_1")
|
|
|
|
campaign = repo.items["camp_1"]
|
|
assert campaign.guardrails == {"max_cost_per_lead": 150}
|
|
assert campaign.approved_by == "owner"
|
|
|
|
|
|
@pytest.mark.parametrize("status", ["draft", "pending_approval", "approved"])
|
|
@pytest.mark.asyncio
|
|
async def test_local_only_statuses_are_left_alone(status):
|
|
"""A campaign that never reached Meta must not be reconciled."""
|
|
repo = FakeRepo([_local(status=status)])
|
|
meta = FakeMeta([{"id": "meta_1", "name": "Local", "status": "ACTIVE"}])
|
|
|
|
result = await reconcile_once(repo, meta, "act_1")
|
|
|
|
assert result.status_changed == []
|
|
assert repo.items["camp_1"].status == status
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_campaigns_from_other_ad_accounts_are_ignored():
|
|
repo = FakeRepo([_local(ad_account_id="act_other")])
|
|
meta = FakeMeta([])
|
|
|
|
result = await reconcile_once(repo, meta, "act_1")
|
|
|
|
assert result.archived == []
|
|
assert repo.items["camp_1"].status == "live"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_matching_status_produces_no_write():
|
|
repo = FakeRepo([_local(status="live")])
|
|
meta = FakeMeta([{"id": "meta_1", "name": "Local", "status": "ACTIVE"}])
|
|
|
|
result = await reconcile_once(repo, meta, "act_1")
|
|
|
|
assert result.changed is False
|
|
assert repo.events == []
|