package db import ( "context" "database/sql" "fmt" "net/url" "os" "testing" "time" "rond-with-you/internal/stats" ) // TestFreshDatabaseServesAppQueries 在一个全新库里跑完整迁移,再用应用侧的读查询真打一遍。 // // 为什么非要在「全新库」上测:schema.sql 里有一段 DO 块(附属数据改挂坐标键)依赖 // place_images / place_visitor_votes / places / datasets 已经建好。迁移块一旦排到依赖表 // 之前,**老库毫无症状**(表早就存在),只有新部署起不来 —— 这类改动在本机开发时根本看不见。 // 顺带也守住「表/列/索引/主键」在全新库上的最终形态,而不是只看「建了 10 张表就算过」。 func TestFreshDatabaseServesAppQueries(t *testing.T) { dsn := os.Getenv("ROND_DSN") if dsn == "" { t.Skip("设置 ROND_DSN 后运行全新库冒烟") } name := fmt.Sprintf("rond_test_fresh_%d", time.Now().UnixNano()) u, err := url.Parse(dsn) if err != nil { t.Fatalf("解析 ROND_DSN: %v", err) } u.Path = "/" + name d, err := Open(u.String()) if err != nil { t.Fatalf("全新库应自动建出来: %v", err) } t.Cleanup(func() { d.Close() // 必须放在 dropDatabase 之前:DROP DATABASE 要求没有连接挂着 dropDatabase(t, dsn, name) }) ctx := context.Background() if err := Migrate(ctx, d); err != nil { t.Fatalf("全新库初始化表结构失败(迁移块顺序/依赖问题会让新库起不来): %v", err) } if err := Migrate(ctx, d); err != nil { t.Fatalf("重复 Migrate 不该报错(部署会重复跑): %v", err) } tables := []string{ "users", "datasets", "activities", "tags", "places", "visits", "visit_tags", "movements", "weather", "settings", "category_labels", "fog_blocks", "fog_masks", "fog_raw_files", "gpx_tracks", "topics", "place_notes", "place_votes", "place_visitor_votes", "place_images", "entity_raw", } for _, tbl := range tables { if !hasTable(t, d, tbl) { t.Errorf("全新库里缺表 %s", tbl) } } // 这些列全靠迁移块补出来:列不在 = 块没跑到 / 被插到了依赖表前面 cols := map[string][]string{ "places": {"coord_source", "coord_sys", "src_lat", "src_lon"}, "place_notes": {"place_key", "rond_note", "dataset_id"}, "place_votes": {"place_key", "dataset_id"}, "place_visitor_votes": {"place_key"}, "place_images": {"place_key", "storage", "object_key"}, "datasets": {"content_hash", "coredata_meta"}, "users": {"totp_secret", "totp_enabled"}, "entity_raw": {"skipped"}, } for tbl, want := range cols { for _, col := range want { if !hasColumn(t, d, tbl, col) { t.Errorf("全新库里 %s.%s 没有建出来", tbl, col) } } } // 附属数据的主键必须已经搬到坐标键上:停在老主键就是「半迁移」状态 for tbl, want := range map[string]string{ "place_notes": "user_id,place_key", "place_votes": "user_id,place_key", "place_visitor_votes": "place_key,voter_key", } { var got string err := d.QueryRow(`SELECT string_agg(a.attname, ',' ORDER BY array_position(c.conkey, a.attnum)) FROM pg_constraint c JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey) WHERE c.conrelid = $1::regclass AND c.contype = 'p'`, tbl).Scan(&got) if err != nil { t.Fatalf("读 %s 主键失败: %v", tbl, err) } if got != want { t.Errorf("%s 的主键是 (%s),期望 (%s)", tbl, got, want) } } // DO 块之后才建的索引:块顺序错了它就会在缺表时报错 for _, idx := range []string{"idx_place_images_key", "idx_pvv_key", "idx_places_geo", "idx_visits_place"} { if !hasIndex(t, d, idx) { t.Errorf("全新库里缺索引 %s", idx) } } // 应用侧读查询真打一遍空库:任何查询引用了没建出来的列,这里就会报错 sd := stats.DB{DB: d} f := stats.Filter{DatasetID: 1} reads := map[string]func() error{ "Overview": func() error { _, err := sd.Overview(ctx, f); return err }, "Places": func() error { _, _, err := sd.Places(ctx, f, "visits", 20, 0); return err }, "MapPlaces": func() error { _, err := sd.MapPlaces(ctx, f, 20); return err }, "Visits": func() error { _, _, err := sd.Visits(ctx, f, 20, 0, "desc"); return err }, "RecentVisits": func() error { _, err := sd.RecentVisits(ctx, f, 5); return err }, "CityStats": func() error { _, err := sd.CityStats(ctx, f, 10); return err }, "ProvinceStats": func() error { _, err := sd.ProvinceStats(ctx, f); return err }, "ActivityStats": func() error { _, err := sd.ActivityStats(ctx, f); return err }, "ActivityCounts": func() error { _, err := sd.ActivityCounts(ctx, f); return err }, "TransportStats": func() error { _, err := sd.TransportStats(ctx, f); return err }, "TagStats": func() error { _, err := sd.TagStats(ctx, f); return err }, "Monthly": func() error { _, err := sd.Monthly(ctx, f); return err }, "Calendar": func() error { _, err := sd.Calendar(ctx, f); return err }, "Hourly": func() error { _, err := sd.Hourly(ctx, f); return err }, "Weekday": func() error { _, err := sd.Weekday(ctx, f); return err }, "Cities": func() error { _, err := sd.Cities(ctx, 1, nil, false); return err }, "Tags": func() error { _, err := sd.Tags(ctx, 1); return err }, "Activities": func() error { _, err := sd.Activities(ctx, 1); return err }, } for label, run := range reads { if err := run(); err != nil { t.Errorf("全新库上跑 %s 失败(多半是某个列/索引没建出来): %v", label, err) } } } func hasTable(t *testing.T, d *sql.DB, tbl string) bool { t.Helper() var n int if err := d.QueryRow(`SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_name=$1`, tbl).Scan(&n); err != nil { t.Fatalf("查表 %s: %v", tbl, err) } return n > 0 } func hasColumn(t *testing.T, d *sql.DB, tbl, col string) bool { t.Helper() var n int if err := d.QueryRow(`SELECT count(*) FROM information_schema.columns WHERE table_schema='public' AND table_name=$1 AND column_name=$2`, tbl, col).Scan(&n); err != nil { t.Fatalf("查列 %s.%s: %v", tbl, col, err) } return n > 0 } func hasIndex(t *testing.T, d *sql.DB, idx string) bool { t.Helper() var n int if err := d.QueryRow(`SELECT count(*) FROM pg_indexes WHERE schemaname='public' AND indexname=$1`, idx).Scan(&n); err != nil { t.Fatalf("查索引 %s: %v", idx, err) } return n > 0 }