Files
rond-with-you/internal/web/dataset_ownership_test.go
T
tamakyi 4c3032cf48 防复发:把「删数据集带走站点自建数据」做成结构性门禁 + 常驻体检
用户放弃继续救那批旧图,要求的是「别再发生同类事件」。所以不靠记性,靠结构:

1) 归属铁律(写进 schema.sql 抬头 + README 新章节)
   判断标准只有一句:**这份记录能不能从备份重新导入?**
   能(places/visits/movements/weather/activities/tags/entity_raw)⇒ 可挂
   `REFERENCES datasets(id) ON DELETE CASCADE`;
   不能(place_images/place_notes/place_votes/topics)⇒ 只留 dataset_id 当出处、**不许挂外键**。

2) 两道门禁(dataset_ownership_test.go),都做了变异验证:
   - 运行时:查 pg_constraint,凡挂 datasets 的表必须在白名单里;**反向也查**
     (白名单里的表不许被顺手摘掉,否则删数据集会留成片孤儿行)。
   - 静态:扫 schema.sql 的 CREATE TABLE。只看运行时有盲点——新建的库走 CREATE TABLE,
     开发库可能压根没跑过那次迁移。变异(把 topics 的 CASCADE 补回去)当场红。
   - 变异验证顺带发现:扫描必须先跳过 `--` 注释行,规矩的说明文字里就写着
     `REFERENCES datasets(id)`,不跳过会把自己写的文档当成违规。
   - 还发现运行时那道单独看**不够**:夹具每次跑迁移,迁移自己就把坏外键摘了,
     它永远不会在不健康的库上报警 ⇒ 补了「注入事故原样的外键再跑迁移,断言被摘掉」
     的自愈用例(并先确认变异真的生效,防假绿)。

3) 常驻体检:后台首页显示「库里 N 张 / 本地盘上 M 个 / 落单 K 个」,落单 >0 直接报警。
   事故的本质不是「删数据集」本身,而是**行与对象悄悄对不上、没人发现**,等照片看不见了才追。

自愈用例又抓出一个此前漏掉的缺口:place_notes / place_votes 上的同类外键迁移并不会摘
(原先只写了 place_images 与 topics 两句 DROP)⇒ 补成五张自建数据表全覆盖。

⚠️ 排序坑(本项目第二次踩):摘外键那段最初放在 place_images 建表**之前**,
开发库毫无症状、**全新库起不来**(ALTER 报「关系不存在」),被
TestFreshDatabaseServesAppQueries 抓住。整段已挪到 place_images 之后,并把坑写进注释。

顺带订正「删除数据集」的二次确认文案(专题改完之后它还在说专题会一起删)。
全量验收 PASS=336 FAIL=0 SKIP=0。
2026-10-01 01:15:04 +08:00

235 lines
9.4 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 (
"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), "</html>") {
t.Error("后台首页没渲染到底")
}
}