package main import ( "database/sql" "fmt" "os" "path/filepath" "strings" "testing" "git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/store" ) // U4 集成(环境门控 AGENT_PG_TEST_DSN:CI test-pg job / 本机隔离实例): // 夹具 → 受管迁移 → 校验和验证 → 幂等重跑 → 断点续传 → 非空目标拒绝 → // 未知 owner 阻塞。每用例独立 PG schema(与 store 测试同隔离约定)。 func pgSchemaDSN(t *testing.T) (string, func()) { t.Helper() dsn := os.Getenv("AGENT_PG_TEST_DSN") if dsn == "" { t.Skip("AGENT_PG_TEST_DSN 未设置:真库集成由 CI 的必跑 job 覆盖") } schema := "mig_" + strings.ToLower(store.NewID("t")) + "_" + fmt.Sprint(os.Getpid()) admin, err := sql.Open("pgx", dsn) if err != nil { t.Fatal(err) } if _, err := admin.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); err != nil { admin.Close() t.Fatalf("preinstall pg_trgm: %v", err) } if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil { admin.Close() t.Fatalf("create schema: %v", err) } cleanup := func() { admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") admin.Close() } return store.AppendPGRuntimeParam(dsn, "search_path", schema+",public"), cleanup } func TestMigrateEndToEndAndIdempotent(t *testing.T) { target, cleanup := pgSchemaDSN(t) defer cleanup() fixture := buildFixture(t) if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-e2e"}); err != nil { t.Fatalf("首次迁移:%v", err) } // 幂等重跑(同身份):表级 done 对账,零重复行。 if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-e2e"}); err != nil { t.Fatalf("幂等重跑:%v", err) } // 目标侧关键断言:布尔映射、NULL 保留、Unicode、BLOB、SERIAL 序列推进、 // 复合主键事件序。 st, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts")) if err != nil { t.Fatal(err) } defer st.Close() db := st.DB() var pinned, archived bool if err := db.QueryRow(`SELECT pinned, archived FROM conversations WHERE id='c1'`).Scan(&pinned, &archived); err != nil || !pinned || archived { t.Fatalf("布尔映射:pinned=%v archived=%v err=%v", pinned, archived, err) } var title, note string if err := db.QueryRow(`SELECT title, session_note FROM conversations WHERE id='c1'`).Scan(&title, ¬e); err != nil || !strings.Contains(note, "注") { t.Fatalf("Unicode:title=%q note=%q err=%v", title, note, err) } var vec []byte if err := db.QueryRow(`SELECT vec FROM knowledge_vectors WHERE doc_id='k1'`).Scan(&vec); err != nil || len(vec) != 12 { t.Fatalf("BLOB 向量:len=%d err=%v", len(vec), err) } var ended sql.NullString if err := db.QueryRow(`SELECT ended_at FROM runs WHERE id='r1'`).Scan(&ended); err != nil || ended.Valid { t.Fatalf("NULL 保留:ended=%v err=%v", ended, err) } var seqMax int if err := db.QueryRow(`SELECT MAX(seq) FROM events WHERE conversation_id='c1'`).Scan(&seqMax); err != nil || seqMax != 2 { t.Fatalf("事件复合主键序:max=%d err=%v", seqMax, err) } // 复制的显式 id 之后,应用侧插入不撞序列(command_audit.id=7 已复制)。 if _, err := db.Exec(`INSERT INTO command_audit (time, command_id, source, entrance, state, operation_id, conversation_id, payload, owner) VALUES ('2026-10-11T03:00:00Z','cmd-2','http','chat','done','op-2','c1','{}','alice')`); err != nil { t.Fatalf("序列推进失败(复制后插入撞主键):%v", err) } // 报告不含秘密值。 var report string if err := db.QueryRow(`SELECT report FROM migration_control WHERE migration_id='mig-e2e'`).Scan(&report); err != nil || strings.Contains(report, "sk-secret-value") { t.Fatalf("报告泄密:err=%v", err) } } func TestMigrateResumeAfterInterrupt(t *testing.T) { target, cleanup := pgSchemaDSN(t) defer cleanup() fixture := buildFixture(t) if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-resume"}); err != nil { t.Fatal(err) } // 模拟断点:messages 已 done 后被部分回退(行删一半、状态回 pending), // 重跑只重做该表,终态校验仍全绿。 db, err := sql.Open("pgx", target) if err != nil { t.Fatal(err) } defer db.Close() if _, err := db.Exec(`DELETE FROM messages WHERE id='m3'`); err != nil { t.Fatal(err) } if _, err := db.Exec(`UPDATE migration_table_state SET state='pending' WHERE migration_id='mig-resume' AND table_name='messages'`); err != nil { t.Fatal(err) } if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-resume"}); err != nil { t.Fatalf("断点续跑:%v", err) } var n int if err := db.QueryRow(`SELECT COUNT(*) FROM messages`).Scan(&n); err != nil || n != 3 { t.Fatalf("续跑后 messages=%d:重做该表应恢复 3 行", n) } } func TestMigrateRefusesForeignDataAndWrongSnapshot(t *testing.T) { target, cleanup := pgSchemaDSN(t) defer cleanup() fixture := buildFixture(t) // 非空目标(人工数据、无迁移身份):拒绝。 st, err := store.OpenPostgres(target, filepath.Join(t.TempDir(), "artifacts")) if err != nil { t.Fatal(err) } st.Close() db, err := sql.Open("pgx", target) if err != nil { t.Fatal(err) } defer db.Close() if _, err := db.Exec(`INSERT INTO projects (id, name, settings, created_at, updated_at, owner) VALUES ('foreign', '外来', '{}', '2026-10-11T00:00:00Z', '2026-10-11T00:00:00Z', 'alice')`); err != nil { t.Fatal(err) } err = cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-x"}) if err == nil || !strings.Contains(err.Error(), "非空目标") { t.Fatalf("非空目标应拒绝:%v", err) } if _, err := db.Exec(`DELETE FROM projects WHERE id='foreign'`); err != nil { t.Fatal(err) } // 同 migration_id 换快照:拒绝(仅同一冻结快照可续传)。 if err := cmdMigrate([]string{"--source", fixture, "--target", target, "--migration-id", "mig-snap"}); err != nil { t.Fatal(err) } other := buildFixture(t) // 不同文件 ⇒ 不同 snapshot_id err = cmdMigrate([]string{"--source", other, "--target", target, "--migration-id", "mig-snap"}) if err == nil || !strings.Contains(err.Error(), "快照不同") { t.Fatalf("换快照应拒绝:%v", err) } // 其他 migration_id 已存在:拒绝。 err = cmdMigrate([]string{"--source", other, "--target", target, "--migration-id", "mig-other"}) if err == nil || !strings.Contains(err.Error(), "其他迁移记录") { t.Fatalf("他身份应拒绝:%v", err) } } func TestMigrateBlocksUnknownOwner(t *testing.T) { target, cleanup := pgSchemaDSN(t) defer cleanup() fixture := buildFixture(t) // 在夹具里造一个 users 之外的 owner 行(直接写源库)。 srcPath := fixture st, err := store.OpenSQLite(srcPath) // 普通打开(夹具可写) if err != nil { t.Fatal(err) } if _, err := st.DB().Exec(`INSERT INTO knowledge (id, project_id, source, title, ref, chunk, created_at, owner) VALUES ('k_ghost', 'p1', 'upload', '幽灵', 'g.md', '…', '2026-10-11T01:00:00Z', 'ghost-user')`); err != nil { t.Fatal(err) } st.Close() err = cmdMigrate([]string{"--source", srcPath, "--target", target, "--migration-id", "mig-ghost"}) if err == nil || !strings.Contains(err.Error(), "未知 owner") { t.Fatalf("未知 owner 应阻塞:%v", err) } }