testdb_test.go 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. package auth
  2. import (
  3. "context"
  4. "crypto/rand"
  5. "database/sql"
  6. "encoding/hex"
  7. "fmt"
  8. "net/url"
  9. "os"
  10. "strings"
  11. "testing"
  12. "github.com/kairo/haven/internal/db"
  13. )
  14. // 集成测试的隔离方式:每个用例在自己的 PostgreSQL schema 里跑。
  15. //
  16. // 平铺的表名加上 search_path 切换,既能复用 0001_init.up.sql,又不会让并发的
  17. // TRUNCATE 互相踩踏(`go test ./...` 会并行跑不同包,同包内也可能并发)。
  18. //
  19. // HAVEN_TEST_DB_DSN='postgres://haven:...@127.0.0.1:5432/haven_dev?sslmode=disable' \
  20. // go test ./... -v
  21. // newSchemaService 在独占 schema 里建表,返回已切到该 schema 的 Service 与连接。
  22. func newSchemaService(t *testing.T) *Service {
  23. t.Helper()
  24. admin, dsn := openTestDB(t)
  25. schema := fmt.Sprintf("test_%s", sanitize(t.Name()))
  26. ctx := context.Background()
  27. if _, err := admin.ExecContext(ctx, `DROP SCHEMA IF EXISTS `+schema+` CASCADE`); err != nil {
  28. t.Fatalf("清理测试 schema 失败: %v", err)
  29. }
  30. if _, err := admin.ExecContext(ctx, `CREATE SCHEMA `+schema); err != nil {
  31. t.Fatalf("创建测试 schema 失败: %v", err)
  32. }
  33. // 在该 schema 内重建全部表:search_path 切到新 schema,
  34. // pg_trgm 建在 public 并由 search_path 继承。
  35. conn, err := db.Open(ctx, dsnWithSchema(dsn, schema+",public"))
  36. if err != nil {
  37. t.Fatalf("连接测试 schema 失败: %v", err)
  38. }
  39. t.Cleanup(func() {
  40. conn.Close()
  41. admin.ExecContext(context.Background(), `DROP SCHEMA IF EXISTS `+schema+` CASCADE`)
  42. admin.Close()
  43. })
  44. migration, err := os.ReadFile("./migrations/0001_init.up.sql")
  45. if err == nil {
  46. // 从包目录跑时可用
  47. if _, err := conn.ExecContext(ctx, string(migration)); err != nil {
  48. t.Fatalf("在测试 schema 内建表失败: %v", err)
  49. }
  50. } else {
  51. // 在 internal/auth 下跑时,迁移文件在 ../../migrations
  52. migration, err = os.ReadFile("../../migrations/0001_init.up.sql")
  53. if err != nil {
  54. t.Fatalf("读取 migration 失败: %v", err)
  55. }
  56. if _, err := conn.ExecContext(ctx, string(migration)); err != nil {
  57. t.Fatalf("在测试 schema 内建表失败: %v", err)
  58. }
  59. }
  60. return NewService(conn)
  61. }
  62. // openTestDB 打开与测试库的连接,未设置 HAVEN_TEST_DB_DSN 时跳过。
  63. func openTestDB(t *testing.T) (*sql.DB, string) {
  64. t.Helper()
  65. dsn := os.Getenv("HAVEN_TEST_DB_DSN")
  66. if dsn == "" {
  67. t.Skip("未设置 HAVEN_TEST_DB_DSN,跳过数据库集成测试")
  68. }
  69. conn, err := db.Open(context.Background(), dsn)
  70. if err != nil {
  71. t.Fatalf("连接测试库失败: %v", err)
  72. }
  73. t.Cleanup(func() { conn.Close() })
  74. return conn, dsn
  75. }
  76. // dsnWithSchema 在 DSN 上追加 search_path。
  77. func dsnWithSchema(dsn, searchPath string) string {
  78. u, err := url.Parse(dsn)
  79. if err != nil {
  80. sep := "?"
  81. if strings.Contains(dsn, "?") {
  82. sep = "&"
  83. }
  84. return dsn + sep + "search_path=" + url.QueryEscape(searchPath)
  85. }
  86. q := u.Query()
  87. q.Set("search_path", searchPath)
  88. u.RawQuery = q.Encode()
  89. return u.String()
  90. }
  91. // randomSuffix 给 schema 名加一个短随机后缀,避免 -count>1 时重名。
  92. func randomSuffix() string {
  93. buf := make([]byte, 4)
  94. _, _ = rand.Read(buf)
  95. return hex.EncodeToString(buf)
  96. }
  97. // sanitize 把测试名变成合法的 schema 片段(只留小写字母数字下划线,限长 40)。
  98. func sanitize(name string) string {
  99. var b strings.Builder
  100. for _, r := range strings.ToLower(name) {
  101. switch {
  102. case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_':
  103. b.WriteRune(r)
  104. default:
  105. b.WriteRune('_')
  106. }
  107. }
  108. out := b.String()
  109. if len(out) > 40 {
  110. out = out[:40]
  111. }
  112. return out
  113. }
苏ICP备2026082406号
苏公网安备 待审核