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) } }