visionA/visionA-backend/internal/db/migrate_0005_db_test.go
jim800121chen 59c57fa481 feat(device): WP-B repository 接 agents 模型 + exchange 重塑(A' 走向第二階段 Go 層)
- 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>
2026-07-16 11:40:17 +08:00

431 lines
19 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.

//go:build dbtest
// Migration 0005agents 模型)的真 DB 整合測試(走向 A' 第二階段地基)。
//
// build tag `dbtest`:需要 Docker daemon / testcontainers。預設 `go test ./...` 不編譯本檔。
// 執行:
//
// go test -tags=dbtest ./internal/db/...
// # 無本機 Docker 時,在 130 補跑:
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
// go test -tags=dbtest ./internal/db/...
//
// 對齊 migration-0005-spec.md §6.2 六個驗證要點:
// 1. applyagents 表存在、devices 有 4 新欄、2 新 index 存在。
// 2. data migration 正確N 筆未刪除 device + M 筆 soft-deleted → agents 恰 N 筆、
// 每筆未刪除 device 的 agent_id=自己 id 且 is_representative=true、soft-deleted 的 agent_id 仍 NULL。
// 3. rollback 對稱down 後 agents 表消失、devices 回 13 欄、session_tokens 完全未變。
// 4. re-apply 冪等up→down→up 不報錯、結果一致。
// 5. session_tokens 零影響:遷移前後 session_tokens 的 row 與 device_id 綁定完全不變。
// 6. 既有 device 讀寫回歸0005 後既有 device 讀寫(舊 13 欄)仍正常運作。
package db_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"visiona-backend/internal/db"
"visiona-backend/internal/db/testsupport"
)
// colExists 回傳指定 table 是否有指定欄位information_schema
func colExists(t *testing.T, tdb *testsupport.TestDB, table, column string) bool {
t.Helper()
var exists bool
err := tdb.Pool.QueryRow(context.Background(),
`SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = $1 AND column_name = $2
)`, table, column).Scan(&exists)
require.NoError(t, err, "check column %s.%s", table, column)
return exists
}
// indexExists 回傳指定 index 是否存在pg_indexes
func indexExists(t *testing.T, tdb *testsupport.TestDB, name string) bool {
t.Helper()
var exists bool
err := tdb.Pool.QueryRow(context.Background(),
`SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = $1)`, name).Scan(&exists)
require.NoError(t, err, "check index %s", name)
return exists
}
// tableExists 回傳指定 table 是否存在information_schema
func tableExists(t *testing.T, tdb *testsupport.TestDB, table string) bool {
t.Helper()
var exists bool
err := tdb.Pool.QueryRow(context.Background(),
`SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = $1 AND table_schema = 'public'
)`, table).Scan(&exists)
require.NoError(t, err, "check table %s", table)
return exists
}
// deviceColCount 回傳 devices 表目前的欄位數(用於驗證 up 後 17 欄 / down 後回 13 欄)。
func deviceColCount(t *testing.T, tdb *testsupport.TestDB) int {
t.Helper()
var n int
err := tdb.Pool.QueryRow(context.Background(),
`SELECT count(*) FROM information_schema.columns
WHERE table_name = 'devices' AND table_schema = 'public'`).Scan(&n)
require.NoError(t, err, "count devices columns")
return n
}
// insertRawDevice 直接寫入一筆 devices可控制 serial_number / paired_at / deleted_at。
// 回傳寫入的 device id。data migration 測試需要精確控制 soft-delete 與 serial。
// id 為空時自動產生 UUID不依賴欄位 DEFAULT以便回傳明確 id 供斷言)。
func insertRawDevice(t *testing.T, tdb *testsupport.TestDB, id, ownerID, serial string, softDeleted bool) string {
t.Helper()
ctx := context.Background()
if id == "" {
id = uuid.NewString()
}
var serialArg any
if serial == "" {
serialArg = nil
} else {
serialArg = serial
}
deletedExpr := "NULL"
if softDeleted {
deletedExpr = "now()"
}
err := tdb.Pool.QueryRow(ctx,
`INSERT INTO devices (id, owner_user_id, name, serial_number, paired_at, deleted_at)
VALUES ($1, $2, $3, $4, now(), `+deletedExpr+`)
RETURNING id`,
id, ownerID, "raw-device", serialArg).Scan(&id)
require.NoError(t, err, "insert raw device fixture")
return id
}
// TestMigrate0005_Apply 驗證 up 後agents 表存在、devices 有 4 新欄、2 新 index 存在§6.2-1
func TestMigrate0005_Apply(t *testing.T) {
tdb := testsupport.SetupTestDB(t) // 已 up 到最新(含 0005
require.True(t, tableExists(t, tdb, "agents"), "agents 表應存在")
for _, col := range []string{"agent_id", "agent_local_device_id", "registered_at", "is_representative"} {
assert.True(t, colExists(t, tdb, "devices", col), "devices 應有新欄 %s", col)
}
for _, idx := range []string{"idx_agents_owner_active", "idx_devices_agent_active", "idx_devices_registered"} {
assert.True(t, indexExists(t, tdb, idx), "index %s 應存在", idx)
}
// is_representative 應有 NOT NULL DEFAULT false既有/新 device 加欄後預設 false
assert.Equal(t, 17, deviceColCount(t, tdb), "up 後 devices 應為 13+4=17 欄")
}
// TestMigrate0005_DataMigration 驗證舊資料遷移正確性§6.2-2
//
// 關鍵SetupTestDB 一開機就跑到最新(含 0005此時沒有「遷移前的舊 device」可觀察。
// 故本測試手動 down 一步回到 0004、塞入 N 筆未刪除 + M 筆 soft-deleted device再 up 觀察遷移結果。
func TestMigrate0005_DataMigration(t *testing.T) {
tdb := testsupport.SetupTestDB(t)
ctx := context.Background()
mg, err := db.NewMigrator(tdb.Cfg, discardLog())
require.NoError(t, err)
defer mg.Close()
// 回退 0005回到 0004 後狀態devices 尚無 agent_id 等新欄)。
require.NoError(t, mg.Down(), "down 一步回到 0004")
require.False(t, colExists(t, tdb, "devices", "agent_id"), "down 後不應有 agent_id 欄")
owner := tdb.InsertUser(t, "", "")
// 塞 3 筆未刪除 deviceserial=NULL模擬連線佔位+ 2 筆 soft-deleted device。
activeIDs := []string{
insertRawDevice(t, tdb, "", owner, "", false),
insertRawDevice(t, tdb, "", owner, "", false),
insertRawDevice(t, tdb, "", owner, "", false),
}
deletedIDs := []string{
insertRawDevice(t, tdb, "", owner, "DEL-1", true),
insertRawDevice(t, tdb, "", owner, "DEL-2", true),
}
// 重新 up跑 0005 的 data migration。
require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()), "re-up 0005")
// agents 表應恰 3 筆(= 未刪除 device 數soft-deleted 不建 agent
assert.Equal(t, len(activeIDs), tdb.CountRows(t, "agents"), "agents 應等於未刪除 device 數")
// 每筆未刪除 deviceagent_id = 自己 id、is_representative = true、且該 agent 存在。
for _, id := range activeIDs {
var agentID string
var isRep bool
err = tdb.Pool.QueryRow(ctx,
`SELECT agent_id, is_representative FROM devices WHERE id = $1`, id).Scan(&agentID, &isRep)
require.NoError(t, err)
assert.Equal(t, id, agentID, "未刪除 device 的 agent_id 應等於自己 id")
assert.True(t, isRep, "未刪除 device 應標記 is_representative=true")
var agentOwner string
err = tdb.Pool.QueryRow(ctx,
`SELECT owner_user_id FROM agents WHERE id = $1`, id).Scan(&agentOwner)
require.NoError(t, err, "應存在 id=%s 的 agent", id)
assert.Equal(t, owner, agentOwner, "agent 應繼承 device 的 owner")
}
// soft-deleted deviceagent_id 仍 NULL、is_representative 仍 falseDEFAULT
for _, id := range deletedIDs {
var agentID *string
var isRep bool
err = tdb.Pool.QueryRow(ctx,
`SELECT agent_id, is_representative FROM devices WHERE id = $1`, id).Scan(&agentID, &isRep)
require.NoError(t, err)
assert.Nil(t, agentID, "soft-deleted device 的 agent_id 應維持 NULL")
assert.False(t, isRep, "soft-deleted device 的 is_representative 應維持 false")
}
}
// TestMigrate0005_RollbackSymmetry 驗證 down 對稱agents 消失、devices 回 13 欄§6.2-3
func TestMigrate0005_RollbackSymmetry(t *testing.T) {
tdb := testsupport.SetupTestDB(t)
mg, err := db.NewMigrator(tdb.Cfg, discardLog())
require.NoError(t, err)
defer mg.Close()
require.True(t, tableExists(t, tdb, "agents"), "down 前 agents 應存在")
require.Equal(t, 17, deviceColCount(t, tdb), "down 前 devices 應 17 欄")
require.NoError(t, mg.Down(), "down 一步(回退 0005")
assert.False(t, tableExists(t, tdb, "agents"), "down 後 agents 表應消失")
assert.Equal(t, 13, deviceColCount(t, tdb), "down 後 devices 應回到 0002 的 13 欄")
for _, col := range []string{"agent_id", "agent_local_device_id", "registered_at", "is_representative"} {
assert.False(t, colExists(t, tdb, "devices", col), "down 後 devices 不應有 %s", col)
}
for _, idx := range []string{"idx_agents_owner_active", "idx_devices_agent_active", "idx_devices_registered"} {
assert.False(t, indexExists(t, tdb, idx), "down 後 index %s 應消失", idx)
}
}
// TestMigrate0005_ReApplyIdempotent 驗證 up→down→up 不報錯、結果一致§6.2-4
func TestMigrate0005_ReApplyIdempotent(t *testing.T) {
tdb := testsupport.SetupTestDB(t)
mg, err := db.NewMigrator(tdb.Cfg, discardLog())
require.NoError(t, err)
defer mg.Close()
topVer, dirty, err := mg.Version()
require.NoError(t, err)
require.False(t, dirty)
require.NoError(t, mg.Down(), "down 一步")
require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()), "重新 up")
ver, dirty, err := mg.Version()
require.NoError(t, err)
assert.False(t, dirty, "up→down→up 後不應 dirty")
assert.Equal(t, topVer, ver, "up→down→up 後版本應回到最新")
assert.True(t, tableExists(t, tdb, "agents"), "重新 up 後 agents 表應再次存在")
}
// TestMigrate0005_SessionTokensUntouched 驗證 0005 對 session_tokens 零影響§6.2-3、§6.2-5
//
// 在 0005 已 apply 的狀態下塞一筆 session_token綁 representative device
// 記下 (token_hash, device_id)down 一步再 up驗證 row 與綁定完全不變、且 session_tokens
// schemadevice_id NOT NULL FK在 0005 進出後一字不動。
func TestMigrate0005_SessionTokensUntouched(t *testing.T) {
tdb := testsupport.SetupTestDB(t)
ctx := context.Background()
owner := tdb.InsertUser(t, "", "")
deviceID := tdb.InsertDevice(t, "", owner)
const tokenHash = "sha256-0005-untouched-fixture"
_, err := tdb.Pool.Exec(ctx,
`INSERT INTO session_tokens (token_hash, user_id, device_id) VALUES ($1, $2, $3)`,
tokenHash, owner, deviceID)
require.NoError(t, err, "insert session token fixture")
// session_tokens.device_id 必須是 NOT NULL0005 不得動它)。
var isNullable string
err = tdb.Pool.QueryRow(ctx,
`SELECT is_nullable FROM information_schema.columns
WHERE table_name = 'session_tokens' AND column_name = 'device_id'`).Scan(&isNullable)
require.NoError(t, err)
assert.Equal(t, "NO", isNullable, "session_tokens.device_id 應維持 NOT NULL")
// down 一步 + 重新 up模擬 0005 進出。
mg, err := db.NewMigrator(tdb.Cfg, discardLog())
require.NoError(t, err)
defer mg.Close()
require.NoError(t, mg.Down(), "down 一步")
require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()), "重新 up")
// row 與綁定完全不變。
var gotDevice string
err = tdb.Pool.QueryRow(ctx,
`SELECT device_id FROM session_tokens WHERE token_hash = $1`, tokenHash).Scan(&gotDevice)
require.NoError(t, err, "session_token row 應在 0005 進出後仍存在")
assert.Equal(t, deviceID, gotDevice, "session_token 的 device_id 綁定不應改變")
assert.Equal(t, 1, tdb.CountRows(t, "session_tokens"), "session_tokens 應仍恰 1 筆")
// device_id FK 仍在NOT NULL插入 NULL device_id 應失敗。
_, err = tdb.Pool.Exec(ctx,
`INSERT INTO session_tokens (token_hash, user_id, device_id) VALUES ($1, $2, NULL)`,
"sha256-null-device", owner)
assert.Error(t, err, "session_tokens.device_id NOT NULL 應仍生效")
}
// TestMigrate0005_ExistingDeviceReadWrite 驗證 0005 後既有 device 讀寫(舊 13 欄仍正常§6.2-6
//
// 對齊「deviceColumns/scanDevice 未含新欄 = 既有讀寫不受影響」的相容性分析:
// 用只涉及舊欄的 SELECT / UPDATE 走一遍,確認 0005 加欄後仍運作。
func TestMigrate0005_ExistingDeviceReadWrite(t *testing.T) {
tdb := testsupport.SetupTestDB(t)
ctx := context.Background()
owner := tdb.InsertUser(t, "", "")
deviceID := tdb.InsertDevice(t, "", owner)
// 舊欄 SELECT模擬 deviceColumns未含新 4 欄)仍可讀。
var name, remoteStatus, status string
err := tdb.Pool.QueryRow(ctx,
`SELECT name, remote_status, status FROM devices WHERE id = $1`, deviceID).Scan(&name, &remoteStatus, &status)
require.NoError(t, err, "既有 device 舊欄 SELECT 應正常")
assert.Equal(t, "fixture-device", name)
// 舊欄 UPDATE雙狀態仍可寫且新欄維持預設agent_id NULL / is_representative false
_, err = tdb.Pool.Exec(ctx,
`UPDATE devices SET remote_status = 'online', status = 'online', updated_at = now() WHERE id = $1`, deviceID)
require.NoError(t, err, "既有 device 舊欄 UPDATE 應正常")
var agentID *string
var isRep bool
err = tdb.Pool.QueryRow(ctx,
`SELECT agent_id, is_representative FROM devices WHERE id = $1`, deviceID).Scan(&agentID, &isRep)
require.NoError(t, err)
assert.Nil(t, agentID, "0005 後新插入的 device agent_id 應為 NULL非遷移對象")
assert.False(t, isRep, "新插入的 device is_representative 應為 DEFAULT false")
// devices.owner_user_id FK 仍生效。
_, err = tdb.Pool.Exec(ctx,
`INSERT INTO devices (owner_user_id, name) VALUES ($1, $2)`,
"99999999-9999-9999-9999-999999999999", "bad-owner")
assert.Error(t, err, "devices.owner_user_id FK 應仍生效")
}
// ---------------------------------------------------------------------------
// B5 收尾0005-Mi#1有 serial 未刪除 device 遷移)+ Q4 矛盾態驗證
// ---------------------------------------------------------------------------
// TestMigrate0005_Mi1_ActiveDeviceWithSerialMigration 驗證「有 serial 的未刪除 device」遷移行為
// 0005-Mi#1WP-B 落地後才有此真場景)。
//
// 場景WP-0序號地基已 commitexchange 會落「有 serial 的真 USB device」。若這些 device
// 在 apply 0005 前就已存在於 DB0005 的 data migration對所有未刪除 device 一律標
// is_representative=true會如何處理它們本測試 down 回 0004、塞有 serial 的未刪除 device、
// 再 up 觀察遷移結果——驗證「行為」本身(不論結果是否為預期,先如實記錄)。
func TestMigrate0005_Mi1_ActiveDeviceWithSerialMigration(t *testing.T) {
tdb := testsupport.SetupTestDB(t)
ctx := context.Background()
mg, err := db.NewMigrator(tdb.Cfg, discardLog())
require.NoError(t, err)
defer mg.Close()
require.NoError(t, mg.Down(), "down 回 0004")
require.False(t, colExists(t, tdb, "devices", "agent_id"), "down 後不應有 agent_id 欄")
owner := tdb.InsertUser(t, "", "")
// 塞「有 serial 的未刪除 device」模擬 WP-0 產生的真 USB+ 「無 serial 的未刪除 device」連線佔位
serialID := insertRawDevice(t, tdb, "", owner, "0x1A2B3C4D", false)
nullSerialID := insertRawDevice(t, tdb, "", owner, "", false)
require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()), "re-up 0005")
// 0005 data migration 對「所有未刪除 device」一律建 agentagent_id=自己 id+ 標 representative。
// 故有 serial 的 device 也會被遷移——如實記錄其遷移後狀態。
var (
serialAgentID string
serialIsRep bool
serialNumberKept string
)
err = tdb.Pool.QueryRow(ctx,
`SELECT agent_id, is_representative, COALESCE(serial_number, '') FROM devices WHERE id = $1`,
serialID).Scan(&serialAgentID, &serialIsRep, &serialNumberKept)
require.NoError(t, err)
// 遷移行為斷言(如實反映 0005 現況):
assert.Equal(t, serialID, serialAgentID, "有 serial 的未刪除 device 也建 agentagent_id=自己 id")
assert.True(t, serialIsRep, "0005 對所有未刪除 device 一律標 is_representative=true含有 serial 者)")
assert.Equal(t, "0x1A2B3C4D", serialNumberKept, "serial_number 未被 0005 清除(保留原值)")
// 無 serial 的未刪除 device正常遷移為 representative。
var nullIsRep bool
err = tdb.Pool.QueryRow(ctx,
`SELECT is_representative FROM devices WHERE id = $1`, nullSerialID).Scan(&nullIsRep)
require.NoError(t, err)
assert.True(t, nullIsRep, "無 serial 未刪除 device 標 representative=true")
// agents 表恰 2 筆(兩筆未刪除 device 各建一 agent
assert.Equal(t, 2, tdb.CountRows(t, "agents"))
}
// TestMigrate0005_Q4_ContradictoryStateDetection 專驗 Q4 矛盾態apply 0005 前若 DB 已有
// WP-0 產生的「有 serial device」0005 data migration 是否製造「is_representative=true 但
// serial_number≠NULL」的矛盾態以及實際發生幾筆。
//
// ⚠️ 本測試只**驗證並量化**矛盾態是否發生B5 任務要求),不擅自改 0005 或寫 0006——
// 補償與否由 architect / 使用者依本結果裁決(見 wp-b-scope §5 Q4
func TestMigrate0005_Q4_ContradictoryStateDetection(t *testing.T) {
tdb := testsupport.SetupTestDB(t)
ctx := context.Background()
mg, err := db.NewMigrator(tdb.Cfg, discardLog())
require.NoError(t, err)
defer mg.Close()
require.NoError(t, mg.Down(), "down 回 0004apply 0005 前狀態)")
owner := tdb.InsertUser(t, "", "")
// 模擬 apply 0005 前 DB 已有的 device 分佈:
// - 2 筆有 serial 的未刪除 deviceWP-0 exchange 落的真 USB
// - 1 筆無 serial 的未刪除 device舊連線佔位
// - 1 筆有 serial 的 soft-deleted device不遷移
insertRawDevice(t, tdb, "", owner, "0x1A2B3C4D", false)
insertRawDevice(t, tdb, "", owner, "0x0E5F6071", false)
insertRawDevice(t, tdb, "", owner, "", false)
insertRawDevice(t, tdb, "", owner, "0xDEADBEEF", true) // soft-deleted
require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()), "apply 0005")
// 量化「矛盾態」is_representative=true 且 serial_number IS NOT NULL 且未刪除。
var contradictoryCount int
err = tdb.Pool.QueryRow(ctx,
`SELECT count(*) FROM devices
WHERE is_representative = true AND serial_number IS NOT NULL AND deleted_at IS NULL`).Scan(&contradictoryCount)
require.NoError(t, err)
// 依 0005 現行 data migration 邏輯(對所有未刪除 device 一律標 representative=true
// 上述 2 筆有 serial 的未刪除 device 會落入矛盾態。本測試釘住此事實供 architect 裁決。
// 若未來 0006 補償(如 WHERE serial_number IS NULL 排除有 serial 者),此斷言需同步更新。
assert.Equal(t, 2, contradictoryCount,
"Q4 矛盾態apply 0005 前已有的『有 serial 未刪除 device』被標為 representative=true2 筆)")
// soft-deleted 的有 serial device 不遷移(不計入矛盾態)。
var deletedRepCount int
err = tdb.Pool.QueryRow(ctx,
`SELECT count(*) FROM devices
WHERE is_representative = true AND deleted_at IS NOT NULL`).Scan(&deletedRepCount)
require.NoError(t, err)
assert.Equal(t, 0, deletedRepCount, "soft-deleted device 不應被標 representative")
}