备份(.rondbackup)与迷雾(.fwss)各加一层「先看差异再动手」: - 迷雾:上传先出差异页,说清叠加能补多少、整体替换会丢多少,并在真实底图上 用绿/灰/红标出会新增 / 不变 / 会丢的区域 - 备份:与当前数据集按 UUID 逐条比对(rond 每次导出都会重排 Z_PK,编号认不出人), 新增合入、冲突逐条选、仅站内有逐条勾选删除,全在一个事务里执行; 合入的行写回 entity_raw,保证再传同一份备份时差异归零 - 地点按坐标对齐:站点的「补全地址」会把地点改名,拿名称当键会让一整个地点的 到访全变成假冲突 - 附属数据(备注/结论/访客票/图片)从 dataset_id+src_pk 换成坐标键,换数据集不再失联; 访客票没有 dataset_id,回填按活跃数据集认领 - 修正 contentHash 拿 Z_PK 当指纹的隐患,以及两处「src_pk 跨快照稳定」的错误注释 导出格式(.rondbackup / .fwss)保持原样,往返测试通过。
274 lines
11 KiB
Go
274 lines
11 KiB
Go
package web
|
||
|
||
import (
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// mergeBackup 走真实的合并处理函数;失败(重定向里带 err=)直接判错。
|
||
func mergeBackup(t *testing.T, f *movementFixture, token, body string) {
|
||
t.Helper()
|
||
if loc := mergeBackupAllowErr(t, f, token, body); strings.Contains(loc, "err=") {
|
||
t.Fatalf("合并失败: %s", loc)
|
||
}
|
||
}
|
||
|
||
// mergeBackupAllowErr 同上,但把落点交出来,供「预期会失败」的用例断言。
|
||
func mergeBackupAllowErr(t *testing.T, f *movementFixture, token, body string) string {
|
||
t.Helper()
|
||
rec := httptest.NewRecorder()
|
||
req := httptest.NewRequest("POST", "/admin/preview/"+token+"/merge", strings.NewReader(body))
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
req.SetPathValue("token", token)
|
||
f.srv.adminBackupMerge(rec, req.WithContext(f.ctx))
|
||
if rec.Code != http.StatusSeeOther {
|
||
t.Fatalf("合并应跳回后台: code=%d body=%s", rec.Code, rec.Body.String())
|
||
}
|
||
return rec.Header().Get("Location")
|
||
}
|
||
|
||
// addExtraVisit 造一条「仅站内有」的到访:没有留档,于是没有 UUID,比对时必然落在仅站内有里。
|
||
func (f *movementFixture) addExtraVisit(t *testing.T, pk int, remark string) {
|
||
t.Helper()
|
||
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO visits
|
||
(dataset_id, src_pk, arrival, departure, is_user_added, remark)
|
||
VALUES ($1,$2,now(),now(),true,$3)`, f.dsID, pk, remark); err != nil {
|
||
t.Fatalf("插入补录到访失败: %v", err)
|
||
}
|
||
}
|
||
|
||
func (f *movementFixture) scalar(t *testing.T, q string, args ...any) int {
|
||
t.Helper()
|
||
var n int
|
||
if err := f.pg.QueryRowContext(f.ctx, q, args...).Scan(&n); err != nil {
|
||
t.Fatalf("查询失败: %v", err)
|
||
}
|
||
return n
|
||
}
|
||
|
||
// 合并把备份里多出来的记录写进去,而且写完要「复原」——同一份备份再比一次必须一条不差。
|
||
//
|
||
// 这条测试同时钉住三件事:
|
||
// - 新增行用 nextSrcPK 发新号,且**写进 entity_raw**(否则下次比对认不出 UUID);
|
||
// - 位移/天气的外键指向库里的新编号(写错就成孤儿);
|
||
// - 合并是幂等的:再传同一份备份,差异归零。
|
||
func TestBackupMergeRestoresMissingVisits(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
|
||
visitCount := func() int {
|
||
return f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1`, f.dsID)
|
||
}
|
||
all := visitCount()
|
||
movCount := f.scalar(t, `SELECT count(*) FROM movements WHERE dataset_id=$1`, f.dsID)
|
||
weatherCount := f.scalar(t, `SELECT count(*) FROM weather WHERE dataset_id=$1`, f.dsID)
|
||
// 原始数据里就有指向「因缺到达时间而没入库」的到访的位移,先记下基线再比
|
||
orphanQ := `SELECT count(*) FROM movements m WHERE m.dataset_id=$1
|
||
AND (m.from_visit_src IS NULL OR NOT EXISTS (
|
||
SELECT 1 FROM visits v WHERE v.dataset_id=$1 AND v.src_pk=m.from_visit_src))`
|
||
orphans0 := f.scalar(t, orphanQ, f.dsID)
|
||
|
||
// 制造差异:按**站点删到访的方式**删掉最早的两条(见 editVisitDelete)——
|
||
// 位移删掉、天气的归属置空、到访删掉。直接 DELETE visits 会留下孤儿天气,
|
||
// 那是测试手法造成的,不是产品行为。
|
||
var pk1, pk2 int
|
||
if err := f.pg.QueryRowContext(f.ctx, `SELECT
|
||
(SELECT src_pk FROM visits WHERE dataset_id=$1 ORDER BY arrival LIMIT 1),
|
||
(SELECT src_pk FROM visits WHERE dataset_id=$1 ORDER BY arrival OFFSET 1 LIMIT 1)`,
|
||
f.dsID).Scan(&pk1, &pk2); err != nil {
|
||
t.Fatalf("取待删到访失败: %v", err)
|
||
}
|
||
for _, q := range []string{
|
||
`DELETE FROM movements WHERE dataset_id=$1 AND (from_visit_src IN ($2,$3) OR to_visit_src IN ($2,$3))`,
|
||
`UPDATE weather SET visit_src=NULL WHERE dataset_id=$1 AND visit_src IN ($2,$3)`,
|
||
`DELETE FROM visits WHERE dataset_id=$1 AND src_pk IN ($2,$3)`,
|
||
} {
|
||
if _, err := f.pg.ExecContext(f.ctx, q, f.dsID, pk1, pk2); err != nil {
|
||
t.Fatalf("预置删除失败: %v", err)
|
||
}
|
||
}
|
||
if got := visitCount(); got != all-2 {
|
||
t.Fatalf("预置失败:应有 %d 条,实际 %d", all-2, got)
|
||
}
|
||
|
||
token, page := previewBackupToken(t, f, f.src)
|
||
if a, _, _ := visitCounts(t, page); a != 2 {
|
||
t.Fatalf("差异页应报 2 条新增,实际 %d", a)
|
||
}
|
||
|
||
mergeBackup(t, f, token, "add_new=1")
|
||
|
||
if got := visitCount(); got != all {
|
||
t.Fatalf("合并后到访数应回到 %d,实际 %d", all, got)
|
||
}
|
||
// 新补进来的那两条必须各有一份留档(下次比对就靠它认 UUID)。
|
||
// 被删掉的行会留下孤儿留档——那是老数据,不影响,所以只查新编号。
|
||
if got := f.scalar(t, `SELECT count(*) FROM entity_raw er
|
||
WHERE er.dataset_id=$1 AND er.entity='ZVISIT' AND EXISTS (
|
||
SELECT 1 FROM visits v WHERE v.dataset_id=$1 AND v.src_pk=er.src_pk)`, f.dsID); got != all {
|
||
t.Errorf("每条在库的到访都该有原始行留档,实际 %d / %d", got, all)
|
||
}
|
||
if got := f.scalar(t, `SELECT count(*) FROM movements WHERE dataset_id=$1`, f.dsID); got != movCount {
|
||
t.Errorf("位移应恢复到 %d,实际 %d", movCount, got)
|
||
}
|
||
if got := f.scalar(t, `SELECT count(*) FROM weather WHERE dataset_id=$1`, f.dsID); got != weatherCount {
|
||
t.Errorf("天气应恢复到 %d,实际 %d", weatherCount, got)
|
||
}
|
||
// 合并不该新增孤儿位移(原始数据里本来就有一些,见 orphanQ 的注释)
|
||
if orphans := f.scalar(t, orphanQ, f.dsID); orphans > orphans0 {
|
||
t.Errorf("孤儿位移变多了:%d → %d", orphans0, orphans)
|
||
}
|
||
|
||
// 幂等:再比一次应当完全一致
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 冲突逐条选「用实机」就把备份的值写进去,选「用站内」就原样不动。
|
||
func TestBackupMergeConflictChoice(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)
|
||
}
|
||
// 这条的 UUID 就是页面上的冲突键
|
||
var b64 string
|
||
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(raw #>> '{ZIDENTIFIER_,$blob}','')
|
||
FROM entity_raw WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`, f.dsID, pk).Scan(&b64); err != nil {
|
||
t.Fatalf("取 UUID 失败: %v", err)
|
||
}
|
||
uuid := uuidFromBase64(b64)
|
||
if uuid == "" {
|
||
t.Fatal("这条到访应当有 UUID")
|
||
}
|
||
|
||
remark := func() string {
|
||
var s string
|
||
if err := f.pg.QueryRowContext(f.ctx, `SELECT COALESCE(remark,'') FROM visits
|
||
WHERE dataset_id=$1 AND src_pk=$2`, f.dsID, pk).Scan(&s); err != nil {
|
||
t.Fatalf("读备注失败: %v", err)
|
||
}
|
||
return s
|
||
}
|
||
|
||
// 用站内:备注保持站里那份
|
||
token, _ := previewBackupToken(t, f, f.src)
|
||
mergeBackup(t, f, token, "vc_"+uuid+"=db")
|
||
if got := remark(); got != "站内改过的备注" {
|
||
t.Fatalf("选「用站内」不该改动,实际 %q", got)
|
||
}
|
||
|
||
// 用实机:备注被备份里的值(空)覆盖回去
|
||
token, _ = previewBackupToken(t, f, f.src)
|
||
mergeBackup(t, f, token, "vc_"+uuid+"=backup")
|
||
if got := remark(); got != "" {
|
||
t.Fatalf("选「用实机」应覆盖成备份里的值,实际 %q", got)
|
||
}
|
||
}
|
||
|
||
// 什么都不勾、冲突全跳过时,合并应当什么都不写。
|
||
func TestBackupMergeNoopKeepsData(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
before := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1`, f.dsID)
|
||
|
||
token, _ := previewBackupToken(t, f, f.src)
|
||
mergeBackup(t, f, token, "add_new=0")
|
||
|
||
if got := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1`, f.dsID); got != before {
|
||
t.Fatalf("没勾任何改动却动了数据: %d → %d", before, got)
|
||
}
|
||
}
|
||
|
||
// 删除必须「勾了才删」:没勾的一个都不动,勾了的那条(连带它的位移)才消失。
|
||
func TestBackupMergeDeletesOnlyChecked(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
visitCount := func() int {
|
||
return f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1`, f.dsID)
|
||
}
|
||
before := visitCount()
|
||
|
||
f.addExtraVisit(t, 900001, "稍后删掉")
|
||
f.addExtraVisit(t, 900002, "留着不动")
|
||
if got := visitCount(); got != before+2 {
|
||
t.Fatalf("预置失败: %d", got)
|
||
}
|
||
|
||
// 只勾一条
|
||
token, page := previewBackupToken(t, f, f.src)
|
||
if _, g, _ := visitCounts(t, page); g != 2 {
|
||
t.Fatalf("差异页应报 2 条仅站内有,实际 %d", g)
|
||
}
|
||
mergeBackup(t, f, token, "dv_900001=1")
|
||
|
||
if got := visitCount(); got != before+1 {
|
||
t.Fatalf("应只删掉勾选的那条,实际剩 %d(原 %d)", got, before)
|
||
}
|
||
if n := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND src_pk=900002`, f.dsID); n != 1 {
|
||
t.Error("没勾的那条不该被动")
|
||
}
|
||
// 删掉的到访不该留下留档
|
||
if n := f.scalar(t, `SELECT count(*) FROM entity_raw WHERE dataset_id=$1
|
||
AND entity='ZVISIT' AND src_pk=900001`, f.dsID); n != 0 {
|
||
t.Error("删除到访时要连它的原始行留档一起清掉")
|
||
}
|
||
|
||
// 再合并一次、一条都不勾:剩下那条必须还在
|
||
token, _ = previewBackupToken(t, f, f.src)
|
||
mergeBackup(t, f, token, "add_new=0")
|
||
if got := visitCount(); got != before+1 {
|
||
t.Fatalf("不勾就不该删,实际 %d", got)
|
||
}
|
||
}
|
||
|
||
// 名下有到访的地点必须拒绝删除——那个外键是 ON DELETE CASCADE,
|
||
// 真删下去会把到访一起带走。拒绝后还要整体回滚,别留下半截。
|
||
func TestBackupMergeRefusesDeletingPlaceInUse(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
visitCount := func() int {
|
||
return f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1`, f.dsID)
|
||
}
|
||
beforeVisits := visitCount()
|
||
|
||
// 一个没有到访的空地点:允许删
|
||
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon)
|
||
VALUES ($1, 910001, '临时空地点', 30.0, 120.0)`, f.dsID); err != nil {
|
||
t.Fatalf("插入地点失败: %v", err)
|
||
}
|
||
// 一个有到访的地点:必须拒绝
|
||
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon)
|
||
VALUES ($1, 910002, '有到访的地点', 31.0, 121.0)`, f.dsID); err != nil {
|
||
t.Fatalf("插入地点失败: %v", err)
|
||
}
|
||
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO visits (dataset_id, src_pk, place_id, arrival, departure)
|
||
VALUES ($1, 910003, (SELECT id FROM places WHERE dataset_id=$1 AND src_pk=910002), now(), now())`,
|
||
f.dsID); err != nil {
|
||
t.Fatalf("插入到访失败: %v", err)
|
||
}
|
||
|
||
// 先删空地点:应当成功
|
||
token, _ := previewBackupToken(t, f, f.src)
|
||
mergeBackup(t, f, token, "dp_910001=1")
|
||
if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND src_pk=910001`, f.dsID); n != 0 {
|
||
t.Fatal("空地点应当被删掉")
|
||
}
|
||
|
||
// 再删有到访的地点:应当被拒绝
|
||
token, _ = previewBackupToken(t, f, f.src)
|
||
loc := mergeBackupAllowErr(t, f, token, "dp_910002=1")
|
||
if !strings.Contains(loc, "err=") {
|
||
t.Fatal("名下有到访的地点应当拒绝删除")
|
||
}
|
||
if n := f.scalar(t, `SELECT count(*) FROM places WHERE dataset_id=$1 AND src_pk=910002`, f.dsID); n != 1 {
|
||
t.Error("被拒绝后地点不该消失")
|
||
}
|
||
if got := visitCount(); got != beforeVisits+1 {
|
||
t.Errorf("被拒绝后到访数不该变,实际 %d(期望 %d)", got, beforeVisits+1)
|
||
}
|
||
}
|