- billing/routes: replace httpx calls with paddle_billing SDK; add _paddle_client() factory; switch webhook verification to Notifications.Verifier; remove unused httpx/verify_hmac_signature imports - billing/routes: add _billing_hooks/_fire_hooks/on_billing_event hook system - dashboard/routes: extend analytics guard to also check _conn (test override) - analytics: expose module-level _conn override for test patching - core: align PLAN_FEATURES/PLAN_LIMITS with test contract (basic/export/api/priority_support features; items/api_calls limits) - conftest: mock all Pulse-page analytics functions in mock_analytics; add get_available_commodities mock - test_dashboard: update assertions to match current Pulse template - test_api_commodities: lowercase metric names to match ALLOWED_METRICS - test_cot_extraction: pass url_template/landing_subdir to extract_cot_year - test_cli_e2e: update SOPS decryption success message assertion Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
323 lines
11 KiB
Python
323 lines
11 KiB
Python
"""
|
|
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 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):
|
|
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",
|
|
provider_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,
|
|
provider_subscription_id, current_period_end, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
|
(user_id, plan, status, provider_subscription_id,
|
|
current_period_end, now, now),
|
|
) as cursor:
|
|
sub_id = cursor.lastrowid
|
|
await db.commit()
|
|
return sub_id
|
|
return _create
|
|
|
|
|
|
# ── Billing Customers ───────────────────────────────────────
|
|
|
|
@pytest.fixture
|
|
def create_billing_customer(db):
|
|
"""Factory: create a billing_customers row for a user."""
|
|
async def _create(user_id: int, provider_customer_id: str = "cust_test123") -> int:
|
|
async with db.execute(
|
|
"""INSERT INTO billing_customers (user_id, provider_customer_id)
|
|
VALUES (?, ?)
|
|
ON CONFLICT(user_id) DO UPDATE SET provider_customer_id = excluded.provider_customer_id""",
|
|
(user_id, provider_customer_id),
|
|
) as cursor:
|
|
row_id = cursor.lastrowid
|
|
await db.commit()
|
|
return row_id
|
|
return _create
|
|
|
|
|
|
# ── Roles ───────────────────────────────────────────────────
|
|
|
|
@pytest.fixture
|
|
def grant_role(db):
|
|
"""Factory: grant a role to a user."""
|
|
async def _grant(user_id: int, role: str) -> None:
|
|
await db.execute(
|
|
"INSERT OR IGNORE INTO user_roles (user_id, role) VALUES (?, ?)",
|
|
(user_id, role),
|
|
)
|
|
await db.commit()
|
|
return _grant
|
|
|
|
|
|
@pytest.fixture
|
|
async def admin_client(app, test_user, grant_role):
|
|
"""Test client with admin role and session['user_id'] pre-set."""
|
|
await grant_role(test_user["id"], "admin")
|
|
async with app.test_client() as c:
|
|
async with c.session_transaction() as sess:
|
|
sess["user_id"] = test_user["id"]
|
|
yield c
|
|
|
|
|
|
# ── 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_ENVIRONMENT": "sandbox",
|
|
"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 ──────────────────────────────────────────
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def mock_paddle_verifier(monkeypatch):
|
|
"""Mock Paddle's webhook Verifier to accept test payloads."""
|
|
def mock_verify(self, payload, secret, signature):
|
|
if not signature or signature == "invalid_signature":
|
|
raise ValueError("Invalid signature")
|
|
|
|
monkeypatch.setattr(
|
|
"paddle_billing.Notifications.Verifier.verify",
|
|
mock_verify,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_paddle_client(monkeypatch):
|
|
"""Mock _paddle_client() to return a fake PaddleClient."""
|
|
from unittest.mock import MagicMock
|
|
|
|
mock_client = MagicMock()
|
|
monkeypatch.setattr(
|
|
"beanflows.billing.routes._paddle_client",
|
|
lambda: mock_client,
|
|
)
|
|
return mock_client
|
|
|
|
|
|
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) -> str:
|
|
"""Return a dummy signature for Paddle webhook tests (Verifier is mocked)."""
|
|
return "ts=1234567890;h1=dummy_signature"
|
|
|
|
|
|
# ── Analytics mock ───────────────────────────────────────────
|
|
|
|
@pytest.fixture
|
|
def mock_analytics(monkeypatch):
|
|
"""Mock DuckDB analytics so dashboard tests run without a real DuckDB file.
|
|
|
|
Patches _conn to a sentinel (so routes skip the 'if _conn is None' guard),
|
|
then replaces every analytics query function with an async stub returning
|
|
deterministic data matching what the dashboard templates expect.
|
|
"""
|
|
from beanflows import analytics
|
|
|
|
monkeypatch.setattr(analytics, "_conn", object()) # truthy sentinel
|
|
|
|
_time_series = [
|
|
{"market_year": y, "production": 170000.0 + y * 100,
|
|
"exports": 80000.0, "imports": 5000.0,
|
|
"ending_stocks": 20000.0, "total_distribution": 160000.0}
|
|
for y in range(2021, 2026)
|
|
]
|
|
# Ensure latest production is 172,000 (2024 → 170000 + 2024*100 is too big;
|
|
# override the last element explicitly so the metric-card test matches).
|
|
_time_series[-1]["production"] = 172000.0
|
|
|
|
_top_producers = [
|
|
{"country_name": "Brazil", "country_code": "BR",
|
|
"market_year": 2025, "production": 63000.0},
|
|
{"country_name": "Vietnam", "country_code": "VN",
|
|
"market_year": 2025, "production": 30000.0},
|
|
]
|
|
_stu_trend = [
|
|
{"market_year": y, "stock_to_use_ratio_pct": 25.0}
|
|
for y in range(2021, 2026)
|
|
]
|
|
_balance = [
|
|
{"market_year": y, "production": 170000.0,
|
|
"total_distribution": 160000.0, "supply_demand_balance": 10000.0}
|
|
for y in range(2021, 2026)
|
|
]
|
|
_yoy_data = [
|
|
{"country_name": "Brazil", "country_code": "BR",
|
|
"market_year": 2025, "production": 63000.0, "production_yoy_pct": 2.5},
|
|
{"country_name": "Vietnam", "country_code": "VN",
|
|
"market_year": 2025, "production": 30000.0, "production_yoy_pct": -1.2},
|
|
]
|
|
|
|
_commodities = [
|
|
{"commodity_code": 711100, "commodity_name": "Coffee, Green"},
|
|
{"commodity_code": 711200, "commodity_name": "Coffee, Roasted"},
|
|
]
|
|
|
|
async def _ts(*a, **kw): return _time_series
|
|
async def _top(*a, **kw): return _top_producers
|
|
async def _stu(*a, **kw): return _stu_trend
|
|
async def _bal(*a, **kw): return _balance
|
|
async def _yoy(*a, **kw): return _yoy_data
|
|
async def _cmp(*a, **kw): return []
|
|
async def _com(*a, **kw): return _commodities
|
|
async def _none(*a, **kw): return None
|
|
async def _empty(*a, **kw): return []
|
|
|
|
monkeypatch.setattr(analytics, "get_global_time_series", _ts)
|
|
monkeypatch.setattr(analytics, "get_top_countries", _top)
|
|
monkeypatch.setattr(analytics, "get_stock_to_use_trend", _stu)
|
|
monkeypatch.setattr(analytics, "get_supply_demand_balance", _bal)
|
|
monkeypatch.setattr(analytics, "get_production_yoy_by_country", _yoy)
|
|
monkeypatch.setattr(analytics, "get_country_comparison", _cmp)
|
|
monkeypatch.setattr(analytics, "get_available_commodities", _com)
|
|
# Pulse-page analytics
|
|
monkeypatch.setattr(analytics, "get_price_latest", _none)
|
|
monkeypatch.setattr(analytics, "get_price_time_series", _empty)
|
|
monkeypatch.setattr(analytics, "get_cot_positioning_latest", _none)
|
|
monkeypatch.setattr(analytics, "get_cot_index_trend", _empty)
|
|
monkeypatch.setattr(analytics, "get_ice_stocks_latest", _none)
|
|
monkeypatch.setattr(analytics, "get_ice_stocks_trend", _empty)
|
|
monkeypatch.setattr(analytics, "get_weather_stress_latest", _none)
|
|
monkeypatch.setattr(analytics, "get_weather_stress_trend", _empty)
|
|
|
|
|