// 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-0(ADR-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) }