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

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"