ecaca35977
Make the admin Feedback section a real inbox.
* DB: feedback.read_at column (schema + idempotent migration).
* API: feedback list returns read_at; POST /api/admin/feedback/{id}/read
{read} toggles it; DELETE /api/admin/feedback/{id} removes a message
(both admin-gated). admin_stats gains feedback_unread; the Attention strip
and the tab badge now count UNREAD, not total.
* Frontend: unread messages are highlighted with an accent rail + dot; an
Unread filter joins the category chips; each message has Mark read/unread
and Delete (confirm), with optimistic updates that revert on failure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
75 lines
3.5 KiB
Python
75 lines
3.5 KiB
Python
from goodnews.db import connect, init_db
|
|
from goodnews import queries, localtime
|
|
|
|
|
|
def test_site_tz_from_env_and_fallback(monkeypatch):
|
|
monkeypatch.setenv("GOODNEWS_TZ", "America/New_York")
|
|
assert localtime.site_tz().key == "America/New_York"
|
|
assert localtime.local_now().utcoffset() is not None # tz-aware
|
|
# A bogus zone must not crash — fall back to UTC.
|
|
monkeypatch.setenv("GOODNEWS_TZ", "Not/AZone")
|
|
assert localtime.site_tz().key == "UTC"
|
|
|
|
|
|
def _seed(c):
|
|
c.execute("INSERT INTO sources (id,name,feed_url,active,default_category) VALUES (1,'Good','http://s/1',1,'science')")
|
|
c.execute("INSERT INTO sources (id,name,feed_url,active,consecutive_failures,poll_interval_minutes) "
|
|
"VALUES (2,'Flaky','http://s/2',1,5,60)")
|
|
# source 1: two accepted (one duplicate → not served), one rejected
|
|
for aid, dup in [(1, None), (2, None), (3, 1)]:
|
|
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,duplicate_of,published_at) "
|
|
"VALUES (?,1,?,?,?,?,datetime('now'))", (aid, f"u{aid}", f"T{aid}", f"h{aid}", dup))
|
|
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (1,1)")
|
|
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (2,0)")
|
|
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (3,1)") # duplicate → excluded
|
|
c.commit()
|
|
|
|
|
|
def test_content_stats_counts_served_excluding_duplicates(tmp_path):
|
|
c = connect(str(tmp_path / "t.db")); init_db(c); _seed(c)
|
|
cs = queries.content_stats(c)
|
|
assert cs["served"] == 1 # only article 1 (2 is rejected, 3 is a duplicate)
|
|
assert cs["total"] == 3
|
|
assert cs["rejected"] == 1
|
|
assert cs["active_sources"] == 2
|
|
|
|
|
|
def test_source_health_orders_failing_first_and_computes_next_due(tmp_path):
|
|
c = connect(str(tmp_path / "t.db")); init_db(c); _seed(c)
|
|
c.execute("INSERT INTO ingest_runs (source_id, finished_at, status) "
|
|
"VALUES (2, datetime('now','-30 minutes'), 'failed')")
|
|
c.commit()
|
|
sh = queries.source_health(c)
|
|
assert [s["name"] for s in sh][0] == "Flaky" # failing source floats to top
|
|
flaky = next(s for s in sh if s["name"] == "Flaky")
|
|
assert flaky["failures"] == 5
|
|
# next_due = last_attempt + 60*(1+5)=360 min; attempt was 30m ago → still resting
|
|
assert flaky["next_due_at"] is not None
|
|
good = next(s for s in sh if s["name"] == "Good")
|
|
assert good["served"] == 1 and good["next_due_at"] is None # never attempted → due now
|
|
|
|
|
|
def test_attention_flags_resting_flagged_and_brief():
|
|
from goodnews import queries
|
|
content = {"served": 100, "with_image": 90, "latest_brief_size": 5}
|
|
sources = [
|
|
{"failures": 3, "review_flag": 0},
|
|
{"failures": 0, "review_flag": 1},
|
|
{"failures": 0, "review_flag": 0},
|
|
]
|
|
items = queries._attention(content, sources, feedback_unread=2)
|
|
texts = " | ".join(i["text"] for i in items)
|
|
assert "1 source backing off" in texts # one resting
|
|
assert "1 source flagged" in texts # one flagged
|
|
assert "brief has only 5" in texts # brief < 7
|
|
assert "2 unread feedback" in texts # unread feedback
|
|
# 90/100 = 90% image coverage → no coverage warning
|
|
assert "Image coverage" not in texts
|
|
|
|
|
|
def test_attention_clear_when_healthy():
|
|
from goodnews import queries
|
|
content = {"served": 100, "with_image": 95, "latest_brief_size": 7}
|
|
sources = [{"failures": 0, "review_flag": 0}]
|
|
assert queries._attention(content, sources, feedback_unread=0) == []
|