新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
355 lines
11 KiB
Go
355 lines
11 KiB
Go
package service
|
||
|
||
import (
|
||
"archive/zip"
|
||
"bytes"
|
||
"errors"
|
||
"image"
|
||
"image/jpeg"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/freefire/jiang13-bbs/model"
|
||
)
|
||
|
||
func encodeJPEGBytes(t *testing.T, w, h int) []byte {
|
||
t.Helper()
|
||
var buf bytes.Buffer
|
||
if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, w, h)), &jpeg.Options{Quality: 80}); err != nil {
|
||
t.Fatalf("encode jpeg: %v", err)
|
||
}
|
||
return buf.Bytes()
|
||
}
|
||
|
||
// writeExportZipFile 把内存导出包落盘为临时 zip(模拟 handler 上传后的临时文件)
|
||
func writeExportZipFile(t *testing.T, exp *LibraryExport) string {
|
||
t.Helper()
|
||
p := filepath.Join(t.TempDir(), "export.zip")
|
||
f, err := os.Create(p)
|
||
if err != nil {
|
||
t.Fatalf("create temp zip: %v", err)
|
||
}
|
||
if err := exp.WriteZip(f); err != nil {
|
||
t.Fatalf("write export zip: %v", err)
|
||
}
|
||
if err := f.Close(); err != nil {
|
||
t.Fatalf("close temp zip: %v", err)
|
||
}
|
||
return p
|
||
}
|
||
|
||
// writeZipFileMap 手工构造 ZIP(用于非法包测试)
|
||
func writeZipFileMap(t *testing.T, entries map[string]string) string {
|
||
t.Helper()
|
||
buf := new(bytes.Buffer)
|
||
zw := zip.NewWriter(buf)
|
||
for name, content := range entries {
|
||
w, err := zw.Create(name)
|
||
if err != nil {
|
||
t.Fatalf("zip create %s: %v", name, err)
|
||
}
|
||
if _, err := io.WriteString(w, content); err != nil {
|
||
t.Fatalf("zip write %s: %v", name, err)
|
||
}
|
||
}
|
||
if err := zw.Close(); err != nil {
|
||
t.Fatalf("zip close: %v", err)
|
||
}
|
||
p := filepath.Join(t.TempDir(), "in.zip")
|
||
if err := os.WriteFile(p, buf.Bytes(), 0o644); err != nil {
|
||
t.Fatalf("write temp zip: %v", err)
|
||
}
|
||
return p
|
||
}
|
||
|
||
func assertReportOK(t *testing.T, rep *LibraryImportReport, total int) {
|
||
t.Helper()
|
||
if rep == nil {
|
||
t.Fatal("报告为空")
|
||
}
|
||
if rep.Total != total {
|
||
t.Fatalf("total = %d, want %d", rep.Total, total)
|
||
}
|
||
if len(rep.Failed) != 0 {
|
||
t.Fatalf("存在失败记录: %+v", rep.Failed)
|
||
}
|
||
if len(rep.Imported) != total {
|
||
t.Fatalf("imported = %d, want %d", len(rep.Imported), total)
|
||
}
|
||
}
|
||
|
||
// TestLibraryImportBookRoundTrip 单本包:导出 → 导入 → 冲突跳过 → 覆盖导入
|
||
func TestLibraryImportBookRoundTrip(t *testing.T) {
|
||
// ---- 源库 ----
|
||
src, _ := newLibraryTestService(t)
|
||
srcUploads := t.TempDir()
|
||
src.WithUploadsDir(srcUploads)
|
||
docID, storedName := seedBookForExport(t, src, srcUploads)
|
||
|
||
// 制造两次下载,验证计数随包保留
|
||
var srcFile model.LibraryFile
|
||
if err := src.db.Where("stored_name = ?", storedName).First(&srcFile).Error; err != nil {
|
||
t.Fatalf("find source file: %v", err)
|
||
}
|
||
src.IncrDownload(srcFile.ID)
|
||
src.IncrDownload(srcFile.ID)
|
||
|
||
exp, err := src.BuildBookExport(docID)
|
||
if err != nil {
|
||
t.Fatalf("build export: %v", err)
|
||
}
|
||
zipPath := writeExportZipFile(t, exp)
|
||
exp.Close()
|
||
|
||
// ---- 目标库(空库)----
|
||
dst, _ := newLibraryTestService(t)
|
||
dstUploads := t.TempDir()
|
||
dst.WithUploadsDir(dstUploads)
|
||
|
||
// 1) create 导入
|
||
rep, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 7)
|
||
if err != nil {
|
||
t.Fatalf("import: %v", err)
|
||
}
|
||
assertReportOK(t, rep, 1)
|
||
item := rep.Imported[0]
|
||
if item.Slug != "dao-jia" || item.Action != "created" || item.Sections != 2 || item.Files != 1 {
|
||
t.Fatalf("导入条目异常: %+v", item)
|
||
}
|
||
|
||
var doc model.LibraryDoc
|
||
if err := dst.db.Where("slug = ?", "dao-jia").First(&doc).Error; err != nil {
|
||
t.Fatalf("find imported doc: %v", err)
|
||
}
|
||
if doc.Title != "道家研究" || doc.Description != "# 介绍" || !doc.Published {
|
||
t.Fatalf("元信息恢复不正确: %+v", doc)
|
||
}
|
||
if doc.Author != "老子" {
|
||
t.Fatalf("作者恢复不正确: %q", doc.Author)
|
||
}
|
||
if doc.CreatorID != 7 {
|
||
t.Fatalf("新建导入创建者应为导入操作人,got %d", doc.CreatorID)
|
||
}
|
||
if !strings.HasPrefix(doc.CoverURL, "/uploads/images/") || doc.CoverURL == "/uploads/images/cover.jpg" {
|
||
t.Fatalf("封面 URL 应为重新落盘的随机名,got %q", doc.CoverURL)
|
||
}
|
||
if doc.CoverWidth != 120 || doc.CoverHeight != 160 {
|
||
t.Fatalf("封面尺寸恢复不正确: %dx%d", doc.CoverWidth, doc.CoverHeight)
|
||
}
|
||
coverDisk, ok := dst.resolveCoverPath(doc.CoverURL)
|
||
if !ok {
|
||
t.Fatalf("封面路径无法解析: %s", doc.CoverURL)
|
||
}
|
||
coverData, err := os.ReadFile(coverDisk)
|
||
if err != nil {
|
||
t.Fatalf("封面未落盘: %v", err)
|
||
}
|
||
if !bytes.HasPrefix(coverData, []byte{0xff, 0xd8, 0xff}) {
|
||
t.Fatal("封面内容不是 JPEG")
|
||
}
|
||
|
||
// 章节树:章 + 小节父子关系
|
||
secs, err := dst.ListSections(doc.ID)
|
||
if err != nil {
|
||
t.Fatalf("list sections: %v", err)
|
||
}
|
||
if len(secs) != 2 {
|
||
t.Fatalf("章节数 = %d, want 2", len(secs))
|
||
}
|
||
var chapter, child *model.LibrarySection
|
||
for i := range secs {
|
||
if secs[i].ParentID == nil {
|
||
chapter = &secs[i]
|
||
} else {
|
||
child = &secs[i]
|
||
}
|
||
}
|
||
if chapter == nil || child == nil {
|
||
t.Fatalf("章节层级不正确: %+v", secs)
|
||
}
|
||
if chapter.Title != "第一章" || chapter.SortOrder != 0 {
|
||
t.Fatalf("章信息不正确: %+v", chapter)
|
||
}
|
||
if child.Title != "小节" || *child.ParentID != chapter.ID || child.SortOrder != 0 {
|
||
t.Fatalf("小节信息不正确: %+v", child)
|
||
}
|
||
|
||
// 附件:重新随机落盘、内容与下载计数保留
|
||
var files []model.LibraryFile
|
||
if err := dst.db.Where("doc_id = ?", doc.ID).Find(&files).Error; err != nil {
|
||
t.Fatalf("list files: %v", err)
|
||
}
|
||
if len(files) != 1 {
|
||
t.Fatalf("附件数 = %d, want 1", len(files))
|
||
}
|
||
nf := files[0]
|
||
if nf.Name != "manual.epub" || nf.DownloadCount != 2 || nf.StoredName == srcFile.StoredName {
|
||
t.Fatalf("附件恢复不正确: %+v", nf)
|
||
}
|
||
data, err := os.ReadFile(dst.FilePath(&nf))
|
||
if err != nil {
|
||
t.Fatalf("附件未落盘: %v", err)
|
||
}
|
||
if string(data) != "EPUB-FILE-BYTES" {
|
||
t.Fatalf("附件内容不正确: %q", data)
|
||
}
|
||
firstCoverURL := doc.CoverURL
|
||
|
||
// 2) 再次 create:冲突跳过
|
||
rep2, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 7)
|
||
if err != nil {
|
||
t.Fatalf("re-import: %v", err)
|
||
}
|
||
if rep2.Total != 1 || len(rep2.Imported) != 0 || len(rep2.Failed) != 1 {
|
||
t.Fatalf("冲突报告异常: %+v", rep2)
|
||
}
|
||
if rep2.Failed[0].Reason != libraryImportReasonConflict {
|
||
t.Fatalf("失败原因应为 conflict,got %q", rep2.Failed[0].Reason)
|
||
}
|
||
var docCount int64
|
||
dst.db.Model(&model.LibraryDoc{}).Count(&docCount)
|
||
if docCount != 1 {
|
||
t.Fatalf("冲突跳过后书库数量异常: %d", docCount)
|
||
}
|
||
|
||
// 3) overwrite:先加一个计划外附件与旧封面,导入后应被整体替换
|
||
extra, err := dst.AddFile(doc.ID, 7, "extra.txt", bytes.NewReader([]byte("EXTRA-BYTES")))
|
||
if err != nil {
|
||
t.Fatalf("add extra: %v", err)
|
||
}
|
||
extraPath := dst.FilePath(extra)
|
||
if _, err := os.Stat(extraPath); err != nil {
|
||
t.Fatalf("extra 未先落盘: %v", err)
|
||
}
|
||
|
||
rep3, err := dst.ImportLibraryZip(zipPath, LibraryImportModeOverwrite, 7)
|
||
if err != nil {
|
||
t.Fatalf("overwrite import: %v", err)
|
||
}
|
||
assertReportOK(t, rep3, 1)
|
||
if rep3.Imported[0].Action != "overwritten" {
|
||
t.Fatalf("动作应为 overwritten,got %q", rep3.Imported[0].Action)
|
||
}
|
||
|
||
dst.db.Model(&model.LibraryDoc{}).Count(&docCount)
|
||
if docCount != 1 {
|
||
t.Fatalf("覆盖后书库数量异常: %d", docCount)
|
||
}
|
||
var doc2 model.LibraryDoc
|
||
if err := dst.db.Where("slug = ?", "dao-jia").First(&doc2).Error; err != nil {
|
||
t.Fatalf("find overwritten doc: %v", err)
|
||
}
|
||
if secs2, err := dst.ListSections(doc2.ID); err != nil || len(secs2) != 2 {
|
||
t.Fatalf("覆盖后章节异常: %d %v", len(secs2), err)
|
||
}
|
||
if doc2.Author != "老子" || doc2.CreatorID != 7 {
|
||
t.Fatalf("覆盖后作者/创建者异常: author=%q creator=%d", doc2.Author, doc2.CreatorID)
|
||
}
|
||
var files2 []model.LibraryFile
|
||
if err := dst.db.Where("doc_id = ?", doc2.ID).Find(&files2).Error; err != nil || len(files2) != 1 {
|
||
t.Fatalf("覆盖后附件数量异常: %d %v", len(files2), err)
|
||
}
|
||
if files2[0].Name != "manual.epub" || files2[0].DownloadCount != 2 {
|
||
t.Fatalf("覆盖后附件内容异常: %+v", files2[0])
|
||
}
|
||
if _, err := os.Stat(extraPath); !os.IsNotExist(err) {
|
||
t.Fatalf("旧附件磁盘文件应被删除,err=%v", err)
|
||
}
|
||
oldCoverDisk, _ := dst.resolveCoverPath(firstCoverURL)
|
||
if _, err := os.Stat(oldCoverDisk); !os.IsNotExist(err) {
|
||
t.Fatalf("旧封面磁盘文件应被删除,err=%v", err)
|
||
}
|
||
if doc2.CoverURL == firstCoverURL {
|
||
t.Fatal("覆盖后封面应重新落盘为新文件")
|
||
}
|
||
newCoverDisk, _ := dst.resolveCoverPath(doc2.CoverURL)
|
||
if _, err := os.ReadFile(newCoverDisk); err != nil {
|
||
t.Fatalf("新封面未落盘: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestLibraryImportAll 全库包往返
|
||
func TestLibraryImportAll(t *testing.T) {
|
||
src, _ := newLibraryTestService(t)
|
||
srcUploads := t.TempDir()
|
||
src.WithUploadsDir(srcUploads)
|
||
if _, err := src.Create(&LibraryInput{Slug: "second-book", Title: "第二本"}); err != nil {
|
||
t.Fatalf("create second doc: %v", err)
|
||
}
|
||
seedBookForExport(t, src, srcUploads)
|
||
|
||
exp, err := src.BuildAllExport()
|
||
if err != nil {
|
||
t.Fatalf("build all export: %v", err)
|
||
}
|
||
zipPath := writeExportZipFile(t, exp)
|
||
exp.Close()
|
||
|
||
dst, _ := newLibraryTestService(t)
|
||
dst.WithUploadsDir(t.TempDir())
|
||
rep, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 1)
|
||
if err != nil {
|
||
t.Fatalf("import all: %v", err)
|
||
}
|
||
assertReportOK(t, rep, 2)
|
||
slugs := map[string]bool{}
|
||
for _, it := range rep.Imported {
|
||
slugs[it.Slug] = true
|
||
}
|
||
if !slugs["dao-jia"] || !slugs["second-book"] {
|
||
t.Fatalf("导入书集不正确: %+v", slugs)
|
||
}
|
||
}
|
||
|
||
// TestLibraryImportRejectBadPackage 非法包整体拒绝
|
||
func TestLibraryImportRejectBadPackage(t *testing.T) {
|
||
s, _ := newLibraryTestService(t)
|
||
s.WithUploadsDir(t.TempDir())
|
||
|
||
badPath := filepath.Join(t.TempDir(), "bad.zip")
|
||
if err := os.WriteFile(badPath, []byte("this is not a zip"), 0o644); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
if _, err := s.ImportLibraryZip(badPath, LibraryImportModeCreate, 1); !errors.Is(err, ErrLibraryImportBadZip) {
|
||
t.Fatalf("非 zip 应返回 ErrLibraryImportBadZip,got %v", err)
|
||
}
|
||
|
||
noManifest := writeZipFileMap(t, map[string]string{"files/a.epub": "x"})
|
||
if _, err := s.ImportLibraryZip(noManifest, LibraryImportModeCreate, 1); !errors.Is(err, ErrLibraryImportFormat) {
|
||
t.Fatalf("缺清单应返回 ErrLibraryImportFormat,got %v", err)
|
||
}
|
||
|
||
if _, err := s.ImportLibraryZip(badPath, "bogus", 1); !errors.Is(err, ErrLibraryImportMode) {
|
||
t.Fatalf("非法模式应返回 ErrLibraryImportMode,got %v", err)
|
||
}
|
||
}
|
||
|
||
// TestCleanZipRel 路径穿越校验
|
||
func TestCleanZipRel(t *testing.T) {
|
||
cases := []struct {
|
||
in string
|
||
want bool
|
||
}{
|
||
{"", false},
|
||
{".", false},
|
||
{"..", false},
|
||
{"../evil", false},
|
||
{"a/../../b", false},
|
||
{"/etc/passwd", false},
|
||
{`docs\..\..\x`, false},
|
||
{"files/a.epub", true},
|
||
{"docs/x/book.json", true},
|
||
{`docs\x/cover.jpg`, true},
|
||
{"a//b", true},
|
||
}
|
||
for _, tc := range cases {
|
||
_, ok := cleanZipRel(tc.in)
|
||
if ok != tc.want {
|
||
t.Errorf("cleanZipRel(%q) = %v, want %v", tc.in, ok, tc.want)
|
||
}
|
||
}
|
||
}
|