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