Accounts Phase 3: save articles, account history, device import
- API (auth-required): GET/POST/DELETE /api/saved (+/api/saved/ids), GET/POST /api/history, POST /api/import — all FK-safe (skip ids that no longer exist). queries.saved/saved_ids/history reuse the feed article shape. - Frontend: reactive savedIds store (SvelteSet) + optimistic toggleSave; a Save control on cards for signed-in users; a "Saved" view (You sheet) with its own empty state; newly-seen items mirror to account history (cross-device); and a one-time import folds this device's anonymous history into the account on first sign-in. Anonymous browsing unchanged. 115 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
db = tmp_path / "t.sqlite3"
|
||||
monkeypatch.setenv("GOODNEWS_DB", str(db))
|
||||
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "http://testserver")
|
||||
import importlib
|
||||
import goodnews.api as api
|
||||
importlib.reload(api)
|
||||
from goodnews.db import connect, init_db
|
||||
c = connect(str(db)); init_db(c)
|
||||
c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (1,'S','http://s/f',5)")
|
||||
for aid in (1, 2, 3):
|
||||
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) "
|
||||
"VALUES (?,1,?,?,?)", (aid, f"http://s/{aid}", f"t{aid}", f"h{aid}"))
|
||||
c.commit(); c.close()
|
||||
return api.create_app(), api
|
||||
|
||||
|
||||
def _signed_in(app, api):
|
||||
"""A TestClient with an authenticated session cookie."""
|
||||
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": "a@b.com"})
|
||||
token = sent["link"].split("token=")[1]
|
||||
tc.post("/api/auth/email/verify", json={"token": token})
|
||||
finally:
|
||||
es.send_magic_link = orig
|
||||
return tc
|
||||
|
||||
|
||||
def test_saved_requires_auth(client):
|
||||
app, _ = client
|
||||
assert TestClient(app).get("/api/saved").status_code == 401
|
||||
assert TestClient(app).post("/api/saved/1").status_code == 401
|
||||
|
||||
|
||||
def test_save_unsave_and_list(client):
|
||||
app, api = client
|
||||
tc = _signed_in(app, api)
|
||||
assert tc.get("/api/saved/ids").json() == []
|
||||
assert tc.post("/api/saved/1").json() == {"saved": True}
|
||||
assert tc.post("/api/saved/2").json() == {"saved": True}
|
||||
assert tc.post("/api/saved/1").json() == {"saved": True} # idempotent
|
||||
assert sorted(tc.get("/api/saved/ids").json()) == [1, 2]
|
||||
body = tc.get("/api/saved").json()
|
||||
assert body["count"] == 2 and {a["id"] for a in body["items"]} == {1, 2}
|
||||
assert tc.post("/api/saved/999").status_code == 404 # unknown article
|
||||
tc.delete("/api/saved/1")
|
||||
assert tc.get("/api/saved/ids").json() == [2]
|
||||
|
||||
|
||||
def test_history_and_import(client):
|
||||
app, api = client
|
||||
tc = _signed_in(app, api)
|
||||
tc.post("/api/history", json={"ids": [1, 2, 2, 999]}) # 999 ignored (FK-safe)
|
||||
hist = tc.get("/api/history").json()
|
||||
assert {a["id"] for a in hist["items"]} == {1, 2}
|
||||
# import folds device-local seen + saved (unknown ids dropped)
|
||||
tc.post("/api/import", json={"seen": [3], "saved": [1, 999]})
|
||||
assert {a["id"] for a in tc.get("/api/history").json()["items"]} == {1, 2, 3}
|
||||
assert tc.get("/api/saved/ids").json() == [1]
|
||||
Reference in New Issue
Block a user