Files
maskanx_cm_backend/tests/test_campaign_reconcile.py
AFFAANhandClaude Opus 5 2522701ec4 feat(campaigns): reconcile local records with Meta on a background loop
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>
2026-08-03 16:50:16 +05:30

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 == []