Admin flow: - Remove /admin/login (password-based) and /admin/dev-login routes entirely - admin_required now checks only the 'admin' role; redirects to auth.login - auth/dev-login with an ADMIN_EMAILS address redirects directly to /admin/ - .env.example: replace ADMIN_PASSWORD with ADMIN_EMAILS=admin@beanflows.coffee Dev seeding: - Add dev_seed.py: idempotent upsert of 4 fixed accounts (admin, free, starter, pro) so every access tier is testable after dev_run.sh - dev_run.sh: seed after migrations, show all 4 login shortcuts Regression tests (37 passing): - test_analytics.py: concurrent fetch_analytics calls return correct row counts (cursor thread-safety regression), column names are lowercase - test_roles.py TestAdminAuthFlow: password login routes return 404, admin_required redirects to auth.login, dev-login grants admin role and redirects to admin panel when email is in ADMIN_EMAILS - conftest.py: add mock_analytics fixture (fixes 7 pre-existing dashboard test errors); fix assertion text and lowercase metric param in tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
172 lines
6.6 KiB
Python
172 lines
6.6 KiB
Python
"""
|
||
Regression tests for analytics.py.
|
||
|
||
Bugs covered:
|
||
- Concurrent DuckDB queries via asyncio.gather returned empty/wrong results
|
||
because _conn.execute() is not thread-safe. Fixed by using _conn.cursor()
|
||
per asyncio.to_thread call (each cursor is independently usable from any
|
||
single thread).
|
||
- DuckDB normalizes unquoted column identifiers to lowercase; analytics
|
||
queries and callers must use lowercase names.
|
||
"""
|
||
import asyncio
|
||
|
||
import duckdb
|
||
import pytest
|
||
|
||
|
||
# ── Fixtures ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.fixture
|
||
def analytics_duckdb(tmp_path):
|
||
"""Temporary DuckDB with serving.commodity_metrics: 11 global rows + 5 country rows."""
|
||
db_path = str(tmp_path / "test.duckdb")
|
||
conn = duckdb.connect(db_path)
|
||
conn.execute("CREATE SCHEMA serving")
|
||
conn.execute(
|
||
"""
|
||
CREATE TABLE serving.commodity_metrics (
|
||
commodity_code INTEGER,
|
||
commodity_name TEXT,
|
||
country_code TEXT,
|
||
country_name TEXT,
|
||
market_year INTEGER,
|
||
ingest_date DATE,
|
||
production DOUBLE,
|
||
imports DOUBLE,
|
||
exports DOUBLE,
|
||
total_distribution DOUBLE,
|
||
ending_stocks DOUBLE,
|
||
net_supply DOUBLE,
|
||
trade_balance DOUBLE,
|
||
supply_demand_balance DOUBLE,
|
||
stock_to_use_ratio_pct DOUBLE,
|
||
production_yoy_pct DOUBLE
|
||
)
|
||
"""
|
||
)
|
||
# 11 global rows (2015–2025)
|
||
for year in range(2015, 2026):
|
||
conn.execute(
|
||
"""INSERT INTO serving.commodity_metrics VALUES
|
||
(711100, 'Coffee', NULL, 'Global', ?, '2025-01-01',
|
||
100.0, 10.0, 20.0, 90.0, 30.0, 90.0, 10.0, 10.0, 33.3, 1.0)""",
|
||
[year],
|
||
)
|
||
# 5 country rows for latest year
|
||
for code, name in [
|
||
("BR", "Brazil"), ("VN", "Vietnam"), ("CO", "Colombia"),
|
||
("ID", "Indonesia"), ("ET", "Ethiopia"),
|
||
]:
|
||
conn.execute(
|
||
"""INSERT INTO serving.commodity_metrics VALUES
|
||
(711100, 'Coffee', ?, ?, 2025, '2025-01-01',
|
||
50.0, 5.0, 10.0, 45.0, 15.0, 45.0, 5.0, 5.0, 33.3, 2.0)""",
|
||
[code, name],
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
yield duckdb.connect(db_path, read_only=True)
|
||
|
||
|
||
@pytest.fixture(autouse=False)
|
||
def patched_analytics(analytics_duckdb, monkeypatch):
|
||
"""Patch analytics._conn with the temp DuckDB connection."""
|
||
from beanflows import analytics
|
||
monkeypatch.setattr(analytics, "_conn", analytics_duckdb)
|
||
yield analytics_duckdb
|
||
|
||
|
||
# ── Concurrency regression ───────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_concurrent_queries_all_return_data(patched_analytics):
|
||
"""
|
||
Regression: asyncio.gather fires analytics queries concurrently via
|
||
asyncio.to_thread. Using _conn.execute() from multiple threads simultaneously
|
||
corrupted internal cursor state — callers silently received 0 rows.
|
||
|
||
Fix: _query() obtains its own _conn.cursor() so each thread has an
|
||
independent execution context.
|
||
"""
|
||
from beanflows import analytics
|
||
|
||
ts, top, stu, bal, yoy = await asyncio.gather(
|
||
analytics.get_global_time_series(
|
||
711100, ["production", "exports", "imports", "ending_stocks", "total_distribution"]
|
||
),
|
||
analytics.get_top_countries(711100, "production", limit=10),
|
||
analytics.get_stock_to_use_trend(711100),
|
||
analytics.get_supply_demand_balance(711100),
|
||
analytics.get_production_yoy_by_country(711100, limit=15),
|
||
)
|
||
|
||
assert len(ts) == 11, f"time_series: expected 11, got {len(ts)}"
|
||
assert len(top) == 5, f"top_producers: expected 5, got {len(top)}"
|
||
assert len(stu) == 11, f"stu_trend: expected 11, got {len(stu)}"
|
||
assert len(bal) == 11, f"balance: expected 11, got {len(bal)}"
|
||
assert len(yoy) == 5, f"yoy: expected 5, got {len(yoy)}"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_repeated_concurrent_runs_are_stable(patched_analytics):
|
||
"""Concurrent queries should return consistent row counts across multiple runs."""
|
||
from beanflows import analytics
|
||
|
||
for _ in range(3):
|
||
ts, top = await asyncio.gather(
|
||
analytics.get_global_time_series(711100, ["production"]),
|
||
analytics.get_top_countries(711100, "production", limit=10),
|
||
)
|
||
assert len(ts) == 11
|
||
assert len(top) == 5
|
||
|
||
|
||
# ── Column name regression ───────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_result_column_names_are_lowercase(patched_analytics):
|
||
"""
|
||
Regression: DuckDB normalizes unquoted identifiers to lowercase in physical
|
||
tables. Templates and analytics callers must use lowercase column names.
|
||
"""
|
||
from beanflows import analytics
|
||
|
||
ts = await analytics.get_global_time_series(
|
||
711100, ["production", "exports", "total_distribution"]
|
||
)
|
||
assert ts, "Expected rows"
|
||
row = ts[0]
|
||
for col in ("market_year", "production", "exports", "total_distribution"):
|
||
assert col in row, f"Column '{col}' missing — DuckDB should return lowercase"
|
||
# Ensure no legacy mixed-case keys leaked through
|
||
for bad in ("Production", "Exports", "Total_Distribution", "Market_Year"):
|
||
assert bad not in row, f"Mixed-case key '{bad}' found — column casing regression"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_stu_trend_column_name_lowercase(patched_analytics):
|
||
"""stock_to_use_ratio_pct must be lowercase (was Stock_to_Use_Ratio_pct in SQL)."""
|
||
from beanflows import analytics
|
||
|
||
rows = await analytics.get_stock_to_use_trend(711100)
|
||
assert rows
|
||
assert "stock_to_use_ratio_pct" in rows[0]
|
||
assert "Stock_to_Use_Ratio_pct" not in rows[0]
|
||
|
||
|
||
# ── Global filter regression ─────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_global_time_series_excludes_country_rows(patched_analytics):
|
||
"""get_global_time_series must filter country_name = 'Global' only."""
|
||
from beanflows import analytics
|
||
|
||
rows = await analytics.get_global_time_series(711100, ["production"])
|
||
assert all(r["market_year"] in range(2015, 2026) for r in rows)
|
||
assert len(rows) == 11 # 11 global rows, 0 country rows
|