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:
@@ -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)
|
||||
Reference in New Issue
Block a user