feat: build vehicle data platform and production pipeline

This commit is contained in:
lingniu
2026-07-14 12:35:33 +08:00
parent b452be3b94
commit bb59303a4b
270 changed files with 88016 additions and 1975 deletions

View File

@@ -8,6 +8,7 @@ import (
"net/url"
"strings"
"testing"
"time"
)
func TestHandlerDashboardSummary(t *testing.T) {
@@ -26,6 +27,140 @@ func TestHandlerDashboardSummary(t *testing.T) {
}
}
func TestHandlerV2MonitorSummaryAndMap(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
for _, test := range []struct {
path string
want string
}{
{path: "/api/v2/monitor/summary", want: "drivingVehicles"},
{path: "/api/v2/monitor/map?zoom=5", want: "clusters"},
{path: "/api/v2/monitor/map?zoom=11", want: "points"},
} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, test.path, nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s status = %d body=%s", test.path, rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), test.want) {
t.Fatalf("%s missing %q: %s", test.path, test.want, rec.Body.String())
}
if test.path == "/api/v2/monitor/summary" && (!strings.Contains(rec.Body.String(), `"alertDataAvailable":true`) || !strings.Contains(rec.Body.String(), `"alertVehicles":1`)) {
t.Fatalf("monitor summary must expose filtered active alert vehicles: %s", rec.Body.String())
}
}
}
func TestHandlerRejectsInvalidMonitorBounds(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
request := httptest.NewRequest(http.MethodGet, "/api/v2/monitor/map?zoom=12&bounds=105,31,103,29", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "MONITOR_BOUNDS_INVALID") {
t.Fatalf("unexpected invalid bounds response: status=%d body=%s", response.Code, response.Body.String())
}
}
func TestHandlerV2AccessManagement(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
for _, test := range []struct {
method string
path string
body string
want string
}{
{method: http.MethodPost, path: "/api/v2/access/summary", body: `{}`, want: `"neverReported":1`},
{method: http.MethodPost, path: "/api/v2/access/vehicles", body: `{"onlineState":"offline","limit":10}`, want: `"onlineState":"offline"`},
{method: http.MethodGet, path: "/api/v2/access/thresholds", want: `"defaultThresholdSec":300`},
{method: http.MethodPut, path: "/api/v2/access/thresholds", body: `{"version":1,"defaultThresholdSec":600,"delayThresholdSec":60,"longOfflineSec":3600,"protocols":[{"protocol":"JT808","thresholdSec":300}],"actor":"handler-test"}`, want: `"version":2`},
} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body))
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s %s status=%d body=%s", test.method, test.path, rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), test.want) {
t.Fatalf("%s %s missing %s body=%s", test.method, test.path, test.want, rec.Body.String())
}
}
}
func TestHandlerV2TrackPlayback(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v2/tracks?keyword=%E5%B7%9DAHTWO1&maxPoints=8", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data TrackPlaybackResponse `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String())
}
if body.Data.VIN != "LNXNEGRR7SR318212" || body.Data.Summary.PointCount < 10 {
t.Fatalf("track should resolve vehicle and summarize full point set: %+v", body.Data)
}
if len(body.Data.Points) != 8 || !body.Data.Sampled {
t.Fatalf("track should server-sample map payload to requested bound: %+v", body.Data)
}
if len(body.Data.Events) < 2 || len(body.Data.Sources) != 1 {
t.Fatalf("track should expose events and source evidence: %+v", body.Data)
}
}
func TestHandlerV2HistoryCatalogAndMultiVehicleQuery(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
for _, test := range []struct{ path, want string }{
{path: "/api/v2/metrics", want: "sourceFields"},
{path: "/api/v2/vehicles/%E5%B7%9DAHTWO1/telemetry/latest", want: "sourceField"},
{path: "/api/v2/history/metrics", want: "dailyMileageKm"},
{path: "/api/v2/history/query?keywords=%E5%B7%9DAHTWO1,%E7%B2%A4AG18312&category=location&limit=10", want: "queryDurationMs"},
{path: "/api/v2/history/query?keyword=%E5%B7%9DAHTWO1&category=raw&limit=10", want: "gb32960.vehicle.speed_kmh"},
{path: "/api/v2/history/series?keyword=%E5%B7%9DAHTWO1&dateFrom=2026-07-03T18%3A00&dateTo=2026-07-03T21%3A00&targetPoints=120", want: "grainSeconds"},
} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, test.path, nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s status=%d body=%s", test.path, rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), test.want) {
t.Fatalf("%s missing %q body=%s", test.path, test.want, rec.Body.String())
}
}
}
func TestHistorySeriesGrainAndBounds(t *testing.T) {
if got := historySeriesGrain(24*time.Hour, 240); got != 900 {
t.Fatalf("24h / 240 should select the next safe nice bucket, got %d", got)
}
handler := NewHandler(NewService(NewMockStore()))
for _, test := range []struct{ path, code string }{
{path: "/api/v2/history/series?category=raw&keyword=%E5%B7%9DAHTWO1", code: "HISTORY_SERIES_CATEGORY_UNSUPPORTED"},
{path: "/api/v2/history/series?keyword=%E5%B7%9DAHTWO1&dateFrom=2026-01-01T00%3A00&dateTo=2026-03-01T00%3A00", code: "HISTORY_TIME_RANGE_TOO_LARGE"},
} {
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, test.path, nil))
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), test.code) {
t.Fatalf("%s expected %s, status=%d body=%s", test.path, test.code, rec.Code, rec.Body.String())
}
}
}
func TestHandlerV2HistoryRequiresBoundedVehicleScope(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v2/history/query?category=location", nil)
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "VEHICLE_KEY_REQUIRED") {
t.Fatalf("unbounded history query should be rejected: status=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestHandlerVehicles(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()