Files
grafana/pkg/services/store/entity/sqlstash/utils_test.go
2024-07-18 08:03:18 -07:00

526 lines
13 KiB
Go

package sqlstash
import (
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"io"
"regexp"
"strings"
"testing"
"text/template"
sqlmock "github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/services/store/entity/db"
"github.com/grafana/grafana/pkg/services/store/entity/db/dbimpl"
"github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate"
sqltemplateMocks "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate/mocks"
"github.com/grafana/grafana/pkg/util/testutil"
)
// newMockDBNopSQL returns a db.DB and a sqlmock.Sqlmock that doesn't validates
// SQL. This is only meant to be used to test wrapping utilities exec, query and
// queryRow, where the actual SQL is not relevant to the unit tests, but rather
// how the possible derived error conditions handled.
func newMockDBNopSQL(t *testing.T) (db.DB, sqlmock.Sqlmock) {
t.Helper()
db, mock, err := sqlmock.New(
sqlmock.MonitorPingsOption(true),
sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(
func(expectedSQL, actualSQL string) error {
return nil
},
)),
)
return newUnitTestDB(t, db, mock, err)
}
// newMockDBMatchWords returns a db.DB and a sqlmock.Sqlmock that will match SQL
// by splitting the expected SQL string into words, and then try to find all of
// them in the actual SQL, in the given order, case insensitively. Prepend a
// word with a `!` to say that word should not be found.
func newMockDBMatchWords(t *testing.T) (db.DB, sqlmock.Sqlmock) {
t.Helper()
db, mock, err := sqlmock.New(
sqlmock.MonitorPingsOption(true),
sqlmock.QueryMatcherOption(
sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
actualSQL = strings.ToLower(sqltemplate.FormatSQL(actualSQL))
expectedSQL = strings.ToLower(expectedSQL)
var offset int
for _, vv := range mockDBMatchWordsRE.FindAllStringSubmatch(expectedSQL, -1) {
v := vv[1]
var shouldNotMatch bool
if v != "" && v[0] == '!' {
v = v[1:]
shouldNotMatch = true
}
if v == "" {
return fmt.Errorf("invalid expected word %q in %q", v,
expectedSQL)
}
reWord, err := regexp.Compile(`\b` + regexp.QuoteMeta(v) + `\b`)
if err != nil {
return fmt.Errorf("compile word %q from expected SQL: %s", v,
expectedSQL)
}
if shouldNotMatch {
if reWord.MatchString(actualSQL[offset:]) {
return fmt.Errorf("actual SQL fragent should not cont"+
"ain %q but it does\n\tFragment: %s\n\tFull SQL: %s",
v, actualSQL[offset:], actualSQL)
}
} else {
loc := reWord.FindStringIndex(actualSQL[offset:])
if len(loc) == 0 {
return fmt.Errorf("actual SQL fragment should contain "+
"%q but it doesn't\n\tFragment: %s\n\tFull SQL: %s",
v, actualSQL[offset:], actualSQL)
}
offset = loc[1] // advance the offset
}
}
return nil
},
),
),
)
return newUnitTestDB(t, db, mock, err)
}
var mockDBMatchWordsRE = regexp.MustCompile(`(?:\W|\A)(!?\w+)\b`)
func newUnitTestDB(t *testing.T, db *sql.DB, mock sqlmock.Sqlmock, err error) (db.DB, sqlmock.Sqlmock) {
t.Helper()
require.NoError(t, err)
return dbimpl.NewDB(db, "sqlmock"), mock
}
// mockResults aids in testing code paths with queries returning large number of
// values, like those returning *entity.Entity. This is because we want to
// emulate returning the same row columns and row values the same as a real
// database would do. This utility the same template SQL that is expected to be
// used to help populate all the expected fields.
// fileds
type mockResults[T any] struct {
t *testing.T
tmpl *template.Template
data sqltemplate.WithResults[T]
rows *sqlmock.Rows
}
// newMockResults returns a new *mockResults. If you want to emulate a call
// returning zero rows, then immediately call the Row method afterward.
func newMockResults[T any](t *testing.T, mock sqlmock.Sqlmock, tmpl *template.Template, data sqltemplate.WithResults[T]) *mockResults[T] {
t.Helper()
data.Reset()
err := tmpl.Execute(io.Discard, data)
require.NoError(t, err)
rows := mock.NewRows(data.GetColNames())
return &mockResults[T]{
t: t,
tmpl: tmpl,
data: data,
rows: rows,
}
}
// AddCurrentData uses the values contained in the `data` argument used during
// creation to populate a new expected row. It will access `data` with pointers,
// so you should replace the internal values of `data` with freshly allocated
// results to return different rows.
func (r *mockResults[T]) AddCurrentData() *mockResults[T] {
r.t.Helper()
r.data.Reset()
err := r.tmpl.Execute(io.Discard, r.data)
require.NoError(r.t, err)
d := r.data.GetScanDest()
dv := make([]driver.Value, len(d))
for i, v := range d {
dv[i] = v
}
r.rows.AddRow(dv...)
return r
}
// Rows returns the *sqlmock.Rows object built.
func (r *mockResults[T]) Rows() *sqlmock.Rows {
return r.rows
}
func TestCreateETag(t *testing.T) {
t.Parallel()
v := createETag(nil, nil, nil)
require.Equal(t, "d41d8cd98f00b204e9800998ecf8427e", v)
}
func TestGetCurrentUser(t *testing.T) {
t.Parallel()
ctx := testutil.NewDefaultTestContext(t)
username, err := getCurrentUser(ctx)
require.NotEmpty(t, username)
require.NoError(t, err)
ctx = ctx.WithUser(nil)
username, err = getCurrentUser(ctx)
require.Empty(t, username)
require.Error(t, err)
require.ErrorIs(t, err, ErrUserNotFoundInContext)
}
func TestPtrOr(t *testing.T) {
t.Parallel()
p := ptrOr[*int]()
require.NotNil(t, p)
require.Zero(t, *p)
p = ptrOr[*int](nil, nil, nil, nil, nil, nil)
require.NotNil(t, p)
require.Zero(t, *p)
v := 42
v2 := 5
p = ptrOr(nil, nil, nil, &v, nil, &v2, nil, nil)
require.NotNil(t, p)
require.Equal(t, v, *p)
p = ptrOr(nil, nil, nil, &v)
require.NotNil(t, p)
require.Equal(t, v, *p)
}
func TestSliceOr(t *testing.T) {
t.Parallel()
p := sliceOr[[]int]()
require.NotNil(t, p)
require.Len(t, p, 0)
p = sliceOr[[]int](nil, nil, nil, nil)
require.NotNil(t, p)
require.Len(t, p, 0)
p = sliceOr([]int{}, []int{}, []int{}, []int{})
require.NotNil(t, p)
require.Len(t, p, 0)
v := []int{1, 2}
p = sliceOr([]int{}, nil, []int{}, v, nil, []int{}, []int{10}, nil)
require.NotNil(t, p)
require.Equal(t, v, p)
p = sliceOr([]int{}, nil, []int{}, v)
require.NotNil(t, p)
require.Equal(t, v, p)
}
func TestMapOr(t *testing.T) {
t.Parallel()
p := mapOr[map[string]int]()
require.NotNil(t, p)
require.Len(t, p, 0)
p = mapOr(nil, map[string]int(nil), nil, map[string]int{}, nil)
require.NotNil(t, p)
require.Len(t, p, 0)
v := map[string]int{"a": 0, "b": 1}
v2 := map[string]int{"c": 2, "d": 3}
p = mapOr(nil, map[string]int(nil), v, v2, nil, map[string]int{}, nil)
require.NotNil(t, p)
require.Equal(t, v, p)
p = mapOr(nil, map[string]int(nil), v)
require.NotNil(t, p)
require.Equal(t, v, p)
}
var (
validTestTmpl = template.Must(template.New("test").Parse("nothing special"))
invalidTestTmpl = template.New("no definition should fail to exec")
errTest = errors.New("because of reasons")
)
// expectRows is a testing helper to keep mocks in sync when adding rows to a
// mocked SQL result. This is a helper to test `query` and `queryRow`.
type expectRows[T any] struct {
*sqlmock.Rows
ExpectedResults []T
req *sqltemplateMocks.WithResults[T]
}
func newReturnsRow[T any](dbmock sqlmock.Sqlmock, req *sqltemplateMocks.WithResults[T]) *expectRows[T] {
return &expectRows[T]{
Rows: dbmock.NewRows(nil),
req: req,
}
}
// Add adds a new value that should be returned by the `query` or `queryRow`
// operation.
func (r *expectRows[T]) Add(value T, err error) *expectRows[T] {
r.req.EXPECT().GetScanDest().Return(nil).Once()
r.req.EXPECT().Results().Return(value, err).Once()
r.Rows.AddRow()
r.ExpectedResults = append(r.ExpectedResults, value)
return r
}
func TestQueryRow(t *testing.T) {
t.Parallel()
t.Run("happy path", func(t *testing.T) {
t.Parallel()
// test declarations
ctx := testutil.NewDefaultTestContext(t)
req := sqltemplateMocks.NewWithResults[int64](t)
db, dbmock := newMockDBNopSQL(t)
rows := newReturnsRow(dbmock, req)
// setup expectations
req.EXPECT().Validate().Return(nil).Once()
req.EXPECT().GetArgs().Return(nil).Once()
rows.Add(1, nil)
dbmock.ExpectQuery("").WillReturnRows(rows.Rows)
// execute and assert
res, err := queryRow(ctx, db, validTestTmpl, req)
require.NoError(t, err)
require.Equal(t, rows.ExpectedResults[0], res)
})
t.Run("invalid request", func(t *testing.T) {
t.Parallel()
// test declarations
ctx := testutil.NewDefaultTestContext(t)
req := sqltemplateMocks.NewWithResults[int64](t)
db, _ := newMockDBNopSQL(t)
// setup expectations
req.EXPECT().Validate().Return(errTest).Once()
// execute and assert
res, err := queryRow(ctx, db, invalidTestTmpl, req)
require.Zero(t, res)
require.Error(t, err)
require.ErrorContains(t, err, "invalid request")
})
t.Run("error executing template", func(t *testing.T) {
t.Parallel()
// test declarations
ctx := testutil.NewDefaultTestContext(t)
req := sqltemplateMocks.NewWithResults[int64](t)
db, _ := newMockDBNopSQL(t)
// setup expectations
req.EXPECT().Validate().Return(nil).Once()
// execute and assert
res, err := queryRow(ctx, db, invalidTestTmpl, req)
require.Zero(t, res)
require.Error(t, err)
require.ErrorContains(t, err, "execute template")
})
t.Run("error executing query", func(t *testing.T) {
t.Parallel()
// test declarations
ctx := testutil.NewDefaultTestContext(t)
req := sqltemplateMocks.NewWithResults[int64](t)
db, dbmock := newMockDBNopSQL(t)
// setup expectations
req.EXPECT().Validate().Return(nil).Once()
req.EXPECT().GetArgs().Return(nil)
req.EXPECT().GetScanDest().Return(nil).Maybe()
dbmock.ExpectQuery("").WillReturnError(errTest)
// execute and assert
res, err := queryRow(ctx, db, validTestTmpl, req)
require.Zero(t, res)
require.Error(t, err)
require.ErrorAs(t, err, new(SQLError))
})
}
// scannerFunc is an adapter for the `scanner` interface.
type scannerFunc func(dest ...any) error
func (f scannerFunc) Scan(dest ...any) error {
return f(dest...)
}
func TestScanRow(t *testing.T) {
t.Parallel()
const value int64 = 1
t.Run("happy path", func(t *testing.T) {
t.Parallel()
// test declarations
req := sqltemplateMocks.NewWithResults[int64](t)
sc := scannerFunc(func(dest ...any) error {
return nil
})
// setup expectations
req.EXPECT().GetScanDest().Return(nil).Once()
req.EXPECT().Results().Return(value, nil).Once()
// execute and assert
res, err := scanRow(sc, req)
require.NoError(t, err)
require.Equal(t, value, res)
})
t.Run("scan error", func(t *testing.T) {
t.Parallel()
// test declarations
req := sqltemplateMocks.NewWithResults[int64](t)
sc := scannerFunc(func(dest ...any) error {
return errTest
})
// setup expectations
req.EXPECT().GetScanDest().Return(nil).Once()
// execute and assert
res, err := scanRow(sc, req)
require.Zero(t, res)
require.Error(t, err)
require.ErrorIs(t, err, errTest)
})
t.Run("results error", func(t *testing.T) {
t.Parallel()
// test declarations
req := sqltemplateMocks.NewWithResults[int64](t)
sc := scannerFunc(func(dest ...any) error {
return nil
})
// setup expectations
req.EXPECT().GetScanDest().Return(nil).Once()
req.EXPECT().Results().Return(0, errTest).Once()
// execute and assert
res, err := scanRow(sc, req)
require.Zero(t, res)
require.Error(t, err)
require.ErrorIs(t, err, errTest)
})
}
func TestExec(t *testing.T) {
t.Parallel()
t.Run("happy path", func(t *testing.T) {
t.Parallel()
// test declarations
ctx := testutil.NewDefaultTestContext(t)
req := sqltemplateMocks.NewSQLTemplateIface(t)
db, dbmock := newMockDBNopSQL(t)
// setup expectations
req.EXPECT().Validate().Return(nil).Once()
req.EXPECT().GetArgs().Return(nil).Once()
dbmock.ExpectExec("").WillReturnResult(sqlmock.NewResult(0, 0))
// execute and assert
res, err := exec(ctx, db, validTestTmpl, req)
require.NoError(t, err)
require.NotNil(t, res)
})
t.Run("invalid request", func(t *testing.T) {
t.Parallel()
// test declarations
ctx := testutil.NewDefaultTestContext(t)
req := sqltemplateMocks.NewSQLTemplateIface(t)
db, _ := newMockDBNopSQL(t)
// setup expectations
req.EXPECT().Validate().Return(errTest).Once()
// execute and assert
res, err := exec(ctx, db, invalidTestTmpl, req)
require.Nil(t, res)
require.Error(t, err)
require.ErrorContains(t, err, "invalid request")
})
t.Run("error executing template", func(t *testing.T) {
t.Parallel()
// test declarations
ctx := testutil.NewDefaultTestContext(t)
req := sqltemplateMocks.NewSQLTemplateIface(t)
db, _ := newMockDBNopSQL(t)
// setup expectations
req.EXPECT().Validate().Return(nil).Once()
// execute and assert
res, err := exec(ctx, db, invalidTestTmpl, req)
require.Nil(t, res)
require.Error(t, err)
require.ErrorContains(t, err, "execute template")
})
t.Run("error executing SQL", func(t *testing.T) {
t.Parallel()
// test declarations
ctx := testutil.NewDefaultTestContext(t)
req := sqltemplateMocks.NewSQLTemplateIface(t)
db, dbmock := newMockDBNopSQL(t)
// setup expectations
req.EXPECT().Validate().Return(nil).Once()
req.EXPECT().GetArgs().Return(nil)
dbmock.ExpectExec("").WillReturnError(errTest)
// execute and assert
res, err := exec(ctx, db, validTestTmpl, req)
require.Nil(t, res)
require.Error(t, err)
require.ErrorAs(t, err, new(SQLError))
})
}