Files
upbeatBytes/tests/test_joys_admin.py
thejayman77 cebbed58ab WOTD #4/#5 content quality + Editorial Asymmetric /word page (CD)
Content quality ("LLM polishes, dictionary anchors"):
- New wotd._polish: rewrites the real dictionary gloss into ONE warm plain
  sentence + two clear everyday example sentences, grounded in the real
  definition (no invented meanings). Stored in new wotd_pool/daily_wotd columns
  gloss + usage, alongside the raw definition/examples which stay the anchor.
- harvest() polishes each new word; pick_daily() lazily polishes + caches back
  any older pooled word that lacks a gloss (client threaded through run_daily).
- Admin word-add polishes on insert; re-pick passes an LLM client so quote
  meaning / word gloss fill on a forced fresh pick.
- /api/word/today now prefers gloss + usage, falling back to the raw dictionary
  def/examples when polish is absent (so it's always safe).
- db._migrate adds gloss/usage to wotd_pool + daily_wotd (idempotent ALTER).

Frontend — /word redesigned to CD's "Editorial Asymmetric": faded oversized
initial bleeding off the right, vertical part-of-speech rail, big Newsreader
word, airy definition, left-ruled italic example sentences, outline Listen
button + date. (Uses our self-hosted Newsreader/Hanken stack rather than the
mockup's Google fonts; the made-up syllable respelling is omitted since we only
have real IPA.)

Tests: _polish parse/trim/cap, harvest stores gloss/usage, pick lazy-polishes
older words, admin gloss flows through to /api/word/today. 403 backend + 27 fe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 06:08:14 -04:00

100 lines
5.0 KiB
Python
Raw Permalink 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.
"""Small-joys admin API — route resolution (add/repick must NOT parse as an item id),
auth gating, add/feature/block/delete with numeric ids, and re-pick excluding the
currently-shown item."""
import pytest
from fastapi.testclient import TestClient
from goodnews.db import connect, init_db
@pytest.fixture
def api_app(tmp_path, monkeypatch):
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@b.com")
monkeypatch.setenv("GOODNEWS_LLM_BASE_URL", "http://127.0.0.1:9") # dead → no LLM, fast
import importlib
import goodnews.api as api
importlib.reload(api)
c = connect(str(db)); init_db(c)
c.close()
return api.create_app()
def _admin(app):
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": "admin@b.com"})
tc.post("/api/auth/email/verify", json={"token": sent["link"].split("token=")[1]})
finally:
es.send_magic_link = orig
return tc
def test_add_and_repick_require_admin_not_422(api_app):
"""The blocker: /add and /repick must resolve to their own routes (401 unauth),
not be swallowed by /{item_id} and 422 on int parsing."""
anon = TestClient(api_app)
for kind in ("quote", "word", "onthisday"):
assert anon.post(f"/api/admin/joys/{kind}/add", json={}).status_code == 401
assert anon.post(f"/api/admin/joys/{kind}/repick").status_code == 401
assert anon.get(f"/api/admin/joys/{kind}").status_code == 401
assert anon.post(f"/api/admin/joys/{kind}/items/1",
json={"action": "feature"}).status_code == 401
def test_quote_add_feature_block_delete(api_app):
tc = _admin(api_app)
r = tc.post("/api/admin/joys/quote/add",
json={"text": "A small kindness echoes far.", "author": "Anon"})
assert r.status_code == 200 and r.json()["ok"] is True
items = tc.get("/api/admin/joys/quote").json() # endpoint returns a bare list
qid = next(it["id"] for it in items if "kindness echoes" in (it.get("text") or ""))
assert tc.post(f"/api/admin/joys/quote/items/{qid}", json={"action": "feature"}).json()["ok"]
assert tc.post(f"/api/admin/joys/quote/items/{qid}", json={"action": "block"}).json()["ok"]
assert tc.post(f"/api/admin/joys/quote/items/{qid}", json={"action": "delete"}).json()["ok"]
remaining = tc.get("/api/admin/joys/quote").json()
assert all(it["id"] != qid for it in remaining)
def test_repick_excludes_current(api_app):
tc = _admin(api_app)
tc.post("/api/admin/joys/quote/add", json={"text": "First light is a fresh start.", "author": "A"})
tc.post("/api/admin/joys/quote/add", json={"text": "Every step forward counts.", "author": "B"})
assert tc.post("/api/admin/joys/quote/repick").json()["picked"] is True # establish today's pick
first = tc.get("/api/quote/today").json()
assert tc.post("/api/admin/joys/quote/repick").json()["picked"] is True # force a fresh one
second = tc.get("/api/quote/today").json()
assert second["text"] != first["text"] # a genuinely different item
def test_word_add_and_repick(api_app, monkeypatch):
import goodnews.wotd as wotd
# admin word-add validates against the dictionary + caches audio — stub both (no network)
fake = {"serene": {"word": "serene", "part_of_speech": "adjective", "phonetic": "/sɪˈriːn/",
"audio_url": None, "definition": "Calm, peaceful, untroubled.", "examples": []},
"luminous": {"word": "luminous", "part_of_speech": "adjective", "phonetic": "/ˈluːmɪnəs/",
"audio_url": None, "definition": "Giving off light; radiant.", "examples": []}}
monkeypatch.setattr(wotd, "_lookup", lambda w, prefer_pos=None: fake.get(w))
monkeypatch.setattr(wotd, "_cache_audio", lambda url, word: None)
monkeypatch.setattr(wotd, "_polish", lambda c, w, pos, d: {"gloss": f"{w} means lovely.", "examples": [f"What a {w} morning."]})
tc = _admin(api_app)
assert tc.post("/api/admin/joys/word/add", json={"word": "serene"}).json()["ok"]
assert tc.post("/api/admin/joys/word/add", json={"word": "luminous"}).json()["ok"]
items = tc.get("/api/admin/joys/word").json()
assert {"serene", "luminous"} <= {it.get("word") for it in items}
assert tc.post("/api/admin/joys/word/repick").json()["picked"] is True
first = tc.get("/api/word/today").json()
# the LLM-polished gloss + sentence are what the page shows (not the raw dictionary def)
assert first["definition"] == f"{first['word']} means lovely."
assert first["examples"] == [f"What a {first['word']} morning."]
assert tc.post("/api/admin/joys/word/repick").json()["picked"] is True
second = tc.get("/api/word/today").json()
assert second["word"] != first["word"]