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: `

看图

`}, &legacyPostRow{ID: 101, BoardID: 10, UserID: 2, Title: "普通帖", Status: "published", PostType: "normal", Content: `

正文

`}, &legacyPostRow{ID: 102, BoardID: 10, UserID: 3, Title: "草稿帖", Status: "draft", PostType: "normal", Content: `

草稿

`}, &legacyPostRow{ID: 103, BoardID: 10, UserID: 1, Title: "投票帖", Status: "published", PostType: "poll", Content: `

投票

`}, &legacyPostRow{ID: 104, BoardID: 10, UserID: 4, Title: "缺图帖", Status: "published", PostType: "normal", Content: `

缺图

`}, &legacyCommentRow{ID: 200, PostID: 100, UserID: 2, Status: "published", Content: `

补一张

`}, &legacyCommentRow{ID: 201, PostID: 100, UserID: 3, Status: "published", Content: `

顶

`}, &legacyCommentRow{ID: 202, PostID: 101, UserID: 1, Status: "published", Content: `

前排

`}, } 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 }