Merge ticket/144-series-detail

This commit is contained in:
2026-08-22 00:34:14 +07:00
5 changed files with 351 additions and 1 deletions
+226
View File
@@ -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, "&lt;script&gt;") {
t.Errorf("title is not escaped:\n%s", body)
}
if strings.Contains(body, "<script>") {
t.Errorf("title rendered raw:\n%s", body)
}
}