//go:build dbtest // Migration 0006(模型共享:visibility 欄 + model_shares 表 + index)的真 DB 整合測試。 // // 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/... // // 對齊 migrations/0006_model_sharing.up.sql / .down.sql 與 feature-model-sharing-tdd.md §3: // 1. apply:models 有 visibility 欄(NOT NULL DEFAULT 'private')、model_shares 表存在、 // idx_model_shares_grantee / idx_models_public_active 存在、CHECK constraint 生效。 // 2. 既有相容:apply 前既有 model → apply 後 visibility='private'(零行為改變)。 // 3. model_shares FK / PK / role CHECK 生效。 // 4. rollback 對稱:down 後 visibility 欄與 model_shares 表消失。 // 5. re-apply 冪等:up→down→up 不報錯、結果一致。 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" ) // insertRawModel 直接寫入一筆 models(不經 repository),可控制 visibility(傳空用 DB DEFAULT)。 // 回傳 model id。 func insertRawModel(t *testing.T, tdb *testsupport.TestDB, ownerID, visibility string) string { t.Helper() id := uuid.NewString() ctx := context.Background() if visibility == "" { // 不指定 visibility 欄,走 DB DEFAULT(驗既有相容)。 _, err := tdb.Pool.Exec(ctx, `INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source) VALUES ($1, $2, 'raw-model', 'k', 1024, 'uploaded')`, id, ownerID) require.NoError(t, err, "insert raw model (default visibility)") return id } _, err := tdb.Pool.Exec(ctx, `INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source, visibility) VALUES ($1, $2, 'raw-model', 'k', 1024, 'uploaded', $3)`, id, ownerID, visibility) require.NoError(t, err, "insert raw model") return id } // TestMigrate0006_Apply 驗證 up 後 schema 到位(§1)。 func TestMigrate0006_Apply(t *testing.T) { tdb := testsupport.SetupTestDB(t) // 已 up 到最新(含 0006) assert.True(t, colExists(t, tdb, "models", "visibility"), "models 應有 visibility 欄") assert.True(t, tableExists(t, tdb, "model_shares"), "model_shares 表應存在") for _, idx := range []string{"idx_model_shares_grantee", "idx_models_public_active"} { assert.True(t, indexExists(t, tdb, idx), "index %s 應存在", idx) } // visibility 應 NOT NULL DEFAULT 'private'。 ctx := context.Background() var isNullable, colDefault string err := tdb.Pool.QueryRow(ctx, `SELECT is_nullable, COALESCE(column_default, '') FROM information_schema.columns WHERE table_name = 'models' AND column_name = 'visibility'`).Scan(&isNullable, &colDefault) require.NoError(t, err) assert.Equal(t, "NO", isNullable, "visibility 應 NOT NULL") assert.Contains(t, colDefault, "private", "visibility 預設應為 'private'") } // TestMigrate0006_ExistingModelDefaultsPrivate 驗證既有 model 遷移後 visibility='private'(§2,關鍵相容性)。 func TestMigrate0006_ExistingModelDefaultsPrivate(t *testing.T) { tdb := testsupport.SetupTestDB(t) ctx := context.Background() mg, err := db.NewMigrator(tdb.Cfg, discardLog()) require.NoError(t, err) defer mg.Close() // 回退 0006 → models 無 visibility 欄。 require.NoError(t, mg.Down(), "down 一步回到 0005") require.False(t, colExists(t, tdb, "models", "visibility"), "down 後不應有 visibility 欄") owner := tdb.InsertUser(t, "", "") // 在無 visibility 欄的狀態下塞既有 model。 existingID := uuid.NewString() _, err = tdb.Pool.Exec(ctx, `INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source) VALUES ($1, $2, 'legacy', 'k', 1024, 'uploaded')`, existingID, owner) require.NoError(t, err) // 重新 up 0006。 require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()), "re-up 0006") var vis string err = tdb.Pool.QueryRow(ctx, `SELECT visibility FROM models WHERE id = $1`, existingID).Scan(&vis) require.NoError(t, err) assert.Equal(t, "private", vis, "既有 model 遷移後 visibility 應為 private(零行為改變)") } // TestMigrate0006_VisibilityCheckConstraint 驗證非法 visibility 被 CHECK 擋下(§1)。 func TestMigrate0006_VisibilityCheckConstraint(t *testing.T) { tdb := testsupport.SetupTestDB(t) ctx := context.Background() owner := tdb.InsertUser(t, "", "") _, err := tdb.Pool.Exec(ctx, `INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source, visibility) VALUES ($1, $2, 'bad', 'k', 1024, 'uploaded', 'world')`, uuid.NewString(), owner) assert.Error(t, err, "非法 visibility 'world' 應被 CHECK constraint 擋下") // 合法值可寫入。 for _, v := range []string{"private", "tenant", "public"} { _, err := tdb.Pool.Exec(ctx, `INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source, visibility) VALUES ($1, $2, 'ok', 'k', 1024, 'uploaded', $3)`, uuid.NewString(), owner, v) assert.NoError(t, err, "合法 visibility %q 應可寫入", v) } } // TestMigrate0006_ModelSharesConstraints 驗證 model_shares 的 PK / FK / role CHECK(§3)。 func TestMigrate0006_ModelSharesConstraints(t *testing.T) { tdb := testsupport.SetupTestDB(t) ctx := context.Background() owner := tdb.InsertUser(t, "", "") grantee := tdb.InsertUser(t, "", "") modelID := insertRawModel(t, tdb, owner, "private") // 合法 share。 _, err := tdb.Pool.Exec(ctx, `INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by) VALUES ($1, $2, 'viewer', $3)`, modelID, grantee, owner) require.NoError(t, err, "合法 share 應可寫入") // PK 重複(同 model + 同 grantee)→ 衝突。 _, err = tdb.Pool.Exec(ctx, `INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by) VALUES ($1, $2, 'editor', $3)`, modelID, grantee, owner) assert.Error(t, err, "重複 (model_id, grantee_user_id) 應違反 PK") // role CHECK:非法 role。 grantee2 := tdb.InsertUser(t, "", "") _, err = tdb.Pool.Exec(ctx, `INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by) VALUES ($1, $2, 'admin', $3)`, modelID, grantee2, owner) assert.Error(t, err, "非法 role 'admin' 應被 CHECK 擋下") // FK:不存在的 model_id。 _, err = tdb.Pool.Exec(ctx, `INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by) VALUES ($1, $2, 'viewer', $3)`, uuid.NewString(), grantee2, owner) assert.Error(t, err, "不存在的 model_id 應違反 FK") // FK:不存在的 grantee_user_id。 _, err = tdb.Pool.Exec(ctx, `INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by) VALUES ($1, $2, 'viewer', $3)`, modelID, uuid.NewString(), owner) assert.Error(t, err, "不存在的 grantee_user_id 應違反 FK") } // TestMigrate0006_ModelSharesCascade 驗證 model 硬刪時連帶清 share(ON DELETE CASCADE)。 func TestMigrate0006_ModelSharesCascade(t *testing.T) { tdb := testsupport.SetupTestDB(t) ctx := context.Background() owner := tdb.InsertUser(t, "", "") grantee := tdb.InsertUser(t, "", "") modelID := insertRawModel(t, tdb, owner, "private") _, err := tdb.Pool.Exec(ctx, `INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by) VALUES ($1, $2, 'viewer', $3)`, modelID, grantee, owner) require.NoError(t, err) // 硬刪 model(非軟刪)→ share 應連帶消失。 _, err = tdb.Pool.Exec(ctx, `DELETE FROM models WHERE id = $1`, modelID) require.NoError(t, err) var n int err = tdb.Pool.QueryRow(ctx, `SELECT count(*) FROM model_shares WHERE model_id = $1`, modelID).Scan(&n) require.NoError(t, err) assert.Equal(t, 0, n, "model 硬刪後 model_shares 應連帶清空(CASCADE)") } // TestMigrate0006_RollbackSymmetry 驗證 down 對稱:visibility 欄與 model_shares 表消失(§4)。 func TestMigrate0006_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, colExists(t, tdb, "models", "visibility"), "down 前 visibility 欄應存在") require.True(t, tableExists(t, tdb, "model_shares"), "down 前 model_shares 表應存在") require.NoError(t, mg.Down(), "down 一步(回退 0006)") assert.False(t, colExists(t, tdb, "models", "visibility"), "down 後 visibility 欄應消失") assert.False(t, tableExists(t, tdb, "model_shares"), "down 後 model_shares 表應消失") for _, idx := range []string{"idx_model_shares_grantee", "idx_models_public_active"} { assert.False(t, indexExists(t, tdb, idx), "down 後 index %s 應消失", idx) } } // TestMigrate0006_ReApplyIdempotent 驗證 up→down→up 不報錯、結果一致(§5)。 func TestMigrate0006_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, colExists(t, tdb, "models", "visibility"), "重新 up 後 visibility 欄應再次存在") assert.True(t, tableExists(t, tdb, "model_shares"), "重新 up 後 model_shares 表應再次存在") }