visionA/visionA-backend/internal/api/pairing_exchange_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

239 lines
9.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
// Postgres pairing exchange A' 模型重塑的真 DB 整合測試WP-B B3
//
// build tag `dbtest`:只在帶 `-tags=dbtest`(需要 Docker / testcontainers時編譯/執行。
//
// 執行:
//
// go test -tags=dbtest ./internal/api/...
// # 無本機 Docker 時在 130 補跑:
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
// go test -tags=dbtest ./internal/api/...
//
// 涵蓋 A' 重塑:建/復用 agent + representative device綁 session token+ N 顆真 USB device
// Mi#2tx 內查詢/局部更新、Mi#5serial 白名單、S-2Name 衍生、R1完整 N 顆、R4同序號復用
package api
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/agent"
"visiona-backend/internal/auth"
"visiona-backend/internal/db/testsupport"
"visiona-backend/internal/device"
)
// pgExchangeFixture 建一個已就緒的 Postgres 環境:一個合法 owner user無 agent / device / token
func pgExchangeFixture(t *testing.T) (
tdb *testsupport.TestDB,
exchanger PairingExchanger,
devRepo *device.PostgresRepository,
agentRepo *agent.PostgresRepository,
sessions *auth.PostgresSessionTokenStore,
owner string,
) {
t.Helper()
tdb = testsupport.SetupTestDB(t)
tdb.Truncate(t, "pairing_tokens", "session_tokens", "devices", "agents", "users")
owner = tdb.EnsureDemoUser(t)
devRepo = device.NewPostgresRepository(tdb.Pool)
agentRepo = agent.NewPostgresRepository(tdb.Pool)
sessions = auth.NewPostgresSessionTokenStore(tdb.Pool)
exchanger = NewPostgresPairingExchanger(tdb.Pool, agentRepo, devRepo, sessions, nil)
return
}
// Provision 建 agent + representative device + session token無 USB 上報)。
func TestPGExchange_ProvisionCreatesAgentRepresentativeAndSession(t *testing.T) {
ctx := context.Background()
tdb, exchanger, devRepo, _, sessions, owner := pgExchangeFixture(t)
parentHash := auth.HashToken("vAc_" + uuid.NewString()[:32])
res, err := exchanger.Provision(ctx, owner, parentHash, auth.SessionTokenTTL, nil)
require.NoError(t, err)
require.NotEmpty(t, res.DeviceID, "應建 representative device")
require.NotEmpty(t, res.AgentID, "應建 agent")
require.NotEmpty(t, res.SessionPlaintext)
assert.Empty(t, res.USBDeviceIDs, "無 USB 上報 → 不建真 USB device")
// 1) agent 真的進 DB。
assert.Equal(t, 1, tdb.CountRows(t, "agents"))
// 2) representative deviceis_representative=true、掛該 agent、serial=NULL。
rep, err := devRepo.Get(ctx, res.DeviceID)
require.NoError(t, err)
assert.True(t, rep.IsRepresentative)
assert.Equal(t, res.AgentID, rep.AgentID)
assert.Equal(t, owner, rep.OwnerUserID)
assert.Empty(t, rep.SerialNumber)
assert.NotNil(t, rep.PairedAt)
var serialIsNull bool
require.NoError(t, tdb.Pool.QueryRow(ctx,
`SELECT serial_number IS NULL FROM devices WHERE id = $1`, res.DeviceID).Scan(&serialIsNull))
assert.True(t, serialIsNull, "representative device serial 應為 NULL")
// 3) session token 綁 representative devicedevice_id 非 NULL、FK 滿足)。
tok, err := sessions.Get(ctx, res.SessionPlaintext)
require.NoError(t, err)
assert.Equal(t, res.DeviceID, tok.DeviceID)
assert.Equal(t, owner, tok.UserID)
assert.Equal(t, parentHash, tok.ParentTokenHash)
}
// 原子性owner 不存在FK violation→ 整筆 rollbackagent / device / token 都不殘留。
func TestPGExchange_Provision_RollbackOnBadOwner(t *testing.T) {
ctx := context.Background()
tdb, exchanger, _, _, _, _ := pgExchangeFixture(t)
badOwner := uuid.NewString() // 不在 users 表
_, err := exchanger.Provision(ctx, badOwner, "", auth.SessionTokenTTL, nil)
require.Error(t, err, "owner 不存在 → agent.owner_user_id FK violation")
assert.Equal(t, 0, tdb.CountRows(t, "agents"), "FK 失敗應 rollback無 agent 殘留")
assert.Equal(t, 0, tdb.CountRows(t, "devices"), "無 device 殘留")
assert.Equal(t, 0, tdb.CountRows(t, "session_tokens"), "無 session token 殘留")
}
// 同 owner 多次 exchange復用同一 agent + 同一 representative device各建獨立 session token。
func TestPGExchange_Provision_ReusesAgentAndRepresentative(t *testing.T) {
ctx := context.Background()
tdb, exchanger, _, _, _, owner := pgExchangeFixture(t)
res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, nil)
require.NoError(t, err)
res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, nil)
require.NoError(t, err)
assert.Equal(t, res1.AgentID, res2.AgentID, "同 owner 應復用同一 agent")
assert.Equal(t, res1.DeviceID, res2.DeviceID, "同 owner 應復用同一 representative device")
assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext)
assert.Equal(t, 1, tdb.CountRows(t, "agents"), "復用不多建 agent")
assert.Equal(t, 1, tdb.CountRows(t, "devices"), "復用不多建 representative device")
assert.Equal(t, 2, tdb.CountRows(t, "session_tokens"), "兩次各建一個 session token")
}
// R1N 顆可用序號 USB → 建 N 筆真 USB device掛同一 agent、is_representative=false
func TestPGExchange_Provision_CreatesNUSBDevices(t *testing.T) {
ctx := context.Background()
tdb, exchanger, devRepo, _, _, owner := pgExchangeFixture(t)
res, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL,
[]ExchangeDeviceInput{
{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520"},
{SerialNumber: "0x0E5F6071", DeviceType: "kneron_kl720"},
{SerialNumber: "0xFFFF0001"},
})
require.NoError(t, err)
require.Len(t, res.USBDeviceIDs, 3, "三顆可用序號 → 三筆真 USB device")
// devices 共 4 筆1 representative + 3 USB。
assert.Equal(t, 4, tdb.CountRows(t, "devices"))
for _, id := range res.USBDeviceIDs {
d, err := devRepo.Get(ctx, id)
require.NoError(t, err)
assert.False(t, d.IsRepresentative)
assert.Equal(t, res.AgentID, d.AgentID, "真 USB device 掛同一 agent")
assert.NotEmpty(t, d.SerialNumber)
}
}
// S-2真 USB device Name 由 device type + serial 衍生(非固定字串)。
func TestPGExchange_Provision_DerivesUSBDeviceName(t *testing.T) {
ctx := context.Background()
_, exchanger, devRepo, _, _, owner := pgExchangeFixture(t)
res, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL,
[]ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520"}})
require.NoError(t, err)
require.Len(t, res.USBDeviceIDs, 1)
d, err := devRepo.Get(ctx, res.USBDeviceIDs[0])
require.NoError(t, err)
assert.Equal(t, "kneron_kl520 (0x1A2B3C4D)", d.Name)
assert.Equal(t, "kneron_kl520", d.DeviceType)
}
// R4同序號重配復用同一真 USB device不撞 partial unique23505、不多建。
func TestPGExchange_Provision_SameSerialReusesUSBDevice(t *testing.T) {
ctx := context.Background()
tdb, exchanger, _, _, _, owner := pgExchangeFixture(t)
input := []ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D"}}
res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input)
require.NoError(t, err)
require.Len(t, res1.USBDeviceIDs, 1)
res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input)
require.NoError(t, err, "同序號重配不可撞 23505 炸 500")
require.Len(t, res2.USBDeviceIDs, 1)
assert.Equal(t, res1.USBDeviceIDs[0], res2.USBDeviceIDs[0], "同序號應復用同一 USB device")
// devices1 representative + 1 USB = 2同序號不多建
assert.Equal(t, 2, tdb.CountRows(t, "devices"))
}
// serial 真的寫進 DB非 NULL+ 復用 USB device 掛到 agent。
func TestPGExchange_Provision_FillsSerialNumber(t *testing.T) {
ctx := context.Background()
tdb, exchanger, devRepo, _, _, owner := pgExchangeFixture(t)
res, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL,
[]ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520"}})
require.NoError(t, err)
require.Len(t, res.USBDeviceIDs, 1)
d, err := devRepo.Get(ctx, res.USBDeviceIDs[0])
require.NoError(t, err)
assert.Equal(t, "0x1A2B3C4D", d.SerialNumber)
assert.Equal(t, res.AgentID, d.AgentID)
var serialIsNull bool
require.NoError(t, tdb.Pool.QueryRow(ctx,
`SELECT serial_number IS NULL FROM devices WHERE id = $1`, res.USBDeviceIDs[0]).Scan(&serialIsNull))
assert.False(t, serialIsNull, "真 USB device serial_number 不應為 NULL")
}
// 假序號 0x00000000 + Mi#5 白名單:不符格式的序號視同無序號,不建真 USB device。
func TestPGExchange_Provision_InvalidSerialsSkipped(t *testing.T) {
ctx := context.Background()
tdb, exchanger, _, _, _, owner := pgExchangeFixture(t)
res, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL,
[]ExchangeDeviceInput{
{SerialNumber: "0x00000000"}, // 假序號
{SerialNumber: "bad"}, // 無 0x
{SerialNumber: "0x123"}, // 位數不足
{SerialNumber: "0xGGGGGGGG"}, // 非 hex
})
require.NoError(t, err)
assert.Empty(t, res.USBDeviceIDs, "無合法序號 → 不建真 USB device")
// 只有 representative device。
assert.Equal(t, 1, tdb.CountRows(t, "devices"))
}
// session_tokens 綁 representative兩次 exchange 綁同一 representative device_idQ5 語意驗證)。
func TestPGExchange_Provision_SessionsBindSameRepresentative(t *testing.T) {
ctx := context.Background()
tdb, exchanger, _, _, _, owner := pgExchangeFixture(t)
res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, nil)
require.NoError(t, err)
res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, nil)
require.NoError(t, err)
var distinctDeviceIDs int
require.NoError(t, tdb.Pool.QueryRow(ctx,
`SELECT count(DISTINCT device_id) FROM session_tokens`).Scan(&distinctDeviceIDs))
assert.Equal(t, 1, distinctDeviceIDs, "兩個 session token 應綁同一 representative device")
assert.Equal(t, res1.DeviceID, res2.DeviceID)
}