266 lines
14 KiB
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)
|
|
}
|
|
}
|