diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 8dc3dcc..87cce14 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -122,6 +122,14 @@ try { await postJSON(`/api/admin/sources/${s.id}/review`, { flag: false }); } catch { s.review_flag = 1; s.review_reason = prevR; } } + // Read-only spot-check of a live source — never mutates source state. + async function checkSource(s) { + s._checking = true; s._checkErr = ''; s._check = null; + try { s._check = await postJSON(`/api/admin/sources/${s.id}/preview`); } + catch (e) { s._checkErr = e?.message || 'Could not preview.'; } + finally { s._checking = false; } + } + function dismissCheck(s) { s._check = null; s._checkErr = ''; } // --- Source candidates: supervised "add a source" pipeline --- let candidates = $state([]); @@ -484,8 +492,28 @@ {/if} + + {#if s._checking || s._check || s._checkErr} + + + {#if s._checking} + Checking {s.name}… + {:else if s._checkErr} + Couldn't preview: {s._checkErr} + {:else if s._check} +
+ {s._check.accepted ?? 0}/{s._check.sampled ?? 0} would pass{#if s._check.acceptance_rate != null} · {Math.round(s._check.acceptance_rate * 100)}%{/if}{#if s._check.recent_7d != null} · {s._check.recent_7d} in last 7d{/if} + checked just now + +
+ {#if s._check.examples_accepted?.length}
would accept: {s._check.examples_accepted.slice(0, 3).join(' · ')}
{/if} + {#if s._check.examples_rejected?.length}
would skip: {s._check.examples_rejected.slice(0, 3).map((r) => r.title).join(' · ')}
{/if} + {/if} + + + {/if} {/each} @@ -808,6 +836,12 @@ .srctable .status .good { color: #3f7048; } .srctable .status .paustxt { color: var(--muted); font-style: italic; } .srctable .status .hidtxt { color: #9a3b3b; font-size: 0.78rem; } + .srctable tr.checkrow td { background: var(--bg); font-size: 0.85rem; padding: 10px 12px; } + .chkhead { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } + .chkhead .chkwhen { color: var(--muted); font-size: 0.76rem; } + .chkex { margin-top: 5px; color: var(--ink); } + .chkex .chklbl { color: var(--muted); } + .chkex.chkrej { color: var(--muted); } .srctable .rowactions { white-space: nowrap; } .srctable .rowactions .act { background: none; border: 1px solid var(--line); color: var(--accent-deep); diff --git a/goodnews/api.py b/goodnews/api.py index 077c5e2..e07c148 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -933,6 +933,18 @@ def create_app() -> FastAPI: conn.commit() return {"ok": True, "visible": body.visible} + @app.post("/api/admin/sources/{sid}/preview") + def admin_source_preview(sid: int, request: Request) -> dict: + # Read-only spot-check of a LIVE source: safe-fetch + heuristic preview. + # Mutates nothing — no DB write, no poll attempt, no health/state change. + with get_conn() as conn: + _require_admin(conn, request) + src = conn.execute("SELECT feed_url FROM sources WHERE id = ?", (sid,)).fetchone() + if not src: + raise HTTPException(status_code=404, detail="source not found") + url = src["feed_url"] + return _preview_or_502(url) # safe fetch, no DB connection held + # --- Source candidates (supervised add-a-source pipeline) ---------------- def _candidate_dict(row) -> dict: diff --git a/tests/test_admin.py b/tests/test_admin.py index 3af7ef4..0dc760b 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -198,3 +198,18 @@ def test_export_sources_csv_escapes_formula_injection(tmp_path, monkeypatch): assert "'=HYPERLINK" in body # leading apostrophe defuses the formula (CSV may quote the cell) assert "'+danger" in body assert ",=HYPERLINK" not in body # never written as a bare, evaluable formula cell + + +def test_source_check_preview_is_readonly(tmp_path, monkeypatch): + app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com") + monkeypatch.setattr(api.feeds, "preview_feed", lambda url, **k: {"url": url, "sampled": 8, "accepted": 6}) + tc = _signin(app, api, "boss@x.com") + before = _src(tc) + r = tc.post("/api/admin/sources/1/preview").json() + assert r["sampled"] == 8 and r["accepted"] == 6 + after = _src(tc) + # read-only: no state/health change, no poll attempt recorded + assert after["status"] == before["status"] and after["served"] == before["served"] + assert after["last_success_at"] == before["last_success_at"] and after["next_due_at"] == before["next_due_at"] + assert TestClient(app).post("/api/admin/sources/1/preview").status_code == 401 # gated + assert tc.post("/api/admin/sources/999/preview").status_code == 404