// ------------------------------------------------------------ // Copyright (c) Microsoft Corporation and Dapr Contributors. // Licensed under the MIT License. // ------------------------------------------------------------ package mysql import ( "database/sql" "encoding/base64" "encoding/json" "errors" "fmt" "testing" "github.com/DATA-DOG/go-sqlmock" "github.com/stretchr/testify/assert" "github.com/dapr/components-contrib/state" "github.com/dapr/components-contrib/state/utils" "github.com/dapr/kit/logger" ) const ( fakeConnectionString = "not a real connection" ) func TestEnsureStateSchemaHandlesShortConnectionString(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() m.mySQL.schemaName = "theSchema" m.mySQL.connectionString = "theUser:thePassword@/" rows := sqlmock.NewRows([]string{"exists"}).AddRow(1) m.mock1.ExpectQuery("SELECT EXISTS").WillReturnRows(rows) // Act m.mySQL.ensureStateSchema() // Assert assert.Equal(t, "theUser:thePassword@/theSchema", m.mySQL.connectionString) } func TestFinishInitHandlesSchemaExistsError(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() expectedErr := fmt.Errorf("existsError") m.mock1.ExpectQuery("SELECT EXISTS").WillReturnError(expectedErr) // Act actualErr := m.mySQL.finishInit(m.mySQL.db, nil) // Assert assert.NotNil(t, actualErr, "now error returned") assert.Equal(t, "existsError", actualErr.Error(), "wrong error") } func TestFinishInitHandlesDatabaseCreateError(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() rows := sqlmock.NewRows([]string{"exists"}).AddRow(0) m.mock1.ExpectQuery("SELECT EXISTS").WillReturnRows(rows) expectedErr := fmt.Errorf("createDatabaseError") m.mock1.ExpectExec("CREATE DATABASE").WillReturnError(expectedErr) // Act actualErr := m.mySQL.finishInit(m.mySQL.db, nil) // Assert assert.NotNil(t, actualErr, "now error returned") assert.Equal(t, "createDatabaseError", actualErr.Error(), "wrong error") } func TestFinishInitHandlesOpenError(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() // Act err := m.mySQL.finishInit(m.mySQL.db, fmt.Errorf("failed to open database")) // Assert assert.NotNil(t, err, "now error returned") assert.Equal(t, "failed to open database", err.Error(), "wrong error") } func TestFinishInitHandlesPingError(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() m.factory.openCount = 1 // See if Schema exists rows := sqlmock.NewRows([]string{"exists"}).AddRow(1) m.mock1.ExpectQuery("SELECT EXISTS").WillReturnRows(rows) m.mock1.ExpectClose() expectedErr := fmt.Errorf("pingError") m.mock2.ExpectPing().WillReturnError(expectedErr) // Act actualErr := m.mySQL.finishInit(m.mySQL.db, nil) // Assert assert.NotNil(t, actualErr, "now error returned") assert.Equal(t, "pingError", actualErr.Error(), "wrong error") } // Verifies that finishInit can handle an error from its call to // ensureStateTable. The code should not attempt to create the table. Because // there is no m.mock1.ExpectExec if the code attempts to execute the create // table commnad this test will fail. func TestFinishInitHandlesTableExistsError(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() m.factory.openCount = 1 // See if Schema exists rows := sqlmock.NewRows([]string{"exists"}).AddRow(1) m.mock1.ExpectQuery("SELECT EXISTS").WillReturnRows(rows) m.mock1.ExpectClose() // Execute use command m.mock2.ExpectPing() m.mock2.ExpectQuery("SELECT EXISTS").WillReturnError(fmt.Errorf("tableExistsError")) // Act err := m.mySQL.finishInit(m.mySQL.db, nil) // Assert assert.NotNil(t, err, "no error returned") assert.Equal(t, "tableExistsError", err.Error(), "tableExists did not return err") } func TestClosingDatabaseTwiceReturnsNil(t *testing.T) { // Arrange m, _ := mockDatabase(t) m.mySQL.Close() m.mySQL.db = nil // Act err := m.mySQL.Close() // Assert assert.Nil(t, err, "error returned") } func TestExecuteMultiCannotBeginTransaction(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() m.mock1.ExpectBegin().WillReturnError(fmt.Errorf("beginError")) // Act err := m.mySQL.executeMulti(nil, nil) // Assert assert.NotNil(t, err, "no error returned") assert.Equal(t, "beginError", err.Error(), "wrong error returned") } func TestMySQLBulkDeleteRollbackDeletes(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() m.mock1.ExpectBegin() m.mock1.ExpectExec("DELETE FROM").WillReturnError(fmt.Errorf("deleteError")) m.mock1.ExpectRollback() deletes := []state.DeleteRequest{createDeleteRequest()} // Act err := m.mySQL.BulkDelete(deletes) // Assert assert.NotNil(t, err, "no error returned") assert.Equal(t, "deleteError", err.Error(), "wrong error returned") } func TestMySQLBulkSetRollbackSets(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() m.mock1.ExpectBegin() m.mock1.ExpectExec("INSERT INTO").WillReturnError(fmt.Errorf("setError")) m.mock1.ExpectRollback() sets := []state.SetRequest{createSetRequest()} // Act err := m.mySQL.BulkSet(sets) // Assert assert.NotNil(t, err, "no error returned") assert.Equal(t, "setError", err.Error(), "wrong error returned") } func TestExecuteMultiCommitSetsAndDeletes(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() m.mock1.ExpectBegin() m.mock1.ExpectExec("DELETE FROM").WillReturnResult(sqlmock.NewResult(0, 1)) m.mock1.ExpectExec("INSERT INTO").WillReturnResult(sqlmock.NewResult(0, 1)) m.mock1.ExpectCommit() sets := []state.SetRequest{createSetRequest()} deletes := []state.DeleteRequest{createDeleteRequest()} // Act err := m.mySQL.executeMulti(sets, deletes) // Assert assert.Nil(t, err, "error returned") } func TestSetHandlesOptionsError(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() request := createSetRequest() request.Options.Consistency = "Invalid" // Act err := m.mySQL.setValue(&request) // Assert assert.NotNil(t, err) } func TestSetHandlesNoKey(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() request := createSetRequest() request.Key = "" // Act err := m.mySQL.Set(&request) // Assert assert.NotNil(t, err) assert.Equal(t, "missing key in set operation", err.Error(), "wrong error returned") } func TestSetHandlesUpdate(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() m.mock1.ExpectExec("UPDATE state").WillReturnResult(sqlmock.NewResult(1, 1)) eTag := "946af56e" request := createSetRequest() request.ETag = &eTag // Act err := m.mySQL.setValue(&request) // Assert assert.Nil(t, err) } func TestSetHandlesErr(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() t.Run("error occurs when update with tag", func(t *testing.T) { m.mock1.ExpectExec("UPDATE state").WillReturnError(errors.New("error")) eTag := "946af561" request := createSetRequest() request.ETag = &eTag // Act err := m.mySQL.setValue(&request) // Assert assert.NotNil(t, err) assert.IsType(t, &state.ETagError{}, err) assert.Equal(t, err.(*state.ETagError).Kind(), state.ETagMismatch) }) t.Run("error occurs when insert", func(t *testing.T) { m.mock1.ExpectExec("INSERT INTO state").WillReturnError(errors.New("error")) request := createSetRequest() // Act err := m.mySQL.setValue(&request) // Assert assert.NotNil(t, err) assert.Equal(t, "error", err.Error()) }) t.Run("insert on conflict", func(t *testing.T) { m.mock1.ExpectExec("INSERT INTO state").WillReturnResult(sqlmock.NewResult(1, 2)) request := createSetRequest() // Act err := m.mySQL.setValue(&request) // Assert assert.Nil(t, err) }) t.Run("too many rows error", func(t *testing.T) { m.mock1.ExpectExec("INSERT INTO state").WillReturnResult(sqlmock.NewResult(1, 3)) request := createSetRequest() // Act err := m.mySQL.setValue(&request) // Assert assert.NotNil(t, err) }) t.Run("no rows effected error", func(t *testing.T) { m.mock1.ExpectExec("UPDATE state").WillReturnResult(sqlmock.NewResult(1, 0)) eTag := "illegal etag" request := createSetRequest() request.ETag = &eTag // Act err := m.mySQL.setValue(&request) // Assert assert.NotNil(t, err) assert.IsType(t, &state.ETagError{}, err) assert.Equal(t, err.(*state.ETagError).Kind(), state.ETagMismatch) }) } // Verifies that MySQL passes through to myDBAccess. func TestMySQLDeleteHandlesNoKey(t *testing.T) { // Arrange m, _ := mockDatabase(t) request := createDeleteRequest() request.Key = "" // Act err := m.mySQL.Delete(&request) // Asset assert.NotNil(t, err) assert.Equal(t, "missing key in delete operation", err.Error(), "wrong error returned") } func TestDeleteWithETag(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() m.mock1.ExpectExec("DELETE FROM").WillReturnResult(sqlmock.NewResult(0, 1)) eTag := "946af562" request := createDeleteRequest() request.ETag = &eTag // Act err := m.mySQL.deleteValue(&request) // Assert assert.Nil(t, err) } func TestDeleteWithErr(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() t.Run("error occurs when delete", func(t *testing.T) { m.mock1.ExpectExec("DELETE FROM").WillReturnError(errors.New("error")) request := createDeleteRequest() // Act err := m.mySQL.deleteValue(&request) // Assert assert.NotNil(t, err) assert.Equal(t, "error", err.Error()) }) t.Run("etag mismatch", func(t *testing.T) { m.mock1.ExpectExec("DELETE FROM").WillReturnResult(sqlmock.NewResult(0, 0)) eTag := "946af563" request := createDeleteRequest() request.ETag = &eTag // Act err := m.mySQL.deleteValue(&request) // Assert assert.NotNil(t, err) assert.IsType(t, &state.ETagError{}, err) assert.Equal(t, err.(*state.ETagError).Kind(), state.ETagMismatch) }) } func TestGetHandlesNoRows(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() m.mock1.ExpectQuery("SELECT value").WillReturnRows(sqlmock.NewRows([]string{"value", "eTag"})) request := &state.GetRequest{ Key: "UnitTest", } // Act response, err := m.mySQL.Get(request) // Assert assert.Nil(t, err, "returned error") assert.NotNil(t, response, "did not return empty response") } func TestGetHandlesNoKey(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() request := &state.GetRequest{ Key: "", } // Act response, err := m.mySQL.Get(request) // Assert assert.NotNil(t, err, "returned error") assert.Equal(t, "missing key in get operation", err.Error(), "wrong error returned") assert.Nil(t, response, "returned response") } func TestGetHandlesGenericError(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() m.mock1.ExpectQuery("").WillReturnError(fmt.Errorf("generic error")) request := &state.GetRequest{ Key: "UnitTest", } // Act response, err := m.mySQL.Get(request) // Assert assert.NotNil(t, err) assert.Nil(t, response) } func TestGetSucceeds(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() t.Run("has json type", func(t *testing.T) { rows := sqlmock.NewRows([]string{"value", "eTag", "isbinary"}).AddRow("{}", "946af56e", false) m.mock1.ExpectQuery("SELECT value, eTag, isbinary FROM state WHERE id = ?").WillReturnRows(rows) request := &state.GetRequest{ Key: "UnitTest", } // Act response, err := m.mySQL.Get(request) // Assert assert.Nil(t, err) assert.NotNil(t, response) assert.Equal(t, "{}", string(response.Data)) }) t.Run("has binary type", func(t *testing.T) { value, _ := utils.Marshal(base64.StdEncoding.EncodeToString([]byte("abcdefg")), json.Marshal) rows := sqlmock.NewRows([]string{"value", "eTag", "isbinary"}).AddRow(value, "946af56e", true) m.mock1.ExpectQuery("SELECT value, eTag, isbinary FROM state WHERE id = ?").WillReturnRows(rows) request := &state.GetRequest{ Key: "UnitTest", } // Act response, err := m.mySQL.Get(request) // Assert assert.Nil(t, err) assert.NotNil(t, response) assert.Equal(t, "abcdefg", string(response.Data)) }) } // Verifies that the correct query is executed to test if the table // already exists in the database or not. func TestTableExists(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() // Return a result that indicates that the table already exists in the // database. rows := sqlmock.NewRows([]string{"exists"}).AddRow(1) m.mock1.ExpectQuery("SELECT EXISTS").WillReturnRows(rows) // Act actual, err := tableExists(m.mySQL.db, "store") // Assert assert.Nil(t, err, `error was returned`) assert.True(t, actual, `table does not exists`) } // Verifies that the code returns an error if the create table command fails. func TestEnsureStateTableHandlesCreateTableError(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() rows := sqlmock.NewRows([]string{"exists"}).AddRow(0) m.mock1.ExpectQuery("SELECT EXISTS").WillReturnRows(rows) m.mock1.ExpectExec("CREATE TABLE").WillReturnError(fmt.Errorf("CreateTableError")) // Act err := m.mySQL.ensureStateTable("state") // Assert assert.NotNil(t, err, "no error returned") assert.Equal(t, "CreateTableError", err.Error(), "wrong error returned") } // Verifies that ensureStateTable creates the table when tableExists returns // false. func TestEnsureStateTableCreatesTable(t *testing.T) { // Arrange m, _ := mockDatabase(t) defer m.mySQL.Close() // Return exists = 0 when Select Exists is called to indicate the table // does not already exist. rows := sqlmock.NewRows([]string{"exists"}).AddRow(0) m.mock1.ExpectQuery("SELECT EXISTS").WillReturnRows(rows) m.mock1.ExpectExec("CREATE TABLE").WillReturnResult(sqlmock.NewResult(1, 1)) // Act err := m.mySQL.ensureStateTable("state") // Assert assert.Nil(t, err) } // Verify that the call to MySQL init get passed through // to the DbAccess instance. func TestInitReturnsErrorOnNoConnectionString(t *testing.T) { // Arrange t.Parallel() m, _ := mockDatabase(t) metadata := &state.Metadata{ Properties: map[string]string{connectionStringKey: ""}, } // Act err := m.mySQL.Init(*metadata) // Assert assert.NotNil(t, err) assert.Equal(t, defaultTableName, m.mySQL.tableName, "table name did not default") } func TestInitReturnsErrorOnFailOpen(t *testing.T) { // Arrange t.Parallel() m, _ := mockDatabase(t) metadata := &state.Metadata{ Properties: map[string]string{connectionStringKey: fakeConnectionString}, } // Act err := m.mySQL.Init(*metadata) // Assert assert.NotNil(t, err) } func TestInitHandlesRegisterTLSConfigError(t *testing.T) { // Arrange t.Parallel() m, _ := mockDatabase(t) m.factory.registerErr = fmt.Errorf("registerTLSConfigError") metadata := &state.Metadata{ Properties: map[string]string{ pemPathKey: "./ssl.pem", tableNameKey: "stateStore", connectionStringKey: fakeConnectionString, }, } // Act err := m.mySQL.Init(*metadata) // Assert assert.NotNil(t, err) assert.Equal(t, "registerTLSConfigError", err.Error(), "wrong error") } func TestInitSetsTableName(t *testing.T) { // Arrange t.Parallel() m, _ := mockDatabase(t) metadata := &state.Metadata{ Properties: map[string]string{connectionStringKey: "", tableNameKey: "stateStore"}, } // Act err := m.mySQL.Init(*metadata) // Assert assert.NotNil(t, err) assert.Equal(t, "stateStore", m.mySQL.tableName, "table name did not default") } func TestInitSetsSchemaName(t *testing.T) { // Arrange t.Parallel() m, _ := mockDatabase(t) metadata := &state.Metadata{ Properties: map[string]string{connectionStringKey: "", schemaNameKey: "stateStoreSchema"}, } // Act err := m.mySQL.Init(*metadata) // Assert assert.NotNil(t, err) assert.Equal(t, "stateStoreSchema", m.mySQL.schemaName, "table name did not default") } // This state store does not support BulkGet so it must return false and // nil nil. func TestBulkGetReturnsNil(t *testing.T) { // Arrange t.Parallel() m, _ := mockDatabase(t) // Act supported, response, err := m.mySQL.BulkGet(nil) // Assert assert.Nil(t, err, `returned err`) assert.Nil(t, response, `returned response`) assert.False(t, supported, `returned supported`) } func TestMultiWithNoRequestsReturnsNil(t *testing.T) { // Arrange t.Parallel() m, _ := mockDatabase(t) var ops []state.TransactionalStateOperation // Act err := m.mySQL.Multi(&state.TransactionalStateRequest{ Operations: ops, }) // Assert assert.Nil(t, err) } func TestInvalidMultiAction(t *testing.T) { // Arrange t.Parallel() m, _ := mockDatabase(t) var ops []state.TransactionalStateOperation ops = append(ops, state.TransactionalStateOperation{ Operation: "Something invalid", Request: createSetRequest(), }) // Act err := m.mySQL.Multi(&state.TransactionalStateRequest{ Operations: ops, }) // Assert assert.NotNil(t, err) } func TestClosingMySQLWithNilDba(t *testing.T) { // Arrange t.Parallel() m, _ := mockDatabase(t) m.mySQL.Close() m.mySQL.db = nil // Act err := m.mySQL.Close() // Assert assert.Nil(t, err) } func TestValidSetRequest(t *testing.T) { // Arrange t.Parallel() m, _ := mockDatabase(t) var ops []state.TransactionalStateOperation ops = append(ops, state.TransactionalStateOperation{ Operation: state.Upsert, Request: createSetRequest(), }) m.mock1.ExpectBegin() m.mock1.ExpectExec("INSERT INTO").WillReturnResult(sqlmock.NewResult(0, 1)) m.mock1.ExpectCommit() // Act err := m.mySQL.Multi(&state.TransactionalStateRequest{ Operations: ops, }) // Assert assert.Nil(t, err) } func TestInvalidMultiSetRequest(t *testing.T) { // Arrange t.Parallel() m, _ := mockDatabase(t) var ops []state.TransactionalStateOperation ops = append(ops, state.TransactionalStateOperation{ Operation: state.Upsert, // Delete request is not valid for Upsert operation Request: createDeleteRequest(), }) // Act err := m.mySQL.Multi(&state.TransactionalStateRequest{ Operations: ops, }) // Assert assert.NotNil(t, err) } func TestValidMultiDeleteRequest(t *testing.T) { // Arrange t.Parallel() m, _ := mockDatabase(t) var ops []state.TransactionalStateOperation m.mock1.ExpectBegin() m.mock1.ExpectExec("DELETE FROM").WillReturnResult(sqlmock.NewResult(0, 1)) m.mock1.ExpectCommit() ops = append(ops, state.TransactionalStateOperation{ Operation: state.Delete, Request: createDeleteRequest(), }) // Act err := m.mySQL.Multi(&state.TransactionalStateRequest{ Operations: ops, }) // Assert assert.Nil(t, err) } func TestInvalidMultiDeleteRequest(t *testing.T) { // Arrange t.Parallel() m, _ := mockDatabase(t) var ops []state.TransactionalStateOperation ops = append(ops, state.TransactionalStateOperation{ Operation: state.Delete, // Set request is not valid for Delete operation Request: createSetRequest(), }) // Act err := m.mySQL.Multi(&state.TransactionalStateRequest{ Operations: ops, }) // Assert assert.NotNil(t, err) } func createSetRequest() state.SetRequest { return state.SetRequest{ Key: randomKey(), Value: randomJSON(), } } func createDeleteRequest() state.DeleteRequest { return state.DeleteRequest{ Key: randomKey(), } } type mocks struct { mySQL *MySQL db *sql.DB mock1 sqlmock.Sqlmock mock2 sqlmock.Sqlmock factory *fakeMySQLFactory } // Returns a MySQL and an extra sql.DB for test that have to close the first // db returned in the MySQL instance. func mockDatabase(t *testing.T) (*mocks, error) { db1, mock1, err := sqlmock.New(sqlmock.MonitorPingsOption(true)) if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } db2, mock2, err := sqlmock.New(sqlmock.MonitorPingsOption(true)) if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) } fake := newFakeMySQLFactory(db1, db2, nil, nil) logger := logger.NewLogger("test") mys := newMySQLStateStore(logger, fake) mys.db = db1 mys.tableName = "state" mys.connectionString = "theUser:thePassword@/theDBName" return &mocks{ db: db2, mySQL: mys, mock1: mock1, mock2: mock2, factory: fake, }, err } // Fake for unit testings the part that uses the factory // to open the database and register the pem file. type fakeMySQLFactory struct { openCount int openErr error registerErr error db1 *sql.DB db2 *sql.DB } // startCount is used to set openCount. openCount is used to determine // which fake to open. In the normal flow of code this would go from 1 // to 2. However, in some tests they assume the factory open has already // been called so for those test set startCount to 1. func newFakeMySQLFactory(db1 *sql.DB, db2 *sql.DB, registerErr, openErr error) *fakeMySQLFactory { return &fakeMySQLFactory{ db1: db1, db2: db2, openErr: openErr, registerErr: registerErr, } } func (f *fakeMySQLFactory) Open(connectionString string) (*sql.DB, error) { f.openCount++ if f.openCount == 1 { return f.db1, f.openErr } return f.db2, f.openErr } func (f *fakeMySQLFactory) RegisterTLSConfig(pemPath string) error { return f.registerErr }