modelRT/handler/data_object_attribute_updat...

475 lines
17 KiB
Go

package handler
import (
"context"
"encoding/json"
"fmt"
"testing"
"modelRT/common/errcode"
"modelRT/constants"
"modelRT/orm"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func TestValidateDataObjectAttributeUpdateParameterGroups(t *testing.T) {
groups := []string{
"rated",
"setup",
"model",
"stable",
"craft",
"integrity",
"behavior",
}
for _, group := range groups {
t.Run(group, func(t *testing.T) {
request := dataObjectAttributeUpdateRequest{
Token: fmt.Sprintf("nspath.component.%s.attribute", group),
Field: "VALUE",
Value: json.RawMessage(`"15.2"`),
}
dataObjectType, field, value, err := validateDataObjectAttributeUpdate(request)
require.NoError(t, err)
assert.Equal(t, constants.DataObjectTypeParameter, dataObjectType)
assert.Equal(t, "value", field)
assert.Equal(t, "15.2", value)
})
}
}
func TestValidateDataObjectAttributeUpdateRejectsUnsupportedParameterGroups(t *testing.T) {
for _, group := range []string{"component", "base_extend"} {
t.Run(group, func(t *testing.T) {
_, _, _, err := validateDataObjectAttributeUpdate(dataObjectAttributeUpdateRequest{
Token: fmt.Sprintf("nspath.component.%s.attribute", group),
Field: "value",
Value: json.RawMessage(`"uuid"`),
})
require.Error(t, err)
assert.Contains(t, err.Error(), "do not support token6="+group)
})
}
}
func TestValidateDataObjectAttributeUpdateMeasurementFields(t *testing.T) {
tests := []struct {
name string
field string
value string
expected any
wantError bool
}{
{name: "numeric value", field: "value", value: `15.2`, expected: float64(15.2)},
{name: "numeric string value", field: "value", value: `"15.2"`, expected: float64(15.2)},
{name: "automatic mode", field: "mode", value: `1`, expected: constants.MeasurementModeAutomatic},
{name: "manual mode", field: "mode", value: `0`, expected: constants.MeasurementModeManual},
{name: "boolean mode is rejected", field: "mode", value: `true`, wantError: true},
{name: "out of range mode", field: "mode", value: `2`, wantError: true},
{name: "unsupported field", field: "name", value: `"measurement"`, wantError: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dataObjectType, field, value, err := validateDataObjectAttributeUpdate(dataObjectAttributeUpdateRequest{
Token: "nspath.component.bay.measurement",
Field: tt.field,
Value: json.RawMessage(tt.value),
})
if tt.wantError {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, constants.DataObjectTypeMeasurement, dataObjectType)
assert.Equal(t, tt.field, field)
assert.Equal(t, tt.expected, value)
})
}
}
func TestValidateDataObjectAttributeUpdateAcceptsToken4Token7Measurement(t *testing.T) {
dataObjectType, field, value, err := validateDataObjectAttributeUpdate(dataObjectAttributeUpdateRequest{
Token: "nspath.measurement",
Value: json.RawMessage(`15.2`),
})
require.NoError(t, err)
assert.Equal(t, constants.DataObjectTypeMeasurement, dataObjectType)
assert.Equal(t, "value", field)
assert.Equal(t, float64(15.2), value)
}
func TestValidateDataObjectAttributeUpdateRequiredFields(t *testing.T) {
tests := []struct {
name string
request dataObjectAttributeUpdateRequest
}{
{name: "missing token", request: dataObjectAttributeUpdateRequest{Field: "value", Value: json.RawMessage(`1`)}},
{name: "missing value", request: dataObjectAttributeUpdateRequest{Token: "nspath.measurement", Field: "value"}},
{name: "null value", request: dataObjectAttributeUpdateRequest{Token: "nspath.measurement", Field: "value", Value: json.RawMessage(`null`)}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, _, err := validateDataObjectAttributeUpdate(tt.request)
require.Error(t, err)
})
}
}
func TestValidateDataObjectAttributeUpdateDefaultsEmptyFieldToValue(t *testing.T) {
tests := []struct {
name string
request dataObjectAttributeUpdateRequest
wantType constants.DataObjectType
wantValue any
}{
{
name: "parameter omitted field",
request: dataObjectAttributeUpdateRequest{
Token: "nspath.component.rated.attribute",
Value: json.RawMessage(`"15.2"`),
},
wantType: constants.DataObjectTypeParameter,
wantValue: "15.2",
},
{
name: "measurement whitespace field",
request: dataObjectAttributeUpdateRequest{
Token: "nspath.component.bay.measurement",
Field: " ",
Value: json.RawMessage(`15.2`),
},
wantType: constants.DataObjectTypeMeasurement,
wantValue: float64(15.2),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dataObjectType, field, value, err := validateDataObjectAttributeUpdate(tt.request)
require.NoError(t, err)
assert.Equal(t, tt.wantType, dataObjectType)
assert.Equal(t, "value", field)
assert.Equal(t, tt.wantValue, value)
})
}
}
func TestMeasurementModeIsAutomatic(t *testing.T) {
automatic, err := measurementModeIsAutomatic(constants.MeasurementModeAutomatic)
require.NoError(t, err)
assert.True(t, automatic)
automatic, err = measurementModeIsAutomatic(constants.MeasurementModeManual)
require.NoError(t, err)
assert.False(t, automatic)
_, err = measurementModeIsAutomatic(-1)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid mode")
}
func TestUpdateMeasurementDataObjectLocksRowAndUpdatesMode(t *testing.T) {
db, mock, closeDB := newDataObjectUpdateTestDB(t)
defer closeDB()
mock.ExpectBegin()
tx := db.Begin()
require.NoError(t, tx.Error)
expectMeasurementResolution(mock, 0)
mock.ExpectExec(`UPDATE "measurement" SET .*"mode"=\$1.*"operations"=.*WHERE id = \$4`).
WithArgs(constants.MeasurementModeAutomatic, sqlmock.AnyArg(), 500, int64(10)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectRollback()
startCalled := false
result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "mode", constants.MeasurementModeAutomatic, nil, measurementUpdateDependencies{
startDataRTFunc: func(_ context.Context, dataSource orm.JSONMap, value *float64) error {
startCalled = true
assert.Equal(t, float64(1), dataSource["type"])
assert.Nil(t, value)
return nil
},
})
require.NoError(t, err)
assert.True(t, startCalled)
assert.Contains(t, result.message, "automatic")
require.NoError(t, tx.Rollback().Error)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestUpdateMeasurementModeToAutomaticReturnsErrorWhenDataRTStartFails(t *testing.T) {
db, mock, closeDB := newDataObjectUpdateTestDB(t)
defer closeDB()
mock.ExpectBegin()
tx := db.Begin()
require.NoError(t, tx.Error)
expectMeasurementResolution(mock, constants.MeasurementModeManual)
mock.ExpectExec(`UPDATE "measurement" SET .*"mode"=\$1.*"operations"=.*WHERE id = \$4`).
WithArgs(constants.MeasurementModeAutomatic, sqlmock.AnyArg(), 500, int64(10)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectRollback()
_, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "mode", constants.MeasurementModeAutomatic, nil, measurementUpdateDependencies{
startDataRTFunc: func(context.Context, orm.JSONMap, *float64) error {
return fmt.Errorf("dataRT unavailable")
},
})
require.Error(t, err)
assert.Contains(t, err.Error(), "start automatic measurement write")
require.NoError(t, tx.Rollback().Error)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestUpdateMeasurementDataObjectReturnsMessageWhenModeIsUnchanged(t *testing.T) {
db, mock, closeDB := newDataObjectUpdateTestDB(t)
defer closeDB()
mock.ExpectBegin()
tx := db.Begin()
require.NoError(t, tx.Error)
expectMeasurementResolution(mock, 1)
mock.ExpectRollback()
result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "mode", constants.MeasurementModeAutomatic, nil, measurementUpdateDependencies{})
require.NoError(t, err)
assert.Contains(t, result.message, "already")
require.NoError(t, tx.Rollback().Error)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestUpdateMeasurementModeToManualWithoutDataOnlyStopsDataRT(t *testing.T) {
db, mock, closeDB := newDataObjectUpdateTestDB(t)
defer closeDB()
mock.ExpectBegin()
tx := db.Begin()
require.NoError(t, tx.Error)
expectMeasurementResolution(mock, constants.MeasurementModeAutomatic)
mock.ExpectExec(`UPDATE "measurement" SET .*"mode"=\$1.*"operations"=.*WHERE id = \$4`).
WithArgs(constants.MeasurementModeManual, sqlmock.AnyArg(), 500, int64(10)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectRollback()
stopCalled := false
replaceCalled := false
result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "mode", constants.MeasurementModeManual, nil, measurementUpdateDependencies{
updateDataRTFunc: func(_ context.Context, dataSource orm.JSONMap, value *float64) error {
stopCalled = true
assert.Equal(t, float64(1), dataSource["type"])
assert.Nil(t, value)
return nil
},
replaceRedisValueFunc: func(context.Context, *orm.Measurement, float64) error {
replaceCalled = true
return nil
},
})
require.NoError(t, err)
assert.True(t, stopCalled)
assert.False(t, replaceCalled)
assert.Contains(t, result.message, "manual")
require.NoError(t, tx.Rollback().Error)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestUpdateMeasurementModeToManualReplacesRedisValueWhenDataProvided(t *testing.T) {
db, mock, closeDB := newDataObjectUpdateTestDB(t)
defer closeDB()
mock.ExpectBegin()
tx := db.Begin()
require.NoError(t, tx.Error)
expectMeasurementResolution(mock, constants.MeasurementModeAutomatic)
mock.ExpectExec(`UPDATE "measurement" SET .*"mode"=\$1.*"operations"=.*WHERE id = \$4`).
WithArgs(constants.MeasurementModeManual, sqlmock.AnyArg(), 500, int64(10)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectRollback()
callOrder := make([]string, 0, 2)
result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "mode", constants.MeasurementModeManual, json.RawMessage(`0`), measurementUpdateDependencies{
updateDataRTFunc: func(_ context.Context, _ orm.JSONMap, value *float64) error {
callOrder = append(callOrder, "stop-dataRT")
assert.Nil(t, value)
return nil
},
replaceRedisValueFunc: func(_ context.Context, measurement *orm.Measurement, value float64) error {
callOrder = append(callOrder, "replace-redis")
assert.Equal(t, int64(10), measurement.ID)
assert.Equal(t, float64(0), value)
return nil
},
})
require.NoError(t, err)
assert.Equal(t, []string{"stop-dataRT", "replace-redis"}, callOrder)
assert.Contains(t, result.message, "manual")
require.NoError(t, tx.Rollback().Error)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestUpdateMeasurementModeToManualDoesNotTouchRedisWhenDataRTStopFails(t *testing.T) {
db, mock, closeDB := newDataObjectUpdateTestDB(t)
defer closeDB()
mock.ExpectBegin()
tx := db.Begin()
require.NoError(t, tx.Error)
expectMeasurementResolution(mock, constants.MeasurementModeAutomatic)
mock.ExpectExec(`UPDATE "measurement" SET .*"mode"=\$1.*"operations"=.*WHERE id = \$4`).
WithArgs(constants.MeasurementModeManual, sqlmock.AnyArg(), 500, int64(10)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectRollback()
replaceCalled := false
_, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "mode", constants.MeasurementModeManual, json.RawMessage(`15.2`), measurementUpdateDependencies{
updateDataRTFunc: func(_ context.Context, _ orm.JSONMap, value *float64) error {
assert.Nil(t, value)
return fmt.Errorf("dataRT unavailable")
},
replaceRedisValueFunc: func(context.Context, *orm.Measurement, float64) error {
replaceCalled = true
return nil
},
})
require.Error(t, err)
assert.Contains(t, err.Error(), "stop automatic measurement write")
assert.False(t, replaceCalled)
require.NoError(t, tx.Rollback().Error)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestParseOptionalMeasurementModeData(t *testing.T) {
for _, raw := range []json.RawMessage{nil, json.RawMessage(`null`)} {
value, err := parseOptionalMeasurementModeData(raw)
require.NoError(t, err)
assert.Nil(t, value)
}
value, err := parseOptionalMeasurementModeData(json.RawMessage(`"15.2"`))
require.NoError(t, err)
require.NotNil(t, value)
assert.Equal(t, 15.2, *value)
_, err = parseOptionalMeasurementModeData(json.RawMessage(`"invalid"`))
require.Error(t, err)
}
func TestUpdateMeasurementDataObjectRejectsValueInAutomaticMode(t *testing.T) {
db, mock, closeDB := newDataObjectUpdateTestDB(t)
defer closeDB()
mock.ExpectBegin()
tx := db.Begin()
require.NoError(t, tx.Error)
expectMeasurementResolution(mock, 1)
mock.ExpectRollback()
_, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "value", float64(15.2), nil, measurementUpdateDependencies{})
require.Error(t, err)
assert.Contains(t, err.Error(), "read-only")
require.NoError(t, tx.Rollback().Error)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestUpdateMeasurementDataObjectWritesValueInManualMode(t *testing.T) {
db, mock, closeDB := newDataObjectUpdateTestDB(t)
defer closeDB()
mock.ExpectBegin()
tx := db.Begin()
require.NoError(t, tx.Error)
expectMeasurementResolution(mock, 0)
mock.ExpectExec(`UPDATE "measurement" SET "operations"=.*WHERE id = \$3`).
WithArgs(sqlmock.AnyArg(), 500, int64(10)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectRollback()
called := false
writer := func(_ context.Context, measurement *orm.Measurement, value float64) error {
called = true
assert.Equal(t, int64(10), measurement.ID)
assert.Equal(t, float64(15.2), value)
return nil
}
dataRTCalled := false
dataRTWriter := func(_ context.Context, dataSource orm.JSONMap, value *float64) error {
dataRTCalled = true
require.NotNil(t, value)
assert.Equal(t, float64(15.2), *value)
assert.Equal(t, float64(1), dataSource["type"])
return nil
}
result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "value", float64(15.2), nil, measurementUpdateDependencies{
writeManualValueFunc: writer,
updateDataRTFunc: dataRTWriter,
})
require.NoError(t, err)
assert.True(t, called)
assert.True(t, dataRTCalled)
assert.Contains(t, result.message, "updated")
assert.False(t, result.recordFailure)
require.NoError(t, tx.Rollback().Error)
require.NoError(t, mock.ExpectationsWereMet())
}
func TestUpdateMeasurementDataObjectReturnsFailureResultAndAppError(t *testing.T) {
db, mock, closeDB := newDataObjectUpdateTestDB(t)
defer closeDB()
mock.ExpectBegin()
tx := db.Begin()
require.NoError(t, tx.Error)
expectMeasurementResolution(mock, 0)
mock.ExpectRollback()
writeErr := fmt.Errorf("write value failed")
writer := func(context.Context, *orm.Measurement, float64) error { return writeErr }
result, err := updateMeasurementDataObject(context.Background(), tx, "nspath.measurement", "value", float64(15.2), nil, measurementUpdateDependencies{
writeManualValueFunc: writer,
})
require.Error(t, err)
assert.ErrorIs(t, err, errcode.ErrMeasurementValueUpdateFailed)
assert.ErrorIs(t, err, writeErr)
assert.True(t, result.recordFailure)
assert.Equal(t, int64(10), result.measurementID)
assert.Equal(t, float64(15.2), result.value)
require.NoError(t, tx.Rollback().Error)
require.NoError(t, mock.ExpectationsWereMet())
}
func newDataObjectUpdateTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New()
require.NoError(t, err)
db, err := gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{SkipDefaultTransaction: true})
require.NoError(t, err)
return db, mock, func() { _ = sqlDB.Close() }
}
func expectMeasurementResolution(mock sqlmock.Sqlmock, mode int16) {
const componentUUID = "70c190f2-8a60-42a9-b143-ec5f87e0aa6b"
mock.ExpectQuery(`(?s)SELECT m\.\*.*WHERE c\.nspath = \$1.*AND m\.tag = \$2.*LIMIT 2`).
WithArgs("nspath", "measurement").
WillReturnRows(sqlmock.NewRows([]string{
"id", "tag", "mode", "data_source", "component_uuid",
}).AddRow(int64(10), "measurement", mode, `{"type":1,"io_address":{"station":"station","device":"device","channel":"tm1"}}`, componentUUID))
mock.ExpectQuery(`(?s)SELECT global_uuid, nspath, tag, grid, zone, station.*WHERE global_uuid = \$1.*LIMIT 1`).
WithArgs(componentUUID).
WillReturnRows(sqlmock.NewRows([]string{"global_uuid", "nspath", "tag"}).
AddRow(componentUUID, "nspath", "component"))
mock.ExpectQuery(`SELECT "id","mode","data_source" FROM "measurement" WHERE id = \$1 LIMIT \$2 FOR UPDATE`).
WithArgs(int64(10), 1).
WillReturnRows(sqlmock.NewRows([]string{
"id", "tag", "mode", "data_source", "component_uuid",
}).AddRow(int64(10), "measurement", mode, `{"type":1,"io_address":{"station":"station","device":"device","channel":"tm1"}}`, componentUUID))
}