- 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>
431 lines
19 KiB
Go
431 lines
19 KiB
Go
//go:build dbtest
|
||
|
||
// Migration 0005(agents 模型)的真 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. apply:agents 表存在、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 筆未刪除 device(serial=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 數")
|
||
|
||
// 每筆未刪除 device:agent_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 device:agent_id 仍 NULL、is_representative 仍 false(DEFAULT)。
|
||
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
|
||
// schema(device_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 NULL(0005 不得動它)。
|
||
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#1;WP-B 落地後才有此真場景)。
|
||
//
|
||
// 場景:WP-0(序號地基)已 commit,exchange 會落「有 serial 的真 USB device」。若這些 device
|
||
// 在 apply 0005 前就已存在於 DB,0005 的 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」一律建 agent(agent_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 也建 agent(agent_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 回 0004(apply 0005 前狀態)")
|
||
|
||
owner := tdb.InsertUser(t, "", "")
|
||
|
||
// 模擬 apply 0005 前 DB 已有的 device 分佈:
|
||
// - 2 筆有 serial 的未刪除 device(WP-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=true(2 筆)")
|
||
|
||
// 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")
|
||
}
|