visionA/visionA-backend/internal/api/pairing_exchange_test.go
jim800121chen b9ee184586 feat(device): WP-0 序號串通 + serial 路由(ADR-018 走向 A' 第一階段)
- local-agent/server:detector 保留 kn_number(parseScanDevices 可測化、
  合成 ID 語意不動)+ DeviceInfo.SerialNumber + Manager serialToLocalID
  反查表 + GetDevice 雙查(sessions 先、serial 後,純加法)
- visiona-agent:pairing exchange 帶本地裝置清單(DeviceLister 失敗不中斷
  配對、timeout 2s、omitempty 舊版相容)
- visionA-backend:exchange 收 devices —— R1 取第一顆可用序號、R2 假序號
  0x00000000 寫 NULL、R4 同 owner 同序號復用既有 device_id(防 23505)、
  pg+mem 兩實作對齊
- 五個 proxy 操作(flash/inference/camera/connect/disconnect)收斂於
  GetDevice 單一入口,serial 路由一處涵蓋
- docs:api-spec.md §2 增補 POST /api/pairing/exchange(schema + R1/R2/R4)
- 測試:行為級四環節鏈 + dbtest 130 實跑 6/6 + DBOn 回歸 4/4;
  三 module build/vet/test 全綠
- review:通過 0C/0M/6Mi/5Sug(.autoflow/05-implementation/review/
  wp0-serial-routing-review.md);Minor #1 Rescan stale session 掛 WP-C 前置

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:31:51 +08:00

145 lines
6.2 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 的單元測試DB-on FK 收尾,問題 #2
//
// 不帶 build tag屬於預設 `go test ./...` 範圍(無需 Docker
// 驗證 in-memory exchanger 自建 device + 建 session token、且 session token 綁到該 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/auth"
"visiona-backend/internal/device"
)
func TestMemExchange_ProvisionCreatesDeviceAndSession(t *testing.T) {
ctx := context.Background()
devRepo := device.NewInMemoryRepository()
sessions := auth.NewInMemorySessionTokenStore()
exchanger := NewInMemoryPairingExchanger(devRepo, sessions)
res, err := exchanger.Provision(ctx, "owner-1", "parent-hash", auth.SessionTokenTTL, nil)
require.NoError(t, err)
require.NotEmpty(t, res.DeviceID)
require.NotEmpty(t, res.SessionPlaintext)
require.NotNil(t, res.SessionInfo)
// device 自建、owner 對齊
dev, err := devRepo.Get(ctx, res.DeviceID)
require.NoError(t, err)
assert.Equal(t, "owner-1", dev.OwnerUserID)
assert.Equal(t, defaultPairedDeviceName, dev.Name)
assert.Equal(t, defaultPairedDeviceType, dev.DeviceType)
assert.NotNil(t, dev.PairedAt)
// session token 綁到自建 device
tok, err := sessions.Get(ctx, res.SessionPlaintext)
require.NoError(t, err)
assert.Equal(t, res.DeviceID, tok.DeviceID, "session token 應綁到自建 device")
assert.Equal(t, "owner-1", tok.UserID)
assert.Equal(t, "parent-hash", tok.ParentTokenHash)
}
func TestMemExchange_Provision_DistinctDevices(t *testing.T) {
ctx := context.Background()
exchanger := NewInMemoryPairingExchanger(
device.NewInMemoryRepository(), auth.NewInMemorySessionTokenStore())
res1, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, nil)
require.NoError(t, err)
res2, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, nil)
require.NoError(t, err)
assert.NotEqual(t, res1.DeviceID, res2.DeviceID)
assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext)
}
// ==========================================================================
// WP-0ADR-018 序號地基exchange 收 agent 上報序號
// ==========================================================================
// TestMemExchange_Provision_FillsSerialNumber 驗證 agent 上報序號時,自建 device
// 的 serial_number 有值(+ device_type 若有上報)。
func TestMemExchange_Provision_FillsSerialNumber(t *testing.T) {
ctx := context.Background()
devRepo := device.NewInMemoryRepository()
exchanger := NewInMemoryPairingExchanger(devRepo, auth.NewInMemorySessionTokenStore())
res, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL,
[]ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520", Firmware: "KDP"}})
require.NoError(t, err)
dev, err := devRepo.Get(ctx, res.DeviceID)
require.NoError(t, err)
assert.Equal(t, "0x1A2B3C4D", dev.SerialNumber, "serial_number 應填入 agent 上報值")
assert.Equal(t, "kneron_kl520", dev.DeviceType, "device_type 有上報時應覆蓋預設")
}
// TestMemExchange_Provision_SameSerialReusesDevice 驗證同序號重複 exchange
// 復用既有 device防唯一約束炸裂「同序號重配 = 復用」R4——不新建、
// 不報錯、session token 各自獨立。
func TestMemExchange_Provision_SameSerialReusesDevice(t *testing.T) {
ctx := context.Background()
devRepo := device.NewInMemoryRepository()
exchanger := NewInMemoryPairingExchanger(devRepo, auth.NewInMemorySessionTokenStore())
input := []ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D"}}
res1, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input)
require.NoError(t, err)
res2, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input)
require.NoError(t, err, "同序號重配不可失敗(防 23505 的行為對齊)")
assert.Equal(t, res1.DeviceID, res2.DeviceID, "同序號重配應復用既有 device")
assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext, "session token 應各自獨立")
devices, err := devRepo.List(ctx, "owner-1")
require.NoError(t, err)
assert.Len(t, devices, 1, "同序號重配不應多建 device")
}
// TestMemExchange_Provision_FakeSerialTreatedAsEmpty 驗證假序號 0x00000000
// macOS 無 SDK 的 pyusb fallback視同無序號serial 留空、行為與不帶序號一致
// (每次 exchange 各建一筆 distinct device
func TestMemExchange_Provision_FakeSerialTreatedAsEmpty(t *testing.T) {
ctx := context.Background()
devRepo := device.NewInMemoryRepository()
exchanger := NewInMemoryPairingExchanger(devRepo, auth.NewInMemorySessionTokenStore())
input := []ExchangeDeviceInput{{SerialNumber: "0x00000000"}}
res1, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input)
require.NoError(t, err)
res2, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input)
require.NoError(t, err)
dev1, err := devRepo.Get(ctx, res1.DeviceID)
require.NoError(t, err)
assert.Empty(t, dev1.SerialNumber, "假序號應視同無序號(寫 NULL")
assert.NotEqual(t, res1.DeviceID, res2.DeviceID, "假序號不進復用分支,各建 distinct device")
}
// TestMemExchange_Provision_PicksFirstUsableSerial 驗證多顆上報時取第一顆
// 可用序號(空序號 / 假序號跳過——WP-0 最小落地R1多顆完整模型是 WP-B
func TestMemExchange_Provision_PicksFirstUsableSerial(t *testing.T) {
ctx := context.Background()
devRepo := device.NewInMemoryRepository()
exchanger := NewInMemoryPairingExchanger(devRepo, auth.NewInMemorySessionTokenStore())
res, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL,
[]ExchangeDeviceInput{
{SerialNumber: ""}, // 空序號跳過
{SerialNumber: "0x00000000"}, // 假序號跳過
{SerialNumber: " 0x0E5F6071 ", DeviceType: "kneron_kl720"},
{SerialNumber: "0xFFFF0001"},
})
require.NoError(t, err)
dev, err := devRepo.Get(ctx, res.DeviceID)
require.NoError(t, err)
assert.Equal(t, "0x0E5F6071", dev.SerialNumber, "應取第一顆可用序號trim 空白)")
assert.Equal(t, "kneron_kl720", dev.DeviceType)
}