feat(backend): cover/file/pages endpoints — immutable URLs, Range, disk+redis caches
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user