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>
244 lines
7.2 KiB
Python
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
|