Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/platform/profile_test.go

266 lines
14 KiB
Go

package platform
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"regexp"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
func TestVehicleProfileReturnsCompletenessAndMissingFields(t *testing.T) {
service := NewService(NewMockStore())
profile, err := service.VehicleProfile(context.Background(), "LB9A32A24R0LS1426")
if err != nil {
t.Fatal(err)
}
if profile.Completeness != 100 || len(profile.MissingFields) != 0 || profile.RuntimeSeconds == nil {
t.Fatalf("expected complete seeded profile, got %+v", profile)
}
empty, err := service.VehicleProfile(context.Background(), "LNXNEGRR7SR318212")
if err != nil {
t.Fatal(err)
}
if empty.Version != 0 || empty.Completeness != 0 || len(empty.MissingFields) != 7 {
t.Fatalf("expected explicit empty profile, got %+v", empty)
}
}
func TestSaveVehicleProfileCreatesAndUsesOptimisticVersion(t *testing.T) {
service := NewService(NewMockStore())
runtime := int64(3600)
input := VehicleProfileInput{ModelName: "氢燃料重卡", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "active", AccessProvider: "车厂平台", FirstAccessAt: "2026-07-01T08:30:00+08:00", RuntimeSeconds: &runtime, Actor: "admin-a"}
profile, err := service.SaveVehicleProfile(context.Background(), "LNXNEGRR7SR318212", input)
if err != nil {
t.Fatal(err)
}
if profile.Version != 1 || profile.Completeness != 100 || profile.UpdatedBy != "admin-a" {
t.Fatalf("unexpected created profile: %+v", profile)
}
input.Version = 0
_, err = service.SaveVehicleProfile(context.Background(), "LNXNEGRR7SR318212", input)
clientErr, ok := asClientError(err)
if !ok || clientErr.Code != "VEHICLE_PROFILE_VERSION_CONFLICT" {
t.Fatalf("stale write should conflict, got %v", err)
}
}
func TestVehicleProfileValidationRejectsInventedVINAndInvalidValues(t *testing.T) {
service := NewService(NewMockStore())
negative := int64(-1)
_, err := service.SaveVehicleProfile(context.Background(), "LNOTPRESENT000000", VehicleProfileInput{OperationStatus: "active", Actor: "admin"})
clientErr, ok := asClientError(err)
if !ok || clientErr.Code != "VEHICLE_NOT_FOUND" {
t.Fatalf("invented VIN should be rejected, got %v", err)
}
_, err = service.SaveVehicleProfile(context.Background(), "LNXNEGRR7SR318212", VehicleProfileInput{OperationStatus: "flying", RuntimeSeconds: &negative, Actor: "admin"})
clientErr, ok = asClientError(err)
if !ok || clientErr.Code != "VEHICLE_PROFILE_STATUS_INVALID" {
t.Fatalf("invalid status should be rejected first, got %v", err)
}
}
func TestVehicleProfileSyncIsDryRunnableIdempotentAndVersionSafe(t *testing.T) {
service := NewService(NewMockStore())
runtime := int64(7200)
request := VehicleProfileSyncRequest{
SourceSystem: " OEM-TSP ", SourceVersion: "snapshot-20260714-01", DryRun: true, Actor: "sync-admin",
Items: []VehicleProfileSyncItem{{VIN: " lnxnegrr7sr318212 ", ModelName: "氢燃料重卡", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "ACTIVE", AccessProvider: "车厂平台", FirstAccessAt: "2026-07-01T08:30:00+08:00", RuntimeSeconds: &runtime}},
}
dryRun, err := service.SyncVehicleProfiles(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if !dryRun.DryRun || dryRun.Created != 1 || dryRun.Items[0].VIN != "LNXNEGRR7SR318212" {
t.Fatalf("unexpected dry-run result: %+v", dryRun)
}
profile, err := service.VehicleProfile(context.Background(), "LNXNEGRR7SR318212")
if err != nil || profile.Version != 0 {
t.Fatalf("dry-run must not persist profile: profile=%+v err=%v", profile, err)
}
request.DryRun = false
created, err := service.SyncVehicleProfiles(context.Background(), request)
if err != nil || created.Created != 1 {
t.Fatalf("expected created sync: result=%+v err=%v", created, err)
}
profile, err = service.VehicleProfile(context.Background(), "LNXNEGRR7SR318212")
if err != nil || profile.SourceSystem != "oem-tsp" || profile.SourceVersion != request.SourceVersion || profile.Version != 1 {
t.Fatalf("unexpected synced profile: %+v err=%v", profile, err)
}
repeated, err := service.SyncVehicleProfiles(context.Background(), request)
if err != nil || repeated.Unchanged != 1 || repeated.Items[0].Status != profileSyncUnchanged {
t.Fatalf("same source version should be idempotent: result=%+v err=%v", repeated, err)
}
request.Items[0].CompanyName = "同版本篡改"
conflict, err := service.SyncVehicleProfiles(context.Background(), request)
if err != nil || conflict.Conflicted != 1 || conflict.Items[0].Status != profileSyncConflictSourceVersion {
t.Fatalf("changed payload under same source version should conflict: result=%+v err=%v", conflict, err)
}
request.SourceVersion = "snapshot-20260714-02"
updated, err := service.SyncVehicleProfiles(context.Background(), request)
if err != nil || updated.Updated != 1 || updated.Items[0].ProfileVersion != 2 {
t.Fatalf("new source version should update: result=%+v err=%v", updated, err)
}
}
func TestVehicleProfileSyncProtectsManualOwnershipAndReportsMissingVIN(t *testing.T) {
service := NewService(NewMockStore())
request := VehicleProfileSyncRequest{
SourceSystem: "fleet-gps", SourceVersion: "42", Actor: "sync-admin",
Items: []VehicleProfileSyncItem{
{VIN: "LB9A32A24R0LS1426", ModelName: "外部车型", OperationStatus: "active"},
{VIN: "LNOTPRESENT000000", ModelName: "未知车辆", OperationStatus: "active"},
},
}
result, err := service.SyncVehicleProfiles(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if result.Conflicted != 1 || result.Missing != 1 || result.Items[0].Status != profileSyncConflictSource || result.Items[1].Status != profileSyncMissingVehicle {
t.Fatalf("default policy must preserve manual profile and report missing VIN: %+v", result)
}
request.ConflictPolicy = profileSyncConflictPolicyOverwrite
request.Items = request.Items[:1]
overwritten, err := service.SyncVehicleProfiles(context.Background(), request)
if err != nil || overwritten.Updated != 1 {
t.Fatalf("explicit overwrite should take ownership: result=%+v err=%v", overwritten, err)
}
profile, _ := service.VehicleProfile(context.Background(), "LB9A32A24R0LS1426")
if profile.SourceSystem != "fleet-gps" || profile.ModelName != "外部车型" {
t.Fatalf("overwrite did not update source ownership: %+v", profile)
}
}
func TestVehicleProfileSyncValidatesBatchBoundsAndDuplicateVIN(t *testing.T) {
service := NewService(NewMockStore())
base := VehicleProfileSyncRequest{SourceSystem: "oem-tsp", SourceVersion: "1", Actor: "admin"}
base.Items = []VehicleProfileSyncItem{{VIN: "LNXNEGRR7SR318212"}, {VIN: "lnxnegrr7sr318212"}}
_, err := service.SyncVehicleProfiles(context.Background(), base)
clientErr, ok := asClientError(err)
if !ok || clientErr.Code != "VEHICLE_PROFILE_SYNC_DUPLICATE_VIN" {
t.Fatalf("duplicate VIN should fail validation, got %v", err)
}
base.Items = make([]VehicleProfileSyncItem, vehicleProfileSyncLimit+1)
_, err = service.SyncVehicleProfiles(context.Background(), base)
clientErr, ok = asClientError(err)
if !ok || clientErr.Code != "VEHICLE_PROFILE_SYNC_SIZE_INVALID" {
t.Fatalf("oversized batch should fail validation, got %v", err)
}
}
func TestVehicleDetailIncludesProfile(t *testing.T) {
service := NewService(NewMockStore())
detail, err := service.VehicleDetail(context.Background(), "LB9A32A24R0LS1426", "")
if err != nil {
t.Fatal(err)
}
if detail.Profile == nil || detail.Profile.CompanyName != "岭牛示范车队" || detail.Profile.Completeness != 100 {
t.Fatalf("vehicle detail missing master profile: %+v", detail.Profile)
}
}
func TestVehicleProfileHandlersReadAndUpdate(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
read := httptest.NewRecorder()
handler.ServeHTTP(read, httptest.NewRequest(http.MethodGet, "/api/v2/vehicles/LNXNEGRR7SR318212/profile", nil))
if read.Code != http.StatusOK || !bytes.Contains(read.Body.Bytes(), []byte(`"completeness":0`)) {
t.Fatalf("empty profile read status=%d body=%s", read.Code, read.Body.String())
}
update := httptest.NewRecorder()
body := bytes.NewBufferString(`{"modelName":"氢燃料重卡","vehicleType":"重卡","companyName":"示范物流","operationStatus":"active","accessProvider":"车厂平台","firstAccessAt":"2026-07-01T08:30","runtimeSeconds":3600,"version":0}`)
handler.ServeHTTP(update, httptest.NewRequest(http.MethodPut, "/api/v2/vehicles/LNXNEGRR7SR318212/profile", body))
if update.Code != http.StatusOK {
t.Fatalf("profile update status=%d body=%s", update.Code, update.Body.String())
}
var response struct {
Data VehicleProfile `json:"data"`
}
if err := json.Unmarshal(update.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Data.Version != 1 || response.Data.Completeness != 100 || response.Data.UpdatedBy != "system" {
t.Fatalf("unexpected handler profile: %+v", response.Data)
}
}
func TestVehicleProfileSyncHandlerUsesAuthenticatedActor(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
recorder := httptest.NewRecorder()
body := bytes.NewBufferString(`{"sourceSystem":"oem-tsp","sourceVersion":"v1","items":[{"vin":"LNXNEGRR7SR318212","modelName":"氢燃料重卡","operationStatus":"active"}]}`)
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/api/v2/vehicle-profiles/sync", body))
if recorder.Code != http.StatusOK || !bytes.Contains(recorder.Body.Bytes(), []byte(`"created":1`)) {
t.Fatalf("profile sync status=%d body=%s", recorder.Code, recorder.Body.String())
}
profile, _ := handler.service.VehicleProfile(context.Background(), "LNXNEGRR7SR318212")
if profile.UpdatedBy != "system" {
t.Fatalf("handler must override body actor with authenticated actor, got %+v", profile)
}
}
func TestProductionVehicleProfileCreateIsTransactionalAndAudited(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewProductionStore(db, nil, "")
now := time.Now()
runtime := int64(7200)
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ('vehicle_profile','vehicle_profile_audit')`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT version FROM vehicle_profile WHERE vin=? FOR UPDATE`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"version"}))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile(vin,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?, 'manual','',NULL,1,?)`)).WithArgs("VIN001", "示范车型", "重卡", "示范物流", "active", "车厂平台", sqlmock.AnyArg(), runtime, "admin-a").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile_audit(vin,profile_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`)).WithArgs("VIN001", 1, "admin-a", "create", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
columns := []string{"vin", "model_name", "vehicle_type", "company_name", "operation_status", "access_provider", "first_access_at", "runtime_seconds", "source_system", "source_version", "synced_at", "version", "updated_by", "updated_at"}
mock.ExpectQuery(regexp.QuoteMeta(vehicleProfileSelect + `WHERE vin=?`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows(columns).AddRow("VIN001", "示范车型", "重卡", "示范物流", "active", "车厂平台", now, runtime, "manual", "", nil, 1, "admin-a", now))
profile, err := store.SaveVehicleProfile(context.Background(), "VIN001", VehicleProfileInput{ModelName: "示范车型", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "active", AccessProvider: "车厂平台", FirstAccessAt: now.Format(time.RFC3339), RuntimeSeconds: &runtime, Actor: "admin-a"})
if err != nil {
t.Fatal(err)
}
if profile.Version != 1 || profile.RuntimeSeconds == nil || *profile.RuntimeSeconds != runtime {
t.Fatalf("unexpected stored profile: %+v", profile)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestProductionVehicleProfileSyncCreatesAndAuditsInOneTransaction(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewProductionStore(db, nil, "")
runtime := int64(3600)
request := VehicleProfileSyncRequest{
SourceSystem: "oem-tsp", SourceVersion: "snapshot-1", Actor: "sync-admin",
Items: []VehicleProfileSyncItem{{VIN: "VIN001", ModelName: "示范车型", VehicleType: "重卡", CompanyName: "示范物流", OperationStatus: "active", AccessProvider: "车厂平台", FirstAccessAt: "2026-07-01T08:30:00+08:00", RuntimeSeconds: &runtime}},
}
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name IN ('vehicle_profile','vehicle_profile_audit')`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2))
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT DISTINCT vin FROM vehicle_identity_binding WHERE vin IN (?)`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin"}).AddRow("VIN001"))
mock.ExpectQuery(regexp.QuoteMeta(vehicleProfileSelect + `WHERE vin=? FOR UPDATE`)).WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"vin", "model_name", "vehicle_type", "company_name", "operation_status", "access_provider", "first_access_at", "runtime_seconds", "source_system", "source_version", "synced_at", "version", "updated_by", "updated_at"}))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile(vin,model_name,vehicle_type,company_name,operation_status,access_provider,first_access_at,runtime_seconds,source_system,source_version,synced_at,version,updated_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,1,?)`)).WithArgs("VIN001", "示范车型", "重卡", "示范物流", "active", "车厂平台", sqlmock.AnyArg(), runtime, "oem-tsp", "snapshot-1", sqlmock.AnyArg(), "sync-admin").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_profile_audit(vin,profile_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`)).WithArgs("VIN001", 1, "sync-admin", "sync_created", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
result, err := store.SyncVehicleProfiles(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if result.Created != 1 || result.Items[0].ProfileVersion != 1 {
t.Fatalf("unexpected sync result: %+v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}