- 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>
222 lines
9.1 KiB
Go
222 lines
9.1 KiB
Go
// 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 device(is_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 device(is_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 device(A' 語意),
|
||
// 但各建獨立 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 掉 representative(B4),
|
||
// 故用 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 device:is_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 衍生 Name(S-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-2:Name 由 type + serial 衍生")
|
||
}
|
||
|
||
// 同序號重配 = 復用真 USB device(R4;防 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")
|
||
|
||
// List(filter 掉 representative,B4)只回真 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 device(Mi#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"])
|
||
}
|