// 数据库双方言测试(需求 ⑧): // - sqlite:始终执行(纯 Go,临时目录建库); // - postgres:设置 FCB_TEST_PG_DSN(真实连接串)后执行,未设置时跳过。 // // 覆盖:Open/Migrate 全表建立、settings KV 读写、JSON 字段往返、 // 分页查询(LIMIT/OFFSET 语义)、布尔/时间字段往返 —— 双方言逐项比对。 package database_test import ( "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "testing" "time" "gorm.io/gorm" "filecodebox/internal/database" "filecodebox/internal/model" ) // pgTestDSN 返回 Postgres 测试连接串;未设置 FCB_TEST_PG_DSN 时返回空。 func pgTestDSN(t *testing.T) string { t.Helper() dsn := os.Getenv("FCB_TEST_PG_DSN") if dsn == "" { t.Skip("未设置 FCB_TEST_PG_DSN,跳过 Postgres 双方言用例(sqlite 用例仍执行)") } return dsn } // openTestDB 按方言打开数据库并执行迁移;返回 gorm 实例与关闭函数。 func openTestDB(t *testing.T, driver, dsn string) (*gorm.DB, func()) { t.Helper() if dsn == "" { // sqlite:临时文件库 dir := t.TempDir() dsn = filepath.Join(dir, "test.db") } ctx := context.Background() db, err := database.Open(ctx, database.Options{Driver: driver, DSN: dsn}) if err != nil { t.Fatalf("[%s] Open 失败: %v", driver, err) } if err := database.Migrate(ctx, db); err != nil { _ = database.Close(db) t.Fatalf("[%s] Migrate 失败: %v", driver, err) } return db, func() { _ = database.Close(db) } } // runDialectSuite 双方言共用的行为断言集。 func runDialectSuite(t *testing.T, db *gorm.DB) { t.Helper() ctx := context.Background() // —— 1. 全表建立 —— for _, m := range model.AllModels() { if !db.Migrator().HasTable(m) { t.Fatalf("表 %T 未创建", m) } } // —— 2. settings KV 读写 + JSON 字段往返 —— // GORM 软特性:KeyValue.Value 为 *string(JSON 文本),双方言 text 类型 // 可重跑:先清掉同键旧行(共享测试库场景) if err := db.WithContext(ctx).Where(model.KeyValue{Key: "settings"}).Delete(&model.KeyValue{}).Error; err != nil { t.Fatalf("KV 旧数据清理失败: %v", err) } kv := map[string]any{"background_url": "https://example.com/bg.jpg", "footer_beian": "京ICP备2024000001号-1", "max_save_seconds": 3600} raw, err := json.Marshal(kv) if err != nil { t.Fatalf("marshal KV: %v", err) } row := model.KeyValue{Key: "settings", Value: strPtr(string(raw))} if err := db.WithContext(ctx).Create(&row).Error; err != nil { t.Fatalf("KV 写入失败: %v", err) } var got model.KeyValue if err := db.WithContext(ctx).Where(model.KeyValue{Key: "settings"}).First(&got).Error; err != nil { t.Fatalf("KV 读取失败: %v", err) } parsed := map[string]any{} if err := json.Unmarshal([]byte(*got.Value), &parsed); err != nil { t.Fatalf("KV JSON 解析失败: %v", err) } if parsed["background_url"] != "https://example.com/bg.jpg" { t.Fatalf("KV JSON 字段往返不一致: %v", parsed) } // 更新(先查后改,方言无关) if err := db.WithContext(ctx).Model(&got).Update("value", strPtr(`{"notify_enabled":0}`)).Error; err != nil { t.Fatalf("KV 更新失败: %v", err) } var got2 model.KeyValue _ = db.WithContext(ctx).Where(model.KeyValue{Key: "settings"}).First(&got2) if *got2.Value != `{"notify_enabled":0}` { t.Fatalf("KV 更新未生效: %s", *got2.Value) } // —— 3. 分页查询(LIMIT/OFFSET)—— // 每次运行用随机前缀避免脏数据互相影响 prefix := fmt.Sprintf("pg%d_", time.Now().UnixNano()) for i := 0; i < 25; i++ { fc := model.FileCodes{ Code: fmt.Sprintf("%s%03d", prefix, i), ExpiredCount: -1, IsChunked: i%2 == 0, // 布尔字段往返 } if err := db.WithContext(ctx).Create(&fc).Error; err != nil { t.Fatalf("FileCodes 写入失败: %v", err) } } var page []model.FileCodes if err := db.WithContext(ctx). Where("code LIKE ?", prefix+"%"). Order("id ASC"). Limit(10).Offset(20). Find(&page).Error; err != nil { t.Fatalf("分页查询失败: %v", err) } if len(page) != 5 { t.Fatalf("第二页应剩 5 条,实际 %d", len(page)) } if page[0].Code != prefix+"020" { t.Fatalf("分页偏移错误: %s", page[0].Code) } var total int64 if err := db.WithContext(ctx).Model(&model.FileCodes{}). Where("code LIKE ?", prefix+"%").Count(&total).Error; err != nil { t.Fatalf("计数查询失败: %v", err) } if total != 25 { t.Fatalf("总数应 25,实际 %d", total) } // —— 4. 布尔/时间/可空字段往返 —— now := time.Now().Truncate(time.Second) // sqlite 秒级精度 fc := model.FileCodes{ Code: prefix + "special", ExpiredAt: &now, ExpiredCount: 5, Text: strPtr("你好 FileCodeBox"), FileHash: strPtr("abc123"), IsChunked: true, } if err := db.WithContext(ctx).Create(&fc).Error; err != nil { t.Fatalf("完整字段写入失败: %v", err) } var back model.FileCodes if err := db.WithContext(ctx).Where(model.FileCodes{Code: fc.Code}).First(&back).Error; err != nil { t.Fatalf("完整字段读取失败: %v", err) } if back.Text == nil || *back.Text != "你好 FileCodeBox" { t.Fatalf("text 字段往返不一致: %v", back.Text) } if !back.IsChunked { t.Fatal("布尔字段往返不一致") } if back.ExpiredAt == nil { t.Fatal("时间字段往返丢失") } if diff := back.ExpiredAt.Sub(now); diff > time.Second || diff < -time.Second { t.Fatalf("时间字段偏差过大: %v", diff) } if back.FileHash == nil || *back.FileHash != "abc123" { t.Fatalf("可空字段往返不一致: %v", back.FileHash) } // LOWER + LIKE(admin 列表检索路径:真实代码先对关键词小写化再拼 LIKE 模式, // 对齐 admin.go 的 "LOWER(code) LIKE ?" 用法,双方言均支持) var hits int64 lowerPattern := "%" + strings.ToLower(prefix+"SPECIAL") + "%" if err := db.WithContext(ctx).Model(&model.FileCodes{}). Where("LOWER(code) LIKE ?", lowerPattern).Count(&hits).Error; err != nil { t.Fatalf("LOWER/LIKE 查询失败: %v", err) } if hits != 1 { t.Fatalf("LOWER/LIKE 命中数应 1,实际 %d", hits) } // 可重跑:清理本前缀数据(共享测试库场景) if err := db.WithContext(ctx).Where("code LIKE ?", prefix+"%").Delete(&model.FileCodes{}).Error; err != nil { t.Fatalf("清理测试数据失败: %v", err) } } func strPtr(s string) *string { return &s } // TestSQLiteDialect sqlite(默认模式):临时文件库全流程。 func TestSQLiteDialect(t *testing.T) { db, closeFn := openTestDB(t, "sqlite", "") defer closeFn() runDialectSuite(t, db) } // TestSQLiteInMemoryDialect sqlite 内存库(DSN 为 :memory: 等价路径场景)。 func TestSQLiteInMemoryDialect(t *testing.T) { dir := t.TempDir() db, closeFn := openTestDB(t, "sqlite", filepath.Join(dir, "mem.db")) defer closeFn() runDialectSuite(t, db) } // TestPostgresDialect postgres(可选模式):FCB_TEST_PG_DSN 指向真实实例。 func TestPostgresDialect(t *testing.T) { dsn := pgTestDSN(t) db, closeFn := openTestDB(t, "postgres", dsn) defer closeFn() runDialectSuite(t, db) } // TestOpenRejectsUnknownDriver 非法驱动应报错。 func TestOpenRejectsUnknownDriver(t *testing.T) { if _, err := database.Open(context.Background(), database.Options{Driver: "mysql", DSN: "x"}); err == nil { t.Fatal("非法驱动应报错") } } // TestOpenPostgresRequiresDSN postgres 模式缺 DSN 应报错。 func TestOpenPostgresRequiresDSN(t *testing.T) { if _, err := database.Open(context.Background(), database.Options{Driver: "postgres", DSN: ""}); err == nil { t.Fatal("postgres 缺 DSN 应报错") } } // TestSQLiteAutoCreatesDataDir sqlite 默认相对路径下自动创建父目录。 func TestSQLiteAutoCreatesDataDir(t *testing.T) { dir := t.TempDir() nested := filepath.Join(dir, "deep", "data", "fcb.db") db, closeFn := openTestDB(t, "sqlite", nested) defer closeFn() if _, err := os.Stat(nested); err != nil { t.Fatalf("数据库文件应已创建: %v", err) } runDialectSuite(t, db) }