新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
200 lines
5.9 KiB
Go
200 lines
5.9 KiB
Go
package service
|
||
|
||
import (
|
||
"archive/zip"
|
||
"bytes"
|
||
"encoding/json"
|
||
"errors"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
)
|
||
|
||
// readZipEntry 读取 ZIP 内指定名称的文件内容(不存在返回 nil, false)
|
||
func readZipEntry(t *testing.T, zr *zip.Reader, name string) ([]byte, bool) {
|
||
t.Helper()
|
||
for _, f := range zr.File {
|
||
if f.Name == name {
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
t.Fatalf("open zip entry %s: %v", name, err)
|
||
}
|
||
defer rc.Close()
|
||
data, err := io.ReadAll(rc)
|
||
if err != nil {
|
||
t.Fatalf("read zip entry %s: %v", name, err)
|
||
}
|
||
return data, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
func buildExportZip(t *testing.T, exp *LibraryExport) *zip.Reader {
|
||
t.Helper()
|
||
var buf bytes.Buffer
|
||
if err := exp.WriteZip(&buf); err != nil {
|
||
t.Fatalf("write zip: %v", err)
|
||
}
|
||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||
if err != nil {
|
||
t.Fatalf("read exported zip: %v", err)
|
||
}
|
||
return zr
|
||
}
|
||
|
||
// seedBookForExport 造一本含章节(章 + 小节)、附件与本地封面的书籍
|
||
func seedBookForExport(t *testing.T, s *LibraryService, uploads string) (docID uint, storedName string) {
|
||
t.Helper()
|
||
pub := true
|
||
doc, err := s.Create(&LibraryInput{Slug: "dao-jia", Title: "道家研究", Author: "老子", Description: "# 介绍"})
|
||
if err != nil {
|
||
t.Fatalf("create doc: %v", err)
|
||
}
|
||
|
||
coverDir := filepath.Join(uploads, "images")
|
||
if err := os.MkdirAll(coverDir, 0o755); err != nil {
|
||
t.Fatalf("mkdir uploads: %v", err)
|
||
}
|
||
coverBytes := encodeJPEGBytes(t, 120, 160)
|
||
if err := os.WriteFile(filepath.Join(coverDir, "cover.jpg"), coverBytes, 0o644); err != nil {
|
||
t.Fatalf("write cover: %v", err)
|
||
}
|
||
if _, err := s.Update(doc.ID, &LibraryInput{
|
||
Slug: "dao-jia", Title: "道家研究", Author: "老子", Description: "# 介绍",
|
||
CoverURL: "/uploads/images/cover.jpg", CoverWidth: 120, CoverHeight: 160,
|
||
Published: &pub,
|
||
}); err != nil {
|
||
t.Fatalf("update cover: %v", err)
|
||
}
|
||
|
||
chapter, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章", Content: "章正文"})
|
||
if err != nil {
|
||
t.Fatalf("create chapter: %v", err)
|
||
}
|
||
if _, err := s.CreateSection(doc.ID, &SectionInput{
|
||
ParentID: &chapter.ID, Title: "小节", Content: "小节正文",
|
||
}); err != nil {
|
||
t.Fatalf("create section: %v", err)
|
||
}
|
||
|
||
f, err := s.AddFile(doc.ID, 1, "manual.epub", bytes.NewReader([]byte("EPUB-FILE-BYTES")))
|
||
if err != nil {
|
||
t.Fatalf("add file: %v", err)
|
||
}
|
||
return doc.ID, f.StoredName
|
||
}
|
||
|
||
func TestLibraryExportBook(t *testing.T) {
|
||
s, _ := newLibraryTestService(t)
|
||
uploads := t.TempDir()
|
||
s.WithUploadsDir(uploads)
|
||
docID, storedName := seedBookForExport(t, s, uploads)
|
||
|
||
exp, err := s.BuildBookExport(docID)
|
||
if err != nil {
|
||
t.Fatalf("build export: %v", err)
|
||
}
|
||
defer exp.Close()
|
||
if exp.Filename != "dao-jia.zip" {
|
||
t.Fatalf("filename = %q, want dao-jia.zip", exp.Filename)
|
||
}
|
||
zr := buildExportZip(t, exp)
|
||
|
||
raw, ok := readZipEntry(t, zr, "book.json")
|
||
if !ok {
|
||
t.Fatal("缺少 book.json")
|
||
}
|
||
var manifest libraryExportManifest
|
||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||
t.Fatalf("parse book.json: %v", err)
|
||
}
|
||
if manifest.Format != libraryExportBookFormat || manifest.FormatVersion != libraryExportVersion {
|
||
t.Fatalf("清单格式异常: %s v%d", manifest.Format, manifest.FormatVersion)
|
||
}
|
||
b := manifest.Doc
|
||
if b == nil {
|
||
t.Fatal("book.json 缺少 doc")
|
||
}
|
||
if b.Title != "道家研究" || b.Slug != "dao-jia" || b.Author != "老子" || !b.Published {
|
||
t.Fatalf("元信息导出不正确: %+v", b)
|
||
}
|
||
if b.CoverFile != "cover.jpg" || b.CoverWidth != 120 || b.CoverHeight != 160 {
|
||
t.Fatalf("封面信息导出不正确: %+v", b)
|
||
}
|
||
if len(b.Sections) != 2 || b.Sections[0].Key != "s0" || b.Sections[1].ParentKey != "s0" {
|
||
t.Fatalf("章节树导出不正确: %+v", b.Sections)
|
||
}
|
||
if len(b.Files) != 1 || b.Files[0].Stored != "files/"+storedName || b.Files[0].Missing {
|
||
t.Fatalf("附件清单导出不正确: %+v", b.Files)
|
||
}
|
||
|
||
if data, ok := readZipEntry(t, zr, "files/"+storedName); !ok || string(data) != "EPUB-FILE-BYTES" {
|
||
t.Fatalf("附件内容导出不正确(ok=%v)", ok)
|
||
}
|
||
if data, ok := readZipEntry(t, zr, "cover.jpg"); !ok || !bytes.HasPrefix(data, []byte{0xff, 0xd8, 0xff}) {
|
||
t.Fatalf("封面内容导出不正确(ok=%v)", ok)
|
||
}
|
||
// 单本包不应出现全库索引
|
||
if _, ok := readZipEntry(t, zr, "library.json"); ok {
|
||
t.Fatal("单本包不应包含 library.json")
|
||
}
|
||
}
|
||
|
||
func TestLibraryExportAll(t *testing.T) {
|
||
s, _ := newLibraryTestService(t)
|
||
uploads := t.TempDir()
|
||
s.WithUploadsDir(uploads)
|
||
docID, storedName := seedBookForExport(t, s, uploads)
|
||
_ = docID
|
||
|
||
exp, err := s.BuildAllExport()
|
||
if err != nil {
|
||
t.Fatalf("build all export: %v", err)
|
||
}
|
||
defer exp.Close()
|
||
zr := buildExportZip(t, exp)
|
||
|
||
raw, ok := readZipEntry(t, zr, "library.json")
|
||
if !ok {
|
||
t.Fatal("缺少 library.json")
|
||
}
|
||
var manifest libraryExportManifest
|
||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||
t.Fatalf("parse library.json: %v", err)
|
||
}
|
||
if manifest.Format != libraryExportFormat || len(manifest.Docs) != 1 {
|
||
t.Fatalf("全库索引异常: %+v", manifest)
|
||
}
|
||
idx := manifest.Docs[0]
|
||
if idx.Dir != "docs/dao-jia/" || idx.FileCount != 1 || idx.SectionCount != 2 {
|
||
t.Fatalf("索引条目异常: %+v", idx)
|
||
}
|
||
|
||
bookRaw, ok := readZipEntry(t, zr, "docs/dao-jia/book.json")
|
||
if !ok {
|
||
t.Fatal("缺少 docs/dao-jia/book.json")
|
||
}
|
||
var book libraryExportBook
|
||
if err := json.Unmarshal(bookRaw, &book); err != nil {
|
||
t.Fatalf("parse nested book.json: %v", err)
|
||
}
|
||
if book.Slug != "dao-jia" || book.CoverFile != "cover.jpg" {
|
||
t.Fatalf("嵌套书籍清单异常: %+v", book)
|
||
}
|
||
if _, ok := readZipEntry(t, zr, "docs/dao-jia/files/"+storedName); !ok {
|
||
t.Fatal("缺少嵌套附件")
|
||
}
|
||
if _, ok := readZipEntry(t, zr, "docs/dao-jia/cover.jpg"); !ok {
|
||
t.Fatal("缺少嵌套封面")
|
||
}
|
||
}
|
||
|
||
func TestLibraryExportMissingDoc(t *testing.T) {
|
||
s, _ := newLibraryTestService(t)
|
||
if _, err := s.BuildBookExport(999); !errors.Is(err, ErrLibraryNotFound) {
|
||
t.Fatalf("不存在的条目应返回 ErrLibraryNotFound,got %v", err)
|
||
}
|
||
}
|