package platform import ( "context" "fmt" "net/url" "os" "path/filepath" "strconv" "strings" "sync" "testing" "time" ) func exportAdminContext() context.Context { return WithPrincipal(context.Background(), Principal{SubjectID: "1", Name: "平台管理员", Username: "admin", Role: "admin", UserType: "admin", AuthProvider: "local"}) } type concurrentHistoryStore struct { *MockStore mu sync.Mutex active int max int } func (s *concurrentHistoryStore) HistoryLocationsFromTDengine(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) { s.mu.Lock() s.active++ if s.active > s.max { s.max = s.active } s.mu.Unlock() defer func() { s.mu.Lock() s.active-- s.mu.Unlock() }() time.Sleep(20 * time.Millisecond) return s.MockStore.HistoryLocationsFromTDengine(ctx, query) } func TestHistoryDataQueriesMultipleVehiclesConcurrentlyAndPreservesReceiptOrder(t *testing.T) { store := &concurrentHistoryStore{MockStore: NewMockStore()} response, err := NewService(store).HistoryData(context.Background(), url.Values{ "keywords": {"粤AG18312,川AHTWO1,豫A88888"}, "category": {"location"}, "dateFrom": {"2026-06-23T00:00"}, "dateTo": {"2026-07-23T00:00"}, "limit": {"10"}, }) if err != nil { t.Fatal(err) } if store.max < 2 { t.Fatalf("multi-vehicle history requests should overlap, max concurrency=%d", store.max) } want := []string{"粤AG18312", "川AHTWO1", "豫A88888"} if len(response.Summary.Vehicles) != len(want) { t.Fatalf("unexpected receipt count: %+v", response.Summary.Vehicles) } for index, keyword := range want { if response.Summary.Vehicles[index].Keyword != keyword { t.Fatalf("receipt order changed at %d: %+v", index, response.Summary.Vehicles) } } } func TestBatchUpdateReconciliationIssuesValidatesScopeAndKeepsPartialSuccess(t *testing.T) { service := NewService(NewMockStore()) ctx := WithPrincipal(context.Background(), Principal{Name: "批量处置员", Role: "operator", UserType: "operator"}) result, err := service.BatchUpdateReconciliationIssues(ctx, ReconciliationBatchActionRequest{ Items: []ReconciliationBatchActionItem{ {ID: "reconciliation-demo-position", Version: 1}, {ID: "missing-issue", Version: 1}, }, Status: "no_action", Note: "已核对本页所选问题,确认无需修改原始数据", }) if err != nil { t.Fatal(err) } if result.Requested != 2 || len(result.Succeeded) != 1 || len(result.Skipped) != 1 { t.Fatalf("unexpected result: %+v", result) } if result.Succeeded[0].ResolvedBy != "批量处置员" || result.Skipped[0].Code != "RECONCILIATION_NOT_FOUND" { t.Fatalf("unexpected per-item evidence: %+v", result) } _, err = service.BatchUpdateReconciliationIssues(ctx, ReconciliationBatchActionRequest{ Items: []ReconciliationBatchActionItem{{ID: "reconciliation-demo-position", Version: 2}, {ID: "reconciliation-demo-position", Version: 2}}, Status: "fixed", Note: "重复选择应在写入前被拒绝", }) if clientErr, ok := asClientError(err); !ok || clientErr.Code != "RECONCILIATION_BATCH_DUPLICATE_ID" { t.Fatalf("duplicate ids should be rejected, err=%v", err) } } func TestReconciliationDirectoryIncludesCurrentPrincipalAndHistoricalOwners(t *testing.T) { store := NewMockStore() service := NewService(store) ctx := WithPrincipal(context.Background(), Principal{Name: "当前值班员", Username: "operator-a", Role: "operator", UserType: "operator"}) dueAt := time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339) if _, err := service.AssignReconciliationIssue(ctx, "reconciliation-demo-position", ReconciliationAssignmentRequest{Version: 1, Assignee: "定位运维组", DueAt: dueAt}); err != nil { t.Fatal(err) } items, err := service.ReconciliationAssignees(ctx, "") if err != nil { t.Fatal(err) } if len(items) != 2 || !items[0].Current || items[0].Name != "当前值班员" || items[1].Name != "定位运维组" || items[1].ActiveCount != 1 { t.Fatalf("unexpected assignee directory: %+v", items) } filtered, err := service.ReconciliationAssignees(ctx, "operator-a") if err != nil || len(filtered) != 1 || filtered[0].Name != "当前值班员" { t.Fatalf("search should match username: items=%+v err=%v", filtered, err) } } func TestExportReconciliationIssuesUsesFullFilterAndSafeCSV(t *testing.T) { store := NewMockStore() store.reconciliationIssues[0].Plate = "=FORMULA" service := NewService(store) file, err := service.ExportReconciliationIssues(context.Background(), ReconciliationQuery{Status: "active", Owner: "unassigned"}) if err != nil { t.Fatal(err) } text := string(file.Content) if file.RowCount != 1 || !strings.HasPrefix(text, "\xEF\xBB\xBF") || !strings.Contains(text, "差异编号,等级,状态") || !strings.Contains(text, "'=FORMULA") { t.Fatalf("unexpected export file: rows=%d name=%q content=%q", file.RowCount, file.Name, text) } } func TestReconciliationArchiveLifecycleSeparatesCurrentAndAuditScopes(t *testing.T) { store := NewMockStore() service := NewService(store) admin := WithPrincipal(context.Background(), Principal{Name: "质量管理员", Username: "quality-admin", Role: "admin", UserType: "admin"}) archived, err := service.SetReconciliationIssueArchived(admin, "reconciliation-demo-source", true, ReconciliationLifecycleRequest{ Version: 2, Reason: "问题已自动恢复并完成月度复核,转入长期审计留存", }) if err != nil { t.Fatal(err) } if archived.ArchivedAt == "" || archived.ArchivedBy != "质量管理员" || archived.Version != 3 { t.Fatalf("archive receipt incomplete: %+v", archived) } current, err := service.ReconciliationIssues(admin, ReconciliationQuery{Scope: "current", Status: "all", Limit: 20}) if err != nil || current.Total != 1 { t.Fatalf("current scope should exclude archived issue: page=%+v err=%v", current, err) } audit, err := service.ReconciliationIssues(admin, ReconciliationQuery{Scope: "archived", Keyword: "月度复核", Status: "all", Limit: 20}) if err != nil || audit.Total != 1 || audit.Items[0].ID != archived.ID { t.Fatalf("archive scope should search audit evidence: page=%+v err=%v", audit, err) } if _, err := service.UpdateReconciliationIssue(admin, archived.ID, ReconciliationActionRequest{Version: archived.Version, Status: "pending"}); err == nil { t.Fatal("archived issue must be read-only") } else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "RECONCILIATION_ARCHIVED_READ_ONLY" { t.Fatalf("unexpected read-only error: %v", err) } operator := WithPrincipal(context.Background(), Principal{Name: "质量操作员", Role: "operator", UserType: "operator"}) if _, err := service.SetReconciliationIssueArchived(operator, archived.ID, false, ReconciliationLifecycleRequest{Version: archived.Version, Reason: "重新进入当前队列复核"}); err == nil { t.Fatal("operator must not restore audit records") } else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "PERMISSION_DENIED" { t.Fatalf("unexpected permission error: %v", err) } restored, err := service.SetReconciliationIssueArchived(admin, archived.ID, false, ReconciliationLifecycleRequest{ Version: archived.Version, Reason: "收到新的来源接入证据,需要重新核对", }) if err != nil { t.Fatal(err) } if restored.ArchivedAt != "" || restored.Status != "recovered" || restored.Version != 4 { t.Fatalf("restore must preserve conclusion while returning to current scope: %+v", restored) } if len(restored.Actions) < 2 || restored.Actions[len(restored.Actions)-1].Action != "restore" { t.Fatalf("restore audit missing: %+v", restored.Actions) } } func TestBatchReconciliationArchiveKeepsPartialFailuresRecoverable(t *testing.T) { service := NewService(NewMockStore()) admin := WithPrincipal(context.Background(), Principal{Name: "质量管理员", Role: "admin", UserType: "admin"}) result, err := service.BatchSetReconciliationIssuesArchived(admin, true, ReconciliationBatchLifecycleRequest{ Items: []ReconciliationBatchActionItem{ {ID: "reconciliation-demo-source", Version: 2}, {ID: "reconciliation-demo-position", Version: 1}, }, Reason: "已完成周期复核,批量整理到审计归档", }) if err != nil { t.Fatal(err) } if result.Requested != 2 || len(result.Succeeded) != 1 || len(result.Skipped) != 1 { t.Fatalf("unexpected batch archive result: %+v", result) } if result.Succeeded[0].ID != "reconciliation-demo-source" || result.Skipped[0].Code != "RECONCILIATION_ARCHIVE_OPEN_ISSUE" { t.Fatalf("batch archive should keep open issue unchanged: %+v", result) } } func TestHistoryExportIndexSurvivesServiceRestart(t *testing.T) { dir := t.TempDir() first := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir}) completedAt := time.Now().UTC().Format(time.RFC3339) filePath := filepath.Join(dir, "exp_completed.csv") if err := os.WriteFile(filePath, []byte("vin\nVIN001\n"), 0o640); err != nil { t.Fatal(err) } first.exportsMu.Lock() first.exports["exp_completed"] = &HistoryExportJob{ID: "exp_completed", Name: "完成任务", Status: "completed", Progress: 100, Format: "csv", Category: "location", CreatedAt: completedAt, UpdatedAt: completedAt, DownloadURL: "/api/v2/exports/exp_completed/download", filePath: filePath} first.exports["exp_running"] = &HistoryExportJob{ID: "exp_running", Name: "中断任务", Status: "running", Progress: 50, Format: "csv", Category: "location", CreatedAt: completedAt, UpdatedAt: completedAt} if err := first.persistHistoryExportsLocked(); err != nil { first.exportsMu.Unlock() t.Fatal(err) } first.exportsMu.Unlock() restarted := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir}) ctx := exportAdminContext() jobs := restarted.ListHistoryExports(ctx) if len(jobs) != 2 { t.Fatalf("jobs=%+v", jobs) } path, _, err := restarted.HistoryExportFile(ctx, "exp_completed") if err != nil || path != filePath { t.Fatalf("completed export not restored: path=%q err=%v", path, err) } for _, job := range jobs { if job.ID == "exp_running" && (job.Status != "failed" || !strings.Contains(job.Error, "服务重启")) { t.Fatalf("interrupted export should be failed: %+v", job) } } } func TestHistoryExportPageSearchesAndPaginatesBeyondFirstTwenty(t *testing.T) { exportDir := t.TempDir() service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: exportDir}) now := time.Now().UTC() service.exportsMu.Lock() for index := 0; index < 36; index++ { status := "completed" if index%5 == 0 { status = "failed" } id := fmt.Sprintf("exp_%02d", index+1) filePath := "" if status == "completed" { filePath = filepath.Join(exportDir, id+".csv") if err := os.WriteFile(filePath, []byte("vin\n"), 0o640); err != nil { service.exportsMu.Unlock() t.Fatal(err) } } ownerID := "local:subject:1" ownerUsername := "admin" if index >= 30 { ownerID = "local:subject:2" ownerUsername = "audit-peer" } service.exports[id] = &HistoryExportJob{ ID: id, Name: fmt.Sprintf("历史归档 %02d", index+1), Status: status, Category: "location", Format: "csv", Keywords: []string{fmt.Sprintf("VIN%03d", index+1)}, VehicleVINs: []string{fmt.Sprintf("VIN%03d", index+1)}, OwnerID: ownerID, OwnerUsername: ownerUsername, CreatedAt: now.Add(-time.Duration(index) * time.Hour).Format(time.RFC3339), UpdatedAt: now.Format(time.RFC3339), filePath: filePath, } } service.exportsMu.Unlock() page := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Limit: 10, Offset: 20}) if page.Total != 36 || len(page.Items) != 10 || page.Items[0].ID != "exp_21" || page.Summary.Total != 36 || page.Summary.Recoverable != 8 { t.Fatalf("unexpected page: %+v", page) } filtered := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Search: "VIN035", Status: "completed", Limit: 10}) if filtered.Total != 1 || len(filtered.Items) != 1 || filtered.Items[0].ID != "exp_35" { t.Fatalf("unexpected filtered page: %+v", filtered) } mine := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{OwnerScope: "mine", Limit: 10}) if mine.Total != 30 || len(mine.Items) != 10 || mine.Summary.Total != 30 || mine.Items[0].OwnerUsername != "admin" { t.Fatalf("admin mine scope should isolate the current account without weakening global audit access: %+v", mine) } emptyMine := service.ListHistoryExportsPage(WithPrincipal(context.Background(), Principal{SubjectID: "missing-admin", Username: "missing-admin", Role: "admin", UserType: "admin", AuthProvider: "local"}), HistoryExportQuery{OwnerScope: "mine", Limit: 10}) if emptyMine.Items == nil || emptyMine.Total != 0 || emptyMine.Summary.Total != 0 { t.Fatalf("empty owner scope should return a stable empty collection and zero summary: %+v", emptyMine) } } func TestHistoryExportPageKeepsStableDeepPaginationAcrossTenThousandTasks(t *testing.T) { service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: t.TempDir()}) now := time.Now().UTC() service.exportsMu.Lock() for index := 0; index < 10_005; index++ { id := fmt.Sprintf("scale_%05d", index+1) createdAt := now.Add(-time.Duration(index) * time.Second).Format(time.RFC3339) service.exports[id] = &HistoryExportJob{ ID: id, Name: "规模任务 " + id, Status: "cancelled", Category: "location", Format: "csv", Keywords: []string{id}, VehicleVINs: []string{id}, OwnerID: "local:subject:1", OwnerUsername: "admin", CreatedAt: createdAt, UpdatedAt: createdAt, CancelledAt: createdAt, CancelledBy: "admin", } } service.exportsMu.Unlock() page := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Limit: 50, Offset: 9_950}) if page.Total != 10_005 || page.Limit != 50 || page.Offset != 9_950 || len(page.Items) != 50 { t.Fatalf("unexpected deep page metadata: total=%d limit=%d offset=%d items=%d", page.Total, page.Limit, page.Offset, len(page.Items)) } if page.Items[0].ID != "scale_09951" || page.Items[49].ID != "scale_10000" || page.Summary.Cancelled != 10_005 { t.Fatalf("deep page must keep stable order and complete summary: first=%s last=%s summary=%+v", page.Items[0].ID, page.Items[49].ID, page.Summary) } tail := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Limit: 50, Offset: 10_000}) if tail.Total != 10_005 || len(tail.Items) != 5 || tail.Items[0].ID != "scale_10001" || tail.Items[4].ID != "scale_10005" { t.Fatalf("unexpected tail page: %+v", tail) } } func TestHistoryExportArchiveSeparatesCurrentWorkFromAuditRecords(t *testing.T) { dir := t.TempDir() service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir}) ctx := exportAdminContext() nowTime := time.Now().UTC() now := nowTime.Format(time.RFC3339) archiveFile := filepath.Join(dir, "exp_completed_archive.csv") if err := os.WriteFile(archiveFile, []byte("vin\nVIN-ARCHIVE\n"), 0o640); err != nil { t.Fatal(err) } service.exportsMu.Lock() service.exports["exp_completed_archive"] = &HistoryExportJob{ ID: "exp_completed_archive", Name: "月度历史审计包", Status: "completed", Format: "csv", Category: "location", Keywords: []string{"VIN-ARCHIVE"}, VehicleVINs: []string{"VIN-ARCHIVE"}, OwnerID: "local:subject:1", OwnerUsername: "admin", DateFrom: "2026-06-01T00:00", DateTo: "2026-06-30T23:59", CreatedAt: now, UpdatedAt: now, CompletedAt: now, ExpiresAt: nowTime.Add(12 * time.Hour).Format(time.RFC3339), Evidence: "车辆范围与账号权限已固化", filePath: archiveFile, } service.exports["exp_running_visible"] = &HistoryExportJob{ ID: "exp_running_visible", Name: "执行中任务", Status: "running", Format: "csv", Category: "location", Keywords: []string{"VIN-RUNNING"}, VehicleVINs: []string{"VIN-RUNNING"}, OwnerID: "local:subject:1", OwnerUsername: "admin", CreatedAt: now, UpdatedAt: now, } service.exportsMu.Unlock() archived, err := service.SetHistoryExportArchived(ctx, "exp_completed_archive", true) if err != nil || archived.ArchivedAt == "" || archived.ArchivedBy != "admin" || !strings.Contains(archived.Evidence, "归档") { t.Fatalf("archive failed: job=%+v err=%v", archived, err) } current := service.ListHistoryExportsPage(ctx, HistoryExportQuery{Scope: "current", Limit: 10}) if current.Total != 1 || len(current.Items) != 1 || current.Items[0].ID != "exp_running_visible" || current.Summary.Current != 1 || current.Summary.Archived != 1 || current.Summary.Active != 1 || current.Summary.Completed != 0 || current.Summary.Expiring != 0 { t.Fatalf("current queue should exclude archived audit records: %+v", current) } audit := service.ListHistoryExportsPage(ctx, HistoryExportQuery{Scope: "archived", Search: "车辆范围与账号权限", Limit: 10}) if audit.Total != 1 || len(audit.Items) != 1 || audit.Items[0].ID != "exp_completed_archive" || audit.Summary.Active != 0 || audit.Summary.Completed != 1 || audit.Summary.Expiring != 1 { t.Fatalf("archived evidence should remain searchable: %+v", audit) } expiring := service.ListHistoryExportsPage(ctx, HistoryExportQuery{Scope: "archived", Status: "expiring", Limit: 10}) if expiring.Total != 1 || len(expiring.Items) != 1 || expiring.Items[0].ID != "exp_completed_archive" { t.Fatalf("expiring filter should isolate downloadable files inside the selected archive scope: %+v", expiring) } if _, err := service.SetHistoryExportArchived(ctx, "exp_running_visible", true); err == nil { t.Fatal("active jobs must remain visible and cannot be archived") } else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "EXPORT_NOT_ARCHIVABLE" { t.Fatalf("unexpected active archive error: %v", err) } batch, err := service.BatchHistoryExports(ctx, HistoryExportBatchRequest{IDs: []string{"exp_completed_archive"}, Action: "restore"}) if err != nil || len(batch.Succeeded) != 1 || batch.Succeeded[0].ArchivedAt != "" || !strings.Contains(batch.Succeeded[0].Evidence, "移回当前") { t.Fatalf("restore batch failed: result=%+v err=%v", batch, err) } } func TestHistoryExportCleanupPreviewsProtectsAndAuditsPermanentRemoval(t *testing.T) { dir := t.TempDir() service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir}) ctx := exportAdminContext() now := time.Now().UTC() oldArchive := now.Add(-200 * 24 * time.Hour).Format(time.RFC3339) recentArchive := now.Add(-20 * 24 * time.Hour).Format(time.RFC3339) service.exportsMu.Lock() service.exports["cleanup-old"] = &HistoryExportJob{ ID: "cleanup-old", Name: "待清理年度包", Status: "expired", Category: "location", Format: "csv", Keywords: []string{"VIN-CLEAN"}, VehicleVINs: []string{"VIN-CLEAN"}, OwnerID: "local:subject:1", OwnerUsername: "admin", CreatedAt: oldArchive, UpdatedAt: oldArchive, ArchivedAt: oldArchive, ArchivedBy: "admin", FileSizeBytes: 2048, Evidence: "原范围保留", } service.exports["cleanup-protected"] = &HistoryExportJob{ ID: "cleanup-protected", Name: "长期合规包", Status: "expired", Category: "raw", Format: "csv", Keywords: []string{"VIN-PROTECT"}, VehicleVINs: []string{"VIN-PROTECT"}, OwnerID: "local:subject:other", OwnerUsername: "audit-peer", CreatedAt: oldArchive, UpdatedAt: oldArchive, ArchivedAt: oldArchive, ArchivedBy: "admin", FileSizeBytes: 4096, Evidence: "原范围保留", CleanupProtectedAt: now.Add(-24 * time.Hour).Format(time.RFC3339), CleanupProtectedBy: "admin", CleanupProtectionReason: "年度监管复核", } service.exports["cleanup-recent"] = &HistoryExportJob{ ID: "cleanup-recent", Name: "近期归档", Status: "cancelled", Category: "location", Format: "csv", Keywords: []string{"VIN-RECENT"}, VehicleVINs: []string{"VIN-RECENT"}, OwnerID: "local:subject:1", OwnerUsername: "admin", CreatedAt: recentArchive, UpdatedAt: recentArchive, ArchivedAt: recentArchive, ArchivedBy: "admin", Evidence: "近期归档", } service.exportsMu.Unlock() preview, err := service.PreviewHistoryExportCleanup(ctx, HistoryExportCleanupQuery{OlderThanDays: 180, OwnerScope: "all"}) if err != nil { t.Fatal(err) } if preview.ArchivedCount != 3 || preview.CandidateCount != 1 || preview.PlannedCount != 1 || preview.ProtectedCount != 1 || len(preview.Candidates) != 1 || preview.Candidates[0].ID != "cleanup-old" || len(preview.Protected) != 1 || preview.Protected[0].ID != "cleanup-protected" { t.Fatalf("unexpected cleanup preview: %+v", preview) } protected, err := service.SetHistoryExportCleanupProtection(ctx, "cleanup-old", HistoryExportCleanupProtectionRequest{Protected: true, Reason: "诉讼证据保全"}) if err != nil || protected.CleanupProtectedAt == "" || protected.CleanupProtectionReason != "诉讼证据保全" || !strings.Contains(protected.Evidence, "长期保留") { t.Fatalf("protect cleanup candidate failed: job=%+v err=%v", protected, err) } if _, err := service.CleanupHistoryExports(ctx, HistoryExportCleanupRequest{OlderThanDays: 180, OwnerScope: "all", PreviewToken: preview.PreviewToken}); err == nil { t.Fatal("stale preview token must be rejected after protection changes") } else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "EXPORT_CLEANUP_PREVIEW_STALE" { t.Fatalf("unexpected stale preview error: %v", err) } if _, err := service.SetHistoryExportCleanupProtection(ctx, "cleanup-old", HistoryExportCleanupProtectionRequest{Protected: false}); err != nil { t.Fatal(err) } fresh, err := service.PreviewHistoryExportCleanup(ctx, HistoryExportCleanupQuery{OlderThanDays: 180, OwnerScope: "all"}) if err != nil || fresh.CandidateCount != 1 || fresh.ProtectedCount != 1 { t.Fatalf("unexpected refreshed cleanup preview: %+v err=%v", fresh, err) } result, err := service.CleanupHistoryExports(ctx, HistoryExportCleanupRequest{OlderThanDays: 180, OwnerScope: "all", PreviewToken: fresh.PreviewToken}) if err != nil { t.Fatal(err) } if result.Record.Cleaned != 1 || result.Record.Protected != 1 || result.Record.Actor != "admin" || len(result.Deleted) != 1 || result.Deleted[0].ID != "cleanup-old" { t.Fatalf("unexpected cleanup result: %+v", result) } page := service.ListHistoryExportsPage(ctx, HistoryExportQuery{Scope: "archived", Limit: 10}) if page.Total != 2 || page.Summary.Archived != 2 { t.Fatalf("cleanup must leave protected and recent records: %+v", page) } audit, err := service.HistoryExportCleanupAudit(ctx, 20) if err != nil || len(audit) != 1 || audit[0].ID != result.Record.ID || audit[0].CandidateDigest != fresh.PreviewToken { t.Fatalf("cleanup audit must remain independently searchable: %+v err=%v", audit, err) } reloaded := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir}) reloadedAudit, err := reloaded.HistoryExportCleanupAudit(ctx, 20) if err != nil || len(reloadedAudit) != 1 || reloadedAudit[0].ID != result.Record.ID { t.Fatalf("cleanup audit must survive restart: %+v err=%v", reloadedAudit, err) } if _, exists := reloaded.historyExportJob("cleanup-old"); exists { t.Fatal("cleaned record must not return after restart") } } func TestHistoryExportCleanupRequiresAdministratorAndPreservesMoreThanFiveHundredRecords(t *testing.T) { dir := t.TempDir() service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir}) oldArchive := time.Now().UTC().Add(-400 * 24 * time.Hour).Format(time.RFC3339) service.exportsMu.Lock() for index := 1; index <= 550; index++ { id := fmt.Sprintf("persist-%03d", index) service.exports[id] = &HistoryExportJob{ ID: id, Name: id, Status: "cancelled", Category: "location", Format: "csv", Keywords: []string{id}, VehicleVINs: []string{id}, OwnerID: "local:subject:1", OwnerUsername: "admin", CreatedAt: oldArchive, UpdatedAt: oldArchive, ArchivedAt: oldArchive, ArchivedBy: "admin", } } if err := service.persistHistoryExportsLocked(); err != nil { service.exportsMu.Unlock() t.Fatal(err) } service.exportsMu.Unlock() reloaded := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir}) if page := reloaded.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Scope: "archived", Limit: 50}); page.Summary.Archived != 550 { t.Fatalf("explicit cleanup must replace silent 500-record truncation: %+v", page.Summary) } operator := WithPrincipal(context.Background(), Principal{SubjectID: "operator-1", Username: "operator", Role: "operator", UserType: "operator", AuthProvider: "local"}) if _, err := reloaded.PreviewHistoryExportCleanup(operator, HistoryExportCleanupQuery{OlderThanDays: 365}); err == nil { t.Fatal("operator must not preview permanent archive cleanup") } else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "EXPORT_CLEANUP_ADMIN_REQUIRED" { t.Fatalf("unexpected cleanup permission error: %v", err) } } func TestHistoryExportPageSortsByScopeActivityAndFutureExpiry(t *testing.T) { dir := t.TempDir() service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir}) now := time.Now().UTC() writeJob := func(id string, createdAt, archivedAt, expiresAt time.Time) { path := filepath.Join(dir, id+".csv") if err := os.WriteFile(path, []byte("vin\n"+id+"\n"), 0o640); err != nil { t.Fatal(err) } service.exports[id] = &HistoryExportJob{ ID: id, Name: id, Status: "completed", Format: "csv", Category: "location", Keywords: []string{id}, VehicleVINs: []string{id}, OwnerID: "local:subject:1", OwnerUsername: "admin", CreatedAt: createdAt.Format(time.RFC3339), UpdatedAt: archivedAt.Format(time.RFC3339), CompletedAt: createdAt.Format(time.RFC3339), ArchivedAt: archivedAt.Format(time.RFC3339), ExpiresAt: expiresAt.Format(time.RFC3339), filePath: path, } } service.exportsMu.Lock() writeJob("old-created-recently-archived", now.Add(-72*time.Hour), now.Add(-time.Minute), now.Add(12*time.Hour)) writeJob("new-created-older-archive", now.Add(-24*time.Hour), now.Add(-2*time.Hour), now.Add(4*time.Hour)) service.exportsMu.Unlock() recent := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Scope: "archived", Sort: "recent", Limit: 10}) if len(recent.Items) != 2 || recent.Items[0].ID != "old-created-recently-archived" { t.Fatalf("archived scope should prioritize the latest archive activity, not original creation time: %+v", recent.Items) } expiry := service.ListHistoryExportsPage(exportAdminContext(), HistoryExportQuery{Scope: "archived", Sort: "expiry", Limit: 10}) if len(expiry.Items) != 2 || expiry.Items[0].ID != "new-created-older-archive" { t.Fatalf("expiry sort should prioritize the first downloadable file to expire: %+v", expiry.Items) } } func TestHistoryPreferencesPersistPerAccountAndSupplyExportRetention(t *testing.T) { dir := t.TempDir() service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir}) owner := exportAdminContext() preference, err := service.UpdateHistoryPreferences(owner, HistoryPreferences{ RetentionDays: 30, FieldViews: []HistoryFieldView{{ID: "location:交付核验", Name: "交付核验", Category: "location", Keys: []string{"speedKmh", "socPercent"}}}, }) if err != nil || preference.Revision != 1 || preference.RetentionDays != 30 || len(preference.FieldViews) != 1 { t.Fatalf("save preferences: preference=%+v err=%v", preference, err) } reloaded := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir}) restored, err := reloaded.HistoryPreferences(owner) if err != nil || restored.RetentionDays != 30 || len(restored.FieldViews) != 1 || restored.FieldViews[0].Name != "交付核验" { t.Fatalf("restored preferences: preference=%+v err=%v", restored, err) } reloaded.exportSlots <- struct{}{} job, err := reloaded.CreateHistoryExport(owner, HistoryExportRequest{Keywords: []string{"川AHTWO1"}, Category: "location", Metrics: []string{"speedKmh"}, Format: "csv"}) if err != nil { <-reloaded.exportSlots t.Fatal(err) } if job.RetentionDays != 30 || !strings.Contains(job.Evidence, "文件保留 30 天") { <-reloaded.exportSlots t.Fatalf("account retention not applied: %+v", job) } if _, err := reloaded.CancelHistoryExport(owner, job.ID); err != nil { <-reloaded.exportSlots t.Fatal(err) } <-reloaded.exportSlots } type countingStore struct { *MockStore vehiclesCalls int vehicleRealtimeCalls int overviewBatchCalls int lastVehicleQuery url.Values lastRealtimeQuery url.Values lastHistoryQuery url.Values historyQueries []url.Values rawFrameQueries []RawFrameQuery lastDailyMileageQuery url.Values lastMileageStatisticsQuery url.Values } type hydrogenMetricsStore struct{ *MockStore } func (s *hydrogenMetricsStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) { consumption, rate := 3.1, 5.5 return Page[DailyMileageRow]{Items: []DailyMileageRow{{ VIN: "LB9A32A24R0LS1426", Date: "2026-07-13", DailyMileageKm: 88.7, PureHydrogenMileageKm: 56.2, HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate, }}}, nil } func (s *hydrogenMetricsStore) MileageSummary(context.Context, url.Values) (MileageSummary, error) { return MileageSummary{TotalMileageKm: 88.7, TotalPureHydrogenMileageKm: 56.2}, nil } func (s *hydrogenMetricsStore) MileageStatistics(context.Context, url.Values) (MileageStatistics, error) { consumption, rate := 3.1, 5.5 return MileageStatistics{ PeriodMileageKm: 88.7, PeriodPureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1, PeriodHydrogenConsumptionKg: consumption, HydrogenConsumptionKgPer100Km: &rate, Trend: []MileageTrendPoint{{ Date: "2026-07-13", MileageKm: 88.7, PureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1, HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate, }}, }, nil } func newCountingStore() *countingStore { return &countingStore{MockStore: NewMockStore()} } func TestHydrogenConsumptionMetricsAreNotExposedToCustomerAccounts(t *testing.T) { service := NewService(&hydrogenMetricsStore{MockStore: NewMockStore()}) validFrom := time.Date(2026, 7, 12, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) customer := WithPrincipal(context.Background(), Principal{ Name: "业务客户", Role: "customer", UserType: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426"}, VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}}, }) query := url.Values{"vins": {"LB9A32A24R0LS1426"}, "dateFrom": {"2026-07-13"}, "dateTo": {"2026-07-13"}} daily, err := service.DailyMileage(customer, query) if err != nil { t.Fatal(err) } if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 0 || daily.Items[0].HydrogenConsumptionKg != nil || daily.Items[0].HydrogenConsumptionKgPer100Km != nil { t.Fatalf("customer daily mileage exposed hydrogen metrics: %+v", daily.Items) } summary, err := service.MileageStatistics(customer, query) if err != nil { t.Fatal(err) } if summary.PeriodPureHydrogenMileageKm != 0 || summary.HydrogenMatchedMileageKm != 0 || summary.HydrogenDataDays != 0 || summary.PeriodHydrogenConsumptionKg != 0 || summary.HydrogenConsumptionKgPer100Km != nil || len(summary.Trend) != 1 || summary.Trend[0].PureHydrogenMileageKm != 0 || summary.Trend[0].HydrogenMatchedMileageKm != 0 || summary.Trend[0].HydrogenConsumptionKg != nil { t.Fatalf("customer statistics exposed hydrogen metrics: %+v", summary) } } func TestHydrogenConsumptionMetricsRemainAvailableToInternalAccounts(t *testing.T) { service := NewService(&hydrogenMetricsStore{MockStore: NewMockStore()}) query := url.Values{"vins": {"LB9A32A24R0LS1426"}, "dateFrom": {"2026-07-13"}, "dateTo": {"2026-07-13"}} daily, err := service.DailyMileage(exportAdminContext(), query) if err != nil { t.Fatal(err) } if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 56.2 || daily.Items[0].HydrogenConsumptionKg == nil || *daily.Items[0].HydrogenConsumptionKg != 3.1 { t.Fatalf("internal daily mileage lost hydrogen metrics: %+v", daily.Items) } } func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) { s.vehiclesCalls++ s.lastVehicleQuery = cloneValues(query) return s.MockStore.Vehicles(ctx, query) } func (s *countingStore) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) { s.vehicleRealtimeCalls++ s.lastRealtimeQuery = cloneValues(query) return s.MockStore.VehicleRealtime(ctx, query) } func (s *countingStore) HistoryLocationsFromTDengine(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) { s.lastHistoryQuery = cloneValues(query) s.historyQueries = append(s.historyQueries, cloneValues(query)) return s.MockStore.HistoryLocationsFromTDengine(ctx, query) } func (s *countingStore) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) { s.rawFrameQueries = append(s.rawFrameQueries, query) return s.MockStore.RawFrames(ctx, query) } func (s *countingStore) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) { s.lastDailyMileageQuery = cloneValues(query) return s.MockStore.DailyMileage(ctx, query) } func TestVehicleDetailSkipsUnneededTDengineCounts(t *testing.T) { store := newCountingStore() detail, err := NewService(store).VehicleDetail(exportAdminContext(), "LB9A32A24R0LS1426", "") if err != nil { t.Fatal(err) } if !detail.LookupResolved { t.Fatalf("vehicle detail should resolve the requested VIN: %+v", detail) } if len(store.historyQueries) == 0 { t.Fatal("vehicle detail should query bounded TDengine history previews") } for _, query := range store.historyQueries { if query.Get("skipCount") != "1" { t.Fatalf("every vehicle detail history preview should skip the full TDengine count: %+v", store.historyQueries) } } rawPreviewFound := false for _, query := range store.rawFrameQueries { if query.IncludeFields && query.Limit == 10 { rawPreviewFound = true } if !query.SkipCount { t.Fatalf("every vehicle detail raw preview should skip the full TDengine count: %+v", store.rawFrameQueries) } } if !rawPreviewFound { t.Fatalf("vehicle detail raw preview was not queried: %+v", store.rawFrameQueries) } if got := store.lastDailyMileageQuery.Get("vins"); got != "LB9A32A24R0LS1426" { t.Fatalf("vehicle detail mileage preview should use an exact VIN filter, got %q query=%+v", got, store.lastDailyMileageQuery) } if got := store.lastDailyMileageQuery.Get("vin"); got != "" { t.Fatalf("vehicle detail mileage preview must not use the fuzzy VIN filter, got %q query=%+v", got, store.lastDailyMileageQuery) } if got := store.lastDailyMileageQuery.Get("skipCount"); got != "1" { t.Fatalf("vehicle detail mileage preview should skip its unused count, got %q query=%+v", got, store.lastDailyMileageQuery) } } func (s *countingStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) { s.lastMileageStatisticsQuery = cloneValues(query) return s.MockStore.MileageStatistics(ctx, query) } func TestCustomerVehicleScopeIsInjectedAndExplicitBypassIsDenied(t *testing.T) { store := newCountingStore() service := NewService(store) principal := Principal{Name: "客户甲", Role: "customer", UserType: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426"}} ctx := WithPrincipal(context.Background(), principal) if _, err := service.Vehicles(ctx, url.Values{"limit": {"20"}}); err != nil { t.Fatalf("scoped vehicle list failed: %v", err) } if got := store.lastVehicleQuery.Get("scopeVins"); got != "LB9A32A24R0LS1426" { t.Fatalf("scopeVins=%q", got) } _, err := service.VehicleRealtime(ctx, url.Values{"vin": {"LMRKH9AC2R1004087"}}) if clientErr, ok := asClientError(err); !ok || clientErr.Code != "VEHICLE_PERMISSION_DENIED" { t.Fatalf("cross-vehicle query should be forbidden, err=%v", err) } } func TestCustomerHistoryIsClampedToVehicleGrantStart(t *testing.T) { store := newCountingStore() service := NewService(store) validFrom := time.Date(2026, 7, 10, 12, 34, 56, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) principal := Principal{ Name: "客户甲", Role: "customer", UserType: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426"}, VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}}, } ctx := WithPrincipal(context.Background(), principal) _, err := service.HistoryLocations(ctx, url.Values{ "vin": {"LB9A32A24R0LS1426"}, "dateFrom": {"2026-07-01T00:00:00+08:00"}, "dateTo": {"2026-07-11T00:00:00+08:00"}, }) if err != nil { t.Fatalf("history query failed: %v", err) } if got := store.lastHistoryQuery.Get("dateFrom"); got != validFrom.Format(time.RFC3339) { t.Fatalf("dateFrom=%q want=%q", got, validFrom.Format(time.RFC3339)) } } func TestCustomerHistoryBeforeGrantIsForbidden(t *testing.T) { service := NewService(newCountingStore()) validFrom := time.Date(2026, 7, 10, 12, 34, 56, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) ctx := WithPrincipal(context.Background(), Principal{ Name: "客户甲", Role: "customer", UserType: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426"}, VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}}, }) _, err := service.HistoryLocations(ctx, url.Values{ "vin": {"LB9A32A24R0LS1426"}, "dateFrom": {"2026-07-01T00:00:00+08:00"}, "dateTo": {"2026-07-10T12:34:56+08:00"}, }) clientErr, ok := asClientError(err) if !ok || clientErr.Code != "HISTORY_BEFORE_AUTHORIZATION" { t.Fatalf("expected HISTORY_BEFORE_AUTHORIZATION, err=%v", err) } } func TestCustomerMileageStartsAtFirstCompleteAuthorizedDay(t *testing.T) { store := newCountingStore() service := NewService(store) validFrom := time.Date(2026, 7, 10, 12, 34, 56, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) ctx := WithPrincipal(context.Background(), Principal{ Name: "客户甲", Role: "customer", UserType: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426"}, VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}}, }) if _, err := service.DailyMileage(ctx, url.Values{ "vins": {"LB9A32A24R0LS1426"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-15"}, }); err != nil { t.Fatalf("daily mileage failed: %v", err) } if got := store.lastDailyMileageQuery.Get("scopeVehicleFrom"); got != `{"LB9A32A24R0LS1426":"2026-07-11"}` { t.Fatalf("scopeVehicleFrom=%q", got) } } func TestCustomerMileageFailsClosedWithoutGrantTime(t *testing.T) { service := NewService(newCountingStore()) ctx := WithPrincipal(context.Background(), Principal{ Name: "客户甲", Role: "customer", UserType: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426"}, }) _, err := service.MileageStatistics(ctx, url.Values{ "vins": {"LB9A32A24R0LS1426"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-15"}, }) clientErr, ok := asClientError(err) if !ok || clientErr.Code != "HISTORY_SCOPE_UNAVAILABLE" { t.Fatalf("expected HISTORY_SCOPE_UNAVAILABLE, err=%v", err) } } func TestCustomerSourceEvidenceUsesOnlyCompleteAuthorizedDays(t *testing.T) { service := NewService(newCountingStore()) shanghai := time.FixedZone("Asia/Shanghai", 8*60*60) validFrom := time.Date(2026, 7, 10, 12, 34, 56, 0, shanghai) validTo := time.Date(2026, 7, 15, 10, 0, 0, 0, shanghai) ctx := WithPrincipal(context.Background(), Principal{ Name: "客户甲", Role: "customer", UserType: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426"}, VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom, ValidTo: &validTo}}, }) if _, err := service.VehicleSourceEvidence(ctx, "LB9A32A24R0LS1426", "2026-07-10"); err == nil { t.Fatal("partial authorization start day should not expose daily source evidence") } else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "HISTORY_BEFORE_AUTHORIZATION" { t.Fatalf("expected HISTORY_BEFORE_AUTHORIZATION, err=%v", err) } if _, err := service.VehicleSourceEvidence(ctx, "LB9A32A24R0LS1426", "2026-07-11"); err != nil { t.Fatalf("first complete authorization day should be allowed: %v", err) } if _, err := service.VehicleSourceEvidence(ctx, "LB9A32A24R0LS1426", "2026-07-15"); err == nil { t.Fatal("partial authorization end day should not expose daily source evidence") } else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "HISTORY_AFTER_AUTHORIZATION" { t.Fatalf("expected HISTORY_AFTER_AUTHORIZATION, err=%v", err) } } func TestVehicleSourceDiagnosticExposesOpaqueReferencesAndElectionReasons(t *testing.T) { service := NewService(NewMockStore()) ctx := WithPrincipal(context.Background(), Principal{Name: "运维员", Role: "operator", UserType: "operator"}) diagnostic, err := service.VehicleSourceDiagnostic(ctx, "LB9A32A24R0LS1426") if err != nil { t.Fatalf("VehicleSourceDiagnostic returned error: %v", err) } if diagnostic.Policy.Version != 1 || diagnostic.Access == nil || diagnostic.Evidence.RecommendedLocationProtocol != "JT808" { t.Fatalf("diagnostic lost access or policy evidence: %+v", diagnostic) } if diagnostic.RecommendationReason == "" || diagnostic.RefreshHint == "" { t.Fatalf("diagnostic must explain recommendation and refresh boundary: %+v", diagnostic) } for _, source := range diagnostic.Evidence.LocationSources { if len(source.SourceRef) != 64 || source.SelectionReason == "" || source.FirstSeenAt == "" { t.Fatalf("source diagnostic is incomplete: %+v", source) } if strings.Contains(source.SourceRef, "jt808") || strings.Contains(source.SourceRef, "g7") { t.Fatalf("source reference must stay opaque: %+v", source) } } } func TestVehicleSourcePolicyUpdateRequiresAdminAndUsesOptimisticVersion(t *testing.T) { service := NewService(NewMockStore()) operator := WithPrincipal(context.Background(), Principal{Name: "运维员", Role: "operator", UserType: "operator"}) diagnostic, err := service.VehicleSourceDiagnostic(operator, "LB9A32A24R0LS1426") if err != nil { t.Fatal(err) } source := diagnostic.Evidence.LocationSources[1] if _, err := service.UpdateVehicleSourcePolicy(operator, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{ Version: diagnostic.Policy.Version, SourceRef: source.SourceRef, Enabled: false, Priority: source.Priority, Remark: "人工核验", }); err == nil { t.Fatal("operator must not modify source policy") } admin := WithPrincipal(context.Background(), Principal{Name: "平台管理员", Role: "admin", UserType: "admin"}) updated, err := service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{ Version: diagnostic.Policy.Version, SourceRef: source.SourceRef, Enabled: false, Priority: source.Priority, Remark: "人工核验", }) if err != nil { t.Fatalf("admin source policy update failed: %v", err) } if updated.Policy.Version != 2 || len(updated.Policy.Audit) != 1 || updated.Policy.Audit[0].Actor != "平台管理员" { t.Fatalf("source policy update must version and audit: %+v", updated.Policy) } if _, err := service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{ Version: 2, SourceRef: source.SourceRef, ProviderName: "G7s", Enabled: false, Priority: source.Priority, }); err == nil { t.Fatal("provider maintenance must require an authoritative evidence note") } updated, err = service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{ Version: 2, SourceRef: source.SourceRef, ProviderName: "G7s", ProviderEvidence: "GPS 运维终端清单 2026-07-16", Enabled: false, Priority: source.Priority, Remark: "人工核验", }) if err != nil { t.Fatalf("admin provider update failed: %v", err) } if updated.Policy.Version != 3 || updated.Policy.Audit[0].ChangeType != "provider" { t.Fatalf("provider update must share optimistic versioning and audit: %+v", updated.Policy) } if !strings.Contains(updated.Policy.Audit[0].Summary, "核验依据:GPS 运维终端清单 2026-07-16") || strings.Contains(updated.Policy.Audit[0].Summary, "人工核验") { t.Fatalf("provider evidence and policy remark must remain separate: %+v", updated.Policy.Audit[0]) } providerFound := false for _, item := range updated.Evidence.LocationSources { if item.SourceRef == source.SourceRef && item.ProviderOverride == "G7s" && item.SourceLabel == "G7s" { providerFound = true } } if !providerFound { t.Fatalf("provider update must immediately affect source evidence: %+v", updated.Evidence.LocationSources) } if _, err := service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{ Version: 1, SourceRef: source.SourceRef, Enabled: true, Priority: source.Priority, }); err == nil { t.Fatal("stale source policy update must conflict") } } func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) { s.overviewBatchCalls++ return s.MockStore.VehicleServiceOverviews(ctx, query) } func TestVehicleServiceOverviewUsesBatchDataPath(t *testing.T) { store := newCountingStore() service := NewService(store) overview, err := service.VehicleServiceOverview(context.Background(), "粤AG18312", "") if err != nil { t.Fatalf("VehicleServiceOverview returned error: %v", err) } if overview.VIN != "LB9A32A24R0LS1426" { t.Fatalf("expected canonical VIN from overview, got %+v", overview) } if store.overviewBatchCalls != 1 || store.vehiclesCalls != 0 || store.vehicleRealtimeCalls != 0 { t.Fatalf("single overview should use one batch store call, batch=%d vehicles=%d realtime=%d", store.overviewBatchCalls, store.vehiclesCalls, store.vehicleRealtimeCalls) } } func TestVehicleServiceOverviewsUsesBatchDataPath(t *testing.T) { store := newCountingStore() service := NewService(store) page, err := service.VehicleServiceOverviews(context.Background(), VehicleOverviewBatchQuery{ Keywords: []string{"粤AG18312", "LMRKH9AC2R1004087"}, Limit: 200, }) if err != nil { t.Fatalf("VehicleServiceOverviews returned error: %v", err) } if page.Total != 2 || len(page.Items) != 2 { t.Fatalf("expected two overview rows, got %+v", page) } if store.vehiclesCalls > 1 || store.vehicleRealtimeCalls > 1 { t.Fatalf("batch overview should avoid per-keyword store calls, vehicles=%d realtime=%d", store.vehiclesCalls, store.vehicleRealtimeCalls) } } func TestVehicleServiceSummaryCountsProtocolOnlineByProtocolSlot(t *testing.T) { service := NewService(NewMockStore()) summary, err := service.VehicleServiceSummary(context.Background()) if err != nil { t.Fatalf("VehicleServiceSummary returned error: %v", err) } byProtocol := map[string]ProtocolStat{} for _, protocol := range summary.Protocols { byProtocol[protocol.Protocol] = protocol } gb32960 := byProtocol["GB32960"] if gb32960.Total != 2 { t.Fatalf("expected two GB32960 source slots, got %+v", gb32960) } if gb32960.Online != 1 { t.Fatalf("GB32960 online count must use GB32960 source status only, got %+v", gb32960) } } func TestCompleteProtocolStatsIncludesCanonicalSlots(t *testing.T) { stats := completeProtocolStats([]ProtocolStat{{Protocol: "JT808", Online: 2, Total: 5}}) byProtocol := map[string]ProtocolStat{} for _, stat := range stats { byProtocol[stat.Protocol] = stat } for _, protocol := range canonicalVehicleProtocols { if _, ok := byProtocol[protocol]; !ok { t.Fatalf("canonical protocol %s should be present, got %+v", protocol, stats) } } if byProtocol["JT808"].Online != 2 || byProtocol["JT808"].Total != 5 { t.Fatalf("existing protocol stats should be preserved, got %+v", byProtocol["JT808"]) } if byProtocol["GB32960"].Online != 0 || byProtocol["GB32960"].Total != 0 { t.Fatalf("missing canonical protocol should be exposed as zero slot, got %+v", byProtocol["GB32960"]) } } func TestSummarizeTrackUsesMileageAndChronology(t *testing.T) { points := []HistoryLocationRow{ {DeviceTime: "2026-07-03 10:00:00", TotalMileageKm: 100, MileageAvailable: true, SpeedKmh: 0, Longitude: 113.1, Latitude: 23.1}, {DeviceTime: "2026-07-03 10:30:00", TotalMileageKm: 112.5, MileageAvailable: true, SpeedKmh: 50, Longitude: 113.2, Latitude: 23.2}, } summary := summarizeTrack(points) if summary.DistanceKm != 12.5 || summary.DistanceMethod != "odometer" || summary.DurationSeconds != 1800 || summary.MaximumSpeedKmh != 50 { t.Fatalf("unexpected track summary: %+v", summary) } } func TestSummarizeTrackUsesCoordinatesWhenProtocolMileageIsMissing(t *testing.T) { points := []HistoryLocationRow{ {Protocol: "JT808", DeviceTime: "2026-07-03 10:00:00", Longitude: 113.1, Latitude: 23.1}, {Protocol: "JT808", DeviceTime: "2026-07-03 10:01:00", Longitude: 113.101, Latitude: 23.101}, } summary := summarizeTrack(points) if summary.DistanceMethod != "coordinates" || summary.DistanceKm <= 0 { t.Fatalf("missing protocol mileage must use an explicitly labelled coordinate distance: %+v", summary) } } func TestTrackEventKeepsSynchronizedTelemetryEvidence(t *testing.T) { direction := int64(88) alarm := int64(2) event := trackEvent(7, "alarm", "报警点", HistoryLocationRow{DeviceTime: "2026-07-03 10:00:00", SpeedKmh: 36, SOCPercent: 78.5, SOCAvailable: true, DirectionDeg: &direction, AlarmFlag: &alarm, Longitude: 113.2, Latitude: 23.1}) if event.Index != 7 || !event.SOCAvailable || event.SOCPercent != 78.5 || event.DirectionDeg == nil || *event.DirectionDeg != 88 || event.AlarmFlag == nil || *event.AlarmFlag != 2 { t.Fatalf("track event lost synchronized telemetry evidence: %+v", event) } } func TestSampleTrackPointsPreservesEndpoints(t *testing.T) { points := make([]HistoryLocationRow, 10) for index := range points { points[index].DeviceTime = strconv.Itoa(index) } sampled := sampleTrackPoints(points, 4) if len(sampled) != 4 || sampled[0].DeviceTime != "0" || sampled[len(sampled)-1].DeviceTime != "9" { t.Fatalf("sampling should preserve endpoints: %+v", sampled) } } func TestTrackAnalysisFiltersInvalidDuplicateAndDriftPoints(t *testing.T) { points := []HistoryLocationRow{ {DeviceTime: "2026-07-03 10:00:00", Protocol: "JT808", Longitude: 113.1, Latitude: 23.1}, {DeviceTime: "2026-07-03 10:00:00", Protocol: "JT808", Longitude: 113.1, Latitude: 23.1}, {DeviceTime: "2026-07-03 10:01:00", Protocol: "JT808", Longitude: 114.1, Latitude: 24.1}, {DeviceTime: "2026-07-03 10:02:00", Protocol: "JT808", Longitude: 0, Latitude: 0}, {DeviceTime: "2026-07-03 10:03:00", Protocol: "JT808", Longitude: 113.101, Latitude: 23.101}, } clean, quality := analyzeTrackPoints(points) if len(clean) != 2 || quality.ValidPoints != 2 || quality.DuplicatePoints != 1 || quality.DriftPoints != 1 || quality.InvalidCoordinatePoints != 1 { t.Fatalf("unexpected track quality projection: clean=%+v quality=%+v", clean, quality) } if quality.Status != "warning" { t.Fatalf("filtered evidence must surface as warning: %+v", quality) } } func TestTrackPrimarySourcePreventsParallelProtocolsFromBecomingOneRoute(t *testing.T) { points := []HistoryLocationRow{ {DeviceTime: "2026-07-03 10:00:00", Protocol: "GB32960", Longitude: 113.1, Latitude: 23.1}, {DeviceTime: "2026-07-03 10:00:00", Protocol: "JT808", Longitude: 103.1, Latitude: 30.1}, {DeviceTime: "2026-07-03 10:01:00", Protocol: "GB32960", Longitude: 113.101, Latitude: 23.101}, {DeviceTime: "2026-07-03 10:01:00", Protocol: "JT808", Longitude: 103.101, Latitude: 30.101}, {DeviceTime: "2026-07-03 10:02:00", Protocol: "JT808", Longitude: 103.102, Latitude: 30.102}, } clean, quality := analyzeTrackPoints(points) if len(clean) != 5 || quality.DriftPoints != 0 { t.Fatalf("parallel protocols must be checked within source: clean=%+v quality=%+v", clean, quality) } selected, protocol, alternate := selectTrackPrimarySource(clean, "") if protocol != "JT808" || len(selected) != 3 || alternate != 2 { t.Fatalf("unexpected primary source projection: protocol=%s selected=%+v alternate=%d", protocol, selected, alternate) } explicit, protocol, alternate := selectTrackPrimarySource(clean, "GB32960") if protocol != "GB32960" || len(explicit) != 2 || alternate != 3 { t.Fatalf("explicit source must win: protocol=%s selected=%+v alternate=%d", protocol, explicit, alternate) } } func TestTrackPrimarySourcePrefersUsefulMovementOverHighFrequencyStaticHeartbeat(t *testing.T) { points := make([]HistoryLocationRow, 0, 120) for index := 0; index < 100; index++ { points = append(points, HistoryLocationRow{ DeviceTime: time.Date(2026, 7, 16, 8, 0, index, 0, time.UTC).Format(time.RFC3339), Protocol: "YUTONG_MQTT", Longitude: 121.400000, Latitude: 31.200000, }) } for index := 0; index < 20; index++ { points = append(points, HistoryLocationRow{ DeviceTime: time.Date(2026, 7, 16, 8, index, 0, 0, time.UTC).Format(time.RFC3339), Protocol: "JT808", Longitude: 121.400000 + float64(index)*0.002, Latitude: 31.200000 + float64(index)*0.001, SpeedKmh: 36, }) } selected, protocol, alternate := selectTrackPrimarySource(points, "") if protocol != "JT808" || len(selected) != 20 || alternate != 100 { t.Fatalf("moving source must beat static heartbeat volume: protocol=%s selected=%d alternate=%d", protocol, len(selected), alternate) } } func TestTrackSegmentsExposeStopsAndDataGapsWithoutClaimingIgnition(t *testing.T) { points := []HistoryLocationRow{ {DeviceTime: "2026-07-03 10:00:00", SpeedKmh: 0, Longitude: 113.1, Latitude: 23.1}, {DeviceTime: "2026-07-03 10:02:00", SpeedKmh: 0, Longitude: 113.10001, Latitude: 23.10001}, {DeviceTime: "2026-07-03 10:04:00", SpeedKmh: 0, Longitude: 113.10002, Latitude: 23.10002}, {DeviceTime: "2026-07-03 10:05:00", SpeedKmh: 30, Longitude: 113.11, Latitude: 23.11}, {DeviceTime: "2026-07-03 10:20:00", SpeedKmh: 30, Longitude: 113.12, Latitude: 23.12}, } segments := buildTrackSegments(points) if len(segments) != 3 || segments[0].Type != "stopped" || segments[1].Type != "moving" || segments[2].Type != "gap" { t.Fatalf("unexpected inferred segments: %+v", segments) } stops := buildTrackStops(points, segments) if len(stops) != 1 || stops[0].DurationSeconds != 240 || !strings.Contains(stops[0].Evidence, "不代表点火状态") { t.Fatalf("unexpected inferred stops: %+v", stops) } } func TestTrackSegmentsDoNotTreatSubsecondSamplesAsDataGaps(t *testing.T) { points := []HistoryLocationRow{ {DeviceTime: "2026-07-03T10:00:00.100+08:00", SpeedKmh: 30, Longitude: 113.1, Latitude: 23.1}, {DeviceTime: "2026-07-03T10:00:00.900+08:00", SpeedKmh: 31, Longitude: 113.1001, Latitude: 23.1001}, {DeviceTime: "2026-07-03T10:00:01.700+08:00", SpeedKmh: 32, Longitude: 113.1002, Latitude: 23.1002}, } segments := buildTrackSegments(points) if len(segments) != 1 || segments[0].Type != "moving" || segments[0].PointCount != 3 { t.Fatalf("subsecond telemetry must remain a continuous moving segment: %+v", segments) } } func TestMonitorBoundsAreValidatedAndApplied(t *testing.T) { bounds, ok, err := parseMonitorBounds("103,29,105,31") if err != nil || !ok || !bounds.contains(104, 30) || bounds.contains(106, 30) { t.Fatalf("unexpected monitor bounds: bounds=%+v ok=%t err=%v", bounds, ok, err) } for _, invalid := range []string{"103,29,105", "east,29,105,31", "105,31,103,29", "-181,0,1,1"} { if _, _, invalidErr := parseMonitorBounds(invalid); invalidErr == nil { t.Fatalf("invalid monitor bounds must be rejected: %q", invalid) } } } func TestMonitorMapIgnoresInvalidOptionalBounds(t *testing.T) { vehicles := Page[VehicleRealtimeRow]{ Items: []VehicleRealtimeRow{{ VIN: "LTEST000000000001", Plate: "粤A00001", Online: true, LocationAvailable: true, Longitude: 113.2, Latitude: 23.1, LastSeen: "2026-07-17T15:41:00+08:00", }}, Total: 1, } result, err := buildMonitorMapResponse(vehicles, url.Values{"zoom": {"12"}, "bounds": {"105,31,103,29"}}) if err != nil { t.Fatalf("optional invalid bounds must not fail the map: %v", err) } if result.Total != 1 || len(result.Points) != 1 { t.Fatalf("invalid bounds should fall back to the bounded unfiltered map: %+v", result) } } func TestMonitorQueryKeepsServerOwnedKeywordAndMotionStatus(t *testing.T) { query := normalizeMonitorQuery(url.Values{"keyword": {"沪A"}, "status": {"driving"}}) if query.Get("vin") != "沪A" || query.Get("limit") != "10000" { t.Fatalf("monitor query did not normalize keyword and bound: %v", query) } if !matchesMonitorStatus(VehicleRealtimeRow{Online: true, SpeedKmh: 20, LastSeen: "2026-07-14T08:00:00+08:00"}, "driving") { t.Fatal("driving row must match driving filter") } if matchesMonitorStatus(VehicleRealtimeRow{Online: true, SpeedKmh: 0, LastSeen: "2026-07-14T08:00:00+08:00"}, "driving") { t.Fatal("idle row must not match driving filter") } } func TestMonitorMapTenThousandVehiclesNeverReturnsTenThousandPoints(t *testing.T) { vehicles := syntheticMonitorVehicles(10_000) result, err := buildMonitorMapResponse(vehicles, url.Values{"zoom": {"13"}}) if err != nil { t.Fatal(err) } if (result.Mode != "clusters" && result.Mode != "mixed") || len(result.Clusters) == 0 || len(result.Points)+len(result.Clusters) > monitorPointLimit { t.Fatalf("10k unbounded map must stay aggregated: mode=%s points=%d clusters=%d", result.Mode, len(result.Points), len(result.Clusters)) } for _, cluster := range result.Clusters { if cluster.Count < 2 { t.Fatalf("singleton must be released as a point instead of a fake cluster: %+v", cluster) } } viewport, err := buildMonitorMapResponse(vehicles, url.Values{"zoom": {"11"}, "bounds": {"113,22,114,24"}}) if err != nil { t.Fatal(err) } if viewport.Mode != "points" || len(viewport.Points) >= monitorPointLimit { t.Fatalf("bounded high zoom should return a controlled point set: mode=%s points=%d", viewport.Mode, len(viewport.Points)) } } func TestMonitorWorkspaceSharesOneRealtimeSnapshot(t *testing.T) { store := newCountingStore() service := NewService(store) workspace, err := service.MonitorWorkspace(context.Background(), url.Values{ "zoom": {"13"}, "railLimit": {"2"}, }) if err != nil { t.Fatalf("MonitorWorkspace returned error: %v", err) } if store.vehicleRealtimeCalls != 1 { t.Fatalf("workspace should read one realtime snapshot, calls=%d", store.vehicleRealtimeCalls) } if workspace.Summary.TotalVehicles == 0 || workspace.Map.Total == 0 { t.Fatalf("workspace should derive summary and map from the shared snapshot: %+v", workspace) } if len(workspace.Vehicles.Items) != 2 || workspace.Vehicles.Limit != 2 || workspace.Vehicles.Total < 2 { t.Fatalf("workspace rail page should honor its bounded limit: %+v", workspace.Vehicles) } } func TestMonitorMapUsesMeaningfulClustersAndReleasesPointsAtDetailZoom(t *testing.T) { reportIntervalMs := int64(30000) vehicles := Page[VehicleRealtimeRow]{Items: []VehicleRealtimeRow{ {VIN: "NEAR-1", Plate: "粤A00001", Online: true, LocationAvailable: true, Longitude: 113.260, Latitude: 23.130, ReportIntervalMs: &reportIntervalMs}, {VIN: "NEAR-2", Plate: "粤A00002", Online: true, LocationAvailable: true, Longitude: 113.265, Latitude: 23.135}, {VIN: "SINGLE", Plate: "粤A00003", Online: true, LocationAvailable: true, Longitude: 113.600, Latitude: 23.500}, }, Total: 3, Limit: 3} mixed, err := buildMonitorMapResponse(vehicles, url.Values{"zoom": {"10"}}) if err != nil { t.Fatal(err) } if mixed.Mode != "mixed" || len(mixed.Clusters) != 1 || mixed.Clusters[0].Count != 2 || len(mixed.Points) != 1 || mixed.Points[0].VIN != "SINGLE" { t.Fatalf("zoom below the detail threshold must keep only a meaningful cluster and release its singleton: %+v", mixed) } detail, err := buildMonitorMapResponse(vehicles, url.Values{"zoom": {"11"}}) if err != nil { t.Fatal(err) } if detail.Mode != "points" || len(detail.Points) != 3 || len(detail.Clusters) != 0 { t.Fatalf("zoom 11 must release controlled data as exact points: %+v", detail) } if detail.Points[0].ReportIntervalMs == nil || *detail.Points[0].ReportIntervalMs != reportIntervalMs { t.Fatalf("map point must preserve the primary protocol report interval: %+v", detail.Points[0]) } } func TestMonitorMapNationwideReturnsScopedProvinceSeedsAndGridFallback(t *testing.T) { vehicles := Page[VehicleRealtimeRow]{Items: []VehicleRealtimeRow{ {VIN: "GUANGDONG-1", Online: true, LocationAvailable: true, Longitude: 113.260, Latitude: 23.130}, {VIN: "GUANGDONG-2", Online: false, LocationAvailable: true, Longitude: 113.265, Latitude: 23.135, LastSeen: "2026-07-15T08:00:00+08:00"}, {VIN: "OUTSIDE-STATUS", Online: true, LocationAvailable: true, Longitude: 121.47, Latitude: 31.23, SpeedKmh: 20}, {VIN: "NO-LOCATION", Online: true}, }, Total: 4, Limit: 4} result, err := buildMonitorMapResponse(vehicles, url.Values{ "zoom": {"5"}, "status": {"offline"}, "bounds": {"113,22,114,24"}, }) if err != nil { t.Fatal(err) } if result.Mode != "provinces" || result.Total != 1 || len(result.ProvincePoints) != 1 { t.Fatalf("nationwide response must expose only filtered, located province seeds: %+v", result) } if result.ProvincePoints[0].Status != "offline" || result.ProvincePoints[0].Longitude != 113.265 { t.Fatalf("unexpected province seed: %+v", result.ProvincePoints[0]) } if len(result.Points)+len(result.Clusters) == 0 { t.Fatal("nationwide response must retain a grid fallback when AMap district data is unavailable") } } func BenchmarkMonitorMapTenThousandVehicles(b *testing.B) { vehicles := syntheticMonitorVehicles(10_000) query := url.Values{"zoom": {"5"}} b.ReportAllocs() b.ResetTimer() for index := 0; index < b.N; index++ { if _, err := buildMonitorMapResponse(vehicles, query); err != nil { b.Fatal(err) } } } func syntheticMonitorVehicles(count int) Page[VehicleRealtimeRow] { items := make([]VehicleRealtimeRow, count) for index := range items { items[index] = VehicleRealtimeRow{ VIN: "SYNTH" + strconv.Itoa(index), Online: true, SpeedKmh: float64(index % 90), LocationAvailable: true, Longitude: 73 + float64((index/100)%200)*0.3, Latitude: 18 + float64(index%100)*0.3, LastSeen: "2026-07-14T08:00:00+08:00", } } return Page[VehicleRealtimeRow]{Items: items, Total: count, Limit: count} } func TestKeyPointSamplingPreservesEventsAndMapsToReturnedIndexes(t *testing.T) { points := make([]HistoryLocationRow, 20) for index := range points { points[index].DeviceTime = strconv.Itoa(index) } sampled, indexes := sampleTrackPointsWithKeys(points, 6, []int{5, 11}) if len(sampled) != 6 || !containsInt(indexes, 0) || !containsInt(indexes, 5) || !containsInt(indexes, 11) || !containsInt(indexes, 19) { t.Fatalf("key-point sampling lost evidence: indexes=%+v", indexes) } mapped := nearestSampledIndex(11, indexes) if mapped < 0 || mapped >= len(indexes) || indexes[mapped] != 11 { t.Fatalf("event should map to its preserved returned point: mapped=%d indexes=%+v", mapped, indexes) } } func TestTrackCoverageDoesNotPresentLatestSliceAsCompleteWindow(t *testing.T) { coverage := trackCoverage(url.Values{"dateFrom": {"2026-07-01T00:00:00"}}, 71000, 5000, 4990, 1200, true, true) if coverage.Complete || coverage.TotalPoints != 71000 || coverage.FetchedPoints != 5000 || len(coverage.LimitReasons) != 3 { t.Fatalf("unexpected bounded coverage: %+v", coverage) } if !strings.Contains(coverage.Evidence, "不代表完整时间窗") { t.Fatalf("coverage boundary must be explicit: %+v", coverage) } } func TestTrackWindowRequiresBoundedOrderedPair(t *testing.T) { if err := validateTrackWindow("2026-07-01T00:00", ""); err == nil { t.Fatal("one-sided track window must be rejected") } if err := validateTrackWindow("2026-07-08T00:00", "2026-07-01T00:00"); err == nil { t.Fatal("reversed track window must be rejected") } if err := validateTrackWindow("2026-07-01T00:00", "2026-07-09T00:00"); err == nil { t.Fatal("track window over seven days must be rejected") } if err := validateTrackWindow("2026-07-01T00:00", "2026-07-08T00:00"); err != nil { t.Fatalf("seven-day track window should be accepted: %v", err) } if err := validateTrackWindow("", ""); err != nil { t.Fatalf("unbounded latest-slice compatibility mode should remain available: %v", err) } } func containsInt(values []int, wanted int) bool { for _, value := range values { if value == wanted { return true } } return false } func TestHistoryExportRunsAsControlledAsyncJob(t *testing.T) { service := NewService(NewMockStore()) ctx := exportAdminContext() job, err := service.CreateHistoryExport(ctx, HistoryExportRequest{Keywords: []string{"川AHTWO1"}, Category: "location", Metrics: []string{"speedKmh"}, Format: "csv"}) if err != nil { t.Fatalf("create export: %v", err) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { jobs := service.ListHistoryExports(ctx) if len(jobs) > 0 && jobs[0].ID == job.ID && jobs[0].Status == "completed" { path, _, fileErr := service.HistoryExportFile(ctx, job.ID) if fileErr != nil { t.Fatalf("export file: %v", fileErr) } body, readErr := os.ReadFile(path) if readErr != nil { t.Fatalf("read export: %v", readErr) } if len(body) < 3 || string(body[:3]) != "\xEF\xBB\xBF" { t.Fatalf("CSV should include UTF-8 BOM") } if !strings.Contains(string(body), "数据质量,质量原因,证据ID") { t.Fatalf("CSV should explain platform quality: %q", body[:min(len(body), 512)]) } return } time.Sleep(10 * time.Millisecond) } t.Fatalf("export job did not complete: %+v", service.ListHistoryExports(ctx)) } func exportCustomerContext(subject, username, vin string) context.Context { return WithPrincipal(context.Background(), Principal{ SubjectID: subject, Name: username, Username: username, Role: "customer", UserType: "customer", AuthProvider: "local", CustomerRef: "customer-" + subject, VehicleVINs: []string{vin}, VehicleGrants: []VehicleGrant{{VIN: vin, ValidFrom: time.Date(2026, 7, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))}}, }) } func TestHistoryExportsAreOwnerScopedAndPersistAuditMetadata(t *testing.T) { dir := t.TempDir() service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir}) vin := "LNXNEGRR7SR318212" customerA := exportCustomerContext("101", "customer-a", vin) customerB := exportCustomerContext("102", "customer-b", vin) job, err := service.CreateHistoryExport(customerA, HistoryExportRequest{ Keywords: []string{vin}, Category: "location", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00", Metrics: []string{"speedKmh"}, Format: "csv", }) if err != nil { t.Fatal(err) } if job.OwnerID != "local:subject:101" || job.OwnerUsername != "customer-a" || job.CustomerRef != "customer-101" || len(job.VehicleScopes) != 1 || job.VehicleScopes[0].VIN != vin { t.Fatalf("owner/scope audit metadata missing: %+v", job) } if jobs := service.ListHistoryExports(customerB); len(jobs) != 0 { t.Fatalf("customer B saw customer A export: %+v", jobs) } if _, _, err := service.HistoryExportFile(customerB, job.ID); err == nil { t.Fatal("customer B should not download customer A export") } if jobs := service.ListHistoryExports(exportAdminContext()); len(jobs) != 1 || jobs[0].ID != job.ID { t.Fatalf("admin should audit all jobs: %+v", jobs) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { jobs := service.ListHistoryExports(customerA) if len(jobs) == 1 && jobs[0].Status == "completed" { path, _, fileErr := service.HistoryExportFile(customerA, job.ID) if fileErr != nil { t.Fatal(fileErr) } body, readErr := os.ReadFile(path) if readErr != nil { t.Fatal(readErr) } for _, evidence := range []string{"导出审计", "customer-a", "customer-101", "车辆 Scope", vin} { if !strings.Contains(string(body), evidence) { t.Fatalf("CSV missing audit evidence %q: %q", evidence, body[:min(len(body), 800)]) } } return } time.Sleep(10 * time.Millisecond) } t.Fatalf("customer export did not complete: %+v", service.ListHistoryExports(customerA)) } func TestHistoryExportCancellationIsOwnerScopedAuditedAndPersistent(t *testing.T) { dir := t.TempDir() service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir}) vin := "LNXNEGRR7SR318212" owner := exportCustomerContext("101", "customer-a", vin) other := exportCustomerContext("102", "customer-b", vin) service.exportSlots <- struct{}{} job, err := service.CreateHistoryExport(owner, HistoryExportRequest{ Keywords: []string{vin}, Category: "location", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00", Metrics: []string{"speedKmh"}, Format: "csv", }) if err != nil { <-service.exportSlots t.Fatal(err) } if _, err := service.CancelHistoryExport(other, job.ID); err == nil { <-service.exportSlots t.Fatal("another customer should not cancel the owner's export") } cancelled, err := service.CancelHistoryExport(owner, job.ID) if err != nil { <-service.exportSlots t.Fatal(err) } if cancelled.Status != "cancelled" || cancelled.CancelledBy != "customer-a" || cancelled.CancelledAt == "" || !strings.Contains(cancelled.Evidence, "主动取消") { <-service.exportSlots t.Fatalf("cancellation audit metadata missing: %+v", cancelled) } <-service.exportSlots deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { jobs := service.ListHistoryExports(owner) if len(jobs) == 1 && jobs[0].Status == "cancelled" { break } time.Sleep(10 * time.Millisecond) } jobs := service.ListHistoryExports(owner) if len(jobs) != 1 || jobs[0].Status != "cancelled" { t.Fatalf("cancelled queued task must never start later: %+v", jobs) } if _, _, err := service.HistoryExportFile(owner, job.ID); err == nil { t.Fatal("cancelled export must not expose a download") } if repeated, err := service.CancelHistoryExport(owner, job.ID); err != nil || repeated.Status != "cancelled" { t.Fatalf("cancelling an already cancelled task should be idempotent: job=%+v err=%v", repeated, err) } reloaded := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir}) reloadedJobs := reloaded.ListHistoryExports(owner) if len(reloadedJobs) != 1 || reloadedJobs[0].Status != "cancelled" || reloadedJobs[0].CancelledBy != "customer-a" { t.Fatalf("cancelled state should survive restart: %+v", reloadedJobs) } } func TestHistoryExportExpiryRebuildAndSafeBatchActions(t *testing.T) { dir := t.TempDir() service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{ExportDir: dir}) ctx := exportAdminContext() vin := "LNXNEGRR7SR318212" filePath := filepath.Join(dir, "exp_expired.csv") if err := os.WriteFile(filePath, []byte("vin\n"+vin+"\n"), 0o640); err != nil { t.Fatal(err) } past := time.Now().UTC().Add(-time.Hour).Format(time.RFC3339) createdAt := time.Now().UTC().Add(-8 * 24 * time.Hour).Format(time.RFC3339) service.exportsMu.Lock() service.exports["exp_expired"] = &HistoryExportJob{ ID: "exp_expired", Name: "过期任务", Status: "completed", Progress: 100, Format: "csv", Category: "location", Keywords: []string{vin}, Metrics: []string{"speedKmh"}, VehicleVINs: []string{vin}, DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00", OwnerID: "local:subject:1", OwnerUsername: "admin", RowCount: 12, CreatedAt: createdAt, UpdatedAt: createdAt, CompletedAt: createdAt, ExpiresAt: past, DownloadURL: "/api/v2/exports/exp_expired/download", Evidence: "原范围已固化", filePath: filePath, } service.exports["exp_running_batch"] = &HistoryExportJob{ ID: "exp_running_batch", Name: "执行中任务", Status: "running", Format: "csv", Category: "location", Keywords: []string{vin}, OwnerID: "local:subject:1", OwnerUsername: "admin", CreatedAt: createdAt, UpdatedAt: createdAt, Evidence: "执行中", } service.exportsMu.Unlock() jobs := service.ListHistoryExports(ctx) var expired HistoryExportJob for _, job := range jobs { if job.ID == "exp_expired" { expired = job } } if expired.Status != "expired" || expired.DownloadURL != "" || expired.ExpiredAt == "" || !strings.Contains(expired.Error, "保留期") { t.Fatalf("completed file should become an auditable expired task: %+v", expired) } if _, _, err := service.HistoryExportFile(ctx, expired.ID); err == nil { t.Fatal("expired file should not remain downloadable") } else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "EXPORT_EXPIRED" { t.Fatalf("expired download error=%v", err) } service.exportSlots <- struct{}{} rebuilt, err := service.RebuildHistoryExport(ctx, expired.ID) if err != nil { <-service.exportSlots t.Fatal(err) } if rebuilt.Status != "queued" || rebuilt.RebuiltFrom != expired.ID || !strings.Contains(rebuilt.Evidence, expired.ID) { <-service.exportSlots t.Fatalf("rebuild should create a linked queued task: %+v", rebuilt) } if _, err := service.RebuildHistoryExport(ctx, "exp_running_batch"); err == nil { <-service.exportSlots t.Fatal("active task must not be rebuildable") } batch, err := service.BatchHistoryExports(ctx, HistoryExportBatchRequest{IDs: []string{"exp_running_batch", "exp_expired"}, Action: "cancel"}) if err != nil { <-service.exportSlots t.Fatal(err) } if batch.Requested != 2 || len(batch.Succeeded) != 1 || batch.Succeeded[0].ID != "exp_running_batch" || len(batch.Skipped) != 1 || batch.Skipped[0].Code != "EXPORT_NOT_CANCELLABLE" { <-service.exportSlots t.Fatalf("batch cancellation must explicitly preserve ineligible tasks: %+v", batch) } if _, cancelErr := service.CancelHistoryExport(ctx, rebuilt.ID); cancelErr != nil { <-service.exportSlots t.Fatal(cancelErr) } <-service.exportSlots } type revocableHistoryExportStore struct { *MockStore active bool } func (s *revocableHistoryExportStore) HistoryExportScopeActive(context.Context, HistoryExportJob) (bool, error) { return s.active, nil } func TestCustomerHistoryExportStopsWhenAuthorizationIsRevoked(t *testing.T) { store := &revocableHistoryExportStore{MockStore: NewMockStore(), active: false} service := NewServiceWithRuntime(store, RuntimeInfo{ExportDir: t.TempDir()}) ctx := exportCustomerContext("101", "customer-a", "LNXNEGRR7SR318212") job, err := service.CreateHistoryExport(ctx, HistoryExportRequest{ Keywords: []string{"LNXNEGRR7SR318212"}, Category: "location", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00", Metrics: []string{"speedKmh"}, Format: "csv", }) if err != nil { t.Fatal(err) } deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { jobs := service.ListHistoryExports(ctx) if len(jobs) == 1 && jobs[0].Status == "failed" { if !strings.Contains(jobs[0].Error, "授权已变化") { t.Fatalf("unexpected revoke evidence: %+v", jobs[0]) } if _, _, fileErr := service.HistoryExportFile(ctx, job.ID); fileErr == nil { t.Fatal("revoked customer should not download export") } return } time.Sleep(10 * time.Millisecond) } t.Fatalf("revoked export did not stop: %+v", service.ListHistoryExports(ctx)) } type largeHistoryExportStore struct { *MockStore total int64 } func (s *largeHistoryExportStore) HistoryExportCount(context.Context, HistoryExportStoreQuery) (int64, error) { return s.total, nil } func (s *largeHistoryExportStore) HistoryExportBatch(_ context.Context, query HistoryExportStoreQuery, cursor HistoryExportCursor, limit int) ([]HistoryDataRow, HistoryExportCursor, error) { remaining := int(s.total) - cursor.Offset if remaining <= 0 { return []HistoryDataRow{}, cursor, nil } if remaining < limit { limit = remaining } rows := make([]HistoryDataRow, limit) for index := range rows { number := cursor.Offset + index rows[index] = HistoryDataRow{ID: "row-" + strconv.Itoa(number), VIN: query.VIN, Plate: "粤A测试", Protocol: "JT808", DeviceTime: "2026-07-14T00:00:00+08:00", ServerTime: "2026-07-14T00:00:01+08:00", Quality: "normal", Values: map[string]any{"speedKmh": float64(number % 120)}} } cursor.Offset += len(rows) return rows, cursor, nil } func TestHistoryExportStreamsBeyondLegacyLimit(t *testing.T) { assertLargeHistoryExport(t, 12_345, 10*time.Second) } func TestHistoryExportMillionRows(t *testing.T) { if os.Getenv("EXPORT_MILLION_TEST") != "1" { t.Skip("set EXPORT_MILLION_TEST=1 for the release capacity gate") } assertLargeHistoryExport(t, 1_000_000, 2*time.Minute) } func assertLargeHistoryExport(t *testing.T, total int64, timeout time.Duration) { t.Helper() dir := t.TempDir() store := &largeHistoryExportStore{MockStore: NewMockStore(), total: total} service := NewServiceWithRuntime(store, RuntimeInfo{ExportDir: dir}) ctx := exportAdminContext() job, err := service.CreateHistoryExport(ctx, HistoryExportRequest{Keywords: []string{"LNXNEGRR7SR318212"}, Category: "location", DateFrom: "2026-07-14T00:00", DateTo: "2026-07-14T06:00", Metrics: []string{"speedKmh"}, Format: "csv"}) if err != nil { t.Fatal(err) } deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { current := service.ListHistoryExports(ctx)[0] if current.Status == "failed" { t.Fatalf("export failed: %+v", current) } if current.Status == "completed" { if current.RowCount != int(total) || current.ProcessedRows != total || current.TotalRows != total || current.FileSizeBytes == 0 || current.CompletedAt == "" { t.Fatalf("incomplete evidence: %+v", current) } path, _, fileErr := service.HistoryExportFile(ctx, job.ID) if fileErr != nil { t.Fatal(fileErr) } if _, statErr := os.Stat(path + ".part"); !os.IsNotExist(statErr) { t.Fatalf("part file should be atomically removed: %v", statErr) } body, readErr := os.ReadFile(path) if readErr != nil { t.Fatal(readErr) } if !strings.Contains(string(body[:min(len(body), 512)]), "查询开始") || !strings.Contains(string(body[:min(len(body), 512)]), "速度(km/h)") { t.Fatalf("CSV metadata missing: %q", body[:min(len(body), 512)]) } return } time.Sleep(10 * time.Millisecond) } t.Fatalf("export timed out: %+v", service.ListHistoryExports(ctx)) } func TestRawMetricMetadataKeepsProtocolAndUnit(t *testing.T) { label, unit := rawMetricMetadata("jt808.location.additional.total_mileage_km") if label != "总里程 · JT808" || unit != "km" { t.Fatalf("unexpected RAW metric metadata: %q %q", label, unit) } } func TestHistoryQualityReasonsExplainLocationRawAndMileageWarnings(t *testing.T) { quality, reason := historyLocationQuality(HistoryLocationRow{Longitude: 0, Latitude: 0, SpeedKmh: 30, DeviceTime: "2026-07-14 09:29:58", ServerTime: "2026-07-14 09:29:59"}) if quality != "warning" || !strings.Contains(reason, "坐标") { t.Fatalf("invalid coordinate should be explained: %s %s", quality, reason) } quality, reason = historyRawQuality(RawFrameRow{DeviceTime: "2026-07-14 09:29:58", ServerTime: "2026-07-14 09:29:59", ParseStatus: "failed", ParseError: "checksum"}) if quality != "warning" || !strings.Contains(reason, "checksum") { t.Fatalf("parse failure should retain detail: %s %s", quality, reason) } quality, reason = historyMileageQuality(DailyMileageRow{StartMileageKm: 100, EndMileageKm: 130, DailyMileageKm: 12}) if quality != "warning" || !strings.Contains(reason, "不一致") { t.Fatalf("mileage mismatch should be explained: %s %s", quality, reason) } } func TestMergeDiscoveredRawMetricsScansFetchedScope(t *testing.T) { rows := []HistoryDataRow{ {Values: map[string]any{"frameType": "realtime"}}, {Values: map[string]any{"gb32960.vehicle.soc_percent": 88}}, } columns := mergeDiscoveredRawMetrics(historyRawMetrics(), rows) found := false for _, column := range columns { if column.Key == "gb32960.vehicle.soc_percent" { found = true break } } if !found { t.Fatalf("discovered field outside the visible page should remain selectable: %+v", columns) } } func TestRawHistorySegmentsReuseLatestTelemetryCategorySource(t *testing.T) { definitions := []MetricDefinition{ {Key: "speed_kmh", Category: "driving", SourceFields: map[string]string{"GB32960": "gb32960.vehicle.speed_kmh"}}, {Key: "stack_voltage_v", Category: "fuel-cell", SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.stack_1.avg_voltage_v"}}, } rows := []HistoryDataRow{{Values: map[string]any{ "gb32960.vehicle.speed_kmh": 38.0, "gb32960.fuel_cell.stack_1.avg_voltage_v": 62.4, "jt808.location.additional.total_mileage_km": 1200.0, }}} columns := mergeDiscoveredRawMetrics(historyRawMetrics(), rows, definitions) categories := map[string]string{} for _, column := range columns { categories[column.Key] = column.Category } if categories["gb32960.vehicle.speed_kmh"] != "vehicle" || categories["gb32960.fuel_cell.stack_1.avg_voltage_v"] != "fuel-cell" || categories["jt808.location.additional.total_mileage_km"] != "location" { t.Fatalf("raw history did not reuse telemetry categories: %+v", categories) } segments := historyRawSegments(columns) if len(segments) != 3 || segments[0].Key != "vehicle" || segments[0].Label != "整车数据" || segments[1].Key != "fuel-cell" || segments[1].Label != "燃料电池" || segments[2].Key != "location" || segments[2].Label != "定位" { t.Fatalf("unexpected shared raw segments: %+v", segments) } } type exactRawHistoryStore struct { *MockStore rows []HistoryDataRow } func (s *exactRawHistoryStore) HistoryExportBatch(_ context.Context, query HistoryExportStoreQuery, cursor HistoryExportCursor, limit int) ([]HistoryDataRow, HistoryExportCursor, error) { if query.Category != "raw" { return s.MockStore.HistoryExportBatch(context.Background(), query, cursor, limit) } start := cursor.Offset if start > len(s.rows) { start = len(s.rows) } end := start + limit if end > len(s.rows) { end = len(s.rows) } result := append([]HistoryDataRow(nil), s.rows[start:end]...) cursor.Offset = end return result, cursor, nil } func TestHistoryRawDataFiltersOnServerAndPaginatesBeyondOneThousand(t *testing.T) { const totalRows = 1205 rows := make([]HistoryDataRow, 0, totalRows) start := time.Date(2026, 7, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) for index := 0; index < totalRows; index++ { values := map[string]any{ "frameType": "realtime", "rawSizeBytes": 430, "gb32960.fuel_cell.stack_1.avg_voltage_v": 62.4, } if index%2 == 0 { values["gb32960.vehicle.speed_kmh"] = float64(index % 120) } at := start.Add(time.Duration(index) * time.Second).Format(time.RFC3339) rows = append(rows, HistoryDataRow{ ID: "row-" + fmt.Sprintf("%04d", index), VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Protocol: "GB32960", DeviceTime: at, ServerTime: at, Quality: "normal", Values: values, }) } service := NewService(&exactRawHistoryStore{MockStore: NewMockStore(), rows: rows}) response, err := service.HistoryData(context.Background(), url.Values{ "keywords": {"LB9A32A24R0LS1426"}, "category": {"raw"}, "rawSegment": {"fuel-cell"}, "dateFrom": {"2026-07-01T00:00"}, "dateTo": {"2026-07-02T00:00"}, "limit": {"2"}, "offset": {"1000"}, }) if err != nil { t.Fatal(err) } if !response.SegmentCountsExact || response.AllTotal != totalRows || response.Total != totalRows || len(response.Rows) != 2 { t.Fatalf("unexpected exact raw page: exact=%v all=%d total=%d rows=%d", response.SegmentCountsExact, response.AllTotal, response.Total, len(response.Rows)) } if response.Rows[0].ID != "row-0204" || response.Rows[1].ID != "row-0203" { t.Fatalf("deep raw page was truncated or unstable: %+v", response.Rows) } counts := map[string]int{} for _, segment := range response.Segments { counts[segment.Key] = segment.Count } if counts["fuel-cell"] != totalRows || counts["vehicle"] != 603 { t.Fatalf("segment counts should cover the complete time range: %+v", counts) } vehicle, err := service.HistoryData(context.Background(), url.Values{ "keywords": {"LB9A32A24R0LS1426"}, "category": {"raw"}, "rawSegment": {"vehicle"}, "dateFrom": {"2026-07-01T00:00"}, "dateTo": {"2026-07-02T00:00"}, "limit": {"2"}, }) if err != nil { t.Fatal(err) } if vehicle.Total != 603 || len(vehicle.Rows) != 2 || vehicle.Rows[0].ID != "row-1204" || vehicle.Rows[1].ID != "row-1202" { t.Fatalf("server-side category filtering failed: total=%d rows=%+v", vehicle.Total, vehicle.Rows) } } func TestBuildLatestTelemetryResponseUsesCatalogAndNewestSourceEvidence(t *testing.T) { now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) definitions := []MetricDefinition{ {Key: "speed_kmh", Label: "速度", Description: "车辆最新行驶速度", Unit: "km/h", Category: "driving", ValueType: "numeric", SourceFields: map[string]string{"GB32960": "gb32960.vehicle.speed_kmh"}}, {Key: "alarm_active", Label: "协议告警位", Unit: "", Category: "safety", ValueType: "boolean", SourceFields: map[string]string{"GB32960": "gb32960.alarm.general_alarm_flag"}}, {Key: "hydrogen_concentration_percent", Label: "最高氢浓度", Unit: "%", Category: "fuel-cell", ValueType: "numeric", SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_concentration_percent"}}, } frames := []RawFrameRow{ {ID: "new", VIN: "VIN001", Protocol: "GB32960", DeviceTime: "2026-07-14 09:29:58", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", SourceEndpoint: "gateway-a", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 42.5, "gb32960.alarm.general_alarm_flag": float64(1), "gb32960.fuel_cell.max_hydrogen_concentration_percent": 0.032, "vendor.custom_temperature_c": 31.2}}, {ID: "old", VIN: "VIN001", Protocol: "GB32960", DeviceTime: "2026-07-14 09:20:00", ServerTime: "2026-07-14 09:20:01", ParseStatus: "ok", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 10.0}}, } response := buildLatestTelemetryResponse("VIN001", frames, definitions, now) if response.VIN != "VIN001" || response.ScannedFrames != 2 || len(response.Values) != 4 || len(response.Categories) != 4 { t.Fatalf("unexpected response: %+v", response) } byKey := map[string]LatestTelemetryValue{} for _, value := range response.Values { byKey[value.Key] = value } if speed := byKey["speed_kmh"]; speed.Value != 42.5 || speed.SourceField != "gb32960.vehicle.speed_kmh" || speed.FrameID != "new" || speed.Quality != "good" || speed.FreshnessSeconds != 1 { t.Fatalf("unified speed should use newest frame and evidence: %+v", speed) } if alarm := byKey["alarm_active"]; alarm.Value != true || alarm.Category != "alarm" { t.Fatalf("catalog boolean should be normalized: %+v", alarm) } if hydrogen := byKey["hydrogen_concentration_percent"]; hydrogen.Value != 0.032 || hydrogen.Category != "fuel-cell" || hydrogen.Unit != "%" { t.Fatalf("fuel-cell catalog metric should retain its business category: %+v", hydrogen) } if extension := byKey["vendor.custom_temperature_c"]; extension.Category != "extension" || extension.Unit != "℃" || extension.Protocol != "GB32960" { t.Fatalf("manufacturer extension should retain source semantics: %+v", extension) } } func TestGB32960ReferenceFormatsStatusAndHistoryMetadata(t *testing.T) { if display := protocolDisplayValue("gb32960.vehicle.vehicle_status", float64(1)); display != "启动(1)" { t.Fatalf("vehicle status display = %q", display) } if display := protocolDisplayValue("gb32960.position.position_status", 6); display != "有效定位 · 南纬 · 西经(6)" { t.Fatalf("position status display = %q", display) } columns := mergeDiscoveredRawMetrics(historyRawMetrics(), []HistoryDataRow{{Values: map[string]any{ "gb32960.drive_motor.motors.motor_1.state": 2, }}}) for _, column := range columns { if column.Key == "gb32960.drive_motor.motors.motor_1.state" { if column.Label != "驱动电机状态 · GB32960" || len(column.ValueMappings) == 0 || column.ValueMappings[1].Label != "发电" { t.Fatalf("unexpected protocol reference column: %+v", column) } return } } t.Fatalf("GB32960 reference column missing: %+v", columns) } func TestBuildLatestTelemetryResponseRetainsSameMetricAcrossProtocols(t *testing.T) { now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) definitions := []MetricDefinition{{ Key: "total_mileage_km", Label: "总里程", Description: "车辆累计行驶里程", Unit: "km", Category: "driving", ValueType: "numeric", SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_mileage_km", "JT808": "jt808.location.total_mileage_km"}, }} frames := []RawFrameRow{ {ID: "gb", VIN: "VIN001", Protocol: "GB32960", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", ParsedFields: map[string]any{"gb32960.vehicle.total_mileage_km": 1000.0}}, {ID: "jt", VIN: "VIN001", Protocol: "JT808", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", ParsedFields: map[string]any{"jt808.location.total_mileage_km": 980.0}}, } response := buildLatestTelemetryResponse("VIN001", frames, definitions, now) if len(response.Values) != 2 { t.Fatalf("same unified metric from two protocols must be retained independently: %+v", response.Values) } byProtocol := map[string]LatestTelemetryValue{} for _, value := range response.Values { byProtocol[value.Protocol] = value } if byProtocol["GB32960"].Label != "仪表盘总里程" || byProtocol["JT808"].Label != "GPS 总里程" { t.Fatalf("mileage semantics were not preserved: %+v", byProtocol) } } func TestLatestTelemetryQualityDistinguishesStaleAndWarnings(t *testing.T) { now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) stale, reason, freshness, delay := latestTelemetryQuality(RawFrameRow{DeviceTime: "2026-07-14 09:19:59", ServerTime: "2026-07-14 09:20:00", ParseStatus: "ok"}, now) if stale != "stale" || freshness != 600 || delay == nil || *delay != 1 || !strings.Contains(reason, "超过 5 分钟") { t.Fatalf("unexpected stale evidence: %s %s %d %v", stale, reason, freshness, delay) } warning, reason, _, _ := latestTelemetryQuality(RawFrameRow{ServerTime: "2026-07-14 09:29:59", ParseStatus: "failed", ParseError: "checksum"}, now) if warning != "warning" || !strings.Contains(reason, "checksum") { t.Fatalf("parse warning should retain reason: %s %s", warning, reason) } } type latestTelemetryQueryStore struct { *MockStore mu sync.Mutex queries []RawFrameQuery } func (s *latestTelemetryQueryStore) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) { s.mu.Lock() s.queries = append(s.queries, query) s.mu.Unlock() return s.MockStore.RawFrames(ctx, query) } func TestLatestTelemetryBoundsProtocolReads(t *testing.T) { store := &latestTelemetryQueryStore{MockStore: NewMockStore()} response, err := NewService(store).LatestTelemetry(t.Context(), "LNXNEGRR7SR318212") if err != nil { t.Fatal(err) } if len(response.Values) == 0 || response.ScannedFrames != 1 { t.Fatalf("expected one mock GB frame: %+v", response) } store.mu.Lock() defer store.mu.Unlock() if len(store.queries) != len(canonicalVehicleProtocols) { t.Fatalf("queries = %+v", store.queries) } protocols := map[string]bool{} for _, query := range store.queries { protocols[query.Protocol] = true if query.VIN != "LNXNEGRR7SR318212" || query.Limit != 5 || !query.IncludeFields || !query.SkipCount { t.Fatalf("unbounded latest query: %+v", query) } } for _, protocol := range canonicalVehicleProtocols { if !protocols[protocol] { t.Fatalf("missing protocol %s: %+v", protocol, store.queries) } } } func BenchmarkLatestTelemetryHundredFrames(b *testing.B) { now := time.Date(2026, 7, 14, 9, 30, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) definitions := metricDefinitions() frames := make([]RawFrameRow, 100) for frameIndex := range frames { fields := make(map[string]any, 50) for fieldIndex := 0; fieldIndex < 50; fieldIndex++ { fields[fmt.Sprintf("vendor.section_%02d.metric_%02d_voltage_v", fieldIndex/10, fieldIndex)] = float64(frameIndex + fieldIndex) } fields["gb32960.vehicle.speed_kmh"] = float64(frameIndex) frames[frameIndex] = RawFrameRow{ID: fmt.Sprintf("frame-%03d", frameIndex), VIN: "VIN001", Protocol: "GB32960", DeviceTime: "2026-07-14 09:29:58", ServerTime: "2026-07-14 09:29:59", ParseStatus: "ok", ParsedFields: fields} } b.ReportAllocs() b.ResetTimer() for range b.N { response := buildLatestTelemetryResponse("VIN001", frames, definitions, now) if len(response.Values) != 51 { b.Fatalf("values = %d", len(response.Values)) } } } func TestVehicleCoverageSupportsCombinedBusinessMultiSelectFilters(t *testing.T) { service := NewService(NewMockStore()) query := url.Values{ "departmentIds": {"40001,40002"}, "responsibleUserIds": {"50001,50002"}, "customerIds": {"20001,20002"}, "operationStatuses": {"运营中,已停运"}, "limit": {"20"}, } result, err := service.VehicleCoverage(context.Background(), query) if err != nil { t.Fatal(err) } got := map[string]bool{} for _, item := range result.Items { got[item.VIN] = true } for _, vin := range []string{"LB9A32A24R0LS1426", "LNXNEGRR7SR318212", "LB9A32A24P0LS1230"} { if !got[vin] { t.Fatalf("combined multi-select omitted %s: %+v", vin, got) } } if got["LMRKH9AC2R1004087"] { t.Fatalf("responsible/status filters leaked unrelated vehicle: %+v", got) } } func TestVehicleBusinessFiltersStayInsidePrincipalVINScope(t *testing.T) { service := NewService(NewMockStore()) ctx := WithPrincipal(context.Background(), Principal{ UserType: "customer", Role: "customer", VehicleVINs: []string{"LB9A32A24R0LS1426", "LB9A32A24P0LS1230"}, }) result, err := service.VehicleBusinessFilters(ctx) if err != nil { t.Fatal(err) } if len(result.Departments) != 1 || result.Departments[0].Value != "40001" || result.Departments[0].Count != 2 { t.Fatalf("department options escaped principal scope: %+v", result.Departments) } if len(result.ResponsibleUsers) != 1 || result.ResponsibleUsers[0].Value != "50001" { t.Fatalf("responsible options escaped principal scope: %+v", result.ResponsibleUsers) } } func TestBusinessFilterCannotWidenPrincipalVINScope(t *testing.T) { service := NewService(NewMockStore()) ctx := WithPrincipal(context.Background(), Principal{ UserType: "customer", Role: "customer", VehicleVINs: []string{"LNXNEGRR7SR318212"}, }) result, err := service.VehicleCoverage(ctx, url.Values{ "departmentIds": {"40001,40002"}, "responsibleUserIds": {"50001,50002"}, "limit": {"20"}, }) if err != nil { t.Fatal(err) } if len(result.Items) != 1 || result.Items[0].VIN != "LNXNEGRR7SR318212" { t.Fatalf("client business filters widened principal scope: %+v", result.Items) } }