Follow source/topic — account-backed personalization (v1)
Per Codex — turn accounts into a real reason to return, without an algorithmic feed. Durable interests (sources + tags), not moods. * DB: user_follows (user_id, kind source|tag, value, unique). * queries.feed gains follow_sources/follow_tags → the Following feed is "articles from a followed source OR carrying a followed tag", still respecting calm filters/boundaries. * API: GET/POST/DELETE /api/follows (sign-in required; source ids validated); /api/feed?following=true resolves the user's follows (anon → empty, not error). * Frontend: follows store (followKeys + toggleFollow, mirrors savedIds); a Follow button on source + tag/topic views; a "Following" lane in the nav with a tailored empty state; a Following management section in Account (unfollow). Digest "From what you follow" deferred to v2 (brief stays first). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -235,3 +235,33 @@ def test_digest_toggle_and_unsubscribe(tmp_path, monkeypatch):
|
||||
assert TestClient(app).post(f"/api/digest/unsubscribe?u={uid}&t={tok}").json() == {"ok": True}
|
||||
assert tc.get("/api/auth/me").json()["digest_enabled"] is False
|
||||
assert TestClient(app).post("/api/account/digest", json={"enabled": True}).status_code == 401 # gated
|
||||
|
||||
|
||||
def test_follows_and_following_feed(tmp_path, monkeypatch):
|
||||
import os, sqlite3
|
||||
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
||||
c = sqlite3.connect(os.environ["GOODNEWS_DB"])
|
||||
c.execute("INSERT INTO sources (id,name,feed_url) VALUES (2,'Other','http://o/f')")
|
||||
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) VALUES (2,2,'http://o/2','t2','h2')")
|
||||
c.execute("INSERT INTO article_scores (article_id,accepted,topic) VALUES (2,1,'tech')")
|
||||
c.commit(); c.close()
|
||||
tc = _signin(app, api, "reader@x.com")
|
||||
# no follows yet → empty following feed (not an error)
|
||||
assert tc.get("/api/feed?following=true").json()["count"] == 0
|
||||
# follow source 1 → only its article
|
||||
assert tc.post("/api/follows", json={"kind": "source", "value": "1"}).json()["ok"] is True
|
||||
assert any(x["value"] == "1" and x["name"] == "S" for x in tc.get("/api/follows").json())
|
||||
assert [i["id"] for i in tc.get("/api/feed?following=true").json()["items"]] == [1]
|
||||
# follow source 2 too → both
|
||||
tc.post("/api/follows", json={"kind": "source", "value": "2"})
|
||||
assert {i["id"] for i in tc.get("/api/feed?following=true").json()["items"]} == {1, 2}
|
||||
# follow tag works too (article 1 carries 'science')
|
||||
tc.post("/api/follows", json={"kind": "tag", "value": "Science"}) # normalized lower
|
||||
assert any(x["kind"] == "tag" and x["value"] == "science" for x in tc.get("/api/follows").json())
|
||||
# unfollow source 2 (DELETE via query) → back to {1}
|
||||
tc.delete("/api/follows?kind=source&value=2")
|
||||
assert {i["id"] for i in tc.get("/api/feed?following=true").json()["items"]} == {1}
|
||||
# anon: following feed empty, follows API gated, bad source 404
|
||||
assert TestClient(app).get("/api/feed?following=true").json()["count"] == 0
|
||||
assert TestClient(app).get("/api/follows").status_code == 401
|
||||
assert tc.post("/api/follows", json={"kind": "source", "value": "999"}).status_code == 404
|
||||
|
||||
Reference in New Issue
Block a user