feat: expand vehicle data platform capabilities
This commit is contained in:
@@ -29,6 +29,45 @@ func TestHandlerDashboardSummary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRetriesFailedNotificationAndReturnsPersistentAudit(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
principal := Principal{Name: "通知操作员", Username: "operator-a", Role: "operator", UserType: "operator"}
|
||||
retry := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/alerts/notifications/10/retry", strings.NewReader(`{"expectedAttemptCount":2,"reason":"已确认短信网关恢复","idempotencyKey":"notification-retry-10-attempt-3"}`))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), principal))
|
||||
handler.ServeHTTP(retry, request)
|
||||
if retry.Code != http.StatusOK || !strings.Contains(retry.Body.String(), `"attemptCount":3`) || !strings.Contains(retry.Body.String(), `"nextStatus":"reserved"`) {
|
||||
t.Fatalf("retry status=%d body=%s", retry.Code, retry.Body.String())
|
||||
}
|
||||
|
||||
audit := httptest.NewRecorder()
|
||||
auditRequest := httptest.NewRequest(http.MethodGet, "/api/v2/alerts/notifications/10/retry-audit", nil)
|
||||
auditRequest = auditRequest.WithContext(WithPrincipal(auditRequest.Context(), principal))
|
||||
handler.ServeHTTP(audit, auditRequest)
|
||||
if audit.Code != http.StatusOK || !strings.Contains(audit.Body.String(), `"reason":"已确认短信网关恢复"`) {
|
||||
t.Fatalf("audit status=%d body=%s", audit.Code, audit.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerHistoryCleanupAutomationExposesVersionedApprovalPolicy(t *testing.T) {
|
||||
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: t.TempDir()})
|
||||
handler := NewHandler(service)
|
||||
admin := WithPrincipal(context.Background(), Principal{SubjectID: "admin-1", Name: "平台管理员", Username: "admin", Role: "admin", UserType: "admin"})
|
||||
|
||||
get := httptest.NewRecorder()
|
||||
handler.ServeHTTP(get, httptest.NewRequest(http.MethodGet, "/api/v2/exports/cleanup/automation", nil).WithContext(admin))
|
||||
if get.Code != http.StatusOK || !strings.Contains(get.Body.String(), `"revision":1`) || !strings.Contains(get.Body.String(), `"approvalWindowHours":48`) {
|
||||
t.Fatalf("automation status=%d body=%s", get.Code, get.Body.String())
|
||||
}
|
||||
|
||||
update := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPut, "/api/v2/exports/cleanup/automation", strings.NewReader(`{"enabled":true,"olderThanDays":180,"intervalDays":7,"approvalWindowHours":24,"expectedRevision":1}`)).WithContext(admin)
|
||||
handler.ServeHTTP(update, request)
|
||||
if update.Code != http.StatusOK || !strings.Contains(update.Body.String(), `"enabled":true`) || !strings.Contains(update.Body.String(), `"revision":2`) || !strings.Contains(update.Body.String(), `"status":"no_candidates"`) {
|
||||
t.Fatalf("automation update status=%d body=%s", update.Code, update.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationQueueAndReview(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
|
||||
@@ -59,6 +98,111 @@ func TestHandlerReconciliationQueueAndReview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationBatchReviewReturnsPerItemResult(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/batch-actions", strings.NewReader(`{
|
||||
"items":[
|
||||
{"id":"reconciliation-demo-position","version":1},
|
||||
{"id":"missing-issue","version":1}
|
||||
],
|
||||
"status":"fixed",
|
||||
"note":"已核对定位设备与原始报文,修复结果通过复测"
|
||||
}`))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "运维乙", Role: "operator", UserType: "operator"}))
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("batch review status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
for _, expected := range []string{`"requested":2`, `"id":"reconciliation-demo-position"`, `"actor":"运维乙"`, `"id":"missing-issue"`, `"code":"RECONCILIATION_NOT_FOUND"`} {
|
||||
if !strings.Contains(response.Body.String(), expected) {
|
||||
t.Fatalf("batch review response missing %s: %s", expected, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationAssignmentAndOwnerFilter(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
dueAt := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-demo-position/assignment", strings.NewReader(fmt.Sprintf(`{"version":1,"assignee":"定位运维组","dueAt":%q}`, dueAt)))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "运维主管", Role: "operator", UserType: "operator"}))
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"assignee":"定位运维组"`) || !strings.Contains(response.Body.String(), `"action":"assign"`) {
|
||||
t.Fatalf("assignment status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
|
||||
list := httptest.NewRecorder()
|
||||
handler.ServeHTTP(list, httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues", strings.NewReader(`{"status":"active","owner":"assigned","limit":20}`)))
|
||||
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"total":1`) || !strings.Contains(list.Body.String(), `"assignee":"定位运维组"`) {
|
||||
t.Fatalf("assigned filter status=%d body=%s", list.Code, list.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationBatchAssignmentReturnsPerItemResult(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
response := httptest.NewRecorder()
|
||||
dueAt := time.Now().Add(8 * time.Hour).UTC().Format(time.RFC3339)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/batch-assignments", strings.NewReader(fmt.Sprintf(`{"items":[{"id":"reconciliation-demo-position","version":1},{"id":"missing-issue","version":1}],"assignee":"夜班运维组","dueAt":%q}`, dueAt)))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "值班主管", Role: "operator", UserType: "operator"}))
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("batch assignment status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
for _, expected := range []string{`"requested":2`, `"assignee":"夜班运维组"`, `"id":"missing-issue"`, `"code":"RECONCILIATION_NOT_FOUND"`} {
|
||||
if !strings.Contains(response.Body.String(), expected) {
|
||||
t.Fatalf("batch assignment response missing %s: %s", expected, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationDirectoryAndFilteredExport(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
directory := httptest.NewRecorder()
|
||||
directoryRequest := httptest.NewRequest(http.MethodGet, "/api/v2/reconciliation/assignees?search=current", nil)
|
||||
directoryRequest = directoryRequest.WithContext(WithPrincipal(directoryRequest.Context(), Principal{Name: "Current Operator", Username: "current", Role: "operator", UserType: "operator"}))
|
||||
handler.ServeHTTP(directory, directoryRequest)
|
||||
if directory.Code != http.StatusOK || !strings.Contains(directory.Body.String(), `"current":true`) || !strings.Contains(directory.Body.String(), `"name":"Current Operator"`) {
|
||||
t.Fatalf("directory status=%d body=%s", directory.Code, directory.Body.String())
|
||||
}
|
||||
|
||||
exported := httptest.NewRecorder()
|
||||
handler.ServeHTTP(exported, httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/export", strings.NewReader(`{"status":"active","owner":"unassigned"}`)))
|
||||
if exported.Code != http.StatusOK || exported.Header().Get("Content-Type") != "text/csv; charset=utf-8" || exported.Header().Get("X-Export-Count") != "1" || !strings.Contains(exported.Body.String(), "多来源实时位置漂移") {
|
||||
t.Fatalf("export status=%d headers=%v body=%s", exported.Code, exported.Header(), exported.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerReconciliationArchiveAndRestoreExposeAuditReceipt(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
admin := Principal{Name: "质量治理管理员", Username: "quality-admin", Role: "admin", UserType: "admin"}
|
||||
|
||||
archive := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-demo-source/archive", strings.NewReader(`{"version":2,"reason":"已完成周期复核,转入审计归档"}`))
|
||||
handler.ServeHTTP(archive, request.WithContext(WithPrincipal(request.Context(), admin)))
|
||||
if archive.Code != http.StatusOK {
|
||||
t.Fatalf("archive status=%d body=%s", archive.Code, archive.Body.String())
|
||||
}
|
||||
for _, expected := range []string{`"archivedBy":"质量治理管理员"`, `"archiveReason":"已完成周期复核,转入审计归档"`, `"action":"archive"`, `"version":3`} {
|
||||
if !strings.Contains(archive.Body.String(), expected) {
|
||||
t.Fatalf("archive response missing %s: %s", expected, archive.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
list := httptest.NewRecorder()
|
||||
handler.ServeHTTP(list, httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues", strings.NewReader(`{"scope":"archived","keyword":"周期复核","status":"all","limit":20}`)))
|
||||
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"total":1`) || !strings.Contains(list.Body.String(), `"id":"reconciliation-demo-source"`) {
|
||||
t.Fatalf("archive list status=%d body=%s", list.Code, list.Body.String())
|
||||
}
|
||||
|
||||
restore := httptest.NewRecorder()
|
||||
request = httptest.NewRequest(http.MethodPost, "/api/v2/reconciliation/issues/reconciliation-demo-source/restore", strings.NewReader(`{"version":3,"reason":"新的来源证据需要重新核对"}`))
|
||||
handler.ServeHTTP(restore, request.WithContext(WithPrincipal(request.Context(), admin)))
|
||||
if restore.Code != http.StatusOK || !strings.Contains(restore.Body.String(), `"action":"restore"`) || !strings.Contains(restore.Body.String(), `"archivedAt":""`) {
|
||||
t.Fatalf("restore status=%d body=%s", restore.Code, restore.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerV2MonitorSummaryAndMap(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
for _, test := range []struct {
|
||||
@@ -119,6 +263,29 @@ func TestHandlerV2AccessManagement(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerClaimsAccessIdentityWithAuthenticatedActor(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
|
||||
claim := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v2/access/unresolved-identities/mock-unresolved-jt808/claim", strings.NewReader(`{"vin":"LNXNEGRR7SR318212","note":"已核对来源注册信息与车辆档案"}`))
|
||||
request = request.WithContext(WithPrincipal(request.Context(), Principal{Name: "治理管理员", Role: "admin", UserType: "operator"}))
|
||||
handler.ServeHTTP(claim, request)
|
||||
if claim.Code != http.StatusOK {
|
||||
t.Fatalf("claim status=%d body=%s", claim.Code, claim.Body.String())
|
||||
}
|
||||
for _, expected := range []string{`"vin":"LNXNEGRR7SR318212"`, `"claimedBy":"治理管理员"`, `"profileComplete":false`, `"auditId":`} {
|
||||
if !strings.Contains(claim.Body.String(), expected) {
|
||||
t.Fatalf("claim response missing %s: %s", expected, claim.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
queue := httptest.NewRecorder()
|
||||
handler.ServeHTTP(queue, httptest.NewRequest(http.MethodPost, "/api/v2/access/unresolved-identities", strings.NewReader(`{"limit":20}`)))
|
||||
if queue.Code != http.StatusOK || !strings.Contains(queue.Body.String(), `"total":0`) {
|
||||
t.Fatalf("claimed identity must leave unresolved queue: status=%d body=%s", queue.Code, queue.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerV2TrackPlayback(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -166,14 +333,46 @@ func TestHandlerV2HistoryCatalogAndMultiVehicleQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistorySeriesGrainAndBounds(t *testing.T) {
|
||||
func TestHandlerV2HistoryReturnsPerVehicleQueryReceipt(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v2/history/query?keywords=%E5%B7%9DAHTWO1,NO_HISTORY_VEHICLE&category=location&limit=10", 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 HistoryDataResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode history response: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if body.Data.Summary.RequestedVehicleCount != 2 || len(body.Data.Summary.Vehicles) != 2 {
|
||||
t.Fatalf("history query receipt must preserve both requested vehicles: %+v", body.Data.Summary)
|
||||
}
|
||||
if body.Data.Summary.Vehicles[0].Status != "matched" || body.Data.Summary.Vehicles[0].RowCount == 0 {
|
||||
t.Fatalf("known vehicle should expose matched row evidence: %+v", body.Data.Summary.Vehicles[0])
|
||||
}
|
||||
if body.Data.Summary.Vehicles[1].Keyword != "NO_HISTORY_VEHICLE" || body.Data.Summary.Vehicles[1].Status != "no_data" || body.Data.Summary.Vehicles[1].RowCount != 0 {
|
||||
t.Fatalf("empty vehicle should remain visible as a no-data receipt: %+v", body.Data.Summary.Vehicles[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryWindowGrainAndBounds(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)
|
||||
}
|
||||
if got := historySeriesGrain(30*24*time.Hour, 240); got != 21600 {
|
||||
t.Fatalf("30d / 240 should use six-hour buckets, got %d", got)
|
||||
}
|
||||
if _, _, _, _, err := historySeriesWindow("2026-06-01", "2026-07-02", time.Now()); err != nil {
|
||||
t.Fatalf("an exact 31-day history window should remain valid: %v", err)
|
||||
}
|
||||
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"},
|
||||
{path: "/api/v2/history/query?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))
|
||||
@@ -403,6 +602,21 @@ func TestHandlerVehicleDetail(t *testing.T) {
|
||||
t.Fatalf("response missing %q: %s", want, rec.Body.String())
|
||||
}
|
||||
}
|
||||
var body struct {
|
||||
Data VehicleDetail `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 !containsString(body.Data.Sources, "GB32960") || !containsString(body.Data.Sources, "JT808") {
|
||||
t.Fatalf("vehicle detail should expose protocols backed by observed data, got %+v", body.Data.Sources)
|
||||
}
|
||||
if containsString(body.Data.Sources, "YUTONG_MQTT") {
|
||||
t.Fatalf("vehicle detail must not expose an empty canonical slot as an available protocol, got %+v", body.Data.Sources)
|
||||
}
|
||||
if len(body.Data.SourceStatus) < len(canonicalVehicleProtocols) {
|
||||
t.Fatalf("diagnostic source status should retain canonical readiness slots, got %+v", body.Data.SourceStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerVehicleSourceEvidence(t *testing.T) {
|
||||
@@ -1129,6 +1343,47 @@ func TestHandlerHistoryMileageQualityOps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerMileagePostCarriesLargeArrayQuery(t *testing.T) {
|
||||
store := newCountingStore()
|
||||
handler := NewHandler(NewService(store))
|
||||
vins := make([]string, 250)
|
||||
for index := range vins {
|
||||
vins[index] = fmt.Sprintf("VIN%014d", index)
|
||||
}
|
||||
body, err := json.Marshal(MileageQuery{
|
||||
DateFrom: "2026-07-01", DateTo: "2026-07-31", VINs: vins,
|
||||
Protocols: []string{"GB32960", "JT808"}, Deduplicate: true, Limit: 10_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"/api/mileage/daily", "/api/v2/statistics/mileage"} {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s status = %d body=%s", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if got := len(strings.Split(store.lastDailyMileageQuery.Get("vins"), ",")); got != len(vins) {
|
||||
t.Fatalf("daily mileage VIN count = %d, want %d", got, len(vins))
|
||||
}
|
||||
if got := store.lastMileageStatisticsQuery.Get("protocols"); got != "GB32960,JT808" {
|
||||
t.Fatalf("statistics protocols = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerMileagePostRejectsUnknownJSONField(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v2/statistics/mileage", bytes.NewBufferString(`{"dateFrom":"2026-07-01","unknown":true}`))
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "BAD_JSON") {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerQualityIncludesNoSourceVehicles(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
Reference in New Issue
Block a user