package ingest import ( "context" "database/sql" "fmt" "net/url" "os" "strings" "testing" "time" _ "github.com/jackc/pgx/v5/stdlib" "rond-with-you/internal/db" "rond-with-you/internal/rond" ) // ingestTestDSN 把 ROND_DSN 的库名换成专用库。Import 会把该用户的 is_active 切到自己那份, // 而 stats 的夹具是「WHERE is_active LIMIT 1」找数据集(不带 user 过滤)——共用库时, // 本用例运行期间那份临时数据集会被别的包选中,直接串味。换个库谁都不影响谁(库不存在 db.Open 会建)。 func ingestTestDSN(base string) string { u, err := url.Parse(base) if err != nil || u.Path == "" { return base } u.Path = strings.TrimSuffix(u.Path, "/") + "_ingest" return u.String() } // 脏时间(parse 阶段被 appleTime 判成 nil 的到访)导入后必须: // - 不进站点模型(visit_count 不含它); // - 按「跳过」留档,导出时原样补回(否则一趟导入导出就少一条); // - 绝不污染 datasets.first_visit_at —— 旧实现会把它回绕成 2001,时间轴直接从 2001 起算。 func TestImportKeepsDirtyTimeOutOfTimeline(t *testing.T) { base := os.Getenv("ROND_DSN") if base == "" { t.Skip("未设置 ROND_DSN") } ctx := context.Background() d, err := db.Open(ingestTestDSN(base)) if err != nil { t.Fatal(err) } // 先注册 close、后注册删用户:Cleanup 是后进先出,删用户时连接还没关。 t.Cleanup(func() { d.Close() }) if err := db.Migrate(ctx, d); err != nil { t.Fatal(err) } var userID int64 uname := fmt.Sprintf("__dirty_%d", time.Now().UnixNano()) if err := d.QueryRowContext(ctx, `INSERT INTO users (username, password_hash) VALUES ($1,'x') RETURNING id`, uname).Scan(&userID); err != nil { t.Fatal(err) } t.Cleanup(func() { d.Exec(`DELETE FROM users WHERE id=$1`, userID) }) good := time.Date(2026, 9, 20, 8, 0, 0, 0, time.UTC) b := &rond.Backup{ VisitUUID: map[int]string{1: strings.Repeat("a", 32)}, ActivityUUID: map[int]string{}, Locations: []rond.Location{{SrcPK: 1, Name: "家", Lat: 24.1, Lon: 109.1}}, Visits: []rond.Visit{ {SrcPK: 1, Arrival: &good, LocationSrcPK: intp(1)}, {SrcPK: 2, Arrival: nil, LocationSrcPK: intp(1)}, // 脏时间被 appleTime 判成 nil }, RawRows: map[string]map[int]map[string]any{ "ZVISIT": {2: {"Z_PK": int64(2), "ZARRIVALDATE_": 1e30}}, }, } datasetID, st, err := Import(ctx, d, userID, "dirty.rondbackup", strings.Repeat("0", 64), 1, b) if err != nil { t.Fatal(err) } if st.Visits != 1 { t.Errorf("只该导入 1 条到访(脏时间那条跳过),实际 %d", st.Visits) } var stored int if err := d.QueryRowContext(ctx, `SELECT count(*) FROM visits WHERE dataset_id=$1`, datasetID).Scan(&stored); err != nil { t.Fatal(err) } if stored != 1 { t.Errorf("库里到访应为 1 条,实际 %d", stored) } var archived int if err := d.QueryRowContext(ctx, `SELECT count(*) FROM entity_raw WHERE dataset_id=$1 AND entity='ZVISIT' AND skipped`, datasetID).Scan(&archived); err != nil { t.Fatal(err) } if archived != 1 { t.Errorf("脏时间的到访应当留档为 skipped,实际 %d 条", archived) } var first, last sql.NullTime if err := d.QueryRowContext(ctx, `SELECT first_visit_at, last_visit_at FROM datasets WHERE id=$1`, datasetID).Scan(&first, &last); err != nil { t.Fatal(err) } if !first.Valid || !first.Time.Equal(good) { t.Errorf("first_visit_at 应等于合法到访的时间 %s,实际 %v", good.Format(time.RFC3339), first) } }