feat: expand vehicle data platform capabilities
This commit is contained in:
@@ -17,6 +17,193 @@ 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})
|
||||
@@ -51,6 +238,309 @@ func TestHistoryExportIndexSurvivesServiceRestart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -59,14 +549,81 @@ type countingStore struct {
|
||||
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)
|
||||
@@ -81,14 +638,60 @@ func (s *countingStore) VehicleRealtime(ctx context.Context, query url.Values) (
|
||||
|
||||
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)
|
||||
@@ -815,6 +1418,133 @@ func TestHistoryExportsAreOwnerScopedAndPersistAuditMetadata(t *testing.T) {
|
||||
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
|
||||
@@ -969,6 +1699,114 @@ func TestMergeDiscoveredRawMetricsScansFetchedScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
@@ -1120,3 +1958,63 @@ func BenchmarkLatestTelemetryHundredFrames(b *testing.B) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user