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>
122 lines
3.8 KiB
Python
122 lines
3.8 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Dashboard figures and campaign ranking.
|
|
|
|
The two things worth pinning down: costs are recomputed from totals rather
|
|
than averaged, and the windows end yesterday so partial figures for today
|
|
never make the numbers look like a collapse.
|
|
"""
|
|
from datetime import date
|
|
|
|
from adclaw.campaigns.analytics import dashboard, rank_campaigns, summarise
|
|
|
|
|
|
def test_costs_are_derived_from_the_totals():
|
|
summary = summarise({"spend": 60000, "clicks": 30, "leads": 4, "impressions": 3000})
|
|
|
|
assert summary["cost_per_lead"] == 15000
|
|
assert summary["cost_per_click"] == 2000
|
|
assert summary["ctr"] == 1.0
|
|
|
|
|
|
def test_costs_are_none_rather_than_zero_with_no_outcomes():
|
|
"""Zero would read as 'free', which is the opposite of the truth."""
|
|
summary = summarise({"spend": 60000, "clicks": 0, "leads": 0})
|
|
|
|
assert summary["cost_per_lead"] is None
|
|
assert summary["cost_per_click"] is None
|
|
|
|
|
|
def test_missing_metrics_are_treated_as_zero():
|
|
assert summarise({})["spend"] == 0
|
|
|
|
|
|
def test_ctr_is_safe_at_zero_impressions():
|
|
assert summarise({"clicks": 5, "impressions": 0})["ctr"] == 0.0
|
|
|
|
|
|
# --- ranking ---
|
|
|
|
|
|
def _row(campaign_id, spend, leads, name=None):
|
|
return {
|
|
"campaign_id": campaign_id,
|
|
"name": name or campaign_id,
|
|
"status": "live",
|
|
"spend": spend,
|
|
"leads": leads,
|
|
"clicks": 100,
|
|
"impressions": 10000,
|
|
}
|
|
|
|
|
|
def test_best_and_worst_rank_by_cost_per_lead_not_spend():
|
|
"""Efficiency is the question, not who spent the most."""
|
|
ranked = rank_campaigns([
|
|
_row("cheap", spend=10000, leads=10), # 1000 per lead
|
|
_row("costly", spend=90000, leads=3), # 30000 per lead
|
|
])
|
|
|
|
assert ranked["best"]["campaign_id"] == "cheap"
|
|
assert ranked["worst"]["campaign_id"] == "costly"
|
|
|
|
|
|
def test_campaigns_with_no_leads_are_excluded_from_ranking():
|
|
"""A campaign an hour old should not be called the worst performer."""
|
|
ranked = rank_campaigns([
|
|
_row("established", spend=10000, leads=10),
|
|
_row("brand new", spend=500, leads=0),
|
|
])
|
|
|
|
assert ranked["best"]["campaign_id"] == "established"
|
|
assert ranked["worst"] is None
|
|
|
|
|
|
def test_a_single_campaign_has_no_worst():
|
|
"""There is nothing to compare it against."""
|
|
ranked = rank_campaigns([_row("only", spend=10000, leads=10)])
|
|
|
|
assert ranked["best"]["campaign_id"] == "only"
|
|
assert ranked["worst"] is None
|
|
|
|
|
|
def test_no_campaigns_ranks_nothing():
|
|
assert rank_campaigns([]) == {"best": None, "worst": None}
|
|
|
|
|
|
# --- dashboard ---
|
|
|
|
|
|
class FakeInsightsRepo:
|
|
def __init__(self):
|
|
self.total_calls: list[tuple] = []
|
|
self.per_campaign_calls: list[tuple] = []
|
|
|
|
async def totals_between(self, since, until, campaign_id=None):
|
|
self.total_calls.append((since, until))
|
|
return {"spend": 10000, "clicks": 20, "leads": 2, "impressions": 1000}
|
|
|
|
async def per_campaign_totals(self, since, until):
|
|
self.per_campaign_calls.append((since, until))
|
|
return [_row("camp_1", spend=10000, leads=2)]
|
|
|
|
|
|
async def test_both_windows_end_yesterday_not_today():
|
|
"""Today is partial; including it makes every morning look like a crash."""
|
|
repo = FakeInsightsRepo()
|
|
|
|
result = await dashboard(repo, today=date(2026, 8, 3))
|
|
|
|
assert repo.total_calls[0] == (date(2026, 8, 2), date(2026, 8, 2))
|
|
assert repo.total_calls[1] == (date(2026, 7, 27), date(2026, 8, 2))
|
|
assert result["window"]["yesterday"] == "2026-08-02"
|
|
assert result["window"]["last_7_days_to"] == "2026-08-02"
|
|
|
|
|
|
async def test_the_dashboard_carries_totals_and_the_ranking():
|
|
result = await dashboard(FakeInsightsRepo(), today=date(2026, 8, 3))
|
|
|
|
assert result["yesterday"]["cost_per_lead"] == 5000
|
|
assert result["last_7_days"]["spend"] == 10000
|
|
assert result["best"]["campaign_id"] == "camp_1"
|
|
assert len(result["campaigns"]) == 1
|