Files
maskanx_cm_backend/tests/test_campaign_insights_sync.py
T
AFFAANhandClaude Opus 5 e0cec074ff fix(campaigns): persist the Meta linkage on insert
create_campaign never wrote meta_campaign_id, sync_status, sync_error or
approved_by. An adopted campaign arrives already linked to a Meta object,
and the link was dropped on the way into the database.

The consequences compounded. Every lookup keyed on meta_campaign_id
missed the row, so adopting a campaign twice created a second copy
instead of returning the first, and the reconciler — which imports any
Meta campaign it cannot find locally — imported the same campaign again
on every cycle. At the default 120s interval that is a new row every two
minutes, indefinitely.

The unit tests could not have caught this: their fake repository stores
the spec object itself, so no field can be lost between write and read.
It took a real database. The two regression tests added here run against
one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:14:24 +05:30

244 lines
7.2 KiB
Python

# -*- coding: utf-8 -*-
"""Fetching insights from Meta and shaping them for storage.
The properties that matter: a trailing window is re-fetched rather than one
day (late attribution), rows are asked for per-day, breakdown rows are
tagged so aggregates can exclude them, and one failure never costs the rest.
"""
from datetime import date
import pytest
from adclaw.campaigns.insights_sync import (
DEFAULT_WINDOW_DAYS,
SUPPORTED_BREAKDOWNS,
sync_all_insights,
sync_campaign_insights,
window_days,
)
from adclaw.campaigns.models import CampaignSpec
from adclaw.meta.client import MetaError
class FakeInsightsRepo:
def __init__(self):
self.written: list[dict] = []
async def upsert_many(self, rows):
self.written.extend(rows)
return len(rows)
class FakeRepo:
def __init__(self, campaigns=()):
self.items = list(campaigns)
async def list_campaigns(self, company_id=None, status=None):
return list(self.items)
class FakeMeta:
def __init__(self, rows=None, breakdown_rows=None):
self.rows = rows if rows is not None else []
self.breakdown_rows = breakdown_rows or {}
self.calls: list[dict] = []
self.reject_breakdowns: set[str] = set()
async def get_insights(self, object_id, **kwargs):
breakdowns = kwargs.get("breakdowns")
key = breakdowns[0] if breakdowns else None
if key in self.reject_breakdowns:
raise MetaError("Breakdown not supported for this objective", code=100)
self.calls.append({"object_id": object_id, "breakdown": key, **kwargs})
if key:
return self.breakdown_rows.get(key, [])
return self.rows
def _campaign(**overrides) -> CampaignSpec:
data = {
"id": "camp_1",
"name": "Q3 lead gen",
"status": "live",
"ad_account_id": "act_1",
"meta_campaign_id": "meta_camp_1",
}
data.update(overrides)
return CampaignSpec(**data)
# --- window ---
def test_the_window_defaults_to_metas_attribution_period(monkeypatch):
monkeypatch.delenv("MASKANX_INSIGHTS_WINDOW_DAYS", raising=False)
assert window_days() == DEFAULT_WINDOW_DAYS
def test_a_nonsense_window_falls_back_to_the_default(monkeypatch):
monkeypatch.setenv("MASKANX_INSIGHTS_WINDOW_DAYS", "a month")
assert window_days() == DEFAULT_WINDOW_DAYS
def test_the_window_is_never_shorter_than_one_day(monkeypatch):
monkeypatch.setenv("MASKANX_INSIGHTS_WINDOW_DAYS", "0")
assert window_days() == 1
# --- fetching ---
async def test_a_trailing_window_is_requested_one_row_per_day():
"""Without time_increment the whole range collapses into one row."""
meta = FakeMeta()
await sync_campaign_insights(
FakeRepo(), FakeInsightsRepo(), meta, _campaign(),
since=date(2026, 7, 1), until=date(2026, 7, 31),
)
call = meta.calls[0]
assert call["since"] == "2026-07-01"
assert call["until"] == "2026-07-31"
assert call["time_increment"] == 1
async def test_metrics_are_normalised_before_storage():
"""Meta's strings and major units are converted on the way in."""
insights = FakeInsightsRepo()
meta = FakeMeta(rows=[{
"date_start": "2026-08-01",
"spend": "460.00",
"actions": [{"action_type": "lead", "value": "4"}],
}])
written = await sync_campaign_insights(
FakeRepo(), insights, meta, _campaign(),
)
assert written == 1
row = insights.written[0]
assert row["date"] == "2026-08-01"
assert row["campaign_id"] == "camp_1"
assert row["level"] == "campaign"
assert row["metrics"]["spend"] == 46000
assert row["metrics"]["leads"] == 4
assert row["metrics"]["cost_per_lead"] == 11500
async def test_a_row_without_a_date_is_dropped():
"""The date is part of the key that makes re-fetching idempotent."""
insights = FakeInsightsRepo()
meta = FakeMeta(rows=[{"spend": "10.00"}])
assert await sync_campaign_insights(
FakeRepo(), insights, meta, _campaign(),
) == 0
async def test_an_unsynced_campaign_is_skipped_without_calling_meta():
meta = FakeMeta()
written = await sync_campaign_insights(
FakeRepo(), FakeInsightsRepo(), meta, _campaign(meta_campaign_id=None),
)
assert written == 0
assert meta.calls == []
# --- breakdowns ---
async def test_breakdown_rows_are_tagged_with_their_key_and_value():
"""Untagged, they would be summed alongside the totals they duplicate."""
insights = FakeInsightsRepo()
meta = FakeMeta(
rows=[{"date_start": "2026-08-01", "spend": "100.00"}],
breakdown_rows={
"age": [
{"date_start": "2026-08-01", "age": "25-34", "spend": "60.00"},
{"date_start": "2026-08-01", "age": "35-44", "spend": "40.00"},
],
},
)
await sync_campaign_insights(
FakeRepo(), insights, meta, _campaign(), breakdowns=("age",),
)
plain = [r for r in insights.written if not r["breakdown_key"]]
by_age = [r for r in insights.written if r["breakdown_key"] == "age"]
assert len(plain) == 1
assert {r["breakdown_value"] for r in by_age} == {"25-34", "35-44"}
async def test_a_rejected_breakdown_does_not_cost_the_headline_numbers():
"""Meta refuses some breakdowns for some objectives."""
insights = FakeInsightsRepo()
meta = FakeMeta(rows=[{"date_start": "2026-08-01", "spend": "100.00"}])
meta.reject_breakdowns = {"region"}
written = await sync_campaign_insights(
FakeRepo(), insights, meta, _campaign(), breakdowns=("region",),
)
assert written == 1
assert insights.written[0]["breakdown_key"] == ""
@pytest.mark.parametrize("breakdown", SUPPORTED_BREAKDOWNS)
async def test_every_supported_breakdown_is_requested(breakdown):
meta = FakeMeta()
await sync_campaign_insights(
FakeRepo(), FakeInsightsRepo(), meta, _campaign(),
breakdowns=SUPPORTED_BREAKDOWNS,
)
assert breakdown in [c["breakdown"] for c in meta.calls]
# --- the sweep ---
async def test_paused_campaigns_are_synced_too():
"""A campaign paused last week still spent money last week."""
repo = FakeRepo([_campaign(status="paused"), _campaign(id="c2", status="stopped")])
meta = FakeMeta(rows=[{"date_start": "2026-08-01", "spend": "10.00"}])
result = await sync_all_insights(repo, FakeInsightsRepo(), meta, breakdowns=())
assert result.campaigns == 2
async def test_campaigns_that_never_reached_meta_are_skipped():
repo = FakeRepo([_campaign(meta_campaign_id=None)])
result = await sync_all_insights(repo, FakeInsightsRepo(), FakeMeta(), breakdowns=())
assert result.campaigns == 0
async def test_one_campaign_failing_does_not_stop_the_others():
repo = FakeRepo([
_campaign(id="c1", meta_campaign_id="meta_1"),
_campaign(id="c2", meta_campaign_id="meta_2"),
])
class PartlyFailingMeta(FakeMeta):
async def get_insights(self, object_id, **kwargs):
if object_id == "meta_1":
raise MetaError("Rate limited", code=17)
return [{"date_start": "2026-08-01", "spend": "10.00"}]
result = await sync_all_insights(
repo, FakeInsightsRepo(), PartlyFailingMeta(), breakdowns=(),
)
assert result.failed == ["c1"]
assert result.rows_written == 1