Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/platform/service_test.go
2026-07-27 16:46:15 +08:00

2021 lines
93 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}