diff --git a/stovepipe/extension/storage/mysql/BUILD.bazel b/stovepipe/extension/storage/mysql/BUILD.bazel index 675f575c..4e9a3612 100644 --- a/stovepipe/extension/storage/mysql/BUILD.bazel +++ b/stovepipe/extension/storage/mysql/BUILD.bazel @@ -1,4 +1,4 @@ -load("@rules_go//go:def.bzl", "go_library") +load("@rules_go//go:def.bzl", "go_library", "go_test") go_library( name = "go_default_library", @@ -19,3 +19,24 @@ go_library( "@com_github_uber_go_tally//:go_default_library", ], ) + +go_test( + name = "go_default_test", + srcs = [ + "build_store_test.go", + "queue_store_test.go", + "request_store_test.go", + "request_uri_store_test.go", + "storage_test.go", + ], + embed = [":go_default_library"], + deps = [ + "//stovepipe/entity:go_default_library", + "//stovepipe/extension/storage:go_default_library", + "@com_github_data_dog_go_sqlmock//:go_default_library", + "@com_github_go_sql_driver_mysql//:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + "@com_github_uber_go_tally//:go_default_library", + ], +) diff --git a/stovepipe/extension/storage/mysql/build_store_test.go b/stovepipe/extension/storage/mysql/build_store_test.go new file mode 100644 index 00000000..f9425986 --- /dev/null +++ b/stovepipe/extension/storage/mysql/build_store_test.go @@ -0,0 +1,247 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mysql + +import ( + "context" + "database/sql" + "fmt" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-sql-driver/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/uber/submitqueue/stovepipe/entity" + "github.com/uber/submitqueue/stovepipe/extension/storage" +) + +func setupBuildStoreTest(t *testing.T) (*sql.DB, sqlmock.Sqlmock, storage.BuildStore) { + t.Helper() + + db, mock, err := sqlmock.New() + require.NoError(t, err) + + store := NewBuildStore(db, testMetrics()) + + return db, mock, store +} + +func TestBuildStore_Create(t *testing.T) { + build := entity.Build{ + ID: "bk-1001", + RequestID: "request/monorepo/main/1", + Status: entity.BuildStatusAccepted, + Version: 1, + } + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO build"). + WithArgs(build.ID, build.RequestID, build.Status, build.Version). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "duplicate id returns ErrAlreadyExists", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO build"). + WithArgs(build.ID, build.RequestID, build.Status, build.Version). + WillReturnError(&mysql.MySQLError{Number: mysqlErrDuplicateEntry}) + }, + wantErr: true, + wantErrIs: storage.ErrAlreadyExists, + }, + { + name: "other exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO build"). + WithArgs(build.ID, build.RequestID, build.Status, build.Version). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupBuildStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Create(context.Background(), build) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestBuildStore_Get(t *testing.T) { + want := entity.Build{ + ID: "bk-1001", + RequestID: "request/monorepo/main/1", + Status: entity.BuildStatusRunning, + Version: 2, + } + + tests := []struct { + name string + id string + setup func(mock sqlmock.Sqlmock) + want entity.Build + wantErr bool + wantErrIs error + }{ + { + name: "found", + id: want.ID, + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"id", "request_id", "status", "version"}). + AddRow(want.ID, want.RequestID, string(want.Status), want.Version) + mock.ExpectQuery("SELECT id, request_id, status, version"). + WithArgs(want.ID). + WillReturnRows(rows) + }, + want: want, + }, + { + name: "not found", + id: "missing", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT id, request_id, status, version"). + WithArgs("missing"). + WillReturnError(sql.ErrNoRows) + }, + wantErr: true, + wantErrIs: storage.ErrNotFound, + }, + { + name: "query error", + id: "bad", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT id, request_id, status, version"). + WithArgs("bad"). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupBuildStoreTest(t) + defer db.Close() + + tt.setup(mock) + + got, err := store.Get(context.Background(), tt.id) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestBuildStore_Update(t *testing.T) { + build := entity.Build{ID: "bk-1001", Status: entity.BuildStatusRunning} + const oldVersion, newVersion = int32(1), int32(2) + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE build"). + WithArgs(build.Status, newVersion, build.ID, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "version mismatch", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE build"). + WithArgs(build.Status, newVersion, build.ID, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 0)) + }, + wantErr: true, + wantErrIs: storage.ErrVersionMismatch, + }, + { + name: "exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE build"). + WithArgs(build.Status, newVersion, build.ID, oldVersion). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + { + name: "rows affected error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE build"). + WithArgs(build.Status, newVersion, build.ID, oldVersion). + WillReturnResult(sqlmock.NewErrorResult(fmt.Errorf("driver error"))) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupBuildStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Update(context.Background(), build, oldVersion, newVersion) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} diff --git a/stovepipe/extension/storage/mysql/queue_store_test.go b/stovepipe/extension/storage/mysql/queue_store_test.go new file mode 100644 index 00000000..b766d2ab --- /dev/null +++ b/stovepipe/extension/storage/mysql/queue_store_test.go @@ -0,0 +1,254 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mysql + +import ( + "context" + "database/sql" + "fmt" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-sql-driver/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/uber/submitqueue/stovepipe/entity" + "github.com/uber/submitqueue/stovepipe/extension/storage" +) + +func setupQueueStoreTest(t *testing.T) (*sql.DB, sqlmock.Sqlmock, storage.QueueStore) { + t.Helper() + + db, mock, err := sqlmock.New() + require.NoError(t, err) + + store := NewQueueStore(db, testMetrics()) + + return db, mock, store +} + +func TestQueueStore_Create(t *testing.T) { + queue := entity.Queue{ + Name: "monorepo/main", + LastGreenURI: "git://remote/monorepo/main/green", + InFlightCount: 0, + LatestRequestID: "request/monorepo/main/1", + Version: 1, + } + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO queue"). + WithArgs(queue.Name, queue.LastGreenURI, queue.InFlightCount, queue.LatestRequestID, queue.Version). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "duplicate name returns ErrAlreadyExists", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO queue"). + WithArgs(queue.Name, queue.LastGreenURI, queue.InFlightCount, queue.LatestRequestID, queue.Version). + WillReturnError(&mysql.MySQLError{Number: mysqlErrDuplicateEntry}) + }, + wantErr: true, + wantErrIs: storage.ErrAlreadyExists, + }, + { + name: "other exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO queue"). + WithArgs(queue.Name, queue.LastGreenURI, queue.InFlightCount, queue.LatestRequestID, queue.Version). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupQueueStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Create(context.Background(), queue) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestQueueStore_Get(t *testing.T) { + want := entity.Queue{ + Name: "monorepo/main", + LastGreenURI: "git://remote/monorepo/main/green", + InFlightCount: 2, + LatestRequestID: "request/monorepo/main/3", + Version: 3, + } + + tests := []struct { + name string + queueName string + setup func(mock sqlmock.Sqlmock) + want entity.Queue + wantErr bool + wantErrIs error + }{ + { + name: "found", + queueName: want.Name, + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"name", "last_green_uri", "in_flight_count", "latest_request_id", "version"}). + AddRow(want.Name, want.LastGreenURI, want.InFlightCount, want.LatestRequestID, want.Version) + mock.ExpectQuery("SELECT name, last_green_uri, in_flight_count, latest_request_id, version FROM queue"). + WithArgs(want.Name). + WillReturnRows(rows) + }, + want: want, + }, + { + name: "not found", + queueName: "missing", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT name, last_green_uri, in_flight_count, latest_request_id, version FROM queue"). + WithArgs("missing"). + WillReturnError(sql.ErrNoRows) + }, + wantErr: true, + wantErrIs: storage.ErrNotFound, + }, + { + name: "query error", + queueName: "bad", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT name, last_green_uri, in_flight_count, latest_request_id, version FROM queue"). + WithArgs("bad"). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupQueueStoreTest(t) + defer db.Close() + + tt.setup(mock) + + got, err := store.Get(context.Background(), tt.queueName) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestQueueStore_Update(t *testing.T) { + queue := entity.Queue{ + Name: "monorepo/main", + LastGreenURI: "git://remote/monorepo/main/green", + InFlightCount: 1, + LatestRequestID: "request/monorepo/main/2", + } + const oldVersion, newVersion = int32(1), int32(2) + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE queue"). + WithArgs(queue.LastGreenURI, queue.InFlightCount, queue.LatestRequestID, newVersion, queue.Name, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "version mismatch", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE queue"). + WithArgs(queue.LastGreenURI, queue.InFlightCount, queue.LatestRequestID, newVersion, queue.Name, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 0)) + }, + wantErr: true, + wantErrIs: storage.ErrVersionMismatch, + }, + { + name: "exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE queue"). + WithArgs(queue.LastGreenURI, queue.InFlightCount, queue.LatestRequestID, newVersion, queue.Name, oldVersion). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + { + name: "rows affected error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE queue"). + WithArgs(queue.LastGreenURI, queue.InFlightCount, queue.LatestRequestID, newVersion, queue.Name, oldVersion). + WillReturnResult(sqlmock.NewErrorResult(fmt.Errorf("driver error"))) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupQueueStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Update(context.Background(), queue, oldVersion, newVersion) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} diff --git a/stovepipe/extension/storage/mysql/request_store_test.go b/stovepipe/extension/storage/mysql/request_store_test.go new file mode 100644 index 00000000..b12465ce --- /dev/null +++ b/stovepipe/extension/storage/mysql/request_store_test.go @@ -0,0 +1,295 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mysql + +import ( + "context" + "database/sql" + "errors" + "fmt" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-sql-driver/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/uber/submitqueue/stovepipe/entity" + "github.com/uber/submitqueue/stovepipe/extension/storage" +) + +func setupRequestStoreTest(t *testing.T) (*sql.DB, sqlmock.Sqlmock, storage.RequestStore) { + t.Helper() + + db, mock, err := sqlmock.New() + require.NoError(t, err) + + store := NewRequestStore(db, testMetrics()) + + return db, mock, store +} + +func TestRequestStore_Create(t *testing.T) { + request := entity.Request{ + ID: "request/monorepo/main/1", + Queue: "monorepo/main", + URI: "git://remote/monorepo/main/deadbeef", + State: entity.RequestStateAccepted, + BuildStrategy: entity.BuildStrategyUnknown, + BaseURI: "", + Version: 1, + } + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO request"). + WithArgs(request.ID, request.Queue, request.URI, request.State, request.BuildStrategy, request.BaseURI, request.Version). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "duplicate id returns ErrAlreadyExists", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO request"). + WithArgs(request.ID, request.Queue, request.URI, request.State, request.BuildStrategy, request.BaseURI, request.Version). + WillReturnError(&mysql.MySQLError{Number: mysqlErrDuplicateEntry}) + }, + wantErr: true, + wantErrIs: storage.ErrAlreadyExists, + }, + { + name: "other exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO request"). + WithArgs(request.ID, request.Queue, request.URI, request.State, request.BuildStrategy, request.BaseURI, request.Version). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupRequestStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Create(context.Background(), request) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestRequestStore_Get(t *testing.T) { + want := entity.Request{ + ID: "request/monorepo/main/1", + Queue: "monorepo/main", + URI: "git://remote/monorepo/main/deadbeef", + State: entity.RequestStateProcessing, + BuildStrategy: entity.BuildStrategyFull, + BaseURI: "", + Version: 2, + } + + tests := []struct { + name string + id string + setup func(mock sqlmock.Sqlmock) + want entity.Request + wantErr bool + wantErrIs error + }{ + { + name: "found", + id: want.ID, + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"id", "queue", "uri", "state", "build_strategy", "base_uri", "version"}). + AddRow(want.ID, want.Queue, want.URI, string(want.State), string(want.BuildStrategy), want.BaseURI, want.Version) + mock.ExpectQuery("SELECT id, queue, uri, state, build_strategy, base_uri, version"). + WithArgs(want.ID). + WillReturnRows(rows) + }, + want: want, + }, + { + name: "not found", + id: "missing", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT id, queue, uri, state, build_strategy, base_uri, version"). + WithArgs("missing"). + WillReturnError(sql.ErrNoRows) + }, + wantErr: true, + wantErrIs: storage.ErrNotFound, + }, + { + name: "query error", + id: "bad", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT id, queue, uri, state, build_strategy, base_uri, version"). + WithArgs("bad"). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupRequestStoreTest(t) + defer db.Close() + + tt.setup(mock) + + got, err := store.Get(context.Background(), tt.id) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestRequestStore_Update(t *testing.T) { + request := entity.Request{ + ID: "request/monorepo/main/1", + URI: "git://remote/monorepo/main/deadbeef", + State: entity.RequestStateProcessing, + BuildStrategy: entity.BuildStrategyFull, + BaseURI: "", + } + const oldVersion, newVersion = int32(1), int32(2) + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE request"). + WithArgs(request.URI, request.State, request.BuildStrategy, request.BaseURI, newVersion, request.ID, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "version mismatch", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE request"). + WithArgs(request.URI, request.State, request.BuildStrategy, request.BaseURI, newVersion, request.ID, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 0)) + }, + wantErr: true, + wantErrIs: storage.ErrVersionMismatch, + }, + { + name: "exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE request"). + WithArgs(request.URI, request.State, request.BuildStrategy, request.BaseURI, newVersion, request.ID, oldVersion). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + { + name: "rows affected error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE request"). + WithArgs(request.URI, request.State, request.BuildStrategy, request.BaseURI, newVersion, request.ID, oldVersion). + WillReturnResult(sqlmock.NewErrorResult(fmt.Errorf("driver error"))) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupRequestStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Update(context.Background(), request, oldVersion, newVersion) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestIsDuplicateEntry(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "mysql duplicate entry error", + err: &mysql.MySQLError{Number: mysqlErrDuplicateEntry}, + want: true, + }, + { + name: "other mysql error", + err: &mysql.MySQLError{Number: 1213}, + want: false, + }, + { + name: "wrapped mysql duplicate entry error", + err: fmt.Errorf("insert failed: %w", &mysql.MySQLError{Number: mysqlErrDuplicateEntry}), + want: true, + }, + { + name: "non-mysql error", + err: errors.New("connection reset"), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isDuplicateEntry(tt.err)) + }) + } +} diff --git a/stovepipe/extension/storage/mysql/request_uri_store_test.go b/stovepipe/extension/storage/mysql/request_uri_store_test.go new file mode 100644 index 00000000..1e7b3765 --- /dev/null +++ b/stovepipe/extension/storage/mysql/request_uri_store_test.go @@ -0,0 +1,170 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mysql + +import ( + "context" + "database/sql" + "fmt" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-sql-driver/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/uber/submitqueue/stovepipe/extension/storage" +) + +func setupRequestURIStoreTest(t *testing.T) (*sql.DB, sqlmock.Sqlmock, storage.RequestURIStore) { + t.Helper() + + db, mock, err := sqlmock.New() + require.NoError(t, err) + + store := NewRequestURIStore(db, testMetrics()) + + return db, mock, store +} + +func TestRequestURIStore_Create(t *testing.T) { + const queue, uri, id = "monorepo/main", "git://remote/monorepo/main/deadbeef", "request/monorepo/main/1" + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO request_uri"). + WithArgs(queue, uri, id, requestURIInitialVersion). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "duplicate mapping returns ErrAlreadyExists", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO request_uri"). + WithArgs(queue, uri, id, requestURIInitialVersion). + WillReturnError(&mysql.MySQLError{Number: mysqlErrDuplicateEntry}) + }, + wantErr: true, + wantErrIs: storage.ErrAlreadyExists, + }, + { + name: "other exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO request_uri"). + WithArgs(queue, uri, id, requestURIInitialVersion). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupRequestURIStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Create(context.Background(), queue, uri, id) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestRequestURIStore_GetIDByURI(t *testing.T) { + const queue, uri, wantID = "monorepo/main", "git://remote/monorepo/main/deadbeef", "request/monorepo/main/1" + + tests := []struct { + name string + queue string + uri string + setup func(mock sqlmock.Sqlmock) + want string + wantErr bool + wantErrIs error + }{ + { + name: "found", + queue: queue, + uri: uri, + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"request_id"}).AddRow(wantID) + mock.ExpectQuery("SELECT request_id FROM request_uri"). + WithArgs(queue, uri). + WillReturnRows(rows) + }, + want: wantID, + }, + { + name: "not found", + queue: queue, + uri: "git://remote/monorepo/main/missing", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT request_id FROM request_uri"). + WithArgs(queue, "git://remote/monorepo/main/missing"). + WillReturnError(sql.ErrNoRows) + }, + wantErr: true, + wantErrIs: storage.ErrNotFound, + }, + { + name: "query error", + queue: queue, + uri: "git://remote/monorepo/main/bad", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT request_id FROM request_uri"). + WithArgs(queue, "git://remote/monorepo/main/bad"). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupRequestURIStoreTest(t) + defer db.Close() + + tt.setup(mock) + + got, err := store.GetIDByURI(context.Background(), tt.queue, tt.uri) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} diff --git a/stovepipe/extension/storage/mysql/storage_test.go b/stovepipe/extension/storage/mysql/storage_test.go new file mode 100644 index 00000000..7cd7963d --- /dev/null +++ b/stovepipe/extension/storage/mysql/storage_test.go @@ -0,0 +1,56 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mysql + +import ( + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber-go/tally" +) + +// testMetrics returns a test metrics scope for use in tests. +func testMetrics() tally.Scope { + return tally.NoopScope +} + +func TestNewStorage(t *testing.T) { + db, _, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + + s, err := NewStorage(db, testMetrics()) + require.NoError(t, err) + + assert.NotNil(t, s.GetRequestStore()) + assert.NotNil(t, s.GetRequestURIStore()) + assert.NotNil(t, s.GetQueueStore()) + assert.NotNil(t, s.GetBuildStore()) +} + +func TestMysqlStorage_Close(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + + mock.ExpectClose() + + s, err := NewStorage(db, testMetrics()) + require.NoError(t, err) + + require.NoError(t, s.Close()) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/submitqueue/extension/storage/mysql/BUILD.bazel b/submitqueue/extension/storage/mysql/BUILD.bazel index cf351b43..1cbbf031 100644 --- a/submitqueue/extension/storage/mysql/BUILD.bazel +++ b/submitqueue/extension/storage/mysql/BUILD.bazel @@ -1,4 +1,4 @@ -load("@rules_go//go:def.bzl", "go_library") +load("@rules_go//go:def.bzl", "go_library", "go_test") go_library( name = "go_default_library", @@ -26,3 +26,33 @@ go_library( "@com_github_uber_go_tally//:go_default_library", ], ) + +go_test( + name = "go_default_test", + srcs = [ + "batch_dependent_store_test.go", + "batch_store_test.go", + "build_store_test.go", + "change_store_test.go", + "request_log_store_test.go", + "request_queue_summary_store_test.go", + "request_store_test.go", + "request_summary_store_test.go", + "request_uri_store_test.go", + "speculation_path_build_store_test.go", + "speculation_tree_store_test.go", + "storage_test.go", + ], + embed = [":go_default_library"], + deps = [ + "//platform/base/change:go_default_library", + "//platform/base/mergestrategy:go_default_library", + "//submitqueue/entity:go_default_library", + "//submitqueue/extension/storage:go_default_library", + "@com_github_data_dog_go_sqlmock//:go_default_library", + "@com_github_go_sql_driver_mysql//:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + "@com_github_uber_go_tally//:go_default_library", + ], +) diff --git a/submitqueue/extension/storage/mysql/batch_dependent_store_test.go b/submitqueue/extension/storage/mysql/batch_dependent_store_test.go new file mode 100644 index 00000000..5988d72c --- /dev/null +++ b/submitqueue/extension/storage/mysql/batch_dependent_store_test.go @@ -0,0 +1,249 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-sql-driver/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/storage" +) + +func setupBatchDependentStoreTest(t *testing.T) (*sql.DB, sqlmock.Sqlmock, storage.BatchDependentStore) { + t.Helper() + + db, mock, err := sqlmock.New() + require.NoError(t, err) + + store := NewBatchDependentStore(db, testMetrics()) + + return db, mock, store +} + +func TestBatchDependentStore_Get(t *testing.T) { + want := entity.BatchDependent{ + BatchID: "monorepo/batch/1", + Dependents: []string{"monorepo/batch/2", "monorepo/batch/3"}, + Version: 1, + } + dependentsJSON, err := json.Marshal(want.Dependents) + require.NoError(t, err) + + tests := []struct { + name string + batchID string + setup func(mock sqlmock.Sqlmock) + want entity.BatchDependent + wantErr bool + wantErrIs error + }{ + { + name: "found", + batchID: want.BatchID, + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"batch_id", "dependents", "version"}). + AddRow(want.BatchID, dependentsJSON, want.Version) + mock.ExpectQuery("SELECT batch_id, dependents, version FROM batch_dependent"). + WithArgs(want.BatchID). + WillReturnRows(rows) + }, + want: want, + }, + { + name: "not found", + batchID: "missing", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT batch_id, dependents, version FROM batch_dependent"). + WithArgs("missing"). + WillReturnError(sql.ErrNoRows) + }, + wantErr: true, + wantErrIs: storage.ErrNotFound, + }, + { + name: "query error", + batchID: "bad", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT batch_id, dependents, version FROM batch_dependent"). + WithArgs("bad"). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupBatchDependentStoreTest(t) + defer db.Close() + + tt.setup(mock) + + got, err := store.Get(context.Background(), tt.batchID) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestBatchDependentStore_Create(t *testing.T) { + bd := entity.BatchDependent{ + BatchID: "monorepo/batch/1", + Dependents: []string{"monorepo/batch/2"}, + Version: 1, + } + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO batch_dependent"). + WithArgs(bd.BatchID, sqlmock.AnyArg(), bd.Version). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "duplicate batch id returns ErrAlreadyExists", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO batch_dependent"). + WithArgs(bd.BatchID, sqlmock.AnyArg(), bd.Version). + WillReturnError(&mysql.MySQLError{Number: mysqlErrDuplicateEntry}) + }, + wantErr: true, + wantErrIs: storage.ErrAlreadyExists, + }, + { + name: "other exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO batch_dependent"). + WithArgs(bd.BatchID, sqlmock.AnyArg(), bd.Version). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupBatchDependentStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Create(context.Background(), bd) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestBatchDependentStore_UpdateDependents(t *testing.T) { + const batchID = "monorepo/batch/1" + const oldVersion, newVersion = int32(1), int32(2) + dependents := []string{"monorepo/batch/2", "monorepo/batch/3"} + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE batch_dependent"). + WithArgs(sqlmock.AnyArg(), newVersion, batchID, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "version mismatch", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE batch_dependent"). + WithArgs(sqlmock.AnyArg(), newVersion, batchID, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 0)) + }, + wantErr: true, + wantErrIs: storage.ErrVersionMismatch, + }, + { + name: "exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE batch_dependent"). + WithArgs(sqlmock.AnyArg(), newVersion, batchID, oldVersion). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + { + name: "rows affected error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE batch_dependent"). + WithArgs(sqlmock.AnyArg(), newVersion, batchID, oldVersion). + WillReturnResult(sqlmock.NewErrorResult(fmt.Errorf("driver error"))) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupBatchDependentStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.UpdateDependents(context.Background(), batchID, oldVersion, newVersion, dependents) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} diff --git a/submitqueue/extension/storage/mysql/batch_store_test.go b/submitqueue/extension/storage/mysql/batch_store_test.go new file mode 100644 index 00000000..1fca075a --- /dev/null +++ b/submitqueue/extension/storage/mysql/batch_store_test.go @@ -0,0 +1,380 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-sql-driver/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/storage" +) + +func setupBatchStoreTest(t *testing.T) (*sql.DB, sqlmock.Sqlmock, storage.BatchStore) { + t.Helper() + + db, mock, err := sqlmock.New() + require.NoError(t, err) + + store := NewBatchStore(db, testMetrics()) + + return db, mock, store +} + +func TestBatchStore_Get(t *testing.T) { + want := entity.Batch{ + ID: "monorepo/batch/1", + Queue: "monorepo", + Contains: []string{"monorepo/1", "monorepo/2"}, + Dependencies: []string{"monorepo/batch/0"}, + Score: 0.9, + State: entity.BatchStateCreated, + Version: 1, + } + containsJSON, err := json.Marshal(want.Contains) + require.NoError(t, err) + dependenciesJSON, err := json.Marshal(want.Dependencies) + require.NoError(t, err) + + tests := []struct { + name string + id string + setup func(mock sqlmock.Sqlmock) + want entity.Batch + wantErr bool + wantErrIs error + }{ + { + name: "found", + id: want.ID, + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"id", "queue", "contains", "dependencies", "score", "state", "version"}). + AddRow(want.ID, want.Queue, containsJSON, dependenciesJSON, want.Score, string(want.State), want.Version) + mock.ExpectQuery("SELECT id, queue, contains, dependencies, score, state, version FROM batch"). + WithArgs(want.ID). + WillReturnRows(rows) + }, + want: want, + }, + { + name: "not found", + id: "missing", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT id, queue, contains, dependencies, score, state, version FROM batch"). + WithArgs("missing"). + WillReturnError(sql.ErrNoRows) + }, + wantErr: true, + wantErrIs: storage.ErrNotFound, + }, + { + name: "query error", + id: "bad", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT id, queue, contains, dependencies, score, state, version FROM batch"). + WithArgs("bad"). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + { + name: "malformed contains JSON", + id: "malformed", + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"id", "queue", "contains", "dependencies", "score", "state", "version"}). + AddRow(want.ID, want.Queue, []byte("not json"), dependenciesJSON, want.Score, string(want.State), want.Version) + mock.ExpectQuery("SELECT id, queue, contains, dependencies, score, state, version FROM batch"). + WithArgs("malformed"). + WillReturnRows(rows) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupBatchStoreTest(t) + defer db.Close() + + tt.setup(mock) + + got, err := store.Get(context.Background(), tt.id) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestBatchStore_Create(t *testing.T) { + batch := entity.Batch{ + ID: "monorepo/batch/1", + Queue: "monorepo", + Contains: []string{"monorepo/1"}, + Dependencies: nil, + Score: 0, + State: entity.BatchStateCreated, + Version: 1, + } + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO batch"). + WithArgs(batch.ID, batch.Queue, sqlmock.AnyArg(), sqlmock.AnyArg(), batch.Score, batch.State, batch.Version). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "duplicate id returns ErrAlreadyExists", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO batch"). + WithArgs(batch.ID, batch.Queue, sqlmock.AnyArg(), sqlmock.AnyArg(), batch.Score, batch.State, batch.Version). + WillReturnError(&mysql.MySQLError{Number: mysqlErrDuplicateEntry}) + }, + wantErr: true, + wantErrIs: storage.ErrAlreadyExists, + }, + { + name: "other exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO batch"). + WithArgs(batch.ID, batch.Queue, sqlmock.AnyArg(), sqlmock.AnyArg(), batch.Score, batch.State, batch.Version). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupBatchStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Create(context.Background(), batch) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestBatchStore_UpdateState(t *testing.T) { + const id = "monorepo/batch/1" + const oldVersion, newVersion = int32(1), int32(2) + const newState = entity.BatchStateMerging + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE batch"). + WithArgs(newState, newVersion, id, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "version mismatch", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE batch"). + WithArgs(newState, newVersion, id, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 0)) + }, + wantErr: true, + wantErrIs: storage.ErrVersionMismatch, + }, + { + name: "exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE batch"). + WithArgs(newState, newVersion, id, oldVersion). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + { + name: "rows affected error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE batch"). + WithArgs(newState, newVersion, id, oldVersion). + WillReturnResult(sqlmock.NewErrorResult(fmt.Errorf("driver error"))) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupBatchStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.UpdateState(context.Background(), id, oldVersion, newVersion, newState) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestBatchStore_UpdateScoreAndState(t *testing.T) { + const id = "monorepo/batch/1" + const oldVersion, newVersion = int32(1), int32(2) + const newState = entity.BatchStateScored + const score = 0.75 + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE batch"). + WithArgs(score, newState, newVersion, id, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "version mismatch", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE batch"). + WithArgs(score, newState, newVersion, id, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 0)) + }, + wantErr: true, + wantErrIs: storage.ErrVersionMismatch, + }, + { + name: "exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE batch"). + WithArgs(score, newState, newVersion, id, oldVersion). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupBatchStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.UpdateScoreAndState(context.Background(), id, oldVersion, newVersion, score, newState) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestBatchStore_GetByQueueAndStates(t *testing.T) { + t.Run("empty states returns nil without querying", func(t *testing.T) { + db, mock, store := setupBatchStoreTest(t) + defer db.Close() + + got, err := store.GetByQueueAndStates(context.Background(), "monorepo", nil) + require.NoError(t, err) + assert.Nil(t, got) + require.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("found", func(t *testing.T) { + db, mock, store := setupBatchStoreTest(t) + defer db.Close() + + batch := entity.Batch{ID: "monorepo/batch/1", Queue: "monorepo", State: entity.BatchStateCreated, Version: 1} + containsJSON, err := json.Marshal(batch.Contains) + require.NoError(t, err) + dependenciesJSON, err := json.Marshal(batch.Dependencies) + require.NoError(t, err) + + rows := sqlmock.NewRows([]string{"id", "queue", "contains", "dependencies", "score", "state", "version"}). + AddRow(batch.ID, batch.Queue, containsJSON, dependenciesJSON, batch.Score, string(batch.State), batch.Version) + mock.ExpectQuery("SELECT id, queue, contains, dependencies, score, state, version FROM batch"). + WithArgs("monorepo", entity.BatchStateCreated, entity.BatchStateMerging). + WillReturnRows(rows) + + got, err := store.GetByQueueAndStates(context.Background(), "monorepo", []entity.BatchState{entity.BatchStateCreated, entity.BatchStateMerging}) + require.NoError(t, err) + assert.Equal(t, []entity.Batch{batch}, got) + require.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("query error", func(t *testing.T) { + db, mock, store := setupBatchStoreTest(t) + defer db.Close() + + mock.ExpectQuery("SELECT id, queue, contains, dependencies, score, state, version FROM batch"). + WithArgs("monorepo", entity.BatchStateCreated). + WillReturnError(fmt.Errorf("connection reset")) + + _, err := store.GetByQueueAndStates(context.Background(), "monorepo", []entity.BatchState{entity.BatchStateCreated}) + require.Error(t, err) + require.NoError(t, mock.ExpectationsWereMet()) + }) +} diff --git a/submitqueue/extension/storage/mysql/build_store_test.go b/submitqueue/extension/storage/mysql/build_store_test.go new file mode 100644 index 00000000..540ae8c0 --- /dev/null +++ b/submitqueue/extension/storage/mysql/build_store_test.go @@ -0,0 +1,247 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mysql + +import ( + "context" + "database/sql" + "fmt" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-sql-driver/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/storage" +) + +func setupBuildStoreTest(t *testing.T) (*sql.DB, sqlmock.Sqlmock, storage.BuildStore) { + t.Helper() + + db, mock, err := sqlmock.New() + require.NoError(t, err) + + store := NewBuildStore(db, testMetrics()) + + return db, mock, store +} + +func TestBuildStore_Get(t *testing.T) { + want := entity.Build{ + ID: "bk-1001", + BatchID: "monorepo/batch/1", + Status: entity.BuildStatusRunning, + SpeculationPathID: "path-1", + } + + tests := []struct { + name string + id string + setup func(mock sqlmock.Sqlmock) + want entity.Build + wantErr bool + wantErrIs error + }{ + { + name: "found", + id: want.ID, + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"id", "batch_id", "status", "speculation_path_id"}). + AddRow(want.ID, want.BatchID, string(want.Status), want.SpeculationPathID) + mock.ExpectQuery("SELECT id, batch_id, status, speculation_path_id"). + WithArgs(want.ID). + WillReturnRows(rows) + }, + want: want, + }, + { + name: "not found", + id: "missing", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT id, batch_id, status, speculation_path_id"). + WithArgs("missing"). + WillReturnError(sql.ErrNoRows) + }, + wantErr: true, + wantErrIs: storage.ErrNotFound, + }, + { + name: "query error", + id: "bad", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT id, batch_id, status, speculation_path_id"). + WithArgs("bad"). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupBuildStoreTest(t) + defer db.Close() + + tt.setup(mock) + + got, err := store.Get(context.Background(), tt.id) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestBuildStore_Create(t *testing.T) { + build := entity.Build{ + ID: "bk-1001", + BatchID: "monorepo/batch/1", + Status: entity.BuildStatusAccepted, + SpeculationPathID: "path-1", + } + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO build"). + WithArgs(build.ID, build.BatchID, build.Status, build.SpeculationPathID). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "duplicate id returns ErrAlreadyExists", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO build"). + WithArgs(build.ID, build.BatchID, build.Status, build.SpeculationPathID). + WillReturnError(&mysql.MySQLError{Number: mysqlErrDuplicateEntry}) + }, + wantErr: true, + wantErrIs: storage.ErrAlreadyExists, + }, + { + name: "other exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO build"). + WithArgs(build.ID, build.BatchID, build.Status, build.SpeculationPathID). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupBuildStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Create(context.Background(), build) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestBuildStore_UpdateStatus(t *testing.T) { + const id = "bk-1001" + const newStatus = entity.BuildStatusSucceeded + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE build"). + WithArgs(newStatus, id). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "not found", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE build"). + WithArgs(newStatus, id). + WillReturnResult(sqlmock.NewResult(0, 0)) + }, + wantErr: true, + wantErrIs: storage.ErrNotFound, + }, + { + name: "exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE build"). + WithArgs(newStatus, id). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + { + name: "rows affected error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE build"). + WithArgs(newStatus, id). + WillReturnResult(sqlmock.NewErrorResult(fmt.Errorf("driver error"))) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupBuildStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.UpdateStatus(context.Background(), id, newStatus) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} diff --git a/submitqueue/extension/storage/mysql/change_store_test.go b/submitqueue/extension/storage/mysql/change_store_test.go new file mode 100644 index 00000000..db80fddc --- /dev/null +++ b/submitqueue/extension/storage/mysql/change_store_test.go @@ -0,0 +1,178 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mysql + +import ( + "context" + "database/sql" + "fmt" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/storage" +) + +func setupChangeStoreTest(t *testing.T) (*sql.DB, sqlmock.Sqlmock, storage.ChangeStore) { + t.Helper() + + db, mock, err := sqlmock.New() + require.NoError(t, err) + + store := NewChangeStore(db, testMetrics()) + + return db, mock, store +} + +func TestChangeStore_Create(t *testing.T) { + record := entity.ChangeRecord{ + URI: "github://github.example.com/uber/submitqueue/pull/123/deadbeef", + RequestID: "monorepo/1", + Queue: "monorepo", + CreatedAt: 1000, + UpdatedAt: 1000, + Version: 1, + } + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT IGNORE INTO `change`"). + WithArgs(record.URI, record.RequestID, record.Queue, sqlmock.AnyArg(), record.CreatedAt, record.UpdatedAt, record.Version). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "redelivery is a no-op via INSERT IGNORE", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT IGNORE INTO `change`"). + WithArgs(record.URI, record.RequestID, record.Queue, sqlmock.AnyArg(), record.CreatedAt, record.UpdatedAt, record.Version). + WillReturnResult(sqlmock.NewResult(0, 0)) + }, + }, + { + name: "exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT IGNORE INTO `change`"). + WithArgs(record.URI, record.RequestID, record.Queue, sqlmock.AnyArg(), record.CreatedAt, record.UpdatedAt, record.Version). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupChangeStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Create(context.Background(), record) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestChangeStore_GetByURI(t *testing.T) { + record := entity.ChangeRecord{ + URI: "github://github.example.com/uber/submitqueue/pull/123/deadbeef", + RequestID: "monorepo/1", + Queue: "monorepo", + CreatedAt: 1000, + UpdatedAt: 1000, + Version: 1, + } + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + want []entity.ChangeRecord + wantErr bool + }{ + { + name: "found", + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"uri", "request_id", "queue", "details", "created_at", "updated_at", "version"}). + AddRow(record.URI, record.RequestID, record.Queue, []byte(`{"author":{},"changed_files":null}`), record.CreatedAt, record.UpdatedAt, record.Version) + mock.ExpectQuery("SELECT uri, request_id, queue, details, created_at, updated_at, version FROM `change`"). + WithArgs(record.Queue, record.URI). + WillReturnRows(rows) + }, + want: []entity.ChangeRecord{record}, + }, + { + name: "no rows returns empty slice", + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"uri", "request_id", "queue", "details", "created_at", "updated_at", "version"}) + mock.ExpectQuery("SELECT uri, request_id, queue, details, created_at, updated_at, version FROM `change`"). + WithArgs(record.Queue, record.URI). + WillReturnRows(rows) + }, + want: nil, + }, + { + name: "query error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT uri, request_id, queue, details, created_at, updated_at, version FROM `change`"). + WithArgs(record.Queue, record.URI). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + { + name: "malformed details JSON", + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"uri", "request_id", "queue", "details", "created_at", "updated_at", "version"}). + AddRow(record.URI, record.RequestID, record.Queue, []byte("not json"), record.CreatedAt, record.UpdatedAt, record.Version) + mock.ExpectQuery("SELECT uri, request_id, queue, details, created_at, updated_at, version FROM `change`"). + WithArgs(record.Queue, record.URI). + WillReturnRows(rows) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupChangeStoreTest(t) + defer db.Close() + + tt.setup(mock) + + got, err := store.GetByURI(context.Background(), record.Queue, record.URI) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} diff --git a/submitqueue/extension/storage/mysql/request_log_store_test.go b/submitqueue/extension/storage/mysql/request_log_store_test.go new file mode 100644 index 00000000..577a9ab9 --- /dev/null +++ b/submitqueue/extension/storage/mysql/request_log_store_test.go @@ -0,0 +1,168 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mysql + +import ( + "context" + "database/sql" + "fmt" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/storage" +) + +func setupRequestLogStoreTest(t *testing.T) (*sql.DB, sqlmock.Sqlmock, storage.RequestLogStore) { + t.Helper() + + db, mock, err := sqlmock.New() + require.NoError(t, err) + + store := NewRequestLogStore(db, testMetrics()) + + return db, mock, store +} + +func TestRequestLogStore_Insert(t *testing.T) { + log := entity.RequestLog{ + RequestID: "monorepo/1", + TimestampMs: 1000, + Status: entity.RequestStatusStarted, + RequestVersion: 1, + LastError: "", + Metadata: map[string]string{"key": "value"}, + } + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO request_log"). + WithArgs(log.RequestID, log.TimestampMs, sqlmock.AnyArg(), log.Status, log.RequestVersion, log.LastError, sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO request_log"). + WithArgs(log.RequestID, log.TimestampMs, sqlmock.AnyArg(), log.Status, log.RequestVersion, log.LastError, sqlmock.AnyArg()). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupRequestLogStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Insert(context.Background(), log) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestRequestLogStore_List(t *testing.T) { + log := entity.RequestLog{ + RequestID: "monorepo/1", + TimestampMs: 1000, + Status: entity.RequestStatusStarted, + RequestVersion: 1, + LastError: "", + Metadata: map[string]string{}, + } + + tests := []struct { + name string + requestID string + setup func(mock sqlmock.Sqlmock) + want []entity.RequestLog + wantErr bool + wantErrIs error + }{ + { + name: "found", + requestID: log.RequestID, + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"request_id", "timestamp_ms", "status", "request_version", "last_error", "metadata"}). + AddRow(log.RequestID, log.TimestampMs, string(log.Status), log.RequestVersion, log.LastError, []byte(`{}`)) + mock.ExpectQuery("SELECT request_id, timestamp_ms, status, request_version, last_error, metadata FROM request_log"). + WithArgs(log.RequestID). + WillReturnRows(rows) + }, + want: []entity.RequestLog{log}, + }, + { + name: "no rows returns ErrNotFound", + requestID: "missing", + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"request_id", "timestamp_ms", "status", "request_version", "last_error", "metadata"}) + mock.ExpectQuery("SELECT request_id, timestamp_ms, status, request_version, last_error, metadata FROM request_log"). + WithArgs("missing"). + WillReturnRows(rows) + }, + wantErr: true, + wantErrIs: storage.ErrNotFound, + }, + { + name: "query error", + requestID: "bad", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT request_id, timestamp_ms, status, request_version, last_error, metadata FROM request_log"). + WithArgs("bad"). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupRequestLogStoreTest(t) + defer db.Close() + + tt.setup(mock) + + got, err := store.List(context.Background(), tt.requestID) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} diff --git a/submitqueue/extension/storage/mysql/request_queue_summary_store_test.go b/submitqueue/extension/storage/mysql/request_queue_summary_store_test.go new file mode 100644 index 00000000..1f04ba1e --- /dev/null +++ b/submitqueue/extension/storage/mysql/request_queue_summary_store_test.go @@ -0,0 +1,350 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mysql + +import ( + "context" + "database/sql" + "fmt" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-sql-driver/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/storage" +) + +func setupRequestQueueSummaryStoreTest(t *testing.T) (*sql.DB, sqlmock.Sqlmock, storage.RequestQueueSummaryStore) { + t.Helper() + + db, mock, err := sqlmock.New() + require.NoError(t, err) + + store := NewRequestQueueSummaryStore(db, testMetrics()) + + return db, mock, store +} + +func TestRequestQueueSummaryStore_Create(t *testing.T) { + summary := entity.RequestQueueSummary{ + RequestID: "monorepo/1", + Queue: "monorepo", + ChangeURIs: []string{"github://github.example.com/uber/submitqueue/pull/123/deadbeef"}, + ReceivedAtMs: 1000, + Status: entity.RequestStatusStarted, + Version: 1, + LastError: "", + Metadata: map[string]string{"key": "value"}, + } + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO request_summary_by_queue"). + WithArgs(summary.Queue, summary.ReceivedAtMs, summary.RequestID, sqlmock.AnyArg(), + summary.Status, summary.Version, summary.LastError, sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "duplicate key returns ErrAlreadyExists", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO request_summary_by_queue"). + WithArgs(summary.Queue, summary.ReceivedAtMs, summary.RequestID, sqlmock.AnyArg(), + summary.Status, summary.Version, summary.LastError, sqlmock.AnyArg()). + WillReturnError(&mysql.MySQLError{Number: mysqlErrDuplicateEntry}) + }, + wantErr: true, + wantErrIs: storage.ErrAlreadyExists, + }, + { + name: "other exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO request_summary_by_queue"). + WithArgs(summary.Queue, summary.ReceivedAtMs, summary.RequestID, sqlmock.AnyArg(), + summary.Status, summary.Version, summary.LastError, sqlmock.AnyArg()). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupRequestQueueSummaryStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Create(context.Background(), summary) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestRequestQueueSummaryStore_Get(t *testing.T) { + want := entity.RequestQueueSummary{ + RequestID: "monorepo/1", + Queue: "monorepo", + ChangeURIs: []string{"github://github.example.com/uber/submitqueue/pull/123/deadbeef"}, + ReceivedAtMs: 1000, + Status: entity.RequestStatusStarted, + Version: 1, + LastError: "", + Metadata: map[string]string{"key": "value"}, + } + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + want entity.RequestQueueSummary + wantErr bool + wantErrIs error + }{ + { + name: "found", + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"queue", "received_at_ms", "request_id", "change_uris", "status", "version", "last_error", "metadata"}). + AddRow(want.Queue, want.ReceivedAtMs, want.RequestID, []byte(`["github://github.example.com/uber/submitqueue/pull/123/deadbeef"]`), + string(want.Status), want.Version, want.LastError, []byte(`{"key":"value"}`)) + mock.ExpectQuery("SELECT queue, received_at_ms, request_id, change_uris, status"). + WithArgs(want.Queue, want.ReceivedAtMs, want.RequestID). + WillReturnRows(rows) + }, + want: want, + }, + { + name: "not found", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT queue, received_at_ms, request_id, change_uris, status"). + WithArgs("monorepo", int64(1000), "missing"). + WillReturnError(sql.ErrNoRows) + }, + wantErr: true, + wantErrIs: storage.ErrNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupRequestQueueSummaryStoreTest(t) + defer db.Close() + + tt.setup(mock) + + requestID := want.RequestID + if tt.name == "not found" { + requestID = "missing" + } + + got, err := store.Get(context.Background(), "monorepo", 1000, requestID) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestRequestQueueSummaryStore_Update(t *testing.T) { + summary := entity.RequestQueueSummary{ + RequestID: "monorepo/1", + Queue: "monorepo", + ReceivedAtMs: 1000, + Status: entity.RequestStatusValidated, + LastError: "", + } + const oldVersion, newVersion = int32(1), int32(2) + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE request_summary_by_queue"). + WithArgs(summary.Status, newVersion, summary.LastError, sqlmock.AnyArg(), + summary.Queue, summary.ReceivedAtMs, summary.RequestID, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "version mismatch", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE request_summary_by_queue"). + WithArgs(summary.Status, newVersion, summary.LastError, sqlmock.AnyArg(), + summary.Queue, summary.ReceivedAtMs, summary.RequestID, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 0)) + }, + wantErr: true, + wantErrIs: storage.ErrVersionMismatch, + }, + { + name: "exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE request_summary_by_queue"). + WithArgs(summary.Status, newVersion, summary.LastError, sqlmock.AnyArg(), + summary.Queue, summary.ReceivedAtMs, summary.RequestID, oldVersion). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupRequestQueueSummaryStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Update(context.Background(), summary, oldVersion, newVersion) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestRequestQueueSummaryStore_List(t *testing.T) { + summary := entity.RequestQueueSummary{ + RequestID: "monorepo/1", + Queue: "monorepo", + ReceivedAtMs: 1000, + Status: entity.RequestStatusStarted, + Version: 1, + } + + tests := []struct { + name string + query storage.RequestQueueSummaryQuery + setup func(mock sqlmock.Sqlmock) + want []entity.RequestQueueSummary + wantErr bool + }{ + { + name: "without cursor", + query: storage.RequestQueueSummaryQuery{ + Queue: "monorepo", + ReceivedAtOrAfterMs: 0, + ReceivedBeforeMs: 2000, + Limit: 10, + }, + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"queue", "received_at_ms", "request_id", "change_uris", "status", "version", "last_error", "metadata"}). + AddRow(summary.Queue, summary.ReceivedAtMs, summary.RequestID, []byte(`[]`), string(summary.Status), summary.Version, "", []byte(`{}`)) + mock.ExpectQuery("SELECT queue, received_at_ms, request_id, change_uris, status"). + WithArgs("monorepo", int64(0), int64(2000), 10). + WillReturnRows(rows) + }, + want: []entity.RequestQueueSummary{{ + RequestID: summary.RequestID, + Queue: summary.Queue, + ChangeURIs: []string{}, + ReceivedAtMs: summary.ReceivedAtMs, + Status: summary.Status, + Version: summary.Version, + LastError: "", + Metadata: map[string]string{}, + }}, + }, + { + name: "with cursor", + query: storage.RequestQueueSummaryQuery{ + Queue: "monorepo", + ReceivedAtOrAfterMs: 0, + ReceivedBeforeMs: 2000, + Limit: 10, + HasCursor: true, + Cursor: storage.RequestQueueSummaryCursor{ + ReceivedAtMs: 1500, + RequestID: "monorepo/2", + }, + }, + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"queue", "received_at_ms", "request_id", "change_uris", "status", "version", "last_error", "metadata"}) + mock.ExpectQuery("SELECT queue, received_at_ms, request_id, change_uris, status"). + WithArgs("monorepo", int64(0), int64(2000), int64(1500), int64(1500), "monorepo/2", 10). + WillReturnRows(rows) + }, + want: []entity.RequestQueueSummary{}, + }, + { + name: "query error", + query: storage.RequestQueueSummaryQuery{ + Queue: "monorepo", + ReceivedAtOrAfterMs: 0, + ReceivedBeforeMs: 2000, + Limit: 10, + }, + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT queue, received_at_ms, request_id, change_uris, status"). + WithArgs("monorepo", int64(0), int64(2000), 10). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupRequestQueueSummaryStoreTest(t) + defer db.Close() + + tt.setup(mock) + + got, err := store.List(context.Background(), tt.query) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} diff --git a/submitqueue/extension/storage/mysql/request_store_test.go b/submitqueue/extension/storage/mysql/request_store_test.go new file mode 100644 index 00000000..c13668b8 --- /dev/null +++ b/submitqueue/extension/storage/mysql/request_store_test.go @@ -0,0 +1,269 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-sql-driver/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/uber/submitqueue/platform/base/change" + "github.com/uber/submitqueue/platform/base/mergestrategy" + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/storage" +) + +func setupRequestStoreTest(t *testing.T) (*sql.DB, sqlmock.Sqlmock, storage.RequestStore) { + t.Helper() + + db, mock, err := sqlmock.New() + require.NoError(t, err) + + store := NewRequestStore(db, testMetrics()) + + return db, mock, store +} + +func TestRequestStore_Get(t *testing.T) { + want := entity.Request{ + ID: "monorepo/1", + Queue: "monorepo", + Change: change.Change{URIs: []string{"github://github.example.com/uber/submitqueue/pull/123/deadbeef"}}, + LandStrategy: mergestrategy.MergeStrategyRebase, + State: entity.RequestStateStarted, + Version: 1, + } + changeURIsJSON, err := json.Marshal(want.Change.URIs) + require.NoError(t, err) + + tests := []struct { + name string + id string + setup func(mock sqlmock.Sqlmock) + want entity.Request + wantErr bool + wantErrIs error + }{ + { + name: "found", + id: want.ID, + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"id", "queue", "change_uri", "land_strategy", "state", "version"}). + AddRow(want.ID, want.Queue, changeURIsJSON, string(want.LandStrategy), string(want.State), want.Version) + mock.ExpectQuery("SELECT id, queue, change_uri, land_strategy, state, version FROM request"). + WithArgs(want.ID). + WillReturnRows(rows) + }, + want: want, + }, + { + name: "not found", + id: "missing", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT id, queue, change_uri, land_strategy, state, version FROM request"). + WithArgs("missing"). + WillReturnError(sql.ErrNoRows) + }, + wantErr: true, + wantErrIs: storage.ErrNotFound, + }, + { + name: "query error", + id: "bad", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT id, queue, change_uri, land_strategy, state, version FROM request"). + WithArgs("bad"). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + { + name: "malformed change URIs JSON", + id: "malformed", + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"id", "queue", "change_uri", "land_strategy", "state", "version"}). + AddRow(want.ID, want.Queue, []byte("not json"), string(want.LandStrategy), string(want.State), want.Version) + mock.ExpectQuery("SELECT id, queue, change_uri, land_strategy, state, version FROM request"). + WithArgs("malformed"). + WillReturnRows(rows) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupRequestStoreTest(t) + defer db.Close() + + tt.setup(mock) + + got, err := store.Get(context.Background(), tt.id) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestRequestStore_Create(t *testing.T) { + request := entity.Request{ + ID: "monorepo/1", + Queue: "monorepo", + Change: change.Change{URIs: []string{"github://github.example.com/uber/submitqueue/pull/123/deadbeef"}}, + LandStrategy: mergestrategy.MergeStrategyRebase, + State: entity.RequestStateStarted, + Version: 1, + } + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO request"). + WithArgs(request.ID, request.Queue, sqlmock.AnyArg(), request.LandStrategy, request.State, request.Version). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "duplicate id returns ErrAlreadyExists", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO request"). + WithArgs(request.ID, request.Queue, sqlmock.AnyArg(), request.LandStrategy, request.State, request.Version). + WillReturnError(&mysql.MySQLError{Number: mysqlErrDuplicateEntry}) + }, + wantErr: true, + wantErrIs: storage.ErrAlreadyExists, + }, + { + name: "other exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO request"). + WithArgs(request.ID, request.Queue, sqlmock.AnyArg(), request.LandStrategy, request.State, request.Version). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupRequestStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Create(context.Background(), request) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestRequestStore_UpdateState(t *testing.T) { + const id = "monorepo/1" + const oldVersion, newVersion = int32(1), int32(2) + const newState = entity.RequestStateValidated + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE request"). + WithArgs(newState, newVersion, id, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "version mismatch", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE request"). + WithArgs(newState, newVersion, id, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 0)) + }, + wantErr: true, + wantErrIs: storage.ErrVersionMismatch, + }, + { + name: "exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE request"). + WithArgs(newState, newVersion, id, oldVersion). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + { + name: "rows affected error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE request"). + WithArgs(newState, newVersion, id, oldVersion). + WillReturnResult(sqlmock.NewErrorResult(fmt.Errorf("driver error"))) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupRequestStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.UpdateState(context.Background(), id, oldVersion, newVersion, newState) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} diff --git a/submitqueue/extension/storage/mysql/request_summary_store_test.go b/submitqueue/extension/storage/mysql/request_summary_store_test.go new file mode 100644 index 00000000..9399794b --- /dev/null +++ b/submitqueue/extension/storage/mysql/request_summary_store_test.go @@ -0,0 +1,278 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mysql + +import ( + "context" + "database/sql" + "fmt" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-sql-driver/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/storage" +) + +func setupRequestSummaryStoreTest(t *testing.T) (*sql.DB, sqlmock.Sqlmock, storage.RequestSummaryStore) { + t.Helper() + + db, mock, err := sqlmock.New() + require.NoError(t, err) + + store := NewRequestSummaryStore(db, testMetrics()) + + return db, mock, store +} + +func TestRequestSummaryStore_Create(t *testing.T) { + summary := entity.RequestSummary{ + RequestID: "monorepo/1", + Queue: "monorepo", + ChangeURIs: []string{"github://github.example.com/uber/submitqueue/pull/123/deadbeef"}, + ReceivedAtMs: 1000, + Status: entity.RequestStatusStarted, + RequestVersion: 1, + StatusTimestampMs: 1000, + Version: 1, + LastError: "", + Metadata: map[string]string{"key": "value"}, + } + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO request_summary"). + WithArgs(summary.RequestID, summary.Queue, sqlmock.AnyArg(), summary.ReceivedAtMs, summary.Status, + summary.RequestVersion, summary.StatusTimestampMs, summary.Version, summary.LastError, sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "duplicate request id returns ErrAlreadyExists", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO request_summary"). + WithArgs(summary.RequestID, summary.Queue, sqlmock.AnyArg(), summary.ReceivedAtMs, summary.Status, + summary.RequestVersion, summary.StatusTimestampMs, summary.Version, summary.LastError, sqlmock.AnyArg()). + WillReturnError(&mysql.MySQLError{Number: mysqlErrDuplicateEntry}) + }, + wantErr: true, + wantErrIs: storage.ErrAlreadyExists, + }, + { + name: "other exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO request_summary"). + WithArgs(summary.RequestID, summary.Queue, sqlmock.AnyArg(), summary.ReceivedAtMs, summary.Status, + summary.RequestVersion, summary.StatusTimestampMs, summary.Version, summary.LastError, sqlmock.AnyArg()). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupRequestSummaryStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Create(context.Background(), summary) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestRequestSummaryStore_Get(t *testing.T) { + want := entity.RequestSummary{ + RequestID: "monorepo/1", + Queue: "monorepo", + ChangeURIs: []string{"github://github.example.com/uber/submitqueue/pull/123/deadbeef"}, + ReceivedAtMs: 1000, + Status: entity.RequestStatusStarted, + RequestVersion: 1, + StatusTimestampMs: 1000, + Version: 1, + LastError: "", + Metadata: map[string]string{"key": "value"}, + } + + tests := []struct { + name string + requestID string + setup func(mock sqlmock.Sqlmock) + want entity.RequestSummary + wantErr bool + wantErrIs error + }{ + { + name: "found", + requestID: want.RequestID, + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{ + "request_id", "queue", "change_uris", "received_at_ms", "status", + "request_version", "status_timestamp_ms", "version", "last_error", "metadata", + }).AddRow(want.RequestID, want.Queue, []byte(`["github://github.example.com/uber/submitqueue/pull/123/deadbeef"]`), + want.ReceivedAtMs, string(want.Status), want.RequestVersion, want.StatusTimestampMs, + want.Version, want.LastError, []byte(`{"key":"value"}`)) + mock.ExpectQuery("SELECT request_id, queue, change_uris, received_at_ms, status"). + WithArgs(want.RequestID). + WillReturnRows(rows) + }, + want: want, + }, + { + name: "not found", + requestID: "missing", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT request_id, queue, change_uris, received_at_ms, status"). + WithArgs("missing"). + WillReturnError(sql.ErrNoRows) + }, + wantErr: true, + wantErrIs: storage.ErrNotFound, + }, + { + name: "query error", + requestID: "bad", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT request_id, queue, change_uris, received_at_ms, status"). + WithArgs("bad"). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupRequestSummaryStoreTest(t) + defer db.Close() + + tt.setup(mock) + + got, err := store.Get(context.Background(), tt.requestID) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestRequestSummaryStore_Update(t *testing.T) { + summary := entity.RequestSummary{ + RequestID: "monorepo/1", + Queue: "monorepo", + ReceivedAtMs: 1000, + Status: entity.RequestStatusValidated, + RequestVersion: 2, + StatusTimestampMs: 2000, + LastError: "", + } + const oldVersion, newVersion = int32(1), int32(2) + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE request_summary"). + WithArgs(summary.Status, summary.RequestVersion, summary.StatusTimestampMs, + newVersion, summary.LastError, sqlmock.AnyArg(), summary.RequestID, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "version mismatch", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE request_summary"). + WithArgs(summary.Status, summary.RequestVersion, summary.StatusTimestampMs, + newVersion, summary.LastError, sqlmock.AnyArg(), summary.RequestID, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 0)) + }, + wantErr: true, + wantErrIs: storage.ErrVersionMismatch, + }, + { + name: "exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE request_summary"). + WithArgs(summary.Status, summary.RequestVersion, summary.StatusTimestampMs, + newVersion, summary.LastError, sqlmock.AnyArg(), summary.RequestID, oldVersion). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupRequestSummaryStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Update(context.Background(), summary, oldVersion, newVersion) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestNormalizeChangeURIs(t *testing.T) { + assert.Equal(t, []string{}, normalizeChangeURIs(nil)) + assert.Equal(t, []string{"a"}, normalizeChangeURIs([]string{"a"})) +} + +func TestNormalizeMetadata(t *testing.T) { + assert.Equal(t, map[string]string{}, normalizeMetadata(nil)) + assert.Equal(t, map[string]string{"a": "b"}, normalizeMetadata(map[string]string{"a": "b"})) +} diff --git a/submitqueue/extension/storage/mysql/request_uri_store_test.go b/submitqueue/extension/storage/mysql/request_uri_store_test.go new file mode 100644 index 00000000..6e01e71b --- /dev/null +++ b/submitqueue/extension/storage/mysql/request_uri_store_test.go @@ -0,0 +1,168 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mysql + +import ( + "context" + "database/sql" + "fmt" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-sql-driver/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/storage" +) + +func setupRequestURIStoreTest(t *testing.T) (*sql.DB, sqlmock.Sqlmock, storage.RequestURIStore) { + t.Helper() + + db, mock, err := sqlmock.New() + require.NoError(t, err) + + store := NewRequestURIStore(db, testMetrics()) + + return db, mock, store +} + +func TestRequestURIStore_Create(t *testing.T) { + mapping := entity.RequestURI{ + ChangeURI: "github://github.example.com/uber/submitqueue/pull/123/deadbeef", + ReceivedAtMs: 1000, + RequestID: "monorepo/1", + } + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO change_uri_request_mapping"). + WithArgs(mapping.ChangeURI, mapping.ReceivedAtMs, mapping.RequestID). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "duplicate mapping returns ErrAlreadyExists", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO change_uri_request_mapping"). + WithArgs(mapping.ChangeURI, mapping.ReceivedAtMs, mapping.RequestID). + WillReturnError(&mysql.MySQLError{Number: mysqlErrDuplicateEntry}) + }, + wantErr: true, + wantErrIs: storage.ErrAlreadyExists, + }, + { + name: "other exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO change_uri_request_mapping"). + WithArgs(mapping.ChangeURI, mapping.ReceivedAtMs, mapping.RequestID). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupRequestURIStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Create(context.Background(), mapping) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestRequestURIStore_ListByURI(t *testing.T) { + mapping := entity.RequestURI{ + ChangeURI: "github://github.example.com/uber/submitqueue/pull/123/deadbeef", + ReceivedAtMs: 1000, + RequestID: "monorepo/1", + } + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + want []entity.RequestURI + wantErr bool + }{ + { + name: "found", + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"change_uri", "received_at_ms", "request_id"}). + AddRow(mapping.ChangeURI, mapping.ReceivedAtMs, mapping.RequestID) + mock.ExpectQuery("SELECT change_uri, received_at_ms, request_id"). + WithArgs(mapping.ChangeURI, 10). + WillReturnRows(rows) + }, + want: []entity.RequestURI{mapping}, + }, + { + name: "no rows returns empty slice", + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"change_uri", "received_at_ms", "request_id"}) + mock.ExpectQuery("SELECT change_uri, received_at_ms, request_id"). + WithArgs(mapping.ChangeURI, 10). + WillReturnRows(rows) + }, + want: []entity.RequestURI{}, + }, + { + name: "query error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT change_uri, received_at_ms, request_id"). + WithArgs(mapping.ChangeURI, 10). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupRequestURIStoreTest(t) + defer db.Close() + + tt.setup(mock) + + got, err := store.ListByURI(context.Background(), mapping.ChangeURI, 10) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} diff --git a/submitqueue/extension/storage/mysql/speculation_path_build_store_test.go b/submitqueue/extension/storage/mysql/speculation_path_build_store_test.go new file mode 100644 index 00000000..f9911250 --- /dev/null +++ b/submitqueue/extension/storage/mysql/speculation_path_build_store_test.go @@ -0,0 +1,180 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mysql + +import ( + "context" + "database/sql" + "fmt" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-sql-driver/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/storage" +) + +func setupSpeculationPathBuildStoreTest(t *testing.T) (*sql.DB, sqlmock.Sqlmock, storage.SpeculationPathBuildStore) { + t.Helper() + + db, mock, err := sqlmock.New() + require.NoError(t, err) + + store := NewSpeculationPathBuildStore(db, testMetrics()) + + return db, mock, store +} + +func TestSpeculationPathBuildStore_Create(t *testing.T) { + pathBuild := entity.SpeculationPathBuild{ + PathID: "path-1", + BuildID: "bk-1001", + BatchID: "monorepo/batch/1", + CreatedAt: 1000, + Version: 1, + } + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO speculation_path_build"). + WithArgs(pathBuild.PathID, pathBuild.BuildID, pathBuild.BatchID, pathBuild.Version, pathBuild.CreatedAt). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "duplicate path id returns ErrAlreadyExists", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO speculation_path_build"). + WithArgs(pathBuild.PathID, pathBuild.BuildID, pathBuild.BatchID, pathBuild.Version, pathBuild.CreatedAt). + WillReturnError(&mysql.MySQLError{Number: mysqlErrDuplicateEntry}) + }, + wantErr: true, + wantErrIs: storage.ErrAlreadyExists, + }, + { + name: "other exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO speculation_path_build"). + WithArgs(pathBuild.PathID, pathBuild.BuildID, pathBuild.BatchID, pathBuild.Version, pathBuild.CreatedAt). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupSpeculationPathBuildStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Create(context.Background(), pathBuild) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestSpeculationPathBuildStore_Get(t *testing.T) { + want := entity.SpeculationPathBuild{ + PathID: "path-1", + BuildID: "bk-1001", + BatchID: "monorepo/batch/1", + CreatedAt: 1000, + Version: 1, + } + + tests := []struct { + name string + pathID string + setup func(mock sqlmock.Sqlmock) + want entity.SpeculationPathBuild + wantErr bool + wantErrIs error + }{ + { + name: "found", + pathID: want.PathID, + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"path_id", "build_id", "batch_id", "version", "created_at"}). + AddRow(want.PathID, want.BuildID, want.BatchID, want.Version, want.CreatedAt) + mock.ExpectQuery("SELECT path_id, build_id, batch_id, version, created_at"). + WithArgs(want.PathID). + WillReturnRows(rows) + }, + want: want, + }, + { + name: "not found", + pathID: "missing", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT path_id, build_id, batch_id, version, created_at"). + WithArgs("missing"). + WillReturnError(sql.ErrNoRows) + }, + wantErr: true, + wantErrIs: storage.ErrNotFound, + }, + { + name: "query error", + pathID: "bad", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT path_id, build_id, batch_id, version, created_at"). + WithArgs("bad"). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupSpeculationPathBuildStoreTest(t) + defer db.Close() + + tt.setup(mock) + + got, err := store.Get(context.Background(), tt.pathID) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} diff --git a/submitqueue/extension/storage/mysql/speculation_tree_store_test.go b/submitqueue/extension/storage/mysql/speculation_tree_store_test.go new file mode 100644 index 00000000..23c88610 --- /dev/null +++ b/submitqueue/extension/storage/mysql/speculation_tree_store_test.go @@ -0,0 +1,276 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-sql-driver/mysql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/storage" +) + +func setupSpeculationTreeStoreTest(t *testing.T) (*sql.DB, sqlmock.Sqlmock, storage.SpeculationTreeStore) { + t.Helper() + + db, mock, err := sqlmock.New() + require.NoError(t, err) + + store := NewSpeculationTreeStore(db, testMetrics()) + + return db, mock, store +} + +func TestSpeculationTreeStore_Get(t *testing.T) { + want := entity.SpeculationTree{ + BatchID: "monorepo/batch/1", + Paths: []entity.SpeculationPathInfo{ + {ID: "path-1", Path: entity.SpeculationPath{Head: "monorepo/batch/1"}, Status: entity.SpeculationPathStatusCandidate}, + }, + Version: 1, + } + pathsJSON, err := json.Marshal(want.Paths) + require.NoError(t, err) + + tests := []struct { + name string + batchID string + setup func(mock sqlmock.Sqlmock) + want entity.SpeculationTree + wantErr bool + wantErrIs error + }{ + { + name: "found", + batchID: want.BatchID, + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"batch_id", "paths", "version"}). + AddRow(want.BatchID, pathsJSON, want.Version) + mock.ExpectQuery("SELECT batch_id, paths, version FROM speculation_tree"). + WithArgs(want.BatchID). + WillReturnRows(rows) + }, + want: want, + }, + { + name: "not found", + batchID: "missing", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT batch_id, paths, version FROM speculation_tree"). + WithArgs("missing"). + WillReturnError(sql.ErrNoRows) + }, + wantErr: true, + wantErrIs: storage.ErrNotFound, + }, + { + name: "query error", + batchID: "bad", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery("SELECT batch_id, paths, version FROM speculation_tree"). + WithArgs("bad"). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + { + name: "malformed paths JSON", + batchID: "malformed", + setup: func(mock sqlmock.Sqlmock) { + rows := sqlmock.NewRows([]string{"batch_id", "paths", "version"}). + AddRow(want.BatchID, []byte("not json"), want.Version) + mock.ExpectQuery("SELECT batch_id, paths, version FROM speculation_tree"). + WithArgs("malformed"). + WillReturnRows(rows) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupSpeculationTreeStoreTest(t) + defer db.Close() + + tt.setup(mock) + + got, err := store.Get(context.Background(), tt.batchID) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestSpeculationTreeStore_Create(t *testing.T) { + tree := entity.SpeculationTree{ + BatchID: "monorepo/batch/1", + Paths: []entity.SpeculationPathInfo{ + {ID: "path-1", Path: entity.SpeculationPath{Head: "monorepo/batch/1"}, Status: entity.SpeculationPathStatusCandidate}, + }, + Version: 1, + } + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO speculation_tree"). + WithArgs(tree.BatchID, sqlmock.AnyArg(), tree.Version). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "duplicate batch id returns ErrAlreadyExists", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO speculation_tree"). + WithArgs(tree.BatchID, sqlmock.AnyArg(), tree.Version). + WillReturnError(&mysql.MySQLError{Number: mysqlErrDuplicateEntry}) + }, + wantErr: true, + wantErrIs: storage.ErrAlreadyExists, + }, + { + name: "other exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("INSERT INTO speculation_tree"). + WithArgs(tree.BatchID, sqlmock.AnyArg(), tree.Version). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupSpeculationTreeStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Create(context.Background(), tree) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestSpeculationTreeStore_Update(t *testing.T) { + const batchID = "monorepo/batch/1" + const oldVersion, newVersion = int32(1), int32(2) + paths := []entity.SpeculationPathInfo{ + {ID: "path-1", Path: entity.SpeculationPath{Head: batchID}, Status: entity.SpeculationPathStatusCandidate}, + } + + tests := []struct { + name string + setup func(mock sqlmock.Sqlmock) + wantErr bool + wantErrIs error + }{ + { + name: "success", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE speculation_tree"). + WithArgs(sqlmock.AnyArg(), newVersion, batchID, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 1)) + }, + }, + { + name: "version mismatch", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE speculation_tree"). + WithArgs(sqlmock.AnyArg(), newVersion, batchID, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 0)) + }, + wantErr: true, + wantErrIs: storage.ErrVersionMismatch, + }, + { + name: "exec error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE speculation_tree"). + WithArgs(sqlmock.AnyArg(), newVersion, batchID, oldVersion). + WillReturnError(fmt.Errorf("connection reset")) + }, + wantErr: true, + }, + { + name: "rows affected error", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE speculation_tree"). + WithArgs(sqlmock.AnyArg(), newVersion, batchID, oldVersion). + WillReturnResult(sqlmock.NewErrorResult(fmt.Errorf("driver error"))) + }, + wantErr: true, + }, + { + name: "unexpected rows affected", + setup: func(mock sqlmock.Sqlmock) { + mock.ExpectExec("UPDATE speculation_tree"). + WithArgs(sqlmock.AnyArg(), newVersion, batchID, oldVersion). + WillReturnResult(sqlmock.NewResult(0, 2)) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, store := setupSpeculationTreeStoreTest(t) + defer db.Close() + + tt.setup(mock) + + err := store.Update(context.Background(), batchID, oldVersion, newVersion, paths) + if tt.wantErr { + require.Error(t, err) + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + } else { + require.NoError(t, err) + } + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} diff --git a/submitqueue/extension/storage/mysql/storage_test.go b/submitqueue/extension/storage/mysql/storage_test.go new file mode 100644 index 00000000..bf0cb68b --- /dev/null +++ b/submitqueue/extension/storage/mysql/storage_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mysql + +import ( + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/uber-go/tally" +) + +// testMetrics returns a test metrics scope for use in tests. +func testMetrics() tally.Scope { + return tally.NoopScope +} + +func TestNewStorage(t *testing.T) { + db, _, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + + s, err := NewStorage(db, testMetrics()) + require.NoError(t, err) + + assert.NotNil(t, s.GetRequestStore()) + assert.NotNil(t, s.GetChangeStore()) + assert.NotNil(t, s.GetBatchStore()) + assert.NotNil(t, s.GetBatchDependentStore()) + assert.NotNil(t, s.GetBuildStore()) + assert.NotNil(t, s.GetSpeculationPathBuildStore()) + assert.NotNil(t, s.GetSpeculationTreeStore()) + assert.NotNil(t, s.GetRequestLogStore()) + assert.NotNil(t, s.GetRequestSummaryStore()) + assert.NotNil(t, s.GetRequestQueueSummaryStore()) + assert.NotNil(t, s.GetRequestURIStore()) +} + +func TestMysqlStorage_Close(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + + mock.ExpectClose() + + s, err := NewStorage(db, testMetrics()) + require.NoError(t, err) + + require.NoError(t, s.Close()) + require.NoError(t, mock.ExpectationsWereMet()) +}