222 lines
7.8 KiB
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)
|
|
}
|
|
}
|
|
}
|