//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 應仍生效") }