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