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