Files
maskanx_cm_backend/tests/test_campaign_repo.py
T
AFFAANh 4098af5a55 fix(campaigns): use RETURNING in create/update_campaign to avoid extra round-trip and TOCTOU race
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.
2026-08-01 13:36:36 +05:30

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"
)