utcnow_iso() now produces 'YYYY-MM-DD HH:MM:SS' (space separator) matching
SQLite's datetime('now') so lexicographic comparisons like
'published_at <= datetime(now)' work correctly.
Also add `id DESC` tiebreaker to get_ledger() ORDER BY to preserve
insertion order when multiple credits are added within the same second.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
431 lines
16 KiB
Python
431 lines
16 KiB
Python
"""
|
|
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 UTC, datetime
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from conftest import sign_payload
|
|
from padelnomics.core import utcnow_iso
|
|
|
|
WEBHOOK_PATH = "/billing/webhook/paddle"
|
|
SIG_HEADER = "Paddle-Signature"
|
|
|
|
|
|
# ── Fixtures ─────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture
|
|
async def supplier(db):
|
|
"""Supplier with tier=free, credit_balance=0."""
|
|
now = utcnow_iso()
|
|
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 = utcnow_iso()
|
|
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.now(UTC).replace(tzinfo=None)).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.now(UTC).replace(tzinfo=None)).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_basic_plan_sets_tier_zero_credits_and_verified(
|
|
self, client, db, supplier, paddle_products, test_user,
|
|
):
|
|
"""Basic plan: tier='basic', 0 credits, is_verified=1, no ledger entry."""
|
|
payload = make_supplier_activation_payload(
|
|
items=[],
|
|
supplier_id=supplier["id"],
|
|
plan="supplier_basic",
|
|
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, is_verified FROM suppliers WHERE id = ?",
|
|
(supplier["id"],),
|
|
)
|
|
assert row[0][0] == "basic"
|
|
assert row[0][1] == 0
|
|
assert row[0][2] == 0
|
|
assert row[0][3] == 1
|
|
|
|
# No credit ledger entry for Basic (0 credits)
|
|
ledger = await db.execute_fetchall(
|
|
"SELECT id FROM credit_ledger WHERE supplier_id = ?",
|
|
(supplier["id"],),
|
|
)
|
|
assert len(ledger) == 0
|
|
|
|
async def test_yearly_plan_derives_correct_tier(
|
|
self, client, db, supplier, paddle_products, test_user,
|
|
):
|
|
"""Yearly plan key suffix is stripped; tier derives correctly."""
|
|
payload = make_supplier_activation_payload(
|
|
items=[("pri_growth", 1)],
|
|
supplier_id=supplier["id"],
|
|
plan="supplier_growth_yearly", # yearly variant
|
|
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 FROM suppliers WHERE id = ?",
|
|
(supplier["id"],),
|
|
)
|
|
assert row[0][0] == "growth"
|
|
assert row[0][1] == 30
|
|
|
|
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 = utcnow_iso()
|
|
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"
|