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)) }