visionA/visionA-backend/internal/agent/postgres_repository_db_test.go
jim800121chen 59c57fa481 feat(device): WP-B repository 接 agents 模型 + exchange 重塑(A' 走向第二階段 Go 層)
- Device struct 加 4 欄(agent_id/agent_local_device_id/registered_at/
  is_representative)+ deviceColumns 13→17 + scanDevice/SaveTx 讀寫新欄
- 新增 internal/agent package(domain + interface + in-memory + PG repo):
  GetOrCreateAgentTx/GetByOwnerTx,advisory lock 序列化同 owner get-or-create
- exchange 重塑:建/復用 agent → representative device(綁 session_tokens、
  serial=NULL)→ loop 建 N 顆真 USB device(R1 完整 N 顆非只第一顆)
- List filter is_representative=false + DeviceListItem 回傳 agent_id/registered_at
- 併入 WP-0/0005 follow-up Minor:Mi#2 lost-update 收斂(tx 內查詢+局部更新)
  / Mi#3 過時註解 / Mi#4 空 serial 回 ErrNotFound / Mi#5 serial 白名單
  ^0x[0-9A-Fa-f]{8}$ + 去重 / S-1 firmware forward-compat / S-2 device Name 衍生

守 ADR-018 A'(一 owner N agents、session_tokens FK 物理不動、不加 owner
unique 為多機器留路)。Reviewer 通過(0C/0M)。5 套件 dbtest 130 全綠
(db 19/device 37/agent 13/api 172/cmd 60)、build/vet/test 綠。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:40:17 +08:00

180 lines
5.7 KiB
Go
Raw Permalink 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.

//go:build dbtest
// PostgresRepositoryagent的真 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各自 WithTxFOR 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) }