Files
rond-with-you/internal/db/fresh_db_test.go
T
tamakyi b4c2a8452f 审计修复:后端(安全 / 数据一致性 / 性能)
audit-2026-09-21.md 里的后端条目落地。每条都配了回归守卫,并逐条做过变异验证
(注入原 bug → 确认按正确理由变红 → 还原 → 变绿)。

安全
- CSRF 只加同源 Origin/Referer 守卫(csrf.go),不动模板
- 会话 cookie 的 Secure 按请求判断;两步验证两闸(loginGuard + 验证码一次性)
- 跳转收口到 safeInternalPath;clientIP 只认 XFF 最后一段
- GPX 按属主圈定且查 RowsAffected;下载名走 downloadDisposition
- 上传口统一 MaxBytesReader;落盘后的失败路径清掉半成品

数据一致性
- 区域判定两端各过一遍再 AND(apiTrack 与 movementsWhere 对齐)
- Filter.Verdict 全域一致(下推到 visitsWhere);聚合刷新必须返回 error
- 附属数据改挂坐标键,Go 与 SQL 两套表达式逐位一致
- 脏备份的伪时间/越界坐标不再进领域模型;rows 循环统一 drainRows
- 合并与导出:writeRawRow 上抛错误、悬空引用清理、原生列补全

性能
- placeCols 相关子查询带 dataset_id 前导列;findSourceArchive 先按大小筛
- 批量勾选改 ANY($3::bigint[]);fillupCache 加上限;同一查询不再查两遍

另:新增「全新库」冒烟测试 —— 空库跑完 Migrate 再用应用侧读查询真打一遍,
迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
2026-09-24 20:37:16 +08:00

165 lines
6.4 KiB
Go

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
}