//go:build dbtest // Postgres pairing exchange A' 模型重塑的真 DB 整合測試(WP-B B3)。 // // build tag `dbtest`:只在帶 `-tags=dbtest`(需要 Docker / testcontainers)時編譯/執行。 // // 執行: // // go test -tags=dbtest ./internal/api/... // # 無本機 Docker 時在 130 補跑: // DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \ // go test -tags=dbtest ./internal/api/... // // 涵蓋 A' 重塑:建/復用 agent + representative device(綁 session token)+ N 顆真 USB device; // Mi#2(tx 內查詢/局部更新)、Mi#5(serial 白名單)、S-2(Name 衍生)、R1(完整 N 顆)、R4(同序號復用)。 package api import ( "context" "testing" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "visiona-backend/internal/agent" "visiona-backend/internal/auth" "visiona-backend/internal/db/testsupport" "visiona-backend/internal/device" ) // pgExchangeFixture 建一個已就緒的 Postgres 環境:一個合法 owner user(無 agent / device / token)。 func pgExchangeFixture(t *testing.T) ( tdb *testsupport.TestDB, exchanger PairingExchanger, devRepo *device.PostgresRepository, agentRepo *agent.PostgresRepository, sessions *auth.PostgresSessionTokenStore, owner string, ) { t.Helper() tdb = testsupport.SetupTestDB(t) tdb.Truncate(t, "pairing_tokens", "session_tokens", "devices", "agents", "users") owner = tdb.EnsureDemoUser(t) devRepo = device.NewPostgresRepository(tdb.Pool) agentRepo = agent.NewPostgresRepository(tdb.Pool) sessions = auth.NewPostgresSessionTokenStore(tdb.Pool) exchanger = NewPostgresPairingExchanger(tdb.Pool, agentRepo, devRepo, sessions, nil) return } // Provision 建 agent + representative device + session token(無 USB 上報)。 func TestPGExchange_ProvisionCreatesAgentRepresentativeAndSession(t *testing.T) { ctx := context.Background() tdb, exchanger, devRepo, _, sessions, owner := pgExchangeFixture(t) parentHash := auth.HashToken("vAc_" + uuid.NewString()[:32]) res, err := exchanger.Provision(ctx, owner, parentHash, 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) assert.Empty(t, res.USBDeviceIDs, "無 USB 上報 → 不建真 USB device") // 1) agent 真的進 DB。 assert.Equal(t, 1, tdb.CountRows(t, "agents")) // 2) representative device:is_representative=true、掛該 agent、serial=NULL。 rep, err := devRepo.Get(ctx, res.DeviceID) require.NoError(t, err) assert.True(t, rep.IsRepresentative) assert.Equal(t, res.AgentID, rep.AgentID) assert.Equal(t, owner, rep.OwnerUserID) assert.Empty(t, rep.SerialNumber) assert.NotNil(t, rep.PairedAt) var serialIsNull bool require.NoError(t, tdb.Pool.QueryRow(ctx, `SELECT serial_number IS NULL FROM devices WHERE id = $1`, res.DeviceID).Scan(&serialIsNull)) assert.True(t, serialIsNull, "representative device serial 應為 NULL") // 3) session token 綁 representative device(device_id 非 NULL、FK 滿足)。 tok, err := sessions.Get(ctx, res.SessionPlaintext) require.NoError(t, err) assert.Equal(t, res.DeviceID, tok.DeviceID) assert.Equal(t, owner, tok.UserID) assert.Equal(t, parentHash, tok.ParentTokenHash) } // 原子性:owner 不存在(FK violation)→ 整筆 rollback,agent / device / token 都不殘留。 func TestPGExchange_Provision_RollbackOnBadOwner(t *testing.T) { ctx := context.Background() tdb, exchanger, _, _, _, _ := pgExchangeFixture(t) badOwner := uuid.NewString() // 不在 users 表 _, err := exchanger.Provision(ctx, badOwner, "", auth.SessionTokenTTL, nil) require.Error(t, err, "owner 不存在 → agent.owner_user_id FK violation") assert.Equal(t, 0, tdb.CountRows(t, "agents"), "FK 失敗應 rollback,無 agent 殘留") assert.Equal(t, 0, tdb.CountRows(t, "devices"), "無 device 殘留") assert.Equal(t, 0, tdb.CountRows(t, "session_tokens"), "無 session token 殘留") } // 同 owner 多次 exchange:復用同一 agent + 同一 representative device,各建獨立 session token。 func TestPGExchange_Provision_ReusesAgentAndRepresentative(t *testing.T) { ctx := context.Background() tdb, exchanger, _, _, _, owner := pgExchangeFixture(t) res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, nil) require.NoError(t, err) res2, err := exchanger.Provision(ctx, owner, "", 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) assert.Equal(t, 1, tdb.CountRows(t, "agents"), "復用不多建 agent") assert.Equal(t, 1, tdb.CountRows(t, "devices"), "復用不多建 representative device") assert.Equal(t, 2, tdb.CountRows(t, "session_tokens"), "兩次各建一個 session token") } // R1:N 顆可用序號 USB → 建 N 筆真 USB device(掛同一 agent、is_representative=false)。 func TestPGExchange_Provision_CreatesNUSBDevices(t *testing.T) { ctx := context.Background() tdb, exchanger, devRepo, _, _, owner := pgExchangeFixture(t) res, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, []ExchangeDeviceInput{ {SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520"}, {SerialNumber: "0x0E5F6071", DeviceType: "kneron_kl720"}, {SerialNumber: "0xFFFF0001"}, }) require.NoError(t, err) require.Len(t, res.USBDeviceIDs, 3, "三顆可用序號 → 三筆真 USB device") // devices 共 4 筆:1 representative + 3 USB。 assert.Equal(t, 4, tdb.CountRows(t, "devices")) 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) } } // S-2:真 USB device Name 由 device type + serial 衍生(非固定字串)。 func TestPGExchange_Provision_DerivesUSBDeviceName(t *testing.T) { ctx := context.Background() _, exchanger, devRepo, _, _, owner := pgExchangeFixture(t) res, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, []ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520"}}) 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, "kneron_kl520 (0x1A2B3C4D)", d.Name) assert.Equal(t, "kneron_kl520", d.DeviceType) } // R4:同序號重配復用同一真 USB device,不撞 partial unique(23505)、不多建。 func TestPGExchange_Provision_SameSerialReusesUSBDevice(t *testing.T) { ctx := context.Background() tdb, exchanger, _, _, _, owner := pgExchangeFixture(t) input := []ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D"}} res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input) require.NoError(t, err) require.Len(t, res1.USBDeviceIDs, 1) res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input) require.NoError(t, err, "同序號重配不可撞 23505 炸 500") require.Len(t, res2.USBDeviceIDs, 1) assert.Equal(t, res1.USBDeviceIDs[0], res2.USBDeviceIDs[0], "同序號應復用同一 USB device") // devices:1 representative + 1 USB = 2(同序號不多建)。 assert.Equal(t, 2, tdb.CountRows(t, "devices")) } // serial 真的寫進 DB(非 NULL)+ 復用 USB device 掛到 agent。 func TestPGExchange_Provision_FillsSerialNumber(t *testing.T) { ctx := context.Background() tdb, exchanger, devRepo, _, _, owner := pgExchangeFixture(t) res, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, []ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520"}}) 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, res.AgentID, d.AgentID) var serialIsNull bool require.NoError(t, tdb.Pool.QueryRow(ctx, `SELECT serial_number IS NULL FROM devices WHERE id = $1`, res.USBDeviceIDs[0]).Scan(&serialIsNull)) assert.False(t, serialIsNull, "真 USB device serial_number 不應為 NULL") } // 假序號 0x00000000 + Mi#5 白名單:不符格式的序號視同無序號,不建真 USB device。 func TestPGExchange_Provision_InvalidSerialsSkipped(t *testing.T) { ctx := context.Background() tdb, exchanger, _, _, _, owner := pgExchangeFixture(t) res, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, []ExchangeDeviceInput{ {SerialNumber: "0x00000000"}, // 假序號 {SerialNumber: "bad"}, // 無 0x {SerialNumber: "0x123"}, // 位數不足 {SerialNumber: "0xGGGGGGGG"}, // 非 hex }) require.NoError(t, err) assert.Empty(t, res.USBDeviceIDs, "無合法序號 → 不建真 USB device") // 只有 representative device。 assert.Equal(t, 1, tdb.CountRows(t, "devices")) } // session_tokens 綁 representative:兩次 exchange 綁同一 representative device_id(Q5 語意驗證)。 func TestPGExchange_Provision_SessionsBindSameRepresentative(t *testing.T) { ctx := context.Background() tdb, exchanger, _, _, _, owner := pgExchangeFixture(t) res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, nil) require.NoError(t, err) res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, nil) require.NoError(t, err) var distinctDeviceIDs int require.NoError(t, tdb.Pool.QueryRow(ctx, `SELECT count(DISTINCT device_id) FROM session_tokens`).Scan(&distinctDeviceIDs)) assert.Equal(t, 1, distinctDeviceIDs, "兩個 session token 應綁同一 representative device") assert.Equal(t, res1.DeviceID, res2.DeviceID) }