314 lines
14 KiB
Go
314 lines
14 KiB
Go
package openplatform
|
|
|
|
import (
|
|
"context"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/DATA-DOG/go-sqlmock"
|
|
)
|
|
|
|
func TestInQueryPreservesMySQLDateFormatPercentTokens(t *testing.T) {
|
|
query, args := inQuery("SELECT DATE_FORMAT(stat_date,'%Y-%m-%d') FROM metrics WHERE stat_date=? AND vin IN (%s)", "2026-07-21", []string{"VIN1", "VIN2"})
|
|
if strings.Contains(query, "MISSING") || !strings.Contains(query, "DATE_FORMAT(stat_date,'%Y-%m-%d')") {
|
|
t.Fatalf("date format was corrupted: %s", query)
|
|
}
|
|
if !strings.Contains(query, "vin IN (?,?)") {
|
|
t.Fatalf("VIN placeholders missing: %s", query)
|
|
}
|
|
if len(args) != 3 || args[0] != "2026-07-21" || args[1] != "VIN1" || args[2] != "VIN2" {
|
|
t.Fatalf("unexpected args: %#v", args)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizedVehiclesUsesBinaryVINJoin(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
|
|
start := time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local)
|
|
end := start.Add(24*time.Hour - time.Nanosecond)
|
|
mock.ExpectQuery(regexp.QuoteMeta("JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin")).
|
|
WithArgs(uint64(1), start, end, "辽A00001").
|
|
WillReturnRows(sqlmock.NewRows([]string{"plate", "vin"}).AddRow("辽A00001", "LTEST000000000001"))
|
|
|
|
repository := NewMySQLRepository(db)
|
|
vehicles, err := repository.AuthorizedVehicles(context.Background(), 1, []string{"辽A00001"}, start, end)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if vehicles["辽A00001"].VIN != "LTEST000000000001" {
|
|
t.Fatalf("unexpected vehicles: %#v", vehicles)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizedVehiclesWithoutPlateFilterReturnsAllGrantedVehicles(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
start := time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local)
|
|
end := start.Add(24 * time.Hour)
|
|
mock.ExpectQuery("JOIN vehicle_identity_binding.*TRIM.*ORDER BY UPPER").
|
|
WithArgs(uint64(7), start, end).
|
|
WillReturnRows(sqlmock.NewRows([]string{"plate", "vin"}).
|
|
AddRow("粤A12345", "LTEST32960VIN0001").
|
|
AddRow("粤B67890", "LTEST32960VIN0002"))
|
|
|
|
vehicles, err := NewMySQLRepository(db).AuthorizedVehicles(context.Background(), 7, nil, start, end)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(vehicles) != 2 || vehicles["粤A12345"].VIN != "LTEST32960VIN0001" || vehicles["粤B67890"].VIN != "LTEST32960VIN0002" {
|
|
t.Fatalf("vehicles=%#v", vehicles)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestHydrogenStationsUsesNewMasterAndExcelSinopecRoster(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
|
|
mock.ExpectQuery("cooperation_start_date.*FROM ln_asset_management\\.new_hydrogen_site n.*common_district province_region.*common_district city_region.*ORDER BY province_region\\.NAME,city_region\\.NAME,n\\.site_name,n\\.id").
|
|
WithArgs("湖北省", "湖北省", "武汉市", "武汉市").
|
|
WillReturnRows(sqlmock.NewRows([]string{
|
|
"id", "name", "short_name", "address", "longitude", "latitude", "province", "city", "district", "cooperative", "contact_person", "contact_phone", "unit_price", "monthly_hydrogen_kg", "total_hydrogen_kg",
|
|
}).
|
|
AddRow("1", "武汉群力加油站", "中国石化群力加油站", "地址一", 114.1, 30.2, "湖北省", "武汉市", "", false, "张工", "13800000001", 12.5, 12.5, 100.5).
|
|
AddRow("2", "已有合作日期站", "", "地址二", 120.1, 31.2, "上海市", "上海市", "", true, "李工", "13800000002", 13.0, 8.5, 80.5).
|
|
AddRow("3", "普通外部站", "", "地址三", 121.1, 32.2, "江苏省", "南京市", "", false, "", "", 0, 0, 0))
|
|
|
|
cooperateOnly := true
|
|
stations, err := NewMySQLRepository(db).HydrogenStations(context.Background(), HydrogenStationRequest{Province: "湖北省", City: "武汉市", CooperateOnly: &cooperateOnly})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(stations) != 2 {
|
|
t.Fatalf("expected roster and canonical cooperative stations, got %#v", stations)
|
|
}
|
|
if stations[0].Name != "武汉群力加油站" || !stations[0].Cooperative {
|
|
t.Fatalf("Excel SINOPEC station was not classified as cooperative: %#v", stations[0])
|
|
}
|
|
if stations[1].Name != "已有合作日期站" || !stations[1].Cooperative {
|
|
t.Fatalf("canonical cooperative station was lost: %#v", stations[1])
|
|
}
|
|
if stations[0].ContactPerson != "张工" || stations[0].ContactPhone != "13800000001" || stations[0].UnitPrice != 12.5 {
|
|
t.Fatalf("partner station contact and price were not mapped: %#v", stations[0])
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestExcelSinopecRosterMatchesEitherExcelNameColumn(t *testing.T) {
|
|
for _, name := range []string{"中国石化青岛炼油化工有限责任公司", "青岛中石化加氢站", "南京溧水柘塘东站", "南京中石化溧水加氢站", "中国石化樟坑加油加氢站"} {
|
|
if !isExcelSinopecStation(name) {
|
|
t.Fatalf("expected roster match for %q", name)
|
|
}
|
|
}
|
|
if isExcelSinopecStation("普通外部加氢站") {
|
|
t.Fatal("non-roster station must not be classified as cooperative")
|
|
}
|
|
}
|
|
|
|
func TestDailyMileageReturnsDailyAndAuthoritativeSameProtocolEndTotal(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
mock.ExpectQuery("COALESCE\\(m.day_end_total_mileage_km,m.latest_total_mileage_km\\).*FROM vehicle_daily_mileage m\\s+WHERE m.stat_date BETWEEN \\? AND \\?.*COALESCE\\(m.day_end_total_mileage_km,m.latest_total_mileage_km\\)>=0.*m.daily_mileage_km>=0").
|
|
WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "LTEST32960VIN0002").
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}).
|
|
AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 101.235, 12345.679, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00"))
|
|
|
|
values, err := NewMySQLRepository(db).DailyMileage(context.Background(), []string{"LTEST32960VIN0001", "LTEST32960VIN0002"}, "2026-07-21", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first := values["LTEST32960VIN0001"]
|
|
if first.MileageKm != 101.235 || first.TotalMileageKm != 12345.679 || first.DataTime != "2026-07-21T23:58:45+08:00" || first.UpdatedAt == "" {
|
|
t.Fatalf("first=%#v", first)
|
|
}
|
|
if _, ok := values["LTEST32960VIN0002"]; ok {
|
|
t.Fatalf("vehicle without a cumulative total must not be returned: %#v", values)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestDailyMileageExplicitPriorityFiltersDisabledProtocolsAndKeepsZero(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
mock.ExpectQuery("m.protocol IN \\(\\?,\\?\\).*ORDER BY m.stat_date,m.vin,CASE m.protocol WHEN \\? THEN 1 WHEN \\? THEN 2").
|
|
WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "JT808", "GB32960", "JT808", "GB32960").
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}).
|
|
AddRow("LTEST32960VIN0001", "2026-07-21", "JT808", 0.0, 12000.0, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00").
|
|
AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 12.0, 12012.0, "2026-07-21T23:59:00+08:00", "2026-07-22T05:10:00+08:00"))
|
|
|
|
values, err := NewMySQLRepository(db).DailyMileage(context.Background(), []string{"LTEST32960VIN0001"}, "2026-07-21", []string{"JT808", "GB32960"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
selected := values["LTEST32960VIN0001"]
|
|
if selected.Protocol != "JT808" || selected.MileageKm != 0 {
|
|
t.Fatalf("selected=%#v", selected)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestLatestMileageBeforeUsesRequestedProtocolPriority(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
mock.ExpectQuery("SELECT MAX\\(selected.latest_event_time\\).*SELECT prior.vin,prior.protocol,MAX\\(prior.stat_date\\).*FROM vehicle_daily_mileage prior\\s+WHERE prior.stat_date<\\?.*prior.protocol IN \\(\\?,\\?\\).*ORDER BY m.vin,CASE m.protocol").
|
|
WithArgs("2026-07-22", "LTEST32960VIN0001", "JT808", "YUTONG_MQTT", "JT808", "YUTONG_MQTT").
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}).
|
|
AddRow("LTEST32960VIN0001", "2026-07-20", "JT808", 18.5, 9008.5, "2026-07-20T22:00:00+08:00", "2026-07-21T01:00:00+08:00").
|
|
AddRow("LTEST32960VIN0001", "2026-07-21", "YUTONG_MQTT", 20.0, 12020.0, "2026-07-21T23:00:00+08:00", "2026-07-22T01:00:00+08:00"))
|
|
|
|
values, err := NewMySQLRepository(db).LatestMileageBefore(context.Background(), []string{"LTEST32960VIN0001"}, "2026-07-22", []string{"JT808", "YUTONG_MQTT"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
selected := values["LTEST32960VIN0001"]
|
|
if selected.Protocol != "JT808" ||
|
|
selected.MileageKm != 18.5 ||
|
|
selected.TotalMileageKm != 9008.5 ||
|
|
selected.DataTime != "2026-07-20T22:00:00+08:00" ||
|
|
selected.UpdatedAt != "2026-07-21T01:00:00+08:00" {
|
|
t.Fatalf("selected=%#v", selected)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestTotalMileageUsesProtocolPriorityAndLatestRecordAtOrBeforeTime(t *testing.T) {
|
|
mysqlDB, _, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer mysqlDB.Close()
|
|
tdDB, tdMock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer tdDB.Close()
|
|
at := time.Date(2026, 7, 21, 9, 30, 0, 0, time.FixedZone("CST", 8*3600))
|
|
tdMock.ExpectQuery("protocol='GB32960'.*ts<='2026-07-21T09:30:00\\+08:00'.*ORDER BY ts DESC LIMIT 1").
|
|
WillReturnRows(sqlmock.NewRows([]string{"ts", "total_mileage_km", "protocol"}))
|
|
tdMock.ExpectQuery("protocol='YUTONG_MQTT'.*ts<='2026-07-21T09:30:00\\+08:00'.*ORDER BY ts DESC LIMIT 1").
|
|
WillReturnRows(sqlmock.NewRows([]string{"ts", "total_mileage_km", "protocol"}).AddRow(at.Add(-15*time.Second).UnixMilli(), 12345.678, "YUTONG_MQTT"))
|
|
repository := NewMySQLRepository(mysqlDB).WithTDengine(tdDB, "lingniu_vehicle_ts")
|
|
point, err := repository.TotalMileage(context.Background(), "LA9GG68L2PBAF4790", at, []string{"GB32960", "YUTONG_MQTT", "JT808"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if point == nil || point.Protocol != "YUTONG_MQTT" || point.TotalMileageKm != 12345.678 || !point.ObservedAt.Equal(at.Add(-15*time.Second)) {
|
|
t.Fatalf("point=%#v", point)
|
|
}
|
|
if err := tdMock.ExpectationsWereMet(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestStationaryLocationPointsUsesTimeVINAndSmallBoundingBox(t *testing.T) {
|
|
mysqlDB, _, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer mysqlDB.Close()
|
|
tdDB, tdMock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer tdDB.Close()
|
|
start := time.Date(2026, 8, 6, 10, 0, 0, 0, time.FixedZone("CST", 8*3600))
|
|
tdMock.ExpectQuery("FROM lingniu_vehicle_ts.vehicle_locations.*ts>='2026-08-06T10:00:00\\+08:00'.*vin IN \\('LTEST32960VIN0001'\\).*speed_kmh BETWEEN 0 AND 3\\.000.*ORDER BY vin,ts ASC,protocol ASC").
|
|
WillReturnRows(sqlmock.NewRows([]string{"ts", "vin", "protocol", "longitude", "latitude", "speed_kmh"}).
|
|
AddRow(start.UnixMilli(), "LTEST32960VIN0001", "GB32960", 120.000001, 30.000001, 0.2))
|
|
points, err := NewMySQLRepository(mysqlDB).WithTDengine(tdDB, "lingniu_vehicle_ts").StationaryLocationPoints(context.Background(), []string{"LTEST32960VIN0001"}, start, start.Add(time.Hour), 120, 30, 5, 3)
|
|
if err != nil || len(points) != 1 || points[0].SpeedKmh != 0.2 || !points[0].ObservedAt.Equal(start) {
|
|
t.Fatalf("points=%#v err=%v", points, err)
|
|
}
|
|
if err := tdMock.ExpectationsWereMet(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestRealtimeVehiclesAnyFreshProtocolKeepsSelectedSourceOnline(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
now := time.Date(2026, 8, 3, 21, 37, 30, 0, time.Local)
|
|
dayStart := time.Date(2026, 8, 3, 0, 0, 0, 0, time.Local)
|
|
vin := "LTEST32960VIN0001"
|
|
mock.ExpectQuery("SELECT l.vin,l.protocol.*FROM vehicle_realtime_location").
|
|
WithArgs(dayStart, vin, now.Add(-10*time.Minute)).
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "longitude", "latitude", "speed_kmh", "soc_percent", "total_mileage_km", "updated_at", "active_today"}).
|
|
AddRow(vin, "GB32960", 120.1, 30.2, 0, 86.5, 1000, now.Add(-2*time.Minute), true).
|
|
AddRow(vin, "JT808", 120.2, 30.3, 10, nil, 0, now.Add(-20*time.Second), true))
|
|
|
|
points, err := NewMySQLRepository(db).RealtimeVehicles(context.Background(), []string{vin}, now)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
point := points[vin]
|
|
if point.Protocol != "GB32960" || point.SOCPercent == nil || *point.SOCPercent != 86.5 || !point.Online || !point.ActiveToday || !point.ObservedAt.Equal(now.Add(-2*time.Minute)) {
|
|
t.Fatalf("selected source and aggregate online state mismatch: %#v", point)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestListVehicleGrantsUsesBinaryVINJoin(t *testing.T) {
|
|
db, mock, err := sqlmock.New()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
|
|
now := time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local)
|
|
mock.ExpectQuery(regexp.QuoteMeta("LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin")).
|
|
WithArgs(uint64(1)).
|
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "plate", "valid_from", "valid_to", "granted_by", "updated_at"}).
|
|
AddRow("LTEST000000000001", "辽A00001", now, now.AddDate(1, 0, 0), "admin", now))
|
|
|
|
repository := NewMySQLRepository(db)
|
|
grants, err := repository.ListVehicleGrants(context.Background(), 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(grants) != 1 || grants[0].Plate != "辽A00001" {
|
|
t.Fatalf("unexpected grants: %#v", grants)
|
|
}
|
|
if err := mock.ExpectationsWereMet(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|