cebbed58ab
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>
100 lines
5.0 KiB
Python
100 lines
5.0 KiB
Python
"""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"]
|