package identity import ( "context" "regexp" "testing" "time" "github.com/DATA-DOG/go-sqlmock" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" ) func TestJT808RegistrationProjectorProjectsRegistrationUsingReceiveTime(t *testing.T) { loc := time.FixedZone("Asia/Shanghai", 8*60*60) projector := NewJT808RegistrationProjector(loc, 10*time.Minute) receivedAt := time.Date(2026, 7, 13, 17, 20, 30, 987000000, loc) env := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, MessageID: JT808RegisterMessageID, Phone: "0013307795425", VIN: "LTESTVIN000000001", DeviceID: "DEV-1", Plate: "粤A00001", SourceEndpoint: "115.231.168.135:43625", ReceivedAtMS: receivedAt.UnixMilli(), EventTimeMS: receivedAt.Add(24 * time.Hour).UnixMilli(), ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{ "jt808.registration.province": "44", "jt808.registration.city": "1", "jt808.registration.manufacturer": "YUTNG", "jt808.registration.device_type": "TBOX-1", "jt808.registration.plate_color": "2", }, } facts := projector.ProjectBatch([]envelope.FrameEnvelope{env}) if len(facts) != 1 { t.Fatalf("facts = %d, want 1", len(facts)) } fact := facts[0] if fact.Phone != "13307795425" || fact.VIN != env.VIN || fact.Plate != env.Plate { t.Fatalf("identity fact = %+v", fact) } wantTime := receivedAt.Truncate(time.Second) if !fact.SeenAt.Equal(wantTime) || fact.FirstRegisteredAt == nil || !fact.FirstRegisteredAt.Equal(wantTime) { t.Fatalf("fact times = seen %s first %#v, want %s", fact.SeenAt, fact.FirstRegisteredAt, wantTime) } if fact.SourceIP != "115.231.168.135" || fact.Manufacturer != "YUTNG" || fact.PlateColor != "2" { t.Fatalf("registration details = %+v", fact) } } func TestJT808RegistrationProjectorThrottlesLocationOnlyAfterPersist(t *testing.T) { loc := time.UTC projector := NewJT808RegistrationProjector(loc, 10*time.Minute) base := time.Date(2026, 7, 13, 8, 0, 0, 0, loc) location := func(at time.Time) envelope.FrameEnvelope { return envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, MessageID: JT808LocationMessageID, Phone: "013307795425", VIN: "LTESTVIN000000001", ReceivedAtMS: at.UnixMilli(), ParseStatus: envelope.ParseOK, } } first := projector.ProjectBatch([]envelope.FrameEnvelope{location(base)}) if len(first) != 1 { t.Fatalf("first facts = %d, want 1", len(first)) } // A failed database attempt must remain immediately replayable. if replay := projector.ProjectBatch([]envelope.FrameEnvelope{location(base)}); len(replay) != 1 { t.Fatalf("uncommitted replay facts = %d, want 1", len(replay)) } projector.MarkPersisted(first) if throttled := projector.ProjectBatch([]envelope.FrameEnvelope{location(base.Add(9 * time.Minute))}); len(throttled) != 0 { t.Fatalf("throttled facts = %d, want 0", len(throttled)) } if due := projector.ProjectBatch([]envelope.FrameEnvelope{location(base.Add(10 * time.Minute))}); len(due) != 1 { t.Fatalf("due facts = %d, want 1", len(due)) } } func TestJT808RegistrationProjectorMergesRegisterAndAuthForPhone(t *testing.T) { projector := NewJT808RegistrationProjector(time.UTC, 10*time.Minute) base := time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC) register := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, MessageID: JT808RegisterMessageID, Phone: "013307795425", VIN: "LTESTVIN000000001", Plate: "粤A00001", ReceivedAtMS: base.UnixMilli(), ParseStatus: envelope.ParseOK, } auth := envelope.FrameEnvelope{ Protocol: envelope.ProtocolJT808, MessageID: JT808AuthMessageID, Phone: "13307795425", ReceivedAtMS: base.Add(time.Second).UnixMilli(), ParseStatus: envelope.ParseOK, ParsedFields: map[string]any{ "jt808.authentication.token": "g7gps", "jt808.authentication.imei": "123456789012345", }, } facts := projector.ProjectBatch([]envelope.FrameEnvelope{register, auth}) if len(facts) != 1 { t.Fatalf("facts = %d, want 1", len(facts)) } fact := facts[0] if fact.VIN != register.VIN || fact.Plate != register.Plate || fact.AuthToken != "g7gps" { t.Fatalf("merged fact = %+v", fact) } if fact.FirstRegisteredAt == nil || fact.LatestRegisteredAt == nil || fact.LatestAuthenticated == nil { t.Fatalf("merged timestamps missing: %+v", fact) } } func TestJT808RegistrationProjectorDoesNotTrustEnforcedRejectedAuth(t *testing.T) { projector := NewJT808RegistrationProjector(time.UTC, 10*time.Minute) seenAt := time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC) facts := projector.ProjectBatch([]envelope.FrameEnvelope{{ Protocol: envelope.ProtocolJT808, MessageID: JT808AuthMessageID, Phone: "13307795425", ReceivedAtMS: seenAt.UnixMilli(), ParseStatus: envelope.ParseOK, AuthenticationEnforced: true, AuthenticationStatus: "rejected", Parsed: map[string]any{ "authentication": map[string]any{ "token": "untrusted-token", "imei": "untrusted-imei", "software_version": "untrusted-version", }, }, }}) if len(facts) != 1 { t.Fatalf("facts = %d, want 1 audit touch", len(facts)) } fact := facts[0] if fact.AuthToken != "" || fact.AuthIMEI != "" || fact.AuthSoftwareVersion != "" || fact.LatestAuthenticated != nil { t.Fatalf("rejected credential leaked into registration fact: %+v", fact) } } func TestJT808RegistrationStoreUsesIdempotentEventTimeUpsert(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatal(err) } defer db.Close() store := NewJT808RegistrationStore(db) seenAt := time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC) mock.ExpectExec(regexp.QuoteMeta("INSERT INTO jt808_registration")). WithArgs( "13307795425", "DEV-1", "粤A00001", "LTESTVIN000000001", "", "", "YUTNG", "", "", "g7gps", "", "", "115.231.168.135:43625", "115.231.168.135", sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), seenAt, ). WillReturnResult(sqlmock.NewResult(1, 1)) err = store.UpsertBatch(context.Background(), []JT808RegistrationFact{{ Phone: "013307795425", DeviceID: "DEV-1", Plate: "粤A00001", VIN: "LTESTVIN000000001", Manufacturer: "YUTNG", AuthToken: "g7gps", SourceEndpoint: "115.231.168.135:43625", SourceIP: "115.231.168.135", FirstRegisteredAt: timePointer(seenAt), LatestRegisteredAt: timePointer(seenAt), LatestAuthenticated: timePointer(seenAt), SeenAt: seenAt, }}) if err != nil { t.Fatalf("UpsertBatch() error = %v", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } func TestRegistrationUpsertProtectsLatestValuesFromOldReplay(t *testing.T) { var query string db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(_ string, actual string) error { query = actual return nil }))) if err != nil { t.Fatal(err) } defer db.Close() store := NewJT808RegistrationStore(db) mock.ExpectExec("INSERT INTO jt808_registration").WillReturnResult(sqlmock.NewResult(1, 1)) if err := store.UpsertBatch(context.Background(), []JT808RegistrationFact{{ Phone: "13307795425", VIN: "unknown", SeenAt: time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC), }}); err != nil { t.Fatal(err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } for _, required := range []string{ "VALUES(latest_seen_at) >= latest_seen_at", "LEAST(first_registered_at, VALUES(first_registered_at))", "GREATEST(latest_authenticated_at, VALUES(latest_authenticated_at))", "latest_seen_at = GREATEST(latest_seen_at, VALUES(latest_seen_at))", } { if !regexp.MustCompile(regexp.QuoteMeta(required)).MatchString(query) { t.Fatalf("upsert SQL missing %q", required) } } }