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:
parent
b13caa4c51
commit
dcc2c07111
5 changed files with 167 additions and 250 deletions
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue