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" "rond-with-you/internal/rond" ) // ---------- 换基线 ---------- // // 这些用例守的是「底座换代」这件事的几条底线: // 站点数据一行不动、底座文件真的换了、旧底座留档可回滚、记档能撑住重建、 // 以及**不能把一份不相干的包换进来**(那是唯一会让导出包整个错位的操作)。 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 页面能渲染到底(含尾部 )。 // // 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), "") { tail := body if len(tail) > 200 { tail = tail[len(tail)-200:] } t.Errorf("页面没渲染到底(尾部不是 ),多半是模板中途报错了:…%s", tail) } } // TestSwapBaselineDoesNotCarryRowsTheSiteDoesNotHave 守一条反直觉的语义: // 新底座里那些站点还没有的到访**不会**进导出包(导出的行集合以站点数据为准)。 // // 站长很容易以为「换了更新的手机包 ⇒ 导出的包里就有手机上的新记录」,所以提示文案里 // 必须点出这一点;而这条语义本身也不能只靠文案保证——下面用真数据验一遍。 func TestSwapBaselineDoesNotCarryRowsTheSiteDoesNotHave(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) } uuidsBefore := exportVisitUUIDs(t, openExport(t, f.export(t))) // 先在站内补一条到访(没有留档 ⇒ 导出时会补一个本站派生的确定性 UUID), // 导出成一份「比站内多一条」的包,再把这条从站内删掉:新底座里就留下了一条 // 站点没有的行,正是要验证的情形。 seed := pickDupBase(t, srv, ctx, dsID) extra, err := srv.nextSrcPK(ctx, f.pg, dsID, visitSeq) if err != nil { t.Fatal(err) } if err := clearStaleRaw(ctx, f.pg, dsID, "ZVISIT", extra); err != nil { t.Fatal(err) } if _, err := f.pg.ExecContext(ctx, `INSERT INTO visits (dataset_id, src_pk, place_id, activity_id, arrival, departure, duration_min) SELECT dataset_id, $3, place_id, activity_id, arrival, departure, duration_min FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, seed.srcPK, extra); err != nil { t.Fatal(err) } pkg, sha, _ := baselinePackageFile(t, "newer.rondbackup", f.export(t)) if _, err := f.pg.ExecContext(ctx, `DELETE FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, extra); err != nil { t.Fatal(err) } rep, err := srv.swapBaseline(ctx, dsID, pkg, "newer.rondbackup", sha, false) if err != nil { t.Fatalf("换底座失败: %v", err) } if baselineCountValue(rep.Counts, "ZVISIT") <= rep.SiteVisits { t.Fatalf("用例前提不成立:新底座应当比站内多出到访,实际底座 %d / 站内 %d", baselineCountValue(rep.Counts, "ZVISIT"), rep.SiteVisits) } if !strings.Contains(baselineSwapMessage(rep, "数据集"), "不会进导出包") { t.Error("提示里没有点出「底座里多出来的到访不会进导出包」") } after := exportVisitUUIDs(t, openExport(t, f.export(t))) if !sameStrings(after, uuidsBefore) { t.Errorf("换到「更新」的底座后导出内容变了(%d 条 → %d 条)", len(uuidsBefore), len(after)) } want := rond.DerivedVisitUUID(extra) for _, u := range after { if u == want { t.Errorf("那条只在新底座里的到访(%s)被带进了导出包", u) } } }