备份(.rondbackup)与迷雾(.fwss)各加一层「先看差异再动手」: - 迷雾:上传先出差异页,说清叠加能补多少、整体替换会丢多少,并在真实底图上 用绿/灰/红标出会新增 / 不变 / 会丢的区域 - 备份:与当前数据集按 UUID 逐条比对(rond 每次导出都会重排 Z_PK,编号认不出人), 新增合入、冲突逐条选、仅站内有逐条勾选删除,全在一个事务里执行; 合入的行写回 entity_raw,保证再传同一份备份时差异归零 - 地点按坐标对齐:站点的「补全地址」会把地点改名,拿名称当键会让一整个地点的 到访全变成假冲突 - 附属数据(备注/结论/访客票/图片)从 dataset_id+src_pk 换成坐标键,换数据集不再失联; 访客票没有 dataset_id,回填按活跃数据集认领 - 修正 contentHash 拿 Z_PK 当指纹的隐患,以及两处「src_pk 跨快照稳定」的错误注释 导出格式(.rondbackup / .fwss)保持原样,往返测试通过。
165 lines
5.6 KiB
Go
165 lines
5.6 KiB
Go
package web
|
|
|
|
import (
|
|
"bytes"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// previewBackup 走真实的上传处理函数:POST → 303 → GET 差异页,返回渲染结果。
|
|
func previewBackup(t *testing.T, f *movementFixture, path string) string {
|
|
t.Helper()
|
|
_, page := previewBackupToken(t, f, path)
|
|
return page
|
|
}
|
|
|
|
// previewBackupToken 同上,但把令牌也交出来,供后续的合并步骤使用。
|
|
func previewBackupToken(t *testing.T, f *movementFixture, path string) (string, string) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("读取 %s 失败: %v", path, err)
|
|
}
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
fw, err := mw.CreateFormFile("backup", filepath.Base(path))
|
|
if err != nil {
|
|
t.Fatalf("构造上传表单失败: %v", err)
|
|
}
|
|
if _, err := fw.Write(data); err != nil {
|
|
t.Fatalf("写入上传内容失败: %v", err)
|
|
}
|
|
mw.Close()
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest("POST", "/admin/preview", &buf)
|
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
|
f.srv.adminBackupPreview(rec, req.WithContext(f.ctx))
|
|
if rec.Code != http.StatusSeeOther {
|
|
t.Fatalf("上传应跳转到差异页: code=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
loc := rec.Header().Get("Location")
|
|
token := strings.TrimPrefix(loc, "/admin/preview/")
|
|
if token == loc || len(token) != stashTokenLen {
|
|
t.Fatalf("差异页地址不对: %q", loc)
|
|
}
|
|
|
|
rec = httptest.NewRecorder()
|
|
req = httptest.NewRequest("GET", loc, nil).WithContext(f.ctx)
|
|
req.SetPathValue("token", token)
|
|
f.srv.adminBackupPreviewPage(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("差异页渲染失败: code=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
return token, rec.Body.String()
|
|
}
|
|
|
|
// visitCounts 从概览表里取出「到访」那一行的 (备份新增, 仅站内有, 冲突)。
|
|
// 不能全页 Contains 数字——地点行也有同样的 data-label,会张冠李戴。
|
|
func visitCounts(t *testing.T, page string) (added, gone, clash int) {
|
|
t.Helper()
|
|
row := regexp.MustCompile(`(?s)<td>到访</td>(.*?)</tr>`).FindStringSubmatch(page)
|
|
if row == nil {
|
|
t.Fatal("概览表里找不到「到访」行")
|
|
}
|
|
nums := regexp.MustCompile(`data-label="(备份新增|仅站内有|冲突)">(\d+)<`).FindAllStringSubmatch(row[1], -1)
|
|
if len(nums) != 3 {
|
|
t.Fatalf("到访行应有三个计数,实际 %d", len(nums))
|
|
}
|
|
for _, n := range nums {
|
|
v, _ := strconv.Atoi(n[2])
|
|
switch n[1] {
|
|
case "备份新增":
|
|
added = v
|
|
case "仅站内有":
|
|
gone = v
|
|
case "冲突":
|
|
clash = v
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// 同一份备份再传一次:UUID 对得上、字段也没变,到访应当一条不差。
|
|
//
|
|
// 这条也顺带钉住「按 UUID 而不是编号对齐」:库内那些行的编号与备份里完全不同
|
|
// (rond 重排过),只按编号比会让 800 多条全变冲突。
|
|
//
|
|
// 只断言到访,不断言「完全一致」整句:导入后站点会反查补全地点(改名称/地址),
|
|
// 地点层面因此真的存在差异,那是预期行为,不该由这个用例来管。
|
|
func TestBackupDiffIdentical(t *testing.T) {
|
|
f := newMovementFixture(t)
|
|
page := previewBackup(t, f, f.src)
|
|
if a, g, c := visitCounts(t, page); a != 0 || g != 0 || c != 0 {
|
|
t.Fatalf("同一份备份的到访不该有差异: 新增 %d 仅站内有 %d 冲突 %d", a, g, c)
|
|
}
|
|
}
|
|
|
|
// 站内改过备注、又补录了一条到访,差异页要把这两件事分别认出来。
|
|
//
|
|
// 这里的关键是「按 UUID 对齐」:库里那些行的 Z_PK 与备份里完全不同
|
|
// (rond 重排过),只按编号比会把 800 多条全判成冲突。
|
|
func TestBackupDiffDetectsSiteEdits(t *testing.T) {
|
|
f := newMovementFixture(t)
|
|
|
|
var pk int
|
|
if err := f.pg.QueryRowContext(f.ctx, `UPDATE visits SET remark='站点改过的备注'
|
|
WHERE id = (SELECT id FROM visits WHERE dataset_id=$1 ORDER BY src_pk LIMIT 1)
|
|
RETURNING src_pk`, f.dsID).Scan(&pk); err != nil {
|
|
t.Fatalf("改备注失败: %v", err)
|
|
}
|
|
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO visits
|
|
(dataset_id, src_pk, arrival, departure, is_user_added, remark)
|
|
VALUES ($1, 999999, now(), now(), true, '站点补录的一条')`, f.dsID); err != nil {
|
|
t.Fatalf("插入补录到访失败: %v", err)
|
|
}
|
|
|
|
page := previewBackup(t, f, f.src)
|
|
// 「站点改过的备注」只可能从冲突行的字段差异里渲染出来;「站内补录」是那条补录行的来源标记
|
|
for _, want := range []string{"备份差异预览", "站点改过的备注", "站点补录的一条", ">站内补录<"} {
|
|
if !strings.Contains(page, want) {
|
|
t.Errorf("差异页缺少 %q", want)
|
|
}
|
|
}
|
|
a, g, c := visitCounts(t, page)
|
|
if c != 1 {
|
|
t.Errorf("改过一条备注,到访冲突数应为 1,实际 %d", c)
|
|
}
|
|
if g != 1 {
|
|
t.Errorf("补录了一条,到访「仅站内有」应为 1,实际 %d", g)
|
|
}
|
|
if a != 0 {
|
|
t.Errorf("备份里没有多出来的到访,实际 %d", a)
|
|
}
|
|
}
|
|
|
|
// 差异页是只读的:渲染前后库里的行数必须一模一样。
|
|
func TestBackupDiffPreviewWritesNothing(t *testing.T) {
|
|
f := newMovementFixture(t)
|
|
|
|
count := func() (visits, places, raw 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),
|
|
(SELECT count(*) FROM entity_raw WHERE dataset_id=$1)`, f.dsID).
|
|
Scan(&visits, &places, &raw); err != nil {
|
|
t.Fatalf("统计失败: %v", err)
|
|
}
|
|
return
|
|
}
|
|
v0, p0, r0 := count()
|
|
previewBackup(t, f, f.src)
|
|
v1, p1, r1 := count()
|
|
if v0 != v1 || p0 != p1 || r0 != r1 {
|
|
t.Fatalf("差异页不该写库: 到访 %d→%d 地点 %d→%d 留档 %d→%d", v0, v1, p0, p1, r0, r1)
|
|
}
|
|
}
|