feat(backend): directory scanner — diff, error state, covers, cache sweep, redis lock
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"booklib/internal/bookfile"
|
||||
"booklib/internal/config"
|
||||
"booklib/internal/db"
|
||||
"booklib/internal/redispkg"
|
||||
"booklib/internal/store"
|
||||
)
|
||||
|
||||
// 返回 scanner、library、解析过符号链接的 root、以及一个满足 progress FK 的 uid
|
||||
func setupLib(t *testing.T) (*Scanner, store.Library, string, int64) {
|
||||
t.Helper()
|
||||
url := os.Getenv("DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("DATABASE_URL not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
p, _ := db.Connect(ctx, url)
|
||||
db.Migrate(ctx, p)
|
||||
st := store.New(p)
|
||||
p.Exec(ctx, "DELETE FROM reading_progress; DELETE FROM books; DELETE FROM libraries; DELETE FROM users")
|
||||
books := t.TempDir()
|
||||
cache := t.TempDir()
|
||||
resolved := mustResolve(t, books)
|
||||
libID, err := st.CreateLibrary(ctx, "t", filepath.Join(resolved, "lib"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uid, err := st.CreateUser(ctx, "scantest", "h", "member")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := filepath.Join(resolved, "lib")
|
||||
os.MkdirAll(filepath.Join(root, "series-a"), 0o755)
|
||||
cfg := &config.Config{BooksDir: resolved, CacheDir: cache, ScanInterval: time.Minute}
|
||||
lib, _ := st.GetLibrary(ctx, libID)
|
||||
return New(st, cfg, redispkg.New(os.Getenv("REDIS_URL"))), lib, root, uid
|
||||
}
|
||||
|
||||
func mustResolve(t *testing.T, p string) string {
|
||||
r, err := filepath.EvalSymlinks(p) // macOS 上 t.TempDir 是 /var→/private 符号链接
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
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(fmt.Sprintf("%02d.jpg", i))
|
||||
w.Write(bytes.Repeat([]byte("JPG"), 100))
|
||||
}
|
||||
zw.Close()
|
||||
os.WriteFile(path, buf.Bytes(), 0o644)
|
||||
}
|
||||
|
||||
func TestScanFullLifecycle(t *testing.T) {
|
||||
sc, lib, root, uid := setupLib(t)
|
||||
ctx := context.Background()
|
||||
writeCBZ(t, filepath.Join(root, "series-a", "vol_01.cbz"), 3)
|
||||
os.WriteFile(filepath.Join(root, "notes.txt"), []byte("hi"), 0o644)
|
||||
sc.ScanLibrary(ctx, lib)
|
||||
|
||||
meta, _ := sc.st.ListBookMeta(ctx, lib.ID)
|
||||
if len(meta) != 2 {
|
||||
t.Fatalf("want 2 books got %v", meta)
|
||||
}
|
||||
b, err := sc.st.GetBook(ctx, meta["series-a/vol_01.cbz"].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.Format != "cbz" || b.PageCount != 3 || b.Title != "vol 01" {
|
||||
t.Fatalf("bad book %+v", b)
|
||||
}
|
||||
key := bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))
|
||||
covers, _ := os.ReadDir(bookfile.CoverDir(sc.cfg.CacheDir, key))
|
||||
if len(covers) == 0 {
|
||||
t.Fatal("cover not built")
|
||||
}
|
||||
// 二次扫描:无变化 → 不动
|
||||
before := b.AddedAt
|
||||
sc.ScanLibrary(ctx, lib)
|
||||
b2, _ := sc.st.GetBook(ctx, b.ID)
|
||||
if !b2.AddedAt.Equal(before) || b2.FileSize != b.FileSize {
|
||||
t.Fatal("unchanged file must be untouched")
|
||||
}
|
||||
// 修改:page_count 变、hash 变、旧缓存被扫尾清掉
|
||||
writeCBZ(t, filepath.Join(root, "series-a", "vol_01.cbz"), 5)
|
||||
os.Chtimes(filepath.Join(root, "series-a", "vol_01.cbz"), time.Now(), time.Now())
|
||||
sc.ScanLibrary(ctx, lib)
|
||||
b3, _ := sc.st.GetBook(ctx, b.ID)
|
||||
if b3.PageCount != 5 {
|
||||
t.Fatalf("not updated: %+v", b3)
|
||||
}
|
||||
if _, err := os.Stat(bookfile.CoverDir(sc.cfg.CacheDir, key)); !os.IsNotExist(err) {
|
||||
t.Fatal("stale cover cache remains")
|
||||
}
|
||||
// 删除:行没了,进度按 path 还在(spec §4)
|
||||
if err := sc.st.UpsertProgress(ctx, uid, lib.ID, "notes.txt", []byte("{}"), 0.5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(root, "notes.txt")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sc.ScanLibrary(ctx, lib)
|
||||
if _, err := sc.st.GetBook(ctx, b.ID); err != nil {
|
||||
t.Fatal("cbz vanished wrongly")
|
||||
}
|
||||
meta2, _ := sc.st.ListBookMeta(ctx, lib.ID)
|
||||
if _, ok := meta2["notes.txt"]; ok {
|
||||
t.Fatal("deleted file still in db")
|
||||
}
|
||||
if pr, err := sc.st.GetProgress(ctx, uid, lib.ID, "notes.txt"); err != nil || pr.Percent != 0.5 {
|
||||
t.Fatalf("progress must survive book removal: %+v %v", pr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanBrokenCBZStateError(t *testing.T) {
|
||||
sc, lib, root, _ := setupLib(t)
|
||||
ctx := context.Background()
|
||||
os.WriteFile(filepath.Join(root, "bad.cbz"), []byte("not a zip"), 0o644)
|
||||
sc.ScanLibrary(ctx, lib)
|
||||
meta, _ := sc.st.ListBookMeta(ctx, lib.ID)
|
||||
b, _ := sc.st.GetBook(ctx, meta["bad.cbz"].ID)
|
||||
if b.State != "error" || b.ErrMsg == "" {
|
||||
t.Fatalf("want error state, got %+v", b)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user