| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425 |
- //go:build integration
- package repository
- import (
- "context"
- "database/sql"
- "fmt"
- "log"
- "os"
- "os/exec"
- "strconv"
- "strings"
- "sync/atomic"
- "testing"
- "time"
- dbent "github.com/Wei-Shaw/sub2api/ent"
- _ "github.com/Wei-Shaw/sub2api/ent/runtime"
- "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
- "github.com/stretchr/testify/require"
- "github.com/stretchr/testify/suite"
- "entgo.io/ent/dialect"
- entsql "entgo.io/ent/dialect/sql"
- _ "github.com/lib/pq"
- redisclient "github.com/redis/go-redis/v9"
- tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
- tcredis "github.com/testcontainers/testcontainers-go/modules/redis"
- )
- const (
- redisImageTag = "redis:8.4-alpine"
- postgresImageTag = "postgres:18.1-alpine3.23"
- )
- var (
- integrationDB *sql.DB
- integrationEntClient *dbent.Client
- integrationRedis *redisclient.Client
- redisNamespaceSeq uint64
- )
- func TestMain(m *testing.M) {
- ctx := context.Background()
- if err := timezone.Init("UTC"); err != nil {
- log.Printf("failed to init timezone: %v", err)
- os.Exit(1)
- }
- if !dockerIsAvailable(ctx) {
- // In CI we expect Docker to be available so integration tests should fail loudly.
- if os.Getenv("CI") != "" {
- log.Printf("docker is not available (CI=true); failing integration tests")
- os.Exit(1)
- }
- log.Printf("docker is not available; skipping integration tests (start Docker to enable)")
- os.Exit(0)
- }
- postgresImage := selectDockerImage(ctx, postgresImageTag)
- pgContainer, err := tcpostgres.Run(
- ctx,
- postgresImage,
- tcpostgres.WithDatabase("sub2api_test"),
- tcpostgres.WithUsername("postgres"),
- tcpostgres.WithPassword("postgres"),
- tcpostgres.BasicWaitStrategies(),
- )
- if err != nil {
- log.Printf("failed to start postgres container: %v", err)
- os.Exit(1)
- }
- defer func() { _ = pgContainer.Terminate(ctx) }()
- redisContainer, err := tcredis.Run(
- ctx,
- redisImageTag,
- )
- if err != nil {
- log.Printf("failed to start redis container: %v", err)
- os.Exit(1)
- }
- defer func() { _ = redisContainer.Terminate(ctx) }()
- dsn, err := pgContainer.ConnectionString(ctx, "sslmode=disable", "TimeZone=UTC")
- if err != nil {
- log.Printf("failed to get postgres dsn: %v", err)
- os.Exit(1)
- }
- integrationDB, err = openSQLWithRetry(ctx, dsn, 30*time.Second)
- if err != nil {
- log.Printf("failed to open sql db: %v", err)
- os.Exit(1)
- }
- if err := ApplyMigrations(ctx, integrationDB); err != nil {
- log.Printf("failed to apply db migrations: %v", err)
- os.Exit(1)
- }
- // 创建 ent client 用于集成测试
- drv := entsql.OpenDB(dialect.Postgres, integrationDB)
- integrationEntClient = dbent.NewClient(dbent.Driver(drv))
- redisHost, err := redisContainer.Host(ctx)
- if err != nil {
- log.Printf("failed to get redis host: %v", err)
- os.Exit(1)
- }
- redisPort, err := redisContainer.MappedPort(ctx, "6379/tcp")
- if err != nil {
- log.Printf("failed to get redis port: %v", err)
- os.Exit(1)
- }
- integrationRedis = redisclient.NewClient(&redisclient.Options{
- Addr: fmt.Sprintf("%s:%d", redisHost, redisPort.Int()),
- DB: 0,
- })
- if err := integrationRedis.Ping(ctx).Err(); err != nil {
- log.Printf("failed to ping redis: %v", err)
- os.Exit(1)
- }
- code := m.Run()
- _ = integrationEntClient.Close()
- _ = integrationRedis.Close()
- _ = integrationDB.Close()
- os.Exit(code)
- }
- func dockerIsAvailable(ctx context.Context) bool {
- cmd := exec.CommandContext(ctx, "docker", "info")
- cmd.Env = os.Environ()
- return cmd.Run() == nil
- }
- // selectDockerImage resolves the container image for the harness.
- //
- // SUB2API_TEST_POSTGRES_IMAGE overrides the PostgreSQL image so the suite can be
- // run against the oldest documented-supported server, not just the newest. That
- // matters for SQL that behaves differently across major versions: jsonpath
- // .datetime() only accepts the ISO-8601 "Z" designator from PostgreSQL 17 on, so
- // a suite pinned to 18 cannot observe breakage on 14-16.
- //
- // SUB2API_TEST_POSTGRES_IMAGE=postgres:15-alpine go test -tags integration ./internal/repository/
- func selectDockerImage(ctx context.Context, preferred string) string {
- if override := strings.TrimSpace(os.Getenv("SUB2API_TEST_POSTGRES_IMAGE")); override != "" &&
- strings.HasPrefix(preferred, "postgres:") {
- return override
- }
- if dockerImageExists(ctx, preferred) {
- return preferred
- }
- return preferred
- }
- func dockerImageExists(ctx context.Context, image string) bool {
- cmd := exec.CommandContext(ctx, "docker", "image", "inspect", image)
- cmd.Env = os.Environ()
- cmd.Stdout = nil
- cmd.Stderr = nil
- return cmd.Run() == nil
- }
- func openSQLWithRetry(ctx context.Context, dsn string, timeout time.Duration) (*sql.DB, error) {
- deadline := time.Now().Add(timeout)
- var lastErr error
- for time.Now().Before(deadline) {
- db, err := sql.Open("postgres", dsn)
- if err != nil {
- lastErr = err
- time.Sleep(250 * time.Millisecond)
- continue
- }
- if err := pingWithTimeout(ctx, db, 2*time.Second); err != nil {
- lastErr = err
- _ = db.Close()
- time.Sleep(250 * time.Millisecond)
- continue
- }
- return db, nil
- }
- return nil, fmt.Errorf("db not ready after %s: %w", timeout, lastErr)
- }
- func pingWithTimeout(ctx context.Context, db *sql.DB, timeout time.Duration) error {
- pingCtx, cancel := context.WithTimeout(ctx, timeout)
- defer cancel()
- return db.PingContext(pingCtx)
- }
- func testTx(t *testing.T) *sql.Tx {
- t.Helper()
- tx, err := integrationDB.BeginTx(context.Background(), nil)
- require.NoError(t, err, "begin tx")
- t.Cleanup(func() {
- _ = tx.Rollback()
- })
- return tx
- }
- // testEntClient 返回全局的 ent client,用于测试需要内部管理事务的代码(如 Create/Update 方法)。
- // 注意:此 client 的操作会真正写入数据库,测试结束后不会自动回滚。
- func testEntClient(t *testing.T) *dbent.Client {
- t.Helper()
- return integrationEntClient
- }
- // testEntTx 返回一个 ent 事务,用于需要事务隔离的测试。
- // 测试结束后会自动回滚,不会影响数据库状态。
- func testEntTx(t *testing.T) *dbent.Tx {
- t.Helper()
- tx, err := integrationEntClient.Tx(context.Background())
- require.NoError(t, err, "begin ent tx")
- t.Cleanup(func() {
- _ = tx.Rollback()
- })
- return tx
- }
- // testEntSQLTx 已弃用:不要在新测试中使用此函数。
- // 基于 *sql.Tx 创建的 ent client 在调用 client.Tx() 时会 panic。
- // 对于需要测试内部使用事务的代码,请使用 testEntClient。
- // 对于需要事务隔离的测试,请使用 testEntTx。
- //
- // Deprecated: Use testEntClient or testEntTx instead.
- func testEntSQLTx(t *testing.T) (*dbent.Client, *sql.Tx) {
- t.Helper()
- // 直接失败,避免旧测试误用导致的事务嵌套 panic。
- t.Fatalf("testEntSQLTx 已弃用:请使用 testEntClient 或 testEntTx")
- return nil, nil
- }
- func testRedis(t *testing.T) *redisclient.Client {
- t.Helper()
- prefix := fmt.Sprintf(
- "it:%s:%d:%d:",
- sanitizeRedisNamespace(t.Name()),
- time.Now().UnixNano(),
- atomic.AddUint64(&redisNamespaceSeq, 1),
- )
- opts := *integrationRedis.Options()
- rdb := redisclient.NewClient(&opts)
- rdb.AddHook(prefixHook{prefix: prefix})
- t.Cleanup(func() {
- ctx := context.Background()
- var cursor uint64
- for {
- keys, nextCursor, err := integrationRedis.Scan(ctx, cursor, prefix+"*", 500).Result()
- require.NoError(t, err, "scan redis keys for cleanup")
- if len(keys) > 0 {
- require.NoError(t, integrationRedis.Unlink(ctx, keys...).Err(), "unlink redis keys for cleanup")
- }
- cursor = nextCursor
- if cursor == 0 {
- break
- }
- }
- _ = rdb.Close()
- })
- return rdb
- }
- func assertTTLWithin(t *testing.T, ttl time.Duration, min, max time.Duration) {
- t.Helper()
- require.GreaterOrEqual(t, ttl, min, "ttl should be >= min")
- require.LessOrEqual(t, ttl, max, "ttl should be <= max")
- }
- func sanitizeRedisNamespace(name string) string {
- name = strings.ReplaceAll(name, "/", "_")
- name = strings.ReplaceAll(name, " ", "_")
- return name
- }
- type prefixHook struct {
- prefix string
- }
- func (h prefixHook) DialHook(next redisclient.DialHook) redisclient.DialHook { return next }
- func (h prefixHook) ProcessHook(next redisclient.ProcessHook) redisclient.ProcessHook {
- return func(ctx context.Context, cmd redisclient.Cmder) error {
- h.prefixCmd(cmd)
- return next(ctx, cmd)
- }
- }
- func (h prefixHook) ProcessPipelineHook(next redisclient.ProcessPipelineHook) redisclient.ProcessPipelineHook {
- return func(ctx context.Context, cmds []redisclient.Cmder) error {
- for _, cmd := range cmds {
- h.prefixCmd(cmd)
- }
- return next(ctx, cmds)
- }
- }
- func (h prefixHook) prefixCmd(cmd redisclient.Cmder) {
- args := cmd.Args()
- if len(args) < 2 {
- return
- }
- prefixOne := func(i int) {
- if i < 0 || i >= len(args) {
- return
- }
- switch v := args[i].(type) {
- case string:
- if v != "" && !strings.HasPrefix(v, h.prefix) {
- args[i] = h.prefix + v
- }
- case []byte:
- s := string(v)
- if s != "" && !strings.HasPrefix(s, h.prefix) {
- args[i] = []byte(h.prefix + s)
- }
- }
- }
- switch strings.ToLower(cmd.Name()) {
- case "get", "set", "setnx", "setex", "psetex", "incr", "decr", "incrby", "expire", "pexpire", "ttl", "pttl",
- "hgetall", "hget", "hset", "hdel", "hincrbyfloat", "exists",
- "zadd", "zcard", "zrange", "zrangebyscore", "zrem", "zremrangebyscore", "zrevrange", "zrevrangebyscore", "zscore":
- prefixOne(1)
- case "mget":
- for i := 1; i < len(args); i++ {
- prefixOne(i)
- }
- case "del", "unlink":
- for i := 1; i < len(args); i++ {
- prefixOne(i)
- }
- case "eval", "evalsha", "eval_ro", "evalsha_ro":
- if len(args) < 3 {
- return
- }
- numKeys, err := strconv.Atoi(fmt.Sprint(args[2]))
- if err != nil || numKeys <= 0 {
- return
- }
- for i := 0; i < numKeys && 3+i < len(args); i++ {
- prefixOne(3 + i)
- }
- case "scan":
- for i := 2; i+1 < len(args); i++ {
- if strings.EqualFold(fmt.Sprint(args[i]), "match") {
- prefixOne(i + 1)
- break
- }
- }
- }
- }
- // IntegrationRedisSuite provides a base suite for Redis integration tests.
- // Embedding suites should call SetupTest to initialize ctx and rdb.
- type IntegrationRedisSuite struct {
- suite.Suite
- ctx context.Context
- rdb *redisclient.Client
- }
- // SetupTest initializes ctx and rdb for each test method.
- func (s *IntegrationRedisSuite) SetupTest() {
- s.ctx = context.Background()
- s.rdb = testRedis(s.T())
- }
- // RequireNoError is a convenience method wrapping require.NoError with s.T().
- func (s *IntegrationRedisSuite) RequireNoError(err error, msgAndArgs ...any) {
- s.T().Helper()
- require.NoError(s.T(), err, msgAndArgs...)
- }
- // AssertTTLWithin asserts that ttl is within [min, max].
- func (s *IntegrationRedisSuite) AssertTTLWithin(ttl, min, max time.Duration) {
- s.T().Helper()
- assertTTLWithin(s.T(), ttl, min, max)
- }
- // IntegrationDBSuite provides a base suite for DB integration tests.
- // Embedding suites should call SetupTest to initialize ctx and client.
- type IntegrationDBSuite struct {
- suite.Suite
- ctx context.Context
- client *dbent.Client
- tx *dbent.Tx
- }
- // SetupTest initializes ctx and client for each test method.
- func (s *IntegrationDBSuite) SetupTest() {
- s.ctx = context.Background()
- // 统一使用 ent.Tx,确保每个测试都有独立事务并自动回滚。
- tx := testEntTx(s.T())
- s.tx = tx
- s.client = tx.Client()
- }
- // RequireNoError is a convenience method wrapping require.NoError with s.T().
- func (s *IntegrationDBSuite) RequireNoError(err error, msgAndArgs ...any) {
- s.T().Helper()
- require.NoError(s.T(), err, msgAndArgs...)
- }
|