Merge ticket/144-series-detail
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -2243,3 +2244,228 @@ func TestSeriesListRowShape(t *testing.T) {
|
||||
t.Errorf("the page content carries an ember token:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The per-Series page renders every Series-level fact the admin read model
|
||||
// holds for the key the list row already shows: title, the composite key with
|
||||
// Site and kind, the Latest Chapter, the check age and the anonymous Reader
|
||||
// count. No Reader identity or progress may appear anywhere in the response.
|
||||
func TestAdminSeriesDetailRendersFacts(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
// Two Readers hold the same Series, so the anonymous count is 2.
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "kagane:sp-baby", Site: "kagane", SeriesID: "sp-baby",
|
||||
Title: "SP Baby", SeriesURL: "https://kagane.to/series/sp-baby",
|
||||
Kind: "manga", LatestChapter: "Chapter 45", LatestChapterNum: floatPtr(45),
|
||||
})
|
||||
other, err := st.EnsureReader("reader-two", sha256.Sum256([]byte("reader-two-hash")))
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureReader: %v", err)
|
||||
}
|
||||
if _, err := st.Upsert(other, store.Bookmark{
|
||||
Key: "kagane:sp-baby", Site: "kagane", SeriesID: "sp-baby",
|
||||
Title: "SP Baby", SeriesURL: "https://kagane.to/series/sp-baby",
|
||||
Kind: "manga", LatestChapter: "Chapter 45", LatestChapterNum: floatPtr(45),
|
||||
}); err != nil {
|
||||
t.Fatalf("Upsert second reader: %v", err)
|
||||
}
|
||||
if err := st.MarkLatestChecked("kagane", "sp-baby", time.Now().Add(-2*time.Hour).UnixMilli()); err != nil {
|
||||
t.Fatalf("MarkLatestChecked: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/series/kagane:sp-baby", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /admin/series/kagane:sp-baby status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{
|
||||
"SP Baby",
|
||||
"kagane:sp-baby · kagane · manga",
|
||||
"ch 45",
|
||||
"checked 2h0m",
|
||||
"2 readers",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("series detail lacks %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A Series the poller has never read renders the never-read state — "ch —" and
|
||||
// "checked never" — rather than a confident zero, and a Series no Reader holds
|
||||
// renders its count as "0 readers" rather than as a blank.
|
||||
func TestAdminSeriesDetailNeverReadOrphanState(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:abandoned", Site: "asura", SeriesID: "abandoned",
|
||||
Title: "Abandoned", SeriesURL: "https://asurascans.com/series/abandoned",
|
||||
Kind: "manga",
|
||||
})
|
||||
// Removing the only Bookmark orphans the Series: the row outlives it.
|
||||
if err := st.Delete(st.OwnerID(), "asura:abandoned"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/series/asura:abandoned", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"ch —", "checked never", "0 readers"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("never-read orphan detail lacks %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "ch 0") {
|
||||
t.Errorf("a never-read Series renders chapter 0:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The meta row renders each hygiene mark exactly when the underlying fact
|
||||
// holds: a Series with no page to fetch, no cover, no Reader and a chapter a
|
||||
// Sighting raised carries all four, and a whole one carries none.
|
||||
func TestAdminSeriesDetailRendersMarks(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "demonic:broken", Site: "demonic", SeriesID: "broken",
|
||||
Title: "Broken", Kind: "manga",
|
||||
})
|
||||
if err := st.Delete(st.OwnerID(), "demonic:broken"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if err := st.RecordSighting(st.OwnerID(), "demonic", "broken", floatPtr(7), time.Now().UnixMilli()); err != nil {
|
||||
t.Fatalf("RecordSighting: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/series/demonic:broken", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
for _, want := range []string{"unpollable", "no cover", "orphan", "sighting-raised"} {
|
||||
if !strings.Contains(rr.Body.String(), want) {
|
||||
t.Errorf("broken series detail lacks the %q mark:\n%s", want, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/series/solo",
|
||||
Kind: "manga", LatestChapter: "45", LatestChapterNum: floatPtr(45),
|
||||
})
|
||||
if err := st.SetSeriesCover("asura", "solo", "https://cdn.asurascans.com/covers/solo.webp",
|
||||
[]byte("\x00webp-bytes"), "image/webp"); err != nil {
|
||||
t.Fatalf("SetSeriesCover: %v", err)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/series/asura:solo", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, mark := range []string{"unpollable", "no cover", "orphan", "sighting-raised"} {
|
||||
if strings.Contains(body, mark) {
|
||||
t.Errorf("whole series detail carries the %q mark:\n%s", mark, body)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, `src="https://bookmarks.test/covers/`) {
|
||||
t.Errorf("whole series detail does not render its stored cover:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// A well-formed key naming no row is a 404, and so is a key with no ":",
|
||||
// an empty Site or an empty SeriesID — the detail page never answers 500 for
|
||||
// an address nobody can reach.
|
||||
func TestAdminSeriesDetailUnknownKey404(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
||||
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/series/solo",
|
||||
Kind: "manga",
|
||||
})
|
||||
|
||||
for _, path := range []string{
|
||||
"/admin/series/asura:no-such-row",
|
||||
"/admin/series/no-colon",
|
||||
"/admin/series/:empty-site",
|
||||
"/admin/series/asura:",
|
||||
"/admin/series/unknown-site:row",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("GET %s status = %d, want 404", path, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A Series past the first page of its Site's read (50 rows) must still
|
||||
// render: the list shows it, so its detail link may not answer 404. The row
|
||||
// lookup walks the window total, not just page 1.
|
||||
func TestAdminSeriesDetailBeyondFirstPage(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
for i := 0; i < 55; i++ {
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: fmt.Sprintf("asura:s%03d", i), Site: "asura", SeriesID: fmt.Sprintf("s%03d", i),
|
||||
Title: "Bulk", SeriesURL: "https://asurascans.com/series/bulk",
|
||||
Kind: "manga",
|
||||
})
|
||||
}
|
||||
// Rows order by (latest_checked_at, site, series_id), all zero stamps, so
|
||||
// "zzz" lands on page 2 behind the fifty-five "s*" rows.
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:zzz", Site: "asura", SeriesID: "zzz",
|
||||
Title: "Late", SeriesURL: "https://asurascans.com/series/zzz",
|
||||
Kind: "manga",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/series/asura:zzz", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /admin/series/asura:zzz status = %d, want 200 for a page-2 row", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "Late") {
|
||||
t.Errorf("page-2 row did not render:\n%s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The title and the key line come from the database, so they must render
|
||||
// escaped: a title that is markup stays markup in the response, never HTML.
|
||||
func TestAdminSeriesDetailEscapesStoredStrings(t *testing.T) {
|
||||
router, st := newWebTestServer(t, testConfig())
|
||||
seed(t, st, store.Bookmark{
|
||||
Key: "asura:evil", Site: "asura", SeriesID: "evil",
|
||||
Title: `<script>alert("xss")</script>`, SeriesURL: "https://asurascans.com/series/evil",
|
||||
Kind: "manga",
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/series/asura:evil", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "<script>") {
|
||||
t.Errorf("title is not escaped:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "<script>") {
|
||||
t.Errorf("title rendered raw:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user