create_campaign/update_campaign previously committed then opened a second connection via get_campaign() just to re-read the row they had just written, adding an extra round-trip and a race window where a concurrent delete between commit and re-fetch was misreported as "disappeared immediately after insert/update". Both now RETURNING the row from the same statement/cursor that wrote it, using a shared _RETURNING_CLAUSE derived from _SELECT_COLUMNS so the two column lists cannot drift apart. update_campaign now raises LookupError for an unknown id (0 rows affected) instead of misdiagnosing it as a race. Also: delete_campaign uses the (cur.rowcount or 0) > 0 idiom already established in postgres_repo.py, the module docstring documents that this repository's SQL paths rely on integration tests rather than unit tests, and a new DB-free test asserts the RETURNING and SELECT column lists stay identical.
83 lines
2.8 KiB
Python
83 lines
2.8 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Row mapping for the campaign repository."""
|
|
import inspect
|
|
from datetime import datetime, timezone
|
|
|
|
from adclaw.campaigns import repo as repo_module
|
|
from adclaw.campaigns.repo import CampaignRepository, campaign_from_row
|
|
|
|
|
|
def _row(**overrides):
|
|
row = {
|
|
"id": "camp_1",
|
|
"company_id": "maskanx",
|
|
"name": "Lead gen",
|
|
"status": "draft",
|
|
"origin": "maskanx",
|
|
"objective": "OUTCOME_LEADS",
|
|
"ad_account_id": "act_1",
|
|
"budget": {"daily_budget": 10000},
|
|
"guardrails": {"max_cost_per_lead": 150},
|
|
"targeting": {"age_min": 25},
|
|
"advanced": {},
|
|
"channels": ["facebook"],
|
|
"schedule": {},
|
|
"meta_campaign_id": None,
|
|
"sync_status": "not_synced",
|
|
"sync_error": None,
|
|
"approved_by": None,
|
|
"created_at": datetime(2026, 8, 1, tzinfo=timezone.utc),
|
|
"updated_at": datetime(2026, 8, 1, tzinfo=timezone.utc),
|
|
}
|
|
row.update(overrides)
|
|
return row
|
|
|
|
|
|
def test_campaign_from_row_maps_all_fields():
|
|
spec = campaign_from_row(_row())
|
|
assert spec.id == "camp_1"
|
|
assert spec.budget["daily_budget"] == 10000
|
|
assert spec.guardrails["max_cost_per_lead"] == 150
|
|
assert spec.channels == ["facebook"]
|
|
assert spec.sync_status == "not_synced"
|
|
|
|
|
|
def test_campaign_from_row_defaults_null_json_to_empty():
|
|
spec = campaign_from_row(
|
|
_row(budget=None, guardrails=None, targeting=None, advanced=None,
|
|
channels=None, schedule=None),
|
|
)
|
|
assert spec.budget == {}
|
|
assert spec.guardrails == {}
|
|
assert spec.targeting == {}
|
|
assert spec.advanced == {}
|
|
assert spec.channels == []
|
|
assert spec.schedule == {}
|
|
|
|
|
|
def _column_names(column_block):
|
|
return [c.strip() for c in column_block.strip().split(",") if c.strip()]
|
|
|
|
|
|
def test_returning_columns_match_select_columns():
|
|
"""create_campaign/update_campaign RETURNING must match _SELECT_COLUMNS
|
|
exactly, so the row a write returns maps to a CampaignSpec the same way
|
|
a plain SELECT would, without a second query. Guards against the two
|
|
column lists silently drifting apart.
|
|
"""
|
|
select_columns = _column_names(repo_module._SELECT_COLUMNS)
|
|
returning_columns = _column_names(
|
|
repo_module._RETURNING_CLAUSE.split("RETURNING", 1)[1],
|
|
)
|
|
assert returning_columns == select_columns
|
|
|
|
# Both methods must build their SQL from the shared _RETURNING_CLAUSE
|
|
# constant rather than a hand-duplicated column list that could
|
|
# silently diverge from _SELECT_COLUMNS.
|
|
for method in (CampaignRepository.create_campaign, CampaignRepository.update_campaign):
|
|
source = inspect.getsource(method)
|
|
assert "_RETURNING_CLAUSE" in source, (
|
|
f"{method.__name__} must reuse _RETURNING_CLAUSE, not a "
|
|
"hand-written RETURNING column list"
|
|
)
|