// 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"]) }