Files
beanflows/web/tests/test_analytics.py
Deeman d09ba91023 Remove password admin login, seed dev accounts, add regression tests
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>
2026-02-20 20:10:45 +01:00

172 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 (20152025)
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