refactor: flatten padelnomics/padelnomics/ → repo root

git mv all tracked files from the nested padelnomics/ workspace
directory to the git repo root. Merged .gitignore files.
No code changes — pure path rename.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-22 00:44:40 +01:00
parent 5e471567b9
commit 4ae00b35d1
235 changed files with 45 additions and 42 deletions

View File

@@ -0,0 +1,429 @@
"""
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
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_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 = 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"