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

291 lines
16 KiB
Go

package platform
import (
"context"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
func TestAccessSummaryUsesDynamicFreshnessAndDelay(t *testing.T) {
service := NewService(NewMockStore())
summary, err := service.AccessSummary(context.Background(), AccessQuery{})
if err != nil {
t.Fatalf("AccessSummary returned error: %v", err)
}
if summary.TotalVehicles != 6 || summary.OnlineVehicles != 2 || summary.OfflineVehicles != 2 {
t.Fatalf("unexpected access states: %+v", summary)
}
if summary.NeverReported != 1 || summary.UnknownVehicles != 1 || summary.LongOfflineVehicles != 2 {
t.Fatalf("summary must distinguish never/offline/unknown: %+v", summary)
}
if summary.DelayAbnormal != 1 || summary.ThresholdVersion != 1 {
t.Fatalf("summary must expose dynamic delay and threshold version: %+v", summary)
}
if summary.HealthyVehicles != 1 || summary.IncompleteVehicles != 0 || summary.DegradedVehicles != 2 {
t.Fatalf("connection-state counters must be mutually exclusive: %+v", summary)
}
if summary.MasterDataIncompleteVehicles != 1 {
t.Fatalf("master-data maintenance must be counted independently from connection health: %+v", summary)
}
}
func TestAccessVehiclesFiltersAndKeepsEvidenceGapsExplicit(t *testing.T) {
service := NewService(NewMockStore())
page, err := service.AccessVehicles(context.Background(), AccessQuery{OnlineState: "never_reported", Limit: 10})
if err != nil {
t.Fatalf("AccessVehicles returned error: %v", err)
}
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].OnlineState != "never_reported" {
t.Fatalf("unexpected never-reported page: %+v", page)
}
if page.Items[0].FirstSeenAt != "" || page.Items[0].FirstSeenEvidence == "" || page.Items[0].ReportIntervalProof == "" {
t.Fatalf("missing source evidence must stay explicit: %+v", page.Items[0])
}
delayed, err := service.AccessVehicles(context.Background(), AccessQuery{DelayState: "abnormal", Limit: 10})
if err != nil || delayed.Total != 1 || delayed.Items[0].DataDelaySec == nil || *delayed.Items[0].DataDelaySec != 45 {
t.Fatalf("delay filter should use event/receive difference: page=%+v err=%v", delayed, err)
}
attention, err := service.AccessVehicles(context.Background(), AccessQuery{ConnectionState: "attention", Limit: 20})
if err != nil || attention.Total != 5 {
t.Fatalf("attention filter should return every vehicle requiring attention: page=%+v err=%v", attention, err)
}
masterData, err := service.AccessVehicles(context.Background(), AccessQuery{ConnectionState: "master_data", Limit: 20})
if err != nil || masterData.Total != 1 || len(masterData.Items[0].MasterDataIssues) == 0 {
t.Fatalf("master-data queue should include issues regardless of connection state: page=%+v err=%v", masterData, err)
}
}
func TestAccessVehiclesSupportsModelProviderAndTimeFilters(t *testing.T) {
service := NewService(NewMockStore())
page, err := service.AccessVehicles(t.Context(), AccessQuery{Model: "氢燃料", Provider: "车厂", LatestSeenFrom: time.Now().Add(-time.Minute).Format("2006-01-02T15:04"), Limit: 10})
if err != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0].VIN != "LNXNEGRR7SR318212" {
t.Fatalf("advanced access filters failed: page=%+v err=%v", page, err)
}
_, err = service.AccessVehicles(t.Context(), AccessQuery{FirstSeenFrom: "not-a-time"})
clientErr, ok := asClientError(err)
if !ok || clientErr.Code != "ACCESS_TIME_INVALID" {
t.Fatalf("invalid access time must be rejected, got %v", err)
}
_, err = service.AccessSummary(t.Context(), AccessQuery{LatestSeenFrom: "2026-07-14T09:00", LatestSeenTo: "2026-07-14T08:00"})
clientErr, ok = asClientError(err)
if !ok || clientErr.Code != "ACCESS_TIME_RANGE_INVALID" {
t.Fatalf("reversed access time range must be rejected, got %v", err)
}
}
func TestAccessThresholdUpdateUsesOptimisticVersion(t *testing.T) {
store := NewMockStore()
service := NewService(store)
updated, err := service.UpdateAccessThresholds(context.Background(), AccessThresholdUpdate{
Version: 1, DefaultThresholdSec: 600, DelayThresholdSec: 60, LongOfflineSec: 3600,
Protocols: []AccessProtocolThreshold{{Protocol: "JT808", ThresholdSec: 300}}, Actor: "tester",
})
if err != nil {
t.Fatalf("UpdateAccessThresholds returned error: %v", err)
}
if updated.Version != 2 || updated.UpdatedBy != "tester" || len(updated.Audit) != 1 {
t.Fatalf("threshold update should version and audit: %+v", updated)
}
_, err = service.UpdateAccessThresholds(context.Background(), AccessThresholdUpdate{
Version: 1, DefaultThresholdSec: 600, DelayThresholdSec: 60, LongOfflineSec: 3600,
})
clientErr, ok := asClientError(err)
if !ok || clientErr.Code != "ACCESS_THRESHOLD_VERSION_CONFLICT" {
t.Fatalf("stale update should conflict, got %v", err)
}
}
func TestAccessVehicleCarriesDurableProjectionEvidenceAndMasterData(t *testing.T) {
service := NewService(NewMockStore())
page, err := service.AccessVehicles(context.Background(), AccessQuery{Keyword: "LB9A32A24R0LS1426", Limit: 10})
if err != nil || len(page.Items) != 1 {
t.Fatalf("access vehicle query failed: page=%+v err=%v", page, err)
}
row := page.Items[0]
if row.Company != "岭牛示范车队" || row.FirstSeenSource != "live_writer" || row.ReportSampleCount != 120 || row.ReportIntervalSec == nil {
t.Fatalf("durable access evidence/master data missing: %+v", row)
}
}
func TestAccessVehiclesKeywordMatchesFleetAndModel(t *testing.T) {
service := NewService(NewMockStore())
fleet, err := service.AccessVehicles(t.Context(), AccessQuery{Keyword: "岭牛示范车队", Limit: 20})
if err != nil || fleet.Total != 1 || fleet.Items[0].VIN != "LB9A32A24R0LS1426" {
t.Fatalf("fleet keyword should find its vehicles: page=%+v err=%v", fleet, err)
}
model, err := service.AccessVehicles(t.Context(), AccessQuery{Keyword: "氢燃料车型", Limit: 20})
if err != nil || model.Total != 1 || model.Items[0].VIN != "LNXNEGRR7SR318212" {
t.Fatalf("model keyword should find its vehicles: page=%+v err=%v", model, err)
}
}
func TestAccessVehicleShowsCanonicalSlotsWithoutInventingExpectedSources(t *testing.T) {
store := NewMockStore()
service := NewService(store)
page, err := service.AccessVehicles(t.Context(), AccessQuery{Keyword: "LB9A32A24R0LS1426", Limit: 10})
if err != nil || len(page.Items) != 1 {
t.Fatalf("access vehicle query failed: page=%+v err=%v", page, err)
}
row := page.Items[0]
if len(row.ExpectedProtocols) != 0 || len(row.ProtocolStatuses) != 3 {
t.Fatalf("vehicle must expose comparison slots without inventing an expected-source baseline: %+v", row)
}
if len(row.ActualProtocols) != 1 || row.ActualProtocols[0] != "JT808" || len(row.MissingProtocols) != 0 {
t.Fatalf("actual source must remain truthful and absent protocols must not be reported as missing: %+v", row)
}
if row.ConnectionState != "healthy" || row.OnlineState != "online" {
t.Fatalf("a maintained single online source should be healthy: %+v", row)
}
for _, status := range row.ProtocolStatuses {
if status.Protocol == "JT808" && (!status.Connected || status.LatestReceivedAt == "") {
t.Fatalf("connected protocol lost evidence: %+v", status)
}
if status.Expected {
t.Fatalf("business expectation must remain unknown until OneOS provides it: %+v", status)
}
}
}
func TestAccessVehicleSeparatesMasterDataMaintenanceFromSourceAbsence(t *testing.T) {
now := time.Now()
row := buildAccessVehicleGroup([]AccessEvidenceRow{{
VIN: "VIN-MASTER-DATA", Protocol: "JT808", OEM: "", Provider: "",
LatestEventAt: now.Add(-2 * time.Second).Format(time.RFC3339),
LatestReceivedAt: now.Add(-time.Second).Format(time.RFC3339),
}}, defaultAccessThresholds(now), now)
if row.ConnectionState != "incomplete" || len(row.MasterDataIssues) != 2 {
t.Fatalf("online source with missing brand/provider should enter the maintenance queue: %+v", row)
}
if len(row.MissingProtocols) != 0 || len(row.ExpectedProtocols) != 0 {
t.Fatalf("master-data maintenance must not invent absent protocol alarms: %+v", row)
}
}
func TestProductionAccessEvidenceReadsDurableWriterProjection(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewProductionStore(db, nil, "")
mock.ExpectQuery("(?s)SELECT.*access_first_seen_at.*access_latest_received_at.*FROM.*SELECT DISTINCT vin FROM vehicle_identity_binding.*vehicle_profile.*vehicle_realtime_snapshot").
WithArgs(accessEvidenceLimit + 1).
WillReturnRows(sqlmock.NewRows([]string{
"vin", "plate", "oem", "model_name", "company_name", "protocol", "provider", "first_seen_at", "first_seen_source",
"event_time", "received_at", "updated_at", "report_interval_ms", "report_sample_count", "event_id", "longitude", "latitude", "speed_kmh", "soc_percent", "total_mileage_km", "daily_mileage_km",
}).AddRow("VIN001", "粤A1", "示范厂家", "纯电客车", "示范公交", "GB32960", "车厂平台", "2026-07-14 05:00:00.000", "snapshot_backfill", "2026-07-14 05:01:00.000", "2026-07-14 05:01:30.500", "2026-07-14 05:01:30.500", int64(30500), int64(3), "evt-3", 113.1, 23.1, 32.5, 76.0, 12000.5, 38.6))
rows, err := store.AccessEvidence(context.Background())
if err != nil || len(rows) != 1 {
t.Fatalf("AccessEvidence rows=%+v err=%v", rows, err)
}
row := rows[0]
if row.ReportIntervalSec == nil || *row.ReportIntervalSec != 31 || row.ReportSampleCount != 3 || row.Model != "纯电客车" || row.Company != "示范公交" {
t.Fatalf("unexpected durable projection row: %+v", row)
}
if row.SpeedKmh != 32.5 || row.TotalMileageKm != 12000.5 || row.DailyMileageKm != 38.6 {
t.Fatalf("realtime source metrics missing: %+v", row)
}
if row.FirstSeenEvidence != "上线基线:由实时快照回填(非历史首次接入)" || row.ReportIntervalProof == "" {
t.Fatalf("projection evidence boundary missing: %+v", row)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestAccessUnresolvedIdentitiesRemainMaskedAndProtocolScoped(t *testing.T) {
service := NewService(NewMockStore())
page, err := service.AccessUnresolvedIdentities(t.Context(), AccessUnresolvedIdentityQuery{Protocol: "jt808", Limit: 20})
if err != nil || page.Total != 1 || len(page.Items) != 1 {
t.Fatalf("unresolved identity queue failed: page=%+v err=%v", page, err)
}
if page.Items[0].IdentifierMasked != "138****0001" || page.Items[0].IssueCode != "missing_vin_jt808" || page.Items[0].RecommendedAction == "" {
t.Fatalf("unresolved evidence is not actionable and masked: %+v", page.Items[0])
}
empty, err := service.AccessUnresolvedIdentities(t.Context(), AccessUnresolvedIdentityQuery{Protocol: "GB32960"})
if err != nil || empty.Total != 0 || len(empty.Items) != 0 {
t.Fatalf("non-JT808 unresolved query must be empty: page=%+v err=%v", empty, err)
}
}
func TestClaimAccessIdentityBindsExistingVehicleAndReturnsProfileFollowUp(t *testing.T) {
service := NewService(NewMockStore())
result, err := service.ClaimAccessIdentity(t.Context(), "mock-unresolved-jt808", AccessIdentityClaimInput{VIN: "LB9A32A24R0LS1426", Actor: "access-admin", Note: "设备交付单核对通过"})
if err != nil {
t.Fatalf("claim access identity: %v", err)
}
if result.VIN != "LB9A32A24R0LS1426" || result.Plate != "粤AG18312" || result.IdentifierMasked != "138****0001" {
t.Fatalf("claim result lost identity evidence: %+v", result)
}
if !result.ProfileComplete || len(result.ProfileMissingFields) != 0 || result.AuditID == 0 || result.ClaimedBy != "access-admin" {
t.Fatalf("claim result must expose profile and audit state: %+v", result)
}
page, err := service.AccessUnresolvedIdentities(t.Context(), AccessUnresolvedIdentityQuery{Protocol: "JT808", Limit: 20})
if err != nil || page.Total != 0 || len(page.Items) != 0 {
t.Fatalf("claimed identity must leave the pending queue: page=%+v err=%v", page, err)
}
if _, err := service.ClaimAccessIdentity(t.Context(), "mock-unresolved-jt808", AccessIdentityClaimInput{VIN: "LB9A32A24R0LS1426"}); err == nil {
t.Fatal("repeated claim must fail as stale")
}
}
func TestProductionClaimAccessIdentityUsesLockedBindingAndImmutableAudit(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewProductionStore(db, nil, "")
mock.ExpectExec(`(?s)CREATE TABLE IF NOT EXISTS vehicle_access_threshold_config`).WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec(`(?s)CREATE TABLE IF NOT EXISTS vehicle_access_threshold_audit`).WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec(`(?s)CREATE TABLE IF NOT EXISTS vehicle_access_identity_audit`).WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectExec(`(?s)INSERT IGNORE INTO vehicle_access_threshold_config`).WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectBegin()
mock.ExpectQuery(`(?s)SELECT r\.phone.*SHA2\(CONCAT\('JT808:',r\.phone\),256\)=\?.*FOR UPDATE`).WithArgs("identity-hash").WillReturnRows(sqlmock.NewRows([]string{"phone", "plate", "manufacturer"}).AddRow("13800000001", "粤A00001", "测试终端"))
mock.ExpectQuery(`(?s)SELECT COALESCE\(plate,''\),COALESCE\(phone,''\).*vehicle_identity_binding.*FOR UPDATE`).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"plate", "phone"}).AddRow("粤A00001", ""))
mock.ExpectQuery(`(?s)SELECT vin FROM vehicle_identity_binding WHERE phone=\?.*FOR UPDATE`).WithArgs("13800000001", "VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin"}))
mock.ExpectExec(`UPDATE vehicle_identity_binding SET phone=\?,updated_at=CURRENT_TIMESTAMP`).WithArgs("13800000001", "VIN001").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(`UPDATE jt808_registration SET vin=\? WHERE phone=\?`).WithArgs("VIN001", "13800000001").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery(`(?s)SELECT COALESCE\(brand_name,''\).*FROM vehicle_profile`).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"brand", "model", "type", "company", "status", "provider", "first_access"}).AddRow("品牌", "车型", "乘用车", "测试企业", "active", "测试服务商", "2026-07-01 08:00:00"))
mock.ExpectExec(`(?s)INSERT INTO vehicle_access_identity_audit`).WithArgs("identity-hash", "identity-hash", "138****0001", "粤A00001", "测试终端", "VIN001", "access-admin", "核对通过").WillReturnResult(sqlmock.NewResult(42, 1))
mock.ExpectCommit()
result, err := store.ClaimAccessIdentity(t.Context(), "identity-hash", AccessIdentityClaimInput{VIN: "VIN001", Actor: "access-admin", Note: "核对通过"})
if err != nil {
t.Fatalf("production claim: %v", err)
}
if result.AuditID != 42 || !result.ProfileComplete || result.IdentifierMasked != "138****0001" {
t.Fatalf("production claim result: %+v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestProductionUnresolvedIdentityQueueExcludesBoundPhonesAndNeverReturnsRawIdentifier(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewProductionStore(db, nil, "")
mock.ExpectQuery(`(?s)SELECT COUNT\(\*\).*jt808_registration.*COALESCE.*b\.vin`).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(`(?s)SELECT.*SHA2\(CONCAT\('JT808:',r\.phone\),256\).*CHAR_LENGTH\(r\.phone\).*missing_vin_jt808.*ORDER BY r\.latest_seen_at`).WithArgs(20, 0).WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "identifier_masked", "plate", "manufacturer", "source_endpoint", "first_registered_at", "latest_registered_at", "latest_authenticated_at", "latest_seen_at", "freshness_sec", "issue_code", "recommended_action",
}).AddRow("hash-id", "JT808", "410****3543", "", "示范终端", "gateway-a", "", "", "", "2026-07-14 07:52:09", 20, "missing_vin_jt808", "核对后绑定"))
page, err := store.AccessUnresolvedIdentities(t.Context(), AccessUnresolvedIdentityQuery{Limit: 20})
if err != nil || page.Total != 1 || len(page.Items) != 1 {
t.Fatalf("production unresolved query failed: page=%+v err=%v", page, err)
}
if page.Items[0].ID != "hash-id" || page.Items[0].IdentifierMasked != "410****3543" || page.Items[0].FreshnessSec != 20 {
t.Fatalf("production unresolved evidence lost: %+v", page.Items[0])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}