feat(backend): book list/detail/delete with embedded progress + cache cleanup

This commit is contained in:
2026-09-05 00:42:08 +08:00
parent 193d7612b5
commit 249619b857
7 changed files with 339 additions and 26 deletions
+157
View File
@@ -0,0 +1,157 @@
package api
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"booklib/internal/scanner"
"booklib/internal/store"
)
func newLibrary(t *testing.T, st *store.Store, h http.Handler, tok, booksDir, name string) (store.Library, string) {
t.Helper()
root := filepath.Join(booksDir, name)
os.MkdirAll(filepath.Join(root, "series-a"), 0o755)
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": name, "root_path": root})
if w.Code != 201 {
t.Fatalf("create lib %d %s", w.Code, w.Body)
}
var v struct {
ID int64 `json:"id"`
}
json.Unmarshal(w.Body.Bytes(), &v)
lib, err := st.GetLibrary(context.Background(), v.ID)
if err != nil {
t.Fatal(err)
}
return lib, root
}
func writeCBZ(t *testing.T, path string, pages int) {
t.Helper()
os.MkdirAll(filepath.Dir(path), 0o755)
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
for i := 1; i <= pages; i++ {
w, _ := zw.Create(i2name(i))
w.Write(bytes.Repeat([]byte("IMG"), 64))
}
zw.Close()
os.WriteFile(path, buf.Bytes(), 0o644)
}
func i2name(i int) string { return fmt.Sprintf("%02d.jpg", i) }
func scanNow(t *testing.T, sc *scanner.Scanner, lib store.Library) {
t.Helper()
sc.ScanLibrary(context.Background(), lib)
}
func TestBookListDetailDelete(t *testing.T) {
st, sc, h, booksDir := setupAPI(t)
atok := adminToken(t, h)
// member token + 进度前置数据
do(h, "POST", "/api/users", atok, map[string]string{"username": "m2", "password": testPW, "role": "member"})
mtok := loginAs(t, h, "m2", testPW)
lib, root := newLibrary(t, st, h, atok, booksDir, "comics")
writeCBZ(t, filepath.Join(root, "series-a", "vol_01.cbz"), 4)
os.WriteFile(filepath.Join(root, "readme.txt"), []byte("hello world"), 0o644)
scanNow(t, sc, lib)
w := do(h, "GET", "/api/books", mtok, nil)
var books []map[string]any
json.Unmarshal(w.Body.Bytes(), &books)
if w.Code != 200 || len(books) != 2 {
t.Fatalf("list %d %s", w.Code, w.Body)
}
var cbz map[string]any
for _, b := range books {
if b["format"] == "cbz" {
cbz = b
}
}
if cbz == nil {
t.Fatal("cbz missing")
}
if cbz["pages"].(float64) != 4 || cbz["library"] != "comics" || cbz["percent"].(float64) != 0 {
t.Fatalf("bad json %+v", cbz)
}
if !strings.HasPrefix(cbz["cover_url"].(string), "/api/books/") || !strings.Contains(cbz["cover_url"].(string), "?v=") {
t.Fatalf("cover_url %+v", cbz["cover_url"])
}
if cbz["page_url_fmt"] == nil {
t.Fatalf("cbz must have page_url_fmt: %+v", cbz)
}
id := itoa(cbz["id"])
// detail
w = do(h, "GET", "/api/books/"+id, mtok, nil)
if w.Code != 200 {
t.Fatalf("detail %d", w.Code)
}
// 进度联动的断言在 Task 12(progress 端点此任务还不存在)
// member 不能删
w = do(h, "DELETE", "/api/books/"+id, mtok, nil)
if w.Code != 403 {
t.Fatalf("member delete want 403 got %d", w.Code)
}
// admin 删:行、文件、缓存目录都没;txt 文件保留
w = do(h, "DELETE", "/api/books/"+id, atok, nil)
if w.Code != 204 {
t.Fatalf("admin delete %d %s", w.Code, w.Body)
}
if _, err := os.Stat(filepath.Join(root, "series-a", "vol_01.cbz")); !os.IsNotExist(err) {
t.Fatal("file not removed")
}
w = do(h, "GET", "/api/books", atok, nil)
json.Unmarshal(w.Body.Bytes(), &books)
if len(books) != 1 || books[0]["format"] != "txt" {
t.Fatalf("books after delete %+v", books)
}
// 过滤器
w = do(h, "GET", "/api/books?library="+itoa(lib.ID)+"&q=readme&prefix=series/", atok, nil)
json.Unmarshal(w.Body.Bytes(), &books)
if len(books) != 0 {
t.Fatalf("prefix+q filter broken %+v", books)
}
w = do(h, "GET", "/api/books?q=readme", atok, nil)
json.Unmarshal(w.Body.Bytes(), &books)
if len(books) != 1 {
t.Fatalf("q broken %+v", books)
}
}
func loginAs(t *testing.T, h http.Handler, user, pass string) string {
t.Helper()
w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": user, "password": pass})
if w.Code != 200 {
t.Fatalf("login %s: %d", user, w.Code)
}
var v struct {
Token string
}
json.Unmarshal(w.Body.Bytes(), &v)
return v.Token
}
func TestAbsBookPathTraversalRejected(t *testing.T) {
root := "/data/books/lib" // 纯路径逻辑,不碰文件系统,无需 DB
for _, bad := range []string{"../../etc/passwd", "a/../../../etc/x", "../sibling"} {
if _, e := absBookPath(root, store.Book{Path: bad}); e == nil {
t.Fatalf("must reject %q", bad)
}
}
if p, e := absBookPath(root, store.Book{Path: "series-a/vol.cbz"}); e != nil || p != filepath.Join(root, "series-a", "vol.cbz") {
t.Fatalf("must accept relative path: %q %v", p, e)
}
}