diff --git a/CHANGELOG.md b/CHANGELOG.md index a0406a7..4ad72fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Emojis for the following are chosen based on [gitmoji](https://gitmoji.dev/). - The `update_data.sh` download command was fixed to specify the correct dump snapshot location ([#65](https://github.com/scribe-org/Scribe-Server/pull/65)). - Trusted proxies are now explicitly configured for security. - `/packs` requests with `..` in the path are rejected so they can't show whether directories outside of the packs directory exist, `/packs/sqlite/list` returns `[]` instead of `null` when there are no SQLite files, and SQLite download file names are quoted ([#91](https://github.com/scribe-org/Scribe-Server/issues/91)). +- Database queries for languages, data types, table schemas and table data return an error instead of partial results if reading rows fails partway through ([#92](https://github.com/scribe-org/Scribe-Server/issues/92)). - German data contract fixes for missing `displayValue` fields on declensions, and fully indexed conjugations/declensions. ### 📝 Documentation @@ -77,6 +78,7 @@ Emojis for the following are chosen based on [gitmoji](https://gitmoji.dev/). - Unit tests were added for the language statistics retrieval endpoint handler ([#86](https://github.com/scribe-org/Scribe-Server/issues/86)). - Unit tests were added for the `/packs` static file and SQLite download handler, including directory traversal attempts, and for the Swagger documentation routes ([#91](https://github.com/scribe-org/Scribe-Server/issues/91)). - Unit tests were added for API route registration, the home page, server startup and configuration loading ([#90](https://github.com/scribe-org/Scribe-Server/issues/90)). +- Unit tests were added for the database package using [go-sqlmock](https://github.com/DATA-DOG/go-sqlmock) ([#92](https://github.com/scribe-org/Scribe-Server/issues/92)). - Unit tests were added for the language data retrieval endpoint handler ([#87](https://github.com/scribe-org/Scribe-Server/issues/87)). ### ♻️ Code Refactoring diff --git a/Makefile b/Makefile index 05c4164..3c85048 100644 --- a/Makefile +++ b/Makefile @@ -8,8 +8,7 @@ ENV ?= dev GIN_MODE ?= debug # Minimum total test coverage (%) required by CI. -# Note: Raise this as tests are added so that coverage can only go up. -MIN_COVERAGE ?=45 +MIN_COVERAGE ?=70 # Clean any build artifacts. clean: diff --git a/database/connection.go b/database/connection.go index 905e30b..6c65b4b 100644 --- a/database/connection.go +++ b/database/connection.go @@ -17,6 +17,8 @@ import ( // It should be initialized via InitDatabase before use. var DB *sql.DB +var openDatabase = sql.Open + // InitDatabase initializes the database connection. func InitDatabase() error { dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", @@ -28,7 +30,7 @@ func InitDatabase() error { ) var err error - DB, err = sql.Open("mysql", dsn) + DB, err = openDatabase("mysql", dsn) if err != nil { return fmt.Errorf("error opening database: %w", err) } diff --git a/database/connection_test.go b/database/connection_test.go new file mode 100644 index 0000000..7cd25ba --- /dev/null +++ b/database/connection_test.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package database + +import ( + "database/sql" + "errors" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// openedDatabase records how InitDatabase opened the database. +type openedDatabase struct { + driver string + dsn string +} + +// setupInitDatabase sets the database config and replaces opening the database with the given connection or error. +// Tests using it must not call t.Parallel() as it changes the DB, openDatabase and Viper globals. +func setupInitDatabase(t *testing.T, db *sql.DB, openErr error) *openedDatabase { + t.Helper() + + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set("database.user", "scribe") + viper.Set("database.password", "secret") + viper.Set("database.host", "localhost") + viper.Set("database.port", "3306") + viper.Set("database.name", testDatabaseName) + + originalDB, originalOpen := DB, openDatabase + t.Cleanup(func() { DB, openDatabase = originalDB, originalOpen }) + + opened := &openedDatabase{} + openDatabase = func(driver, dsn string) (*sql.DB, error) { + opened.driver, opened.dsn = driver, dsn + return db, openErr + } + + return opened +} + +// newPingMockDB returns a sqlmock connection that checks pings. +func newPingMockDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock) { + t.Helper() + + db, mock, err := sqlmock.New(sqlmock.MonitorPingsOption(true)) + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, mock.ExpectationsWereMet()) + _ = db.Close() + }) + + return db, mock +} + +func TestInitDatabase(t *testing.T) { + t.Run("connects with the configured database", func(t *testing.T) { + db, mock := newPingMockDB(t) + mock.ExpectPing() + opened := setupInitDatabase(t, db, nil) + + require.NoError(t, InitDatabase()) + + assert.Equal(t, "mysql", opened.driver) + assert.Equal(t, "scribe:secret@tcp(localhost:3306)/scribe_test?charset=utf8mb4&parseTime=True&loc=Local", opened.dsn) + assert.Same(t, db, DB) + }) + + t.Run("error when the database can't be opened", func(t *testing.T) { + setupInitDatabase(t, nil, errors.New("unknown driver")) + + err := InitDatabase() + + assert.ErrorContains(t, err, "error opening database: unknown driver") + }) + + t.Run("error when the database can't be reached", func(t *testing.T) { + db, mock := newPingMockDB(t) + mock.ExpectPing().WillReturnError(errors.New("connection refused")) + setupInitDatabase(t, db, nil) + + err := InitDatabase() + + assert.ErrorContains(t, err, "error pinging database: connection refused") + }) +} diff --git a/database/helpers_test.go b/database/helpers_test.go new file mode 100644 index 0000000..65310ef --- /dev/null +++ b/database/helpers_test.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package database + +import ( + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testDatabaseName is the database name that queries are expected to use in tests. +const testDatabaseName = "scribe_test" + +// newMockDB replaces the database connection with sqlmock and sets the database name for a test. +// Tests using it must not call t.Parallel() as it changes the DB and Viper globals. +func newMockDB(t *testing.T) sqlmock.Sqlmock { + t.Helper() + + viper.Reset() + viper.Set("database.name", testDatabaseName) + t.Cleanup(viper.Reset) + + db, mock, err := sqlmock.New() + require.NoError(t, err) + + original := DB + DB = db + t.Cleanup(func() { + // Every expected query must have been sent. + assert.NoError(t, mock.ExpectationsWereMet()) + + DB = original + // Note: Closing isn't an expected call for sqlmock, so its error isn't checked. + _ = db.Close() + }) + + return mock +} diff --git a/database/language.go b/database/language.go index f651402..53b390c 100644 --- a/database/language.go +++ b/database/language.go @@ -42,6 +42,11 @@ func GetAvailableLanguages() ([]string, error) { languages = append(languages, strings.ToLower(lang)) } + // check to not return partial results. + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error reading languages: %w", err) + } + return languages, nil } @@ -86,6 +91,10 @@ func GetLanguageDataTypes(lang string) ([]string, error) { } } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error reading data types: %w", err) + } + return dataTypes, nil } diff --git a/database/language_test.go b/database/language_test.go index bf47e0f..29f27dd 100644 --- a/database/language_test.go +++ b/database/language_test.go @@ -3,9 +3,17 @@ package database import ( + "errors" "reflect" + "regexp" "strings" "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/go-sql-driver/mysql" + "github.com/scribe-org/scribe-server/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestBuildLanguageStatQueryAllDataTypes(t *testing.T) { @@ -92,3 +100,279 @@ func TestBuildLanguageStatQueryIgnoresOtherDataTypes(t *testing.T) { t.Fatalf("data types %v, want %v", dataTypes, want) } } + +// MARK: Database Queries + +var ( + availableLanguagesQuery = regexp.QuoteMeta("SELECT DISTINCT SUBSTRING(TABLE_NAME, 1, 2) as language_code") + dataTypesQuery = regexp.QuoteMeta("SELECT TABLE_NAME") +) + +// tableRows returns query rows with a single column of table names. +func tableRows(tableNames ...string) *sqlmock.Rows { + rows := sqlmock.NewRows([]string{"TABLE_NAME"}) + for _, tableName := range tableNames { + rows.AddRow(tableName) + } + + return rows +} + +func TestGetAvailableLanguages(t *testing.T) { + t.Run("returns lowercase language codes", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(availableLanguagesQuery). + WithArgs(testDatabaseName). + WillReturnRows(sqlmock.NewRows([]string{"language_code"}).AddRow("DE").AddRow("EN")) + + languages, err := GetAvailableLanguages() + + require.NoError(t, err) + assert.Equal(t, []string{"de", "en"}, languages) + }) + + t.Run("error when the query fails", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(availableLanguagesQuery).WillReturnError(errors.New("connection lost")) + + _, err := GetAvailableLanguages() + + assert.ErrorContains(t, err, "error querying available languages: connection lost") + }) + + t.Run("error when a language can't be read", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(availableLanguagesQuery). + WillReturnRows(sqlmock.NewRows([]string{"language_code"}).AddRow(nil)) + + _, err := GetAvailableLanguages() + + assert.ErrorContains(t, err, "error scanning language") + }) + + t.Run("error when reading the rows fails", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(availableLanguagesQuery). + WillReturnRows(sqlmock.NewRows([]string{"language_code"}). + AddRow("DE").AddRow("EN").RowError(1, errors.New("connection lost"))) + + languages, err := GetAvailableLanguages() + + assert.ErrorContains(t, err, "error reading languages: connection lost") + assert.Nil(t, languages) + }) +} + +func TestGetLanguageDataTypes(t *testing.T) { + t.Run("returns lowercase data types of language tables", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(dataTypesQuery). + WithArgs(testDatabaseName, "ENLanguageData%Scribe"). + WillReturnRows(tableRows( + "ENLanguageDataEmojiKeywordsScribe", + "ENLanguageDataNounsScribe", + "ENLanguageDataVerbsScribe", + // Note: Tables that don't match the pattern, such as migration backups, are ignored. + "ENLanguageDataNounsScribeOld", + )) + + dataTypes, err := GetLanguageDataTypes("en") + + require.NoError(t, err) + assert.Equal(t, []string{"emojikeywords", "nouns", "verbs"}, dataTypes) + }) + + t.Run("rejects an invalid language code without querying", func(t *testing.T) { + newMockDB(t) + + for _, lang := range []string{"", "e", "eng"} { + _, err := GetLanguageDataTypes(lang) + assert.EqualError(t, err, "invalid language code", lang) + } + }) + + t.Run("error when the query fails", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(dataTypesQuery).WillReturnError(errors.New("connection lost")) + + _, err := GetLanguageDataTypes("en") + + assert.ErrorContains(t, err, "error querying data types: connection lost") + }) + + t.Run("error when a table name can't be read", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(dataTypesQuery).WillReturnRows(sqlmock.NewRows([]string{"TABLE_NAME"}).AddRow(nil)) + + _, err := GetLanguageDataTypes("en") + + assert.ErrorContains(t, err, "error scanning table name") + }) + + t.Run("error when reading the rows fails", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(dataTypesQuery). + WillReturnRows(tableRows("ENLanguageDataNounsScribe", "ENLanguageDataVerbsScribe"). + RowError(1, errors.New("connection lost"))) + + dataTypes, err := GetLanguageDataTypes("en") + + assert.ErrorContains(t, err, "error reading data types: connection lost") + assert.Nil(t, dataTypes) + }) +} + +// MARK: Language Statistics + +// expectStatQuery expects the query counting the entries of the given data types for a language. +func expectStatQuery(mock sqlmock.Sqlmock, lang string, dataTypes ...string) *sqlmock.ExpectedQuery { + counts := make([]string, 0, len(dataTypes)) + for _, dataType := range dataTypes { + tableName := strings.ToUpper(lang) + "LanguageData" + strings.ToUpper(dataType[:1]) + dataType[1:] + "Scribe" + counts = append(counts, "(SELECT COUNT(*) FROM "+tableName+") AS "+dataType) + } + + return mock.ExpectQuery("^" + regexp.QuoteMeta("SELECT "+strings.Join(counts, ", ")) + "$") +} + +func TestGetLanguageStat(t *testing.T) { + t.Run("counts the available data types", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(dataTypesQuery). + WithArgs(testDatabaseName, "DELanguageData%Scribe"). + WillReturnRows(tableRows( + "DELanguageDataNounsScribe", + "DELanguageDataPrepositionsScribe", + "DELanguageDataProfanityScribe", + "DELanguageDataVerbsScribe", + )) + expectStatQuery(mock, "de", "nouns", "verbs", "prepositions", "profanity"). + WillReturnRows(sqlmock.NewRows([]string{"nouns", "verbs", "prepositions", "profanity"}). + AddRow(178751, 23, 196, 113)) + + // Note: Codes are trimmed and are case insensitive. + stat, err := GetLanguageStat(" De ") + + require.NoError(t, err) + assert.Equal(t, map[string]any{ + "code": "de", "nouns": 178751, "verbs": 23, "prepositions": 196, "profanity": 113, + }, stat) + }) + + t.Run("doesn't count data types the language doesn't have", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(dataTypesQuery). + WillReturnRows(tableRows("ENLanguageDataNounsScribe", "ENLanguageDataVerbsScribe")) + expectStatQuery(mock, "en", "nouns", "verbs"). + WillReturnRows(sqlmock.NewRows([]string{"nouns", "verbs"}).AddRow(33498, 8787)) + + stat, err := GetLanguageStat("en") + + require.NoError(t, err) + assert.Equal(t, map[string]any{"code": "en", "nouns": 33498, "verbs": 8787}, stat) + }) + + t.Run("skips a language without nouns or verbs", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(dataTypesQuery).WillReturnRows(tableRows("FRLanguageDataNounsScribe")) + + stat, err := GetLanguageStat("fr") + + require.NoError(t, err) + assert.Nil(t, stat) + }) + + t.Run("skips a language when a table is removed during the query", func(t *testing.T) { + for name, queryErr := range map[string]error{ + "MariaDB missing table error": &mysql.MySQLError{Number: 1146, Message: "Table 'ENLanguageDataVerbsScribe' doesn't exist"}, + "missing table message": errors.New("Table 'ENLanguageDataVerbsScribe' doesn't exist"), + "SQLite missing table message": errors.New("no such table: ENLanguageDataVerbsScribe"), + } { + mock := newMockDB(t) + mock.ExpectQuery(dataTypesQuery). + WillReturnRows(tableRows("ENLanguageDataNounsScribe", "ENLanguageDataVerbsScribe")) + expectStatQuery(mock, "en", "nouns", "verbs").WillReturnError(queryErr) + + stat, err := GetLanguageStat("en") + + require.NoError(t, err, name) + assert.Nil(t, stat, name) + } + }) + + t.Run("rejects an invalid language code without querying", func(t *testing.T) { + newMockDB(t) + + for _, lang := range []string{"", "e", "eng", "e1"} { + _, err := GetLanguageStat(lang) + assert.ErrorContains(t, err, "invalid language code", lang) + } + }) + + t.Run("error when the data types can't be fetched", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(dataTypesQuery).WillReturnError(errors.New("connection lost")) + + _, err := GetLanguageStat("en") + + assert.ErrorContains(t, err, "error fetching data types for EN") + }) + + t.Run("error when the counts can't be fetched", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(dataTypesQuery). + WillReturnRows(tableRows("ENLanguageDataNounsScribe", "ENLanguageDataVerbsScribe")) + expectStatQuery(mock, "en", "nouns", "verbs").WillReturnError(errors.New("connection lost")) + + _, err := GetLanguageStat("en") + + assert.ErrorContains(t, err, "error scanning stats for EN: connection lost") + }) +} + +func TestGetAllLanguageStats(t *testing.T) { + t.Run("returns statistics for languages with nouns and verbs", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(availableLanguagesQuery). + WillReturnRows(sqlmock.NewRows([]string{"language_code"}).AddRow("DE").AddRow("EN").AddRow("FR")) + + // German fails, so it's skipped. + mock.ExpectQuery(dataTypesQuery).WithArgs(testDatabaseName, "DELanguageData%Scribe"). + WillReturnError(errors.New("connection lost")) + + // English is included. + mock.ExpectQuery(dataTypesQuery).WithArgs(testDatabaseName, "ENLanguageData%Scribe"). + WillReturnRows(tableRows("ENLanguageDataNounsScribe", "ENLanguageDataVerbsScribe")) + expectStatQuery(mock, "en", "nouns", "verbs"). + WillReturnRows(sqlmock.NewRows([]string{"nouns", "verbs"}).AddRow(33498, 8787)) + + // French has no verbs, so it's skipped. + mock.ExpectQuery(dataTypesQuery).WithArgs(testDatabaseName, "FRLanguageData%Scribe"). + WillReturnRows(tableRows("FRLanguageDataNounsScribe")) + + stats, err := GetAllLanguageStats() + + require.NoError(t, err) + require.Len(t, stats, 1) + assert.Equal(t, BuildLanguageStatResponse("en", map[string]any{"nouns": 33498, "verbs": 8787}), stats[0]) + }) + + t.Run("empty list when there are no languages", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(availableLanguagesQuery).WillReturnRows(sqlmock.NewRows([]string{"language_code"})) + + stats, err := GetAllLanguageStats() + + require.NoError(t, err) + assert.Equal(t, []models.LanguageStatisticsReponse{}, stats) + }) + + t.Run("error when the languages can't be fetched", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(availableLanguagesQuery).WillReturnError(errors.New("connection lost")) + + _, err := GetAllLanguageStats() + + assert.ErrorContains(t, err, "failed to get available languages") + }) +} diff --git a/database/table.go b/database/table.go index 197fa04..5c57a61 100644 --- a/database/table.go +++ b/database/table.go @@ -59,6 +59,11 @@ func GetTableSchema(tableName string) (map[string]string, error) { schema[columnName] = columnType } + // check to not return partial results. + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error reading column info: %w", err) + } + return schema, nil } @@ -110,5 +115,9 @@ func GetTableData(tableName string) ([]map[string]any, error) { results = append(results, rowMap) } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error reading rows: %w", err) + } + return results, nil } diff --git a/database/table_test.go b/database/table_test.go new file mode 100644 index 0000000..8870b97 --- /dev/null +++ b/database/table_test.go @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package database + +import ( + "errors" + "regexp" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// MARK: Table Exists + +func TestTableExists(t *testing.T) { + query := regexp.QuoteMeta("FROM information_schema.TABLES") + + t.Run("true when the table exists", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(query). + WithArgs(testDatabaseName, "ENLanguageDataNounsScribe"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + exists, err := TableExists("ENLanguageDataNounsScribe") + + require.NoError(t, err) + assert.True(t, exists) + }) + + t.Run("false when the table doesn't exist", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(query). + WithArgs(testDatabaseName, "FRLanguageDataNounsScribe"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + + exists, err := TableExists("FRLanguageDataNounsScribe") + + require.NoError(t, err) + assert.False(t, exists) + }) + + t.Run("error when the query fails", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(query).WillReturnError(errors.New("connection lost")) + + exists, err := TableExists("ENLanguageDataNounsScribe") + + assert.ErrorContains(t, err, "error checking table existence: connection lost") + assert.False(t, exists) + }) +} + +// MARK: Table Schema + +func TestGetTableSchema(t *testing.T) { + query := regexp.QuoteMeta("FROM information_schema.COLUMNS") + + t.Run("returns column names and types", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(query). + WithArgs(testDatabaseName, "ENLanguageDataNounsScribe"). + WillReturnRows(sqlmock.NewRows([]string{"COLUMN_NAME", "COLUMN_TYPE"}). + AddRow("wdLexemeId", "text"). + AddRow("lastModified", "timestamp")) + + schema, err := GetTableSchema("ENLanguageDataNounsScribe") + + require.NoError(t, err) + assert.Equal(t, map[string]string{"wdLexemeId": "text", "lastModified": "timestamp"}, schema) + }) + + t.Run("rejects an invalid table name without querying", func(t *testing.T) { + // Note: No queries are expected, so the test fails if one is sent. + newMockDB(t) + + schema, err := GetTableSchema("ENLanguageDataNounsScribe; DROP TABLE users") + + assert.EqualError(t, err, "invalid table name") + assert.Nil(t, schema) + }) + + t.Run("error when the query fails", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(query).WillReturnError(errors.New("connection lost")) + + _, err := GetTableSchema("ENLanguageDataNounsScribe") + + assert.ErrorContains(t, err, "error querying table schema: connection lost") + }) + + t.Run("error when a column can't be read", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(query). + WillReturnRows(sqlmock.NewRows([]string{"COLUMN_NAME", "COLUMN_TYPE"}).AddRow(nil, "text")) + + _, err := GetTableSchema("ENLanguageDataNounsScribe") + + assert.ErrorContains(t, err, "error scanning column info") + }) + + t.Run("error when reading the rows fails", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(query). + WillReturnRows(sqlmock.NewRows([]string{"COLUMN_NAME", "COLUMN_TYPE"}). + AddRow("wdLexemeId", "text"). + AddRow("lastModified", "timestamp"). + RowError(1, errors.New("connection lost"))) + + schema, err := GetTableSchema("ENLanguageDataNounsScribe") + + // Partial results shouldn't be returned as if they were complete. + assert.ErrorContains(t, err, "connection lost") + assert.Nil(t, schema) + }) +} + +// MARK: Table Data + +func TestGetTableData(t *testing.T) { + query := regexp.QuoteMeta("SELECT * FROM ENLanguageDataNounsScribe") + + t.Run("returns rows with text, numbers, dates and nulls", func(t *testing.T) { + modified := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + mock := newMockDB(t) + mock.ExpectQuery(query). + WillReturnRows(sqlmock.NewRows([]string{"wdLexemeId", "count", "lastModified", "gender"}). + AddRow([]byte("L1"), int64(3), modified, nil). + AddRow([]byte("L2"), int64(5), modified, []byte("feminine"))) + + data, err := GetTableData("ENLanguageDataNounsScribe") + + require.NoError(t, err) + assert.Equal(t, []map[string]any{ + {"wdLexemeId": "L1", "count": int64(3), "lastModified": modified, "gender": nil}, + {"wdLexemeId": "L2", "count": int64(5), "lastModified": modified, "gender": "feminine"}, + }, data) + }) + + t.Run("no rows for an empty table", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(query).WillReturnRows(sqlmock.NewRows([]string{"wdLexemeId"})) + + data, err := GetTableData("ENLanguageDataNounsScribe") + + require.NoError(t, err) + assert.Empty(t, data) + }) + + t.Run("rejects an invalid table name without querying", func(t *testing.T) { + newMockDB(t) + + data, err := GetTableData("users") + + assert.EqualError(t, err, "invalid table name") + assert.Nil(t, data) + }) + + t.Run("error when the query fails", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(query).WillReturnError(errors.New("connection lost")) + + _, err := GetTableData("ENLanguageDataNounsScribe") + + assert.ErrorContains(t, err, "error querying table data: connection lost") + }) + + t.Run("error when reading the rows fails", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(query). + WillReturnRows(sqlmock.NewRows([]string{"wdLexemeId"}). + AddRow([]byte("L1")). + AddRow([]byte("L2")). + RowError(1, errors.New("connection lost"))) + + data, err := GetTableData("ENLanguageDataNounsScribe") + + // Partial results shouldn't be returned as if they were complete. + assert.ErrorContains(t, err, "connection lost") + assert.Nil(t, data) + }) +} diff --git a/database/versions_test.go b/database/versions_test.go index 10730d9..bd64b23 100644 --- a/database/versions_test.go +++ b/database/versions_test.go @@ -2,7 +2,15 @@ package database -import "testing" +import ( + "errors" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) func TestLatestVersionDate(t *testing.T) { t.Parallel() @@ -59,3 +67,134 @@ func TestLatestVersionDate(t *testing.T) { } } } + +// MARK: Database Queries + +var ( + createVersionsTableQuery = regexp.QuoteMeta("CREATE TABLE IF NOT EXISTS language_data_versions") + updateVersionQuery = regexp.QuoteMeta("INSERT INTO language_data_versions") + lastModifiedColumnQuery = regexp.QuoteMeta("AND COLUMN_NAME = 'lastModified'") +) + +// lastModifiedQuery returns a pattern for the query of the most recent last modified date of a table. +func lastModifiedQuery(tableName string) string { + return "(?s)" + regexp.QuoteMeta("COALESCE(MAX(lastModified), '1970-01-01')") + ".*" + regexp.QuoteMeta("FROM "+tableName) +} + +func TestCreateLanguageDataVersionsTable(t *testing.T) { + t.Run("creates the table", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectExec(createVersionsTableQuery).WillReturnResult(sqlmock.NewResult(0, 0)) + + assert.NoError(t, CreateLanguageDataVersionsTable()) + }) + + t.Run("error when the table can't be created", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectExec(createVersionsTableQuery).WillReturnError(errors.New("permission denied")) + + err := CreateLanguageDataVersionsTable() + + assert.ErrorContains(t, err, "error creating language_data_versions table: permission denied") + }) +} + +func TestUpdateLanguageVersion(t *testing.T) { + t.Run("inserts or updates the language", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectExec(updateVersionQuery).WithArgs("en").WillReturnResult(sqlmock.NewResult(1, 1)) + + assert.NoError(t, UpdateLanguageVersion("en")) + }) + + t.Run("error when the language can't be updated", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectExec(updateVersionQuery).WithArgs("en").WillReturnError(errors.New("connection lost")) + + err := UpdateLanguageVersion("en") + + assert.ErrorContains(t, err, "error updating language version: connection lost") + }) +} + +// expectLanguageVersions expects the queries of GetLanguageVersions for English with each fallback case. +func expectLanguageVersions(mock sqlmock.Sqlmock) { + mock.ExpectQuery(dataTypesQuery). + WithArgs(testDatabaseName, "ENLanguageData%Scribe"). + WillReturnRows(tableRows( + "ENLanguageDataNounsScribe", + "ENLanguageDataPrepositionsScribe", + "ENLanguageDataProfanityScribe", + "ENLanguageDataVerbsScribe", + )) + + // Nouns have a last modified date. + mock.ExpectQuery(lastModifiedColumnQuery). + WithArgs(testDatabaseName, "ENLanguageDataNounsScribe"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery(lastModifiedQuery("ENLanguageDataNounsScribe")). + WillReturnRows(sqlmock.NewRows([]string{"max_last_modified"}).AddRow("2026-09-30 03:21:30")) + + // Checking the prepositions column fails. + mock.ExpectQuery(lastModifiedColumnQuery). + WithArgs(testDatabaseName, "ENLanguageDataPrepositionsScribe"). + WillReturnError(errors.New("connection lost")) + + // Getting the most recent profanity date fails. + mock.ExpectQuery(lastModifiedColumnQuery). + WithArgs(testDatabaseName, "ENLanguageDataProfanityScribe"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery(lastModifiedQuery("ENLanguageDataProfanityScribe")). + WillReturnError(errors.New("connection lost")) + + // Verbs don't have a lastModified column, so there's no query for their most recent date. + mock.ExpectQuery(lastModifiedColumnQuery). + WithArgs(testDatabaseName, "ENLanguageDataVerbsScribe"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) +} + +func TestGetLanguageVersions(t *testing.T) { + t.Run("returns last modified dates with 1970-01-01 when they can't be found", func(t *testing.T) { + mock := newMockDB(t) + expectLanguageVersions(mock) + + versions, err := GetLanguageVersions("en") + + require.NoError(t, err) + assert.Equal(t, map[string]string{ + "nouns_last_modified": "2026-09-30 03:21:30", + "prepositions_last_modified": "1970-01-01", + "profanity_last_modified": "1970-01-01", + "verbs_last_modified": "1970-01-01", + }, versions) + }) + + t.Run("error when the data types can't be fetched", func(t *testing.T) { + mock := newMockDB(t) + mock.ExpectQuery(dataTypesQuery).WillReturnError(errors.New("connection lost")) + + _, err := GetLanguageVersions("en") + + assert.ErrorContains(t, err, "error querying data types: connection lost") + }) +} + +func TestGetLanguageUpdatedAt(t *testing.T) { + t.Run("returns the most recent last modified date", func(t *testing.T) { + mock := newMockDB(t) + expectLanguageVersions(mock) + + updatedAt, err := GetLanguageUpdatedAt("en") + + require.NoError(t, err) + assert.Equal(t, "2026-09-30", updatedAt) + }) + + t.Run("error when the versions can't be fetched", func(t *testing.T) { + newMockDB(t) + + _, err := GetLanguageUpdatedAt("e") + + assert.ErrorContains(t, err, "error fetching versions for e: invalid language code") + }) +} diff --git a/go.mod b/go.mod index 38a010c..8c1c61f 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/scribe-org/scribe-server go 1.25.0 require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/gin-gonic/gin v1.10.1 github.com/glebarez/sqlite v1.11.0 github.com/go-sql-driver/mysql v1.9.3 diff --git a/go.sum b/go.sum index c96e619..d937b7b 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= @@ -186,6 +188,7 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=