Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/stats/data_source_query_test.go

701 lines
29 KiB
Go

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())
}
}