Files
rond-with-you/internal/web/backup_merge_test.go
T
tamakyi 10d23e03cf 合并:重复地点不再反复判「备份新增」,到访的活动改按身份比
- DiffPlaces 的 byCoord/byName 改为多值索引 + take() 逐条认领未用项:真机一个坐标下会有
  多条同名同坐标的地点(实测「柳州站」3 条),单槽位时同一坐标最多只配上 2 条,第 3 条
  永远判「备份新增」⇒ 每次合并都再插一条重复地点、差异永远归不了零(实测 368→438→439)。
  修完:地点新增 70→67、合并后再比对归零、再点合并直接「没有要写入的改动」。
- 到访的「活动」由按名称比改为按身份(ZUID_)比,两边都缺 UUID 才退回名称:16 个活动改名
  曾把 355 条到访全变成同因冲突,且逐条选「用实机」只写回 activity_id(映射回同一个活动)
  改不掉名字。loadStoredVisits 为此多 JOIN 一次 entity_raw 取 ZACTIVITY.ZUID_。
- 回归用例 4 条(重复地点一一配上、活动改名不算冲突、换活动仍要报、缺 UUID 退回名称)+
  「合并后地点差异归零」断言 3 处;两处改动都做过变异验证。
- 实测(09-28 备份并进 09-14 数据集):到访冲突 253→6,合并两次地点数 438→439 变为 435→435。
2026-09-28 19:31:21 +08:00

366 lines
16 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 (
"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);
// - 位移/天气的外键指向库里的新编号(写错就成孤儿);
// - 合并是幂等的:再传同一份备份,差异归零。
//
// 注意:这里库内编号与备份逐条相同(夹具导入的就是 f.src 本身,实测 823/823),
// 所以走的是「编号直接命中」的快路径。编号重排过的真实场景见
// TestBackupMergeRenumberedBackupRestoresOnlyMissing。
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)
}
if a, g, _ := overviewCounts(t, page, "地点"); a != 0 || g != 0 {
t.Fatalf("合并后再比对,地点应当归零,实际 新增 %d / 仅站内有 %d"+
"(重复地点会被反复当成「备份新增」→ 每合并一次多一条)", a, g)
}
}
// 「本地 + **后来新增的备份**」合并的真实形态:备份里的编号被 rond 全部重排过,
// 与库里的编号对不上,而备份里还多了几条新记录。
//
// 这条要钉住的是最贵的一种错:如果比对退回按编号对齐,那 800 多条老记录会被判成
// 「备份新增 + 仅站内有」,用户勾一下「并进来」就把整份足迹**复制一遍**(数量翻倍、
// 地图上每个点都重影)。必须在平移编号的前提下,差异仍然只有真正缺的那 2 条。
func TestBackupMergeRenumberedBackupRestoresOnlyMissing(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)
if all == 0 || movCount == 0 {
t.Skip("这份备份没有足够的到访/位移可测")
}
// ① 库里的编号整体平移:等价于「这批数据其实来自另一份编号完全不同的备份」
f.shiftSrcPKs(t, 500000)
// ② 再按**站点删到访的方式**删掉最早两条:备份里还有它们,所以差异应当正好是 2 条。
// 和平移前的用例一样,必须连带清位移、清天气归属,否则留下的是测试手法造成的孤儿。
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, g, c := visitCounts(t, page); a != 2 || g != 0 || c != 0 {
t.Fatalf("编号重排过、只缺 2 条 ⇒ 差异应当正好 2 条新增;实际 新增 %d 仅站内有 %d 冲突 %d"+
"(判成 800+ 就是把整份足迹当新记录,合并会复制一遍)", a, g, c)
}
mergeBackup(t, f, token, "add_new=1")
if got := visitCount(); got != all {
t.Fatalf("合并后到访数应回到 %d 且不重不漏,实际 %d", all, got)
}
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)
}
// 补进来的那两条必须真的挂回了某个经历,不能因为编号变了就成孤儿
if got := f.scalar(t, `SELECT count(*) FROM movements m WHERE m.dataset_id=$1
AND m.from_visit_src IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM visits v WHERE v.dataset_id=$1 AND v.src_pk=m.from_visit_src)`, f.dsID); got != 0 {
t.Errorf("恢复后出现了 %d 条起点悬空的位移", got)
}
// 地点是按**坐标**对齐的(不是按编号),所以编号平移不该让补回来的到访挂空 ——
// place_id 可空,映射错了不会报错,只会变成地图上没地点的孤点到访。
if got := f.scalar(t, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND place_id IS NULL`, f.dsID); got != 0 {
t.Errorf("恢复后有 %d 条到访没挂上地点(按编号映射就会这样)", got)
}
// 幂等:再比一次必须归零(否则每合并一次就多一批)
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)
}
if a, g, _ := overviewCounts(t, page, "地点"); a != 0 || g != 0 {
t.Fatalf("合并后地点差异也该归零,实际 新增 %d 仅站内有 %d", a, g)
}
}
// 冲突逐条选「用实机」就把备份的值写进去,选「用站内」就原样不动。
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)
}
}