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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user