add credit system and supplier webhook test suites, remove dead test
24 tests for credits.py (balance, spend, unlock, refill, ledger) and 10 integration tests for supplier webhook handlers (credit packs, sticky boosts, subscription activation, business plan purchase). Removed test_mobile_nav_no_overflow which never asserted its JS result. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
294
padelnomics/tests/test_credits.py
Normal file
294
padelnomics/tests/test_credits.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
Tests for the credit system (credits.py).
|
||||
|
||||
Pure SQL operations against real in-memory SQLite — no mocking needed.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
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 = datetime.utcnow().isoformat()
|
||||
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 = datetime.utcnow().isoformat()
|
||||
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 = datetime.utcnow().isoformat()
|
||||
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 = datetime.utcnow().isoformat()
|
||||
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 = datetime.utcnow().isoformat()
|
||||
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 == []
|
||||
380
padelnomics/tests/test_supplier_webhooks.py
Normal file
380
padelnomics/tests/test_supplier_webhooks.py
Normal file
@@ -0,0 +1,380 @@
|
||||
"""
|
||||
Integration tests for supplier webhook handlers.
|
||||
|
||||
POST real webhook payloads to /billing/webhook/paddle and verify DB state.
|
||||
Uses the existing client, db, sign_payload from conftest.
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from conftest import sign_payload
|
||||
|
||||
WEBHOOK_PATH = "/billing/webhook/paddle"
|
||||
SIG_HEADER = "Paddle-Signature"
|
||||
|
||||
|
||||
# ── Fixtures ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def supplier(db):
|
||||
"""Supplier with tier=free, credit_balance=0."""
|
||||
now = datetime.utcnow().isoformat()
|
||||
async with db.execute(
|
||||
"""INSERT INTO suppliers
|
||||
(name, slug, country_code, region, category, tier,
|
||||
credit_balance, monthly_credits, created_at)
|
||||
VALUES ('Webhook Test Supplier', 'webhook-test', 'DE', 'Europe',
|
||||
'Courts', 'free', 0, 0, ?)""",
|
||||
(now,),
|
||||
) as cursor:
|
||||
supplier_id = cursor.lastrowid
|
||||
await db.commit()
|
||||
return {"id": supplier_id}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def paddle_products(db):
|
||||
"""Insert paddle_products rows for all keys the handlers need."""
|
||||
now = datetime.utcnow().isoformat()
|
||||
products = [
|
||||
("credits_25", "pri_credits25", "Credit Pack 25", 999, "one_time"),
|
||||
("credits_100", "pri_credits100", "Credit Pack 100", 3290, "one_time"),
|
||||
("boost_sticky_week", "pri_sticky_week", "Sticky Week", 4900, "one_time"),
|
||||
("boost_sticky_month", "pri_sticky_month", "Sticky Month", 14900, "one_time"),
|
||||
("boost_highlight", "pri_highlight", "Highlight", 2900, "recurring"),
|
||||
("boost_verified", "pri_verified", "Verified Badge", 1900, "recurring"),
|
||||
("boost_card_color", "pri_card_color", "Card Color", 1900, "recurring"),
|
||||
("supplier_growth", "pri_growth", "Growth Plan", 14900, "recurring"),
|
||||
("supplier_pro", "pri_pro", "Pro Plan", 39900, "recurring"),
|
||||
("business_plan", "pri_bplan", "Business Plan PDF", 9900, "one_time"),
|
||||
]
|
||||
for key, price_id, name, price_cents, billing_type in products:
|
||||
await db.execute(
|
||||
"""INSERT INTO paddle_products
|
||||
(key, paddle_product_id, paddle_price_id, name, price_cents, billing_type, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(key, f"pro_{key}", price_id, name, price_cents, billing_type, now),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def make_transaction_payload(items, supplier_id, **custom_data_extra):
|
||||
"""Build a transaction.completed event payload."""
|
||||
custom_data = {"supplier_id": str(supplier_id), **custom_data_extra}
|
||||
return {
|
||||
"event_type": "transaction.completed",
|
||||
"data": {
|
||||
"id": "txn_test_123",
|
||||
"status": "completed",
|
||||
"customer_id": "ctm_supplier_test",
|
||||
"custom_data": custom_data,
|
||||
"items": [
|
||||
{"price": {"id": price_id}, "quantity": qty}
|
||||
for price_id, qty in items
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def make_supplier_activation_payload(items, supplier_id, plan, user_id):
|
||||
"""Build a subscription.activated event for supplier plans."""
|
||||
return {
|
||||
"event_type": "subscription.activated",
|
||||
"data": {
|
||||
"id": "sub_supplier_test_456",
|
||||
"status": "active",
|
||||
"customer_id": "ctm_supplier_test",
|
||||
"custom_data": {
|
||||
"supplier_id": str(supplier_id),
|
||||
"plan": plan,
|
||||
"user_id": str(user_id),
|
||||
},
|
||||
"current_billing_period": {
|
||||
"starts_at": "2026-02-01T00:00:00.000000Z",
|
||||
"ends_at": "2026-03-01T00:00:00.000000Z",
|
||||
},
|
||||
"items": [
|
||||
{"price": {"id": price_id}, "quantity": qty}
|
||||
for price_id, qty in items
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _post_webhook(client, payload):
|
||||
"""Sign and POST a webhook payload, return awaitable response."""
|
||||
payload_bytes = json.dumps(payload).encode()
|
||||
sig = sign_payload(payload_bytes)
|
||||
return client.post(
|
||||
WEBHOOK_PATH,
|
||||
data=payload_bytes,
|
||||
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
# ── Credit Pack Purchase ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestCreditPackPurchase:
|
||||
async def test_credits_25_adds_25_credits(self, client, db, supplier, paddle_products):
|
||||
payload = make_transaction_payload([("pri_credits25", 1)], supplier["id"])
|
||||
resp = await _post_webhook(client, payload)
|
||||
assert resp.status_code == 200
|
||||
|
||||
row = await db.execute_fetchall(
|
||||
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier["id"],),
|
||||
)
|
||||
assert row[0][0] == 25
|
||||
|
||||
ledger = await db.execute_fetchall(
|
||||
"SELECT delta, event_type, note FROM credit_ledger WHERE supplier_id = ?",
|
||||
(supplier["id"],),
|
||||
)
|
||||
assert len(ledger) == 1
|
||||
assert ledger[0][0] == 25
|
||||
assert ledger[0][1] == "pack_purchase"
|
||||
|
||||
async def test_credits_100_adds_100_credits(self, client, db, supplier, paddle_products):
|
||||
payload = make_transaction_payload([("pri_credits100", 1)], supplier["id"])
|
||||
resp = await _post_webhook(client, payload)
|
||||
assert resp.status_code == 200
|
||||
|
||||
row = await db.execute_fetchall(
|
||||
"SELECT credit_balance FROM suppliers WHERE id = ?", (supplier["id"],),
|
||||
)
|
||||
assert row[0][0] == 100
|
||||
|
||||
|
||||
# ── Sticky Boost Purchase ────────────────────────────────────
|
||||
|
||||
|
||||
class TestStickyBoostPurchase:
|
||||
async def test_sticky_week_creates_boost_and_updates_supplier(
|
||||
self, client, db, supplier, paddle_products,
|
||||
):
|
||||
payload = make_transaction_payload(
|
||||
[("pri_sticky_week", 1)], supplier["id"],
|
||||
sticky_country="DE",
|
||||
)
|
||||
resp = await _post_webhook(client, payload)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify supplier_boosts row
|
||||
boosts = await db.execute_fetchall(
|
||||
"SELECT boost_type, status, expires_at FROM supplier_boosts WHERE supplier_id = ?",
|
||||
(supplier["id"],),
|
||||
)
|
||||
assert len(boosts) == 1
|
||||
assert boosts[0][0] == "sticky_week"
|
||||
assert boosts[0][1] == "active"
|
||||
# expires_at should be ~7 days from now
|
||||
expires = datetime.fromisoformat(boosts[0][2])
|
||||
assert abs((expires - datetime.utcnow()).days - 7) <= 1
|
||||
|
||||
# Verify sticky_until set on supplier
|
||||
sup = await db.execute_fetchall(
|
||||
"SELECT sticky_until, sticky_country FROM suppliers WHERE id = ?",
|
||||
(supplier["id"],),
|
||||
)
|
||||
assert sup[0][0] is not None # sticky_until set
|
||||
assert sup[0][1] == "DE"
|
||||
|
||||
async def test_sticky_month_creates_boost_and_updates_supplier(
|
||||
self, client, db, supplier, paddle_products,
|
||||
):
|
||||
payload = make_transaction_payload(
|
||||
[("pri_sticky_month", 1)], supplier["id"],
|
||||
sticky_country="ES",
|
||||
)
|
||||
resp = await _post_webhook(client, payload)
|
||||
assert resp.status_code == 200
|
||||
|
||||
boosts = await db.execute_fetchall(
|
||||
"SELECT boost_type, expires_at FROM supplier_boosts WHERE supplier_id = ?",
|
||||
(supplier["id"],),
|
||||
)
|
||||
assert len(boosts) == 1
|
||||
assert boosts[0][0] == "sticky_month"
|
||||
expires = datetime.fromisoformat(boosts[0][1])
|
||||
assert abs((expires - datetime.utcnow()).days - 30) <= 1
|
||||
|
||||
async def test_sticky_boost_sets_country(self, client, db, supplier, paddle_products):
|
||||
payload = make_transaction_payload(
|
||||
[("pri_sticky_week", 1)], supplier["id"],
|
||||
sticky_country="FR",
|
||||
)
|
||||
resp = await _post_webhook(client, payload)
|
||||
assert resp.status_code == 200
|
||||
|
||||
sup = await db.execute_fetchall(
|
||||
"SELECT sticky_country FROM suppliers WHERE id = ?", (supplier["id"],),
|
||||
)
|
||||
assert sup[0][0] == "FR"
|
||||
|
||||
|
||||
# ── Supplier Subscription Activated ──────────────────────────
|
||||
|
||||
|
||||
class TestSupplierSubscriptionActivated:
|
||||
async def test_growth_plan_sets_tier_and_credits(
|
||||
self, client, db, supplier, paddle_products, test_user,
|
||||
):
|
||||
payload = make_supplier_activation_payload(
|
||||
items=[("pri_growth", 1)],
|
||||
supplier_id=supplier["id"],
|
||||
plan="supplier_growth",
|
||||
user_id=test_user["id"],
|
||||
)
|
||||
resp = await _post_webhook(client, payload)
|
||||
assert resp.status_code == 200
|
||||
|
||||
row = await db.execute_fetchall(
|
||||
"SELECT tier, credit_balance, monthly_credits, claimed_by FROM suppliers WHERE id = ?",
|
||||
(supplier["id"],),
|
||||
)
|
||||
assert row[0][0] == "growth"
|
||||
assert row[0][1] == 30
|
||||
assert row[0][2] == 30
|
||||
assert row[0][3] == test_user["id"]
|
||||
|
||||
async def test_pro_plan_sets_tier_and_credits(
|
||||
self, client, db, supplier, paddle_products, test_user,
|
||||
):
|
||||
payload = make_supplier_activation_payload(
|
||||
items=[("pri_pro", 1)],
|
||||
supplier_id=supplier["id"],
|
||||
plan="supplier_pro",
|
||||
user_id=test_user["id"],
|
||||
)
|
||||
resp = await _post_webhook(client, payload)
|
||||
assert resp.status_code == 200
|
||||
|
||||
row = await db.execute_fetchall(
|
||||
"SELECT tier, credit_balance, monthly_credits FROM suppliers WHERE id = ?",
|
||||
(supplier["id"],),
|
||||
)
|
||||
assert row[0][0] == "pro"
|
||||
assert row[0][1] == 100
|
||||
assert row[0][2] == 100
|
||||
|
||||
async def test_boost_items_create_boost_records(
|
||||
self, client, db, supplier, paddle_products, test_user,
|
||||
):
|
||||
payload = make_supplier_activation_payload(
|
||||
items=[
|
||||
("pri_growth", 1),
|
||||
("pri_highlight", 1),
|
||||
("pri_verified", 1),
|
||||
],
|
||||
supplier_id=supplier["id"],
|
||||
plan="supplier_growth",
|
||||
user_id=test_user["id"],
|
||||
)
|
||||
resp = await _post_webhook(client, payload)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify boost rows
|
||||
boosts = await db.execute_fetchall(
|
||||
"SELECT boost_type FROM supplier_boosts WHERE supplier_id = ? ORDER BY boost_type",
|
||||
(supplier["id"],),
|
||||
)
|
||||
boost_types = [b[0] for b in boosts]
|
||||
assert "highlight" in boost_types
|
||||
assert "verified" in boost_types
|
||||
|
||||
# Verify denormalized columns
|
||||
sup = await db.execute_fetchall(
|
||||
"SELECT highlight, is_verified FROM suppliers WHERE id = ?",
|
||||
(supplier["id"],),
|
||||
)
|
||||
assert sup[0][0] == 1 # highlight
|
||||
assert sup[0][1] == 1 # is_verified
|
||||
|
||||
async def test_no_supplier_id_is_noop(
|
||||
self, client, db, supplier, paddle_products, test_user,
|
||||
):
|
||||
"""Activation without supplier_id in custom_data does nothing."""
|
||||
payload = {
|
||||
"event_type": "subscription.activated",
|
||||
"data": {
|
||||
"id": "sub_no_supplier",
|
||||
"status": "active",
|
||||
"customer_id": "ctm_test",
|
||||
"custom_data": {
|
||||
"plan": "supplier_growth",
|
||||
"user_id": str(test_user["id"]),
|
||||
# no supplier_id
|
||||
},
|
||||
"current_billing_period": {
|
||||
"starts_at": "2026-02-01T00:00:00.000000Z",
|
||||
"ends_at": "2026-03-01T00:00:00.000000Z",
|
||||
},
|
||||
"items": [],
|
||||
},
|
||||
}
|
||||
resp = await _post_webhook(client, payload)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Supplier unchanged
|
||||
row = await db.execute_fetchall(
|
||||
"SELECT tier, credit_balance FROM suppliers WHERE id = ?",
|
||||
(supplier["id"],),
|
||||
)
|
||||
assert row[0][0] == "free"
|
||||
assert row[0][1] == 0
|
||||
|
||||
|
||||
# ── Business Plan Purchase ───────────────────────────────────
|
||||
|
||||
|
||||
class TestBusinessPlanPurchase:
|
||||
async def test_creates_export_record(
|
||||
self, client, db, supplier, paddle_products, test_user,
|
||||
):
|
||||
# Need a scenario for the export
|
||||
now = datetime.utcnow().isoformat()
|
||||
async with db.execute(
|
||||
"""INSERT INTO scenarios (user_id, name, state_json, created_at)
|
||||
VALUES (?, 'Test Scenario', '{}', ?)""",
|
||||
(test_user["id"], now),
|
||||
) as cursor:
|
||||
scenario_id = cursor.lastrowid
|
||||
await db.commit()
|
||||
|
||||
payload = {
|
||||
"event_type": "transaction.completed",
|
||||
"data": {
|
||||
"id": "txn_bplan_123",
|
||||
"status": "completed",
|
||||
"customer_id": "ctm_test",
|
||||
"custom_data": {
|
||||
"user_id": str(test_user["id"]),
|
||||
"scenario_id": str(scenario_id),
|
||||
"language": "de",
|
||||
},
|
||||
"items": [{"price": {"id": "pri_bplan"}, "quantity": 1}],
|
||||
},
|
||||
}
|
||||
|
||||
# enqueue is lazily imported inside the handler via `from ..worker import enqueue`
|
||||
# Patching at the module level ensures the lazy import picks up the mock
|
||||
with patch("padelnomics.worker.enqueue", new_callable=AsyncMock):
|
||||
resp = await _post_webhook(client, payload)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify business_plan_exports row
|
||||
exports = await db.execute_fetchall(
|
||||
"SELECT user_id, scenario_id, language, status FROM business_plan_exports",
|
||||
)
|
||||
assert len(exports) == 1
|
||||
assert exports[0][0] == test_user["id"]
|
||||
assert exports[0][1] == scenario_id
|
||||
assert exports[0][2] == "de"
|
||||
assert exports[0][3] == "pending"
|
||||
@@ -275,21 +275,6 @@ def test_mobile_landing_screenshot(live_server, browser):
|
||||
page.close()
|
||||
|
||||
|
||||
def test_mobile_nav_no_overflow(live_server, browser):
|
||||
"""Verify nav doesn't overflow on mobile."""
|
||||
page = browser.new_page(viewport={"width": 375, "height": 812})
|
||||
page.goto(live_server)
|
||||
page.wait_for_load_state("networkidle")
|
||||
|
||||
page.evaluate("""
|
||||
(() => {
|
||||
const nav = document.querySelector('nav');
|
||||
return nav.scrollWidth > nav.clientWidth;
|
||||
})()
|
||||
""")
|
||||
page.close()
|
||||
# Nav may wrap on mobile, which is fine — just verify no JS errors
|
||||
|
||||
|
||||
def test_landing_no_dark_remnants(live_server, page):
|
||||
"""Check that no major elements have dark backgrounds."""
|
||||
|
||||
Reference in New Issue
Block a user