Files
maskanx_cm_backend/tests/test_campaign_crm_sync.py
T
AFFAANhandClaude Opus 5 b4007ce825 feat(campaigns): push leads and campaign figures into Maskan CRM
Server to server over the CRM's integration API, deliberately not through
MCP. MCP is for an agent deciding to do something; leads have to reach the
CRM on a schedule whether or not anyone is talking to the agent, and a
lead that arrives only when someone asks for it arrives too late.

Leads are re-sent for a trailing window on every run rather than tracked
as new-since-last-time. Meta's lead id is the external id, so the CRM
ignores one it already has — which makes a half-failed run heal itself on
the next cycle with no bookkeeping.

Meta returns form answers as a list under names the form's author chose,
so mapping is best-effort against aliases: full_name or first/last, phone
or phone_number or mobile. Unrecognised answers are kept in metadata
rather than dropped, and a lead with a phone but no name still gets
through — the CRM requires a first name, and losing a real enquiry to
satisfy a validator would be the wrong trade.

Campaign figures come from stored insights, not Meta: this runs every few
minutes and re-reading Meta would spend the quota on numbers that change
hourly. They go through an upsert rather than the idempotent create used
for leads, because a campaign's figures change every time they are read.

One care point, learnt the hard way and now commented and tested: stored
metrics are already in minor units, so summing them with the raw-row
normaliser reports spend a hundredfold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:55:34 +05:30

284 lines
8.5 KiB
Python

# -*- coding: utf-8 -*-
"""Pushing leads and campaign figures into Maskan CRM.
The properties worth protecting: every lead in the window is re-sent so a
half-failed run heals itself, a lead without Meta's id is never sent (it
would duplicate on every run), campaign figures come from stored insights
rather than Meta, and one campaign failing does not stop the rest.
"""
import pytest
from adclaw.campaigns.crm_sync import (
DEFAULT_INTERVAL_SECONDS,
crm_sync_interval_seconds,
lead_window_days,
push_campaign_leads,
push_campaign_metrics,
sync_to_crm,
)
from adclaw.campaigns.models import CampaignSpec
class FakeCRM:
def __init__(self):
self.leads: list[tuple[dict, str]] = []
self.campaigns: list[dict] = []
self.fail_on_lead: str | None = None
def create_lead(self, payload, idempotency_key):
if payload.get("external_id") == self.fail_on_lead:
raise RuntimeError("CRM rejected the lead")
self.leads.append((payload, idempotency_key))
return {"lead_id": "crm_1", "created": True}
def push_campaign(self, payload):
self.campaigns.append(payload)
return {"campaign_id": "crm_camp_1", "created": True}
class FakeMeta:
def __init__(self, leads=None):
self.leads = leads if leads is not None else []
self.calls: list[dict] = []
async def get_leads(self, object_id, *, since=None, limit=200):
self.calls.append({"object_id": object_id, "since": since})
return self.leads
class FakeInsightsRepo:
"""Returns totals the way the real repository does: already normalised.
The real `totals_between` sums in SQL over stored rows, whose metrics
were normalised on the way in. Returning raw Meta-shaped values here
would let a double-conversion bug pass unnoticed.
"""
def __init__(self, totals=None):
self.totals = totals if totals is not None else {}
async def totals_between(self, since, until, campaign_id=None):
return dict(self.totals)
class FakeRepo:
def __init__(self, campaigns=()):
self.items = list(campaigns)
async def list_campaigns(self, company_id=None, status=None):
return list(self.items)
def _campaign(**overrides) -> CampaignSpec:
data = {
"id": "camp_1",
"name": "Q3 lead gen",
"status": "live",
"objective": "OUTCOME_LEADS",
"ad_account_id": "act_1",
"meta_campaign_id": "meta_camp_1",
"budget": {"daily_budget": 50000, "currency": "INR"},
"channels": ["facebook"],
}
data.update(overrides)
return CampaignSpec(**data)
def _meta_lead(lead_id="lead_1", **overrides):
lead = {
"id": lead_id,
"campaign_id": "meta_camp_1",
"adset_id": "meta_set_1",
"ad_id": "meta_ad_1",
"field_data": [
{"name": "full_name", "values": ["Asha Menon"]},
{"name": "email", "values": ["asha@example.com"]},
],
}
lead.update(overrides)
return lead
# --- configuration ---
def test_the_interval_defaults_to_five_minutes(monkeypatch):
monkeypatch.delenv("MASKANX_CRM_SYNC_SECONDS", raising=False)
assert crm_sync_interval_seconds() == DEFAULT_INTERVAL_SECONDS
def test_a_nonsense_interval_falls_back_to_the_default(monkeypatch):
monkeypatch.setenv("MASKANX_CRM_SYNC_SECONDS", "often")
assert crm_sync_interval_seconds() == DEFAULT_INTERVAL_SECONDS
def test_the_lead_window_is_never_shorter_than_a_day(monkeypatch):
monkeypatch.setenv("MASKANX_CRM_LEAD_WINDOW_DAYS", "0")
assert lead_window_days() == 1
# --- leads ---
async def test_each_lead_is_sent_keyed_on_metas_lead_id():
"""That key is what lets the CRM ignore a lead it already has."""
crm, meta = FakeCRM(), FakeMeta([_meta_lead("lead_1"), _meta_lead("lead_2")])
sent = await push_campaign_leads(crm, meta, _campaign())
assert sent == 2
assert [key for _, key in crm.leads] == ["meta-lead-lead_1", "meta-lead-lead_2"]
async def test_the_lead_carries_its_campaign_attribution():
crm, meta = FakeCRM(), FakeMeta([_meta_lead()])
await push_campaign_leads(crm, meta, _campaign())
payload, _ = crm.leads[0]
assert payload["campaign"]["ad_id"] == "meta_ad_1"
assert payload["campaign"]["campaign_name"] == "Q3 lead gen"
assert payload["first_name"] == "Asha"
async def test_a_lead_without_an_id_is_never_sent():
"""With no stable external id it would duplicate on every run."""
crm, meta = FakeCRM(), FakeMeta([_meta_lead(lead_id="")])
assert await push_campaign_leads(crm, meta, _campaign()) == 0
assert crm.leads == []
async def test_leads_are_requested_from_a_trailing_window():
crm, meta = FakeCRM(), FakeMeta()
await push_campaign_leads(crm, meta, _campaign())
assert meta.calls[0]["object_id"] == "meta_camp_1"
assert meta.calls[0]["since"] is not None
async def test_an_unsynced_campaign_asks_meta_for_nothing():
crm, meta = FakeCRM(), FakeMeta()
assert await push_campaign_leads(crm, meta, _campaign(meta_campaign_id=None)) == 0
assert meta.calls == []
# --- campaign figures ---
async def test_campaign_figures_come_from_stored_insights_not_meta():
"""This runs every few minutes; re-reading Meta would burn the quota."""
crm = FakeCRM()
insights = FakeInsightsRepo(
{"spend": 46000, "clicks": 30, "leads": 4, "impressions": 1800},
)
await push_campaign_metrics(crm, insights, _campaign())
payload = crm.campaigns[0]
assert payload["spend"] == 46000
assert payload["leads"] == 4
assert payload["cost_per_lead"] == 11500
async def test_stored_spend_is_sent_as_is_not_converted_again():
"""Stored metrics are already in minor units.
Passing them through the raw-row normaliser would multiply spend by a
hundred, and the CRM would report a campaign that cost 460 rupees as
having cost 46,000.
"""
crm = FakeCRM()
await push_campaign_metrics(
crm, FakeInsightsRepo({"spend": 46000, "leads": 4}), _campaign(),
)
assert crm.campaigns[0]["spend"] == 46000
async def test_the_campaign_is_identified_by_its_maskanx_id():
"""The CRM upserts on it, so it has to be stable across pushes."""
crm = FakeCRM()
await push_campaign_metrics(crm, FakeInsightsRepo(), _campaign())
assert crm.campaigns[0]["external_id"] == "camp_1"
assert crm.campaigns[0]["provider"] == "maskanx"
async def test_a_campaign_with_no_insights_still_reports_zero():
"""Absent numbers and zero numbers should look the same in the CRM."""
crm = FakeCRM()
await push_campaign_metrics(crm, FakeInsightsRepo({}), _campaign())
assert crm.campaigns[0]["spend"] == 0
assert crm.campaigns[0]["cost_per_lead"] is None
async def test_the_budget_and_currency_are_carried_through():
crm = FakeCRM()
await push_campaign_metrics(crm, FakeInsightsRepo(), _campaign())
assert crm.campaigns[0]["daily_budget"] == 50000
assert crm.campaigns[0]["currency"] == "INR"
# --- the sweep ---
async def test_the_sweep_sends_both_figures_and_leads():
repo = FakeRepo([_campaign()])
crm, meta = FakeCRM(), FakeMeta([_meta_lead()])
result = await sync_to_crm(repo, FakeInsightsRepo(), meta, crm)
assert result.campaigns_sent == 1
assert result.leads_sent == 1
async def test_campaigns_that_never_reached_meta_are_skipped():
repo = FakeRepo([_campaign(meta_campaign_id=None)])
result = await sync_to_crm(repo, FakeInsightsRepo(), FakeMeta(), FakeCRM())
assert result.campaigns_sent == 0
async def test_one_campaign_failing_does_not_stop_the_rest():
repo = FakeRepo([
_campaign(id="c1", meta_campaign_id="meta_1"),
_campaign(id="c2", meta_campaign_id="meta_2"),
])
crm = FakeCRM()
crm.fail_on_lead = "lead_1"
class PerCampaignMeta(FakeMeta):
async def get_leads(self, object_id, *, since=None, limit=200):
# Only the first campaign has the lead the CRM will reject.
return [_meta_lead("lead_1")] if object_id == "meta_1" else []
result = await sync_to_crm(repo, FakeInsightsRepo(), PerCampaignMeta(), crm)
assert result.failed == ["c1"]
assert result.campaigns_sent == 2
@pytest.mark.parametrize("status", ["live", "paused", "stopped", "synced"])
async def test_paused_and_stopped_campaigns_are_still_reported(status):
"""They spent money; the CRM should keep showing what it bought."""
repo = FakeRepo([_campaign(status=status)])
crm = FakeCRM()
result = await sync_to_crm(repo, FakeInsightsRepo(), FakeMeta(), crm)
assert result.campaigns_sent == 1
assert crm.campaigns[0]["status"] == status