package web import ( "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "regexp" "strings" "testing" "rond-with-you/internal/db" ) // ---------- 归属口径:删数据集只许带走「从备份导入」的数据 ---------- // // 事故(2026-09-30):place_images 与 topics 带 `dataset_id … ON DELETE CASCADE`, // 删掉旧数据集时把**站点自建**的记录一起删了——图片行没了、文件却还留在盘上(读取按坐标键、 // 行按数据集级联,两套口径打架)。这类记录**不在备份里**,删掉就是永久丢失。 // // 这不是靠记性能守住的事,所以做成结构性门禁: // 凡是要挂在 datasets 上的外键,表名必须在这份白名单里。新增一张带 dataset_id 的表时, // 先问一句「它的记录能不能从备份重新导入」——能,才允许随数据集删除;不能,就只留 // dataset_id 当出处、不许挂外键(参考 place_images / topics 现在的写法)。 // backupDerivedTables 是从备份导入、删了还能再导回来的那些表。删数据集时它们跟着走是对的。 var backupDerivedTables = []string{ "places", "visits", "movements", "weather", "activities", "tags", "entity_raw", } // userOwnedTables 是站点自建、删数据集**必须留下**的表(只有列、没有外键)。 var userOwnedTables = []string{"place_images", "place_notes", "place_votes", "topics"} // TestOnlyBackupDerivedTablesCascadeFromDatasets 运行时判据:查 pg_constraint。 func TestOnlyBackupDerivedTablesCascadeFromDatasets(t *testing.T) { f := newMovementFixture(t) rows, err := f.pg.QueryContext(f.ctx, `SELECT conrelid::regclass::text, confdeltype FROM pg_constraint WHERE confrelid='datasets'::regclass`) if err != nil { t.Fatalf("查外键失败: %v", err) } defer rows.Close() allowed := map[string]bool{} for _, n := range backupDerivedTables { allowed[n] = true } got := map[string]string{} // 表 -> 删除行为 for rows.Next() { var tbl, del string if err := rows.Scan(&tbl, &del); err != nil { t.Fatal(err) } got[tbl] = del if !allowed[tbl] { t.Errorf("%s 挂了 datasets 的外键(删除行为 %s):它的记录不是从备份导入的,"+ "删数据集会连它一起删掉、且无法恢复。只留 dataset_id 当出处,不要挂外键。", tbl, del) } } if err := rows.Err(); err != nil { t.Fatal(err) } // 反向:白名单里的表必须还在。顺手去掉 places/visits 的级联会留下成片孤儿行, // 而删数据集的提示文案是按「这些都随数据集删除」写的。 for _, n := range backupDerivedTables { if _, ok := got[n]; !ok { t.Errorf("%s 应当仍随数据集删除(现在没有 datasets 外键了):删数据集会留下一堆孤儿行,"+ "或者外键变成 RESTRICT 之后干脆删不动数据集。", n) } } } // TestMigrationDropsStaleDatasetForeignKeys 「库里残留着旧外键」⇒ 跑一次迁移必须把它摘掉。 // // 这条最要紧:**已经部署出去的库**上就带着那根 CASCADE 外键(本机库现在还是), // 光改 CREATE TABLE 救不了它——必须靠 schema.sql 里那句 ALTER TABLE … DROP CONSTRAINT。 // 这里故意把事故原样的外键加回去,跑一次迁移,断言它们没了:迁移能自愈这件事被钉住。 // // 顺带解释了为什么「运行时判据」单独看不够:测试夹具每次都会跑迁移, // 于是运行时那条永远不会在不健康的库上报警——真正的自愈靠这条。 func TestMigrationDropsStaleDatasetForeignKeys(t *testing.T) { f := newMovementFixture(t) ctx := f.ctx gone := func(table string) bool { var n int if err := f.pg.QueryRowContext(ctx, `SELECT count(*) FROM pg_constraint WHERE conrelid=$1::regclass AND confrelid='datasets'::regclass`, table).Scan(&n); err != nil { t.Fatalf("查 %s 的约束失败: %v", table, err) } return n == 0 } // 先把事故原样的外键加回去(NOT VALID 跳过既有行的校验,只关心约束在不在) for _, tbl := range userOwnedTables { if !hasDatasetColumn(t, f, tbl) { continue } if _, err := f.pg.ExecContext(ctx, fmt.Sprintf( `ALTER TABLE %s DROP CONSTRAINT IF EXISTS %s_dataset_id_fkey`, tbl, tbl)); err != nil { t.Fatalf("清理 %s 的旧约束失败: %v", tbl, err) } if _, err := f.pg.ExecContext(ctx, fmt.Sprintf( `ALTER TABLE %s ADD CONSTRAINT %s_dataset_id_fkey FOREIGN KEY (dataset_id) REFERENCES datasets(id) ON DELETE CASCADE NOT VALID`, tbl, tbl)); err != nil { t.Fatalf("给 %s 补回外键失败: %v", tbl, err) } } // 确认变异生效(否则这条用例本身是假绿的) injected := false for _, tbl := range userOwnedTables { if hasDatasetColumn(t, f, tbl) && !gone(tbl) { injected = true } } if !injected { t.Fatal("变异没生效:没有任何一张自建数据表被挂上外键,这条用例等于没测") } if err := db.Migrate(ctx, f.pg); err != nil { t.Fatalf("迁移失败: %v", err) } for _, tbl := range userOwnedTables { if hasDatasetColumn(t, f, tbl) && !gone(tbl) { t.Errorf("跑完迁移后 %s 上还挂着 datasets 的外键:那句 DROP CONSTRAINT 没生效,"+ "已经部署出去的库就不会自愈。", tbl) } } } // hasDatasetColumn 判断某表有没有 dataset_id 列(不是所有自建数据表都有)。 func hasDatasetColumn(t *testing.T, f *movementFixture, table string) bool { t.Helper() var n int if err := f.pg.QueryRowContext(f.ctx, `SELECT count(*) FROM pg_attribute WHERE attrelid=$1::regclass AND attname='dataset_id' AND NOT attisdropped`, table).Scan(&n); err != nil { t.Fatalf("查 %s 的列失败: %v", table, err) } return n > 0 } // TestSchemaDeclaresNoDatasetFKForUserData 静态判据:直接扫 schema.sql。 // // 只看运行时约束有个盲点:**新建的库走的是 CREATE TABLE**,而开发库可能压根没跑过那次迁移。 // 两边都钉住才不会漏——新加一张表时,写 CREATE TABLE 的那一刻就会被这条拦住。 func TestSchemaDeclaresNoDatasetFKForUserData(t *testing.T) { b, err := os.ReadFile(filepath.Join(moduleRoot(t), "internal", "db", "schema.sql")) if err != nil { t.Fatalf("读 schema.sql 失败: %v", err) } src := string(b) allowed := map[string]bool{} for _, n := range backupDerivedTables { allowed[n] = true } userOwned := map[string]bool{} for _, n := range userOwnedTables { userOwned[n] = true } createRe := regexp.MustCompile(`(?i)CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-z_]+)`) refRe := regexp.MustCompile(`(?i)REFERENCES\s+datasets\s*\(`) cur := "" for _, line := range strings.Split(src, "\n") { // 必须先跳过注释:这份规矩的说明文字里**就写着** REFERENCES datasets(id) 这几个字, // 不跳过会把自己的文档当成违规(实测就这么误报过一次)。 if strings.HasPrefix(strings.TrimSpace(line), "--") { continue } if m := createRe.FindStringSubmatch(line); m != nil { cur = m[1] } if !refRe.MatchString(line) { continue } if cur == "" { t.Errorf("有一条 datasets 的外键没归属到任何 CREATE TABLE:%s", strings.TrimSpace(line)) continue } if !allowed[cur] { t.Errorf("schema.sql 里 %s 声明了 datasets 的外键:它的记录不是从备份导入的,"+ "删数据集会连它一起删掉(%s)。", cur, strings.TrimSpace(line)) } } // 反向:自建数据表必须**真的存在**且带 dataset_id 列(防止有人顺手删表、或漏建列, // 让这条门禁失去意义) for _, n := range userOwnedTables { if !regexp.MustCompile(`(?i)CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?` + n + `\b`).MatchString(src) { t.Errorf("schema.sql 里找不到 %s 的建表语句", n) } } } // TestAdminHomeReportsOrphanImages 后台首页要当场报出「有图片文件在库里没有记录」。 // // 这才是真正的预防:那次事故不是「删数据集」本身,而是**行与对象悄悄对不上、没人发现**, // 等到照片看不见了才追。让它每次进后台都能看到,同样的漂移下次当场就亮。 func TestAdminHomeReportsOrphanImages(t *testing.T) { f := newMovementFixture(t) ctx, dsID, srv := f.ctx, f.dsID, f.srv srcPK, _, lat, lon := pickPlaceWithCoords(t, f) isolateImageState(t, f, lat, lon) saveTwoImages(t, f, srcPK) // 没漂移时:不该报警 rows, objects, orphans := srv.imageHealth(ctx) if rows != 2 || objects != 2 || orphans != 0 { t.Fatalf("刚存完两张应当完全对得上,实际 行%d 对象%d 落单%d", rows, objects, orphans) } // 制造事故形状:行没了、文件还在 if _, err := f.pg.ExecContext(ctx, `DELETE FROM place_images WHERE dataset_id=$1`, dsID); err != nil { t.Fatal(err) } rows, objects, orphans = srv.imageHealth(ctx) if rows != 0 || objects != 2 || orphans != 2 { t.Fatalf("行删掉之后应当报 落单 2,实际 行%d 对象%d 落单%d", rows, objects, orphans) } rec := httptest.NewRecorder() req := httptest.NewRequest("GET", "/admin", nil).WithContext(ctx) srv.adminHome(rec, req) if rec.Code != http.StatusOK { t.Fatalf("后台首页状态码 %d:%s", rec.Code, rec.Body.String()) } body := rec.Body.String() if !strings.Contains(body, "落单 2 个") { t.Error("后台首页没有报出落单数,漂移就还是静默的") } if !strings.Contains(body, "在库里没有记录") { t.Error("后台首页没解释「落单」是什么意思") } if !strings.HasSuffix(strings.TrimSpace(body), "") { t.Error("后台首页没渲染到底") } }