package web import ( "net/http" "net/http/httptest" "strings" "testing" "rond-with-you/internal/rond" ) // 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) } } // 两条同名备份地点在合并时必须各配一条站内地点。以前这里用**单槽位** map 自己再配一遍, // 两条同名地点会映射到同一条站内地点上,到访于是被挂到错的分店——实测「茶颜悦色」两家 // 分店相隔 8.3km,到访却都指向同一条。现在配对直接沿用比对阶段那一份(rond.MatchPlaces)。 func TestMergePlacesMapsSameNameLocationsOneToOne(t *testing.T) { f := newMovementFixture(t) // 两条同名的站内地点,放在不太可能与真实数据撞车的坐标上 if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon) VALUES ($1, 920001, '同名测试店', 1.000000, 1.000000), ($1, 920002, '同名测试店', 1.100000, 1.100000)`, f.dsID); err != nil { t.Fatalf("插入站内地点失败: %v", err) } t.Cleanup(func() { f.pg.ExecContext(f.ctx, `DELETE FROM places WHERE dataset_id=$1 AND src_pk IN (920001,920002)`, f.dsID) }) // 备份各一条同名地点,分别距上面两条约 700m(属于坐标纠偏,该配上); // 与另一条相距 15km,远超 1km 上限,不该被配走 b := &rond.Backup{Locations: []rond.Location{ {SrcPK: 41, Name: "同名测试店", Lat: 1.004500, Lon: 1.004500}, {SrcPK: 42, Name: "同名测试店", Lat: 1.095500, Lon: 1.095500}, }} var placeID map[int]int64 if err := f.srv.withTx(f.ctx, func(ex execer) error { var err error placeID, err = f.srv.mergePlaces(f.ctx, ex, f.dsID, b, rond.MergePlan{}, &mergeStats{}) return err }); err != nil { t.Fatalf("mergePlaces 失败: %v", err) } if len(placeID) != 2 { t.Fatalf("两条都该配上,实际 %d 条", len(placeID)) } if placeID[41] == placeID[42] { t.Fatalf("两条同名备份地点被映射到同一条站内地点上: 41→%d 42→%d", placeID[41], placeID[42]) } srcOf := func(id int64) int { t.Helper() var pk int if err := f.pg.QueryRowContext(f.ctx, `SELECT src_pk FROM places WHERE id=$1`, id).Scan(&pk); err != nil { t.Fatalf("反查 id=%d 失败: %v", id, err) } return pk } if got := srcOf(placeID[41]); got != 920001 { t.Errorf("备份 #41 应配到最近的 920001,实际 %d", got) } if got := srcOf(placeID[42]); got != 920002 { t.Errorf("备份 #42 应配到最近的 920002,实际 %d", got) } }