visionA/visionA-backend/internal/db/migrate_0005_db_test.go
jim800121chen 8369cab85c feat(db): migration 0005 agents 模型(個人設備管理 A' 走向第二階段地基)
- 新增 agents 表 + devices 加 4 欄(agent_id/agent_local_device_id/
  registered_at/is_representative)+ 2 index + 純 SQL data migration
- data migration 採 R-A:agent.id := device.id 決定性推導、冪等、
  soft-deleted device 排除(WHERE deleted_at IS NULL)
- 守住 ADR-018 走向 A':不碰 session_tokens FK、不改現有 partial
  unique index uq_devices_owner_serial_active、對 0001-0003 零破壞
- migrate_0005_db_test.go 6 個 dbtest case(apply/data migration/
  rollback 對稱/re-apply 冪等/session_tokens 零影響/既有 device 讀寫回歸)
- 修 TestMigrate_UpDownUp 編號 gap 假設(0001/2/3/5 無 0004):
  assert.Equal(topVer-1) → assert.Less(downVer, topVer)
- .gitignore 加 .logs/ + **/.logs/(本機執行 log per-branch 不進 git)

Reviewer 通過(0 Critical/0 Major/3 Minor/4 Sug)。全 db package 41
dbtest 130 綠、build/vet/test 綠、gitleaks 0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 04:05:53 +08:00

322 lines
13 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 應仍生效")
}