"""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"]