427 lines
15 KiB
Go
427 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql/driver"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/DATA-DOG/go-sqlmock"
|
|
|
|
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity"
|
|
)
|
|
|
|
func TestReportItemLimitExpandsWhenCSVOutputIsRequested(t *testing.T) {
|
|
if got := reportItemLimit(50, "", ""); got != 50 {
|
|
t.Fatalf("reportItemLimit without csv = %d, want 50", got)
|
|
}
|
|
if got := reportItemLimit(50, "unresolved.csv", ""); got != -1 {
|
|
t.Fatalf("reportItemLimit with unresolved csv = %d, want -1", got)
|
|
}
|
|
if got := reportItemLimit(50, "", "conflicts.csv"); got != -1 {
|
|
t.Fatalf("reportItemLimit with conflicts csv = %d, want -1", got)
|
|
}
|
|
}
|
|
|
|
func TestUnsupportedMappingFilesError(t *testing.T) {
|
|
err := unsupportedMappingFilesError(identity.MappingScanReport{})
|
|
if err != nil {
|
|
t.Fatalf("unsupportedMappingFilesError(empty) = %v", err)
|
|
}
|
|
err = unsupportedMappingFilesError(identity.MappingScanReport{
|
|
UnsupportedFiles: 2,
|
|
UnsupportedItems: []identity.MappingUnsupportedFileReport{
|
|
{File: "G7s/legacy.xls", Ext: ".xls"},
|
|
{File: "信达/legacy.xlsb", Ext: ".xlsb"},
|
|
},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("unsupportedMappingFilesError() nil, want error")
|
|
}
|
|
text := err.Error()
|
|
for _, want := range []string{"2 unsupported workbook", "G7s/legacy.xls", "信达/legacy.xlsb", "before -apply"} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("error missing %q: %s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestValidateMappingApplyInputAllowsDryRunButBlocksApply(t *testing.T) {
|
|
scan := identity.MappingScanReport{
|
|
UnsupportedFiles: 1,
|
|
UnsupportedItems: []identity.MappingUnsupportedFileReport{
|
|
{File: "G7s/legacy.xls", Ext: ".xls"},
|
|
},
|
|
}
|
|
if err := validateMappingApplyInput(false, scan); err != nil {
|
|
t.Fatalf("validateMappingApplyInput(dry-run) = %v", err)
|
|
}
|
|
if err := validateMappingApplyInput(true, scan); err == nil {
|
|
t.Fatal("validateMappingApplyInput(apply) nil, want error")
|
|
}
|
|
}
|
|
|
|
func TestWriteUnresolvedCSV(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "unresolved.csv")
|
|
err := writeUnresolvedCSV(path, []identity.MappingRecord{
|
|
{
|
|
File: "G7s/example.xlsx",
|
|
Sheet: "Sheet1",
|
|
Row: 2,
|
|
SourceCode: "g7s",
|
|
SourceName: "G7s",
|
|
Protocol: "JT808",
|
|
IdentifierType: identity.IdentifierTypeJT808Phone,
|
|
IdentifierValue: "13307795425",
|
|
RawValue: "013307795425",
|
|
Plate: "粤AG18312",
|
|
OEM: "G7s",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("writeUnresolvedCSV() error = %v", err)
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile() error = %v", err)
|
|
}
|
|
text := string(data)
|
|
for _, want := range []string{"identifier_type", "JT808_PHONE", "13307795425", "vin_not_found_in_legacy_binding"} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("csv missing %q:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWriteConflictsCSV(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "conflicts.csv")
|
|
err := writeConflictsCSV(path, []identity.MappingConflict{
|
|
{
|
|
Record: identity.MappingRecord{
|
|
File: "source.xlsx",
|
|
Sheet: "Sheet1",
|
|
Row: 3,
|
|
SourceCode: "xinda",
|
|
Protocol: "JT808",
|
|
IdentifierType: identity.IdentifierTypePlate,
|
|
IdentifierValue: "粤AG18312",
|
|
Plate: "粤AG18312",
|
|
},
|
|
ExistingVIN: "VIN001",
|
|
NewVIN: "VIN002",
|
|
Reason: "identifier already points to another vin",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("writeConflictsCSV() error = %v", err)
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile() error = %v", err)
|
|
}
|
|
text := string(data)
|
|
for _, want := range []string{"existing_vin", "VIN001", "VIN002", "identifier already points to another vin"} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("csv missing %q:\n%s", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSyncJT808DataSourcesFromIdentifiersPreservesManualPlatformNames(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("sqlmock.New() error = %v", err)
|
|
}
|
|
defer db.Close()
|
|
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(3))
|
|
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
|
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
|
|
mock.ExpectExec("INSERT INTO vehicle_data_source").
|
|
WillReturnResult(driver.RowsAffected(3))
|
|
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(5))
|
|
mock.ExpectExec("UPDATE vehicle_data_source").
|
|
WillReturnResult(driver.RowsAffected(5))
|
|
mock.ExpectQuery("SELECT DISTINCT s.vin, s.stat_date, s.protocol").
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "stat_date", "protocol"}).
|
|
AddRow("LNXNEGRRXSR319449", "2026-07-13", "JT808"))
|
|
mock.ExpectBegin()
|
|
mock.ExpectExec("INSERT INTO vehicle_daily_mileage").
|
|
WillReturnResult(driver.RowsAffected(1))
|
|
mock.ExpectExec("UPDATE vehicle_daily_mileage_source s").
|
|
WillReturnResult(driver.RowsAffected(1))
|
|
mock.ExpectExec("DELETE FROM vehicle_daily_mileage").
|
|
WillReturnResult(driver.RowsAffected(0))
|
|
mock.ExpectCommit()
|
|
|
|
report, err := syncJT808DataSourcesFromIdentifiers(context.Background(), db, true)
|
|
if err != nil {
|
|
t.Fatalf("syncJT808DataSourcesFromIdentifiers() error = %v", err)
|
|
}
|
|
if !report.Apply || report.CandidateSources != 3 || report.SkippedSources != 1 || report.ConflictingSources != 2 || report.Synced != 3 || report.PlatformKindCandidates != 5 || report.PlatformKindClassified != 5 || report.ReprojectDailyMileageTargets != 1 || report.ReprojectedDailyMileageTargets != 1 {
|
|
t.Fatalf("report = %#v", report)
|
|
}
|
|
for _, want := range []string{
|
|
"COUNT(DISTINCT",
|
|
"vehicle_identifier vi",
|
|
"TRIM(r.source_ip) AS source_ip",
|
|
"GROUP BY TRIM(r.source_ip)",
|
|
"inferred.source_code",
|
|
"'PLATFORM'",
|
|
"inferred.source_code_count = 1",
|
|
"vehicle_data_source.source_code IS NULL OR TRIM(vehicle_data_source.source_code) = ''",
|
|
"ELSE vehicle_data_source.source_code",
|
|
"vehicle_data_source.source_kind IS NULL OR TRIM(vehicle_data_source.source_kind) = '' OR vehicle_data_source.source_kind = 'UNKNOWN'",
|
|
"ELSE vehicle_data_source.source_kind",
|
|
"vehicle_data_source.enabled = 0",
|
|
"vehicle_data_source.remark LIKE 'auto-retired:%'",
|
|
"vehicle_data_source.remark = 'auto-reenabled: source evidence restored'",
|
|
"THEN 'auto-reenabled: source evidence restored'",
|
|
"THEN 1",
|
|
"LEFT JOIN vehicle_data_source ds",
|
|
"ds.id IS NULL",
|
|
} {
|
|
if !strings.Contains(syncJT808DataSourcesSQL, want) {
|
|
t.Fatalf("sync sql missing %q:\n%s", want, syncJT808DataSourcesSQL)
|
|
}
|
|
}
|
|
if strings.Index(syncJT808DataSourcesSQL, "enabled = CASE") < 0 ||
|
|
strings.Index(syncJT808DataSourcesSQL, "remark = CASE") < 0 ||
|
|
strings.Index(syncJT808DataSourcesSQL, "enabled = CASE") > strings.Index(syncJT808DataSourcesSQL, "remark = CASE") {
|
|
t.Fatalf("sync sql must restore enabled before updating remark because MySQL evaluates assignments in order:\n%s", syncJT808DataSourcesSQL)
|
|
}
|
|
for _, want := range []string{
|
|
"LEFT JOIN vehicle_data_source ds",
|
|
"ds.id IS NULL",
|
|
"ds.source_code IS NULL OR TRIM(ds.source_code) = ''",
|
|
"ds.enabled = 0 AND (ds.remark LIKE 'auto-retired:%'",
|
|
"ds.remark = 'auto-reenabled: source evidence restored'",
|
|
} {
|
|
if !strings.Contains(syncJT808DataSourcesCandidateCountSQL, want) {
|
|
t.Fatalf("candidate count sql missing %q:\n%s", want, syncJT808DataSourcesCandidateCountSQL)
|
|
}
|
|
}
|
|
for _, want := range []string{
|
|
"JOIN vehicle_data_source ds",
|
|
"ds.source_code <> inferred.source_code",
|
|
} {
|
|
if !strings.Contains(syncJT808DataSourcesConflictCountSQL, want) {
|
|
t.Fatalf("conflict count sql missing %q:\n%s", want, syncJT808DataSourcesConflictCountSQL)
|
|
}
|
|
}
|
|
for _, want := range []string{
|
|
"vehicle_data_source.platform_name IS NULL OR TRIM(vehicle_data_source.platform_name) = ''",
|
|
"ELSE vehicle_data_source.platform_name",
|
|
} {
|
|
if !strings.Contains(syncJT808DataSourcesSQL, want) {
|
|
t.Fatalf("sync sql missing %q:\n%s", want, syncJT808DataSourcesSQL)
|
|
}
|
|
}
|
|
for _, want := range []string{
|
|
"source_kind IS NULL OR TRIM(source_kind) = '' OR source_kind = 'UNKNOWN'",
|
|
"source_code IS NOT NULL AND TRIM(source_code) <> ''",
|
|
"platform_name IS NOT NULL AND TRIM(platform_name) <> ''",
|
|
"SET source_kind = 'PLATFORM'",
|
|
} {
|
|
if !strings.Contains(classifyConfiguredDataSourcesSQL, want) && !strings.Contains(classifyConfiguredDataSourcesCountSQL, want) {
|
|
t.Fatalf("configured-source classification sql missing %q:\n%s\n%s", want, classifyConfiguredDataSourcesSQL, classifyConfiguredDataSourcesCountSQL)
|
|
}
|
|
}
|
|
for _, want := range []string{
|
|
"SELECT DISTINCT s.vin, s.stat_date, s.protocol",
|
|
"vehicle_daily_mileage_source s",
|
|
"LEFT JOIN vehicle_data_source ds",
|
|
"LEFT JOIN vehicle_daily_mileage m",
|
|
"s.protocol = 'JT808'",
|
|
"s.quality_status = 'OK'",
|
|
"ds.enabled = 1",
|
|
"m.vin IS NULL",
|
|
"selected.is_selected = 1",
|
|
} {
|
|
if !strings.Contains(reprojectSelectableDailyMileageSourcesSQL, want) {
|
|
t.Fatalf("reproject sql missing %q:\n%s", want, reprojectSelectableDailyMileageSourcesSQL)
|
|
}
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSyncJT808DataSourcesDryRunDoesNotWrite(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("sqlmock.New() error = %v", err)
|
|
}
|
|
defer db.Close()
|
|
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(4))
|
|
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
|
|
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
|
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(6))
|
|
mock.ExpectQuery("SELECT DISTINCT s.vin, s.stat_date, s.protocol").
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "stat_date", "protocol"}).
|
|
AddRow("LNXNEGRRXSR319449", "2026-07-13", "JT808"))
|
|
|
|
report, err := syncJT808DataSourcesFromIdentifiers(context.Background(), db, false)
|
|
if err != nil {
|
|
t.Fatalf("syncJT808DataSourcesFromIdentifiers() error = %v", err)
|
|
}
|
|
if report.Apply || report.CandidateSources != 4 || report.SkippedSources != 2 || report.ConflictingSources != 1 || report.PlatformKindCandidates != 6 || report.PlatformKindClassified != 0 || report.Synced != 0 || report.ReprojectDailyMileageTargets != 1 || report.ReprojectedDailyMileageTargets != 0 {
|
|
t.Fatalf("report = %#v", report)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMaintenanceModeNamesAllSourceMaintenanceSteps(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
apply bool
|
|
sync bool
|
|
prune bool
|
|
retire bool
|
|
want string
|
|
}{
|
|
{name: "dry run", want: "dry_run"},
|
|
{name: "apply", apply: true, want: "apply"},
|
|
{name: "sync dry run", sync: true, want: "sync_dry_run"},
|
|
{name: "sync prune retire apply", apply: true, sync: true, prune: true, retire: true, want: "sync_prune_retire_apply"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := maintenanceMode(tt.apply, tt.sync, tt.prune, tt.retire); got != tt.want {
|
|
t.Fatalf("maintenanceMode() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPruneUnmanagedDataSourcesDryRunDoesNotDelete(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("sqlmock.New() error = %v", err)
|
|
}
|
|
defer db.Close()
|
|
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
|
WithArgs(int64(3600)).
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(339))
|
|
|
|
report, err := pruneUnmanagedDataSourcesWithoutEvidence(context.Background(), db, false, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("pruneUnmanagedDataSourcesWithoutEvidence() error = %v", err)
|
|
}
|
|
if report.Apply || report.MinAgeSeconds != 3600 || report.CandidateSources != 339 || report.Pruned != 0 {
|
|
t.Fatalf("report = %#v", report)
|
|
}
|
|
for _, want := range []string{
|
|
"vehicle_data_source ds",
|
|
"vehicle_daily_mileage m",
|
|
"m.source_id = ds.id",
|
|
"jt808_registration r",
|
|
"r.source_ip = ds.source_ip",
|
|
"ds.source_kind IS NULL OR TRIM(ds.source_kind) = '' OR ds.source_kind = 'UNKNOWN'",
|
|
"TIMESTAMPDIFF(SECOND",
|
|
} {
|
|
if !strings.Contains(pruneUnmanagedDataSourcesCountSQL, want) {
|
|
t.Fatalf("prune count sql missing %q:\n%s", want, pruneUnmanagedDataSourcesCountSQL)
|
|
}
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRetireStaleUnmanagedDataSourcesDryRunDoesNotDisable(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("sqlmock.New() error = %v", err)
|
|
}
|
|
defer db.Close()
|
|
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
|
WithArgs(int64(86400)).
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(83))
|
|
|
|
report, err := retireStaleUnmanagedDataSourcesWithoutEvidence(context.Background(), db, false, 24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("retireStaleUnmanagedDataSourcesWithoutEvidence() error = %v", err)
|
|
}
|
|
if report.Apply || report.MinAgeSeconds != 86400 || report.CandidateSources != 83 || report.Retired != 0 {
|
|
t.Fatalf("report = %#v", report)
|
|
}
|
|
for _, want := range []string{
|
|
"vehicle_data_source ds",
|
|
"ds.enabled = 1",
|
|
"jt808_registration r",
|
|
"r.source_ip = ds.source_ip",
|
|
"ds.source_kind IS NULL OR TRIM(ds.source_kind) = '' OR ds.source_kind = 'UNKNOWN'",
|
|
"TIMESTAMPDIFF(SECOND",
|
|
} {
|
|
if !strings.Contains(retireStaleUnmanagedDataSourcesCountSQL, want) {
|
|
t.Fatalf("retire count sql missing %q:\n%s", want, retireStaleUnmanagedDataSourcesCountSQL)
|
|
}
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRetireStaleUnmanagedDataSourcesApplyDisablesOnlyCandidates(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("sqlmock.New() error = %v", err)
|
|
}
|
|
defer db.Close()
|
|
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
|
WithArgs(int64(7200)).
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(7))
|
|
mock.ExpectExec("UPDATE vehicle_data_source ds").
|
|
WithArgs(int64(7200)).
|
|
WillReturnResult(driver.RowsAffected(7))
|
|
|
|
report, err := retireStaleUnmanagedDataSourcesWithoutEvidence(context.Background(), db, true, 2*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("retireStaleUnmanagedDataSourcesWithoutEvidence() error = %v", err)
|
|
}
|
|
if !report.Apply || report.MinAgeSeconds != 7200 || report.CandidateSources != 7 || report.Retired != 7 {
|
|
t.Fatalf("report = %#v", report)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPruneUnmanagedDataSourcesApplyDeletesOnlyCandidates(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatalf("sqlmock.New() error = %v", err)
|
|
}
|
|
defer db.Close()
|
|
mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
|
|
WithArgs(int64(1800)).
|
|
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(12))
|
|
mock.ExpectExec("DELETE ds\\s+FROM vehicle_data_source ds").
|
|
WithArgs(int64(1800)).
|
|
WillReturnResult(driver.RowsAffected(12))
|
|
|
|
report, err := pruneUnmanagedDataSourcesWithoutEvidence(context.Background(), db, true, 30*time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("pruneUnmanagedDataSourcesWithoutEvidence() error = %v", err)
|
|
}
|
|
if !report.Apply || report.MinAgeSeconds != 1800 || report.CandidateSources != 12 || report.Pruned != 12 {
|
|
t.Fatalf("report = %#v", report)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatalf("sql expectations: %v", err)
|
|
}
|
|
}
|