//go:build dbtest // PostgresRepository(agent)的真 DB 整合測試(WP-B B2)。 // // build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需 Docker / testcontainers)。 // // 執行: // // go test -tags=dbtest ./internal/agent/... // # 無本機 Docker 時在 130 補跑: // DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \ // go test -tags=dbtest ./internal/agent/... package agent import ( "context" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "visiona-backend/internal/db" "visiona-backend/internal/db/testsupport" ) // newPGRepo 啟動測試 DB、truncate、確保 demo user,回傳 repo + owner。 func newPGRepo(t *testing.T) (*PostgresRepository, *testsupport.TestDB, string) { t.Helper() tdb := testsupport.SetupTestDB(t) tdb.Truncate(t, "devices", "agents", "users") owner := tdb.EnsureDemoUser(t) return NewPostgresRepository(tdb.Pool), tdb, owner } // GetOrCreate 新建:DB 中先無 agent → 建一筆、欄位正確。 func TestPG_GetOrCreate_CreatesWhenAbsent(t *testing.T) { ctx := context.Background() r, tdb, owner := newPGRepo(t) paired := time.Now().Add(-1 * time.Minute).UTC().Truncate(time.Microsecond) a, err := r.GetOrCreateAgentTx(ctx, tdb.Pool, owner, "", "darwin", "1.2.3", paired) require.NoError(t, err) require.NotNil(t, a) assert.NotEmpty(t, a.ID) assert.Equal(t, owner, a.OwnerUserID) assert.Equal(t, "local-agent", a.Name, "空 name 走 DEFAULT") assert.Equal(t, "darwin", a.Platform) assert.Equal(t, "1.2.3", a.AgentVersion) require.NotNil(t, a.LastPairedAt) assert.True(t, paired.Equal(*a.LastPairedAt)) assert.Equal(t, 1, tdb.CountRows(t, "agents")) } // GetOrCreate 復用:同 owner 第二次呼叫復用同 agent、不新建。 func TestPG_GetOrCreate_ReusesExisting(t *testing.T) { ctx := context.Background() r, tdb, owner := newPGRepo(t) first, err := r.GetOrCreateAgentTx(ctx, tdb.Pool, owner, "local-agent", "darwin", "1.0", time.Now().UTC()) require.NoError(t, err) later := time.Now().Add(1 * time.Minute).UTC().Truncate(time.Microsecond) second, err := r.GetOrCreateAgentTx(ctx, tdb.Pool, owner, "", "", "", later) require.NoError(t, err) assert.Equal(t, first.ID, second.ID, "同 owner 應復用") assert.Equal(t, 1, tdb.CountRows(t, "agents"), "復用不應多建 agent") // 空上報值不覆寫既有欄位(COALESCE NULLIF 保留)。 assert.Equal(t, "darwin", second.Platform) assert.Equal(t, "1.0", second.AgentVersion) require.NotNil(t, second.LastPairedAt) assert.True(t, later.Equal(*second.LastPairedAt), "last_paired_at 應更新") } // GetOrCreate 復用時非空上報值更新既有欄位。 func TestPG_GetOrCreate_UpdatesNonEmptyOnReuse(t *testing.T) { ctx := context.Background() r, tdb, owner := newPGRepo(t) _, err := r.GetOrCreateAgentTx(ctx, tdb.Pool, owner, "local-agent", "darwin", "1.0", time.Now().UTC()) require.NoError(t, err) upd, err := r.GetOrCreateAgentTx(ctx, tdb.Pool, owner, "renamed", "linux", "2.0", time.Now().UTC()) require.NoError(t, err) assert.Equal(t, "renamed", upd.Name) assert.Equal(t, "linux", upd.Platform) assert.Equal(t, "2.0", upd.AgentVersion) } // GetOrCreate 在 WithTx 內:與其他寫入同一交易,成功 commit。 func TestPG_GetOrCreate_WithinTx(t *testing.T) { ctx := context.Background() r, tdb, owner := newPGRepo(t) var agentID string err := db.WithTx(ctx, tdb.Pool, func(q db.Querier) error { a, e := r.GetOrCreateAgentTx(ctx, q, owner, "", "", "", time.Now().UTC()) if e != nil { return e } agentID = a.ID return nil }) require.NoError(t, err) assert.NotEmpty(t, agentID) assert.Equal(t, 1, tdb.CountRows(t, "agents")) // commit 後 GetByOwner 讀得到。 got, err := r.GetByOwnerTx(ctx, tdb.Pool, owner) require.NoError(t, err) assert.Equal(t, agentID, got.ID) } // GetOrCreate 在 WithTx rollback:交易失敗時 agent 不殘留。 func TestPG_GetOrCreate_RollbackOnTxError(t *testing.T) { ctx := context.Background() r, tdb, owner := newPGRepo(t) sentinel := assertErr("forced rollback") err := db.WithTx(ctx, tdb.Pool, func(q db.Querier) error { if _, e := r.GetOrCreateAgentTx(ctx, q, owner, "", "", "", time.Now().UTC()); e != nil { return e } return sentinel // 強制 rollback }) require.ErrorIs(t, err, sentinel) assert.Equal(t, 0, tdb.CountRows(t, "agents"), "rollback 後 agent 不應殘留") } // GetByOwner 不存在回 ErrNotFound。 func TestPG_GetByOwner_NotFound(t *testing.T) { ctx := context.Background() r, tdb, owner := newPGRepo(t) _, err := r.GetByOwnerTx(ctx, tdb.Pool, owner) assert.ErrorIs(t, err, ErrNotFound) } // 併發 GetOrCreate 同 owner(各自 WithTx):FOR UPDATE 序列化,最終恰一筆 agent。 func TestPG_GetOrCreate_ConcurrentSameOwner(t *testing.T) { ctx := context.Background() r, tdb, owner := newPGRepo(t) const n = 10 var wg sync.WaitGroup ids := make([]string, n) errs := make([]error, n) for i := 0; i < n; i++ { wg.Add(1) go func(i int) { defer wg.Done() errs[i] = db.WithTx(ctx, tdb.Pool, func(q db.Querier) error { a, e := r.GetOrCreateAgentTx(ctx, q, owner, "", "", "", time.Now().UTC()) if e != nil { return e } ids[i] = a.ID return nil }) }(i) } wg.Wait() for i, e := range errs { require.NoError(t, e, "併發 GetOrCreate #%d 不應報錯", i) } // 所有 goroutine 應拿到同一個 agent id。 first := ids[0] for i, id := range ids { assert.Equal(t, first, id, "併發應復用同一 agent(#%d)", i) } assert.Equal(t, 1, tdb.CountRows(t, "agents"), "併發同 owner 最終恰一筆 agent") } // assertErr 是測試用 sentinel error。 type assertErr string func (e assertErr) Error() string { return string(e) }