integration_harness_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. //go:build integration
  2. package repository
  3. import (
  4. "context"
  5. "database/sql"
  6. "fmt"
  7. "log"
  8. "os"
  9. "os/exec"
  10. "strconv"
  11. "strings"
  12. "sync/atomic"
  13. "testing"
  14. "time"
  15. dbent "github.com/Wei-Shaw/sub2api/ent"
  16. _ "github.com/Wei-Shaw/sub2api/ent/runtime"
  17. "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
  18. "github.com/stretchr/testify/require"
  19. "github.com/stretchr/testify/suite"
  20. "entgo.io/ent/dialect"
  21. entsql "entgo.io/ent/dialect/sql"
  22. _ "github.com/lib/pq"
  23. redisclient "github.com/redis/go-redis/v9"
  24. tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
  25. tcredis "github.com/testcontainers/testcontainers-go/modules/redis"
  26. )
  27. const (
  28. redisImageTag = "redis:8.4-alpine"
  29. postgresImageTag = "postgres:18.1-alpine3.23"
  30. )
  31. var (
  32. integrationDB *sql.DB
  33. integrationEntClient *dbent.Client
  34. integrationRedis *redisclient.Client
  35. redisNamespaceSeq uint64
  36. )
  37. func TestMain(m *testing.M) {
  38. ctx := context.Background()
  39. if err := timezone.Init("UTC"); err != nil {
  40. log.Printf("failed to init timezone: %v", err)
  41. os.Exit(1)
  42. }
  43. if !dockerIsAvailable(ctx) {
  44. // In CI we expect Docker to be available so integration tests should fail loudly.
  45. if os.Getenv("CI") != "" {
  46. log.Printf("docker is not available (CI=true); failing integration tests")
  47. os.Exit(1)
  48. }
  49. log.Printf("docker is not available; skipping integration tests (start Docker to enable)")
  50. os.Exit(0)
  51. }
  52. postgresImage := selectDockerImage(ctx, postgresImageTag)
  53. pgContainer, err := tcpostgres.Run(
  54. ctx,
  55. postgresImage,
  56. tcpostgres.WithDatabase("sub2api_test"),
  57. tcpostgres.WithUsername("postgres"),
  58. tcpostgres.WithPassword("postgres"),
  59. tcpostgres.BasicWaitStrategies(),
  60. )
  61. if err != nil {
  62. log.Printf("failed to start postgres container: %v", err)
  63. os.Exit(1)
  64. }
  65. defer func() { _ = pgContainer.Terminate(ctx) }()
  66. redisContainer, err := tcredis.Run(
  67. ctx,
  68. redisImageTag,
  69. )
  70. if err != nil {
  71. log.Printf("failed to start redis container: %v", err)
  72. os.Exit(1)
  73. }
  74. defer func() { _ = redisContainer.Terminate(ctx) }()
  75. dsn, err := pgContainer.ConnectionString(ctx, "sslmode=disable", "TimeZone=UTC")
  76. if err != nil {
  77. log.Printf("failed to get postgres dsn: %v", err)
  78. os.Exit(1)
  79. }
  80. integrationDB, err = openSQLWithRetry(ctx, dsn, 30*time.Second)
  81. if err != nil {
  82. log.Printf("failed to open sql db: %v", err)
  83. os.Exit(1)
  84. }
  85. if err := ApplyMigrations(ctx, integrationDB); err != nil {
  86. log.Printf("failed to apply db migrations: %v", err)
  87. os.Exit(1)
  88. }
  89. // 创建 ent client 用于集成测试
  90. drv := entsql.OpenDB(dialect.Postgres, integrationDB)
  91. integrationEntClient = dbent.NewClient(dbent.Driver(drv))
  92. redisHost, err := redisContainer.Host(ctx)
  93. if err != nil {
  94. log.Printf("failed to get redis host: %v", err)
  95. os.Exit(1)
  96. }
  97. redisPort, err := redisContainer.MappedPort(ctx, "6379/tcp")
  98. if err != nil {
  99. log.Printf("failed to get redis port: %v", err)
  100. os.Exit(1)
  101. }
  102. integrationRedis = redisclient.NewClient(&redisclient.Options{
  103. Addr: fmt.Sprintf("%s:%d", redisHost, redisPort.Int()),
  104. DB: 0,
  105. })
  106. if err := integrationRedis.Ping(ctx).Err(); err != nil {
  107. log.Printf("failed to ping redis: %v", err)
  108. os.Exit(1)
  109. }
  110. code := m.Run()
  111. _ = integrationEntClient.Close()
  112. _ = integrationRedis.Close()
  113. _ = integrationDB.Close()
  114. os.Exit(code)
  115. }
  116. func dockerIsAvailable(ctx context.Context) bool {
  117. cmd := exec.CommandContext(ctx, "docker", "info")
  118. cmd.Env = os.Environ()
  119. return cmd.Run() == nil
  120. }
  121. // selectDockerImage resolves the container image for the harness.
  122. //
  123. // SUB2API_TEST_POSTGRES_IMAGE overrides the PostgreSQL image so the suite can be
  124. // run against the oldest documented-supported server, not just the newest. That
  125. // matters for SQL that behaves differently across major versions: jsonpath
  126. // .datetime() only accepts the ISO-8601 "Z" designator from PostgreSQL 17 on, so
  127. // a suite pinned to 18 cannot observe breakage on 14-16.
  128. //
  129. // SUB2API_TEST_POSTGRES_IMAGE=postgres:15-alpine go test -tags integration ./internal/repository/
  130. func selectDockerImage(ctx context.Context, preferred string) string {
  131. if override := strings.TrimSpace(os.Getenv("SUB2API_TEST_POSTGRES_IMAGE")); override != "" &&
  132. strings.HasPrefix(preferred, "postgres:") {
  133. return override
  134. }
  135. if dockerImageExists(ctx, preferred) {
  136. return preferred
  137. }
  138. return preferred
  139. }
  140. func dockerImageExists(ctx context.Context, image string) bool {
  141. cmd := exec.CommandContext(ctx, "docker", "image", "inspect", image)
  142. cmd.Env = os.Environ()
  143. cmd.Stdout = nil
  144. cmd.Stderr = nil
  145. return cmd.Run() == nil
  146. }
  147. func openSQLWithRetry(ctx context.Context, dsn string, timeout time.Duration) (*sql.DB, error) {
  148. deadline := time.Now().Add(timeout)
  149. var lastErr error
  150. for time.Now().Before(deadline) {
  151. db, err := sql.Open("postgres", dsn)
  152. if err != nil {
  153. lastErr = err
  154. time.Sleep(250 * time.Millisecond)
  155. continue
  156. }
  157. if err := pingWithTimeout(ctx, db, 2*time.Second); err != nil {
  158. lastErr = err
  159. _ = db.Close()
  160. time.Sleep(250 * time.Millisecond)
  161. continue
  162. }
  163. return db, nil
  164. }
  165. return nil, fmt.Errorf("db not ready after %s: %w", timeout, lastErr)
  166. }
  167. func pingWithTimeout(ctx context.Context, db *sql.DB, timeout time.Duration) error {
  168. pingCtx, cancel := context.WithTimeout(ctx, timeout)
  169. defer cancel()
  170. return db.PingContext(pingCtx)
  171. }
  172. func testTx(t *testing.T) *sql.Tx {
  173. t.Helper()
  174. tx, err := integrationDB.BeginTx(context.Background(), nil)
  175. require.NoError(t, err, "begin tx")
  176. t.Cleanup(func() {
  177. _ = tx.Rollback()
  178. })
  179. return tx
  180. }
  181. // testEntClient 返回全局的 ent client,用于测试需要内部管理事务的代码(如 Create/Update 方法)。
  182. // 注意:此 client 的操作会真正写入数据库,测试结束后不会自动回滚。
  183. func testEntClient(t *testing.T) *dbent.Client {
  184. t.Helper()
  185. return integrationEntClient
  186. }
  187. // testEntTx 返回一个 ent 事务,用于需要事务隔离的测试。
  188. // 测试结束后会自动回滚,不会影响数据库状态。
  189. func testEntTx(t *testing.T) *dbent.Tx {
  190. t.Helper()
  191. tx, err := integrationEntClient.Tx(context.Background())
  192. require.NoError(t, err, "begin ent tx")
  193. t.Cleanup(func() {
  194. _ = tx.Rollback()
  195. })
  196. return tx
  197. }
  198. // testEntSQLTx 已弃用:不要在新测试中使用此函数。
  199. // 基于 *sql.Tx 创建的 ent client 在调用 client.Tx() 时会 panic。
  200. // 对于需要测试内部使用事务的代码,请使用 testEntClient。
  201. // 对于需要事务隔离的测试,请使用 testEntTx。
  202. //
  203. // Deprecated: Use testEntClient or testEntTx instead.
  204. func testEntSQLTx(t *testing.T) (*dbent.Client, *sql.Tx) {
  205. t.Helper()
  206. // 直接失败,避免旧测试误用导致的事务嵌套 panic。
  207. t.Fatalf("testEntSQLTx 已弃用:请使用 testEntClient 或 testEntTx")
  208. return nil, nil
  209. }
  210. func testRedis(t *testing.T) *redisclient.Client {
  211. t.Helper()
  212. prefix := fmt.Sprintf(
  213. "it:%s:%d:%d:",
  214. sanitizeRedisNamespace(t.Name()),
  215. time.Now().UnixNano(),
  216. atomic.AddUint64(&redisNamespaceSeq, 1),
  217. )
  218. opts := *integrationRedis.Options()
  219. rdb := redisclient.NewClient(&opts)
  220. rdb.AddHook(prefixHook{prefix: prefix})
  221. t.Cleanup(func() {
  222. ctx := context.Background()
  223. var cursor uint64
  224. for {
  225. keys, nextCursor, err := integrationRedis.Scan(ctx, cursor, prefix+"*", 500).Result()
  226. require.NoError(t, err, "scan redis keys for cleanup")
  227. if len(keys) > 0 {
  228. require.NoError(t, integrationRedis.Unlink(ctx, keys...).Err(), "unlink redis keys for cleanup")
  229. }
  230. cursor = nextCursor
  231. if cursor == 0 {
  232. break
  233. }
  234. }
  235. _ = rdb.Close()
  236. })
  237. return rdb
  238. }
  239. func assertTTLWithin(t *testing.T, ttl time.Duration, min, max time.Duration) {
  240. t.Helper()
  241. require.GreaterOrEqual(t, ttl, min, "ttl should be >= min")
  242. require.LessOrEqual(t, ttl, max, "ttl should be <= max")
  243. }
  244. func sanitizeRedisNamespace(name string) string {
  245. name = strings.ReplaceAll(name, "/", "_")
  246. name = strings.ReplaceAll(name, " ", "_")
  247. return name
  248. }
  249. type prefixHook struct {
  250. prefix string
  251. }
  252. func (h prefixHook) DialHook(next redisclient.DialHook) redisclient.DialHook { return next }
  253. func (h prefixHook) ProcessHook(next redisclient.ProcessHook) redisclient.ProcessHook {
  254. return func(ctx context.Context, cmd redisclient.Cmder) error {
  255. h.prefixCmd(cmd)
  256. return next(ctx, cmd)
  257. }
  258. }
  259. func (h prefixHook) ProcessPipelineHook(next redisclient.ProcessPipelineHook) redisclient.ProcessPipelineHook {
  260. return func(ctx context.Context, cmds []redisclient.Cmder) error {
  261. for _, cmd := range cmds {
  262. h.prefixCmd(cmd)
  263. }
  264. return next(ctx, cmds)
  265. }
  266. }
  267. func (h prefixHook) prefixCmd(cmd redisclient.Cmder) {
  268. args := cmd.Args()
  269. if len(args) < 2 {
  270. return
  271. }
  272. prefixOne := func(i int) {
  273. if i < 0 || i >= len(args) {
  274. return
  275. }
  276. switch v := args[i].(type) {
  277. case string:
  278. if v != "" && !strings.HasPrefix(v, h.prefix) {
  279. args[i] = h.prefix + v
  280. }
  281. case []byte:
  282. s := string(v)
  283. if s != "" && !strings.HasPrefix(s, h.prefix) {
  284. args[i] = []byte(h.prefix + s)
  285. }
  286. }
  287. }
  288. switch strings.ToLower(cmd.Name()) {
  289. case "get", "set", "setnx", "setex", "psetex", "incr", "decr", "incrby", "expire", "pexpire", "ttl", "pttl",
  290. "hgetall", "hget", "hset", "hdel", "hincrbyfloat", "exists",
  291. "zadd", "zcard", "zrange", "zrangebyscore", "zrem", "zremrangebyscore", "zrevrange", "zrevrangebyscore", "zscore":
  292. prefixOne(1)
  293. case "mget":
  294. for i := 1; i < len(args); i++ {
  295. prefixOne(i)
  296. }
  297. case "del", "unlink":
  298. for i := 1; i < len(args); i++ {
  299. prefixOne(i)
  300. }
  301. case "eval", "evalsha", "eval_ro", "evalsha_ro":
  302. if len(args) < 3 {
  303. return
  304. }
  305. numKeys, err := strconv.Atoi(fmt.Sprint(args[2]))
  306. if err != nil || numKeys <= 0 {
  307. return
  308. }
  309. for i := 0; i < numKeys && 3+i < len(args); i++ {
  310. prefixOne(3 + i)
  311. }
  312. case "scan":
  313. for i := 2; i+1 < len(args); i++ {
  314. if strings.EqualFold(fmt.Sprint(args[i]), "match") {
  315. prefixOne(i + 1)
  316. break
  317. }
  318. }
  319. }
  320. }
  321. // IntegrationRedisSuite provides a base suite for Redis integration tests.
  322. // Embedding suites should call SetupTest to initialize ctx and rdb.
  323. type IntegrationRedisSuite struct {
  324. suite.Suite
  325. ctx context.Context
  326. rdb *redisclient.Client
  327. }
  328. // SetupTest initializes ctx and rdb for each test method.
  329. func (s *IntegrationRedisSuite) SetupTest() {
  330. s.ctx = context.Background()
  331. s.rdb = testRedis(s.T())
  332. }
  333. // RequireNoError is a convenience method wrapping require.NoError with s.T().
  334. func (s *IntegrationRedisSuite) RequireNoError(err error, msgAndArgs ...any) {
  335. s.T().Helper()
  336. require.NoError(s.T(), err, msgAndArgs...)
  337. }
  338. // AssertTTLWithin asserts that ttl is within [min, max].
  339. func (s *IntegrationRedisSuite) AssertTTLWithin(ttl, min, max time.Duration) {
  340. s.T().Helper()
  341. assertTTLWithin(s.T(), ttl, min, max)
  342. }
  343. // IntegrationDBSuite provides a base suite for DB integration tests.
  344. // Embedding suites should call SetupTest to initialize ctx and client.
  345. type IntegrationDBSuite struct {
  346. suite.Suite
  347. ctx context.Context
  348. client *dbent.Client
  349. tx *dbent.Tx
  350. }
  351. // SetupTest initializes ctx and client for each test method.
  352. func (s *IntegrationDBSuite) SetupTest() {
  353. s.ctx = context.Background()
  354. // 统一使用 ent.Tx,确保每个测试都有独立事务并自动回滚。
  355. tx := testEntTx(s.T())
  356. s.tx = tx
  357. s.client = tx.Client()
  358. }
  359. // RequireNoError is a convenience method wrapping require.NoError with s.T().
  360. func (s *IntegrationDBSuite) RequireNoError(err error, msgAndArgs ...any) {
  361. s.T().Helper()
  362. require.NoError(s.T(), err, msgAndArgs...)
  363. }