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

222 lines
9.1 KiB
Go
Raw 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.

// memPairingExchanger 的單元測試A' 模型 exchange 重塑WP-B B3
//
// 不帶 build tag屬於預設 `go test ./...` 範圍(無需 Docker
// 驗證 in-memory exchanger 建/復用 agent + representative device綁 session token+ N 顆真
// USB device與 pairing_exchange_db_test.go 的 Postgres dbtest 對齊行為。
package api
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/agent"
"visiona-backend/internal/auth"
"visiona-backend/internal/device"
)
// memFixture 建一組 in-memory exchanger + 底層 repo供斷言直接查 device / agent。
func memFixture() (
PairingExchanger,
*device.InMemoryRepository,
*agent.InMemoryRepository,
*auth.InMemorySessionTokenStore,
) {
devRepo := device.NewInMemoryRepository()
agentRepo := agent.NewInMemoryRepository()
sessions := auth.NewInMemorySessionTokenStore()
ex := NewInMemoryPairingExchanger(agentRepo, devRepo, sessions)
return ex, devRepo, agentRepo, sessions
}
// Provision 建 agent + representative device + session token無 USB 上報時)。
// session token 綁 representative deviceis_representative=true
func TestMemExchange_ProvisionCreatesAgentRepresentativeAndSession(t *testing.T) {
ctx := context.Background()
ex, devRepo, _, sessions := memFixture()
res, err := ex.Provision(ctx, "owner-1", "parent-hash", 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)
require.NotNil(t, res.SessionInfo)
assert.Empty(t, res.USBDeviceIDs, "無 USB 上報 → 不建真 USB device")
// DeviceID 指向的是 representative deviceis_representative=true、綁該 agent
rep, err := devRepo.Get(ctx, res.DeviceID)
require.NoError(t, err)
assert.True(t, rep.IsRepresentative, "session 綁的應為 representative device")
assert.Equal(t, res.AgentID, rep.AgentID)
assert.Equal(t, representativeDeviceName, rep.Name)
assert.Empty(t, rep.SerialNumber, "representative device serial 應為空NULL")
assert.NotNil(t, rep.PairedAt)
// session token 綁 representative device。
tok, err := sessions.Get(ctx, res.SessionPlaintext)
require.NoError(t, err)
assert.Equal(t, res.DeviceID, tok.DeviceID)
assert.Equal(t, "owner-1", tok.UserID)
assert.Equal(t, "parent-hash", tok.ParentTokenHash)
}
// 同 owner 多次 exchange復用同一 agent + 同一 representative deviceA' 語意),
// 但各建獨立 session token。取代舊 DistinctDevices 測試——A' 下 representative 不再各建。)
func TestMemExchange_Provision_ReusesAgentAndRepresentative(t *testing.T) {
ctx := context.Background()
ex, devRepo, _, _ := memFixture()
res1, err := ex.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, nil)
require.NoError(t, err)
res2, err := ex.Provision(ctx, "owner-1", "", 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, "session token 各自獨立")
// 只有一顆 representative device未長出第二顆。List filter 掉 representativeB4
// 故用 GetRepresentativeByAgentTx 直接查該 agent 的 representative 確認唯一。
rep, err := devRepo.GetRepresentativeByAgentTx(ctx, nil, res1.AgentID)
require.NoError(t, err)
assert.Equal(t, res1.DeviceID, rep.ID, "representative 應復用同一顆")
// List真 USB為空無 USB 上報)。
list, err := devRepo.List(ctx, "owner-1")
require.NoError(t, err)
assert.Empty(t, list, "無 USB 上報 → List真 USB為空")
}
// R1多顆可用序號 USB → 各建一筆真 USB device掛同一 agent、is_representative=false
func TestMemExchange_Provision_CreatesNUSBDevices(t *testing.T) {
ctx := context.Background()
ex, devRepo, _, _ := memFixture()
res, err := ex.Provision(ctx, "owner-1", "", auth.SessionTokenTTL,
[]ExchangeDeviceInput{
{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520"},
{SerialNumber: "0x0E5F6071", DeviceType: "kneron_kl720"},
})
require.NoError(t, err)
require.Len(t, res.USBDeviceIDs, 2, "兩顆可用序號 → 兩筆真 USB device")
// 每顆真 USB deviceis_representative=false、掛同一 agent、有 serial、Name 由 type 衍生。
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)
assert.Contains(t, d.Name, "kneron_kl", "Name 應由 device type 衍生")
assert.Contains(t, d.Name, d.SerialNumber, "Name 應含 serial")
}
}
// serial 填入 + device_type 衍生 NameS-2
func TestMemExchange_Provision_FillsSerialAndDerivedName(t *testing.T) {
ctx := context.Background()
ex, devRepo, _, _ := memFixture()
res, err := ex.Provision(ctx, "owner-1", "", auth.SessionTokenTTL,
[]ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520", Firmware: "KDP"}})
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, "kneron_kl520", d.DeviceType)
assert.Equal(t, "kneron_kl520 (0x1A2B3C4D)", d.Name, "S-2Name 由 type + serial 衍生")
}
// 同序號重配 = 復用真 USB deviceR4防 unique 炸裂)。
func TestMemExchange_Provision_SameSerialReusesUSBDevice(t *testing.T) {
ctx := context.Background()
ex, devRepo, _, _ := memFixture()
input := []ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D"}}
res1, err := ex.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input)
require.NoError(t, err)
require.Len(t, res1.USBDeviceIDs, 1)
res2, err := ex.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input)
require.NoError(t, err)
require.Len(t, res2.USBDeviceIDs, 1)
assert.Equal(t, res1.USBDeviceIDs[0], res2.USBDeviceIDs[0], "同序號重配應復用同一 USB device")
// Listfilter 掉 representativeB4只回真 USB一顆同序號不重複建
list, err := devRepo.List(ctx, "owner-1")
require.NoError(t, err)
require.Len(t, list, 1, "List 只回真 USB同序號復用不重複建")
assert.Equal(t, res1.USBDeviceIDs[0], list[0].ID)
}
// 假序號 0x00000000 視同無序號:不建真 USB deviceMi#5 白名單前的既有規則保留)。
func TestMemExchange_Provision_FakeSerialSkipped(t *testing.T) {
ctx := context.Background()
ex, _, _, _ := memFixture()
res, err := ex.Provision(ctx, "owner-1", "", auth.SessionTokenTTL,
[]ExchangeDeviceInput{{SerialNumber: "0x00000000"}})
require.NoError(t, err)
assert.Empty(t, res.USBDeviceIDs, "假序號視同無序號 → 不建真 USB device")
}
// Mi#5 serial 白名單:不符 ^0x[0-9A-Fa-f]{8}$ 的序號視同無序號、不建真 USB device。
func TestMemExchange_Provision_InvalidSerialFormatSkipped(t *testing.T) {
ctx := context.Background()
ex, _, _, _ := memFixture()
res, err := ex.Provision(ctx, "owner-1", "", auth.SessionTokenTTL,
[]ExchangeDeviceInput{
{SerialNumber: "not-a-serial"}, // 無 0x 前綴
{SerialNumber: "0x12"}, // 位數不足
{SerialNumber: "0x1234567890AB"}, // 位數過多
{SerialNumber: "0xZZZZZZZZ"}, // 非 hex
})
require.NoError(t, err)
assert.Empty(t, res.USBDeviceIDs, "格式不符白名單的序號應全部跳過")
}
// usableSerialDevices 去重:同一次 exchange 上報重複序號只建一顆。
func TestMemExchange_Provision_DedupsRepeatedSerial(t *testing.T) {
ctx := context.Background()
ex, _, _, _ := memFixture()
res, err := ex.Provision(ctx, "owner-1", "", auth.SessionTokenTTL,
[]ExchangeDeviceInput{
{SerialNumber: "0x1A2B3C4D"},
{SerialNumber: "0x1a2b3c4d"}, // 同序號(大小寫不同)→ 去重
{SerialNumber: "0xFFFF0001"},
})
require.NoError(t, err)
assert.Len(t, res.USBDeviceIDs, 2, "重複序號應去重(大小寫不敏感)")
}
// 空序號 / 假序號混雜時仍取所有可用序號不再只取第一顆——R1
func TestMemExchange_Provision_PicksAllUsableSerials(t *testing.T) {
ctx := context.Background()
ex, devRepo, _, _ := memFixture()
res, err := ex.Provision(ctx, "owner-1", "", auth.SessionTokenTTL,
[]ExchangeDeviceInput{
{SerialNumber: ""}, // 空 → 跳過
{SerialNumber: "0x00000000"}, // 假 → 跳過
{SerialNumber: " 0x0E5F6071 ", DeviceType: "a"}, // trim 後可用
{SerialNumber: "0xFFFF0001"}, // 可用
})
require.NoError(t, err)
require.Len(t, res.USBDeviceIDs, 2, "R1取所有可用序號非只第一顆")
// 確認 trim 生效。
serials := map[string]bool{}
for _, id := range res.USBDeviceIDs {
d, err := devRepo.Get(ctx, id)
require.NoError(t, err)
serials[d.SerialNumber] = true
}
assert.True(t, serials["0x0E5F6071"], "trim 後序號應寫入")
assert.True(t, serials["0xFFFF0001"])
}