Files
rond-with-you/internal/web/baseline_swap_test.go
T
tamakyi 6ff6f944a1 换基线:把「底座换代」做进后台(站点数据一行不动)
导出是「底座为底 + 只覆盖托管表」,所以底座必须是手机导出的包。底座跟着数据集走,
手机重新导出后想换代,以前只能整包重新导入成新数据集(站点补录的行、主题、备注都得重收拾)。
新动作 /admin/baseline(后台首页加入口)就是给同一份数据的底座换代用的。

三道闸,顺序不能挪:
1. 重合判据:新包里的到访 UUID 与站点侧带 UUID 的到访比。一条都对不上直接拒
   (连 force 也不给过)——干跑只验结构,一份不相干的包照样导得出,但换下去站点
   每行都在新底座里认不出身份,全按新行插到最大编号之后,真机上就是「这些到访
   全变成新的」。重合率不到一半要显式确认;判据没过不当场报错了事:包已经收进
   uploads,页面给一个「我确认,强制替换」按钮,不必重传几十 MB。
2. 干跑导出:底座缺表/缺实体编号在这步挡住,不会出现「换完了才发现导不出、
   原来那份已经不知去哪」。
3. 旧底座留档 + 记档,换错了可一键回滚。

两个坑:
- 留档名不能只用秒级时间戳。「替换后紧接着回滚」落在同一秒时,回滚那次 rename 会把
  刚生成的留档覆盖掉,于是回滚回了个寂寞(TestRollbackBaselineRestoresPrevious 当场红)。
  改用 baselineBackupName():20060102-150405 + 冲突自增。
- ensureBaseline 必须先看记档。只认 datasets.sha256(= 最初那份包)的话,底座一丢就静默
  重建回旧包,上一次换基线白做且毫无提示。抽出 ensureBaselineTo(ctx, dsID, dst) 共用;
  「重建」入口写临时文件再 rename,不做破坏性删除。

回归用例 7 条(internal/web/baseline_swap_test.go):成功路径断言「换底座前后导出包里
ZVISIT 的 UUID 集合逐条一致」——编号会换、身份不该换,谁在底座里没对上立刻亮;
记档优先重建带反证(清掉记档后同一条路必须走不通);部分重合需确认、完全对不上 force
也不给过、非包文件第一步就挡住、回滚还原且当前那份也留档、页面渲染到底。

验收:./scripts/verify.sh 全量 PASS=318 FAIL=0 SKIP=0。
2026-09-30 19:00:18 +08:00

501 lines
18 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.
package web
import (
"bytes"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"rond-with-you/internal/ingest"
)
// ---------- 换基线 ----------
//
// 这些用例守的是「底座换代」这件事的几条底线:
// 站点数据一行不动、底座文件真的换了、旧底座留档可回滚、记档能撑住重建、
// 以及**不能把一份不相干的包换进来**(那是唯一会让导出包整个错位的操作)。
func baselineSHA(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读 %s 失败: %v", path, err)
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// baselinePackageFile 把一份包写到临时文件,当作「新底座」用。
// 传当前数据集的导出结果最贴近真实情形:它与原始包不是同一个文件(编号、WAL、私有列都不同),
// 但到访 UUID 与站点完全一致。
func baselinePackageFile(t *testing.T, name string, blob []byte) (path, sha string, size int64) {
t.Helper()
p := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(p, blob, 0o644); err != nil {
t.Fatalf("写 %s 失败: %v", p, err)
}
sha, size, err := ingest.File(p)
if err != nil {
t.Fatalf("算摘要失败: %v", err)
}
return p, sha, size
}
// exportVisitUUIDs 读导出包里全部到访的身份(小写 hex),排序后返回。
// 这是「换底座有没有把行搬到别的编号空间」最锋利的判据:编号会换、身份不该换。
func exportVisitUUIDs(t *testing.T, edb *sql.DB) []string {
t.Helper()
rows, err := edb.Query(`SELECT COALESCE(hex(ZIDENTIFIER_),'') FROM ZVISIT ORDER BY 1`)
if err != nil {
t.Fatalf("读导出包的到访身份失败: %v", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
t.Fatalf("读导出包的到访身份失败: %v", err)
}
out = append(out, s)
}
if err := rows.Err(); err != nil {
t.Fatalf("读导出包的到访身份失败: %v", err)
}
return out
}
func sameStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// siteCounts 数站点的到访与地点,用来断言「换底座不动数据」。
func siteCounts(t *testing.T, f *movementFixture) (visits, places int) {
t.Helper()
if err := f.pg.QueryRowContext(f.ctx, `SELECT
(SELECT count(*) FROM visits WHERE dataset_id=$1),
(SELECT count(*) FROM places WHERE dataset_id=$1)`, f.dsID).Scan(&visits, &places); err != nil {
t.Fatalf("数站点数据失败: %v", err)
}
return
}
// baselineRecord 读换基线的记档。
func baselineRecord(t *testing.T, f *movementFixture) (name, sha string, at sql.NullTime) {
t.Helper()
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(baseline_name,''), COALESCE(baseline_sha256,''), baseline_at
FROM datasets WHERE id=$1`, f.dsID).Scan(&name, &sha, &at); err != nil {
t.Fatalf("读记档失败: %v", err)
}
return
}
// scrambleVisitUUIDs 把站内到访留档里的 UUID 换成同一组假值:换完之后,
// 任何真实包里都找不到这些身份。
//
// 只动留档(entity_raw),不动 visits 表——到访本身还在,只是「认不出身份」了。
// 这正是「拿一份别的数据集的包来换底座」时站内看到的样子。
func scrambleVisitUUIDs(t *testing.T, f *movementFixture, only int) {
t.Helper()
fake := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef"))
q := `UPDATE entity_raw SET raw = jsonb_set(raw, '{ZIDENTIFIER_,$blob}', to_jsonb($2::text))
WHERE dataset_id=$1 AND entity='ZVISIT' AND raw->'ZIDENTIFIER_' IS NOT NULL`
if only > 0 {
q += ` AND src_pk IN (SELECT src_pk FROM entity_raw WHERE dataset_id=$1 AND entity='ZVISIT'
AND raw->'ZIDENTIFIER_' IS NOT NULL ORDER BY src_pk LIMIT $3)`
if _, err := f.pg.ExecContext(f.ctx, q, f.dsID, fake, only); err != nil {
t.Fatalf("改写留档 UUID 失败: %v", err)
}
return
}
if _, err := f.pg.ExecContext(f.ctx, q, f.dsID, fake); err != nil {
t.Fatalf("改写留档 UUID 失败: %v", err)
}
}
// TestSwapBaselineRebasesExportAndKeepsData 换底座成功路径:
// 底座文件真的换了、旧底座留档、记档写入、站点数据一行不动、导出内容逐条不变。
func TestSwapBaselineRebasesExportAndKeepsData(t *testing.T) {
f := newMovementFixture(t)
ctx, dsID, srv := f.ctx, f.dsID, f.srv
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("准备底座失败: %v", err)
}
base := srv.baselinePath(dsID)
oldSHA := baselineSHA(t, base)
visitsBefore, placesBefore := siteCounts(t, f)
var arrivalBefore time.Time
if err := f.pg.QueryRowContext(ctx, `SELECT arrival FROM visits WHERE dataset_id=$1
ORDER BY src_pk LIMIT 1`, dsID).Scan(&arrivalBefore); err != nil {
t.Fatalf("取样本到访失败: %v", err)
}
// 拿「换底座之前导出的那一份包」当新底座。先记下它的内容,换完再对一遍。
blob := f.export(t)
edbBefore := openExport(t, blob)
visitsInExport := countExportAll(t, edbBefore)
uuidsBefore := exportVisitUUIDs(t, edbBefore)
pkg, sha, _ := baselinePackageFile(t, "new-base.rondbackup", blob)
rep, err := srv.swapBaseline(ctx, dsID, pkg, "new-base.rondbackup", sha, false)
if err != nil {
t.Fatalf("换底座失败: %v", err)
}
if rep.WithUUID < minOverlapVisits {
t.Fatalf("样本量太小(%d 条带 UUID 的到访),用例前提不成立", rep.WithUUID)
}
// 同一批数据的包应当条条对得上:对不上就说明这条判据本身有问题
if rep.Matched != rep.WithUUID {
t.Errorf("同一批数据的包应当全部对上,实际 %d/%d", rep.Matched, rep.WithUUID)
}
if !rep.Replaced {
t.Error("原本已有底座,应当报告为「替换」而不是「首次建立」")
}
// ① 底座文件真的换了,旧的那份进了留档
if got := baselineSHA(t, base); got == oldSHA {
t.Error("底座文件没变,替换没生效")
}
if rep.Backup == "" {
t.Fatal("没有留档旧底座")
}
if got := baselineSHA(t, rep.Backup); got != oldSHA {
t.Errorf("留档的不是旧底座:%s != %s", got, oldSHA)
}
// ② 记档
name, recSHA, at := baselineRecord(t, f)
if name != "new-base.rondbackup" || recSHA != sha || !at.Valid {
t.Errorf("记档不对:name=%q sha=%q at=%v", name, recSHA, at)
}
// ③ 站点数据一行没动
visitsAfter, placesAfter := siteCounts(t, f)
if visitsAfter != visitsBefore || placesAfter != placesBefore {
t.Errorf("换底座动了站点数据:到访 %d→%d、地点 %d→%d",
visitsBefore, visitsAfter, placesBefore, placesAfter)
}
var arrivalAfter time.Time
if err := f.pg.QueryRowContext(ctx, `SELECT arrival FROM visits WHERE dataset_id=$1
ORDER BY src_pk LIMIT 1`, dsID).Scan(&arrivalAfter); err != nil {
t.Fatalf("取样本到访失败: %v", err)
}
if !arrivalAfter.Equal(arrivalBefore) {
t.Errorf("样本到访的到达时间被改了:%v → %v", arrivalBefore, arrivalAfter)
}
// ④ 导出的内容逐条不变:换了编号空间,身份(UUID)不该动。
// 只要有一行在新底座里认不出身份,它就会被按「新行」补一个派生 UUID 插到最大编号之后,
// 这里立刻会亮。
edbAfter := openExport(t, f.export(t))
if n := countExportAll(t, edbAfter); n != visitsInExport {
t.Errorf("换底座改变了导出包的到访数:%d → %d", visitsInExport, n)
}
if got := exportVisitUUIDs(t, edbAfter); !sameStrings(got, uuidsBefore) {
t.Errorf("换底座改变了导出包里到访的身份(%d 条 → %d 条),说明有行在新底座里没对上",
len(uuidsBefore), len(got))
}
}
// TestEnsureBaselinePrefersRecordedPackage 底座文件丢了要按记档重建。
//
// 这是换底座必须配的一环:datasets.sha256 指的是**最初**那份包,
// 只认它的话,底座一丢就会被悄悄重建回旧包——站长上一次换基线白做,且毫无提示。
func TestEnsureBaselinePrefersRecordedPackage(t *testing.T) {
f := newMovementFixture(t)
ctx, dsID, srv := f.ctx, f.dsID, f.srv
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("准备底座失败: %v", err)
}
base := srv.baselinePath(dsID)
pkg, sha, size := baselinePackageFile(t, "new-base.rondbackup", f.export(t))
// 换底座时上传件会收进 uploads,重建就靠它
if _, err := srv.stashUpload(pkg, "new-base.rondbackup", sha, size); err != nil {
t.Fatalf("收下上传件失败: %v", err)
}
stored, err := srv.findUploadBySha(sha, 0)
if err != nil {
t.Fatalf("收下的包找不回来: %v", err)
}
t.Cleanup(func() { os.Remove(stored) })
if _, err := srv.swapBaseline(ctx, dsID, stored, "new-base.rondbackup", sha, false); err != nil {
t.Fatalf("换底座失败: %v", err)
}
// 把「最初那份包」的摘要指向一个不存在的值,逼出「按 datasets.sha256 找原始包」必然失败
if _, err := f.pg.ExecContext(ctx, `UPDATE datasets SET sha256=$2 WHERE id=$1`,
dsID, strings.Repeat("0", 64)); err != nil {
t.Fatalf("改写数据集摘要失败: %v", err)
}
if err := os.Remove(base); err != nil {
t.Fatalf("删底座失败: %v", err)
}
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("应当按记档的那份包重建底座,实际报错: %v", err)
}
if _, err := os.Stat(base); err != nil {
t.Error("底座没有被重建")
}
// 反证:把记档也清掉,同一条路就走不通了 —— 说明上面那次成功确实是记档起的作用,
// 不是别的兜底把用例弄绿了。
if _, err := f.pg.ExecContext(ctx, `UPDATE datasets SET baseline_sha256=NULL, baseline_name=NULL
WHERE id=$1`, dsID); err != nil {
t.Fatalf("清记档失败: %v", err)
}
if err := os.Remove(base); err != nil {
t.Fatalf("删底座失败: %v", err)
}
if _, err := srv.ensureBaseline(ctx, dsID); err == nil {
t.Error("记档清掉后不该还能重建(原始包已不可用)")
}
}
// TestSwapBaselineAsksBeforePartialMismatch 重合率偏低时先问一句;
// 站长点了确认(force)才放行。
func TestSwapBaselineAsksBeforePartialMismatch(t *testing.T) {
f := newMovementFixture(t)
ctx, dsID, srv := f.ctx, f.dsID, f.srv
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("准备底座失败: %v", err)
}
base := srv.baselinePath(dsID)
// 先导出(此时包里的 UUID 还是真的),再把大部分站内到访的身份改掉
pkg, sha, _ := baselinePackageFile(t, "partial.rondbackup", f.export(t))
visitsBefore, _ := siteCounts(t, f)
scrambleVisitUUIDs(t, f, visitsBefore*7/10)
oldSHA := baselineSHA(t, base)
_, err := srv.swapBaseline(ctx, dsID, pkg, "partial.rondbackup", sha, false)
var mism *errBaselineMismatch
if err == nil {
t.Fatal("重合率不到一半,不该直接放行")
}
if !errors.As(err, &mism) || mism.zero {
t.Fatalf("应当是「需确认」而不该是「一条都对不上」: %v", err)
}
if baselineSHA(t, base) != oldSHA {
t.Error("被拦下了却已经把底座换了")
}
if _, recSHA, _ := baselineRecord(t, f); recSHA != "" {
t.Error("被拦下了却写了记档")
}
// 站长确认后放行
rep, err := srv.swapBaseline(ctx, dsID, pkg, "partial.rondbackup", sha, true)
if err != nil {
t.Fatalf("确认后仍无法替换: %v", err)
}
if rep.Replaced && baselineSHA(t, base) == oldSHA {
t.Error("确认后底座文件仍然没变")
}
if _, recSHA, _ := baselineRecord(t, f); recSHA != sha {
t.Error("确认替换后没写记档")
}
}
// TestSwapBaselineRefusesTotallyUnrelated 一条都对不上时,连「强制」都不给过。
// 这种情况下换下去没有任何一处能对齐,等于把整个导出包搬到另一个编号空间上。
func TestSwapBaselineRefusesTotallyUnrelated(t *testing.T) {
f := newMovementFixture(t)
ctx, dsID, srv := f.ctx, f.dsID, f.srv
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("准备底座失败: %v", err)
}
base := srv.baselinePath(dsID)
pkg, sha, _ := baselinePackageFile(t, "foreign.rondbackup", f.export(t))
scrambleVisitUUIDs(t, f, 0) // 全部
oldSHA := baselineSHA(t, base)
for _, force := range []bool{false, true} {
_, err := srv.swapBaseline(ctx, dsID, pkg, "foreign.rondbackup", sha, force)
var mism *errBaselineMismatch
if err == nil {
t.Fatalf("force=%v 时一份完全对不上的包也不该被接受", force)
}
if !errors.As(err, &mism) || !mism.zero {
t.Fatalf("force=%v 时应当判定为「一条都对不上」: %v", force, err)
}
}
if baselineSHA(t, base) != oldSHA {
t.Error("被拒绝却已经把底座换了")
}
if _, recSHA, _ := baselineRecord(t, f); recSHA != "" {
t.Error("被拒绝却写了记档")
}
}
// TestSwapBaselineRejectsNonPackage 不是备份包的文件要在第一步就被挡住:
// 生成底座失败 ⇒ 既不换文件也不写记档,数据集保持原样可用。
func TestSwapBaselineRejectsNonPackage(t *testing.T) {
f := newMovementFixture(t)
ctx, dsID, srv := f.ctx, f.dsID, f.srv
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("准备底座失败: %v", err)
}
base := srv.baselinePath(dsID)
oldSHA := baselineSHA(t, base)
junk := filepath.Join(t.TempDir(), "junk.rondbackup")
if err := os.WriteFile(junk, []byte("这不是备份包"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := srv.swapBaseline(ctx, dsID, junk, "junk.rondbackup", strings.Repeat("a", 64), true); err == nil {
t.Fatal("不是备份包却换成功了")
}
if baselineSHA(t, base) != oldSHA {
t.Error("被拒绝却已经把底座换了")
}
if _, recSHA, _ := baselineRecord(t, f); recSHA != "" {
t.Error("被拒绝却写了记档")
}
}
// TestRollbackBaselineRestoresPrevious 回滚要把旧底座换回来、记档清空,
// 并且当前这份也要留档(回滚本身也得可逆)。
func TestRollbackBaselineRestoresPrevious(t *testing.T) {
f := newMovementFixture(t)
ctx, dsID, srv := f.ctx, f.dsID, f.srv
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("准备底座失败: %v", err)
}
base := srv.baselinePath(dsID)
oldSHA := baselineSHA(t, base)
pkg, sha, _ := baselinePackageFile(t, "new-base.rondbackup", f.export(t))
if _, err := srv.swapBaseline(ctx, dsID, pkg, "new-base.rondbackup", sha, false); err != nil {
t.Fatalf("换底座失败: %v", err)
}
newSHA := baselineSHA(t, base)
if newSHA == oldSHA {
t.Fatal("换底座没生效,后续断言没有意义")
}
if _, err := srv.rollbackBaseline(ctx, dsID); err != nil {
t.Fatalf("回滚失败: %v", err)
}
if got := baselineSHA(t, base); got != oldSHA {
t.Errorf("回滚后底座不是原来那份:%s != %s", got, oldSHA)
}
if _, recSHA, at := baselineRecord(t, f); recSHA != "" || at.Valid {
t.Error("回滚后应当清空记档(表示来源是数据集最初那份包)")
}
// 被回滚掉的那份也要留档,否则滚错了就回不去
st, err := srv.baselineStatusOf(ctx, dsID)
if err != nil {
t.Fatalf("读底座状态失败: %v", err)
}
found := false
for _, b := range st.Backups {
if baselineSHA(t, filepath.Join(filepath.Dir(base), b.Name)) == newSHA {
found = true
}
}
if !found {
t.Error("回滚时没有把当前这份也留档,滚错了就回不去了")
}
}
// TestBaselineUploadHandlerSwapsAndRecords 走一次真实的「上传并换底座」处理函数:
// 303 回页面带提示、底座真的换了、上传件留在 uploads(以后底座丢了要按摘要找回它)。
func TestBaselineUploadHandlerSwapsAndRecords(t *testing.T) {
f := newMovementFixture(t)
ctx, dsID, srv := f.ctx, f.dsID, f.srv
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("准备底座失败: %v", err)
}
base := srv.baselinePath(dsID)
oldSHA := baselineSHA(t, base)
blob := f.export(t)
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("backup", "new-base.rondbackup")
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write(blob); err != nil {
t.Fatal(err)
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/admin/baseline", &buf).WithContext(ctx)
req.Header.Set("Content-Type", mw.FormDataContentType())
srv.adminBaselineUpload(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("应当 303 回页面,实际 %d:%s", rec.Code, rec.Body.String())
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, "/admin/baseline?ok=") {
t.Fatalf("应当带着成功提示回页面,实际 Location=%q", loc)
}
if baselineSHA(t, base) == oldSHA {
t.Error("处理函数跑完了但底座没换")
}
var stored string
if err := f.pg.QueryRowContext(ctx, `SELECT COALESCE(baseline_sha256,'') FROM datasets WHERE id=$1`, dsID).
Scan(&stored); err != nil {
t.Fatalf("读记档失败: %v", err)
}
if len(stored) != 64 {
t.Fatalf("记档里的摘要不对: %q", stored)
}
path, err := srv.findUploadBySha(stored, 0)
if err != nil {
t.Fatalf("上传的包没留在 uploads,底座丢了就重建不出来: %v", err)
}
t.Cleanup(func() { os.Remove(path) })
}
// TestBaselinePageRenders 页面能渲染到底(含尾部 </html>)。
//
// render 是流式直写,模板里的字段错(比如把 *time.Time 传给只认 time.Time 的函数)
// 会在写到一半时中断:HTTP 200 + 一个「页脚之后整段不存在」的页面,服务端一声不吭。
// 所以既要断言 200,也要断言动作之后的字和收尾标签。
func TestBaselinePageRenders(t *testing.T) {
f := newMovementFixture(t)
ctx, srv := f.ctx, f.srv
if _, err := srv.ensureBaseline(ctx, f.dsID); err != nil {
t.Fatalf("准备底座失败: %v", err)
}
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/admin/baseline", nil).WithContext(ctx)
srv.adminBaselinePage(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("页面状态码 %d:%s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, want := range []string{"当前底座", "换成另一份包当底座", "旧底座留档"} {
if !strings.Contains(body, want) {
t.Errorf("页面里没有「%s」", want)
}
}
if !strings.HasSuffix(strings.TrimSpace(body), "</html>") {
tail := body
if len(tail) > 200 {
tail = tail[len(tail)-200:]
}
t.Errorf("页面没渲染到底(尾部不是 </html>),多半是模板中途报错了:…%s", tail)
}
}