feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
281
backend/service/legacyimport_test.go
Normal file
281
backend/service/legacyimport_test.go
Normal file
@@ -0,0 +1,281 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user