Files
maskanx_cm_backend/tests/test_campaign_insights_integration.py
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

199 lines
6.0 KiB
Python

# -*- coding: utf-8 -*-
"""Insight storage against a real PostgreSQL database.
The upsert is the reason this file exists. Meta revises a day's figures for
weeks, so the same (level, object, date, breakdown) is written many times.
If those writes appended instead of replacing, spend would double the first
time a day was refreshed — the worst possible bug in a table used to decide
whether a campaign is profitable.
Skipped when PostgreSQL is unreachable, so the suite stays runnable offline.
"""
import sys
import uuid
from datetime import date, timedelta
import pytest
from adclaw.campaigns.insights_repo import InsightsRepository
from adclaw.campaigns.models import CampaignSpec
from adclaw.campaigns.repo import CampaignRepository
# psycopg's async mode cannot run on Windows' default ProactorEventLoop.
if sys.platform == "win32":
import asyncio
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
async def _database_available() -> bool:
try:
from adclaw.db.connection import connect_database
conn = await connect_database()
await conn.close()
return True
except Exception:
return False
@pytest.fixture()
async def repos():
if not await _database_available():
pytest.skip("PostgreSQL is not reachable")
return CampaignRepository(), InsightsRepository()
@pytest.fixture()
async def campaign(repos):
"""A saved campaign for insight rows to hang off.
Insights carry a foreign key to the campaign, so rows cannot be stored
for one that does not exist.
"""
campaigns, _ = repos
spec = CampaignSpec(
id=f"test_{uuid.uuid4().hex[:12]}",
name="Insights integration test",
status="live",
ad_account_id="act_test",
meta_campaign_id=f"meta_{uuid.uuid4().hex[:12]}",
budget={"daily_budget": 10000},
)
return await campaigns.create_campaign(spec)
def _row(campaign, day, spend, **overrides):
row = {
"level": "campaign",
"object_id": campaign.meta_campaign_id,
"campaign_id": campaign.id,
"date": day,
"metrics": {"spend": spend, "clicks": 10, "leads": 2, "impressions": 1000},
}
row.update(overrides)
return row
async def test_refetching_a_day_replaces_it_rather_than_doubling_it(repos, campaign):
_, insights = repos
day = date(2026, 8, 1)
await insights.upsert_many([_row(campaign, day, 10000)])
await insights.upsert_many([_row(campaign, day, 46000)])
totals = await insights.totals_between(day, day, campaign_id=campaign.id)
assert totals["spend"] == 46000
async def test_an_empty_write_is_a_no_op(repos):
_, insights = repos
assert await insights.upsert_many([]) == 0
async def test_totals_sum_across_days(repos, campaign):
_, insights = repos
start = date(2026, 8, 1)
await insights.upsert_many([
_row(campaign, start, 10000),
_row(campaign, start + timedelta(days=1), 20000),
_row(campaign, start + timedelta(days=2), 30000),
])
totals = await insights.totals_between(
start, start + timedelta(days=2), campaign_id=campaign.id,
)
assert totals["spend"] == 60000
assert totals["leads"] == 6
async def test_totals_exclude_days_outside_the_window(repos, campaign):
_, insights = repos
start = date(2026, 8, 1)
await insights.upsert_many([
_row(campaign, start, 10000),
_row(campaign, start + timedelta(days=5), 99999),
])
totals = await insights.totals_between(start, start, campaign_id=campaign.id)
assert totals["spend"] == 10000
async def test_breakdown_rows_do_not_inflate_the_totals(repos, campaign):
"""The same spend appears in every breakdown; summing them would triple it."""
_, insights = repos
day = date(2026, 8, 1)
await insights.upsert_many([
_row(campaign, day, 46000),
_row(campaign, day, 30000, breakdown_key="age", breakdown_value="25-34"),
_row(campaign, day, 16000, breakdown_key="age", breakdown_value="35-44"),
])
totals = await insights.totals_between(day, day, campaign_id=campaign.id)
assert totals["spend"] == 46000
async def test_breakdown_totals_return_one_row_per_value(repos, campaign):
_, insights = repos
day = date(2026, 8, 1)
await insights.upsert_many([
_row(campaign, day, 30000, breakdown_key="age", breakdown_value="25-34"),
_row(campaign, day, 16000, breakdown_key="age", breakdown_value="35-44"),
])
rows = await insights.breakdown_totals(campaign.id, "age", day, day)
assert [r["breakdown_value"] for r in rows] == ["25-34", "35-44"]
assert rows[0]["spend"] == 30000
async def test_two_breakdowns_of_the_same_day_do_not_collide(repos, campaign):
"""A shared key would let 'age' overwrite 'gender'."""
_, insights = repos
day = date(2026, 8, 1)
await insights.upsert_many([
_row(campaign, day, 30000, breakdown_key="age", breakdown_value="25-34"),
_row(campaign, day, 30000, breakdown_key="gender", breakdown_value="male"),
])
by_age = await insights.breakdown_totals(campaign.id, "age", day, day)
by_gender = await insights.breakdown_totals(campaign.id, "gender", day, day)
assert len(by_age) == 1
assert len(by_gender) == 1
async def test_per_campaign_totals_carry_the_name_for_display(repos, campaign):
_, insights = repos
day = date(2026, 8, 1)
await insights.upsert_many([_row(campaign, day, 46000)])
rows = await insights.per_campaign_totals(day, day)
mine = [r for r in rows if r["campaign_id"] == campaign.id]
assert mine and mine[0]["name"] == "Insights integration test"
assert mine[0]["spend"] == 46000
async def test_deleting_a_campaign_takes_its_insights_with_it(repos, campaign):
"""The foreign key cascades, so no orphan rows inflate later totals."""
campaigns, insights = repos
day = date(2026, 8, 1)
await insights.upsert_many([_row(campaign, day, 46000)])
await campaigns.delete_campaign(campaign.id)
rows = await insights.list_insights(campaign.id, since=day, until=day)
assert rows == []