package main import ( "context" "database/sql" "database/sql/driver" "errors" "os" "reflect" "testing" "time" mysql "github.com/go-sql-driver/mysql" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" ) // A lost session must never reconnect without its temporary shadow tables. type oneSessionConnector struct { driver.Connector used bool } func (c *oneSessionConnector) Connect(ctx context.Context) (driver.Conn, error) { if c.used { return nil, errors.New("temporary-table integration session cannot reconnect") } c.used = true return c.Connector.Connect(ctx) } func TestProtectedGPSWriteMySQLTemporaryTables(t *testing.T) { dsn := os.Getenv("BACKFILL_TEST_MYSQL_DSN") if dsn == "" { t.Skip("set BACKFILL_TEST_MYSQL_DSN for isolated temporary-table integration") } cfg, err := mysql.ParseDSN(dsn) if err != nil { t.Fatal("invalid integration DSN") } cfg.ParseTime = true cfg.Loc = time.FixedZone("Asia/Shanghai", 8*3600) connector, err := mysql.NewConnector(cfg) if err != nil { t.Fatal("integration connector unavailable") } db := sql.OpenDB(&oneSessionConnector{Connector: connector}) db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) defer db.Close() ctx := context.Background() for _, table := range []string{"vehicle_daily_mileage_source", "vehicle_daily_mileage", "vehicle_data_source"} { if _, err := db.ExecContext(ctx, "CREATE TEMPORARY TABLE gps_fixture_"+table+" LIKE "+table); err != nil { t.Fatal(err) } if _, err := db.ExecContext(ctx, "CREATE TEMPORARY TABLE "+table+" LIKE gps_fixture_"+table); err != nil { t.Fatal(err) } } loc := cfg.Loc prev := time.Date(2026, 10, 20, 23, 59, 0, 0, loc) end := time.Date(2026, 10, 21, 23, 59, 0, 0, loc) const vin = "LTESTGPSPROTECT001" key := "JT808:fixture@PLATFORM:g7s" if _, err := db.Exec(`INSERT INTO vehicle_daily_mileage_source (vin,stat_date,protocol,source_key,source_ip,source_endpoint,phone,platform_name,first_total_mileage_km,latest_total_mileage_km,daily_mileage_km,sample_count,first_event_time,latest_event_time,quality_status,quality_reason) VALUES (?,'2026-10-21','JT808',?,'1.2.3.4','1.2.3.4:1','fixture','G7s',100,120,20,200,?,?,'OK','historical_source_baseline'), (?,'2026-10-21','JT808','JT808:other@PLATFORM:other','2.3.4.5','2.3.4.5:2','other','Other',100,101,1,3,?,?,'OK','historical_source_baseline')`, vin, key, prev, end, vin, prev, end.Add(-time.Minute)); err != nil { t.Fatal(err) } // Keep the existing manual final selected. This exercises the protected // write path without MySQL's temporary-table self-join restriction in the // independent projection query (covered separately by replay verification). if _, err := db.Exec(`INSERT INTO vehicle_daily_mileage_source (vin,stat_date,protocol,source_key,source_ip,daily_mileage_km,sample_count,quality_status,quality_reason,is_selected) VALUES (?,'2026-10-21','JT808','manual-report','manual-import',88,1,'OK','manual_gps_report',1)`, vin); err != nil { t.Fatal(err) } if _, err := db.Exec(`INSERT INTO vehicle_daily_mileage (vin,stat_date,protocol,source_id,daily_mileage_km) VALUES (?,'2026-10-21','JT808',0,88)`, vin); err != nil { t.Fatal(err) } agg := &metricAgg{VIN: vin, Date: "2026-10-21", Protocol: envelope.ProtocolJT808, SourceKey: key, Phone: "fixture", SourceEndpoint: "1.2.3.4:3", SourceCode: "g7s", SourceKind: "PLATFORM", PlatformName: "G7s", FirstKM: 90, LatestKM: 115, Count: 100, FirstEventTime: prev.Add(-24 * time.Hour), LatestEventTime: end.Add(-time.Hour), QualityStatus: "OK", QualityReason: "historical_source_baseline"} read := func() []any { var first, last, daily float64 var count int var firstTS, lastTS time.Time if err := db.QueryRow(`SELECT first_total_mileage_km,latest_total_mileage_km,daily_mileage_km,sample_count,first_event_time,latest_event_time FROM vehicle_daily_mileage_source WHERE vin=? AND source_key=?`, vin, key).Scan(&first, &last, &daily, &count, &firstTS, &lastTS); err != nil { t.Fatal(err) } return []any{first, last, daily, count, firstTS, lastTS} } var before []any for run := 0; run < 2; run++ { if _, err := writeAggregates(ctx, db, map[string]*metricAgg{"fixture": agg}, 500); err != nil { t.Fatal(err) } got := read() if got[0] != float64(100) || got[1] != float64(120) || got[2] != float64(20) || got[3] != 200 { t.Fatalf("durable data overwritten: %+v", got) } if run == 1 && !reflect.DeepEqual(before, got) { t.Fatalf("repeated backfill changed business values: before=%+v after=%+v", before, got) } before = got } var sources int if err := db.QueryRow("SELECT COUNT(*) FROM vehicle_daily_mileage_source WHERE vin=?", vin).Scan(&sources); err != nil { t.Fatal(err) } if sources != 3 { t.Fatalf("missing durable alternate source: %d", sources) } // Newer event with a lower counter wins by time, not MAX(odometer). agg.FirstKM, agg.FirstEventTime = 100, prev agg.LatestKM, agg.LatestEventTime = 119, end.Add(time.Second) if _, err := writeAggregates(ctx, db, map[string]*metricAgg{"fixture": agg}, 500); err != nil { t.Fatal(err) } if got := read(); got[1] != float64(119) || got[2] != float64(19) { t.Fatalf("newer event rejected: %+v", got) } agg.LatestKM, agg.LatestEventTime = 121, end.Add(2*time.Second) if _, err := writeAggregates(ctx, db, map[string]*metricAgg{"fixture": agg}, 500); err != nil { t.Fatal(err) } var final float64 if err := db.QueryRow("SELECT daily_mileage_km FROM vehicle_daily_mileage WHERE vin=?", vin).Scan(&final); err != nil { t.Fatal(err) } if final != 88 { t.Fatalf("manual final overwritten: %v", final) } t.Log("temporary-table scenarios passed: incomplete RAW, alternate source retention, repeated backfill, event ordering, manual final; production rows modified=0") }