- 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>
180 lines
5.7 KiB
Go
180 lines
5.7 KiB
Go
//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) }
|