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:
129
web/tests/test_api_commodities.py
Normal file
129
web/tests/test_api_commodities.py
Normal 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"
|
||||
Reference in New Issue
Block a user