From 3a633235eac5f7967705e710e1505c8c12f6dd6a Mon Sep 17 00:00:00 2001 From: Evan Reichard Date: Sun, 16 Jun 2024 20:00:41 -0400 Subject: [PATCH] tests(db): add additional tests & comments --- api/app-admin-routes.go | 4 +- api/utils.go | 16 ++- database/manager.go | 14 +- database/manager_test.go | 279 +++++++++++++++++++++------------------ database/users_test.go | 204 ++++++++++++++++++++++++++++ 5 files changed, 383 insertions(+), 134 deletions(-) create mode 100644 database/users_test.go diff --git a/api/app-admin-routes.go b/api/app-admin-routes.go index 728368c..34054e9 100644 --- a/api/app-admin-routes.go +++ b/api/app-admin-routes.go @@ -869,10 +869,10 @@ func (api *API) updateUser(user string, rawPassword *string, isAdmin *bool) erro updateParams.Admin = user.Admin } - // Check Admins + // Check Admins - Disallow Demotion if isLast, err := api.isLastAdmin(user); err != nil { return err - } else if isLast { + } else if isLast && !updateParams.Admin { return fmt.Errorf("unable to demote %s - last admin", user) } diff --git a/api/utils.go b/api/utils.go index 5164e6e..d52d281 100644 --- a/api/utils.go +++ b/api/utils.go @@ -13,6 +13,7 @@ import ( "reichard.io/antholume/metadata" ) +// getTimeZones returns a string slice of IANA timezones. func getTimeZones() []string { return []string{ "Africa/Cairo", @@ -52,6 +53,8 @@ func getTimeZones() []string { } } +// niceSeconds takes in an int (in seconds) and returns a string readable +// representation. For example 1928371 -> "22d 7h 39m 31s". func niceSeconds(input int64) (result string) { if input == 0 { return "N/A" @@ -80,6 +83,8 @@ func niceSeconds(input int64) (result string) { return } +// niceNumbers takes in an int and returns a string representation. For example +// 19823 -> "19.8k". func niceNumbers(input int64) string { if input == 0 { return "0" @@ -98,7 +103,8 @@ func niceNumbers(input int64) string { } } -// Convert Database Array -> Int64 Array +// getSVGGraphData builds SVGGraphData from the provided stats, width and height. +// It is used exclusively in templates to generate the daily read stats graph. func getSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, svgHeight int) graph.SVGGraphData { var intData []int64 for _, item := range inputData { @@ -108,6 +114,8 @@ func getSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, sv return graph.GetSVGGraphData(intData, svgWidth, svgHeight) } +// dict returns a map[string]any dict. Each pair of two is a key & value +// respectively. It's primarily utilized in templates. func dict(values ...any) (map[string]any, error) { if len(values)%2 != 0 { return nil, errors.New("invalid dict call") @@ -123,6 +131,8 @@ func dict(values ...any) (map[string]any, error) { return dict, nil } +// fields returns a map[string]any of the provided struct. It's primarily +// utilized in templates. func fields(value any) (map[string]any, error) { v := reflect.Indirect(reflect.ValueOf(value)) if v.Kind() != reflect.Struct { @@ -137,10 +147,13 @@ func fields(value any) (map[string]any, error) { return m, nil } +// slice returns a slice of the provided arguments. It's primarily utilized in +// templates. func slice(elements ...any) []any { return elements } +// deriveBaseFileName builds the base filename for a given MetadataInfo object. func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string { // Derive New FileName var newFileName string @@ -160,6 +173,7 @@ func deriveBaseFileName(metadataInfo *metadata.MetadataInfo) string { return "." + filepath.Clean(fmt.Sprintf("/%s [%s]%s", fileName, *metadataInfo.PartialMD5, metadataInfo.Type)) } +// importStatusPriority returns the order priority for import status in the UI. func importStatusPriority(status importStatus) int { switch status { case importFailed: diff --git a/database/manager.go b/database/manager.go index a355ea9..51372fa 100644 --- a/database/manager.go +++ b/database/manager.go @@ -43,7 +43,7 @@ func init() { }) } -// Returns an initialized manager +// NewMgr Returns an initialized manager func NewMgr(c *config.Config) *DBManager { // Create Manager dbm := &DBManager{ @@ -58,7 +58,7 @@ func NewMgr(c *config.Config) *DBManager { return dbm } -// Init manager +// init loads the DB manager func (dbm *DBManager) init() error { // Build DB Location var dbLocation string @@ -125,7 +125,7 @@ func (dbm *DBManager) init() error { return nil } -// Reload manager (close DB & reinit) +// Reload closes the DB & reinits func (dbm *DBManager) Reload() error { // Close handle err := dbm.DB.Close() @@ -141,6 +141,7 @@ func (dbm *DBManager) Reload() error { return nil } +// CacheTempTables clears existing statistics and recalculates func (dbm *DBManager) CacheTempTables() error { start := time.Now() user_streaks_sql := ` @@ -165,6 +166,8 @@ func (dbm *DBManager) CacheTempTables() error { return nil } +// updateSettings ensures that we're enforcing foreign keys and enable journal +// mode. func (dbm *DBManager) updateSettings() error { // Set SQLite PRAGMA Settings pragmaQuery := ` @@ -188,6 +191,7 @@ func (dbm *DBManager) updateSettings() error { return nil } +// performMigrations runs all migrations func (dbm *DBManager) performMigrations(isNew bool) error { // Create context ctx := context.WithValue(context.Background(), "isNew", isNew) // nolint @@ -204,7 +208,7 @@ func (dbm *DBManager) performMigrations(isNew bool) error { return goose.UpContext(ctx, dbm.DB, "migrations") } -// Determines whether the database is empty +// isEmpty determines whether the database is empty func isEmpty(db *sql.DB) (bool, error) { var tableCount int err := db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table';").Scan(&tableCount) @@ -214,7 +218,7 @@ func isEmpty(db *sql.DB) (bool, error) { return tableCount == 0, nil } -// LOCAL_TIME custom SQL function +// localTime is a custom SQL function that is registered as LOCAL_TIME in the init function func localTime(ctx *sqlite.FunctionContext, args []driver.Value) (driver.Value, error) { timeStr, ok := args[0].(string) if !ok { diff --git a/database/manager_test.go b/database/manager_test.go index 5bc9ad4..c55444b 100644 --- a/database/manager_test.go +++ b/database/manager_test.go @@ -5,164 +5,191 @@ import ( "testing" "time" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "reichard.io/antholume/config" "reichard.io/antholume/utils" ) -type databaseTest struct { - *testing.T +var ( + userID string = "testUser" + userPass string = "testPass" + deviceID string = "testDevice" + deviceName string = "testDeviceName" + documentID string = "testDocument" + documentTitle string = "testTitle" + documentAuthor string = "testAuthor" + documentWords int64 = 5000 +) + +type DatabaseTestSuite struct { + suite.Suite dbm *DBManager } -var userID string = "testUser" -var userPass string = "testPass" -var deviceID string = "testDevice" -var deviceName string = "testDeviceName" -var documentID string = "testDocument" -var documentTitle string = "testTitle" -var documentAuthor string = "testAuthor" +func TestDatabase(t *testing.T) { + suite.Run(t, new(DatabaseTestSuite)) +} -func TestNewMgr(t *testing.T) { +// PROGRESS - TODO: +// - 󰊕 (q *Queries) GetProgress +// - 󰊕 (q *Queries) UpdateProgress + +func (suite *DatabaseTestSuite) SetupTest() { cfg := config.Config{ DBType: "memory", } - dbm := NewMgr(&cfg) - assert.NotNil(t, dbm, "should not have nil dbm") + suite.dbm = NewMgr(&cfg) - t.Run("Database", func(t *testing.T) { - dt := databaseTest{t, dbm} - dt.TestUser() - dt.TestDocument() - dt.TestDevice() - dt.TestActivity() - dt.TestDailyReadStats() + // Create User + rawAuthHash, _ := utils.GenerateToken(64) + authHash := fmt.Sprintf("%x", rawAuthHash) + _, err := suite.dbm.Queries.CreateUser(suite.dbm.Ctx, CreateUserParams{ + ID: userID, + Pass: &userPass, + AuthHash: &authHash, }) -} + suite.NoError(err) -func (dt *databaseTest) TestUser() { - dt.Run("User", func(t *testing.T) { - // Generate Auth Hash - rawAuthHash, err := utils.GenerateToken(64) - assert.Nil(t, err, "should have nil err") + // Create Document + _, err = suite.dbm.Queries.UpsertDocument(suite.dbm.Ctx, UpsertDocumentParams{ + ID: documentID, + Title: &documentTitle, + Author: &documentAuthor, + Words: &documentWords, + }) + suite.NoError(err) - authHash := fmt.Sprintf("%x", rawAuthHash) - changed, err := dt.dbm.Queries.CreateUser(dt.dbm.Ctx, CreateUserParams{ - ID: userID, - Pass: &userPass, - AuthHash: &authHash, + // Create Device + _, err = suite.dbm.Queries.UpsertDevice(suite.dbm.Ctx, UpsertDeviceParams{ + ID: deviceID, + UserID: userID, + DeviceName: deviceName, + }) + suite.NoError(err) + + // Create Activity + end := time.Now() + start := end.AddDate(0, 0, -9) + var counter int64 = 0 + + for d := start; d.After(end) == false; d = d.AddDate(0, 0, 1) { + counter += 1 + + // Add Item + activity, err := suite.dbm.Queries.AddActivity(suite.dbm.Ctx, AddActivityParams{ + DocumentID: documentID, + DeviceID: deviceID, + UserID: userID, + StartTime: d.UTC().Format(time.RFC3339), + Duration: 60, + StartPercentage: float64(counter) / 100.0, + EndPercentage: float64(counter+1) / 100.0, }) - assert.Nil(t, err, "should have nil err") - assert.Equal(t, int64(1), changed) + suite.Nil(err, fmt.Sprintf("[%d] should have nil err for add activity", counter)) + suite.Equal(counter, activity.ID, fmt.Sprintf("[%d] should have correct id for add activity", counter)) + } - user, err := dt.dbm.Queries.GetUser(dt.dbm.Ctx, userID) - - assert.Nil(t, err, "should have nil err") - assert.Equal(t, userPass, *user.Pass) - }) + // Initiate Cache + err = suite.dbm.CacheTempTables() + suite.NoError(err) } -func (dt *databaseTest) TestDocument() { - dt.Run("Document", func(t *testing.T) { - doc, err := dt.dbm.Queries.UpsertDocument(dt.dbm.Ctx, UpsertDocumentParams{ - ID: documentID, - Title: &documentTitle, - Author: &documentAuthor, - }) +// DOCUMENT - TODO: +// - 󰊕 (q *Queries) DeleteDocument +// - 󰊕 (q *Queries) GetDeletedDocuments +// - 󰊕 (q *Queries) GetDocument +// - 󰊕 (q *Queries) GetDocumentProgress +// - 󰊕 (q *Queries) GetDocumentWithStats +// - 󰊕 (q *Queries) GetDocuments +// - 󰊕 (q *Queries) GetDocumentsSize +// - 󰊕 (q *Queries) GetDocumentsWithStats +// - 󰊕 (q *Queries) GetMissingDocuments +// - 󰊕 (q *Queries) GetWantedDocuments +// - 󰊕 (q *Queries) UpsertDocument +func (suite *DatabaseTestSuite) TestDocument() { + testDocID := "docid1" - assert.Nil(t, err, "should have nil err") - assert.Equal(t, documentID, doc.ID, "should have document id") - assert.Equal(t, documentTitle, *doc.Title, "should have document title") - assert.Equal(t, documentAuthor, *doc.Author, "should have document author") + doc, err := suite.dbm.Queries.UpsertDocument(suite.dbm.Ctx, UpsertDocumentParams{ + ID: testDocID, + Title: &documentTitle, + Author: &documentAuthor, }) + + suite.Nil(err, "should have nil err") + suite.Equal(testDocID, doc.ID, "should have document id") + suite.Equal(documentTitle, *doc.Title, "should have document title") + suite.Equal(documentAuthor, *doc.Author, "should have document author") } -func (dt *databaseTest) TestDevice() { - dt.Run("Device", func(t *testing.T) { - device, err := dt.dbm.Queries.UpsertDevice(dt.dbm.Ctx, UpsertDeviceParams{ - ID: deviceID, - UserID: userID, - DeviceName: deviceName, - }) - - assert.Nil(t, err, "should have nil err") - assert.Equal(t, deviceID, device.ID, "should have device id") - assert.Equal(t, userID, device.UserID, "should have user id") - assert.Equal(t, deviceName, device.DeviceName, "should have device name") +// DEVICES - TODO: +// - 󰊕 (q *Queries) GetDevice +// - 󰊕 (q *Queries) GetDevices +// - 󰊕 (q *Queries) UpsertDevice +func (suite *DatabaseTestSuite) TestDevice() { + testDevice := "dev123" + device, err := suite.dbm.Queries.UpsertDevice(suite.dbm.Ctx, UpsertDeviceParams{ + ID: testDevice, + UserID: userID, + DeviceName: deviceName, }) + + suite.Nil(err, "should have nil err") + suite.Equal(testDevice, device.ID, "should have device id") + suite.Equal(userID, device.UserID, "should have user id") + suite.Equal(deviceName, device.DeviceName, "should have device name") } -func (dt *databaseTest) TestActivity() { - dt.Run("Progress", func(t *testing.T) { - // 10 Activities, 10 Days - end := time.Now() - start := end.AddDate(0, 0, -9) - var counter int64 = 0 - - for d := start; d.After(end) == false; d = d.AddDate(0, 0, 1) { - counter += 1 - - // Add Item - activity, err := dt.dbm.Queries.AddActivity(dt.dbm.Ctx, AddActivityParams{ - DocumentID: documentID, - DeviceID: deviceID, - UserID: userID, - StartTime: d.UTC().Format(time.RFC3339), - Duration: 60, - StartPercentage: float64(counter) / 100.0, - EndPercentage: float64(counter+1) / 100.0, - }) - - assert.Nil(t, err, fmt.Sprintf("[%d] should have nil err for add activity", counter)) - assert.Equal(t, counter, activity.ID, fmt.Sprintf("[%d] should have correct id for add activity", counter)) - } - - // Initiate Cache - dt.dbm.CacheTempTables() - - // Validate Exists - existsRows, err := dt.dbm.Queries.GetActivity(dt.dbm.Ctx, GetActivityParams{ - UserID: userID, - Offset: 0, - Limit: 50, - }) - - assert.Nil(t, err, "should have nil err for get activity") - assert.Len(t, existsRows, 10, "should have correct number of rows get activity") - - // Validate Doesn't Exist - doesntExistsRows, err := dt.dbm.Queries.GetActivity(dt.dbm.Ctx, GetActivityParams{ - UserID: userID, - DocumentID: "unknownDoc", - DocFilter: true, - Offset: 0, - Limit: 50, - }) - - assert.Nil(t, err, "should have nil err for get activity") - assert.Len(t, doesntExistsRows, 0, "should have no rows") +// ACTIVITY - TODO: +// - 󰊕 (q *Queries) AddActivity +// - 󰊕 (q *Queries) GetActivity +// - 󰊕 (q *Queries) GetLastActivity +func (suite *DatabaseTestSuite) TestActivity() { + // Validate Exists + existsRows, err := suite.dbm.Queries.GetActivity(suite.dbm.Ctx, GetActivityParams{ + UserID: userID, + Offset: 0, + Limit: 50, }) + + suite.Nil(err, "should have nil err for get activity") + suite.Len(existsRows, 10, "should have correct number of rows get activity") + + // Validate Doesn't Exist + doesntExistsRows, err := suite.dbm.Queries.GetActivity(suite.dbm.Ctx, GetActivityParams{ + UserID: userID, + DocumentID: "unknownDoc", + DocFilter: true, + Offset: 0, + Limit: 50, + }) + + suite.Nil(err, "should have nil err for get activity") + suite.Len(doesntExistsRows, 0, "should have no rows") } -func (dt *databaseTest) TestDailyReadStats() { - dt.Run("DailyReadStats", func(t *testing.T) { - readStats, err := dt.dbm.Queries.GetDailyReadStats(dt.dbm.Ctx, userID) +// MISC - TODO: +// - 󰊕 (q *Queries) AddMetadata +// - 󰊕 (q *Queries) GetDailyReadStats +// - 󰊕 (q *Queries) GetDatabaseInfo +// - 󰊕 (q *Queries) UpdateSettings +func (suite *DatabaseTestSuite) TestGetDailyReadStats() { + readStats, err := suite.dbm.Queries.GetDailyReadStats(suite.dbm.Ctx, userID) - assert.Nil(t, err, "should have nil err") - assert.Len(t, readStats, 30, "should have length of 30") + suite.Nil(err, "should have nil err") + suite.Len(readStats, 30, "should have length of 30") - // Validate 1 Minute / Day - Last 10 Days - for i := 0; i < 10; i++ { - stat := readStats[i] - assert.Equal(t, int64(1), stat.MinutesRead, "should have one minute read") - } + // Validate 1 Minute / Day - Last 10 Days + for i := 0; i < 10; i++ { + stat := readStats[i] + suite.Equal(int64(1), stat.MinutesRead, "should have one minute read") + } - // Validate 0 Minute / Day - Remaining 20 Days - for i := 10; i < 30; i++ { - stat := readStats[i] - assert.Equal(t, int64(0), stat.MinutesRead, "should have zero minutes read") - } - }) + // Validate 0 Minute / Day - Remaining 20 Days + for i := 10; i < 30; i++ { + stat := readStats[i] + suite.Equal(int64(0), stat.MinutesRead, "should have zero minutes read") + } } diff --git a/database/users_test.go b/database/users_test.go new file mode 100644 index 0000000..7a6de8e --- /dev/null +++ b/database/users_test.go @@ -0,0 +1,204 @@ +package database + +import ( + "database/sql" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "reichard.io/antholume/config" + "reichard.io/antholume/utils" +) + +var ( + testUserID string = "testUser" + testUserPass string = "testPass" +) + +type UsersTestSuite struct { + suite.Suite + dbm *DBManager +} + +func TestUsers(t *testing.T) { + suite.Run(t, new(UsersTestSuite)) +} + +func (suite *UsersTestSuite) SetupTest() { + cfg := config.Config{ + DBType: "memory", + } + + suite.dbm = NewMgr(&cfg) + + // Create User + rawAuthHash, _ := utils.GenerateToken(64) + authHash := fmt.Sprintf("%x", rawAuthHash) + _, err := suite.dbm.Queries.CreateUser(suite.dbm.Ctx, CreateUserParams{ + ID: testUserID, + Pass: &testUserPass, + AuthHash: &authHash, + }) + suite.NoError(err) + + // Create Document + _, err = suite.dbm.Queries.UpsertDocument(suite.dbm.Ctx, UpsertDocumentParams{ + ID: documentID, + Title: &documentTitle, + Author: &documentAuthor, + Words: &documentWords, + }) + suite.NoError(err) + + // Create Device + _, err = suite.dbm.Queries.UpsertDevice(suite.dbm.Ctx, UpsertDeviceParams{ + ID: deviceID, + UserID: testUserID, + DeviceName: deviceName, + }) + suite.NoError(err) +} + +func (suite *UsersTestSuite) TestGetUser() { + user, err := suite.dbm.Queries.GetUser(suite.dbm.Ctx, testUserID) + suite.Nil(err, "should have nil err") + suite.Equal(testUserPass, *user.Pass) +} + +func (suite *UsersTestSuite) TestCreateUser() { + testUser := "user1" + testPass := "pass1" + + // Generate Auth Hash + rawAuthHash, err := utils.GenerateToken(64) + suite.Nil(err, "should have nil err") + + authHash := fmt.Sprintf("%x", rawAuthHash) + changed, err := suite.dbm.Queries.CreateUser(suite.dbm.Ctx, CreateUserParams{ + ID: testUser, + Pass: &testPass, + AuthHash: &authHash, + }) + + suite.Nil(err, "should have nil err") + suite.Equal(int64(1), changed) + + user, err := suite.dbm.Queries.GetUser(suite.dbm.Ctx, testUser) + suite.Nil(err, "should have nil err") + suite.Equal(testPass, *user.Pass) +} + +func (suite *UsersTestSuite) TestDeleteUser() { + changed, err := suite.dbm.Queries.DeleteUser(suite.dbm.Ctx, testUserID) + suite.Nil(err, "should have nil err") + suite.Equal(int64(1), changed, "should have one changed row") + + _, err = suite.dbm.Queries.GetUser(suite.dbm.Ctx, testUserID) + suite.ErrorIs(err, sql.ErrNoRows, "should have no rows error") +} + +func (suite *UsersTestSuite) TestGetUsers() { + users, err := suite.dbm.Queries.GetUsers(suite.dbm.Ctx) + suite.Nil(err, "should have nil err") + suite.Len(users, 1, "should have single user") +} + +func (suite *UsersTestSuite) TestUpdateUser() { + newPassword := "newPass123" + user, err := suite.dbm.Queries.UpdateUser(suite.dbm.Ctx, UpdateUserParams{ + UserID: testUserID, + Password: &newPassword, + }) + suite.Nil(err, "should have nil err") + suite.Equal(newPassword, *user.Pass, "should have new password") +} + +func (suite *UsersTestSuite) TestGetUserStatistics() { + err := suite.dbm.CacheTempTables() + suite.NoError(err) + + // Ensure Zero Items + userStats, err := suite.dbm.Queries.GetUserStatistics(suite.dbm.Ctx) + suite.Nil(err, "should have nil err") + suite.Empty(userStats, "should be empty") + + // Create Activity + end := time.Now() + start := end.AddDate(0, 0, -9) + var counter int64 = 0 + + for d := start; d.After(end) == false; d = d.AddDate(0, 0, 1) { + counter += 1 + + // Add Item + activity, err := suite.dbm.Queries.AddActivity(suite.dbm.Ctx, AddActivityParams{ + DocumentID: documentID, + DeviceID: deviceID, + UserID: testUserID, + StartTime: d.UTC().Format(time.RFC3339), + Duration: 60, + StartPercentage: float64(counter) / 100.0, + EndPercentage: float64(counter+1) / 100.0, + }) + + suite.Nil(err, fmt.Sprintf("[%d] should have nil err for add activity", counter)) + suite.Equal(counter, activity.ID, fmt.Sprintf("[%d] should have correct id for add activity", counter)) + } + + err = suite.dbm.CacheTempTables() + suite.NoError(err) + + // Ensure One Item + userStats, err = suite.dbm.Queries.GetUserStatistics(suite.dbm.Ctx) + suite.Nil(err, "should have nil err") + suite.Len(userStats, 1, "should have length of one") +} + +func (suite *UsersTestSuite) TestGetUsersStreaks() { + err := suite.dbm.CacheTempTables() + suite.NoError(err) + + // Ensure Zero Items + userStats, err := suite.dbm.Queries.GetUserStreaks(suite.dbm.Ctx, testUserID) + suite.Nil(err, "should have nil err") + suite.Empty(userStats, "should be empty") + + // Create Activity + end := time.Now() + start := end.AddDate(0, 0, -9) + var counter int64 = 0 + + for d := start; d.After(end) == false; d = d.AddDate(0, 0, 1) { + counter += 1 + + // Add Item + activity, err := suite.dbm.Queries.AddActivity(suite.dbm.Ctx, AddActivityParams{ + DocumentID: documentID, + DeviceID: deviceID, + UserID: testUserID, + StartTime: d.UTC().Format(time.RFC3339), + Duration: 60, + StartPercentage: float64(counter) / 100.0, + EndPercentage: float64(counter+1) / 100.0, + }) + + suite.Nil(err, fmt.Sprintf("[%d] should have nil err for add activity", counter)) + suite.Equal(counter, activity.ID, fmt.Sprintf("[%d] should have correct id for add activity", counter)) + } + + err = suite.dbm.CacheTempTables() + suite.NoError(err) + + // Ensure Two Item + userStats, err = suite.dbm.Queries.GetUserStreaks(suite.dbm.Ctx, testUserID) + suite.Nil(err, "should have nil err") + suite.Len(userStats, 2, "should have length of two") + + // Ensure Streak Stats + dayStats := userStats[0] + weekStats := userStats[1] + suite.Equal(int64(10), dayStats.CurrentStreak, "should be 10 days") + suite.Greater(weekStats.CurrentStreak, int64(1), "should be 2 or 3") +}