Files
lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity/registration_writer_test.go

222 lines
7.8 KiB
Go

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