Files
book-comic-library/backend/cmd/webui/handlers/content_test.go
T
2026-09-14 19:38:33 +08:00

223 lines
7.5 KiB
Go

package handlers_test
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func serveFixture(t *testing.T) (http.Handler, string, string) {
st, sc, h, booksDir := setupAPI(t)
atok := adminToken(t, h)
lib, root := newLibrary(t, st, h, atok, booksDir, "comics")
writeCBZ(t, filepath.Join(root, "s", "one.cbz"), 3)
os.WriteFile(filepath.Join(root, "two.txt"), []byte("plain text body"), 0o644)
scanNow(t, sc, lib)
w := do(h, "GET", "/api/books?q=one", atok, nil)
var bs []map[string]any
json.Unmarshal(w.Body.Bytes(), &bs)
cbzID := itoa(bs[0]["id"])
w = do(h, "GET", "/api/books?q=two", atok, nil) // 检索走 title(文件名去扩展名),不是 path
json.Unmarshal(w.Body.Bytes(), &bs)
txtID := itoa(bs[0]["id"])
return h, cbzID, txtID
}
func TestCoverCBZAndPlaceholder(t *testing.T) {
h, cbzID, txtID := serveFixture(t)
tok := adminToken(t, h)
w := do(h, "GET", "/api/books/"+cbzID+"/cover", tok, nil)
if w.Code != 200 || !strings.Contains(w.Header().Get("Content-Type"), "image/") {
t.Fatalf("cbz cover %d %s %q", w.Code, w.Body, w.Header().Get("Content-Type"))
}
if !strings.Contains(w.Header().Get("Cache-Control"), "immutable") {
t.Fatal("cover must be immutable")
}
w = do(h, "GET", "/api/books/"+txtID+"/cover", tok, nil)
if w.Code != 200 || w.Header().Get("Content-Type") != "image/svg+xml" {
t.Fatalf("placeholder cover %d %q", w.Code, w.Header().Get("Content-Type"))
}
w = do(h, "GET", "/api/books/999999/cover", tok, nil)
if w.Code != 404 {
t.Fatalf("missing book cover want 404 got %d", w.Code)
}
}
// 不经 scanner 直接入库(模拟缓存丢失/卷漂移)→ GET cover 就地抽图自愈并落盘(tmp+rename)
func TestCoverSelfHeal(t *testing.T) {
st, _, h, booksDir := setupAPI(t)
atok := adminToken(t, h)
lib, root := newLibrary(t, st, h, atok, booksDir, "comics")
p := filepath.Join(root, "heal.cbz")
writeCBZ(t, p, 2)
fi, err := os.Stat(p)
if err != nil {
t.Fatal(err)
}
id, err := st.InsertBook(context.Background(), lib.ID, "heal.cbz", "heal", "cbz", fi.Size(), fi.ModTime().Unix(), 2)
if err != nil {
t.Fatal(err)
}
for i := 1; i <= 2; i++ { // 第二次走已缓存目录,均应 200 image/*
w := do(h, "GET", "/api/books/"+itoa(id)+"/cover", atok, nil)
if w.Code != 200 || !strings.Contains(w.Header().Get("Content-Type"), "image/") {
t.Fatalf("self-heal cover pass %d: %d %q", i, w.Code, w.Header().Get("Content-Type"))
}
}
}
func TestPages(t *testing.T) {
h, cbzID, txtID := serveFixture(t)
tok := adminToken(t, h)
w := do(h, "GET", "/api/books/"+cbzID+"/pages", tok, nil)
var v struct{ Count int }
json.Unmarshal(w.Body.Bytes(), &v)
if w.Code != 200 || v.Count != 3 {
t.Fatalf("pages %d %s", w.Code, w.Body)
}
w = do(h, "GET", "/api/books/"+cbzID+"/pages/1", tok, nil)
if w.Code != 200 || !strings.Contains(w.Body.String(), "IMG") {
t.Fatalf("page 1 %d", w.Code)
}
if !strings.Contains(w.Header().Get("Cache-Control"), "immutable") {
t.Fatal("page must be immutable")
}
for _, bad := range []string{"4", "-1", "abc"} {
if w = do(h, "GET", "/api/books/"+cbzID+"/pages/"+bad, tok, nil); w.Code != 404 && w.Code != 400 {
t.Fatalf("pages/%s want 404/400 got %d", bad, w.Code)
}
}
if w = do(h, "GET", "/api/books/"+txtID+"/pages", tok, nil); w.Code != 400 {
t.Fatalf("pages on txt want 400 got %d", w.Code)
}
// 二次命中磁盘缓存(服务仍 200,字节一致)
w2 := do(h, "GET", "/api/books/"+cbzID+"/pages/2", tok, nil)
w3 := do(h, "GET", "/api/books/"+cbzID+"/pages/2", tok, nil)
if w2.Code != 200 || w2.Body.String() != w3.Body.String() {
t.Fatal("page cache inconsistent")
}
}
func zipTo(t *testing.T, path string, kv map[string]string) {
t.Helper()
os.MkdirAll(filepath.Dir(path), 0o755)
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
for n, v := range kv {
w, err := zw.Create(n)
if err != nil {
t.Fatal(err)
}
w.Write([]byte(v))
}
zw.Close()
os.WriteFile(path, buf.Bytes(), 0o644)
}
func TestPagesChaptersAndNoImageZip(t *testing.T) {
st, sc, h, booksDir := setupAPI(t)
tok := adminToken(t, h)
lib, root := newLibrary(t, st, h, tok, booksDir, "ch")
zipTo(t, filepath.Join(root, "show.cbz"), map[string]string{
"第2季/第2話/0001.jpg": "IMG2",
"第2季/第1話/0001.jpg": "IMG1a",
"第2季/第1話/._0001.jpg": "junk",
"__MACOSX/第2季/._0001.jpg": "junk",
})
zipTo(t, filepath.Join(root, "videos.zip"), map[string]string{"ep/01.mkv": "x"})
scanNow(t, sc, lib)
bookID := func(q string) string {
w := do(h, "GET", "/api/books?q="+q, tok, nil)
var bs []map[string]any
json.Unmarshal(w.Body.Bytes(), &bs)
if len(bs) != 1 {
t.Fatalf("q=%s books: %s", q, w.Body)
}
return itoa(bs[0]["id"])
}
cbzID := bookID("show")
w := do(h, "GET", "/api/books/"+cbzID+"/pages", tok, nil)
var pg struct {
Count int `json:"count"`
Chapters []struct {
Title string `json:"title"`
Start int `json:"start"`
} `json:"chapters"`
}
json.Unmarshal(w.Body.Bytes(), &pg)
if pg.Count != 2 || len(pg.Chapters) != 2 {
t.Fatalf("pages want 2/2ch got %s", w.Body)
}
if pg.Chapters[0].Title != "第1話" || pg.Chapters[0].Start != 0 || pg.Chapters[1].Start != 1 {
t.Fatalf("chapters: %s", w.Body)
}
ww := do(h, "GET", "/api/books/"+cbzID+"/pages/0", tok, nil)
if ww.Code != 200 || ww.Body.String() != "IMG1a" {
t.Fatalf("page0 must be real image (junk filtered): %d %s", ww.Code, ww.Body)
}
if ww := do(h, "GET", "/api/books/"+cbzID+"/pages/2", tok, nil); ww.Code != 404 {
t.Fatalf("junk-indexed page must be gone: %d", ww.Code)
}
// 无图 zip → state=error,而不是空白 reader
w = do(h, "GET", "/api/books/"+bookID("videos"), tok, nil)
var bk map[string]any
json.Unmarshal(w.Body.Bytes(), &bk)
if bk["state"] != "error" || !strings.Contains(bk["error"].(string), "no images") {
t.Fatalf("empty-image zip want error, got %s", w.Body)
}
}
func TestBrokenCBZ(t *testing.T) {
st, sc, h, booksDir := setupAPI(t)
atok := adminToken(t, h)
lib, root := newLibrary(t, st, h, atok, booksDir, "comics")
os.MkdirAll(filepath.Join(root, "b"), 0o755)
os.WriteFile(filepath.Join(root, "b", "bad.cbz"), []byte("not a zip at all"), 0o644)
scanNow(t, sc, lib)
w := do(h, "GET", "/api/books?q=bad", atok, nil)
var bs []map[string]any
json.Unmarshal(w.Body.Bytes(), &bs)
id := itoa(bs[0]["id"])
if w = do(h, "GET", "/api/books/"+id+"/pages", atok, nil); w.Code != 422 {
t.Fatalf("broken pages want 422 got %d", w.Code)
}
// 封面目录缺失(坏 cbz 抽不出封面)→ 占位 SVG 兜底
if w = do(h, "GET", "/api/books/"+id+"/cover", atok, nil); w.Code != 200 || w.Header().Get("Content-Type") != "image/svg+xml" {
t.Fatalf("broken cover want placeholder svg got %d %q", w.Code, w.Header().Get("Content-Type"))
}
}
func TestFileRangeETag(t *testing.T) {
h, _, txtID := serveFixture(t)
tok := adminToken(t, h)
w := do(h, "GET", "/api/books/"+txtID+"/file", tok, nil)
if w.Code != 200 || w.Body.String() != "plain text body" {
t.Fatalf("file %d %q", w.Code, w.Body)
}
if w.Header().Get("ETag") == "" {
t.Fatal("no etag")
}
if etag := w.Header().Get("ETag"); !strings.HasPrefix(etag, `"`) || !strings.HasSuffix(etag, `"`) {
t.Fatalf("etag must be quoted: %q", etag)
}
if w.Header().Get("Accept-Ranges") != "bytes" {
t.Fatal("no accept-ranges")
}
req := httptest.NewRequest("GET", "/api/books/"+txtID+"/file", nil)
req.Header.Set("Range", "bytes=0-4")
req.Header.Set("Authorization", "Bearer "+tok)
ww := httptest.NewRecorder()
h.ServeHTTP(ww, req)
if ww.Code != 206 || ww.Body.String() != "plain" {
t.Fatalf("range %d %q", ww.Code, ww.Body)
}
}