test(campaigns): exercise repository SQL against PostgreSQL

Phase 1 covered only the pure row mapper, so every SQL path in
CampaignRepository was untested — parameter ordering, RETURNING clauses and
transaction behaviour had never been executed. Phase 2 creates real Meta
objects on top of this layer, so the gap is closed first.

Seven tests cover the CRUD round trip, LookupError on unknown ids, event
recording, and that update_campaign_with_event writes both rows or neither.
They skip rather than fail when PostgreSQL is unreachable, and delete every
row they create in a finally block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AFFAANh
2026-08-03 00:51:49 +05:30
co-authored by Claude Opus 5
parent 50345a1c89
commit 04ad516fe2
+142
View File
@@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
"""Repository SQL exercised against a real PostgreSQL database.
Skipped when PostgreSQL is unreachable so the suite stays runnable offline.
"""
import sys
import uuid
import pytest
from adclaw.campaigns.models import CampaignSpec
from adclaw.campaigns.repo import CampaignRepository
# psycopg's async mode cannot run on Windows' default ProactorEventLoop
# (see https://www.psycopg.org/psycopg3/docs/advanced/async.html#async-and-windows).
# pytest-asyncio creates its per-test event loop lazily using whatever
# policy is active when each test runs, so switching the policy here at
# collection time (module import, before any test executes) is sufficient
# to make every event loop created afterwards -- in this file or any test
# module collected after it -- selector-based instead. This mirrors what
# adclaw.cli.main already does for the real app entrypoint; it is set here
# only for the test process and does not touch any production file.
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 repo():
if not await _database_available():
pytest.skip("PostgreSQL is not reachable")
return CampaignRepository()
def _spec(**overrides) -> CampaignSpec:
data = {
"id": f"test_{uuid.uuid4().hex[:12]}",
"name": "Integration test campaign",
"status": "draft",
"origin": "maskanx",
"objective": "OUTCOME_LEADS",
"ad_account_id": "act_test",
"budget": {"daily_budget": 10000},
"guardrails": {"max_cost_per_lead": 150},
"targeting": {"age_min": 25},
"channels": ["facebook"],
}
data.update(overrides)
return CampaignSpec(**data)
@pytest.mark.asyncio
async def test_create_get_update_delete_round_trip(repo):
spec = _spec()
created = await repo.create_campaign(spec)
try:
assert created.id == spec.id
assert created.budget["daily_budget"] == 10000
fetched = await repo.get_campaign(spec.id)
assert fetched is not None
assert fetched.name == "Integration test campaign"
fetched.name = "Renamed"
fetched.company_id = "co_1"
updated = await repo.update_campaign(fetched)
assert updated.name == "Renamed"
assert updated.company_id == "co_1"
finally:
assert await repo.delete_campaign(spec.id) is True
assert await repo.get_campaign(spec.id) is None
@pytest.mark.asyncio
async def test_update_unknown_id_raises_lookup_error(repo):
with pytest.raises(LookupError):
await repo.update_campaign(_spec(id="test_does_not_exist"))
@pytest.mark.asyncio
async def test_delete_unknown_id_returns_false(repo):
assert await repo.delete_campaign("test_does_not_exist") is False
@pytest.mark.asyncio
async def test_events_are_recorded_and_listed(repo):
spec = _spec()
await repo.create_campaign(spec)
try:
await repo.add_event(spec.id, "campaign.created", actor="tester")
events = await repo.list_events(spec.id)
assert [e["event_type"] for e in events] == ["campaign.created"]
assert events[0]["actor"] == "tester"
finally:
await repo.delete_campaign(spec.id)
@pytest.mark.asyncio
async def test_transactional_update_writes_both_rows(repo):
spec = await repo.create_campaign(_spec())
try:
spec.status = "pending_approval"
saved = await repo.update_campaign_with_event(
spec, event_type="campaign.submit", actor="tester",
)
assert saved.status == "pending_approval"
events = await repo.list_events(spec.id)
assert "campaign.submit" in [e["event_type"] for e in events]
finally:
await repo.delete_campaign(spec.id)
@pytest.mark.asyncio
async def test_transactional_update_rolls_back_on_unknown_id(repo):
ghost = _spec(id="test_ghost")
with pytest.raises(LookupError):
await repo.update_campaign_with_event(ghost, event_type="campaign.submit")
assert await repo.list_events("test_ghost") == []
@pytest.mark.asyncio
async def test_list_filters_by_status(repo):
spec = await repo.create_campaign(_spec(status="draft"))
try:
drafts = await repo.list_campaigns(status="draft")
assert spec.id in [c.id for c in drafts]
approved = await repo.list_campaigns(status="approved")
assert spec.id not in [c.id for c in approved]
finally:
await repo.delete_campaign(spec.id)