0c68c22221
Per the brand-name standard (camelCase, one word). Updated the SMTP From default and the digest email body/subject strings. Live env From values (auth.env + goodnews.env) updated to match. (Web/OG brand strings in share.py + app.html are the remaining sweep.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
92 lines
5.1 KiB
Python
92 lines
5.1 KiB
Python
from goodnews.db import connect, init_db
|
|
from goodnews import digest, email_send
|
|
|
|
|
|
def _seed(c, n=5, date="2026-06-09"):
|
|
bid = c.execute("INSERT INTO daily_briefs (brief_date, title) VALUES (?, 't')", (date,)).lastrowid
|
|
for i in range(1, n + 1):
|
|
c.execute("INSERT INTO sources (id,name,feed_url,active,content_visible) VALUES (?,?,?,1,1)",
|
|
(i, f"Src{i}", f"http://s{i}/f"))
|
|
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) VALUES (?,?,?,?,?)",
|
|
(i, i, f"http://a/{i}", f"Title {i}", f"h{i}"))
|
|
c.execute("INSERT INTO article_scores (article_id,accepted,reason_text) VALUES (?,1,?)", (i, f"reason {i}"))
|
|
c.execute("INSERT INTO article_summaries (article_id,summary) VALUES (?,?)", (i, f"summary {i}"))
|
|
c.execute("INSERT INTO daily_brief_items (brief_id,article_id,rank) VALUES (?,?,?)", (bid, i, i))
|
|
c.execute("INSERT INTO users (id,email,digest_enabled) VALUES (1,'reader@x.com',1)")
|
|
c.commit()
|
|
return date
|
|
|
|
|
|
def test_build_digest_is_calm_and_dated():
|
|
items = [{"id": 1, "title": "Good thing", "canonical_url": "http://a/1", "source": "Src", "summary": "nice", "reason_text": "wonder", "paywalled": False}]
|
|
subject, text, html = digest.build_digest(items, "2026-06-09", "http://ub/unsub")
|
|
assert "Tuesday's upbeatBytes" in subject and "1 calm read" in subject
|
|
assert "Daily Highlights" in text and "Daily Highlights" in html
|
|
assert "more good news is always" in text and "http://ub/unsub" in text # points back to the site
|
|
assert "Good thing" in html and "Read on upbeatBytes" in html and "Unsubscribe" in html
|
|
assert "you missed" not in (text + html).lower() # no guilt language
|
|
|
|
|
|
def test_send_due_digests_sends_dedupes_and_skips(monkeypatch, tmp_path):
|
|
sent = []
|
|
monkeypatch.setattr(email_send, "send_email",
|
|
lambda to, subj, text, html=None, headers=None, **k: sent.append((to, headers)))
|
|
c = connect(str(tmp_path / "d.db")); init_db(c)
|
|
date = _seed(c, n=5)
|
|
monkeypatch.setattr(digest, "local_today", lambda: date)
|
|
# force=True bypasses the morning window
|
|
assert digest.send_due_digests(c, force=True) == 1
|
|
assert sent[0][0] == "reader@x.com"
|
|
hdrs = sent[0][1] # RFC 8058 one-click unsubscribe headers present
|
|
assert "List-Unsubscribe" in hdrs and hdrs["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
|
|
assert c.execute("SELECT item_count FROM digest_sends WHERE user_id=1").fetchone()[0] == 5
|
|
# dedupe: a second run sends nothing
|
|
assert digest.send_due_digests(c, force=True) == 0
|
|
# unsub token was generated on send
|
|
assert c.execute("SELECT digest_unsub_token FROM users WHERE id=1").fetchone()[0]
|
|
|
|
|
|
def test_send_skips_thin_day(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(email_send, "send_email", lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not send")))
|
|
c = connect(str(tmp_path / "d.db")); init_db(c)
|
|
date = _seed(c, n=3) # below MIN_ITEMS (4)
|
|
monkeypatch.setattr(digest, "local_today", lambda: date)
|
|
assert digest.send_due_digests(c, force=True) == 0
|
|
|
|
|
|
def test_send_respects_opt_out(monkeypatch, tmp_path):
|
|
sent = []
|
|
monkeypatch.setattr(email_send, "send_email", lambda to, *a, **k: sent.append(to))
|
|
c = connect(str(tmp_path / "d.db")); init_db(c)
|
|
date = _seed(c, n=5)
|
|
c.execute("UPDATE users SET digest_enabled=0 WHERE id=1"); c.commit()
|
|
monkeypatch.setattr(digest, "local_today", lambda: date)
|
|
assert digest.send_due_digests(c, force=True) == 0 and sent == []
|
|
|
|
|
|
def test_followed_digest_items_caps_excludes_and_section(tmp_path):
|
|
c = connect(str(tmp_path / "fd.db")); init_db(c)
|
|
date = _seed(c, n=5) # brief sources/articles 1..5 + user 1
|
|
# two NON-brief accepted articles from followed source 1 → cap should keep 1
|
|
for aid in (10, 11):
|
|
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) VALUES (?,1,?,?,?)",
|
|
(aid, f"http://a/{aid}", f"Followed {aid}", f"h{aid}"))
|
|
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (?,1)", (aid,))
|
|
c.execute("INSERT INTO user_follows (user_id,kind,value) VALUES (1,'source','1')")
|
|
c.commit()
|
|
fol = digest.followed_digest_items(c, 1, exclude_ids=[1, 2, 3, 4, 5], limit=3)
|
|
assert len(fol) == 1 and fol[0]["source_id"] == 1 # one-per-source cap, brief ids excluded
|
|
assert fol[0]["id"] in (10, 11)
|
|
# section appears with followed items, omitted without (brief stays the star)
|
|
brief = [{"id": 1, "title": "B", "canonical_url": "http://b/1", "source": "S", "summary": "s", "reason_text": "r", "paywalled": False}]
|
|
_, text, html = digest.build_digest(brief, "2026-06-09", "http://u", followed=fol)
|
|
assert "From what you follow" in html and "From what you follow" in text
|
|
_, _, html2 = digest.build_digest(brief, "2026-06-09", "http://u", followed=[])
|
|
assert "From what you follow" not in html2
|
|
|
|
|
|
def test_followed_digest_items_empty_when_no_follows(tmp_path):
|
|
c = connect(str(tmp_path / "fd2.db")); init_db(c)
|
|
_seed(c, n=5)
|
|
assert digest.followed_digest_items(c, 1, exclude_ids=[], limit=3) == []
|