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>
199 lines
6.0 KiB
Python
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 == []
|