Admin step B: stats endpoint + /admin dashboard
- users.is_admin (+ migration); admin = is_admin OR email in GOODNEWS_ADMIN_EMAILS (normalized). is_admin exposed on /api/auth/me. Server-authorized GET /api/admin/stats (403 for non-admins). - queries.admin_stats: visitors (today/7d/30d), returning vs one-and-done, top opened articles, popular groupings + topics (derived from article_id at query time), share breakdown, daily opens/visits trend — all aggregate, no PII. - /admin page (gated, redirects non-admins): stat cards, CSS bar lists, a daily trend; "Admin dashboard" link on /account for admins. 129 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _make(tmp_path, monkeypatch, admin_email=""):
|
||||
db = tmp_path / "t.sqlite3"
|
||||
monkeypatch.setenv("GOODNEWS_DB", str(db))
|
||||
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "http://testserver")
|
||||
monkeypatch.setenv("GOODNEWS_ADMIN_EMAILS", admin_email)
|
||||
import importlib
|
||||
import goodnews.api as api
|
||||
importlib.reload(api)
|
||||
from goodnews.db import connect, init_db
|
||||
c = connect(str(db)); init_db(c)
|
||||
c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (1,'S','http://s/f',5)")
|
||||
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) VALUES (1,1,'http://s/1','t1','h1')")
|
||||
c.execute("INSERT INTO article_scores (article_id,accepted,topic) VALUES (1,1,'science')")
|
||||
c.execute("INSERT INTO article_tags (article_id,tag) VALUES (1,'science')")
|
||||
c.commit(); c.close()
|
||||
return api.create_app(), api
|
||||
|
||||
|
||||
def _signin(app, api, email):
|
||||
tc = TestClient(app)
|
||||
sent = {}
|
||||
import goodnews.email_send as es
|
||||
orig = es.send_magic_link
|
||||
es.send_magic_link = lambda to, link: sent.update(link=link)
|
||||
try:
|
||||
tc.post("/api/auth/email/start", json={"email": email})
|
||||
tc.post("/api/auth/email/verify", json={"token": sent["link"].split("token=")[1]})
|
||||
finally:
|
||||
es.send_magic_link = orig
|
||||
return tc
|
||||
|
||||
|
||||
def test_admin_gating(tmp_path, monkeypatch):
|
||||
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
||||
assert TestClient(app).get("/api/admin/stats").status_code == 401 # anon
|
||||
nonadmin = _signin(app, api, "rando@x.com")
|
||||
assert nonadmin.get("/api/admin/stats").status_code == 403 # signed in, not admin
|
||||
assert nonadmin.get("/api/auth/me").json()["is_admin"] is False
|
||||
admin = _signin(app, api, "Boss@X.com") # case-insensitive match
|
||||
assert admin.get("/api/auth/me").json()["is_admin"] is True
|
||||
assert admin.get("/api/admin/stats").status_code == 200
|
||||
|
||||
|
||||
def test_admin_stats_shape(tmp_path, monkeypatch):
|
||||
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
||||
admin = _signin(app, api, "boss@x.com")
|
||||
# log a couple of events
|
||||
admin.post("/api/events", json={"kind": "visit", "visitor": "v1"})
|
||||
admin.post("/api/events", json={"kind": "open", "article_id": 1, "visitor": "v1"})
|
||||
stats = admin.get("/api/admin/stats").json()
|
||||
assert set(stats) >= {"visitors", "returning", "once", "top_articles", "top_groupings", "top_topics", "shares", "daily"}
|
||||
assert stats["top_articles"][0]["id"] == 1 and stats["top_articles"][0]["opens"] == 1
|
||||
assert any(g["tag"] == "science" for g in stats["top_groupings"])
|
||||
Reference in New Issue
Block a user