Files
rond-with-you/internal/web/dup_visits_test.go
T
tamakyi 152af81f6a 编辑页:新增「重复到访」卡片 —— 以基线(手机包)为参照清掉站点侧的历史副本
站点库里有一批「到达时间 + 地点与手机包里某条完全相同、但 UUID 不同」的到访副本,
合并/差异按 UUID 认人会把它们当成备份新增,导出到 rond 就成了成对的两条重复记录。
判定与 tools/dedup_site_visits.py 同一套语义:UUID 是前提(只按时间+地点判会误删手机包
自身合法的成对条目);基线跟本站一条都对不上时只给说明、不给按钮。清理在单事务里删
副本 + 其位移/天气/留档,并把指向副本的 ZMERGEDTO/ZPARENT 改指到保留项——只动本来
就有的那个键,工具那版会凭空补出一个 ZMERGEDTO(真机里是「已并入别处」,会白丢记录)。

顺带修一个「一补录天气就整包导出失败」的洞:rond 导出把 weather.is_daylight 扫进裸 bool,
而站点「新增天气」不写这一列 ⇒ NULL ⇒ sql.Scan 报错。改成 COALESCE(is_daylight,false),
与导入侧读真机包同口径。

验收:301 PASS / 0 FAIL / 0 SKIP(新增 4 个用例,含变异验证:去掉 UUID 前提/改回
create_if_missing=true/不删副本位移,三种改法都被用例抓到)。
2026-09-29 19:17:29 +08:00

634 lines
26 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 (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"fmt"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"rond-with-you/internal/rond"
)
// 重复到访(站点侧副本)的判定与清理。夹具是「刚导入、还没被改过」的真实数据集,
// 基线就是它当初那份手机包——判定逻辑必须在这种真数据形状上跑,不能只喂人造的小库。
// dupBlob 是一条到访的 UUID(既要有 hex 用来比,也要有 base64 用来写留档)。
type dupBlob struct {
hex, b64 string
}
// newDupBlob 造一个基线里绝不会有的 UUID——副本的特征就是「UUID 在基线里找不到」。
func newDupBlob(t *testing.T) dupBlob {
t.Helper()
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
t.Fatal(err)
}
return dupBlob{hex: fmt.Sprintf("%x", b), b64: base64.StdEncoding.EncodeToString(b[:])}
}
// dupSeedBase 是被拿来当「原件」的那条到访。
type dupSeedBase struct {
srcPK int
arrival time.Time
}
// pickDupBase 挑一条「UUID 在手机包里也有」的到访当原件。skip 里的编号不选
// (同一份数据里要造两对时,别把上一对的双方再拿去当原件)。
func pickDupBase(t *testing.T, srv *Server, ctx context.Context, dsID int64, skip ...int) dupSeedBase {
t.Helper()
base, err := readDupBaseline(ctx, srv.baselinePath(dsID))
if err != nil {
t.Fatalf("读基线库失败: %v", err)
}
site, err := srv.loadDupSiteVisits(ctx, dsID)
if err != nil {
t.Fatalf("读站点到访失败: %v", err)
}
used := map[int]bool{}
for _, s := range skip {
used[s] = true
}
for _, sv := range site {
if sv.uuid == "" || !base.uuids[sv.uuid] || used[sv.srcPK] {
continue
}
var placeID int64
if err := srv.db.QueryRowContext(ctx, `SELECT COALESCE(place_id,0)
FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, sv.srcPK).Scan(&placeID); err != nil {
t.Fatal(err)
}
if placeID > 0 {
return dupSeedBase{srcPK: sv.srcPK, arrival: sv.arrival}
}
}
t.Fatal("数据集里挑不出可当原件的到访(UUID 得在基线里)")
return dupSeedBase{}
}
// seedDupVictim 复制一条到访造出「站点副本」,并给它配上位移 / 天气 / 留档。
// 返回副本的 src_pk 与 UUID。
func seedDupVictim(t *testing.T, srv *Server, ctx context.Context, dsID int64,
base dupSeedBase, keeper int) (int, dupBlob) {
t.Helper()
pg := srv.db
src, err := srv.nextSrcPK(ctx, pg, dsID, visitSeq)
if err != nil {
t.Fatalf("分配副本编号失败: %v", err)
}
if err := clearStaleRaw(ctx, pg, dsID, "ZVISIT", src); err != nil {
t.Fatal(err)
}
// 到访行照抄原件:现实里的副本就是合并时多出来的那一份拷贝,字段一模一样
// (所以才会「时间 + 地点完全相同」,光看时间是分不出来的)
if _, err := pg.ExecContext(ctx, `INSERT INTO visits
(dataset_id, src_pk, place_id, activity_id, arrival, departure, duration_min, is_home, is_work,
bookmarked, is_user_added, remark, emoji, weather_symbol)
SELECT dataset_id, $3, place_id, activity_id, arrival, departure, duration_min, is_home, is_work,
bookmarked, is_user_added, remark, emoji, weather_symbol
FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, base.srcPK, src); err != nil {
t.Fatalf("造副本到访失败: %v", err)
}
blob := newDupBlob(t)
// 留档也照抄原件那一行,只改主键与 UUID,合并标记清成 null(副本不该自称已并入别处)。
// 这样导出时这一行的列形状和原件一致,往返对比才有意义。
if _, err := pg.ExecContext(ctx, `INSERT INTO entity_raw (dataset_id, entity, src_pk, raw)
SELECT dataset_id, entity, $3,
jsonb_set(jsonb_set(jsonb_set(jsonb_set(raw,
'{Z_PK}', to_jsonb($3::int), true),
'{ZIDENTIFIER_}', jsonb_build_object('$blob', $4::text), true),
'{ZMERGEDTO}', 'null'::jsonb, true),
'{ZPARENT}', 'null'::jsonb, true)
FROM entity_raw WHERE dataset_id=$1 AND entity=$2 AND src_pk=$5`,
dsID, "ZVISIT", src, blob.b64, base.srcPK); err != nil {
t.Fatalf("造副本留档失败: %v", err)
}
// 附属记录:两端各挂一条位移 + 一条天气。清理时必须连着一起走,
// 否则导出包里会留下指着不存在到访的悬空引用。
for _, ends := range [][2]int{{src, keeper}, {keeper, src}} {
mv, err := srv.nextSrcPK(ctx, pg, dsID, movementSeq)
if err != nil {
t.Fatal(err)
}
if err := clearStaleRaw(ctx, pg, dsID, "ZMOVEMENT", mv); err != nil {
t.Fatal(err)
}
if _, err := pg.ExecContext(ctx, `INSERT INTO movements
(dataset_id, src_pk, from_visit_src, to_visit_src) VALUES ($1,$2,$3,$4)`,
dsID, mv, ends[0], ends[1]); err != nil {
t.Fatalf("造副本位移失败: %v", err)
}
}
w, err := srv.nextSrcPK(ctx, pg, dsID, weatherSeq)
if err != nil {
t.Fatal(err)
}
if err := clearStaleRaw(ctx, pg, dsID, "ZHOURLYWEATHER", w); err != nil {
t.Fatal(err)
}
if _, err := pg.ExecContext(ctx, `INSERT INTO weather (dataset_id, src_pk, visit_src, at)
VALUES ($1,$2,$3,$4)`, dsID, w, src, base.arrival); err != nil {
t.Fatalf("造副本天气失败: %v", err)
}
return src, blob
}
// pickReferrers 挑两条跟副本无关、两个合并标记都是空的到访留档。
// 两条必须不同,否则「只带其中一个键」的两种情形就分不出来了。
func pickReferrers(t *testing.T, srv *Server, ctx context.Context, dsID int64, exclude ...int64) (int, int) {
t.Helper()
rows, err := srv.db.QueryContext(ctx, `SELECT src_pk FROM entity_raw
WHERE dataset_id=$1 AND entity='ZVISIT' AND NOT (src_pk = ANY($2::bigint[]))
AND raw->>'ZMERGEDTO' IS NULL AND raw->>'ZPARENT' IS NULL
ORDER BY src_pk LIMIT 2`, dsID, exclude)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []int
for rows.Next() {
var pk int
if err := rows.Scan(&pk); err != nil {
t.Fatal(err)
}
out = append(out, pk)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if len(out) < 2 {
t.Fatalf("挑不出两条指向副本的引用行(找到 %d 条)", len(out))
}
return out[0], out[1]
}
// pointRef 把某条到访留档里的一个合并标记指到 target(另一个保持 null),
// 模拟「别的到访的留档指着副本」这种要改指的情形。
func pointRef(t *testing.T, srv *Server, ctx context.Context, dsID int64, referrer int, col string, target int) {
t.Helper()
other := "ZPARENT"
if col == "ZPARENT" {
other = "ZMERGEDTO"
}
if _, err := srv.db.ExecContext(ctx, `UPDATE entity_raw
SET raw = jsonb_set(jsonb_set(raw, ARRAY[$4::text], to_jsonb($3::int), true),
ARRAY[$5::text], 'null'::jsonb, true)
WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`,
dsID, referrer, target, col, other); err != nil {
t.Fatalf("设置合并标记失败: %v", err)
}
}
// readRef 读某条到访留档里的一个合并标记。
func readRef(t *testing.T, srv *Server, ctx context.Context, dsID int64, srcPK int, col string) sql.NullString {
t.Helper()
var v sql.NullString
if err := srv.db.QueryRowContext(ctx, `SELECT raw->>$3 FROM entity_raw
WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`, dsID, srcPK, col).Scan(&v); err != nil {
t.Fatalf("读合并标记失败: %v", err)
}
return v
}
// openExport 把导出的字节落到临时文件并打开,返回可直接查询的库。
// 一份包只开一次:每次打开都要写盘 + 解压,动辄几秒。
func openExport(t *testing.T, blob []byte) *sql.DB {
t.Helper()
out := filepath.Join(t.TempDir(), "export.rondbackup")
if err := os.WriteFile(out, blob, 0o644); err != nil {
t.Fatal(err)
}
edb, err := rond.Open(out, t.TempDir())
if err != nil {
t.Fatalf("打开导出包失败: %v", err)
}
t.Cleanup(func() { edb.Close() })
return edb
}
// countExportTwin 数导出包里「跟某条到访同地点、同到达时间」的到访有几条——
// 这正是用户看到的那对重复(清理前 2、清理后 1)。
//
// 只能按「时间 + 地点」数,不能按 UUID:站点侧的行(副本就是)在导出时会被补上本站
// 派生的确定性 UUID(coreDataUUID),留档里那个 UUID 本来就不会写进包里。
func countExportTwin(t *testing.T, edb *sql.DB, keeperHex string, arrival time.Time) int {
t.Helper()
var n int
if err := edb.QueryRow(`SELECT count(*) FROM ZVISIT
WHERE ZLOCATION = (SELECT ZLOCATION FROM ZVISIT WHERE hex(ZIDENTIFIER_)=?)
AND abs(ZARRIVALDATE_ - ?) < 0.5`, strings.ToUpper(keeperHex), appleSec(arrival)).Scan(&n); err != nil {
t.Fatalf("查导出包失败: %v", err)
}
return n
}
// countExportAll 数导出包里的到访总条数。
func countExportAll(t *testing.T, edb *sql.DB) int {
t.Helper()
var n int
if err := edb.QueryRow(`SELECT count(*) FROM ZVISIT`).Scan(&n); err != nil {
t.Fatalf("查导出包失败: %v", err)
}
return n
}
// visitUUIDHex 读某条到访留档里的 UUID(小写 hex)。
func visitUUIDHex(t *testing.T, srv *Server, ctx context.Context, dsID int64, srcPK int) string {
t.Helper()
var b64 string
if err := srv.db.QueryRowContext(ctx, `SELECT COALESCE(raw #>> '{ZIDENTIFIER_,$blob}','')
FROM entity_raw WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`, dsID, srcPK).Scan(&b64); err != nil {
t.Fatal(err)
}
return blobHex(b64)
}
// countDangling 数站点里所有悬空引用:留档指向不存在的到访 / 位移指向不存在的到访。
func countDangling(t *testing.T, srv *Server, ctx context.Context, dsID int64) (int, int) {
t.Helper()
var raw, mv int
if err := srv.db.QueryRowContext(ctx, `SELECT
(SELECT count(*) FROM entity_raw r WHERE r.dataset_id=$1 AND r.entity='ZVISIT' AND NOT r.skipped
AND NOT EXISTS (SELECT 1 FROM visits v WHERE v.dataset_id=r.dataset_id AND v.src_pk=r.src_pk)),
(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=m.dataset_id AND v.src_pk=m.from_visit_src))
OR (m.to_visit_src IS NOT NULL AND NOT EXISTS (SELECT 1 FROM visits v WHERE v.dataset_id=m.dataset_id AND v.src_pk=m.to_visit_src))))`,
dsID).Scan(&raw, &mv); err != nil {
t.Fatal(err)
}
return raw, mv
}
// TestDuplicateVisitsCleanE2E 端到端验证「检出重复到访 → 勾选清理 → 导出不再重复」这条链路。
//
// 造两对副本(各带位移 / 天气 / 留档),只提交其中一对,然后逐项核对:
// 勾掉的那条连附属记录一起没了、没勾的纹丝不动、指向副本的合并标记改指到保留项、
// 没有任何悬空引用,并且导出包里那一处重复真的从 2 条变回 1 条。
func TestDuplicateVisitsCleanE2E(t *testing.T) {
f := newMovementFixture(t)
ctx, pg, dsID := f.ctx, f.pg, f.dsID
srv := f.srv
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("准备基线库失败: %v", err)
}
// 起点:夹具是「刚导入的包」,每条到访的 UUID 都来自这个包,站点侧副本必然是 0 对
before, unavailable, err := srv.detectDuplicateVisits(ctx, dsID)
if err != nil {
t.Fatalf("检测失败: %v", err)
}
if unavailable != "" {
t.Fatalf("夹具的基线判定不了重复(%s),用例前提不成立", unavailable)
}
if len(before) != 0 {
t.Fatalf("刚导入的包不该检出站点副本,实际 %d 对——判定的前提被改坏了", len(before))
}
base, err := readDupBaseline(ctx, srv.baselinePath(dsID))
if err != nil {
t.Fatalf("读基线库失败: %v", err)
}
var startVisits int
if err := pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1`, dsID).Scan(&startVisits); err != nil {
t.Fatal(err)
}
dangRaw0, dangMv0 := countDangling(t, srv, ctx, dsID)
// 造两对:keeper1/victim1、keeper2/victim2
keeper1 := pickDupBase(t, srv, ctx, dsID)
victim1, blob1 := seedDupVictim(t, srv, ctx, dsID, keeper1, keeper1.srcPK)
keeper2 := pickDupBase(t, srv, ctx, dsID, keeper1.srcPK, victim1)
victim2, _ := seedDupVictim(t, srv, ctx, dsID, keeper2, keeper2.srcPK)
t.Logf("造副本:原件 #%d → 副本 #%d(UUID %s…);原件 #%d → 副本 #%d",
keeper1.srcPK, victim1, blob1.hex[:8], keeper2.srcPK, victim2)
// 复制品是「同地点同时间、UUID 不同」,判定必须刚好揪出这两条
pairs, unavailable, err := srv.detectDuplicateVisits(ctx, dsID)
if err != nil || unavailable != "" {
t.Fatalf("检测失败: err=%v unavailable=%s", err, unavailable)
}
got := map[int]int{}
for _, p := range pairs {
got[p.Victim] = p.Keep
}
if len(pairs) != len(before)+2 {
t.Fatalf("应检出 %d 对,实际 %d 对", len(before)+2, len(pairs))
}
if got[victim1] != keeper1.srcPK || got[victim2] != keeper2.srcPK {
t.Fatalf("配对不对:%v(应 #%d→#%d、#%d→#%d)",
got, victim1, keeper1.srcPK, victim2, keeper2.srcPK)
}
// 判定的定义性不变量:**副本的 UUID 一定不在基线里,保留项的 UUID 一定在**。
// 只按「时间 + 地点」判(漏了 UUID 前提)时,同一处会冒出一大堆「自己配自己」的配对,
// 而计数断言是会跟着一起膨胀的——只有这条不变量不随样本量走。
for _, p := range pairs {
if vh := visitUUIDHex(t, srv, ctx, dsID, p.Victim); vh == "" || base.uuids[vh] {
t.Errorf("把 #%d 判成副本,可它的 UUID(%s)在基线里也有——UUID 是判定的前提", p.Victim, vh)
}
if kh := visitUUIDHex(t, srv, ctx, dsID, p.Keep); kh == "" || !base.uuids[kh] {
t.Errorf("#%d 被当成保留项,可它的 UUID(%s)不在基线里", p.Keep, kh)
}
}
// 指向副本的合并标记:一条只带 ZMERGEDTO,一条只带 ZPARENT
refMerged, refParent := pickReferrers(t, srv, ctx, dsID,
int64(keeper1.srcPK), int64(victim1), int64(keeper2.srcPK), int64(victim2))
pointRef(t, srv, ctx, dsID, refMerged, "ZMERGEDTO", victim1)
pointRef(t, srv, ctx, dsID, refParent, "ZPARENT", victim1)
// 页面要能渲染出卡片:标题里带对数,每行一个默认勾上的勾选框。
//
// ⚠️ render 是**流式直写**的:模板执行到一半出错也只记日志、状态码照样 200,body 就此截断。
// 所以不能只看 code,必须断言「卡片之后的内容也在」(「全部保留」在 {{.EditLink}} 之后,
// 「到访记录」在整张卡片之后),末尾还得是闭合的 </html>。
rec := httptest.NewRecorder()
srv.editPage(rec, httptest.NewRequest("GET", "/admin/edit", nil).WithContext(ctx))
if rec.Code != 200 {
t.Fatalf("渲染数据编辑页失败: code=%d", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{
fmt.Sprintf("重复到访 %d 对", len(pairs)),
fmt.Sprintf(`name="victim" value="%d" checked`, victim1),
"全部保留",
"到访记录",
} {
if !strings.Contains(body, want) {
t.Errorf("页面里没有 %q(模板可能执行到一半就断了)", want)
}
}
if !strings.HasSuffix(strings.TrimSpace(body), "</html>") {
t.Error("页面没有渲染完(body 被截断)")
}
// 清理前导出:那一处就是成对的两条(用户看到的现象)
keeperHex1 := visitUUIDHex(t, srv, ctx, dsID, keeper1.srcPK)
edbBefore := openExport(t, f.export(t))
if n := countExportTwin(t, edbBefore, keeperHex1, keeper1.arrival); n != 2 {
t.Fatalf("清理前导出包里同时间同地点应有 2 条,实际 %d", n)
}
exportVisitsBefore := countExportAll(t, edbBefore)
// 只提交 victim1:victim2 必须原地不动
loc := post(t, srv.editDuplicateVisitsApply, ctx, "/admin/edit/dup/clean",
url.Values{"victim": {fmt.Sprint(victim1)}})
if !strings.Contains(loc, "ok=") {
t.Errorf("清理后没有 ok= 闪信: %s", loc)
}
var n int
pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1`, dsID).Scan(&n)
if n != startVisits+2-1 {
t.Errorf("到访数应为 %d,实际 %d", startVisits+2-1, n)
}
for _, c := range []struct {
name string
sql string
}{
{"副本到访", `SELECT count(*) FROM visits WHERE dataset_id=$1 AND src_pk=$2`},
{"副本留档", `SELECT count(*) FROM entity_raw WHERE dataset_id=$1 AND entity='ZVISIT' AND src_pk=$2`},
{"副本位移(起点)", `SELECT count(*) FROM movements WHERE dataset_id=$1 AND from_visit_src=$2`},
{"副本位移(终点)", `SELECT count(*) FROM movements WHERE dataset_id=$1 AND to_visit_src=$2`},
{"副本天气", `SELECT count(*) FROM weather WHERE dataset_id=$1 AND visit_src=$2`},
} {
pg.QueryRowContext(ctx, c.sql, dsID, victim1).Scan(&n)
if n != 0 {
t.Errorf("%s 没清掉(还剩 %d 条)", c.name, n)
}
}
// 保留项与未被勾选的那对都在
for _, pk := range []int{keeper1.srcPK, victim2, keeper2.srcPK} {
pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, pk).Scan(&n)
if n != 1 {
t.Errorf("到访 #%d 不该被删(实际存在 %d 条)", pk, n)
}
}
pg.QueryRowContext(ctx, `SELECT count(*) FROM movements WHERE dataset_id=$1 AND (from_visit_src=$2 OR to_visit_src=$2)`,
dsID, victim2).Scan(&n)
if n != 2 {
t.Errorf("未勾选的副本 #%d 的位移不该被清(还剩 %d 条)", victim2, n)
}
// 合并标记改指到保留项,且**不凭空补出另一个键**:
// 「只有 ZPARENT 指向副本」的那条不许长出 ZMERGEDTO(真机里那是「已并入别处」)
if got := readRef(t, srv, ctx, dsID, refMerged, "ZMERGEDTO"); got.String != fmt.Sprint(keeper1.srcPK) {
t.Errorf("ZMERGEDTO 应改指到 #%d,实际 %q", keeper1.srcPK, got.String)
}
if got := readRef(t, srv, ctx, dsID, refMerged, "ZPARENT"); got.Valid {
t.Errorf("只带 ZMERGEDTO 的那条不该被补上 ZPARENT,实际 %q", got.String)
}
if got := readRef(t, srv, ctx, dsID, refParent, "ZPARENT"); got.String != fmt.Sprint(keeper1.srcPK) {
t.Errorf("ZPARENT 应改指到 #%d,实际 %q", keeper1.srcPK, got.String)
}
if got := readRef(t, srv, ctx, dsID, refParent, "ZMERGEDTO"); got.Valid {
t.Errorf("只带 ZPARENT 的那条不该被补上 ZMERGEDTO,实际 %q", got.String)
}
// 不得留下任何悬空引用(清理前是多少,清理后还得是多少)
if dangRaw, dangMv := countDangling(t, srv, ctx, dsID); dangRaw != dangRaw0 || dangMv != dangMv0 {
t.Errorf("清理后出现悬空引用:留档 %d→%d,位移 %d→%d", dangRaw0, dangRaw, dangMv0, dangMv)
}
// 数据集计数(页脚「数据截至」与后台 flash 用的那份)也要跟着减
var dsCount int
pg.QueryRowContext(ctx, `SELECT visit_count FROM datasets WHERE id=$1`, dsID).Scan(&dsCount)
if dsCount != startVisits+2-1 {
t.Errorf("datasets.visit_count 应为 %d,实际 %d", startVisits+2-1, dsCount)
}
// 再检出一次:未勾选的那对还在(留着让用户自己决定)
pairs, unavailable, err = srv.detectDuplicateVisits(ctx, dsID)
if err != nil || unavailable != "" {
t.Fatalf("复核检测失败: err=%v unavailable=%s", err, unavailable)
}
still := false
for _, p := range pairs {
if p.Victim == victim2 {
still = true
}
if p.Victim == victim1 {
t.Errorf("已清理的副本 #%d 仍被检出", victim1)
}
}
if !still {
t.Errorf("未勾选的副本 #%d 不该消失", victim2)
}
// 导出:那一处重复从 2 条变回 1 条,整包到访数只少了被清掉的那一条
edbAfter := openExport(t, f.export(t))
if n := countExportTwin(t, edbAfter, keeperHex1, keeper1.arrival); n != 1 {
t.Errorf("清理后导出包里同时间同地点应只剩 1 条,实际 %d", n)
}
if n := countExportAll(t, edbAfter); n != exportVisitsBefore-1 {
t.Errorf("清理后导出包的到访数应为 %d,实际 %d", exportVisitsBefore-1, n)
}
// 导出包里那条引用的 ZMERGEDTO 必须正好指向保留项(编号对齐后也要对上)
var mergedTo sql.NullInt64
if err := edbAfter.QueryRow(`SELECT ZMERGEDTO FROM ZVISIT WHERE Z_PK=?`, refMerged).Scan(&mergedTo); err != nil {
t.Fatalf("导出包里找不到引用行 #%d: %v", refMerged, err)
}
if !mergedTo.Valid || int(mergedTo.Int64) != keeper1.srcPK {
t.Errorf("导出包里 ZMERGEDTO 应为 %d,实际 %v", keeper1.srcPK, mergedTo)
}
}
// TestDuplicateVisitsRefusesWrongBaseline 基线不可用 / 跟本站对不上时必须判定不了、且不给按钮。
//
// 这是这个功能唯一会造成不可逆损失的地方:照着对不上的参照跑,站内数据会被成片清掉
// (所有 UUID 都比不上 ⇒ 所有到访都算副本)。所以宁可什么都不做。
func TestDuplicateVisitsRefusesWrongBaseline(t *testing.T) {
f := newMovementFixture(t)
ctx, dsID := f.ctx, f.dsID
srv := f.srv
path, err := srv.ensureBaseline(ctx, dsID)
if err != nil {
t.Fatalf("准备基线库失败: %v", err)
}
// 缺基线:判定不了,而不是「没有重复」
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if _, unavail, err := srv.detectDuplicateVisits(ctx, dsID); err != nil || unavail == "" {
t.Errorf("基线缺失时应当判定不了(err=%v unavail=%q)", err, unavail)
}
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("重建基线库失败: %v", err)
}
// 基线里的 UUID 全换掉 ⇒ 跟本站一条都对不上
base, err := readDupBaseline(ctx, path)
if err != nil {
t.Fatal(err)
}
if len(base.uuids) == 0 {
t.Fatal("基线里没有 UUID,用例前提不成立")
}
sdb, err := sql.Open("sqlite", "file:"+filepath.ToSlash(path))
if err != nil {
t.Fatal(err)
}
if _, err := sdb.Exec(`UPDATE ZVISIT SET ZIDENTIFIER_ = randomblob(16) WHERE ZIDENTIFIER_ IS NOT NULL`); err != nil {
sdb.Close()
t.Fatalf("改写基线库失败: %v", err)
}
if err := sdb.Close(); err != nil {
t.Fatal(err)
}
pairs, unavail, err := srv.detectDuplicateVisits(ctx, dsID)
if err != nil {
t.Fatalf("检测失败: %v", err)
}
if unavail == "" {
t.Fatalf("基线对不上时必须判定不了,却检出 %d 对", len(pairs))
}
if len(pairs) != 0 {
t.Errorf("判定不了时不该给出任何配对,实际 %d 对", len(pairs))
}
// 页面:只给一行说明,不给「清理」入口
rec := httptest.NewRecorder()
srv.editPage(rec, httptest.NewRequest("GET", "/admin/edit", nil).WithContext(ctx))
if rec.Code != 200 {
t.Fatalf("渲染数据编辑页失败: code=%d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, unavail) {
t.Error("页面里没有显示判定不了的说明")
}
if strings.Contains(body, "/admin/edit/dup/clean") {
t.Error("判定不了时页面不该出现清理入口")
}
}
// TestDupCleanSkipsStaleSelection 提交「已经不是副本」的编号时必须拒绝,一条都不删。
//
// 渲染到点击之间库可能已经变过(另一处清理过、或用户拿着旧页面点),
// 照着表单里的编号删就是删错行。
func TestDupCleanSkipsStaleSelection(t *testing.T) {
f := newMovementFixture(t)
ctx, pg, dsID := f.ctx, f.pg, f.dsID
srv := f.srv
if _, err := srv.ensureBaseline(ctx, dsID); err != nil {
t.Fatalf("准备基线库失败: %v", err)
}
var startVisits int
pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1`, dsID).Scan(&startVisits)
// 挑一条**不是**副本的到访(UUID 就在基线里)提交上去
base, err := readDupBaseline(ctx, srv.baselinePath(dsID))
if err != nil {
t.Fatal(err)
}
site, err := srv.loadDupSiteVisits(ctx, dsID)
if err != nil {
t.Fatal(err)
}
var innocent int
for _, sv := range site {
if sv.uuid != "" && base.uuids[sv.uuid] {
innocent = sv.srcPK
break
}
}
if innocent == 0 {
t.Skip("夹具里挑不出「UUID 在基线里」的到访")
}
r := httptest.NewRequest("POST", "/admin/edit/dup/clean",
strings.NewReader(url.Values{"victim": {fmt.Sprint(innocent)}}.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r = r.WithContext(ctx)
w := httptest.NewRecorder()
srv.editDuplicateVisitsApply(w, r)
if w.Code >= 500 {
t.Fatalf("处理失败: code=%d", w.Code)
}
if !strings.Contains(w.Header().Get("Location"), "err=") {
t.Error("提交一条不是副本的到访应当被拒绝")
}
var n int
pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1`, dsID).Scan(&n)
if n != startVisits {
t.Errorf("被拒绝的提交不该删掉任何到访:%d → %d", startVisits, n)
}
pg.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1 AND src_pk=$2`, dsID, innocent).Scan(&n)
if n != 1 {
t.Error("那条到访被误删了")
}
}
// TestDupCleanKeepsMergeKeysAsIs 静态守卫:改指合并标记必须「只动本来就有的那个键」。
//
// tools/dedup_site_visits.py 是把两个键都写成保留项(jsonb_set 的 create_if_missing=true)。
// 照抄的话,「只有 ZPARENT 指向副本」的行会凭空长出一个 ZMERGEDTO——真机里那是
// 「这条已并入别处」的意思,会白丢一条记录。实测基线 956 条留档都带着这两个键、
// 只有 54 / 10 条有值,凭空补键很容易踩到。
// 另外 raw->> 取出来是 text,必须按字符串比(拿数字比会报「操作符不存在」,整个清理会失败)。
func TestDupCleanKeepsMergeKeysAsIs(t *testing.T) {
src, err := os.ReadFile(pkgFile(t, "edit_duplicates.go"))
if err != nil {
t.Fatalf("读 edit_duplicates.go 失败: %v", err)
}
body := sliceFunc(t, string(src), "func (s *Server) cleanDuplicateVisits(")
if !strings.Contains(body, "jsonb_set(raw, ARRAY[$4::text], to_jsonb($3::int), false)") {
t.Error("改指合并标记必须用 create_if_missing=false,只动本来就存在的那个键")
}
if strings.Contains(body, "to_jsonb($3::int), true)") {
t.Error("不许用 create_if_missing=true:那会凭空补出副本本来没有的合并标记")
}
if !strings.Contains(body, "raw->>$4 = $2") {
t.Error("raw->> 取出来是 text,必须按字符串比")
}
if !strings.Contains(body, "s.pruneEntityRaw") {
t.Error("删完必须 pruneEntityRaw,否则留档里会留下指向已删到访的行")
}
}