Files
jiang13-bbs/backend/service/library_test.go
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

507 lines
16 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 (
"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("<h1>hi</h1>"))
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("草稿条目不应出现在公开详情")
}
}