Files
jiang13-bbs/backend/service/library_import_test.go
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

355 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}