Reader mode presents the stories on a page one at a time in a focused overlay, marking each read as it's shown. Left/right arrows (or the header book button / `f`) page through them; read stories dim on the grid. Read state is device-local in localStorage. Backing this required actually capturing article bodies, which Pete wasn't doing — it kept only the RSS <description> lede and discarded content:encoded: - stories.content column (idempotent migration; old rows fall back to lede) - parser keeps content:encoded as paragraph-preserving text - article fetch already done for paywall detection now also returns its body, so ingest stores the richer of feed-content vs scraped body with no extra request (prefers the archive snapshot body for paywalled stories) - GET /api/article?id= serves the stored text; card queries now select id and expose it as data-id for the reader Tests cover content extraction, the storage round-trip, and the article endpoint + card rendering end to end.
96 lines
2.7 KiB
Go
96 lines
2.7 KiB
Go
package web
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"pete/internal/config"
|
|
"pete/internal/storage"
|
|
)
|
|
|
|
// TestReaderCardDataAndArticleAPI exercises the reader-mode path end to end: a
|
|
// classified story renders a card carrying its id + headline data attributes,
|
|
// and /api/article returns the stored full text for that id.
|
|
func TestReaderCardDataAndArticleAPI(t *testing.T) {
|
|
storage.Close()
|
|
if err := storage.Init(filepath.Join(t.TempDir(), "reader.db")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { storage.Close() })
|
|
|
|
story := &storage.Story{
|
|
GUID: "reader-e2e",
|
|
Headline: "A Distinctive Reader Headline",
|
|
Lede: "The lede.",
|
|
Content: "Opening paragraph of the piece.\n\nA second paragraph with more detail.",
|
|
ArticleURL: "https://example.com/story",
|
|
Source: "Example Wire",
|
|
Channel: "tech",
|
|
Classified: true,
|
|
SeenAt: time.Now().Unix(),
|
|
}
|
|
if err := storage.InsertStory(story); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var id int64
|
|
if err := storage.Get().QueryRow(`SELECT id FROM stories WHERE guid = ?`, story.GUID).Scan(&id); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Index page renders the card with the reader data attributes.
|
|
rw := httptest.NewRecorder()
|
|
s.handleIndex(rw, httptest.NewRequest("GET", "/", nil))
|
|
body := rw.Body.String()
|
|
if rw.Code != 200 {
|
|
t.Fatalf("index status = %d", rw.Code)
|
|
}
|
|
for _, want := range []string{
|
|
`data-id="` + strconv.FormatInt(id, 10) + `"`,
|
|
`data-headline="A Distinctive Reader Headline"`,
|
|
`data-story-card`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("index HTML missing %q", want)
|
|
}
|
|
}
|
|
|
|
// The article endpoint returns the stored content for that id.
|
|
rw2 := httptest.NewRecorder()
|
|
s.handleArticle(rw2, httptest.NewRequest("GET", "/api/article?id="+strconv.FormatInt(id, 10), nil))
|
|
if rw2.Code != 200 {
|
|
t.Fatalf("article status = %d body=%s", rw2.Code, rw2.Body.String())
|
|
}
|
|
var got struct {
|
|
Content string `json:"content"`
|
|
Lede string `json:"lede"`
|
|
}
|
|
if err := json.Unmarshal(rw2.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if got.Content != story.Content {
|
|
t.Errorf("content = %q, want %q", got.Content, story.Content)
|
|
}
|
|
|
|
// A bad id is a 400, an unknown id is a 404.
|
|
for _, tc := range []struct {
|
|
q string
|
|
code int
|
|
}{{"id=0", 400}, {"id=abc", 400}, {"id=999999", 404}} {
|
|
w := httptest.NewRecorder()
|
|
s.handleArticle(w, httptest.NewRequest("GET", "/api/article?"+tc.q, nil))
|
|
if w.Code != tc.code {
|
|
t.Errorf("article?%s status = %d, want %d", tc.q, w.Code, tc.code)
|
|
}
|
|
}
|
|
}
|