Files
hfb_sys/backend/internal/modules/chat/qrcode_test.go
T
yml2213 cbd5412315 test: 后端测试从 SQLite 全面切换到 MySQL,与生产环境对齐
测试库不再使用 SQLite 内存库,改为每个测试包独立的 MySQL 库,
表结构由 backend/migrations 的真实迁移建立。

基础设施
- 新增 internal/database/testdb.go:NewTestDB() 保持原签名(28 处调用零改动),
  内部连接 MySQL 测试库,首次调用重建库并执行全部真实迁移,之后每次调用清空全表
- 库名 = 测试库基础名 + 包目录名,同包共享、跨包隔离,使 go test ./... 可并发执行
  (共用一库时各包会互相 DROP/CREATE 导致 database exists)
- 新增 NewDryRunDB():MySQL 方言 + DryRun,供断言 SQL 形态的 5 处测试使用
- 新增 AutoMigrateIfMissing():只对不存在的表 AutoMigrate,
  避免改动迁移定义的索引与约束
- 新增 scripts/prepare-test-db.sh:建库与通配授权,可重复执行
- 33 处 sqlite.Open 全部替换,26 个测试文件改用 MySQL
- 删除 46 处手写 SQLite 方言 DDL(AUTOINCREMENT / INTEGER PRIMARY KEY / TEXT),
  表统一由真实迁移建立
- go.mod 移除 gorm.io/driver/sqlite 与 mattn/go-sqlite3
- 删除生产代码里 distribution/repository.go 的 sqlite 方言分支(strftime)

对齐环境后暴露并修复的真实问题
- DATE(col) 在 MySQL 返回 DATE 类型,使 Date string 字段变成 RFC3339 时间串,
  大盘日期会带出时间部分;28 处改为 DATE_FORMAT(col, '%Y-%m-%d')
- 系统自动创建分销结算批次时 created_by 写入 0,违反指向 admin_users 的外键,
  生产会直接插入失败;改为写 NULL(新增 adminRef helper)
- MySQL DATETIME 仅到秒,静默截断 Go 侧写入的时间,
  导致租用时长计算与结算时点回读比对偏差;
  rental_orders 的 9 个业务时间列提到 DATETIME(6)
- 测试数据补齐严格模式要求的 NOT NULL 列、修正超出列长的取值、补齐外键父记录

迁移
- 000065 的 ALTER TABLE rental_orders 一并完成业务时间列精度调整,
  feature 侧不再单开迁移文件

脚本与文档
- check.sh / check-backend-coverage.sh 去掉 -p 1 串行与 SQLite 相关注释
- 更新 docs/Repository层测试补充总结.md 的测试运行说明
2026-09-10 14:34:01 +08:00

482 lines
14 KiB
Go

package chat
import (
"errors"
"strings"
"testing"
"time"
"hfb_sys/backend/internal/database"
"hfb_sys/backend/internal/model"
"gorm.io/gorm"
)
func setupQrCodeTestDB(t *testing.T) *gorm.DB {
t.Helper()
db := database.NewTestDB()
if err := database.AutoMigrateIfMissing(db,
&model.ChatQrCode{},
&model.DistributionSite{},
&model.DistributionSiteAccount{},
&model.ChatConversation{},
&model.ChatParticipant{},
&model.ChatMessage{},
&model.ChatQrCodeDeliveryTask{},
&model.ChatSupportGroup{},
&model.ChatSupportGroupMember{},
&model.AdminUser{},
&model.AdminUserRole{},
&model.Role{},
&model.SystemConfig{},
); err != nil {
t.Fatalf("数据库迁移失败: %v", err)
}
return db
}
func TestGetQrCodeOCRConfigReadsSystemConfig(t *testing.T) {
db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil, nil)
rows := []model.SystemConfig{
{Key: qrCodeOCRTokenConfigKey, Value: "test-token"},
{Key: qrCodeOCRJobURLConfigKey, Value: "https://example.test/ocr/jobs"},
{Key: qrCodeOCRModelConfigKey, Value: "PaddleOCR-Test"},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatalf("创建系统配置失败: %v", err)
}
config, err := repo.GetQrCodeOCRConfig(t.Context())
if err != nil {
t.Fatalf("读取 OCR 配置失败: %v", err)
}
if config.Token != "test-token" {
t.Fatalf("token = %q", config.Token)
}
if config.JobURL != "https://example.test/ocr/jobs" {
t.Fatalf("job_url = %q", config.JobURL)
}
if config.Model != "PaddleOCR-Test" {
t.Fatalf("model = %q", config.Model)
}
}
func TestListQrCodesIncludesBoundConversationTitle(t *testing.T) {
db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil, nil)
conversation := model.ChatConversation{
Title: "账号群 L202606180001",
Type: ConversationTypeListingGroup,
Status: "active",
}
if err := db.Create(&conversation).Error; err != nil {
t.Fatalf("创建群聊失败: %v", err)
}
qrcode := model.ChatQrCode{
ImageURL: "/api/files/object?key=qrcode/group.png",
GroupName: "王大锤-鼠鼠跑刀一群",
Status: QrCodeStatusUsed,
ConversationID: &conversation.ID,
CreatedBy: 1,
}
if err := db.Create(&qrcode).Error; err != nil {
t.Fatalf("创建二维码失败: %v", err)
}
items, total, err := repo.ListQrCodes(t.Context(), QrCodeListRequest{Page: 1, Limit: 20})
if err != nil {
t.Fatalf("查询二维码失败: %v", err)
}
if total != 1 || len(items) != 1 {
t.Fatalf("total = %d, len = %d, want 1", total, len(items))
}
if items[0].GroupName != "王大锤-鼠鼠跑刀一群" {
t.Fatalf("群名 = %q", items[0].GroupName)
}
if items[0].BoundConversationTitle != "账号群 L202606180001" {
t.Fatalf("绑定群名 = %q", items[0].BoundConversationTitle)
}
}
func TestListQrCodesFilters(t *testing.T) {
db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil, nil)
now := time.Now()
expiredAt := now.Add(-time.Hour)
validAt := now.Add(7 * 24 * time.Hour)
expiringAt := now.Add(2 * time.Hour)
conversation := model.ChatConversation{
Title: "账号群 绑定测试",
Type: ConversationTypeListingGroup,
Status: "active",
}
if err := db.Create(&conversation).Error; err != nil {
t.Fatalf("创建群聊失败: %v", err)
}
qrcodes := []model.ChatQrCode{
{
ImageURL: "/api/files/object?key=qrcode/unused-a.png",
GroupName: "待用群A",
Status: QrCodeStatusUnused,
CreatedBy: 1,
Note: "六月首批",
ExpiresAt: &validAt,
CreatedAt: now.Add(-48 * time.Hour),
},
{
ImageURL: "/api/files/object?key=qrcode/used-b.png",
GroupName: "已发群B",
Status: QrCodeStatusUsed,
ConversationID: &conversation.ID,
CreatedBy: 1,
Note: "特殊备注",
WecomRenamed: true,
ExpiresAt: &expiredAt,
CreatedAt: now.Add(-2 * time.Hour),
},
{
ImageURL: "/api/files/object?key=qrcode/permanent-c.png",
GroupName: "永久群C",
Status: QrCodeStatusDisabled,
CreatedBy: 1,
Note: "长期备用",
CreatedAt: now.Add(-24 * time.Hour),
},
{
ImageURL: "/api/files/object?key=qrcode/expiring-d.png",
GroupName: "临期群D",
Status: QrCodeStatusUnused,
CreatedBy: 1,
Note: "三天内到期",
ExpiresAt: &expiringAt,
CreatedAt: now.Add(-30 * time.Minute),
},
}
if err := db.Create(&qrcodes).Error; err != nil {
t.Fatalf("创建二维码失败: %v", err)
}
assertListIDs := func(name string, req QrCodeListRequest, want ...uint64) {
t.Helper()
req.Page = 1
req.Limit = 20
items, total, err := repo.ListQrCodes(t.Context(), req)
if err != nil {
t.Fatalf("%s 查询二维码失败: %v", name, err)
}
if total != int64(len(want)) || len(items) != len(want) {
t.Fatalf("%s total = %d, len = %d, want %d", name, total, len(items), len(want))
}
for i, id := range want {
if items[i].ID != id {
t.Fatalf("%s 第 %d 条 ID = %d, want %d", name, i, items[i].ID, id)
}
}
}
assertListIDs("状态", QrCodeListRequest{Status: QrCodeStatusUnused}, qrcodes[3].ID, qrcodes[0].ID)
assertListIDs("关键词群名", QrCodeListRequest{Keyword: "待用群"}, qrcodes[0].ID)
assertListIDs("关键词备注", QrCodeListRequest{Keyword: "特殊备注"}, qrcodes[1].ID)
assertListIDs("关键词绑定群", QrCodeListRequest{Keyword: "绑定测试"}, qrcodes[1].ID)
assertListIDs("企微改名", QrCodeListRequest{WecomRenamed: "true"}, qrcodes[1].ID)
assertListIDs("未绑定", QrCodeListRequest{Bound: "false"}, qrcodes[3].ID, qrcodes[2].ID, qrcodes[0].ID)
assertListIDs("已过期", QrCodeListRequest{ExpireStatus: "expired"}, qrcodes[1].ID)
assertListIDs("永久有效", QrCodeListRequest{ExpireStatus: "permanent"}, qrcodes[2].ID)
assertListIDs("即将过期", QrCodeListRequest{ExpireStatus: "expiring_soon"}, qrcodes[3].ID)
assertListIDs(
"上传时间",
QrCodeListRequest{
CreatedStart: now.Add(-3 * time.Hour).Format(time.RFC3339),
CreatedEnd: now.Add(-90 * time.Minute).Format(time.RFC3339),
},
qrcodes[1].ID,
)
}
func TestParseQrCodeGroupNameCandidates(t *testing.T) {
rawText := strings.Join([]string{
"使用微信或企业微信扫码加入",
"王大锤-鼠鼠跑刀一群",
"这二维码7天内有效",
}, "\n")
candidates := parseQrCodeGroupNameCandidates(rawText)
if len(candidates) == 0 {
t.Fatal("未解析出群名候选")
}
if candidates[0] != "王大锤-鼠鼠跑刀一群" {
t.Fatalf("首个候选 = %q", candidates[0])
}
}
func TestUpdateQrCodeRejectsUsedToUnused(t *testing.T) {
db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil, nil)
now := time.Date(2026, 6, 18, 12, 0, 0, 0, time.UTC)
conversationID := uint64(1001)
qrcode := model.ChatQrCode{
ImageURL: "/api/files/object?key=qrcode/used.png",
Status: QrCodeStatusUsed,
ConversationID: &conversationID,
UsedAt: &now,
CreatedBy: 1,
}
if err := db.Create(&qrcode).Error; err != nil {
t.Fatalf("创建二维码失败: %v", err)
}
status := QrCodeStatusUnused
err := repo.UpdateQrCode(t.Context(), qrcode.ID, UpdateQrCodeRequest{Status: &status})
if !errors.Is(err, ErrQrCodeCannotReenable) {
t.Fatalf("error = %v, want ErrQrCodeCannotReenable", err)
}
var saved model.ChatQrCode
if err := db.First(&saved, qrcode.ID).Error; err != nil {
t.Fatalf("查询二维码失败: %v", err)
}
if saved.Status != QrCodeStatusUsed {
t.Fatalf("二维码状态 = %q, want %q", saved.Status, QrCodeStatusUsed)
}
}
func TestUpdateQrCodeRejectsIssuedDisabledToUnused(t *testing.T) {
db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil, nil)
conversationID := uint64(1002)
qrcode := model.ChatQrCode{
ImageURL: "/api/files/object?key=qrcode/disabled.png",
Status: QrCodeStatusDisabled,
ConversationID: &conversationID,
CreatedBy: 1,
}
if err := db.Create(&qrcode).Error; err != nil {
t.Fatalf("创建二维码失败: %v", err)
}
status := QrCodeStatusUnused
err := repo.UpdateQrCode(t.Context(), qrcode.ID, UpdateQrCodeRequest{Status: &status})
if !errors.Is(err, ErrQrCodeCannotReenable) {
t.Fatalf("error = %v, want ErrQrCodeCannotReenable", err)
}
}
func TestUpdateQrCodeAllowsUnusedToDisabled(t *testing.T) {
db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil, nil)
qrcode := model.ChatQrCode{
ImageURL: "/api/files/object?key=qrcode/unused.png",
Status: QrCodeStatusUnused,
CreatedBy: 1,
}
if err := db.Create(&qrcode).Error; err != nil {
t.Fatalf("创建二维码失败: %v", err)
}
status := QrCodeStatusDisabled
if err := repo.UpdateQrCode(t.Context(), qrcode.ID, UpdateQrCodeRequest{Status: &status}); err != nil {
t.Fatalf("更新二维码失败: %v", err)
}
var saved model.ChatQrCode
if err := db.First(&saved, qrcode.ID).Error; err != nil {
t.Fatalf("查询二维码失败: %v", err)
}
if saved.Status != QrCodeStatusDisabled {
t.Fatalf("二维码状态 = %q, want %q", saved.Status, QrCodeStatusDisabled)
}
}
func TestUpdateQrCodeGroupNameAndRenameFlag(t *testing.T) {
db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil, nil)
qrcode := model.ChatQrCode{
ImageURL: "/api/files/object?key=qrcode/unused.png",
Status: QrCodeStatusUnused,
CreatedBy: 1,
}
if err := db.Create(&qrcode).Error; err != nil {
t.Fatalf("创建二维码失败: %v", err)
}
groupName := "王大锤-鼠鼠跑刀一群"
renamed := true
if err := repo.UpdateQrCode(t.Context(), qrcode.ID, UpdateQrCodeRequest{
GroupName: &groupName,
WecomRenamed: &renamed,
}); err != nil {
t.Fatalf("更新二维码失败: %v", err)
}
var saved model.ChatQrCode
if err := db.First(&saved, qrcode.ID).Error; err != nil {
t.Fatalf("查询二维码失败: %v", err)
}
if saved.GroupName != groupName {
t.Fatalf("群名 = %q, want %q", saved.GroupName, groupName)
}
if !saved.WecomRenamed {
t.Fatal("企微改名标记未保存")
}
}
func TestDeleteQrCodeAllowsIssuedQrCode(t *testing.T) {
db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil, nil)
now := time.Date(2026, 6, 19, 17, 8, 0, 0, time.UTC)
conversationID := uint64(1003)
qrcode := model.ChatQrCode{
ImageURL: "/api/files/object?key=qrcode/issued.png",
Status: QrCodeStatusUsed,
ConversationID: &conversationID,
UsedAt: &now,
CreatedBy: 1,
}
if err := db.Create(&qrcode).Error; err != nil {
t.Fatalf("创建二维码失败: %v", err)
}
if err := repo.DeleteQrCode(t.Context(), qrcode.ID); err != nil {
t.Fatalf("删除已发放二维码失败: %v", err)
}
var count int64
if err := db.Model(&model.ChatQrCode{}).Where("id = ?", qrcode.ID).Count(&count).Error; err != nil {
t.Fatalf("统计二维码失败: %v", err)
}
if count != 0 {
t.Fatalf("二维码数量 = %d, want 0", count)
}
}
func TestBatchDeleteQrCodes(t *testing.T) {
db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil, nil)
now := time.Date(2026, 6, 19, 17, 8, 0, 0, time.UTC)
conversationID := uint64(1004)
qrcodes := []model.ChatQrCode{
{
ImageURL: "/api/files/object?key=qrcode/unused.png",
Status: QrCodeStatusUnused,
CreatedBy: 1,
},
{
ImageURL: "/api/files/object?key=qrcode/used.png",
Status: QrCodeStatusUsed,
ConversationID: &conversationID,
UsedAt: &now,
CreatedBy: 1,
},
{
ImageURL: "/api/files/object?key=qrcode/keep.png",
Status: QrCodeStatusDisabled,
CreatedBy: 1,
},
}
if err := db.Create(&qrcodes).Error; err != nil {
t.Fatalf("创建二维码失败: %v", err)
}
deleted, err := repo.BatchDeleteQrCodes(t.Context(), []uint64{qrcodes[0].ID, qrcodes[1].ID})
if err != nil {
t.Fatalf("批量删除二维码失败: %v", err)
}
if deleted != 2 {
t.Fatalf("删除数量 = %d, want 2", deleted)
}
var remaining []model.ChatQrCode
if err := db.Order("id ASC").Find(&remaining).Error; err != nil {
t.Fatalf("查询剩余二维码失败: %v", err)
}
if len(remaining) != 1 || remaining[0].ID != qrcodes[2].ID {
t.Fatalf("剩余二维码 = %+v, want id %d", remaining, qrcodes[2].ID)
}
}
func TestEnsureListingConversationCreatesQrCodeDeliveryTaskWhenStockEmpty(t *testing.T) {
db := setupQrCodeTestDB(t)
listing := model.RentalListing{
ID: 101,
ListingNo: "L202606260001",
OwnerID: 10,
}
if err := db.Transaction(func(tx *gorm.DB) error {
_, err := EnsureListingConversation(tx, listing, 0)
return err
}); err != nil {
t.Fatalf("建发布群失败: %v", err)
}
var task model.ChatQrCodeDeliveryTask
if err := db.First(&task).Error; err != nil {
t.Fatalf("未创建补发任务: %v", err)
}
if task.Status != "pending" {
t.Fatalf("补发任务状态 = %q, want pending", task.Status)
}
}
func TestBatchCreateQrCodeDeliversPendingTask(t *testing.T) {
db := setupQrCodeTestDB(t)
repo := NewRepository(db, nil, nil)
conversation := model.ChatConversation{
Title: "账号群 L202606260002",
Type: ConversationTypeListingGroup,
Status: "active",
}
if err := db.Create(&conversation).Error; err != nil {
t.Fatalf("创建群聊失败: %v", err)
}
task := model.ChatQrCodeDeliveryTask{
ConversationID: conversation.ID,
Status: "pending",
}
if err := db.Create(&task).Error; err != nil {
t.Fatalf("创建补发任务失败: %v", err)
}
_, err := repo.BatchCreateQrCode(t.Context(), 1, BatchCreateQrCodeRequest{
Items: []CreateQrCodeRequest{
{ImageURL: "/api/files/object?key=qrcode/replenish.png", GroupName: "补发群"},
},
})
if err != nil {
t.Fatalf("批量创建二维码失败: %v", err)
}
var savedTask model.ChatQrCodeDeliveryTask
if err := db.First(&savedTask, task.ID).Error; err != nil {
t.Fatalf("查询补发任务失败: %v", err)
}
if savedTask.Status != "sent" {
t.Fatalf("补发任务状态 = %q, want sent", savedTask.Status)
}
if savedTask.QrCodeID == nil {
t.Fatal("补发任务未记录二维码 ID")
}
if savedTask.SentAt == nil {
t.Fatal("补发任务未记录 sent_at")
}
var qrcode model.ChatQrCode
if err := db.First(&qrcode, *savedTask.QrCodeID).Error; err != nil {
t.Fatalf("查询二维码失败: %v", err)
}
if qrcode.Status != QrCodeStatusUsed {
t.Fatalf("二维码状态 = %q, want used", qrcode.Status)
}
if qrcode.ConversationID == nil || *qrcode.ConversationID != conversation.ID {
t.Fatalf("二维码绑定会话 = %v, want %d", qrcode.ConversationID, conversation.ID)
}
var message model.ChatMessage
if err := db.Where("conversation_id = ? AND content_type = ?", conversation.ID, "image").First(&message).Error; err != nil {
t.Fatalf("未生成二维码图片消息: %v", err)
}
}