新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
282 lines
11 KiB
Go
282 lines
11 KiB
Go
package service
|
||
|
||
import (
|
||
"archive/zip"
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/freefire/jiang13-bbs/model"
|
||
"github.com/glebarez/sqlite"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/logger"
|
||
)
|
||
|
||
// newLegacyTestEnv 建新站内存库 + 旧站 SQLite 文件 + 两个 zip 包,并灌入固定种子数据:
|
||
// 用户 alice(#1)/bob(#2)/carol(#3)/dave(#4),板块 闲聊(#10),
|
||
// 帖子 #100(alice)/#101(bob)/#102(carol 草稿)/#103(alice 投票)/#104(dave),
|
||
// 评论 #200(bob→100)/#201(carol→100)/#202(alice→101)。
|
||
func newLegacyTestEnv(t *testing.T) (*LegacyImportService, string, string, string, uint, map[uint]bool) {
|
||
t.Helper()
|
||
tmp := t.TempDir()
|
||
|
||
newDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||
if err != nil {
|
||
t.Fatalf("open new db: %v", err)
|
||
}
|
||
if err := newDB.AutoMigrate(&model.User{}, &model.Board{}, &model.Post{}, &model.Comment{}, &model.ImportRecord{}); err != nil {
|
||
t.Fatalf("migrate new db: %v", err)
|
||
}
|
||
admin := model.User{Username: "admin", Password: "x", Nickname: "站长", Role: model.RoleOwner}
|
||
if err := newDB.Create(&admin).Error; err != nil {
|
||
t.Fatalf("seed admin: %v", err)
|
||
}
|
||
|
||
oldPath := filepath.Join(tmp, "old.db")
|
||
oldDB, err := gorm.Open(sqlite.Open(oldPath), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||
if err != nil {
|
||
t.Fatalf("open old db: %v", err)
|
||
}
|
||
if err := oldDB.AutoMigrate(&legacyUserRow{}, &legacyBoardRow{}, &legacyPostRow{}, &legacyCommentRow{}); err != nil {
|
||
t.Fatalf("migrate old db: %v", err)
|
||
}
|
||
seed := []interface{}{
|
||
&legacyUserRow{ID: 1, Username: "alice", Password: "h1", Nickname: "爱丽丝", Avatar: "/uploads/avatars/a1.jpg"},
|
||
&legacyUserRow{ID: 2, Username: "bob", Password: "h2", Nickname: "鲍勃"},
|
||
&legacyUserRow{ID: 3, Username: "carol", Password: "h3", Nickname: "卡罗尔"},
|
||
&legacyUserRow{ID: 4, Username: "dave", Password: "h4"},
|
||
&legacyBoardRow{ID: 10, Name: "闲聊"},
|
||
&legacyPostRow{ID: 100, BoardID: 10, UserID: 1, Title: "看图帖", Status: "published", PostType: "normal",
|
||
Content: `<p>看图</p><p><img src="/uploads/posts/img1.jpg"></p>`},
|
||
&legacyPostRow{ID: 101, BoardID: 10, UserID: 2, Title: "普通帖", Status: "published", PostType: "normal", Content: `<p>正文</p>`},
|
||
&legacyPostRow{ID: 102, BoardID: 10, UserID: 3, Title: "草稿帖", Status: "draft", PostType: "normal", Content: `<p>草稿</p>`},
|
||
&legacyPostRow{ID: 103, BoardID: 10, UserID: 1, Title: "投票帖", Status: "published", PostType: "poll", Content: `<p>投票</p>`},
|
||
&legacyPostRow{ID: 104, BoardID: 10, UserID: 4, Title: "缺图帖", Status: "published", PostType: "normal",
|
||
Content: `<p>缺图</p><p><img src="/uploads/posts/missing.jpg"></p>`},
|
||
&legacyCommentRow{ID: 200, PostID: 100, UserID: 2, Status: "published",
|
||
Content: `<p>补一张</p><p><img src="https://old.example.com/uploads/posts/img1.jpg"></p>`},
|
||
&legacyCommentRow{ID: 201, PostID: 100, UserID: 3, Status: "published", Content: `<p>顶</p>`},
|
||
&legacyCommentRow{ID: 202, PostID: 101, UserID: 1, Status: "published", Content: `<p>前排</p>`},
|
||
}
|
||
for _, row := range seed {
|
||
if err := oldDB.Create(row).Error; err != nil {
|
||
t.Fatalf("seed old db: %v", err)
|
||
}
|
||
}
|
||
// seed 完成即释放句柄,避免 TempDir 清理时文件被占用
|
||
if sqlDB, err := oldDB.DB(); err == nil {
|
||
_ = sqlDB.Close()
|
||
}
|
||
|
||
avatarZip := filepath.Join(tmp, "avatars.zip")
|
||
writeTestZip(t, avatarZip, map[string]string{"a1.jpg": "fake-avatar"})
|
||
imagesZip := filepath.Join(tmp, "images.zip")
|
||
writeTestZip(t, imagesZip, map[string]string{"img1.jpg": "fake-image"})
|
||
|
||
svc := NewLegacyImportService(newDB, filepath.Join(tmp, "uploads"))
|
||
// 入厅钩子桩:生产为 PostgreSQL,ChatService.EnsureDefaultMembership 里的
|
||
// GREATEST 表达式 SQLite 不支持,故用等价桩验证「建号即触发钩子」的接线
|
||
hallMembers := map[uint]bool{}
|
||
svc.WithHallMembership(func(id uint) error {
|
||
hallMembers[id] = true
|
||
return nil
|
||
})
|
||
return svc, oldPath, avatarZip, imagesZip, admin.ID, hallMembers
|
||
}
|
||
|
||
func writeTestZip(t *testing.T, path string, files map[string]string) {
|
||
t.Helper()
|
||
f, err := os.Create(path)
|
||
if err != nil {
|
||
t.Fatalf("create zip: %v", err)
|
||
}
|
||
w := zip.NewWriter(f)
|
||
for name, content := range files {
|
||
fw, err := w.Create(name)
|
||
if err != nil {
|
||
t.Fatalf("zip entry: %v", err)
|
||
}
|
||
if _, err := fw.Write([]byte(content)); err != nil {
|
||
t.Fatalf("zip write: %v", err)
|
||
}
|
||
}
|
||
if err := w.Close(); err != nil {
|
||
t.Fatalf("zip close: %v", err)
|
||
}
|
||
if err := f.Close(); err != nil {
|
||
t.Fatalf("zip file close: %v", err)
|
||
}
|
||
}
|
||
|
||
func TestLegacyImportPreviewLists(t *testing.T) {
|
||
svc, oldPath, avatarZip, imagesZip, _, _ := newLegacyTestEnv(t)
|
||
rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, LegacyImportOptions{WithContent: true, DryRun: true}, 1)
|
||
if err != nil {
|
||
t.Fatalf("dry run: %v", err)
|
||
}
|
||
if len(rep.UserList) != 4 || len(rep.PostList) != 5 || len(rep.CommentList) != 3 {
|
||
t.Fatalf("预检清单数量不符: users=%d posts=%d comments=%d", len(rep.UserList), len(rep.PostList), len(rep.CommentList))
|
||
}
|
||
if rep.UserList[0].Username != "alice" || !rep.UserList[0].HasAvatar {
|
||
t.Fatal("alice 预检项不符")
|
||
}
|
||
// 将导入:#100/#101/#104(草稿、投票排除)
|
||
if rep.Posts.Imported != 3 || rep.Posts.PollsSkipped != 1 {
|
||
t.Fatalf("预检帖子统计不符: %+v", rep.Posts)
|
||
}
|
||
// dry-run 不落盘
|
||
if _, err := os.Stat(filepath.Join(svc.uploadsDir, "images", "img1.jpg")); !os.IsNotExist(err) {
|
||
t.Fatal("预检不应写文件")
|
||
}
|
||
}
|
||
|
||
func TestLegacyImportSelectiveRun(t *testing.T) {
|
||
svc, oldPath, avatarZip, imagesZip, adminID, hallMembers := newLegacyTestEnv(t)
|
||
opts := LegacyImportOptions{
|
||
WithContent: true,
|
||
SkipUserIDs: map[uint]bool{2: true, 3: true}, // bob / carol 不建号
|
||
UserTargetNames: map[uint]string{2: "admin"}, // bob 的内容归到已有账号 admin
|
||
SkipCommentIDs: map[uint]bool{202: true}, // 排除 alice 在 #101 的评论
|
||
}
|
||
rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
|
||
if err != nil {
|
||
t.Fatalf("import: %v", err)
|
||
}
|
||
|
||
// 用户:alice / dave 建号,bob / carol 排除
|
||
if rep.Users.Imported != 2 || rep.Users.Excluded != 2 {
|
||
t.Fatalf("用户统计不符: %+v", rep.Users)
|
||
}
|
||
// 帖子:#100/#101/#104 导入,#102 草稿跳过,#103 投票
|
||
if rep.Posts.Imported != 3 || rep.Posts.Excluded != 0 || rep.Posts.PollsSkipped != 1 {
|
||
t.Fatalf("帖子统计不符: %+v", rep.Posts)
|
||
}
|
||
// 评论:#200/#201 导入,#202 排除
|
||
if rep.Comments.Imported != 2 || rep.Comments.Excluded != 1 {
|
||
t.Fatalf("评论统计不符: %+v", rep.Comments)
|
||
}
|
||
// 图片:#100 与 #200 各引用 img1.jpg,只落盘一次
|
||
if rep.Posts.ImagesWritten != 1 || len(rep.Comments.ImagesMissing) != 0 {
|
||
t.Fatalf("图片统计不符: posts=%+v comments=%+v", rep.Posts.ImagesWritten, rep.Comments.ImagesMissing)
|
||
}
|
||
if len(rep.Posts.ImagesMissing) != 1 || rep.Posts.ImagesMissing[0] != "missing.jpg" {
|
||
t.Fatalf("缺失图片应记录: %v", rep.Posts.ImagesMissing)
|
||
}
|
||
|
||
// 内容归属
|
||
var post100, post101 model.Post
|
||
if err := svc.db.Where("title = ?", "看图帖").First(&post100).Error; err != nil {
|
||
t.Fatalf("post100: %v", err)
|
||
}
|
||
if err := svc.db.Where("title = ?", "普通帖").First(&post101).Error; err != nil {
|
||
t.Fatalf("post101: %v", err)
|
||
}
|
||
var alice model.User
|
||
if err := svc.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
|
||
t.Fatalf("alice: %v", err)
|
||
}
|
||
if post100.UserID != alice.ID {
|
||
t.Fatalf("#100 应归 alice: got %d", post100.UserID)
|
||
}
|
||
if post101.UserID != adminID {
|
||
t.Fatalf("#101 应按 user_map 归 admin: got %d", post101.UserID)
|
||
}
|
||
|
||
// bob 的评论 #200 也归 admin;carol 的评论 #201 兜底归 admin
|
||
var cm200, cm201 model.Comment
|
||
if err := svc.db.Where("content LIKE ?", "%补一张%").First(&cm200).Error; err != nil {
|
||
t.Fatalf("cm200: %v", err)
|
||
}
|
||
if err := svc.db.Where("content LIKE ?", "%顶%").First(&cm201).Error; err != nil {
|
||
t.Fatalf("cm201: %v", err)
|
||
}
|
||
if cm200.UserID != adminID || cm201.UserID != adminID {
|
||
t.Fatalf("评论归属不符: cm200=%d cm201=%d", cm200.UserID, cm201.UserID)
|
||
}
|
||
|
||
// 图片 URL 改写 + 文件落盘
|
||
if !strings.Contains(post100.Content, legacyImagePrefix+"img1.jpg") || strings.Contains(post100.Content, legacyPostImagePrefix) {
|
||
t.Fatalf("#100 图片 URL 未改写: %s", post100.Content)
|
||
}
|
||
if !strings.Contains(cm200.Content, legacyImagePrefix+"img1.jpg") || strings.Contains(cm200.Content, "old.example.com") {
|
||
t.Fatalf("#200 图片 URL 未改写: %s", cm200.Content)
|
||
}
|
||
// 缺图帖:包内缺失的引用保留原链接,不改写
|
||
p104 := post104Content(t, svc)
|
||
if !strings.Contains(p104, legacyPostImagePrefix+"missing.jpg") || strings.Contains(p104, legacyImagePrefix) {
|
||
t.Fatalf("缺图引用应保留原链接: %s", p104)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(svc.uploadsDir, "images", "img1.jpg")); err != nil {
|
||
t.Fatalf("img1.jpg 未落盘: %v", err)
|
||
}
|
||
if _, err := os.Stat(filepath.Join(svc.uploadsDir, "avatars", "a1.jpg")); err != nil {
|
||
t.Fatalf("头像未落盘: %v", err)
|
||
}
|
||
|
||
// 导入建号的用户触发入厅钩子;被排除建号的 bob/carol 不触发
|
||
var dave model.User
|
||
if err := svc.db.Where("username = ?", "dave").First(&dave).Error; err != nil {
|
||
t.Fatalf("dave: %v", err)
|
||
}
|
||
if len(hallMembers) != 2 || !hallMembers[alice.ID] || !hallMembers[dave.ID] {
|
||
t.Fatalf("入厅钩子应只对 alice/dave 触发: %v", hallMembers)
|
||
}
|
||
|
||
// 幂等:重复导入全部跳过
|
||
rep2, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
|
||
if err != nil {
|
||
t.Fatalf("re-import: %v", err)
|
||
}
|
||
if rep2.Users.Imported != 0 || rep2.Posts.Imported != 0 || rep2.Comments.Imported != 0 {
|
||
t.Fatalf("重复导入应全部跳过: %+v", rep2)
|
||
}
|
||
if rep2.Users.Excluded != 2 || rep2.Posts.Skipped != 4 || rep2.Comments.Skipped != 2 {
|
||
t.Fatalf("重复导入统计不符: users=%+v posts=%+v comments=%+v", rep2.Users, rep2.Posts, rep2.Comments)
|
||
}
|
||
}
|
||
|
||
// TestLegacyImportSkipPostCascades 排除帖子时其评论自动跳过
|
||
func TestLegacyImportSkipPostCascades(t *testing.T) {
|
||
svc, oldPath, avatarZip, imagesZip, adminID, _ := newLegacyTestEnv(t)
|
||
opts := LegacyImportOptions{
|
||
WithContent: true,
|
||
SkipPostIDs: map[uint]bool{101: true}, // #101 排除 → 其评论 #202 自动跳过
|
||
}
|
||
rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
|
||
if err != nil {
|
||
t.Fatalf("import: %v", err)
|
||
}
|
||
if rep.Posts.Imported != 2 || rep.Posts.Excluded != 1 {
|
||
t.Fatalf("帖子统计不符: %+v", rep.Posts)
|
||
}
|
||
if rep.Comments.Imported != 2 || rep.Comments.Excluded != 1 {
|
||
t.Fatalf("评论统计不符(#202 应随 #101 排除): %+v", rep.Comments)
|
||
}
|
||
var n int64
|
||
svc.db.Model(&model.Post{}).Where("title = ?", "普通帖").Count(&n)
|
||
if n != 0 {
|
||
t.Fatal("#101 不应导入")
|
||
}
|
||
}
|
||
|
||
// TestLegacyImportBadTarget 归属目标账号不存在时报错
|
||
func TestLegacyImportBadTarget(t *testing.T) {
|
||
svc, oldPath, avatarZip, imagesZip, adminID, _ := newLegacyTestEnv(t)
|
||
opts := LegacyImportOptions{WithContent: true, UserTargetNames: map[uint]string{2: "ghost"}}
|
||
_, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
|
||
if !errors.Is(err, ErrLegacyBadTarget) {
|
||
t.Fatalf("应返回 ErrLegacyBadTarget: %v", err)
|
||
}
|
||
}
|
||
|
||
func post104Content(t *testing.T, svc *LegacyImportService) string {
|
||
t.Helper()
|
||
var p model.Post
|
||
if err := svc.db.Where("title = ?", "缺图帖").First(&p).Error; err != nil {
|
||
t.Fatalf("post104: %v", err)
|
||
}
|
||
return p.Content
|
||
}
|