Add BeanFlows MVP: coffee analytics dashboard, API, and web app
- Fix pipeline granularity: add market_year to cleaned/serving SQL models - Add DuckDB data access layer with async query functions (analytics.py) - Build Chart.js dashboard: supply/demand, STU ratio, top producers, YoY table - Add country comparison page with multi-select picker - Replace items CRUD with read-only commodity API (list, metrics, countries, CSV) - Configure BeanFlows plan tiers (Free/Starter/Pro) with feature gating - Rewrite public pages for coffee market intelligence positioning - Remove boilerplate items schema, update health check for DuckDB - Add test suite: 139 tests passing (dashboard, API, billing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
247
web/tests/conftest.py
Normal file
247
web/tests/conftest.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
Shared test fixtures for the BeanFlows test suite.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
|
||||
from beanflows import analytics, core
|
||||
from beanflows.app import create_app
|
||||
|
||||
SCHEMA_PATH = Path(__file__).parent.parent / "src" / "beanflows" / "migrations" / "schema.sql"
|
||||
|
||||
|
||||
# ── Database ─────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
async def db():
|
||||
"""In-memory SQLite with full schema, patches core._db."""
|
||||
conn = await aiosqlite.connect(":memory:")
|
||||
conn.row_factory = aiosqlite.Row
|
||||
await conn.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
schema = SCHEMA_PATH.read_text()
|
||||
await conn.executescript(schema)
|
||||
await conn.commit()
|
||||
|
||||
original_db = core._db
|
||||
core._db = conn
|
||||
|
||||
yield conn
|
||||
|
||||
core._db = original_db
|
||||
await conn.close()
|
||||
|
||||
|
||||
# ── App & client ─────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
async def app(db):
|
||||
"""Quart app with DB already initialized (init_db/close_db patched to no-op)."""
|
||||
with patch.object(core, "init_db", new_callable=AsyncMock), \
|
||||
patch.object(core, "close_db", new_callable=AsyncMock), \
|
||||
patch.object(analytics, "open_analytics_db"), \
|
||||
patch.object(analytics, "close_analytics_db"):
|
||||
application = create_app()
|
||||
application.config["TESTING"] = True
|
||||
yield application
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(app):
|
||||
"""Unauthenticated test client."""
|
||||
async with app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ── Users ────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
async def test_user(db):
|
||||
"""Create a test user, return dict with id/email/name."""
|
||||
now = datetime.utcnow().isoformat()
|
||||
async with db.execute(
|
||||
"INSERT INTO users (email, name, created_at) VALUES (?, ?, ?)",
|
||||
("test@example.com", "Test User", now),
|
||||
) as cursor:
|
||||
user_id = cursor.lastrowid
|
||||
await db.commit()
|
||||
return {"id": user_id, "email": "test@example.com", "name": "Test User"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_client(app, test_user):
|
||||
"""Test client with session['user_id'] pre-set."""
|
||||
async with app.test_client() as c:
|
||||
async with c.session_transaction() as sess:
|
||||
sess["user_id"] = test_user["id"]
|
||||
yield c
|
||||
|
||||
|
||||
# ── Subscriptions ────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture
|
||||
def create_subscription(db):
|
||||
"""Factory: create a subscription row for a user."""
|
||||
async def _create(
|
||||
user_id: int,
|
||||
plan: str = "pro",
|
||||
status: str = "active",
|
||||
|
||||
paddle_customer_id: str = "ctm_test123",
|
||||
paddle_subscription_id: str = "sub_test456",
|
||||
|
||||
current_period_end: str = "2025-03-01T00:00:00Z",
|
||||
) -> int:
|
||||
now = datetime.utcnow().isoformat()
|
||||
async with db.execute(
|
||||
|
||||
"""INSERT INTO subscriptions
|
||||
(user_id, plan, status, paddle_customer_id,
|
||||
paddle_subscription_id, current_period_end, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(user_id, plan, status, paddle_customer_id, paddle_subscription_id,
|
||||
current_period_end, now, now),
|
||||
|
||||
) as cursor:
|
||||
sub_id = cursor.lastrowid
|
||||
await db.commit()
|
||||
return sub_id
|
||||
return _create
|
||||
|
||||
|
||||
# ── Config ───────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_config():
|
||||
|
||||
"""Set test Paddle config values."""
|
||||
|
||||
original_values = {}
|
||||
test_values = {
|
||||
|
||||
"PADDLE_API_KEY": "test_api_key_123",
|
||||
"PADDLE_WEBHOOK_SECRET": "whsec_test_secret",
|
||||
"PADDLE_PRICES": {"starter": "pri_starter_123", "pro": "pri_pro_456"},
|
||||
|
||||
"BASE_URL": "http://localhost:5000",
|
||||
"DEBUG": True,
|
||||
}
|
||||
for key, val in test_values.items():
|
||||
original_values[key] = getattr(core.config, key, None)
|
||||
setattr(core.config, key, val)
|
||||
|
||||
|
||||
yield
|
||||
|
||||
|
||||
for key, val in original_values.items():
|
||||
setattr(core.config, key, val)
|
||||
|
||||
|
||||
# ── Webhook helpers ──────────────────────────────────────────
|
||||
|
||||
|
||||
def make_webhook_payload(
|
||||
event_type: str,
|
||||
subscription_id: str = "sub_test456",
|
||||
customer_id: str = "ctm_test123",
|
||||
user_id: str = "1",
|
||||
plan: str = "starter",
|
||||
status: str = "active",
|
||||
ends_at: str = "2025-03-01T00:00:00.000000Z",
|
||||
) -> dict:
|
||||
"""Build a Paddle webhook payload dict."""
|
||||
return {
|
||||
"event_type": event_type,
|
||||
"data": {
|
||||
"id": subscription_id,
|
||||
"status": status,
|
||||
"customer_id": customer_id,
|
||||
"custom_data": {"user_id": user_id, "plan": plan},
|
||||
"current_billing_period": {
|
||||
"starts_at": "2025-02-01T00:00:00.000000Z",
|
||||
"ends_at": ends_at,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def sign_payload(payload_bytes: bytes, secret: str = "whsec_test_secret") -> str:
|
||||
"""Compute HMAC-SHA256 signature for a webhook payload."""
|
||||
return hmac.new(secret.encode(), payload_bytes, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
# ── Analytics mock data ──────────────────────────────────────
|
||||
|
||||
MOCK_TIME_SERIES = [
|
||||
{"market_year": 2018, "Production": 165000, "Exports": 115000, "Imports": 105000,
|
||||
"Ending_Stocks": 33000, "Total_Distribution": 160000},
|
||||
{"market_year": 2019, "Production": 168000, "Exports": 118000, "Imports": 108000,
|
||||
"Ending_Stocks": 34000, "Total_Distribution": 163000},
|
||||
{"market_year": 2020, "Production": 170000, "Exports": 120000, "Imports": 110000,
|
||||
"Ending_Stocks": 35000, "Total_Distribution": 165000},
|
||||
{"market_year": 2021, "Production": 175000, "Exports": 125000, "Imports": 115000,
|
||||
"Ending_Stocks": 36000, "Total_Distribution": 170000},
|
||||
{"market_year": 2022, "Production": 172000, "Exports": 122000, "Imports": 112000,
|
||||
"Ending_Stocks": 34000, "Total_Distribution": 168000},
|
||||
]
|
||||
|
||||
MOCK_TOP_COUNTRIES = [
|
||||
{"country_name": "Brazil", "country_code": "BR", "market_year": 2022, "Production": 65000},
|
||||
{"country_name": "Vietnam", "country_code": "VN", "market_year": 2022, "Production": 30000},
|
||||
{"country_name": "Colombia", "country_code": "CO", "market_year": 2022, "Production": 14000},
|
||||
]
|
||||
|
||||
MOCK_STU_TREND = [
|
||||
{"market_year": 2020, "Stock_to_Use_Ratio_pct": 21.2},
|
||||
{"market_year": 2021, "Stock_to_Use_Ratio_pct": 21.1},
|
||||
{"market_year": 2022, "Stock_to_Use_Ratio_pct": 20.2},
|
||||
]
|
||||
|
||||
MOCK_BALANCE = [
|
||||
{"market_year": 2020, "Production": 170000, "Total_Distribution": 165000, "Supply_Demand_Balance": 5000},
|
||||
{"market_year": 2021, "Production": 175000, "Total_Distribution": 170000, "Supply_Demand_Balance": 5000},
|
||||
{"market_year": 2022, "Production": 172000, "Total_Distribution": 168000, "Supply_Demand_Balance": 4000},
|
||||
]
|
||||
|
||||
MOCK_YOY = [
|
||||
{"country_name": "Brazil", "country_code": "BR", "market_year": 2022,
|
||||
"Production": 65000, "Production_YoY_pct": -3.5},
|
||||
{"country_name": "Vietnam", "country_code": "VN", "market_year": 2022,
|
||||
"Production": 30000, "Production_YoY_pct": 2.1},
|
||||
]
|
||||
|
||||
MOCK_COMMODITIES = [
|
||||
{"commodity_code": 711100, "commodity_name": "Coffee, Green"},
|
||||
{"commodity_code": 222000, "commodity_name": "Soybeans"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_analytics():
|
||||
"""Patch all analytics query functions with mock data."""
|
||||
with patch.object(analytics, "get_global_time_series", new_callable=AsyncMock,
|
||||
return_value=MOCK_TIME_SERIES), \
|
||||
patch.object(analytics, "get_top_countries", new_callable=AsyncMock,
|
||||
return_value=MOCK_TOP_COUNTRIES), \
|
||||
patch.object(analytics, "get_stock_to_use_trend", new_callable=AsyncMock,
|
||||
return_value=MOCK_STU_TREND), \
|
||||
patch.object(analytics, "get_supply_demand_balance", new_callable=AsyncMock,
|
||||
return_value=MOCK_BALANCE), \
|
||||
patch.object(analytics, "get_production_yoy_by_country", new_callable=AsyncMock,
|
||||
return_value=MOCK_YOY), \
|
||||
patch.object(analytics, "get_country_comparison", new_callable=AsyncMock,
|
||||
return_value=[]), \
|
||||
patch.object(analytics, "get_available_commodities", new_callable=AsyncMock,
|
||||
return_value=MOCK_COMMODITIES), \
|
||||
patch.object(analytics, "fetch_analytics", new_callable=AsyncMock,
|
||||
return_value=[{"result": 1}]):
|
||||
yield
|
||||
|
||||
|
||||
129
web/tests/test_api_commodities.py
Normal file
129
web/tests/test_api_commodities.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Tests for the commodity analytics API endpoints.
|
||||
"""
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
async def _create_api_key_for_user(db, user_id, plan="starter"):
|
||||
"""Helper: create an API key and subscription, return the raw key."""
|
||||
raw_key = f"sk_{secrets.token_urlsafe(32)}"
|
||||
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
||||
now = datetime.utcnow().isoformat()
|
||||
|
||||
await db.execute(
|
||||
"""INSERT INTO api_keys (user_id, name, key_hash, key_prefix, scopes, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(user_id, "test-key", key_hash, raw_key[:12], "read,write", now),
|
||||
)
|
||||
|
||||
# Create subscription for plan
|
||||
if plan != "free":
|
||||
await db.execute(
|
||||
"""INSERT OR REPLACE INTO subscriptions
|
||||
(user_id, plan, status, created_at, updated_at)
|
||||
VALUES (?, ?, 'active', ?, ?)""",
|
||||
(user_id, plan, now, now),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return raw_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_requires_auth(client):
|
||||
"""API returns 401 without auth header."""
|
||||
response = await client.get("/api/v1/commodities")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_rejects_free_plan(client, db, test_user, mock_analytics):
|
||||
"""API returns 403 for free plan users."""
|
||||
raw_key = await _create_api_key_for_user(db, test_user["id"], plan="free")
|
||||
response = await client.get(
|
||||
"/api/v1/commodities",
|
||||
headers={"Authorization": f"Bearer {raw_key}"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_commodities(client, db, test_user, mock_analytics):
|
||||
"""GET /commodities returns commodity list."""
|
||||
raw_key = await _create_api_key_for_user(db, test_user["id"])
|
||||
response = await client.get(
|
||||
"/api/v1/commodities",
|
||||
headers={"Authorization": f"Bearer {raw_key}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert "commodities" in data
|
||||
assert len(data["commodities"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commodity_metrics(client, db, test_user, mock_analytics):
|
||||
"""GET /commodities/<code>/metrics returns time series."""
|
||||
raw_key = await _create_api_key_for_user(db, test_user["id"])
|
||||
response = await client.get(
|
||||
"/api/v1/commodities/711100/metrics?metrics=Production&metrics=Exports",
|
||||
headers={"Authorization": f"Bearer {raw_key}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data["commodity_code"] == 711100
|
||||
assert "Production" in data["metrics"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commodity_metrics_invalid_metric(client, db, test_user, mock_analytics):
|
||||
"""GET /commodities/<code>/metrics rejects invalid metrics."""
|
||||
raw_key = await _create_api_key_for_user(db, test_user["id"])
|
||||
response = await client.get(
|
||||
"/api/v1/commodities/711100/metrics?metrics=DROP_TABLE",
|
||||
headers={"Authorization": f"Bearer {raw_key}"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commodity_countries(client, db, test_user, mock_analytics):
|
||||
"""GET /commodities/<code>/countries returns ranking."""
|
||||
raw_key = await _create_api_key_for_user(db, test_user["id"])
|
||||
response = await client.get(
|
||||
"/api/v1/commodities/711100/countries?metric=Production&limit=5",
|
||||
headers={"Authorization": f"Bearer {raw_key}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data["metric"] == "Production"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commodity_csv_export(client, db, test_user, mock_analytics):
|
||||
"""GET /commodities/<code>/metrics.csv returns CSV."""
|
||||
raw_key = await _create_api_key_for_user(db, test_user["id"])
|
||||
response = await client.get(
|
||||
"/api/v1/commodities/711100/metrics.csv",
|
||||
headers={"Authorization": f"Bearer {raw_key}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "text/csv" in response.content_type
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_me_endpoint(client, db, test_user, mock_analytics):
|
||||
"""GET /me returns user info."""
|
||||
raw_key = await _create_api_key_for_user(db, test_user["id"])
|
||||
response = await client.get(
|
||||
"/api/v1/me",
|
||||
headers={"Authorization": f"Bearer {raw_key}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data["email"] == "test@example.com"
|
||||
assert data["plan"] == "starter"
|
||||
325
web/tests/test_billing_helpers.py
Normal file
325
web/tests/test_billing_helpers.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
Unit tests for billing SQL helpers, feature/limit access, and plan determination.
|
||||
"""
|
||||
import pytest
|
||||
from hypothesis import HealthCheck, given
|
||||
from hypothesis import settings as h_settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from beanflows.billing.routes import (
|
||||
|
||||
can_access_feature,
|
||||
get_subscription,
|
||||
get_subscription_by_provider_id,
|
||||
is_within_limits,
|
||||
update_subscription_status,
|
||||
upsert_subscription,
|
||||
)
|
||||
from beanflows.core import config
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# get_subscription
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class TestGetSubscription:
|
||||
async def test_returns_none_for_user_without_subscription(self, db, test_user):
|
||||
result = await get_subscription(test_user["id"])
|
||||
assert result is None
|
||||
|
||||
async def test_returns_subscription_for_user(self, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], plan="pro", status="active")
|
||||
result = await get_subscription(test_user["id"])
|
||||
assert result is not None
|
||||
assert result["plan"] == "pro"
|
||||
assert result["status"] == "active"
|
||||
assert result["user_id"] == test_user["id"]
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# upsert_subscription
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class TestUpsertSubscription:
|
||||
async def test_insert_new_subscription(self, db, test_user):
|
||||
sub_id = await upsert_subscription(
|
||||
user_id=test_user["id"],
|
||||
plan="pro",
|
||||
status="active",
|
||||
provider_customer_id="cust_abc",
|
||||
provider_subscription_id="sub_xyz",
|
||||
current_period_end="2025-06-01T00:00:00Z",
|
||||
)
|
||||
assert sub_id > 0
|
||||
row = await get_subscription(test_user["id"])
|
||||
assert row["plan"] == "pro"
|
||||
assert row["status"] == "active"
|
||||
|
||||
assert row["paddle_customer_id"] == "cust_abc"
|
||||
assert row["paddle_subscription_id"] == "sub_xyz"
|
||||
|
||||
assert row["current_period_end"] == "2025-06-01T00:00:00Z"
|
||||
|
||||
async def test_update_existing_subscription(self, db, test_user, create_subscription):
|
||||
original_id = await create_subscription(
|
||||
test_user["id"], plan="starter", status="active",
|
||||
|
||||
paddle_subscription_id="sub_old",
|
||||
|
||||
)
|
||||
returned_id = await upsert_subscription(
|
||||
user_id=test_user["id"],
|
||||
plan="pro",
|
||||
status="active",
|
||||
provider_customer_id="cust_new",
|
||||
provider_subscription_id="sub_new",
|
||||
)
|
||||
assert returned_id == original_id
|
||||
row = await get_subscription(test_user["id"])
|
||||
assert row["plan"] == "pro"
|
||||
|
||||
assert row["paddle_subscription_id"] == "sub_new"
|
||||
|
||||
|
||||
async def test_upsert_with_none_period_end(self, db, test_user):
|
||||
await upsert_subscription(
|
||||
user_id=test_user["id"],
|
||||
plan="pro",
|
||||
status="active",
|
||||
provider_customer_id="cust_1",
|
||||
provider_subscription_id="sub_1",
|
||||
current_period_end=None,
|
||||
)
|
||||
row = await get_subscription(test_user["id"])
|
||||
assert row["current_period_end"] is None
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# get_subscription_by_provider_id
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class TestGetSubscriptionByProviderId:
|
||||
async def test_returns_none_for_unknown_id(self, db):
|
||||
result = await get_subscription_by_provider_id("nonexistent")
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_finds_by_paddle_subscription_id(self, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], paddle_subscription_id="sub_findme")
|
||||
|
||||
result = await get_subscription_by_provider_id("sub_findme")
|
||||
assert result is not None
|
||||
assert result["user_id"] == test_user["id"]
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# update_subscription_status
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class TestUpdateSubscriptionStatus:
|
||||
async def test_updates_status(self, db, test_user, create_subscription):
|
||||
|
||||
await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_upd")
|
||||
|
||||
await update_subscription_status("sub_upd", status="cancelled")
|
||||
row = await get_subscription(test_user["id"])
|
||||
assert row["status"] == "cancelled"
|
||||
assert row["updated_at"] is not None
|
||||
|
||||
async def test_updates_extra_fields(self, db, test_user, create_subscription):
|
||||
|
||||
await create_subscription(test_user["id"], paddle_subscription_id="sub_extra")
|
||||
|
||||
await update_subscription_status(
|
||||
"sub_extra",
|
||||
status="active",
|
||||
plan="starter",
|
||||
current_period_end="2026-01-01T00:00:00Z",
|
||||
)
|
||||
row = await get_subscription(test_user["id"])
|
||||
assert row["status"] == "active"
|
||||
assert row["plan"] == "starter"
|
||||
assert row["current_period_end"] == "2026-01-01T00:00:00Z"
|
||||
|
||||
async def test_noop_for_unknown_provider_id(self, db, test_user, create_subscription):
|
||||
|
||||
await create_subscription(test_user["id"], paddle_subscription_id="sub_known", status="active")
|
||||
|
||||
await update_subscription_status("sub_unknown", status="expired")
|
||||
row = await get_subscription(test_user["id"])
|
||||
assert row["status"] == "active" # unchanged
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# can_access_feature
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class TestCanAccessFeature:
|
||||
async def test_no_subscription_gets_free_features(self, db, test_user):
|
||||
assert await can_access_feature(test_user["id"], "dashboard") is True
|
||||
assert await can_access_feature(test_user["id"], "export") is False
|
||||
assert await can_access_feature(test_user["id"], "api") is False
|
||||
|
||||
async def test_active_pro_gets_all_features(self, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], plan="pro", status="active")
|
||||
assert await can_access_feature(test_user["id"], "dashboard") is True
|
||||
assert await can_access_feature(test_user["id"], "export") is True
|
||||
assert await can_access_feature(test_user["id"], "api") is True
|
||||
assert await can_access_feature(test_user["id"], "priority_support") is True
|
||||
|
||||
async def test_active_starter_gets_starter_features(self, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], plan="starter", status="active")
|
||||
assert await can_access_feature(test_user["id"], "dashboard") is True
|
||||
assert await can_access_feature(test_user["id"], "export") is True
|
||||
assert await can_access_feature(test_user["id"], "all_commodities") is False
|
||||
|
||||
async def test_cancelled_still_has_features(self, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], plan="pro", status="cancelled")
|
||||
assert await can_access_feature(test_user["id"], "api") is True
|
||||
|
||||
async def test_on_trial_has_features(self, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], plan="pro", status="on_trial")
|
||||
assert await can_access_feature(test_user["id"], "api") is True
|
||||
|
||||
async def test_expired_falls_back_to_free(self, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], plan="pro", status="expired")
|
||||
assert await can_access_feature(test_user["id"], "api") is False
|
||||
assert await can_access_feature(test_user["id"], "dashboard") is True
|
||||
|
||||
async def test_past_due_falls_back_to_free(self, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], plan="pro", status="past_due")
|
||||
assert await can_access_feature(test_user["id"], "export") is False
|
||||
|
||||
async def test_paused_falls_back_to_free(self, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], plan="pro", status="paused")
|
||||
assert await can_access_feature(test_user["id"], "api") is False
|
||||
|
||||
async def test_nonexistent_feature_returns_false(self, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], plan="pro", status="active")
|
||||
assert await can_access_feature(test_user["id"], "teleportation") is False
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# is_within_limits
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class TestIsWithinLimits:
|
||||
async def test_free_user_no_api_calls(self, db, test_user):
|
||||
assert await is_within_limits(test_user["id"], "api_calls", 0) is False
|
||||
|
||||
async def test_free_user_commodity_limit(self, db, test_user):
|
||||
assert await is_within_limits(test_user["id"], "commodities", 0) is True
|
||||
assert await is_within_limits(test_user["id"], "commodities", 1) is False
|
||||
|
||||
async def test_free_user_history_limit(self, db, test_user):
|
||||
assert await is_within_limits(test_user["id"], "history_years", 4) is True
|
||||
assert await is_within_limits(test_user["id"], "history_years", 5) is False
|
||||
|
||||
async def test_pro_unlimited(self, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], plan="pro", status="active")
|
||||
assert await is_within_limits(test_user["id"], "commodities", 999999) is True
|
||||
assert await is_within_limits(test_user["id"], "api_calls", 999999) is True
|
||||
|
||||
async def test_starter_limits(self, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], plan="starter", status="active")
|
||||
assert await is_within_limits(test_user["id"], "api_calls", 9999) is True
|
||||
assert await is_within_limits(test_user["id"], "api_calls", 10000) is False
|
||||
|
||||
async def test_expired_pro_gets_free_limits(self, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], plan="pro", status="expired")
|
||||
assert await is_within_limits(test_user["id"], "api_calls", 0) is False
|
||||
|
||||
async def test_unknown_resource_returns_false(self, db, test_user):
|
||||
assert await is_within_limits(test_user["id"], "unicorns", 0) is False
|
||||
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Parameterized: status × feature access matrix
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
STATUSES = ["free", "active", "on_trial", "cancelled", "past_due", "paused", "expired"]
|
||||
FEATURES = ["dashboard", "export", "api", "priority_support"]
|
||||
ACTIVE_STATUSES = {"active", "on_trial", "cancelled"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", STATUSES)
|
||||
@pytest.mark.parametrize("feature", FEATURES)
|
||||
async def test_feature_access_matrix(db, test_user, create_subscription, status, feature):
|
||||
if status != "free":
|
||||
await create_subscription(test_user["id"], plan="pro", status=status)
|
||||
|
||||
result = await can_access_feature(test_user["id"], feature)
|
||||
|
||||
if status in ACTIVE_STATUSES:
|
||||
expected = feature in config.PLAN_FEATURES["pro"]
|
||||
else:
|
||||
expected = feature in config.PLAN_FEATURES["free"]
|
||||
|
||||
assert result == expected, f"status={status}, feature={feature}"
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Parameterized: plan × feature matrix (active status)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
PLANS = ["free", "starter", "pro"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("plan", PLANS)
|
||||
@pytest.mark.parametrize("feature", FEATURES)
|
||||
async def test_plan_feature_matrix(db, test_user, create_subscription, plan, feature):
|
||||
if plan != "free":
|
||||
await create_subscription(test_user["id"], plan=plan, status="active")
|
||||
|
||||
result = await can_access_feature(test_user["id"], feature)
|
||||
expected = feature in config.PLAN_FEATURES.get(plan, [])
|
||||
assert result == expected, f"plan={plan}, feature={feature}"
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Parameterized: plan × resource limit boundaries
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
@pytest.mark.parametrize("plan", PLANS)
|
||||
@pytest.mark.parametrize("resource,at_limit", [
|
||||
("commodities", 1),
|
||||
("commodities", 65),
|
||||
("api_calls", 0),
|
||||
("api_calls", 10000),
|
||||
])
|
||||
async def test_plan_limit_matrix(db, test_user, create_subscription, plan, resource, at_limit):
|
||||
if plan != "free":
|
||||
await create_subscription(test_user["id"], plan=plan, status="active")
|
||||
|
||||
plan_limit = config.PLAN_LIMITS.get(plan, {}).get(resource, 0)
|
||||
result = await is_within_limits(test_user["id"], resource, at_limit)
|
||||
|
||||
if plan_limit == -1:
|
||||
assert result is True
|
||||
elif at_limit < plan_limit:
|
||||
assert result is True
|
||||
else:
|
||||
assert result is False
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Hypothesis: limit boundaries
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class TestLimitsHypothesis:
|
||||
@given(count=st.integers(min_value=0, max_value=100))
|
||||
@h_settings(max_examples=100, deadline=2000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
async def test_free_limit_boundary_commodities(self, db, test_user, count):
|
||||
result = await is_within_limits(test_user["id"], "commodities", count)
|
||||
assert result == (count < 1)
|
||||
|
||||
@given(count=st.integers(min_value=0, max_value=100000))
|
||||
@h_settings(max_examples=100, deadline=2000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
async def test_pro_always_within_limits(self, db, test_user, create_subscription, count):
|
||||
# Use upsert to avoid duplicate inserts across Hypothesis examples
|
||||
await upsert_subscription(
|
||||
user_id=test_user["id"], plan="pro", status="active",
|
||||
provider_customer_id="cust_hyp", provider_subscription_id="sub_hyp",
|
||||
)
|
||||
result = await is_within_limits(test_user["id"], "commodities", count)
|
||||
assert result is True
|
||||
268
web/tests/test_billing_routes.py
Normal file
268
web/tests/test_billing_routes.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
Route integration tests for Paddle billing endpoints.
|
||||
External Paddle API calls mocked with respx.
|
||||
"""
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
|
||||
CHECKOUT_METHOD = "POST"
|
||||
CHECKOUT_PLAN = "starter"
|
||||
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Public routes (pricing, success)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class TestPricingPage:
|
||||
async def test_accessible_without_auth(self, client, db):
|
||||
response = await client.get("/billing/pricing")
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_accessible_with_auth(self, auth_client, db, test_user):
|
||||
response = await auth_client.get("/billing/pricing")
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_with_subscription(self, auth_client, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], plan="pro", status="active")
|
||||
response = await auth_client.get("/billing/pricing")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestSuccessPage:
|
||||
async def test_requires_auth(self, client, db):
|
||||
response = await client.get("/billing/success", follow_redirects=False)
|
||||
assert response.status_code in (302, 303, 307)
|
||||
|
||||
async def test_accessible_with_auth(self, auth_client, db, test_user):
|
||||
response = await auth_client.get("/billing/success")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Checkout
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class TestCheckoutRoute:
|
||||
async def test_requires_auth(self, client, db):
|
||||
|
||||
response = await client.post(f"/billing/checkout/{CHECKOUT_PLAN}", follow_redirects=False)
|
||||
|
||||
assert response.status_code in (302, 303, 307)
|
||||
|
||||
@respx.mock
|
||||
async def test_creates_checkout_session(self, auth_client, db, test_user):
|
||||
|
||||
respx.post("https://api.paddle.com/transactions").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"data": {
|
||||
"checkout": {
|
||||
"url": "https://checkout.paddle.com/test_123"
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
|
||||
response = await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}", follow_redirects=False)
|
||||
|
||||
assert response.status_code in (302, 303, 307)
|
||||
|
||||
async def test_invalid_plan_rejected(self, auth_client, db, test_user):
|
||||
|
||||
response = await auth_client.post("/billing/checkout/invalid", follow_redirects=False)
|
||||
|
||||
assert response.status_code in (302, 303, 307)
|
||||
|
||||
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_api_error_propagates(self, auth_client, db, test_user):
|
||||
|
||||
respx.post("https://api.paddle.com/transactions").mock(
|
||||
return_value=httpx.Response(500, json={"error": "server error"})
|
||||
)
|
||||
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
|
||||
await auth_client.post(f"/billing/checkout/{CHECKOUT_PLAN}")
|
||||
|
||||
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Manage subscription / Portal
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestManageRoute:
|
||||
async def test_requires_auth(self, client, db):
|
||||
response = await client.post("/billing/manage", follow_redirects=False)
|
||||
assert response.status_code in (302, 303, 307)
|
||||
|
||||
async def test_requires_subscription(self, auth_client, db, test_user):
|
||||
response = await auth_client.post("/billing/manage", follow_redirects=False)
|
||||
assert response.status_code in (302, 303, 307)
|
||||
|
||||
@respx.mock
|
||||
async def test_redirects_to_portal(self, auth_client, db, test_user, create_subscription):
|
||||
|
||||
await create_subscription(test_user["id"], paddle_subscription_id="sub_test")
|
||||
|
||||
respx.get("https://api.paddle.com/subscriptions/sub_test").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"data": {
|
||||
"management_urls": {
|
||||
"update_payment_method": "https://paddle.com/manage/test_123"
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
response = await auth_client.post("/billing/manage", follow_redirects=False)
|
||||
assert response.status_code in (302, 303, 307)
|
||||
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Cancel subscription
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestCancelRoute:
|
||||
async def test_requires_auth(self, client, db):
|
||||
response = await client.post("/billing/cancel", follow_redirects=False)
|
||||
assert response.status_code in (302, 303, 307)
|
||||
|
||||
async def test_no_error_without_subscription(self, auth_client, db, test_user):
|
||||
response = await auth_client.post("/billing/cancel", follow_redirects=False)
|
||||
assert response.status_code in (302, 303, 307)
|
||||
|
||||
@respx.mock
|
||||
async def test_cancels_subscription(self, auth_client, db, test_user, create_subscription):
|
||||
|
||||
await create_subscription(test_user["id"], paddle_subscription_id="sub_test")
|
||||
|
||||
respx.post("https://api.paddle.com/subscriptions/sub_test/cancel").mock(
|
||||
return_value=httpx.Response(200, json={"data": {}})
|
||||
)
|
||||
|
||||
|
||||
response = await auth_client.post("/billing/cancel", follow_redirects=False)
|
||||
assert response.status_code in (302, 303, 307)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# subscription_required decorator
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
from beanflows.billing.routes import subscription_required
|
||||
from quart import Blueprint
|
||||
|
||||
test_bp = Blueprint("test", __name__)
|
||||
|
||||
|
||||
@test_bp.route("/protected")
|
||||
@subscription_required()
|
||||
async def protected_route():
|
||||
return "success", 200
|
||||
|
||||
|
||||
@test_bp.route("/custom_allowed")
|
||||
@subscription_required(allowed=("active", "past_due"))
|
||||
async def custom_allowed_route():
|
||||
return "success", 200
|
||||
|
||||
|
||||
class TestSubscriptionRequiredDecorator:
|
||||
@pytest.fixture
|
||||
async def test_app(self, app):
|
||||
app.register_blueprint(test_bp)
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
async def test_client(self, test_app):
|
||||
async with test_app.test_client() as c:
|
||||
yield c
|
||||
|
||||
async def test_redirects_unauthenticated(self, test_client, db):
|
||||
response = await test_client.get("/protected", follow_redirects=False)
|
||||
assert response.status_code in (302, 303, 307)
|
||||
|
||||
async def test_redirects_without_subscription(self, test_client, db, test_user):
|
||||
async with test_client.session_transaction() as sess:
|
||||
sess["user_id"] = test_user["id"]
|
||||
|
||||
response = await test_client.get("/protected", follow_redirects=False)
|
||||
assert response.status_code in (302, 303, 307)
|
||||
|
||||
async def test_allows_active_subscription(self, test_client, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], plan="pro", status="active")
|
||||
|
||||
async with test_client.session_transaction() as sess:
|
||||
sess["user_id"] = test_user["id"]
|
||||
|
||||
response = await test_client.get("/protected")
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_allows_on_trial(self, test_client, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], plan="pro", status="on_trial")
|
||||
|
||||
async with test_client.session_transaction() as sess:
|
||||
sess["user_id"] = test_user["id"]
|
||||
|
||||
response = await test_client.get("/protected")
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_allows_cancelled(self, test_client, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], plan="pro", status="cancelled")
|
||||
|
||||
async with test_client.session_transaction() as sess:
|
||||
sess["user_id"] = test_user["id"]
|
||||
|
||||
response = await test_client.get("/protected")
|
||||
assert response.status_code == 200
|
||||
|
||||
async def test_rejects_expired(self, test_client, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], plan="pro", status="expired")
|
||||
|
||||
async with test_client.session_transaction() as sess:
|
||||
sess["user_id"] = test_user["id"]
|
||||
|
||||
response = await test_client.get("/protected", follow_redirects=False)
|
||||
assert response.status_code in (302, 303, 307)
|
||||
|
||||
@pytest.mark.parametrize("status", ["free", "active", "on_trial", "cancelled", "past_due", "paused", "expired"])
|
||||
async def test_default_allowed_tuple(self, test_client, db, test_user, create_subscription, status):
|
||||
if status != "free":
|
||||
await create_subscription(test_user["id"], plan="pro", status=status)
|
||||
|
||||
async with test_client.session_transaction() as sess:
|
||||
sess["user_id"] = test_user["id"]
|
||||
|
||||
response = await test_client.get("/protected", follow_redirects=False)
|
||||
|
||||
if status in ("active", "on_trial", "cancelled"):
|
||||
assert response.status_code == 200
|
||||
else:
|
||||
assert response.status_code in (302, 303, 307)
|
||||
|
||||
async def test_custom_allowed_tuple(self, test_client, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], plan="pro", status="past_due")
|
||||
|
||||
async with test_client.session_transaction() as sess:
|
||||
sess["user_id"] = test_user["id"]
|
||||
|
||||
response = await test_client.get("/custom_allowed")
|
||||
assert response.status_code == 200
|
||||
274
web/tests/test_billing_webhooks.py
Normal file
274
web/tests/test_billing_webhooks.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
Integration tests for Paddle webhook handling.
|
||||
Covers signature verification, event parsing, subscription lifecycle transitions, and Hypothesis fuzzing.
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from hypothesis import HealthCheck, given
|
||||
from hypothesis import settings as h_settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from beanflows.billing.routes import get_subscription
|
||||
|
||||
from conftest import make_webhook_payload, sign_payload
|
||||
|
||||
|
||||
WEBHOOK_PATH = "/billing/webhook/paddle"
|
||||
SIG_HEADER = "Paddle-Signature"
|
||||
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Signature Verification
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class TestWebhookSignature:
|
||||
async def test_missing_signature_rejected(self, client, db):
|
||||
|
||||
payload = make_webhook_payload("subscription.activated")
|
||||
|
||||
payload_bytes = json.dumps(payload).encode()
|
||||
|
||||
response = await client.post(
|
||||
WEBHOOK_PATH,
|
||||
data=payload_bytes,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
assert response.status_code in (400, 401)
|
||||
|
||||
|
||||
async def test_invalid_signature_rejected(self, client, db):
|
||||
|
||||
payload = make_webhook_payload("subscription.activated")
|
||||
|
||||
payload_bytes = json.dumps(payload).encode()
|
||||
|
||||
response = await client.post(
|
||||
WEBHOOK_PATH,
|
||||
data=payload_bytes,
|
||||
headers={SIG_HEADER: "invalid_signature", "Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
assert response.status_code in (400, 401)
|
||||
|
||||
|
||||
async def test_valid_signature_accepted(self, client, db, test_user):
|
||||
|
||||
payload = make_webhook_payload("subscription.activated", user_id=str(test_user["id"]))
|
||||
payload_bytes = json.dumps(payload).encode()
|
||||
sig = sign_payload(payload_bytes)
|
||||
|
||||
|
||||
response = await client.post(
|
||||
WEBHOOK_PATH,
|
||||
data=payload_bytes,
|
||||
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
assert response.status_code in (200, 204)
|
||||
|
||||
|
||||
async def test_modified_payload_rejected(self, client, db, test_user):
|
||||
|
||||
payload = make_webhook_payload("subscription.activated", user_id=str(test_user["id"]))
|
||||
payload_bytes = json.dumps(payload).encode()
|
||||
sig = sign_payload(payload_bytes)
|
||||
tampered = payload_bytes + b"extra"
|
||||
|
||||
# Paddle/LemonSqueezy: HMAC signature verification fails before JSON parsing
|
||||
response = await client.post(
|
||||
WEBHOOK_PATH,
|
||||
data=tampered,
|
||||
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
|
||||
)
|
||||
assert response.status_code in (400, 401)
|
||||
|
||||
|
||||
async def test_empty_payload_rejected(self, client, db):
|
||||
|
||||
sig = sign_payload(b"")
|
||||
|
||||
|
||||
with pytest.raises(Exception): # JSONDecodeError in TESTING mode
|
||||
await client.post(
|
||||
WEBHOOK_PATH,
|
||||
data=b"",
|
||||
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Subscription Lifecycle Events
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestWebhookSubscriptionActivated:
|
||||
async def test_creates_subscription(self, client, db, test_user):
|
||||
payload = make_webhook_payload(
|
||||
"subscription.activated",
|
||||
user_id=str(test_user["id"]),
|
||||
plan="starter",
|
||||
)
|
||||
payload_bytes = json.dumps(payload).encode()
|
||||
sig = sign_payload(payload_bytes)
|
||||
|
||||
response = await client.post(
|
||||
WEBHOOK_PATH,
|
||||
data=payload_bytes,
|
||||
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
|
||||
)
|
||||
assert response.status_code in (200, 204)
|
||||
|
||||
sub = await get_subscription(test_user["id"])
|
||||
assert sub is not None
|
||||
assert sub["plan"] == "starter"
|
||||
assert sub["status"] == "active"
|
||||
|
||||
|
||||
class TestWebhookSubscriptionUpdated:
|
||||
async def test_updates_subscription_status(self, client, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_test456")
|
||||
|
||||
payload = make_webhook_payload(
|
||||
"subscription.updated",
|
||||
subscription_id="sub_test456",
|
||||
status="paused",
|
||||
)
|
||||
payload_bytes = json.dumps(payload).encode()
|
||||
sig = sign_payload(payload_bytes)
|
||||
|
||||
response = await client.post(
|
||||
WEBHOOK_PATH,
|
||||
data=payload_bytes,
|
||||
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
|
||||
)
|
||||
assert response.status_code in (200, 204)
|
||||
|
||||
sub = await get_subscription(test_user["id"])
|
||||
assert sub["status"] == "paused"
|
||||
|
||||
|
||||
class TestWebhookSubscriptionCanceled:
|
||||
async def test_marks_subscription_cancelled(self, client, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_test456")
|
||||
|
||||
payload = make_webhook_payload(
|
||||
"subscription.canceled",
|
||||
subscription_id="sub_test456",
|
||||
)
|
||||
payload_bytes = json.dumps(payload).encode()
|
||||
sig = sign_payload(payload_bytes)
|
||||
|
||||
response = await client.post(
|
||||
WEBHOOK_PATH,
|
||||
data=payload_bytes,
|
||||
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
|
||||
)
|
||||
assert response.status_code in (200, 204)
|
||||
|
||||
sub = await get_subscription(test_user["id"])
|
||||
assert sub["status"] == "cancelled"
|
||||
|
||||
|
||||
class TestWebhookSubscriptionPastDue:
|
||||
async def test_marks_subscription_past_due(self, client, db, test_user, create_subscription):
|
||||
await create_subscription(test_user["id"], status="active", paddle_subscription_id="sub_test456")
|
||||
|
||||
payload = make_webhook_payload(
|
||||
"subscription.past_due",
|
||||
subscription_id="sub_test456",
|
||||
)
|
||||
payload_bytes = json.dumps(payload).encode()
|
||||
sig = sign_payload(payload_bytes)
|
||||
|
||||
response = await client.post(
|
||||
WEBHOOK_PATH,
|
||||
data=payload_bytes,
|
||||
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
|
||||
)
|
||||
assert response.status_code in (200, 204)
|
||||
|
||||
sub = await get_subscription(test_user["id"])
|
||||
assert sub["status"] == "past_due"
|
||||
|
||||
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Parameterized: event → status transitions
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@pytest.mark.parametrize("event_type,expected_status", [
|
||||
("subscription.activated", "active"),
|
||||
("subscription.updated", "active"),
|
||||
("subscription.canceled", "cancelled"),
|
||||
("subscription.past_due", "past_due"),
|
||||
])
|
||||
async def test_event_status_transitions(client, db, test_user, create_subscription, event_type, expected_status):
|
||||
if event_type != "subscription.activated":
|
||||
await create_subscription(test_user["id"], paddle_subscription_id="sub_test456")
|
||||
|
||||
payload = make_webhook_payload(event_type, user_id=str(test_user["id"]))
|
||||
payload_bytes = json.dumps(payload).encode()
|
||||
sig = sign_payload(payload_bytes)
|
||||
|
||||
response = await client.post(
|
||||
WEBHOOK_PATH,
|
||||
data=payload_bytes,
|
||||
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
|
||||
)
|
||||
assert response.status_code in (200, 204)
|
||||
|
||||
sub = await get_subscription(test_user["id"])
|
||||
assert sub["status"] == expected_status
|
||||
|
||||
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Hypothesis: fuzz webhook payloads
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
fuzz_event_type = st.sampled_from([
|
||||
"subscription.activated",
|
||||
"subscription.updated",
|
||||
"subscription.canceled",
|
||||
"subscription.past_due",
|
||||
])
|
||||
fuzz_status = st.sampled_from(["active", "paused", "past_due", "canceled"])
|
||||
|
||||
|
||||
|
||||
@st.composite
|
||||
def fuzz_payload(draw):
|
||||
event_type = draw(fuzz_event_type)
|
||||
return make_webhook_payload(
|
||||
event_type=event_type,
|
||||
subscription_id=f"sub_{draw(st.integers(min_value=100, max_value=999999))}",
|
||||
user_id=str(draw(st.integers(min_value=1, max_value=999999))),
|
||||
status=draw(fuzz_status),
|
||||
)
|
||||
|
||||
|
||||
|
||||
class TestWebhookHypothesis:
|
||||
@given(payload_dict=fuzz_payload())
|
||||
@h_settings(max_examples=50, deadline=5000, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
async def test_webhook_never_500s(self, client, db, test_user, payload_dict):
|
||||
# Pin user_id to the test user so subscription_created/activated events don't hit FK violations
|
||||
|
||||
payload_dict["data"]["custom_data"]["user_id"] = str(test_user["id"])
|
||||
payload_bytes = json.dumps(payload_dict).encode()
|
||||
sig = sign_payload(payload_bytes)
|
||||
|
||||
|
||||
response = await client.post(
|
||||
WEBHOOK_PATH,
|
||||
data=payload_bytes,
|
||||
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
|
||||
)
|
||||
assert response.status_code < 500
|
||||
79
web/tests/test_dashboard.py
Normal file
79
web/tests/test_dashboard.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Tests for the coffee analytics dashboard.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_requires_login(client):
|
||||
"""Dashboard redirects unauthenticated users."""
|
||||
response = await client.get("/dashboard/")
|
||||
assert response.status_code == 302
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_loads(auth_client, mock_analytics):
|
||||
"""Dashboard renders with chart data for authenticated user."""
|
||||
response = await auth_client.get("/dashboard/")
|
||||
assert response.status_code == 200
|
||||
|
||||
body = (await response.get_data(as_text=True))
|
||||
assert "Coffee Dashboard" in body
|
||||
assert "Global Supply" in body
|
||||
assert "Stock-to-Use" in body
|
||||
assert "Top Producing Countries" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_shows_metric_cards(auth_client, mock_analytics):
|
||||
"""Dashboard shows key metric values from latest data."""
|
||||
response = await auth_client.get("/dashboard/")
|
||||
body = (await response.get_data(as_text=True))
|
||||
|
||||
# Latest production from mock: 172,000
|
||||
assert "172,000" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_yoy_table(auth_client, mock_analytics):
|
||||
"""Dashboard renders YoY production change table."""
|
||||
response = await auth_client.get("/dashboard/")
|
||||
body = (await response.get_data(as_text=True))
|
||||
|
||||
assert "Brazil" in body
|
||||
assert "Vietnam" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_free_plan_limits_history(auth_client, mock_analytics):
|
||||
"""Free plan should show limited history notice."""
|
||||
response = await auth_client.get("/dashboard/")
|
||||
body = (await response.get_data(as_text=True))
|
||||
|
||||
assert "Upgrade" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_free_plan_no_csv_export(auth_client, mock_analytics):
|
||||
"""Free plan should not show CSV export button."""
|
||||
response = await auth_client.get("/dashboard/")
|
||||
body = (await response.get_data(as_text=True))
|
||||
|
||||
assert "CSV export available on Starter" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_countries_page_loads(auth_client, mock_analytics):
|
||||
"""Country comparison page loads."""
|
||||
response = await auth_client.get("/dashboard/countries")
|
||||
assert response.status_code == 200
|
||||
|
||||
body = (await response.get_data(as_text=True))
|
||||
assert "Country Comparison" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_countries_page_with_selection(auth_client, mock_analytics):
|
||||
"""Country comparison with country params."""
|
||||
response = await auth_client.get("/dashboard/countries?country=BR&country=VN&metric=Production")
|
||||
assert response.status_code == 200
|
||||
Reference in New Issue
Block a user