package service import ( "errors" "os" "path/filepath" "strings" "testing" "github.com/freefire/jiang13-bbs/model" "github.com/glebarez/sqlite" "gorm.io/gorm" ) func newLibraryTestService(t *testing.T) (*LibraryService, string) { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := db.AutoMigrate(&model.LibraryDoc{}, &model.LibraryFile{}, &model.LibrarySection{}); err != nil { t.Fatalf("migrate: %v", err) } dir := t.TempDir() s := NewLibraryService(db, dir) if err := s.EnsureDir(); err != nil { t.Fatalf("ensure dir: %v", err) } return s, dir } func libPub(b bool) *bool { return &b } func libInt(v int) *int { return &v } func TestLibraryNormalize(t *testing.T) { cases := []struct { name string mutate func(*LibraryInput) wantErr string }{ {"slug 为空", func(i *LibraryInput) { i.Slug = "" }, "slug 不能为空"}, {"slug 非法", func(i *LibraryInput) { i.Slug = "Bad_Slug" }, "slug 仅允许"}, {"slug 超长", func(i *LibraryInput) { i.Slug = strings.Repeat("a", 65) }, "slug 不能超过"}, {"标题为空", func(i *LibraryInput) { i.Title = " " }, "标题不能为空"}, {"标题超限", func(i *LibraryInput) { i.Title = strings.Repeat("书", 201) }, "标题不能超过"}, {"介绍超限", func(i *LibraryInput) { i.Description = strings.Repeat("介", 20001) }, "介绍不能超过"}, {"合法", func(i *LibraryInput) {}, ""}, } for _, tc := range cases { in := &LibraryInput{Slug: "how-to-live-better", Title: "怎样生活得更好"} tc.mutate(in) err := in.normalize() if tc.wantErr == "" { if err != nil { t.Fatalf("%s: 不应报错,got %v", tc.name, err) } continue } if err == nil || !strings.Contains(err.Error(), tc.wantErr) { t.Fatalf("%s: 期望包含 %q,got %v", tc.name, tc.wantErr, err) } } } func TestLibrarySlugUnique(t *testing.T) { s, _ := newLibraryTestService(t) if _, err := s.Create(&LibraryInput{Slug: "epub-latest", Title: "A"}); err != nil { t.Fatalf("create a: %v", err) } if _, err := s.Create(&LibraryInput{Slug: "epub-latest", Title: "B"}); err == nil { t.Fatal("重复 slug 应报错") } } func TestLibrarySlugReuseAfterSoftDelete(t *testing.T) { s, _ := newLibraryTestService(t) doc, err := s.Create(&LibraryInput{Slug: "test", Title: "测试"}) if err != nil { t.Fatalf("create: %v", err) } if err := s.Delete(doc.ID); err != nil { t.Fatalf("delete: %v", err) } // 软删后重建同 slug:报"已删除"提示而非数据库唯一索引错误 _, err = s.Create(&LibraryInput{Slug: "test", Title: "重建"}) if err == nil || !strings.Contains(err.Error(), "已删除") { t.Fatalf("软删后重建应提示已删除条目占用,got %v", err) } // 彻底删除后可重建 if err := s.Purge(doc.ID); err != nil { t.Fatalf("purge: %v", err) } if _, err := s.Create(&LibraryInput{Slug: "test", Title: "重建"}); err != nil { t.Fatalf("purge 后重建同 slug 不应报错: %v", err) } } func TestLibraryPurgeRequiresDeleted(t *testing.T) { s, _ := newLibraryTestService(t) doc, _ := s.Create(&LibraryInput{Slug: "alive", Title: "在用"}) if err := s.Purge(doc.ID); err == nil || !errors.Is(err, ErrLibraryNotDeleted) { t.Fatalf("未删条目 Purge 应拒绝,got %v", err) } } func TestLibraryPurgeRemovesFilesAndSections(t *testing.T) { s, dir := newLibraryTestService(t) doc, _ := s.Create(&LibraryInput{Slug: "purge-me", Title: "待清"}) f, err := s.AddFile(doc.ID, 1, "x.txt", strings.NewReader("x")) if err != nil { t.Fatalf("add file: %v", err) } full := filepath.Join(dir, "library", f.StoredName) if _, err := os.Stat(full); err != nil { t.Fatalf("文件未落盘: %v", err) } if _, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章"}); err != nil { t.Fatalf("create section: %v", err) } if err := s.Delete(doc.ID); err != nil { t.Fatalf("delete: %v", err) } if err := s.Purge(doc.ID); err != nil { t.Fatalf("purge: %v", err) } var n int64 s.db.Unscoped().Model(&model.LibraryDoc{}).Count(&n) if n != 0 { t.Fatalf("条目行应已清除,got %d", n) } s.db.Unscoped().Model(&model.LibraryFile{}).Count(&n) if n != 0 { t.Fatalf("文件行应已清除,got %d", n) } s.db.Unscoped().Model(&model.LibrarySection{}).Count(&n) if n != 0 { t.Fatalf("章节行应已清除,got %d", n) } if _, err := os.Stat(full); !os.IsNotExist(err) { t.Fatalf("磁盘文件应已删除,err=%v", err) } } func TestLibraryExtWhitelists(t *testing.T) { if !LibraryExtAllowed("epub") || !LibraryExtAllowed(".md") { t.Fatal("白名单内扩展名应允许") } if LibraryExtAllowed("exe") || LibraryExtAllowed("php") || LibraryExtAllowed("") { t.Fatal("白名单外扩展名应拒绝") } } func TestLibraryAddFile(t *testing.T) { s, dir := newLibraryTestService(t) doc, err := s.Create(&LibraryInput{Slug: "how-to-live-better", Title: "怎样生活得更好"}) if err != nil { t.Fatalf("create: %v", err) } // 白名单内:落盘 + 建行 f, err := s.AddFile(doc.ID, 1, "如何生活.pdf", strings.NewReader("PDF-DATA")) if err != nil { t.Fatalf("add pdf: %v", err) } if f.Ext != "pdf" || f.Size != 8 { t.Fatalf("文件元数据错误: %+v", f) } if f.MIME != "application/pdf" { t.Fatalf("pdf MIME 应为 application/pdf,got %s", f.MIME) } if _, err := os.Stat(filepath.Join(dir, "library", f.StoredName)); err != nil { t.Fatalf("文件未落盘: %v", err) } // html 存储层 MIME 强制 octet-stream(下载安全) h, err := s.AddFile(doc.ID, 1, "page.html", strings.NewReader("

hi

")) if err != nil { t.Fatalf("add html: %v", err) } if h.MIME != "application/octet-stream" { t.Fatalf("html 下载 MIME 应强制 octet-stream,got %s", h.MIME) } // 白名单外拒绝 if _, err := s.AddFile(doc.ID, 1, "evil.exe", strings.NewReader("MZ")); err == nil { t.Fatal("exe 应被拒绝") } if _, err := s.AddFile(doc.ID, 1, "noext", strings.NewReader("x")); err == nil { t.Fatal("无扩展名应被拒绝") } // 超过每条目上限 for i := 0; i < MaxLibraryFilesPerDoc-2; i++ { if _, err := s.AddFile(doc.ID, 1, "f.txt", strings.NewReader("x")); err != nil { t.Fatalf("add #%d: %v", i, err) } } if _, err := s.AddFile(doc.ID, 1, "over.txt", strings.NewReader("x")); err == nil { t.Fatal("超出文件数上限应报错") } // 未发布条目的文件公开不可见 if _, err := s.GetPublicFile(f.ID); err == nil { t.Fatal("未发布条目的文件不应公开可见") } } func TestLibraryPublishedFilterAndOrder(t *testing.T) { s, _ := newLibraryTestService(t) if _, err := s.Create(&LibraryInput{Slug: "draft-doc", Title: "草稿"}); err != nil { t.Fatalf("create draft: %v", err) } published, _ := s.Create(&LibraryInput{ Slug: "live-doc", Title: "已发布", Published: libPub(true), SortOrder: libInt(1), }) if _, err := s.AddFile(published.ID, 1, "b.txt", strings.NewReader("b")); err != nil { t.Fatalf("add b: %v", err) } if _, err := s.AddFile(published.ID, 1, "a.txt", strings.NewReader("a")); err != nil { t.Fatalf("add a: %v", err) } // 未发布不出现在公开目录 list, err := s.ListPublished() if err != nil { t.Fatalf("ListPublished: %v", err) } if len(list) != 1 || list[0].Slug != "live-doc" { t.Fatalf("公开目录应只含 live-doc,got %+v", list) } if list[0].FileCount != 2 { t.Fatalf("列表应带文件计数 2,got %d", list[0].FileCount) } if _, err := s.GetPublishedBySlug("draft-doc"); err == nil { t.Fatal("未发布条目按 slug 不应可见") } // 软删后公开彻底不可见 if err := s.Delete(published.ID); err != nil { t.Fatalf("delete: %v", err) } if _, err := s.GetPublishedBySlug("live-doc"); err == nil { t.Fatal("软删条目不应可见") } // 管理端仍可见(含未发布;软删条目带 Deleted 标记一并列出) all, err := s.ListAll() if err != nil { t.Fatalf("ListAll: %v", err) } if len(all) != 2 { t.Fatalf("管理端应含 draft-doc 与软删的 live-doc,got %+v", all) } bySlug := map[string]LibraryDocDetail{} for _, d := range all { bySlug[d.Slug] = d } if d := bySlug["draft-doc"]; d.Deleted { t.Fatalf("draft-doc 不应标记已删除: %+v", d) } if d := bySlug["live-doc"]; !d.Deleted { t.Fatalf("live-doc 应标记已删除: %+v", d) } } func TestLibraryFileOrdering(t *testing.T) { s, _ := newLibraryTestService(t) doc, _ := s.Create(&LibraryInput{Slug: "order", Title: "排序", Published: libPub(true)}) // b 先传(id 小),a 设 sort_order 靠前 b, _ := s.AddFile(doc.ID, 1, "b.txt", strings.NewReader("b")) a, _ := s.AddFile(doc.ID, 1, "a.txt", strings.NewReader("a")) s.db.Model(&model.LibraryFile{}).Where("id = ?", a.ID).Update("sort_order", -1) _ = b d, err := s.GetPublishedBySlug("order") if err != nil { t.Fatalf("detail: %v", err) } if len(d.Files) != 2 || d.Files[0].Name != "a.txt" { t.Fatalf("文件应按 sort_order,id 排序: %+v", d.Files) } } func TestLibraryIncrDownload(t *testing.T) { s, _ := newLibraryTestService(t) doc, _ := s.Create(&LibraryInput{Slug: "cnt", Title: "计数", Published: libPub(true)}) f, err := s.AddFile(doc.ID, 1, "x.pdf", strings.NewReader("x")) if err != nil { t.Fatalf("add: %v", err) } s.IncrDownload(f.ID) s.IncrDownload(f.ID) got, err := s.GetPublicFile(f.ID) if err != nil { t.Fatalf("get: %v", err) } if got.DownloadCount != 2 { t.Fatalf("下载计数应为 2,got %d", got.DownloadCount) } } func TestLibraryDeleteFile(t *testing.T) { s, _ := newLibraryTestService(t) doc, _ := s.Create(&LibraryInput{Slug: "del", Title: "删除"}) f, err := s.AddFile(doc.ID, 1, "gone.txt", strings.NewReader("gone")) if err != nil { t.Fatalf("add: %v", err) } if err := s.DeleteFile(f.ID); err != nil { t.Fatalf("delete file: %v", err) } if err := s.DeleteFile(f.ID); err == nil { t.Fatal("重复删除应报不存在") } } // ---------- 章节(两级树) ---------- func TestLibrarySectionTwoLevelConstraint(t *testing.T) { s, _ := newLibraryTestService(t) doc, _ := s.Create(&LibraryInput{Slug: "book", Title: "书"}) ch, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章"}) if err != nil { t.Fatalf("create chapter: %v", err) } sec, err := s.CreateSection(doc.ID, &SectionInput{ParentID: &ch.ID, Title: "第一节"}) if err != nil { t.Fatalf("create section: %v", err) } if sec.ParentID == nil || *sec.ParentID != ch.ID { t.Fatalf("节的父应为章 %d", ch.ID) } // 节下不能再挂节 if _, err := s.CreateSection(doc.ID, &SectionInput{ParentID: &sec.ID, Title: "深层"}); err == nil { t.Fatal("三级嵌套应被拒绝") } // 跨条目父章节 doc2, _ := s.Create(&LibraryInput{Slug: "book2", Title: "书2"}) if _, err := s.CreateSection(doc2.ID, &SectionInput{ParentID: &ch.ID, Title: "跨条目"}); err == nil { t.Fatal("跨条目父章节应被拒绝") } } func TestLibrarySectionMoveSwap(t *testing.T) { s, _ := newLibraryTestService(t) doc, _ := s.Create(&LibraryInput{Slug: "mv", Title: "排序"}) a, _ := s.CreateSection(doc.ID, &SectionInput{Title: "甲"}) b, _ := s.CreateSection(doc.ID, &SectionInput{Title: "乙"}) c, _ := s.CreateSection(doc.ID, &SectionInput{Title: "丙"}) if err := s.MoveSection(b.ID, "up"); err != nil { t.Fatalf("move up: %v", err) } secs, _ := s.ListSections(doc.ID) if secs[0].ID != b.ID || secs[1].ID != a.ID || secs[2].ID != c.ID { t.Fatalf("上移后顺序应为 乙甲丙: %+v", titles(secs)) } // 边界 no-op if err := s.MoveSection(b.ID, "up"); err != nil { t.Fatalf("边界上移: %v", err) } if err := s.MoveSection(b.ID, "bad"); err == nil { t.Fatal("非法方向应报错") } } func titles(secs []model.LibrarySection) []string { out := make([]string, len(secs)) for i, sec := range secs { out[i] = sec.Title } return out } func TestLibraryImportMarkdown(t *testing.T) { s, _ := newLibraryTestService(t) doc, _ := s.Create(&LibraryInput{Slug: "imp", Title: "导入"}) // h2 拆分:围栏内 ## 不拆;首标题前内容并入第一章 md := "## 前言\n\n引言正文。\n\n```\n## 不是标题\n```\n\n## 第二章\n\n内容二。" secs, err := s.ImportMarkdown(doc.ID, "how.md", md, "h2") if err != nil { t.Fatalf("import: %v", err) } if len(secs) != 2 { t.Fatalf("应拆出 2 章,got %d: %+v", len(secs), titles(secs)) } if secs[0].Title != "前言" || !strings.Contains(secs[0].Content, "引言正文") { t.Fatalf("第一章错误: %+v", secs[0]) } if !strings.Contains(secs[0].Content, "不是标题") { t.Fatal("围栏内的 ## 行应留在第一章正文,不应被拆分") } if secs[1].Title != "第二章" { t.Fatalf("第二章标题错误: %+v", secs[1]) } // 无命中标题 → 回退单章,标题取文件名 secs2, err := s.ImportMarkdown(doc.ID, "README.md", "只有正文,没有标题。", "h1") if err != nil { t.Fatalf("import fallback: %v", err) } if len(secs2) != 1 || secs2[0].Title != "README" { t.Fatalf("应回退单章 README: %+v", titles(secs2)) } // 非法 splitLevel if _, err := s.ImportMarkdown(doc.ID, "x.md", "x", "h3"); err == nil { t.Fatal("非法拆分方式应报错") } } func TestLibraryImportMarkdownBatch(t *testing.T) { s, _ := newLibraryTestService(t) doc, _ := s.Create(&LibraryInput{Slug: "impb", Title: "批量导入"}) files := []ImportFileInput{ {Filename: "01-不想早死.md", Content: "## 甲\n\n内容甲。"}, {Filename: "02-不要慢性自杀.md", Content: "只有正文,没有标题。"}, {Filename: "03.md", Content: "# 丙\n\n内容丙。"}, } secs, err := s.ImportMarkdownBatch(doc.ID, files, "h2") if err != nil { t.Fatalf("batch import: %v", err) } // 文件1 按 h2 拆出 1 章;文件2、文件3 未命中 ## → 回退单章(标题取文件名) if len(secs) != 3 { t.Fatalf("应创建 3 章,got %d: %+v", len(secs), titles(secs)) } if secs[0].Title != "甲" { t.Fatalf("文件1 应按 h2 拆出章: %+v", titles(secs)) } if secs[1].Title != "02-不要慢性自杀" { t.Fatalf("文件2 标题应取文件名: %+v", titles(secs)) } if secs[2].Title != "03" { t.Fatalf("文件3 未命中 ## 应回退单章: %+v", titles(secs)) } for i, sec := range secs { if sec.SortOrder != i { t.Fatalf("sort_order 应按文件顺序连续: %+v", titles(secs)) } } // 文件数超限 many := make([]ImportFileInput, MaxImportFiles+1) for i := range many { many[i] = ImportFileInput{Filename: "f.md", Content: "x"} } if _, err := s.ImportMarkdownBatch(doc.ID, many, "none"); err == nil { t.Fatal("文件数超过上限应报错") } // 空文件列表 if _, err := s.ImportMarkdownBatch(doc.ID, nil, "none"); err == nil { t.Fatal("空文件列表应报错") } } func TestLibraryDeleteSectionCascade(t *testing.T) { s, _ := newLibraryTestService(t) doc, _ := s.Create(&LibraryInput{Slug: "cas", Title: "级联"}) ch, _ := s.CreateSection(doc.ID, &SectionInput{Title: "章"}) sec, _ := s.CreateSection(doc.ID, &SectionInput{ParentID: &ch.ID, Title: "节"}) if err := s.DeleteSection(ch.ID); err != nil { t.Fatalf("delete chapter: %v", err) } // 章与节都被软删 if _, err := s.section(ch.ID); err == nil { t.Fatal("章应已删除") } if _, err := s.section(sec.ID); err == nil { t.Fatal("章删除时其下小节应级联删除") } } func TestLibraryDetailCarriesSections(t *testing.T) { s, _ := newLibraryTestService(t) doc, _ := s.Create(&LibraryInput{Slug: "carry", Title: "携带", Published: libPub(true)}) if _, err := s.CreateSection(doc.ID, &SectionInput{Title: "章一", Content: "正文一"}); err != nil { t.Fatalf("create: %v", err) } d, err := s.GetPublishedBySlug("carry") if err != nil { t.Fatalf("detail: %v", err) } if len(d.Sections) != 1 || d.Sections[0].Content != "正文一" { t.Fatalf("公开详情应携带章节: %+v", d.Sections) } // 草稿条目不可见 doc2, _ := s.Create(&LibraryInput{Slug: "draft", Title: "草稿书"}) if _, err := s.CreateSection(doc2.ID, &SectionInput{Title: "草稿章"}); err != nil { t.Fatalf("create: %v", err) } if _, err := s.GetPublishedBySlug("draft"); err == nil { t.Fatal("草稿条目不应出现在公开详情") } }