导出是「底座为底 + 只覆盖托管表」,所以底座必须是手机导出的包。底座跟着数据集走, 手机重新导出后想换代,以前只能整包重新导入成新数据集(站点补录的行、主题、备注都得重收拾)。 新动作 /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。
501 lines
18 KiB
Go
501 lines
18 KiB
Go
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)
|
||
}
|
||
}
|