Files
FileShare/server/internal/database/database_test.go
T
SKYMirror 6f1a925833
Release 镜像 / 测试(推送前置门禁) (push) Failing after 12s
Release 镜像 / 多架构构建并推送 ACR (push) Skipped
26.9:品牌统一(fileshare)+ 版本号改为日期式
- 数据库默认文件 filecodebox.db → fileshare.db(config.go 默认值与全部文档/编排同步)
- Go module filecodebox → fileshare(全部 import 同步,build/vet/test 全绿)
- 应用版本 APP_VERSION 2.5.6 → 26.9(health 接口已验证返回 26.9)
- deploy 编排统一:compose 项目名、Postgres 默认凭据、minio 桶名、env 注释
- JWT issuer、存储临时目录前缀、web 包名同步 fileshare
- CI:镜像 tag 以 APP_VERSION 为唯一版本源,main/tag 推送即发布
  ${VER} + latest;tag 触发时校验 tag 名与 APP_VERSION 一致,防错版
- 本地开发库文件已改名 fileshare.db(含 -shm/-wal 清理)
2026-09-05 06:32:18 +08:00

238 lines
7.9 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.
// 数据库双方言测试(需求 ⑧):
// - 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"
"fileshare/internal/database"
"fileshare/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 为 *stringJSON 文本),双方言 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 + LIKEadmin 列表检索路径:真实代码先对关键词小写化再拼 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)
}