tests: extract _build_session_factory to a shared conftest fixture

The same per-test sqlite-engine setup was duplicated across 14 test
files (~30 lines each). Consolidated into a single async fixture
`db_factory` in tests/conftest.py; tests now take db_factory as a
parameter and use `async with db_factory() as session` directly.

No behaviour change — same function-scope, same in-memory schema
created via Base.metadata.create_all, same app.db._engine /
_session_factory rebinding so module-level helpers see the test
engine. Just ~420 lines of boilerplate removed.
This commit is contained in:
Giorgio Gilestro 2026-05-27 20:50:09 +02:00
parent b13caa4c51
commit dcc2c07111
5 changed files with 167 additions and 250 deletions

View file

@ -23,29 +23,6 @@ import pytest
# ---------------------------------------------------------------------------
def _build_session_factory(tmp_path):
"""Spin up a fresh in-memory schema and return (engine, factory).
Mirrors test_stripe_billing._build_app's seeding strategy but
skips the FastAPI app most conversion tests only need the
session factory."""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app import db as db_mod
from app.db import Base
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/conv.db")
factory = async_sessionmaker(engine, expire_on_commit=False)
db_mod._engine = engine
db_mod._session_factory = factory
async def _seed():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
asyncio.run(_seed())
return factory
async def _add_pair(factory, *, referrer_id=1, referred_id=2):
"""Insert a referrer + referred user pair and a linking Referral row.
Returns nothing tests re-fetch via the factory."""
@ -68,7 +45,7 @@ async def _add_pair(factory, *, referrer_id=1, referred_id=2):
# ---------------------------------------------------------------------------
def test_first_conversion_credits_both_parties(tmp_path):
async def test_first_conversion_credits_both_parties(db_factory):
"""Calling convert_referral on a freshly-paid referred user should
extend credit_until by REFERRAL_CREDIT_DAYS for BOTH the buyer and
the referrer, and stamp converted_at + credited_at."""
@ -77,100 +54,88 @@ def test_first_conversion_credits_both_parties(tmp_path):
REFERRAL_CREDIT_DAYS, convert_referral,
)
factory = _build_session_factory(tmp_path)
asyncio.run(_add_pair(factory))
factory = db_factory
await _add_pair(factory)
async def _run():
async with factory() as s:
referred = await s.get(User, 2)
ref = await convert_referral(s, referred)
assert ref is not None
assert ref.converted_at is not None
assert ref.credited_at is not None
await s.commit()
async with factory() as s:
referred = await s.get(User, 2)
ref = await convert_referral(s, referred)
assert ref is not None
assert ref.converted_at is not None
assert ref.credited_at is not None
await s.commit()
# Re-open a fresh session so we read committed state, not the
# session-cached version.
async with factory() as s:
referrer = await s.get(User, 1)
referred = await s.get(User, 2)
now = datetime.now(timezone.utc)
# Both windows should sit ~REFERRAL_CREDIT_DAYS in the
# future (allow 1 day slack for clock + rounding).
for u in (referrer, referred):
assert u.credit_until is not None
cu = u.credit_until
if cu.tzinfo is None:
cu = cu.replace(tzinfo=timezone.utc)
delta_days = (cu - now).total_seconds() / 86400
assert REFERRAL_CREDIT_DAYS - 1 <= delta_days <= REFERRAL_CREDIT_DAYS + 1
asyncio.run(_run())
# Re-open a fresh session so we read committed state, not the
# session-cached version.
async with factory() as s:
referrer = await s.get(User, 1)
referred = await s.get(User, 2)
now = datetime.now(timezone.utc)
# Both windows should sit ~REFERRAL_CREDIT_DAYS in the
# future (allow 1 day slack for clock + rounding).
for u in (referrer, referred):
assert u.credit_until is not None
cu = u.credit_until
if cu.tzinfo is None:
cu = cu.replace(tzinfo=timezone.utc)
delta_days = (cu - now).total_seconds() / 86400
assert REFERRAL_CREDIT_DAYS - 1 <= delta_days <= REFERRAL_CREDIT_DAYS + 1
def test_idempotent_on_repeat_call(tmp_path):
async def test_idempotent_on_repeat_call(db_factory):
"""A second convert_referral call (e.g. from a duplicate webhook or
renewal event) must NOT extend credit a second time. The Referral
row is already stamped, so we should early-return unchanged."""
from app.models import User
from app.services.referral_service import convert_referral
factory = _build_session_factory(tmp_path)
asyncio.run(_add_pair(factory))
factory = db_factory
await _add_pair(factory)
async def _run():
async with factory() as s:
referred = await s.get(User, 2)
await convert_referral(s, referred)
await s.commit()
# Snapshot credit_until after first conversion.
async with factory() as s:
referrer = await s.get(User, 1)
referred = await s.get(User, 2)
first_referrer_credit = referrer.credit_until
first_referred_credit = referred.credit_until
async with factory() as s:
referred = await s.get(User, 2)
await convert_referral(s, referred)
await s.commit()
# Snapshot credit_until after first conversion.
async with factory() as s:
referrer = await s.get(User, 1)
referred = await s.get(User, 2)
first_referrer_credit = referrer.credit_until
first_referred_credit = referred.credit_until
# Second call — should no-op.
async with factory() as s:
referred = await s.get(User, 2)
ref2 = await convert_referral(s, referred)
assert ref2 is not None # we still return the row
await s.commit()
async with factory() as s:
referrer = await s.get(User, 1)
referred = await s.get(User, 2)
assert referrer.credit_until == first_referrer_credit
assert referred.credit_until == first_referred_credit
asyncio.run(_run())
# Second call — should no-op.
async with factory() as s:
referred = await s.get(User, 2)
ref2 = await convert_referral(s, referred)
assert ref2 is not None # we still return the row
await s.commit()
async with factory() as s:
referrer = await s.get(User, 1)
referred = await s.get(User, 2)
assert referrer.credit_until == first_referrer_credit
assert referred.credit_until == first_referred_credit
def test_no_referral_row_returns_none(tmp_path):
async def test_no_referral_row_returns_none(db_factory):
"""A user signing up directly (no inviter) has no Referral row.
convert_referral must return None and touch nothing."""
from app.models import User
from app.services.referral_service import convert_referral
factory = _build_session_factory(tmp_path)
factory = db_factory
async def _seed_orphan():
async with factory() as s:
s.add(User(id=9, email="lone@x", tier="free"))
await s.commit()
async with factory() as s:
s.add(User(id=9, email="lone@x", tier="free"))
await s.commit()
asyncio.run(_seed_orphan())
async def _run():
async with factory() as s:
user = await s.get(User, 9)
result = await convert_referral(s, user)
assert result is None
assert user.credit_until is None
asyncio.run(_run())
async with factory() as s:
user = await s.get(User, 9)
result = await convert_referral(s, user)
assert result is None
assert user.credit_until is None
def test_credit_stacks_from_existing_window(tmp_path):
async def test_credit_stacks_from_existing_window(db_factory):
"""If the user already has a future credit_until (admin grant, prior
referral), the new credit should extend from THAT anchor not from
now. Mirrors cli.grant_credit's stacking semantics."""
@ -179,75 +144,63 @@ def test_credit_stacks_from_existing_window(tmp_path):
REFERRAL_CREDIT_DAYS, convert_referral,
)
factory = _build_session_factory(tmp_path)
asyncio.run(_add_pair(factory))
factory = db_factory
await _add_pair(factory)
# Pre-load 30 days of credit on the referred user.
existing = datetime.now(timezone.utc) + timedelta(days=30)
async def _preload():
async with factory() as s:
u = await s.get(User, 2)
u.credit_until = existing
await s.commit()
async with factory() as s:
u = await s.get(User, 2)
u.credit_until = existing
await s.commit()
asyncio.run(_preload())
async def _run():
async with factory() as s:
referred = await s.get(User, 2)
await convert_referral(s, referred)
await s.commit()
async with factory() as s:
referred = await s.get(User, 2)
cu = referred.credit_until
if cu.tzinfo is None:
cu = cu.replace(tzinfo=timezone.utc)
# Expected: existing + REFERRAL_CREDIT_DAYS days, not now + days.
expected = existing + timedelta(days=REFERRAL_CREDIT_DAYS)
delta_seconds = abs((cu - expected).total_seconds())
assert delta_seconds < 60, (
f"new credit anchored at now, not existing window: "
f"got {cu}, expected ~{expected}"
)
asyncio.run(_run())
async with factory() as s:
referred = await s.get(User, 2)
await convert_referral(s, referred)
await s.commit()
async with factory() as s:
referred = await s.get(User, 2)
cu = referred.credit_until
if cu.tzinfo is None:
cu = cu.replace(tzinfo=timezone.utc)
# Expected: existing + REFERRAL_CREDIT_DAYS days, not now + days.
expected = existing + timedelta(days=REFERRAL_CREDIT_DAYS)
delta_seconds = abs((cu - expected).total_seconds())
assert delta_seconds < 60, (
f"new credit anchored at now, not existing window: "
f"got {cu}, expected ~{expected}"
)
def test_deleted_referrer_does_not_crash(tmp_path):
async def test_deleted_referrer_does_not_crash(db_factory):
"""If the referrer's User row has been deleted, the referred user
should still be credited and the Referral still stamped we just
skip the missing referrer."""
from app.models import Referral, User
from app.services.referral_service import convert_referral
factory = _build_session_factory(tmp_path)
factory = db_factory
async def _seed():
from app.db import utcnow
async with factory() as s:
# Referrer with FK SET NULL — we don't delete the row, we
# instead create a Referral pointing at a non-existent id
# to simulate a deleted referrer.
s.add(User(id=2, email="u2@x", tier="free"))
s.add(Referral(referrer_user_id=999, # nonexistent
referred_user_id=2,
created_at=utcnow()))
await s.commit()
from app.db import utcnow
async with factory() as s:
# Referrer with FK SET NULL — we don't delete the row, we
# instead create a Referral pointing at a non-existent id
# to simulate a deleted referrer.
s.add(User(id=2, email="u2@x", tier="free"))
s.add(Referral(referrer_user_id=999, # nonexistent
referred_user_id=2,
created_at=utcnow()))
await s.commit()
asyncio.run(_seed())
async def _run():
async with factory() as s:
referred = await s.get(User, 2)
ref = await convert_referral(s, referred)
await s.commit()
assert ref is not None
assert ref.converted_at is not None
# Referred still got their credit even though referrer is gone.
assert referred.credit_until is not None
asyncio.run(_run())
async with factory() as s:
referred = await s.get(User, 2)
ref = await convert_referral(s, referred)
await s.commit()
assert ref is not None
assert ref.converted_at is not None
# Referred still got their credit even though referrer is gone.
assert referred.credit_until is not None
# ---------------------------------------------------------------------------