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:
Deeman
2026-02-18 16:11:50 +01:00
parent b222c01828
commit 2748c606e9
59 changed files with 6272 additions and 2 deletions

247
web/tests/conftest.py Normal file
View 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

View 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"

View 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

View 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

View 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

View 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