547 lines
24 KiB
Go
547 lines
24 KiB
Go
package openplatform
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type fakeRepository struct {
|
|
app AppCredential
|
|
authErr error
|
|
vehicles map[string]AuthorizedVehicle
|
|
hydrogen map[string]DailyHydrogen
|
|
mileage map[string]DailyMileage
|
|
priorMileage map[string]DailyMileage
|
|
authorizedVIN bool
|
|
totalMileage *TotalMileagePoint
|
|
audits []string
|
|
createdHash [sha256.Size]byte
|
|
createdPrefix string
|
|
requestedPlates []string
|
|
dailyVINs []string
|
|
dailyProtocols []string
|
|
rangeVINs []string
|
|
priorVINs []string
|
|
priorCalls int
|
|
snapshot MileageSnapshot
|
|
}
|
|
|
|
func (f *fakeRepository) Authenticate(context.Context, [sha256.Size]byte, time.Time, time.Time, time.Time) (AppCredential, error) {
|
|
return f.app, f.authErr
|
|
}
|
|
func (f *fakeRepository) AuthorizedVehicles(_ context.Context, _ uint64, plates []string, _ time.Time, _ time.Time) (map[string]AuthorizedVehicle, error) {
|
|
f.requestedPlates = append([]string(nil), plates...)
|
|
return f.vehicles, nil
|
|
}
|
|
func (f *fakeRepository) DailyHydrogen(_ context.Context, vins []string, _ string) (map[string]DailyHydrogen, error) {
|
|
f.dailyVINs = append([]string(nil), vins...)
|
|
return f.hydrogen, nil
|
|
}
|
|
func (f *fakeRepository) DailyMileage(_ context.Context, vins []string, _ string, protocols []string) (map[string]DailyMileage, error) {
|
|
f.dailyVINs = append([]string(nil), vins...)
|
|
f.dailyProtocols = append([]string(nil), protocols...)
|
|
return f.mileage, nil
|
|
}
|
|
func (f *fakeRepository) DailyMileageRange(_ context.Context, vins []string, _, _ string, protocols []string) (map[string]DailyMileage, error) {
|
|
f.rangeVINs = append([]string(nil), vins...)
|
|
f.dailyVINs = append([]string(nil), vins...)
|
|
f.dailyProtocols = append([]string(nil), protocols...)
|
|
return f.mileage, nil
|
|
}
|
|
func (f *fakeRepository) LatestMileageBefore(_ context.Context, vins []string, _ string, protocols []string) (map[string]DailyMileage, error) {
|
|
f.priorCalls++
|
|
f.priorVINs = append([]string(nil), vins...)
|
|
f.dailyVINs = append([]string(nil), vins...)
|
|
f.dailyProtocols = append([]string(nil), protocols...)
|
|
return f.priorMileage, nil
|
|
}
|
|
func (f *fakeRepository) CreateMileageSnapshot(_ context.Context, snapshot MileageSnapshot) error {
|
|
f.snapshot = snapshot
|
|
return nil
|
|
}
|
|
func (f *fakeRepository) LoadMileageSnapshot(_ context.Context, id string, appID uint64, _ time.Time) (MileageSnapshot, error) {
|
|
if f.snapshot.ID != id || f.snapshot.AppID != appID {
|
|
return MileageSnapshot{}, ErrInvalidRequest
|
|
}
|
|
return f.snapshot, nil
|
|
}
|
|
func (f *fakeRepository) AuthorizedVIN(context.Context, uint64, string, time.Time) (bool, error) {
|
|
return f.authorizedVIN, nil
|
|
}
|
|
func (f *fakeRepository) TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error) {
|
|
return f.totalMileage, nil
|
|
}
|
|
func (f *fakeRepository) Audit(_ context.Context, _ uint64, endpoint, result, _ string, _ int, _ string) error {
|
|
f.audits = append(f.audits, endpoint+":"+result)
|
|
return nil
|
|
}
|
|
func (f *fakeRepository) CreateApp(_ context.Context, input AppInput, hash [sha256.Size]byte, prefix string, from time.Time, to *time.Time, actor string) (App, error) {
|
|
f.createdHash, f.createdPrefix = hash, prefix
|
|
return App{ID: 1, Name: input.Name, AppKeyPrefix: prefix, Status: input.Status, ValidFrom: from, ValidTo: to, CreatedBy: actor}, nil
|
|
}
|
|
func (f *fakeRepository) ListApps(context.Context) ([]App, error) { return nil, nil }
|
|
func (f *fakeRepository) UpdateApp(context.Context, uint64, AppInput, time.Time, *time.Time, string) (App, error) {
|
|
return App{}, nil
|
|
}
|
|
func (f *fakeRepository) RotateKey(context.Context, uint64, [sha256.Size]byte, string, string) (App, error) {
|
|
return App{}, nil
|
|
}
|
|
func (f *fakeRepository) ReplaceVehicleGrants(context.Context, uint64, []parsedGrant, string) ([]VehicleGrant, error) {
|
|
return nil, nil
|
|
}
|
|
func (f *fakeRepository) ListVehicleGrants(context.Context, uint64) ([]VehicleGrant, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T) {
|
|
repository := &fakeRepository{
|
|
app: AppCredential{ID: 7, Name: "partner"},
|
|
vehicles: map[string]AuthorizedVehicle{
|
|
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
|
|
"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"},
|
|
},
|
|
hydrogen: map[string]DailyHydrogen{
|
|
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 12.3154, SampleCount: 1, QualityStatus: "OK"},
|
|
},
|
|
mileage: map[string]DailyMileage{
|
|
"LTEST32960VIN0001": {
|
|
VIN: "LTEST32960VIN0001", Protocol: "GB32960", MileageKm: 101.235, TotalMileageKm: 12345.679,
|
|
DataTime: "2026-07-01T23:59:00+08:00", UpdatedAt: "2026-07-02T00:01:00+08:00",
|
|
},
|
|
},
|
|
}
|
|
service := NewService(repository)
|
|
service.now = func() time.Time { return time.Date(2026, 7, 1, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)) }
|
|
request := QueryRequest{PlateNumbers: []string{"粤A12345", "粤B67890"}, Date: "2026-07-01"}
|
|
const key = "0123456789abcdef0123456789abcdef"
|
|
|
|
hydrogen, err := service.QueryHydrogen(context.Background(), key, "trace-h", request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(hydrogen) != 2 || hydrogen[0].HydrogenConsumptionKg == nil || *hydrogen[0].HydrogenConsumptionKg != 12.315 || hydrogen[1].Status != StatusNoData || hydrogen[1].HydrogenConsumptionKg != nil {
|
|
t.Fatalf("hydrogen = %#v", hydrogen)
|
|
}
|
|
mileage, err := service.QueryMileage(context.Background(), key, "trace-m", request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(mileage) != 2 || mileage[0].VIN != "LTEST32960VIN0001" || mileage[0].DailyMileageKm == nil || *mileage[0].DailyMileageKm != 101.235 || mileage[0].TotalMileageKm == nil || *mileage[0].TotalMileageKm != 12345.679 || mileage[0].DataTime == nil || mileage[0].UpdatedAt == nil || mileage[1].Status != StatusNoData || mileage[1].TotalMileageKm != nil || mileage[1].DataTime != nil {
|
|
t.Fatalf("mileage = %#v", mileage)
|
|
}
|
|
}
|
|
|
|
func TestDailyQueriesWithoutPlatesReturnAllAuthorizedVehicles(t *testing.T) {
|
|
repository := &fakeRepository{
|
|
app: AppCredential{ID: 7, Name: "partner"},
|
|
vehicles: map[string]AuthorizedVehicle{
|
|
"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"},
|
|
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
|
|
},
|
|
hydrogen: map[string]DailyHydrogen{
|
|
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 8.5, QualityStatus: "OK"},
|
|
},
|
|
mileage: map[string]DailyMileage{
|
|
"LTEST32960VIN0002": {
|
|
VIN: "LTEST32960VIN0002", Protocol: "YUTONG_MQTT", MileageKm: 88.8, TotalMileageKm: 10088.8,
|
|
DataTime: "2026-07-01T20:00:00+08:00", UpdatedAt: "2026-07-01T20:00:01+08:00",
|
|
},
|
|
},
|
|
}
|
|
service := NewService(repository)
|
|
request := QueryRequest{Date: "2026-07-01"}
|
|
const key = "0123456789abcdef0123456789abcdef"
|
|
|
|
hydrogen, err := service.QueryHydrogen(context.Background(), key, "trace-h-all", request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(repository.requestedPlates) != 0 || len(hydrogen) != 2 || hydrogen[0].PlateNumber != "粤A12345" || hydrogen[1].PlateNumber != "粤B67890" {
|
|
t.Fatalf("requested=%v hydrogen=%#v", repository.requestedPlates, hydrogen)
|
|
}
|
|
if len(repository.dailyVINs) != 2 || repository.dailyVINs[0] != "LTEST32960VIN0001" || repository.dailyVINs[1] != "LTEST32960VIN0002" {
|
|
t.Fatalf("daily VINs=%v", repository.dailyVINs)
|
|
}
|
|
|
|
mileage, err := service.QueryMileage(context.Background(), key, "trace-m-all", QueryRequest{PlateNumbers: []string{}, Date: "2026-07-01"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(mileage) != 2 || mileage[0].PlateNumber != "粤A12345" || mileage[1].PlateNumber != "粤B67890" || mileage[1].DailyMileageKm == nil {
|
|
t.Fatalf("mileage=%#v", mileage)
|
|
}
|
|
}
|
|
|
|
func TestMileageProtocolPriorityValidationAndExternalSourceName(t *testing.T) {
|
|
repository := &fakeRepository{
|
|
app: AppCredential{ID: 7},
|
|
vehicles: map[string]AuthorizedVehicle{
|
|
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
|
|
},
|
|
mileage: map[string]DailyMileage{
|
|
"LTEST32960VIN0001": {
|
|
VIN: "LTEST32960VIN0001", Protocol: "YUTONG_MQTT", MileageKm: 0, TotalMileageKm: 12345,
|
|
DataTime: "2026-07-01T20:00:00+08:00", UpdatedAt: "2026-07-01T20:00:01+08:00",
|
|
},
|
|
},
|
|
}
|
|
service := NewService(repository)
|
|
priority := ProtocolPriority{Values: []string{"JT808", "GB32960", "MQTT"}}
|
|
result, err := service.QueryMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-priority", QueryRequest{
|
|
PlateNumbers: []string{"粤A12345"}, Date: "2026-07-01", ProtocolPriority: priority,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Join(repository.dailyProtocols, ",") != "JT808,GB32960,YUTONG_MQTT" {
|
|
t.Fatalf("internal protocols=%v", repository.dailyProtocols)
|
|
}
|
|
if len(result) != 1 || result[0].DailyMileageKm == nil || *result[0].DailyMileageKm != 0 || result[0].SourceProtocol == nil || *result[0].SourceProtocol != "MQTT" {
|
|
t.Fatalf("result=%#v", result)
|
|
}
|
|
|
|
for _, values := range [][]string{{}, {"YUTONG_MQTT"}, {"GB32960", "GB32960"}, {"mqtt"}} {
|
|
invalid := ProtocolPriority{Values: values, Present: true}
|
|
_, err := service.QueryMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-invalid", QueryRequest{
|
|
Date: "2026-07-01", ProtocolPriority: invalid,
|
|
})
|
|
if !errors.Is(err, ErrInvalidRequest) {
|
|
t.Fatalf("priority=%v error=%v", values, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMileageCarriesForwardPreviousTotalAndPreviousCalculationTime(t *testing.T) {
|
|
previous := DailyMileage{
|
|
VIN: "LTEST32960VIN0001", Date: "2026-07-20", Protocol: "YUTONG_MQTT",
|
|
MileageKm: 35.5, TotalMileageKm: 8888.8,
|
|
DataTime: "2026-07-20T23:58:00+08:00", UpdatedAt: "2026-07-21T01:15:00+08:00",
|
|
}
|
|
repository := &fakeRepository{
|
|
app: AppCredential{ID: 7},
|
|
vehicles: map[string]AuthorizedVehicle{
|
|
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
|
|
},
|
|
mileage: map[string]DailyMileage{},
|
|
priorMileage: map[string]DailyMileage{"LTEST32960VIN0001": previous},
|
|
}
|
|
service := NewService(repository)
|
|
result, err := service.QueryMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-carry", QueryRequest{
|
|
PlateNumbers: []string{"粤A12345"}, Date: "2026-07-21",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
item := result[0]
|
|
if item.Status != StatusNormal || item.DailyMileageKm == nil || *item.DailyMileageKm != 0 ||
|
|
item.TotalMileageKm == nil || *item.TotalMileageKm != 8888.8 ||
|
|
item.SourceProtocol == nil || *item.SourceProtocol != "MQTT" ||
|
|
item.DataTime == nil || *item.DataTime != previous.DataTime ||
|
|
item.UpdatedAt == nil || *item.UpdatedAt != previous.UpdatedAt {
|
|
t.Fatalf("carried result=%#v", item)
|
|
}
|
|
|
|
rangeResult, err := service.QueryMileageRange(context.Background(), "0123456789abcdef0123456789abcdef", "trace-carry-range", MileageRangeRequest{
|
|
StartDate: "2026-07-21", EndDate: "2026-07-22", PageSize: 10,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rangeResult.Data) != 2 {
|
|
t.Fatalf("range=%#v", rangeResult)
|
|
}
|
|
for _, row := range rangeResult.Data {
|
|
if row.DailyMileageKm == nil || *row.DailyMileageKm != 0 ||
|
|
row.TotalMileageKm == nil || *row.TotalMileageKm != previous.TotalMileageKm ||
|
|
row.UpdatedAt == nil || *row.UpdatedAt != previous.UpdatedAt {
|
|
t.Fatalf("carried range row=%#v", row)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMileageRangeUsesStableSnapshotAndDistinguishesZeroFromNoData(t *testing.T) {
|
|
repository := &fakeRepository{
|
|
app: AppCredential{ID: 7, Name: "partner"},
|
|
vehicles: map[string]AuthorizedVehicle{
|
|
"沪A00002": {VIN: "LTEST32960VIN0002", Plate: "沪A00002"},
|
|
"沪A00001": {VIN: "LTEST32960VIN0001", Plate: "沪A00001"},
|
|
},
|
|
mileage: map[string]DailyMileage{
|
|
dailyMileageKey("LTEST32960VIN0001", "2026-07-01"): {
|
|
VIN: "LTEST32960VIN0001", Date: "2026-07-01", Protocol: "GB32960", MileageKm: 0, TotalMileageKm: 12000,
|
|
DataTime: "2026-07-01T23:58:45+08:00", UpdatedAt: "2026-07-02T05:10:00+08:00",
|
|
},
|
|
dailyMileageKey("LTEST32960VIN0001", "2026-07-02"): {
|
|
VIN: "LTEST32960VIN0001", Date: "2026-07-02", Protocol: "GB32960", MileageKm: 10.25, TotalMileageKm: 12010.25,
|
|
DataTime: "2026-07-02T20:00:00+08:00", UpdatedAt: "2026-07-02T20:00:01+08:00",
|
|
},
|
|
dailyMileageKey("LTEST32960VIN0002", "2026-07-02"): {
|
|
VIN: "LTEST32960VIN0002", Date: "2026-07-02", Protocol: "JT808", MileageKm: 8.5, TotalMileageKm: 9008.5,
|
|
DataTime: "2026-07-02T21:00:00+08:00", UpdatedAt: "2026-07-02T21:00:01+08:00",
|
|
},
|
|
},
|
|
}
|
|
service := NewService(repository)
|
|
service.now = func() time.Time { return time.Date(2026, 7, 23, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)) }
|
|
request := MileageRangeRequest{StartDate: "2026-07-01", EndDate: "2026-07-02", PageSize: 3}
|
|
const key = "0123456789abcdef0123456789abcdef"
|
|
|
|
first, err := service.QueryMileageRange(context.Background(), key, "trace-range-1", request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(first.Data) != 3 || first.SnapshotID == "" || first.NextCursor == nil {
|
|
t.Fatalf("first=%#v", first)
|
|
}
|
|
if first.Data[0].PlateNumber != "沪A00001" || first.Data[0].DailyMileageKm == nil || *first.Data[0].DailyMileageKm != 0 || first.Data[0].Status != StatusNormal {
|
|
t.Fatalf("zero mileage row=%#v", first.Data[0])
|
|
}
|
|
if first.Data[1].PlateNumber != "沪A00002" || first.Data[1].Status != StatusNoData || first.Data[1].DailyMileageKm != nil || first.Data[1].DataTime != nil {
|
|
t.Fatalf("no-data row=%#v", first.Data[1])
|
|
}
|
|
if first.Data[2].Date != "2026-07-02" || first.Data[2].PlateNumber != "沪A00001" {
|
|
t.Fatalf("third row=%#v", first.Data[2])
|
|
}
|
|
|
|
repository.vehicles = map[string]AuthorizedVehicle{}
|
|
request.Cursor = *first.NextCursor
|
|
changedPriority := ProtocolPriority{Values: []string{"JT808"}}
|
|
changedRequest := request
|
|
changedRequest.ProtocolPriority = changedPriority
|
|
if _, err := service.QueryMileageRange(context.Background(), key, "trace-range-changed", changedRequest); !errors.Is(err, ErrInvalidRequest) {
|
|
t.Fatalf("changed protocol priority must invalidate cursor: %v", err)
|
|
}
|
|
second, err := service.QueryMileageRange(context.Background(), key, "trace-range-2", request)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if second.SnapshotID != first.SnapshotID || second.NextCursor != nil || len(second.Data) != 1 || second.Data[0].PlateNumber != "沪A00002" || second.Data[0].Date != "2026-07-02" {
|
|
t.Fatalf("second=%#v", second)
|
|
}
|
|
}
|
|
|
|
func TestMileageRangeOnlyLoadsPriorMileageForVINsMissingAtTheirFirstPageDate(t *testing.T) {
|
|
const (
|
|
firstVIN = "LTEST32960VIN0001"
|
|
secondVIN = "LTEST32960VIN0002"
|
|
)
|
|
repository := &fakeRepository{
|
|
app: AppCredential{ID: 7},
|
|
vehicles: map[string]AuthorizedVehicle{
|
|
"沪A00001": {VIN: firstVIN, Plate: "沪A00001"},
|
|
"沪A00002": {VIN: secondVIN, Plate: "沪A00002"},
|
|
},
|
|
mileage: map[string]DailyMileage{
|
|
dailyMileageKey(firstVIN, "2026-07-27"): {
|
|
VIN: firstVIN, Date: "2026-07-27", Protocol: "GB32960",
|
|
MileageKm: 12.5, TotalMileageKm: 12012.5,
|
|
DataTime: "2026-07-27T12:00:00+08:00", UpdatedAt: "2026-07-27T12:00:01+08:00",
|
|
},
|
|
},
|
|
priorMileage: map[string]DailyMileage{
|
|
secondVIN: {
|
|
VIN: secondVIN, Date: "2026-07-26", Protocol: "JT808",
|
|
MileageKm: 8, TotalMileageKm: 9008,
|
|
DataTime: "2026-07-26T23:00:00+08:00", UpdatedAt: "2026-07-27T01:00:00+08:00",
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := NewService(repository).QueryMileageRange(
|
|
context.Background(),
|
|
"0123456789abcdef0123456789abcdef",
|
|
"trace-range-missing-only",
|
|
MileageRangeRequest{StartDate: "2026-07-27", EndDate: "2026-07-27", PageSize: 10},
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if repository.priorCalls != 1 || len(repository.priorVINs) != 1 || repository.priorVINs[0] != secondVIN {
|
|
t.Fatalf("prior calls=%d vins=%v, want only %s", repository.priorCalls, repository.priorVINs, secondVIN)
|
|
}
|
|
if len(result.Data) != 2 || result.Data[0].DailyMileageKm == nil || *result.Data[0].DailyMileageKm != 12.5 ||
|
|
result.Data[1].DailyMileageKm == nil || *result.Data[1].DailyMileageKm != 0 {
|
|
t.Fatalf("result=%#v", result)
|
|
}
|
|
}
|
|
|
|
func TestMileageRangeSkipsPriorMileageLookupWhenEveryVINHasInitialData(t *testing.T) {
|
|
const vin = "LTEST32960VIN0001"
|
|
repository := &fakeRepository{
|
|
app: AppCredential{ID: 7},
|
|
vehicles: map[string]AuthorizedVehicle{
|
|
"沪A00001": {VIN: vin, Plate: "沪A00001"},
|
|
},
|
|
mileage: map[string]DailyMileage{
|
|
dailyMileageKey(vin, "2026-07-27"): {
|
|
VIN: vin, Date: "2026-07-27", Protocol: "GB32960",
|
|
MileageKm: 12.5, TotalMileageKm: 12012.5,
|
|
DataTime: "2026-07-27T12:00:00+08:00", UpdatedAt: "2026-07-27T12:00:01+08:00",
|
|
},
|
|
},
|
|
}
|
|
|
|
if _, err := NewService(repository).QueryMileageRange(
|
|
context.Background(),
|
|
"0123456789abcdef0123456789abcdef",
|
|
"trace-range-no-prior",
|
|
MileageRangeRequest{StartDate: "2026-07-27", EndDate: "2026-07-27", PageSize: 10},
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if repository.priorCalls != 0 || len(repository.priorVINs) != 0 {
|
|
t.Fatalf("prior calls=%d vins=%v, want no lookup", repository.priorCalls, repository.priorVINs)
|
|
}
|
|
}
|
|
|
|
func TestMileageRangeValidationLimitsWindowAndPageSize(t *testing.T) {
|
|
service := NewService(&fakeRepository{})
|
|
for _, request := range []MileageRangeRequest{
|
|
{StartDate: "2025-07-01", EndDate: "2026-07-02", PageSize: 100},
|
|
{StartDate: "2026-07-02", EndDate: "2026-07-01", PageSize: 100},
|
|
{StartDate: "2026-07-01", EndDate: "2026-07-02", PageSize: 5001},
|
|
} {
|
|
if _, _, _, _, _, _, _, err := service.validateMileageRange(request); !errors.Is(err, ErrInvalidRequest) {
|
|
t.Fatalf("request=%#v error=%v", request, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTotalMileageReturnsRecordTimeDifferenceAndProtocolMeaning(t *testing.T) {
|
|
location := time.FixedZone("CST", 8*3600)
|
|
repository := &fakeRepository{
|
|
app: AppCredential{ID: 7}, authorizedVIN: true,
|
|
totalMileage: &TotalMileagePoint{VIN: "LA9GG68L2PBAF4790", Protocol: "GB32960", ObservedAt: time.Date(2026, 7, 21, 9, 29, 45, 0, location), TotalMileageKm: 12345.6784},
|
|
}
|
|
service := NewService(repository)
|
|
service.now = func() time.Time { return time.Date(2026, 7, 21, 12, 0, 0, 0, location) }
|
|
result, err := service.QueryTotalMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-total", TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026-07-21 09:30:00"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.TotalMileageKm == nil || *result.TotalMileageKm != 12345.678 || result.Protocol != "GB32960" || result.RecordTime != "2026-07-21 09:29:45" || result.TimeDifferenceSeconds == nil || *result.TimeDifferenceSeconds != 15 || result.Status != StatusNormal || !strings.Contains(result.MileageMeaning, "仪表盘") {
|
|
t.Fatalf("result = %#v", result)
|
|
}
|
|
}
|
|
|
|
func TestTotalMileageCanonicalProtocolsAndStrictTime(t *testing.T) {
|
|
service := NewService(&fakeRepository{})
|
|
for _, input := range []string{"GB32960", "YUTONG_MQTT", "JT808"} {
|
|
_, _, protocols, _, err := service.validateTotalMileageQuery(TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026-07-21 09:30:00", Protocol: input})
|
|
if err != nil || len(protocols) != 1 || protocols[0] != input {
|
|
t.Fatalf("%s: protocols=%v err=%v", input, protocols, err)
|
|
}
|
|
}
|
|
for _, alias := range []string{"32960", "mqtt", "808"} {
|
|
if _, _, _, _, err := service.validateTotalMileageQuery(TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026-07-21 09:30:00", Protocol: alias}); !errors.Is(err, ErrInvalidRequest) {
|
|
t.Fatalf("alias %s should be rejected: %v", alias, err)
|
|
}
|
|
}
|
|
if _, _, _, _, err := service.validateTotalMileageQuery(TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026/07/21 09:30"}); !errors.Is(err, ErrInvalidRequest) {
|
|
t.Fatalf("error=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestQueryFailsClosedWhenAnyVehicleIsNotGranted(t *testing.T) {
|
|
repository := &fakeRepository{
|
|
app: AppCredential{ID: 7},
|
|
vehicles: map[string]AuthorizedVehicle{"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"}},
|
|
}
|
|
_, err := NewService(repository).QueryHydrogen(context.Background(), "0123456789abcdef0123456789abcdef", "trace", QueryRequest{
|
|
PlateNumbers: []string{"粤A12345", "粤B67890"}, Date: "2026-07-01",
|
|
})
|
|
if !errors.Is(err, ErrForbidden) {
|
|
t.Fatalf("error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateAppReturns32CharacterKeyOnlyOnce(t *testing.T) {
|
|
repository := &fakeRepository{}
|
|
service := NewService(repository)
|
|
created, err := service.CreateApp(context.Background(), AppInput{
|
|
Name: "partner", ValidFrom: "2026-07-01T00:00:00+08:00", ValidTo: "2027-07-01T00:00:00+08:00",
|
|
}, "admin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !appKeyPattern.MatchString(created.AppKey) || len(created.AppKey) != 32 || created.AppKeyPrefix != created.AppKey[:8] {
|
|
t.Fatalf("created = %#v", created)
|
|
}
|
|
if created.AppKey[12] != '4' || !strings.ContainsRune("89ab", rune(created.AppKey[16])) {
|
|
t.Fatalf("appKey is not UUID v4: %s", created.AppKey)
|
|
}
|
|
if repository.createdHash != sha256.Sum256([]byte(created.AppKey)) || repository.createdPrefix != created.AppKey[:8] {
|
|
t.Fatal("repository did not receive the appKey hash and prefix")
|
|
}
|
|
}
|
|
|
|
func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) {
|
|
repository := &fakeRepository{app: AppCredential{ID: 1}, vehicles: map[string]AuthorizedVehicle{
|
|
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
|
|
}, hydrogen: map[string]DailyHydrogen{}}
|
|
handler := NewHandler(NewService(repository))
|
|
request := httptest.NewRequest(http.MethodPost, HydrogenQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345"],"date":"2026-07-01"}`))
|
|
request.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
|
response := httptest.NewRecorder()
|
|
handler.ServeHTTP(response, request)
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
|
}
|
|
var body ExternalResponse
|
|
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Code != "SUCCESS" || body.Message != "success" || len(body.TraceID) != 32 {
|
|
t.Fatalf("body = %#v", body)
|
|
}
|
|
|
|
allRequest := httptest.NewRequest(http.MethodPost, MileageQueryPath, strings.NewReader(`{"date":"2026-07-01"}`))
|
|
allRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
|
allResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(allResponse, allRequest)
|
|
if allResponse.Code != http.StatusOK || !strings.Contains(allResponse.Body.String(), `"plateNumber":"粤A12345"`) {
|
|
t.Fatalf("status=%d body=%s", allResponse.Code, allResponse.Body.String())
|
|
}
|
|
|
|
rangeRequest := httptest.NewRequest(http.MethodPost, MileageRangeQueryPath, strings.NewReader(`{"startDate":"2026-07-01","endDate":"2026-07-01","pageSize":10}`))
|
|
rangeRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
|
rangeResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(rangeResponse, rangeRequest)
|
|
if rangeResponse.Code != http.StatusOK || !strings.Contains(rangeResponse.Body.String(), `"snapshotId"`) || !strings.Contains(rangeResponse.Body.String(), `"nextCursor":null`) {
|
|
t.Fatalf("status=%d body=%s", rangeResponse.Code, rangeResponse.Body.String())
|
|
}
|
|
|
|
badDate := httptest.NewRequest(http.MethodPost, HydrogenQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345"],"date":"2026/07/01"}`))
|
|
badDate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
|
badResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(badResponse, badDate)
|
|
if badResponse.Code != http.StatusBadRequest || !strings.Contains(badResponse.Body.String(), `"code":"INVALID_DATE_FORMAT"`) {
|
|
t.Fatalf("status=%d body=%s", badResponse.Code, badResponse.Body.String())
|
|
}
|
|
|
|
badProtocol := httptest.NewRequest(http.MethodPost, MileageQueryPath, strings.NewReader(`{"date":"2026-07-01","protocolPriority":["GB32960","GB32960"]}`))
|
|
badProtocol.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
|
badProtocolResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(badProtocolResponse, badProtocol)
|
|
if badProtocolResponse.Code != http.StatusBadRequest ||
|
|
!strings.Contains(badProtocolResponse.Body.String(), `"message":"protocolPriority不能包含重复协议"`) ||
|
|
!strings.Contains(badProtocolResponse.Body.String(), `"traceId":"`) {
|
|
t.Fatalf("status=%d body=%s", badProtocolResponse.Code, badProtocolResponse.Body.String())
|
|
}
|
|
|
|
nullProtocol := httptest.NewRequest(http.MethodPost, MileageQueryPath, strings.NewReader(`{"date":"2026-07-01","protocolPriority":null}`))
|
|
nullProtocol.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
|
nullProtocolResponse := httptest.NewRecorder()
|
|
handler.ServeHTTP(nullProtocolResponse, nullProtocol)
|
|
if nullProtocolResponse.Code != http.StatusBadRequest || !strings.Contains(nullProtocolResponse.Body.String(), `"message":"protocolPriority不能为空"`) {
|
|
t.Fatalf("status=%d body=%s", nullProtocolResponse.Code, nullProtocolResponse.Body.String())
|
|
}
|
|
}
|