备份(.rondbackup)与迷雾(.fwss)各加一层「先看差异再动手」: - 迷雾:上传先出差异页,说清叠加能补多少、整体替换会丢多少,并在真实底图上 用绿/灰/红标出会新增 / 不变 / 会丢的区域 - 备份:与当前数据集按 UUID 逐条比对(rond 每次导出都会重排 Z_PK,编号认不出人), 新增合入、冲突逐条选、仅站内有逐条勾选删除,全在一个事务里执行; 合入的行写回 entity_raw,保证再传同一份备份时差异归零 - 地点按坐标对齐:站点的「补全地址」会把地点改名,拿名称当键会让一整个地点的 到访全变成假冲突 - 附属数据(备注/结论/访客票/图片)从 dataset_id+src_pk 换成坐标键,换数据集不再失联; 访客票没有 dataset_id,回填按活跃数据集认领 - 修正 contentHash 拿 Z_PK 当指纹的隐患,以及两处「src_pk 跨快照稳定」的错误注释 导出格式(.rondbackup / .fwss)保持原样,往返测试通过。
150 lines
5.8 KiB
Go
150 lines
5.8 KiB
Go
package web
|
||
|
||
import (
|
||
"fmt"
|
||
"testing"
|
||
"time"
|
||
|
||
"rond-with-you/internal/votes"
|
||
)
|
||
|
||
// testPlace 在两个数据集里各建一个**同坐标**的地点,返回它们的编号与第二个数据集。
|
||
//
|
||
// 坐标是随手挑的专属值:备注按坐标共享,而读取按设计不按 user_id 过滤(单站长站点),
|
||
// 借用真实数据里的坐标会把别的测试残留的记录串进来。
|
||
func testPlace(t *testing.T, f *movementFixture, uid int64, lat, lon float64) (pkA, pkB int, dsB int64) {
|
||
t.Helper()
|
||
const pkAVal, pkBVal = 660001, 660002
|
||
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon)
|
||
VALUES ($1,$2,'甲侧地点',$3,$4)`, f.dsID, pkAVal, lat, lon); err != nil {
|
||
t.Fatalf("建地点失败: %v", err)
|
||
}
|
||
sha := fmt.Sprintf("%064d", time.Now().UnixNano())
|
||
if err := f.pg.QueryRowContext(f.ctx, `INSERT INTO datasets (user_id, original_name, sha256, status, is_active)
|
||
VALUES ($1,'另一个数据集',$2,'done',false) RETURNING id`, uid, sha).Scan(&dsB); 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,$2,'乙侧地点',$3,$4)`, dsB, pkBVal, lat, lon); err != nil {
|
||
t.Fatalf("建地点失败: %v", err)
|
||
}
|
||
key := fmt.Sprintf("%.6f|%.6f", lat, lon)
|
||
t.Cleanup(func() {
|
||
f.pg.ExecContext(f.ctx, `DELETE FROM place_notes WHERE place_key=$1`, key)
|
||
f.pg.ExecContext(f.ctx, `DELETE FROM place_votes WHERE place_key=$1`, key)
|
||
f.pg.ExecContext(f.ctx, `DELETE FROM place_visitor_votes WHERE place_key=$1`, key)
|
||
f.pg.ExecContext(f.ctx, `DELETE FROM datasets WHERE id=$1`, dsB)
|
||
})
|
||
return pkAVal, pkBVal, dsB
|
||
}
|
||
|
||
// 访客票也一样:它是站点自己的数据(rondbackup 里没有这一项),
|
||
// 但同样要认得出「是哪个地点」,所以也挂坐标键。
|
||
func TestVisitorVotesFollowPlaceAcrossDatasets(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
uid := userFrom(f.ctx).ID
|
||
pkA, pkB, dsB := testPlace(t, f, uid, 77.777777, 88.888888)
|
||
|
||
if _, mine, err := f.srv.votes.Cast(f.ctx, f.dsID, pkA, "visitor-a", 1); err != nil || mine != 1 {
|
||
t.Fatalf("投票失败: mine=%d err=%v", mine, err)
|
||
}
|
||
|
||
tally, err := f.srv.votes.Tally(f.ctx, dsB, pkB)
|
||
if err != nil {
|
||
t.Fatalf("读票数失败: %v", err)
|
||
}
|
||
if tally.Up != 1 || tally.Down != 0 {
|
||
t.Fatalf("换数据集后票数应照旧,实际 %+v", tally)
|
||
}
|
||
if v := f.srv.votes.Voter(f.ctx, dsB, pkB, "visitor-a"); v != 1 {
|
||
t.Fatalf("「我投的」也该认得出来,实际 %d", v)
|
||
}
|
||
// 再点一次是撤销:从哪一侧点都该撤掉同一张票
|
||
if _, mine, err := f.srv.votes.Cast(f.ctx, dsB, pkB, "visitor-a", 1); err != nil || mine != 0 {
|
||
t.Fatalf("再点一次应撤销: mine=%d err=%v", mine, err)
|
||
}
|
||
if tally, err := f.srv.votes.Tally(f.ctx, f.dsID, pkA); err != nil || tally.Up != 0 {
|
||
t.Fatalf("撤销后另一边也该归零: %+v err=%v", tally, err)
|
||
}
|
||
}
|
||
|
||
// 备注、结论、图片挂的是坐标键,所以**换数据集之后仍然跟着同一个地点**——
|
||
// 这正是这次迁移要解决的问题。老结构挂 (dataset_id, src_pk),
|
||
// 换一份备份同编号就是另一个地点,内容全部失联。
|
||
func TestNotesAndVerdictsFollowPlaceAcrossDatasets(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
uid := userFrom(f.ctx).ID
|
||
pkA, pkB, dsB := testPlace(t, f, uid, 11.111111, 22.222222)
|
||
|
||
if err := f.srv.notes.Save(f.ctx, uid, f.dsID, pkA, "别名甲", "备注甲"); err != nil {
|
||
t.Fatalf("写备注失败: %v", err)
|
||
}
|
||
if err := f.srv.votes.SaveVerdict(f.ctx, uid, f.dsID, pkA, votes.VerdictRecommend); err != nil {
|
||
t.Fatalf("写结论失败: %v", err)
|
||
}
|
||
|
||
// 从「另一个数据集」读:编号完全不同,但坐标一致,应当照样读得到
|
||
all, err := f.srv.notes.All(f.ctx, dsB)
|
||
if err != nil {
|
||
t.Fatalf("读备注失败: %v", err)
|
||
}
|
||
got, ok := all[pkB]
|
||
if !ok {
|
||
t.Fatal("换数据集后应当仍能读到这个地点的备注")
|
||
}
|
||
if got.Alias != "别名甲" || got.Content != "备注甲" {
|
||
t.Fatalf("备注应原样跟过来,实际 alias=%q note=%q", got.Alias, got.Content)
|
||
}
|
||
|
||
v, err := f.srv.votes.Verdict(f.ctx, dsB, pkB)
|
||
if err != nil {
|
||
t.Fatalf("读结论失败: %v", err)
|
||
}
|
||
if v != votes.VerdictRecommend {
|
||
t.Fatalf("结论应跟过来,实际 %d", v)
|
||
}
|
||
}
|
||
|
||
// 两个数据集里的编号完全不同,但备注与结论是同一份——改一处两边都变。
|
||
func TestNotesUpdateVisibleFromOtherDataset(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
uid := userFrom(f.ctx).ID
|
||
pkA, pkB, dsB := testPlace(t, f, uid, 33.333333, 44.444444)
|
||
|
||
if err := f.srv.notes.Save(f.ctx, uid, dsB, pkB, "别名乙", "备注乙"); err != nil {
|
||
t.Fatalf("在新数据集写备注失败: %v", err)
|
||
}
|
||
back, err := f.srv.notes.All(f.ctx, f.dsID)
|
||
if err != nil {
|
||
t.Fatalf("回读失败: %v", err)
|
||
}
|
||
if back[pkA].Content != "备注乙" {
|
||
t.Fatalf("备注是同一份数据,改一处两边都该变,实际 %q", back[pkA].Content)
|
||
}
|
||
}
|
||
|
||
// 坐标不同的地点绝不能互相认领——键是坐标,不是「大概位置」。
|
||
func TestNotesDoNotLeakBetweenDifferentPlaces(t *testing.T) {
|
||
f := newMovementFixture(t)
|
||
uid := userFrom(f.ctx).ID
|
||
pkA, _, dsB := testPlace(t, f, uid, 55.555555, 66.666666)
|
||
|
||
if err := f.srv.notes.Save(f.ctx, uid, f.dsID, pkA, "别名甲", "备注甲"); err != nil {
|
||
t.Fatalf("写备注失败: %v", err)
|
||
}
|
||
// 在乙数据集里放一个坐标差 0.001 度(约 100m)的地点,是完全不同的地方
|
||
const otherPK = 660003
|
||
if _, err := f.pg.ExecContext(f.ctx, `INSERT INTO places (dataset_id, src_pk, name, lat, lon)
|
||
VALUES ($1,$2,'隔壁那家', 55.556555, 66.666666)`, dsB, otherPK); err != nil {
|
||
t.Fatalf("建地点失败: %v", err)
|
||
}
|
||
|
||
all, err := f.srv.notes.All(f.ctx, dsB)
|
||
if err != nil {
|
||
t.Fatalf("读备注失败: %v", err)
|
||
}
|
||
if _, ok := all[otherPK]; ok {
|
||
t.Fatal("坐标不同的地点不该认领到别人的备注")
|
||
}
|
||
}
|