Files
jiang13-bbs/backend/service/legacyimport_test.go
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

282 lines
11 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 (
"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
}