""" Tests for the credit system (credits.py). Pure SQL operations against real in-memory SQLite — no mocking needed. """ from padelnomics.core import utcnow_iso import pytest from padelnomics.credits import ( InsufficientCredits, add_credits, already_unlocked, compute_credit_cost, get_balance, get_ledger, monthly_credit_refill, spend_credits, unlock_lead, ) # ── Fixtures ───────────────────────────────────────────────── @pytest.fixture async def supplier(db): """Supplier with credit_balance=100, monthly_credits=30, tier=growth.""" now = utcnow_iso() async with db.execute( """INSERT INTO suppliers (name, slug, country_code, region, category, tier, credit_balance, monthly_credits, created_at) VALUES ('Test Supplier', 'test-supplier', 'DE', 'Europe', 'Courts', 'growth', 100, 30, ?)""", (now,), ) as cursor: supplier_id = cursor.lastrowid await db.commit() return {"id": supplier_id} @pytest.fixture async def lead(db): """Lead request with heat_score=warm, credit_cost=20.""" now = utcnow_iso() async with db.execute( """INSERT INTO lead_requests (lead_type, heat_score, credit_cost, status, created_at) VALUES ('supplier_quote', 'warm', 20, 'new', ?)""", (now,), ) as cursor: lead_id = cursor.lastrowid await db.commit() return {"id": lead_id, "credit_cost": 20} # ── GetBalance ─────────────────────────────────────────────── class TestGetBalance: async def test_returns_zero_for_unknown_supplier(self, db): assert await get_balance(99999) == 0 async def test_returns_current_balance(self, db, supplier): assert await get_balance(supplier["id"]) == 100 # ── AddCredits ─────────────────────────────────────────────── class TestAddCredits: async def test_adds_credits_and_updates_balance(self, db, supplier): new_balance = await add_credits(supplier["id"], 50, "pack_purchase") assert new_balance == 150 assert await get_balance(supplier["id"]) == 150 async def test_creates_ledger_entry(self, db, supplier): await add_credits( supplier["id"], 50, "pack_purchase", reference_id=42, note="Credit pack: credits_50", ) rows = await db.execute_fetchall( "SELECT * FROM credit_ledger WHERE supplier_id = ?", (supplier["id"],), ) assert len(rows) == 1 row = rows[0] assert row[2] == 50 # delta assert row[3] == 150 # balance_after assert row[4] == "pack_purchase" # event_type assert row[5] == 42 # reference_id assert row[6] == "Credit pack: credits_50" # note async def test_multiple_adds_accumulate(self, db, supplier): await add_credits(supplier["id"], 10, "pack_purchase") await add_credits(supplier["id"], 10, "pack_purchase") await add_credits(supplier["id"], 10, "pack_purchase") assert await get_balance(supplier["id"]) == 130 # ── SpendCredits ───────────────────────────────────────────── class TestSpendCredits: async def test_spends_credits_and_updates_balance(self, db, supplier): new_balance = await spend_credits(supplier["id"], 30, "lead_unlock") assert new_balance == 70 assert await get_balance(supplier["id"]) == 70 async def test_creates_negative_ledger_entry(self, db, supplier): await spend_credits(supplier["id"], 30, "lead_unlock") rows = await db.execute_fetchall( "SELECT * FROM credit_ledger WHERE supplier_id = ?", (supplier["id"],), ) assert len(rows) == 1 assert rows[0][2] == -30 # delta assert rows[0][3] == 70 # balance_after async def test_raises_insufficient_credits(self, db, supplier): with pytest.raises(InsufficientCredits) as exc_info: await spend_credits(supplier["id"], 200, "lead_unlock") assert exc_info.value.balance == 100 assert exc_info.value.required == 200 async def test_spend_exact_balance(self, db, supplier): new_balance = await spend_credits(supplier["id"], 100, "lead_unlock") assert new_balance == 0 assert await get_balance(supplier["id"]) == 0 # ── ComputeCreditCost ──────────────────────────────────────── class TestComputeCreditCost: def test_hot_costs_35(self): assert compute_credit_cost({"heat_score": "hot"}) == 35 def test_warm_costs_20(self): assert compute_credit_cost({"heat_score": "warm"}) == 20 def test_cool_costs_8(self): assert compute_credit_cost({"heat_score": "cool"}) == 8 def test_missing_heat_defaults_to_cool(self): assert compute_credit_cost({}) == 8 assert compute_credit_cost({"heat_score": None}) == 8 # ── AlreadyUnlocked ────────────────────────────────────────── class TestAlreadyUnlocked: async def test_returns_false_when_not_unlocked(self, db, supplier, lead): assert await already_unlocked(supplier["id"], lead["id"]) is False async def test_returns_true_after_unlock(self, db, supplier, lead): now = utcnow_iso() await db.execute( """INSERT INTO lead_forwards (lead_id, supplier_id, credit_cost, created_at) VALUES (?, ?, 20, ?)""", (lead["id"], supplier["id"], now), ) await db.commit() assert await already_unlocked(supplier["id"], lead["id"]) is True # ── UnlockLead ──────────────────────────────────────────────── class TestUnlockLead: async def test_unlocks_lead_and_returns_details(self, db, supplier, lead): result = await unlock_lead(supplier["id"], lead["id"]) assert result["forward_id"] is not None assert result["credit_cost"] == 20 assert result["new_balance"] == 80 assert result["lead"]["id"] == lead["id"] # Verify lead_forwards row row = await db.execute_fetchall( "SELECT * FROM lead_forwards WHERE supplier_id = ? AND lead_id = ?", (supplier["id"], lead["id"]), ) assert len(row) == 1 assert row[0][3] == 20 # credit_cost # Verify ledger entry ledger = await db.execute_fetchall( "SELECT * FROM credit_ledger WHERE supplier_id = ? AND event_type = 'lead_unlock'", (supplier["id"],), ) assert len(ledger) == 1 assert ledger[0][2] == -20 # delta assert ledger[0][3] == 80 # balance_after # Verify unlock_count incremented lead_row = await db.execute_fetchall( "SELECT unlock_count FROM lead_requests WHERE id = ?", (lead["id"],), ) assert lead_row[0][0] == 1 async def test_raises_on_duplicate_unlock(self, db, supplier, lead): await unlock_lead(supplier["id"], lead["id"]) with pytest.raises(ValueError, match="already unlocked"): await unlock_lead(supplier["id"], lead["id"]) async def test_raises_on_missing_lead(self, db, supplier): with pytest.raises(ValueError, match="not found"): await unlock_lead(supplier["id"], 99999) async def test_raises_insufficient_credits(self, db, lead): """Supplier with only 5 credits tries to unlock a 20-credit lead.""" now = utcnow_iso() async with db.execute( """INSERT INTO suppliers (name, slug, country_code, region, category, tier, credit_balance, monthly_credits, created_at) VALUES ('Poor Supplier', 'poor-supplier', 'DE', 'Europe', 'Courts', 'growth', 5, 0, ?)""", (now,), ) as cursor: poor_id = cursor.lastrowid await db.commit() with pytest.raises(InsufficientCredits) as exc_info: await unlock_lead(poor_id, lead["id"]) assert exc_info.value.balance == 5 assert exc_info.value.required == 20 # ── MonthlyRefill ───────────────────────────────────────────── class TestMonthlyRefill: async def test_refills_monthly_credits(self, db, supplier): new_balance = await monthly_credit_refill(supplier["id"]) assert new_balance == 130 # 100 + 30 assert await get_balance(supplier["id"]) == 130 # Verify ledger entry ledger = await db.execute_fetchall( "SELECT * FROM credit_ledger WHERE supplier_id = ? AND event_type = 'monthly_allocation'", (supplier["id"],), ) assert len(ledger) == 1 assert ledger[0][2] == 30 # delta async def test_noop_when_no_monthly_credits(self, db): """Supplier with monthly_credits=0 gets no refill.""" now = utcnow_iso() async with db.execute( """INSERT INTO suppliers (name, slug, country_code, region, category, tier, credit_balance, monthly_credits, created_at) VALUES ('Free Supplier', 'free-supplier', 'DE', 'Europe', 'Courts', 'free', 50, 0, ?)""", (now,), ) as cursor: free_id = cursor.lastrowid await db.commit() result = await monthly_credit_refill(free_id) assert result == 0 assert await get_balance(free_id) == 50 # ── GetLedger ───────────────────────────────────────────────── class TestGetLedger: async def test_returns_entries_in_descending_order(self, db, supplier): await add_credits(supplier["id"], 10, "pack_purchase", note="first") await add_credits(supplier["id"], 20, "pack_purchase", note="second") await add_credits(supplier["id"], 30, "pack_purchase", note="third") entries = await get_ledger(supplier["id"]) assert len(entries) == 3 # Most recent first assert entries[0]["note"] == "third" assert entries[1]["note"] == "second" assert entries[2]["note"] == "first" async def test_respects_limit(self, db, supplier): for i in range(5): await add_credits(supplier["id"], 1, "pack_purchase", note=f"entry_{i}") entries = await get_ledger(supplier["id"], limit=2) assert len(entries) == 2 async def test_empty_for_unknown_supplier(self, db): entries = await get_ledger(99999) assert entries == []