package stats import ( "context" "database/sql" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/DATA-DOG/go-sqlmock" ) const dataSourceSelectPattern = "SELECT id, protocol, source_ip, latest_source_endpoint, platform_name, source_code, source_kind, trust_priority, enabled, first_seen_at, latest_seen_at, remark, updated_at FROM vehicle_data_source" func TestDataSourceRepositoryQueriesWithOperationalFilters(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dataSourceSelectPattern). WithArgs("JT808", "115.231.168.135", "G7S", "PLATFORM", 1, 20, 10). WillReturnRows(sqlmock.NewRows([]string{ "id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "trust_priority", "enabled", "first_seen_at", "latest_seen_at", "remark", "updated_at", }).AddRow( 3, "JT808", "115.231.168.135", "115.231.168.135:41561", "G7 平台", "G7S", "PLATFORM", 10, 1, time.Date(2026, 7, 8, 10, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)), time.Date(2026, 7, 12, 1, 16, 4, 0, time.FixedZone("Asia/Shanghai", 8*3600)), "trusted source", time.Date(2026, 7, 12, 1, 16, 5, 0, time.FixedZone("Asia/Shanghai", 8*3600)), )) enabled := true rows, err := NewDataSourceRepository(db).Query(context.Background(), DataSourceQuery{ Protocol: "jt808", SourceIP: "115.231.168.135", SourceCode: "G7S", SourceKind: "platform", Enabled: &enabled, Limit: 20, Offset: 10, }) if err != nil { t.Fatalf("Query() error = %v", err) } if len(rows) != 1 { t.Fatalf("row count = %d", len(rows)) } row := rows[0] if row.ID != 3 || row.Protocol != "JT808" || row.PlatformName != "G7 平台" || row.SourceCode != "G7S" || row.SourceKind != "PLATFORM" || !row.Enabled { t.Fatalf("unexpected source row: %#v", row) } if row.LatestSeenAt != "2026-07-12 01:16:04" { t.Fatalf("latest seen = %q", row.LatestSeenAt) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestDataSourceRepositoryFiltersMissingSourceCode(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery(dataSourceSelectPattern). WithArgs("JT808", 50, 0). WillReturnRows(sqlmock.NewRows([]string{ "id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "trust_priority", "enabled", "first_seen_at", "latest_seen_at", "remark", "updated_at", })) missing := true _, err = NewDataSourceRepository(db).Query(context.Background(), DataSourceQuery{ Protocol: "JT808", SourceCodeMissing: &missing, }) if err != nil { t.Fatalf("Query() error = %v", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestDataSourceRepositoryDiagnosesJT808SourceMapping(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery("FROM vehicle_data_source ds"). WithArgs("JT808", "117.132.194.31", 1, 10, 0). WillReturnRows(sqlmock.NewRows([]string{ "id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows", "identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code", "candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones", }).AddRow( 242190, "JT808", "117.132.194.31", "117.132.194.31:20471", nil, nil, "UNKNOWN", "2026-07-11 23:00:00", "2026-07-12 01:36:26", 9386, 120, 5, 4, 2, 3, 1, 0.75, 0, nil, 2, "g7s", "G7s", "g7s,xinda", "G7s,信达", "13307795425,14400000000", )) missing := true rows, err := NewDataSourceRepository(db).QueryDiagnostics(context.Background(), DataSourceDiagnosticsQuery{ Protocol: "jt808", SourceIP: "117.132.194.31", SourceCodeMissing: &missing, Limit: 10, }) if err != nil { t.Fatalf("QueryDiagnostics() error = %v", err) } if len(rows) != 1 { t.Fatalf("row count = %d", len(rows)) } row := rows[0] if row.Reason != "ambiguous_source_code" || row.IdentifierMatchedPhones != 3 || len(row.MatchedSourceCodes) != 2 || len(row.SamplePhones) != 2 { t.Fatalf("unexpected diagnostic row: %#v", row) } if row.SuggestedSourceKind != "UNKNOWN" || row.SuggestionConfidence != "HIGH" { t.Fatalf("unexpected kind suggestion: %#v", row) } sqlText, _ := buildDataSourceDiagnosticsSQL(DataSourceDiagnosticsQuery{Protocol: "JT808", Limit: 10}) if !strings.Contains(sqlText, "ON ds.protocol = 'JT808'") || !strings.Contains(sqlText, "AND r.source_ip = ds.source_ip") { t.Fatalf("diagnostics should join by indexed source_ip:\n%s", sqlText) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestDataSourceDiagnosticsMappingIssueFilterUsesHaving(t *testing.T) { missing := false query := normalizeDataSourceDiagnosticsQuery(DataSourceDiagnosticsQuery{ Protocol: "JT808", SourceCodeMissing: &missing, MappingIssueOnly: true, Limit: 10, }) sqlText, args := buildDataSourceDiagnosticsSQL(query) if !strings.Contains(sqlText, "HAVING") || !strings.Contains(sqlText, "identifier_match_ratio < 0.8") || !strings.Contains(sqlText, "configured_source_code_platform_name") { t.Fatalf("mapping issue query should include aggregate HAVING:\n%s", sqlText) } if strings.Contains(sqlText, "ds.platform_name IS NOT NULL") { t.Fatalf("mapping issue HAVING should use aggregate aliases, not table-qualified platform_name:\n%s", sqlText) } if len(args) != 4 { t.Fatalf("mapping issue query args = %#v, want protocol/enabled/limit/offset", args) } countSQL, _ := buildDataSourceDiagnosticsCountSQL(query) if !strings.Contains(countSQL, "FROM (") || !strings.Contains(countSQL, "HAVING") { t.Fatalf("mapping issue count should wrap grouped diagnostics:\n%s", countSQL) } if strings.Contains(countSQL, "ds.platform_name IS NOT NULL") { t.Fatalf("mapping issue count HAVING should use aggregate aliases, not table-qualified platform_name:\n%s", countSQL) } } func TestDiagnoseDataSourceHighlightsConfiguredMappingIssues(t *testing.T) { lowCoverage := DataSourceDiagnosticRow{ Protocol: "JT808", PlatformName: "东方北斗", SourceCode: "dongfang_beidou", RegistrationRows: 388, PhoneCount: 388, IdentifierMatchedPhones: 8, UnmappedPhoneCount: 380, IdentifierMatchRatio: 0.0206, ConfiguredSourceCodeMatchedPhones: 8, ConfiguredSourceCodePlatformName: "东方北斗", MatchedSourceCodeCount: 1, MatchedSourceCodes: []string{"dongfang_beidou"}, } lowCoverage.ConfiguredSourceCodeConflict = configuredSourceCodeConflict(lowCoverage) lowCoverage.SourcePlatformNameMismatch = sourcePlatformNameMismatch(lowCoverage) reason, _ := diagnoseDataSource(lowCoverage) if reason != "low_identifier_coverage" { t.Fatalf("reason = %q, want low_identifier_coverage", reason) } lowCoverageMismatch := lowCoverage lowCoverageMismatch.PlatformName = "G7易流" lowCoverageMismatch.SourcePlatformNameMismatch = sourcePlatformNameMismatch(lowCoverageMismatch) reason, _ = diagnoseDataSource(lowCoverageMismatch) if reason != "low_identifier_coverage" { t.Fatalf("reason = %q, want low_identifier_coverage when coverage is weak even if platform name differs", reason) } nameMismatch := DataSourceDiagnosticRow{ Protocol: "JT808", PlatformName: "G7易流", SourceCode: "dongfang_beidou", RegistrationRows: 388, PhoneCount: 388, IdentifierMatchedPhones: 8, ConfiguredSourceCodePlatformName: "东方北斗", MatchedSourceCodeCount: 1, MatchedSourceCodes: []string{"dongfang_beidou"}, } nameMismatch.SourcePlatformNameMismatch = sourcePlatformNameMismatch(nameMismatch) reason, _ = diagnoseDataSource(nameMismatch) if reason != "source_platform_name_mismatch" { t.Fatalf("reason = %q, want source_platform_name_mismatch", reason) } conflict := DataSourceDiagnosticRow{ Protocol: "JT808", SourceCode: "g7s", RegistrationRows: 20, PhoneCount: 20, IdentifierMatchedPhones: 20, IdentifierMatchRatio: 1, ConfiguredSourceCodeMatchedPhones: 0, MatchedSourceCodeCount: 1, MatchedSourceCodes: []string{"dongfang_beidou"}, } conflict.ConfiguredSourceCodeConflict = configuredSourceCodeConflict(conflict) reason, _ = diagnoseDataSource(conflict) if reason != "source_code_conflict" { t.Fatalf("reason = %q, want source_code_conflict", reason) } } func TestDataSourceHandlerReturnsSourcePage(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\\(\\*\\) FROM vehicle_data_source"). WithArgs("GB32960", 1). WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(3)) mock.ExpectQuery(dataSourceSelectPattern). WithArgs("GB32960", 1, 50, 0). WillReturnRows(sqlmock.NewRows([]string{ "id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "trust_priority", "enabled", "first_seen_at", "latest_seen_at", "remark", "updated_at", }).AddRow( 7, "GB32960", "8.134.95.166", "8.134.95.166:56432", "现代 HTWO", "HYUNDAI", "PLATFORM", 5, 1, "2026-07-11 18:27:10", "2026-07-12 01:15:52", "", "2026-07-12 01:15:52", )) handler := NewDataSourceHandler(NewDataSourceRepository(db)) request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources?protocol=gb32960&enabled=true&includeTotal=true", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } body := response.Body.String() for _, want := range []string{`"total":3`, `"source_ip":"8.134.95.166"`, `"platform_name":"现代 HTWO"`, `"source_code":"HYUNDAI"`, `"source_kind":"PLATFORM"`} { if !strings.Contains(body, want) { t.Fatalf("response missing %s: %s", want, body) } } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestDataSourceHandlerReturnsDiagnosticsPage(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\\(\\*\\) FROM vehicle_data_source ds"). WithArgs("JT808", 1). WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1)) mock.ExpectQuery("FROM vehicle_data_source ds"). WithArgs("JT808", 1, 50, 0). WillReturnRows(sqlmock.NewRows([]string{ "id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows", "identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code", "candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones", }).AddRow( 242190, "JT808", "117.132.194.31", "117.132.194.31:20471", nil, nil, "UNKNOWN", "2026-07-12 01:00:00", "2026-07-12 01:36:26", 2186, 1800, 5, 4, 2, 0, 4, 0.0, 0, nil, 0, nil, nil, nil, nil, "13307795425", )) handler := NewDataSourceHandler(NewDataSourceRepository(db)) request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/diagnostics?protocol=jt808&includeTotal=true", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } body := response.Body.String() for _, want := range []string{`"total":1`, `"reason":"no_identifier_match"`, `"sample_phones":["13307795425"]`} { if !strings.Contains(body, want) { t.Fatalf("response missing %s: %s", want, body) } } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestDataSourceHandlerReturnsJT808IdentityGaps(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\\(\\*\\) FROM jt808_registration r"). WithArgs("117.132.194.31", "guangan_beidou", 3600). WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1)) mock.ExpectQuery("FROM jt808_registration r"). WithArgs("117.132.194.31", "guangan_beidou", 3600, 20, 0). WillReturnRows(sqlmock.NewRows([]string{ "phone", "device_id", "plate", "vin", "source_ip", "source_endpoint", "source_code", "platform_name", "source_kind", "first_registered_at", "latest_registered_at", "latest_authenticated_at", "latest_seen_at", "latest_seen_age_seconds", }).AddRow( "13307795425", "", "沪A63305F", "unknown", "117.132.194.31", "117.132.194.31:20471", "guangan_beidou", "广安北斗", "PLATFORM", nil, nil, nil, "2026-07-12 22:24:53", 32, )) handler := NewDataSourceHandler(NewDataSourceRepository(db)) request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/jt808-identity-gaps?sourceIP=117.132.194.31&sourceCode=guangan_beidou&recentSeconds=3600&includeTotal=true&limit=20", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } body := response.Body.String() for _, want := range []string{ `"total":1`, `"phone":"13307795425"`, `"reason":"missing_phone_and_plate_binding"`, `"raw_frame_query_path":"/api/history/raw-frames?`, `phone=13307795425`, `dateFrom=2026-07-12+00%3A00%3A00`, `"data_source_query_path":"/api/stats/data-sources?`, `sourceIP=117.132.194.31`, } { if !strings.Contains(body, want) { t.Fatalf("response missing %s: %s", want, body) } } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestDataSourceHandlerReturnsJT808MappingGaps(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\\(\\*\\) FROM jt808_registration r"). WithArgs("115.159.85.149", "dongfang_beidou", 3600). WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1)) mock.ExpectQuery("FROM jt808_registration r"). WithArgs("115.159.85.149", "dongfang_beidou", 3600, 20, 0). WillReturnRows(sqlmock.NewRows([]string{ "phone", "device_id", "plate", "vin", "source_ip", "source_endpoint", "source_code", "platform_name", "source_kind", "identifier_vin", "identifier_plate", "matched_source_codes", "matched_platform_names", "latest_seen_at", "latest_seen_age_seconds", }).AddRow( "64646848246", "", "粤AG18312", "LKLG7C4E8NA774778", "115.159.85.149", "115.159.85.149:16885", "dongfang_beidou", "G7易流", "PLATFORM", nil, nil, "g7s", "G7s", "2026-07-12 23:38:22", 32, )) handler := NewDataSourceHandler(NewDataSourceRepository(db)) request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/jt808-mapping-gaps?sourceIP=115.159.85.149&sourceCode=dongfang_beidou&recentSeconds=3600&includeTotal=true&limit=20", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } body := response.Body.String() for _, want := range []string{ `"total":1`, `"phone":"64646848246"`, `"suggested_source_code":"dongfang_beidou"`, `"suggested_identifier_type":"JT808_PHONE"`, `"suggested_vin":"LKLG7C4E8NA774778"`, `"reason":"missing_source_phone_identifier"`, `"matched_source_codes":["g7s"]`, `"raw_frame_query_path":"/api/history/raw-frames?`, `phone=64646848246`, `"data_source_query_path":"/api/stats/data-sources?`, `sourceIP=115.159.85.149`, `"vehicle_identifier_example":"protocol=JT808, source_code=dongfang_beidou, identifier_type=JT808_PHONE, identifier_value=64646848246, vin=LKLG7C4E8NA774778, plate=粤AG18312, oem=G7易流"`, } { if !strings.Contains(body, want) { t.Fatalf("response missing %s: %s", want, body) } } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestJT808MappingGapSQLUsesConfiguredSourceIdentifier(t *testing.T) { sqlText, args := buildJT808MappingGapSQL(normalizeJT808MappingGapQuery(JT808MappingGapQuery{ SourceIP: "115.159.85.149", SourceCode: "dongfang_beidou", RecentSeconds: 3600, Limit: 20, })) for _, want := range []string{ "JOIN vehicle_data_source ds", "source_vi.source_code = ds.source_code", "source_vi.identifier_type = 'JT808_PHONE'", "source_vi.identifier_value = r.phone", "source_vi.identifier_value IS NULL OR source_vi.vin IS NULL", } { if !strings.Contains(sqlText, want) { t.Fatalf("mapping gap SQL missing %q:\n%s", want, sqlText) } } if len(args) != 5 { t.Fatalf("args = %#v, want sourceIP/sourceCode/recent/limit/offset", args) } } func TestDataSourceDiagnosticsCanQueryRetiredSources(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery("FROM vehicle_data_source ds"). WithArgs("JT808", 0, 50, 0). WillReturnRows(sqlmock.NewRows([]string{ "id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows", "identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code", "candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones", })) handler := NewDataSourceHandler(NewDataSourceRepository(db)) request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/diagnostics?protocol=jt808&enabled=false", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } sqlText, args := buildDataSourceDiagnosticsSQL(normalizeDataSourceDiagnosticsQuery(DataSourceDiagnosticsQuery{Protocol: "JT808"})) if !strings.Contains(sqlText, "ds.enabled = ?") { t.Fatalf("diagnostics should default to enabled sources:\n%s", sqlText) } if len(args) < 2 || args[1] != 1 { t.Fatalf("diagnostics enabled default args = %#v, want enabled=1 before limit", args) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestDataSourceHandlerReturnsGenericDiagnosticsForNonJT808(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery("FROM vehicle_data_source ds"). WithArgs("GB32960", 1, 50, 0). WillReturnRows(sqlmock.NewRows([]string{ "id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows", "identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code", "candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones", }).AddRow( 7, "GB32960", "8.134.95.166", "8.134.95.166:32960", "现代 HTWO", nil, "UNKNOWN", "2026-07-12 01:00:00", "2026-07-12 01:36:26", 2186, 1800, 0, 0, 0, 0, 0, 0.0, 0, nil, 0, nil, nil, nil, nil, nil, )) handler := NewDataSourceHandler(NewDataSourceRepository(db)) request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/diagnostics?protocol=GB32960", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } body := response.Body.String() for _, want := range []string{`"protocol":"GB32960"`, `"reason":"source_code_missing"`, `"suggested_source_kind":"PLATFORM"`} { if !strings.Contains(body, want) { t.Fatalf("response missing %s: %s", want, body) } } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestDataSourceHandlerReturnsKindSuggestionsPage(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\\(\\*\\) FROM vehicle_data_source ds"). WithArgs("JT808", "UNKNOWN", 1). WillReturnRows(sqlmock.NewRows([]string{"total"}).AddRow(1)) mock.ExpectQuery("FROM vehicle_data_source ds"). WithArgs("JT808", "UNKNOWN", 1, 50, 0). WillReturnRows(sqlmock.NewRows([]string{ "id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows", "identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code", "candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones", }).AddRow( 242190, "JT808", "117.132.194.31", "117.132.194.31:20471", "广安北斗", "guangan_beidou", "UNKNOWN", "2026-07-12 00:00:00", "2026-07-12 00:05:00", 300, 7200, 0, 0, 0, 0, 0, 0.0, 0, nil, 0, nil, nil, nil, nil, nil, )) handler := NewDataSourceHandler(NewDataSourceRepository(db)) request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/kind-suggestions?protocol=jt808&includeTotal=true", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } body := response.Body.String() for _, want := range []string{`"total":1`, `"suggested_source_kind":"UNKNOWN"`, `"suggestion_confidence":"MEDIUM"`, `"suggestion_reason":"manual_source_without_registration_evidence"`} { if !strings.Contains(body, want) { t.Fatalf("response missing %s: %s", want, body) } } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestDataSourceHandlerReturnsKindSuggestionsForNonJT808(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery("FROM vehicle_data_source ds"). WithArgs("GB32960", "UNKNOWN", 1, 50, 0). WillReturnRows(sqlmock.NewRows([]string{ "id", "protocol", "source_ip", "latest_source_endpoint", "platform_name", "source_code", "source_kind", "first_seen_at", "latest_seen_at", "active_span_seconds", "latest_seen_age_seconds", "registration_rows", "phone_count", "unknown_vin_rows", "identifier_matched_phones", "unmapped_phone_count", "identifier_match_ratio", "configured_source_code_matched_phones", "configured_source_code_platform_name", "matched_source_code_count", "candidate_source_code", "candidate_platform_name", "matched_source_codes", "matched_platform_names", "sample_phones", }).AddRow( 7, "GB32960", "8.134.95.166", "8.134.95.166:32960", "现代 HTWO", "HYUNDAI", "UNKNOWN", "2026-07-12 01:00:00", "2026-07-12 01:36:26", 2186, 1800, 0, 0, 0, 0, 0, 0.0, 0, nil, 0, nil, nil, nil, nil, nil, )) handler := NewDataSourceHandler(NewDataSourceRepository(db)) request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources/kind-suggestions?protocol=GB32960", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } body := response.Body.String() for _, want := range []string{`"reason":"source_configured"`, `"suggested_source_kind":"PLATFORM"`, `"suggestion_reason":"non_jt808_configured_source"`} { if !strings.Contains(body, want) { t.Fatalf("response missing %s: %s", want, body) } } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestSuggestSourceKindMarksStaleUnclassifiedSourceAsDirect(t *testing.T) { kind, confidence, reason := suggestSourceKind(DataSourceDiagnosticRow{ SourceKind: "UNKNOWN", Reason: "no_registration", LatestSeenAgeSeconds: 25 * 3600, }) if kind != "DIRECT" || confidence != "LOW" || reason != "stale_unclassified_source_without_registration" { t.Fatalf("unexpected suggestion: kind=%s confidence=%s reason=%s", kind, confidence, reason) } } func TestDataSourceHandlerPatchesOnlyManualFields(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("sqlmock.New() error = %v", err) } defer db.Close() mock.ExpectQuery("SELECT 1 FROM vehicle_data_source WHERE id = \\? LIMIT 1"). WithArgs(int64(3)). WillReturnRows(sqlmock.NewRows([]string{"one"}).AddRow(1)) mock.ExpectExec("UPDATE vehicle_data_source SET platform_name = \\?, source_code = \\?, source_kind = \\?, trust_priority = \\?, enabled = \\?, remark = \\?, updated_at = CURRENT_TIMESTAMP WHERE id = \\?"). WithArgs("G7 平台", "G7S", "PLATFORM", 10, 0, "可信 808 来源", int64(3)). WillReturnResult(sqlmock.NewResult(0, 0)) handler := NewDataSourceHandler(NewDataSourceRepository(db)) body := strings.NewReader(`{"platform_name":"G7 平台","source_code":"G7S","source_kind":"platform","trust_priority":10,"enabled":false,"remark":"可信 808 来源","latest_seen_at":"2099-01-01 00:00:00"}`) request := httptest.NewRequest(http.MethodPatch, "/api/stats/data-sources/3", body) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusOK { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } if !strings.Contains(response.Body.String(), `"updated":true`) { t.Fatalf("patch response should confirm update: %s", response.Body.String()) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatalf("sql expectations: %v", err) } } func TestDataSourceHandlerRejectsConflictingSourceCodeFilters(t *testing.T) { handler := NewDataSourceHandler(NewDataSourceRepository(&sql.DB{})) request := httptest.NewRequest(http.MethodGet, "/api/stats/data-sources?sourceCode=g7s&sourceCodeMissing=true", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } if !strings.Contains(response.Body.String(), "sourceCode") { t.Fatalf("response should mention sourceCode: %s", response.Body.String()) } } func TestDataSourceHandlerRejectsInvalidSourceCode(t *testing.T) { handler := NewDataSourceHandler(NewDataSourceRepository(&sql.DB{})) request := httptest.NewRequest(http.MethodPatch, "/api/stats/data-sources/1", strings.NewReader(`{"source_code":"G7 中文"}`)) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } if !strings.Contains(response.Body.String(), "source_code") { t.Fatalf("response should mention source_code: %s", response.Body.String()) } } func TestDataSourceHandlerRejectsInvalidSourceKind(t *testing.T) { handler := NewDataSourceHandler(NewDataSourceRepository(&sql.DB{})) request := httptest.NewRequest(http.MethodPatch, "/api/stats/data-sources/1", strings.NewReader(`{"source_kind":"temporary"}`)) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } if !strings.Contains(response.Body.String(), "source_kind") { t.Fatalf("response should mention source_kind: %s", response.Body.String()) } } func TestDataSourceHandlerRejectsEmptyPatch(t *testing.T) { handler := NewDataSourceHandler(NewDataSourceRepository(&sql.DB{})) request := httptest.NewRequest(http.MethodPatch, "/api/stats/data-sources/1", strings.NewReader(`{}`)) response := httptest.NewRecorder() handler.ServeHTTP(response, request) if response.Code != http.StatusBadRequest { t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) } if !strings.Contains(response.Body.String(), "no mutable fields") { t.Fatalf("response should mention no mutable fields: %s", response.Body.String()) } }