From e66a9199a1a5602a06d4fa5aff9997f5ae685511 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Mon, 27 Sep 2021 20:23:10 +0200 Subject: [PATCH 1/5] Add timeline resolver tests --- .../models/actions/timeline_actions.go | 36 ++++++ .../models/actions/timeline_actions_test.go | 112 ++++++++++++++++++ api/graphql/models/user.go | 21 ++++ api/graphql/resolvers/media.go | 18 +-- api/graphql/resolvers/timeline.go | 27 +---- 5 files changed, 172 insertions(+), 42 deletions(-) create mode 100644 api/graphql/models/actions/timeline_actions.go create mode 100644 api/graphql/models/actions/timeline_actions_test.go diff --git a/api/graphql/models/actions/timeline_actions.go b/api/graphql/models/actions/timeline_actions.go new file mode 100644 index 00000000..d80a1c62 --- /dev/null +++ b/api/graphql/models/actions/timeline_actions.go @@ -0,0 +1,36 @@ +package actions + +import ( + "time" + + "github.com/photoview/photoview/api/graphql/models" + "gorm.io/gorm" +) + +func MyTimeline(db *gorm.DB, user *models.User, paginate *models.Pagination, onlyFavorites *bool, fromDate *time.Time) ([]*models.Media, error) { + query := db. + Joins("JOIN albums ON media.album_id = albums.id"). + Where("albums.id IN (?)", db.Table("user_albums").Select("user_albums.album_id").Where("user_id = ?", user.ID)). + Order("YEAR(media.date_shot) DESC"). + Order("MONTH(media.date_shot) DESC"). + Order("DAY(media.date_shot) DESC"). + Order("albums.title ASC"). + Order("TIME(media.date_shot) DESC") + + if fromDate != nil { + query = query.Where("media.date_shot < ?", fromDate) + } + + if onlyFavorites != nil && *onlyFavorites == true { + query = query.Where("media.id IN (?)", db.Table("user_media_data").Select("user_media_data.media_id").Where("user_media_data.user_id = ?", user.ID).Where("user_media_data.favorite = 1")) + } + + query = models.FormatSQL(query, nil, paginate) + + var media []*models.Media + if err := query.Find(&media).Error; err != nil { + return nil, err + } + + return media, nil +} diff --git a/api/graphql/models/actions/timeline_actions_test.go b/api/graphql/models/actions/timeline_actions_test.go new file mode 100644 index 00000000..92fee1cc --- /dev/null +++ b/api/graphql/models/actions/timeline_actions_test.go @@ -0,0 +1,112 @@ +package actions_test + +import ( + "testing" + "time" + + "github.com/photoview/photoview/api/graphql/models" + "github.com/photoview/photoview/api/graphql/models/actions" + "github.com/photoview/photoview/api/test_utils" + "github.com/stretchr/testify/assert" +) + +func TestMyTimeline(t *testing.T) { + db := test_utils.DatabaseTest(t) + + password := "1234" + user, err := models.RegisterUser(db, "user", &password, false) + assert.NoError(t, err) + + rootAlbum := models.Album{ + Title: "root", + Path: "/photos", + } + + assert.NoError(t, db.Save(&rootAlbum).Error) + + childAlbum := models.Album{ + Title: "subalbum", + Path: "/photos/subalbum", + ParentAlbumID: &rootAlbum.ID, + } + + assert.NoError(t, db.Save(&childAlbum).Error) + + assert.NoError(t, db.Model(&user).Association("Albums").Append(&rootAlbum)) + assert.NoError(t, db.Model(&user).Association("Albums").Append(&childAlbum)) + + media := []models.Media{ + { + Title: "pic1", + Path: "/photos/pic1", + AlbumID: rootAlbum.ID, + DateShot: time.Unix(1632758400, 0), // Sep 27 2021 16:00:00 + }, + { + Title: "pic2", + Path: "/photos/pic2", + AlbumID: rootAlbum.ID, + DateShot: time.Unix(1628762400, 0), // Aug 12 2021 10:00:00 + }, + { + Title: "pic3", + Path: "/photos/subalbum/pic3", + AlbumID: childAlbum.ID, + DateShot: time.Unix(1632763800, 0), // Sep 27 2021 17:30:00 + }, + { + Title: "pic4", + Path: "/photos/subalbum/pic4", + AlbumID: childAlbum.ID, + DateShot: time.Unix(1628775900, 0), // Aug 12 2021 13:45:00 + }, + } + + assert.NoError(t, db.Save(&media).Error) + + _, err = user.FavoriteMedia(db, media[0].ID, true) + assert.NoError(t, err) + + // Add media not owned by first user + anotherUser, err := models.RegisterUser(db, "user2", &password, false) + assert.NoError(t, err) + + anotherAlbum := models.Album{ + Title: "AnotherAlbum", + Path: "/another", + } + + assert.NoError(t, db.Save(&anotherAlbum).Error) + + anotherMedia := models.Media{ + Title: "anotherPic", + Path: "/another/anotherPic", + AlbumID: anotherAlbum.ID, + } + + assert.NoError(t, db.Save(&anotherMedia).Error) + assert.NoError(t, db.Model(&anotherUser).Association("Albums").Append(&anotherAlbum)) + + t.Run("MyTimeline with no filters", func(t *testing.T) { + timelineMedia, err := actions.MyTimeline(db, user, nil, nil, nil) + + assert.NoError(t, err) + assert.Len(t, timelineMedia, 4) + }) + + t.Run("MyTimeline with only favorites", func(t *testing.T) { + favorites := true + timelineMedia, err := actions.MyTimeline(db, user, nil, &favorites, nil) + + assert.NoError(t, err) + assert.Len(t, timelineMedia, 1) + }) + + t.Run("MyTimeline before date", func(t *testing.T) { + beforeDate := time.Unix(1629792000, 0) // Aug 24 2021 08:00:00 + timelineMedia, err := actions.MyTimeline(db, user, nil, nil, &beforeDate) + + assert.NoError(t, err) + assert.Len(t, timelineMedia, 2) + }) +} diff --git a/api/graphql/models/user.go b/api/graphql/models/user.go index 49d021a5..854885e0 100644 --- a/api/graphql/models/user.go +++ b/api/graphql/models/user.go @@ -8,6 +8,7 @@ import ( "github.com/pkg/errors" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type User struct { @@ -185,3 +186,23 @@ func (user *User) OwnsAlbum(db *gorm.DB, album *Album) (bool, error) { return len(ownedParents) > 0, nil } + +// FavoriteMedia sets/clears a media as favorite for the user +func (user *User) FavoriteMedia(db *gorm.DB, mediaID int, favorite bool) (*Media, error) { + userMediaData := UserMediaData{ + UserID: user.ID, + MediaID: mediaID, + Favorite: favorite, + } + + if err := db.Clauses(clause.OnConflict{UpdateAll: true}).Create(&userMediaData).Error; err != nil { + return nil, errors.Wrapf(err, "update user favorite media in database") + } + + var media Media + if err := db.First(&media, mediaID).Error; err != nil { + return nil, errors.Wrap(err, "get media from database after favorite update") + } + + return &media, nil +} diff --git a/api/graphql/resolvers/media.go b/api/graphql/resolvers/media.go index a6601756..66c5a711 100644 --- a/api/graphql/resolvers/media.go +++ b/api/graphql/resolvers/media.go @@ -11,7 +11,6 @@ import ( "github.com/photoview/photoview/api/graphql/models/actions" "github.com/photoview/photoview/api/scanner/face_detection" "github.com/pkg/errors" - "gorm.io/gorm/clause" ) func (r *queryResolver) MyMedia(ctx context.Context, order *models.Ordering, paginate *models.Pagination) ([]*models.Media, error) { @@ -198,22 +197,7 @@ func (r *mutationResolver) FavoriteMedia(ctx context.Context, mediaID int, favor return nil, auth.ErrUnauthorized } - userMediaData := models.UserMediaData{ - UserID: user.ID, - MediaID: mediaID, - Favorite: favorite, - } - - if err := r.Database.Clauses(clause.OnConflict{UpdateAll: true}).Create(&userMediaData).Error; err != nil { - return nil, errors.Wrapf(err, "update user favorite media in database") - } - - var media models.Media - if err := r.Database.First(&media, mediaID).Error; err != nil { - return nil, errors.Wrap(err, "get media from database after favorite update") - } - - return &media, nil + return user.FavoriteMedia(r.Database, mediaID, favorite) } func (r *mediaResolver) Faces(ctx context.Context, media *models.Media) ([]*models.ImageFace, error) { diff --git a/api/graphql/resolvers/timeline.go b/api/graphql/resolvers/timeline.go index a26f7dd0..aef3f6ea 100644 --- a/api/graphql/resolvers/timeline.go +++ b/api/graphql/resolvers/timeline.go @@ -6,6 +6,7 @@ import ( "github.com/photoview/photoview/api/graphql/auth" "github.com/photoview/photoview/api/graphql/models" + "github.com/photoview/photoview/api/graphql/models/actions" ) func (r *queryResolver) MyTimeline(ctx context.Context, paginate *models.Pagination, onlyFavorites *bool, fromDate *time.Time) ([]*models.Media, error) { @@ -14,29 +15,5 @@ func (r *queryResolver) MyTimeline(ctx context.Context, paginate *models.Paginat return nil, auth.ErrUnauthorized } - query := r.Database. - Joins("JOIN albums ON media.album_id = albums.id"). - Where("albums.id IN (?)", r.Database.Table("user_albums").Select("user_albums.album_id").Where("user_id = ?", user.ID)). - Order("YEAR(media.date_shot) DESC"). - Order("MONTH(media.date_shot) DESC"). - Order("DAY(media.date_shot) DESC"). - Order("albums.title ASC"). - Order("TIME(media.date_shot) DESC") - - if fromDate != nil { - query = query.Where("media.date_shot < ?", fromDate) - } - - if onlyFavorites != nil && *onlyFavorites == true { - query = query.Where("media.id IN (?)", r.Database.Table("user_media_data").Select("user_media_data.media_id").Where("user_media_data.user_id = ?", user.ID).Where("user_media_data.favorite = 1")) - } - - query = models.FormatSQL(query, nil, paginate) - - var media []*models.Media - if err := query.Find(&media).Error; err != nil { - return nil, err - } - - return media, nil + return actions.MyTimeline(r.Database, user, paginate, onlyFavorites, fromDate) } From c7865a94e45306b5b4a0c7ac6f92a460a48bdf90 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Mon, 27 Sep 2021 20:43:26 +0200 Subject: [PATCH 2/5] Fix problem with postgres for MyTimeline action --- .../models/actions/timeline_actions.go | 28 +++++++++++++------ .../models/actions/timeline_actions_test.go | 5 ++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/api/graphql/models/actions/timeline_actions.go b/api/graphql/models/actions/timeline_actions.go index d80a1c62..903b8745 100644 --- a/api/graphql/models/actions/timeline_actions.go +++ b/api/graphql/models/actions/timeline_actions.go @@ -8,21 +8,33 @@ import ( ) func MyTimeline(db *gorm.DB, user *models.User, paginate *models.Pagination, onlyFavorites *bool, fromDate *time.Time) ([]*models.Media, error) { + query := db. Joins("JOIN albums ON media.album_id = albums.id"). - Where("albums.id IN (?)", db.Table("user_albums").Select("user_albums.album_id").Where("user_id = ?", user.ID)). - Order("YEAR(media.date_shot) DESC"). - Order("MONTH(media.date_shot) DESC"). - Order("DAY(media.date_shot) DESC"). - Order("albums.title ASC"). - Order("TIME(media.date_shot) DESC") + Where("albums.id IN (?)", db.Table("user_albums").Select("user_albums.album_id").Where("user_id = ?", user.ID)) + + if db.Dialector.Name() == "postgres" { + query = query. + Order("DATE_TRUNC('year', date_shot) DESC"). + Order("DATE_TRUNC('month', date_shot) DESC"). + Order("DATE_TRUNC('day', date_shot) DESC"). + Order("albums.title ASC"). + Order("media.date_shot DESC") + } else { + query = query. + Order("YEAR(media.date_shot) DESC"). + Order("MONTH(media.date_shot) DESC"). + Order("DAY(media.date_shot) DESC"). + Order("albums.title ASC"). + Order("TIME(media.date_shot) DESC") + } if fromDate != nil { query = query.Where("media.date_shot < ?", fromDate) } - if onlyFavorites != nil && *onlyFavorites == true { - query = query.Where("media.id IN (?)", db.Table("user_media_data").Select("user_media_data.media_id").Where("user_media_data.user_id = ?", user.ID).Where("user_media_data.favorite = 1")) + if onlyFavorites != nil && *onlyFavorites { + query = query.Where("media.id IN (?)", db.Table("user_media_data").Select("user_media_data.media_id").Where("user_media_data.user_id = ?", user.ID).Where("user_media_data.favorite")) } query = models.FormatSQL(query, nil, paginate) diff --git a/api/graphql/models/actions/timeline_actions_test.go b/api/graphql/models/actions/timeline_actions_test.go index 92fee1cc..9e738b52 100644 --- a/api/graphql/models/actions/timeline_actions_test.go +++ b/api/graphql/models/actions/timeline_actions_test.go @@ -92,6 +92,11 @@ func TestMyTimeline(t *testing.T) { assert.NoError(t, err) assert.Len(t, timelineMedia, 4) + + for i, title := range []string{"pic1", "pic3", "pic2", "pic4"} { + assert.Equalf(t, timelineMedia[i].Title, title, "Element %d didn't match: got %s expected %s", i, timelineMedia[i].Title, title) + } + }) t.Run("MyTimeline with only favorites", func(t *testing.T) { From 00e436a518b7b7b88d72b1cac284e54d0d513d5f Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Mon, 27 Sep 2021 20:59:41 +0200 Subject: [PATCH 3/5] Add tests for album path --- .github/workflows/tests.yml | 1 + api/graphql/models/actions/album_actions.go | 35 +++++++++++++++++++ .../models/actions/album_actions_test.go | 30 ++++++++++++++++ api/graphql/resolvers/album.go | 33 +---------------- 4 files changed, 67 insertions(+), 32 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 848aa562..507f3e7c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,6 +12,7 @@ jobs: runs-on: ubuntu-20.04 strategy: + fail-fast: false matrix: database: ['mysql', 'postgres', 'sqlite'] diff --git a/api/graphql/models/actions/album_actions.go b/api/graphql/models/actions/album_actions.go index 6787249e..2c429c8a 100644 --- a/api/graphql/models/actions/album_actions.go +++ b/api/graphql/models/actions/album_actions.go @@ -72,6 +72,41 @@ func Album(db *gorm.DB, user *models.User, id int) (*models.Album, error) { return &album, nil } +func AlbumPath(db *gorm.DB, user *models.User, album *models.Album) ([]*models.Album, error) { + var album_path []*models.Album + + err := db.Raw(` + WITH recursive path_albums AS ( + SELECT * FROM albums anchor WHERE anchor.id = ? + UNION + SELECT parent.* FROM path_albums child JOIN albums parent ON parent.id = child.parent_album_id + ) + SELECT * FROM path_albums WHERE id != ? + `, album.ID, album.ID).Scan(&album_path).Error + + // Make sure to only return albums this user owns + for i := len(album_path) - 1; i >= 0; i-- { + album := album_path[i] + + owns, err := user.OwnsAlbum(db, album) + if err != nil { + return nil, err + } + + if !owns { + album_path = album_path[i+1:] + break + } + + } + + if err != nil { + return nil, err + } + + return album_path, nil +} + func SetAlbumCover(db *gorm.DB, user *models.User, mediaID int) (*models.Album, error) { var media models.Media diff --git a/api/graphql/models/actions/album_actions_test.go b/api/graphql/models/actions/album_actions_test.go index 3cdc4e8d..99a76bfb 100644 --- a/api/graphql/models/actions/album_actions_test.go +++ b/api/graphql/models/actions/album_actions_test.go @@ -9,6 +9,36 @@ import ( "github.com/stretchr/testify/assert" ) +func TestAlbumPath(t *testing.T) { + db := test_utils.DatabaseTest(t) + + album := models.Album{ + Title: "Three", + Path: "/one/two/three", + ParentAlbum: &models.Album{ + Title: "Two", + Path: "/one/two", + ParentAlbum: &models.Album{ + Title: "One", + Path: "/one", + }, + }, + } + + assert.NoError(t, db.Save(&album).Error) + + user, err := models.RegisterUser(db, "user", nil, false) + assert.NoError(t, err) + + db.Model(&user).Association("Albums").Append(album.ParentAlbum.ParentAlbum) + + albumPath, err := actions.AlbumPath(db, user, &album) + assert.NoError(t, err) + assert.Len(t, albumPath, 2) + assert.Equal(t, "Two", albumPath[0].Title) + assert.Equal(t, "One", albumPath[1].Title) +} + func TestAlbumCover(t *testing.T) { db := test_utils.DatabaseTest(t) diff --git a/api/graphql/resolvers/album.go b/api/graphql/resolvers/album.go index b89388e9..d57df3b6 100644 --- a/api/graphql/resolvers/album.go +++ b/api/graphql/resolvers/album.go @@ -131,38 +131,7 @@ func (r *albumResolver) Path(ctx context.Context, obj *models.Album) ([]*models. return empty, nil } - var album_path []*models.Album - - err := r.Database.Raw(` - WITH recursive path_albums AS ( - SELECT * FROM albums anchor WHERE anchor.id = ? - UNION - SELECT parent.* FROM path_albums child JOIN albums parent ON parent.id = child.parent_album_id - ) - SELECT * FROM path_albums WHERE id != ? - `, obj.ID, obj.ID).Scan(&album_path).Error - - // Make sure to only return albums this user owns - for i := len(album_path) - 1; i >= 0; i-- { - album := album_path[i] - - owns, err := user.OwnsAlbum(r.Database, album) - if err != nil { - return nil, err - } - - if !owns { - album_path = album_path[i+1:] - break - } - - } - - if err != nil { - return nil, err - } - - return album_path, nil + return actions.AlbumPath(r.Database, user, obj) } // Takes album_id, resets album.cover_id to 0 (null) From bb19d18c42e69f92192bd68aadf0c7ce151c9bd6 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Mon, 27 Sep 2021 21:20:47 +0200 Subject: [PATCH 4/5] Add test for user favorite media --- api/graphql/models/user_test.go | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/api/graphql/models/user_test.go b/api/graphql/models/user_test.go index d0a1cf62..1daf38a0 100644 --- a/api/graphql/models/user_test.go +++ b/api/graphql/models/user_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/photoview/photoview/api/dataloader" "github.com/photoview/photoview/api/graphql/models" "github.com/photoview/photoview/api/test_utils" "github.com/stretchr/testify/assert" @@ -183,3 +184,48 @@ func TestUserOwnsAlbum(t *testing.T) { assert.NoError(t, err) assert.False(t, owns) } + +func TestUserFavoriteMedia(t *testing.T) { + db := test_utils.DatabaseTest(t) + + user, err := models.RegisterUser(db, "user1", nil, false) + assert.NoError(t, err) + + rootAlbum := models.Album{ + Title: "root", + Path: "/photos", + } + + assert.NoError(t, db.Save(&rootAlbum).Error) + assert.NoError(t, db.Model(&user).Association("Albums").Append(&rootAlbum)) + + media := models.Media{ + Title: "pic1", + Path: "/photos/pic1", + AlbumID: rootAlbum.ID, + } + + assert.NoError(t, db.Save(&media).Error) + + // test that it starts out being false + favourite, err := dataloader.NewUserFavoriteLoader(db).Load(&models.UserMediaData{ + UserID: user.ID, + MediaID: media.ID, + }) + + assert.NoError(t, err) + assert.False(t, favourite) + + favMedia, err := user.FavoriteMedia(db, media.ID, true) + assert.NoError(t, err) + assert.NotNil(t, favMedia) + + // test that it is now true + favourite, err = dataloader.NewUserFavoriteLoader(db).Load(&models.UserMediaData{ + UserID: user.ID, + MediaID: media.ID, + }) + + assert.NoError(t, err) + assert.True(t, favourite) +} From c977f076dba7076244405b8b6dcfc4bd4b1ed10a Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Mon, 27 Sep 2021 21:26:30 +0200 Subject: [PATCH 5/5] Fix timeline for sqlite --- api/graphql/models/actions/photoview_test.db | Bin 0 -> 167936 bytes api/graphql/models/actions/timeline_actions.go | 10 ++++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 api/graphql/models/actions/photoview_test.db diff --git a/api/graphql/models/actions/photoview_test.db b/api/graphql/models/actions/photoview_test.db new file mode 100644 index 0000000000000000000000000000000000000000..51babdaa122b7adb2551a11ebb666ddf57435d4c GIT binary patch literal 167936 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