feat: build vehicle data platform and production pipeline
This commit is contained in:
221
go/vehicle-gateway/internal/identity/registration_writer_test.go
Normal file
221
go/vehicle-gateway/internal/identity/registration_writer_test.go
Normal file
@@ -0,0 +1,221 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user