diff --git a/.github/mysql.testing.env b/.github/mysql.testing.env new file mode 100644 index 00000000..2f5ac13d --- /dev/null +++ b/.github/mysql.testing.env @@ -0,0 +1,2 @@ +PHOTOVIEW_DATABASE_DRIVER=mysql +PHOTOVIEW_MYSQL_URL='photoview:photosecret@tcp(127.0.0.1)/photoview_test' diff --git a/.github/postgres.testing.env b/.github/postgres.testing.env new file mode 100644 index 00000000..6099ebd7 --- /dev/null +++ b/.github/postgres.testing.env @@ -0,0 +1,2 @@ +PHOTOVIEW_DATABASE_DRIVER=postgres +PHOTOVIEW_POSTGRES_URL=postgres://photoview:photosecret@127.0.0.1/photoview_test diff --git a/.github/sqlite.testing.env b/.github/sqlite.testing.env new file mode 100644 index 00000000..a24802ed --- /dev/null +++ b/.github/sqlite.testing.env @@ -0,0 +1,2 @@ +PHOTOVIEW_DATABASE_DRIVER=sqlite +PHOTOVIEW_SQLITE_PATH=photoview_test.db diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 58528670..d82a82b7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,20 +11,62 @@ jobs: name: Test API runs-on: ubuntu-20.04 + strategy: + matrix: + database: ['mysql', 'postgres', 'sqlite'] + + services: + mariadb: + image: mariadb:10.5 + env: + MYSQL_DATABASE: photoview_test + MYSQL_USER: photoview + MYSQL_PASSWORD: photosecret + MYSQL_RANDOM_ROOT_PASSWORD: yes + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + ports: + - 3306:3306 + + postgres: + image: postgres:13.2 + env: + POSTGRES_USER: photoview + POSTGRES_PASSWORD: photosecret + POSTGRES_DB: photoview_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + defaults: run: working-directory: api steps: - - name: Set up Go 1.x + - name: Set up Go uses: actions/setup-go@v2 with: - go-version: ^1.13 + go-version: ^1.16 id: go - name: Check out code into the Go module directory uses: actions/checkout@v2 + - name: Cache Go dependencies + uses: actions/cache@v2 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Get C dependencies run: | sudo add-apt-repository ppa:strukturag/libheif @@ -43,8 +85,29 @@ jobs: - name: Build run: go build -v . + - name: Configure MySQL + if: ${{ matrix.database == 'mysql' }} + run: | + cp ../.github/mysql.testing.env testing.env + + - name: Configure Postgres + if: ${{ matrix.database == 'postgres' }} + run: | + cp ../.github/postgres.testing.env testing.env + + - name: Configure Sqlite + if: ${{ matrix.database == 'sqlite' }} + run: | + touch photoview_test.db + cp ../.github/sqlite.testing.env testing.env + - name: Test - run: go test -v ./... + run: go test ./... -v -database -filesystem -p 1 -coverprofile=coverage.txt -covermode=atomic + + - name: Upload coverage + uses: codecov/codecov-action@v1 + with: + flags: api test-ui: name: Test UI @@ -59,15 +122,28 @@ jobs: node-version: [15.x] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v2 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} + - name: Cache NPM dependencies + uses: actions/cache@v2 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- - - name: Install dependencies - run: npm clean-install + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} - - name: Test - run: npm test + - name: Install dependencies + run: npm clean-install + + - name: Test + run: npm run test:ci + + - name: Upload coverage + uses: codecov/codecov-action@v1 + with: + flags: ui diff --git a/.gitignore b/.gitignore index 541190a5..e24ed426 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ photoview.db photoview.db-journal .env +testing.env # docker docker-compose.yml diff --git a/api/database/database.go b/api/database/database.go index 82b8b61f..597e3e44 100644 --- a/api/database/database.go +++ b/api/database/database.go @@ -20,8 +20,7 @@ import ( "gorm.io/gorm/logger" ) -func getMysqlAddress() (string, error) { - addressString := utils.EnvMysqlURL.GetValue() +func GetMysqlAddress(addressString string) (string, error) { if addressString == "" { return "", errors.New(fmt.Sprintf("Environment variable %s missing, exiting", utils.EnvMysqlURL.GetName())) } @@ -37,8 +36,7 @@ func getMysqlAddress() (string, error) { return config.FormatDSN(), nil } -func getPostgresAddress() (*url.URL, error) { - addressString := utils.EnvPostgresURL.GetValue() +func GetPostgresAddress(addressString string) (*url.URL, error) { if addressString == "" { return nil, errors.New(fmt.Sprintf("Environment variable %s missing, exiting", utils.EnvPostgresURL.GetName())) } @@ -51,8 +49,7 @@ func getPostgresAddress() (*url.URL, error) { return address, nil } -func getSqliteAddress() (*url.URL, error) { - path := utils.EnvSqlitePath.GetValue() +func GetSqliteAddress(path string) (*url.URL, error) { if path == "" { path = "photoview.db" } @@ -73,11 +70,11 @@ func getSqliteAddress() (*url.URL, error) { return address, nil } -func configureDatabase(config *gorm.Config) (*gorm.DB, error) { +func ConfigureDatabase(config *gorm.Config) (*gorm.DB, error) { var databaseDialect gorm.Dialector switch drivers.DatabaseDriver() { case drivers.DatabaseDriverMysql: - mysqlAddress, err := getMysqlAddress() + mysqlAddress, err := GetMysqlAddress(utils.EnvMysqlURL.GetValue()) if err != nil { return nil, err } @@ -85,7 +82,7 @@ func configureDatabase(config *gorm.Config) (*gorm.DB, error) { databaseDialect = gorm_mysql.Open(mysqlAddress) case drivers.DatabaseDriverSqlite: - sqliteAddress, err := getSqliteAddress() + sqliteAddress, err := GetSqliteAddress(utils.EnvSqlitePath.GetValue()) if err != nil { return nil, err } @@ -93,7 +90,7 @@ func configureDatabase(config *gorm.Config) (*gorm.DB, error) { databaseDialect = sqlite.Open(sqliteAddress.String()) case drivers.DatabaseDriverPostgres: - postgresAddress, err := getPostgresAddress() + postgresAddress, err := GetPostgresAddress(utils.EnvPostgresURL.GetValue()) if err != nil { return nil, err } @@ -126,7 +123,7 @@ func SetupDatabase() (*gorm.DB, error) { for retryCount := 1; retryCount <= 5; retryCount++ { var err error - db, err = configureDatabase(&config) + db, err = ConfigureDatabase(&config) if err == nil { sqlDB, dbErr := db.DB() if dbErr != nil { @@ -151,24 +148,26 @@ func SetupDatabase() (*gorm.DB, error) { return db, nil } -func MigrateDatabase(db *gorm.DB) error { - err := db.AutoMigrate( - &models.User{}, - &models.AccessToken{}, - &models.SiteInfo{}, - &models.Media{}, - &models.MediaURL{}, - &models.Album{}, - &models.MediaEXIF{}, - &models.VideoMetadata{}, - &models.ShareToken{}, - &models.UserMediaData{}, - &models.UserPreferences{}, +var database_models []interface{} = []interface{}{ + &models.User{}, + &models.AccessToken{}, + &models.SiteInfo{}, + &models.Media{}, + &models.MediaURL{}, + &models.Album{}, + &models.MediaEXIF{}, + &models.VideoMetadata{}, + &models.ShareToken{}, + &models.UserMediaData{}, + &models.UserPreferences{}, - // Face detection - &models.FaceGroup{}, - &models.ImageFace{}, - ) + // Face detection + &models.FaceGroup{}, + &models.ImageFace{}, +} + +func MigrateDatabase(db *gorm.DB) error { + err := db.AutoMigrate(database_models...) if err != nil { log.Printf("Auto migration failed: %v\n", err) @@ -188,3 +187,22 @@ func MigrateDatabase(db *gorm.DB) error { return nil } + +func ClearDatabase(db *gorm.DB) error { + err := db.Transaction(func(tx *gorm.DB) error { + sess := tx.Session(&gorm.Session{AllowGlobalUpdate: true}) + for _, model := range database_models { + if err := sess.Delete(model).Error; err != nil { + return err + } + } + + return nil + }) + + if err != nil { + return err + } + + return nil +} diff --git a/api/go.mod b/api/go.mod index 493ee735..801265e1 100644 --- a/api/go.mod +++ b/api/go.mod @@ -21,6 +21,8 @@ require ( github.com/mitchellh/mapstructure v1.4.1 // indirect github.com/pkg/errors v0.9.1 github.com/sabhiram/go-gitignore v0.0.0-20201211210132-54b8a0bf510f + github.com/stretchr/objx v0.3.0 // indirect + github.com/stretchr/testify v1.7.0 // indirect github.com/strukturag/libheif v1.11.0 github.com/vektah/gqlparser/v2 v2.1.0 github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 @@ -30,6 +32,7 @@ require ( golang.org/x/text v0.3.6 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect gopkg.in/vansante/go-ffprobe.v2 v2.0.2 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect gorm.io/driver/mysql v1.0.5 gorm.io/driver/postgres v1.0.8 gorm.io/driver/sqlite v1.1.4 diff --git a/api/go.sum b/api/go.sum index 02e0d65c..08336cc9 100644 --- a/api/go.sum +++ b/api/go.sum @@ -539,6 +539,8 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As= +github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -546,6 +548,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/strukturag/libheif v1.11.0 h1:HaWu5re98INSXNq7C8o5AwLcv2qD8+U7a+jVCpGWemI= github.com/strukturag/libheif v1.11.0/go.mod h1:E/PNRlmVtrtj9j2AvBZlrO4dsBDu6KfwDZn7X1Ce8Ks= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8 h1:ndzgwNDnKIqyCvHTXaCqh9KlOWKvBry6nuXMJmonVsE= @@ -756,6 +760,8 @@ gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.0.5 h1:WAAmvLK2rG0tCOqrf5XcLi2QUwugd4rcVJ/W3aoon9o= gorm.io/driver/mysql v1.0.5/go.mod h1:N1OIhHAIhx5SunkMGqWbGFVeh4yTNWKmMo1GOAsohLI= gorm.io/driver/postgres v1.0.8 h1:PAgM+PaHOSAeroTjHkCHCBIHHoBIf9RgPWGo8dF2DA8= diff --git a/api/graphql/models/album_test.go b/api/graphql/models/album_test.go new file mode 100644 index 00000000..e73e8692 --- /dev/null +++ b/api/graphql/models/album_test.go @@ -0,0 +1,93 @@ +package models_test + +import ( + "testing" + + "github.com/photoview/photoview/api/graphql/models" + "github.com/photoview/photoview/api/test_utils" + "github.com/stretchr/testify/assert" +) + +func TestAlbumGetChildren(t *testing.T) { + db := test_utils.DatabaseTest(t) + + rootAlbum := models.Album{ + Title: "root", + Path: "/photos", + } + + if !assert.NoError(t, db.Save(&rootAlbum).Error) { + return + } + + children := []models.Album{ + { + Title: "child1", + Path: "/photos/child1", + ParentAlbumID: &rootAlbum.ID, + }, + { + Title: "child2", + Path: "/photos/child2", + ParentAlbumID: &rootAlbum.ID, + }, + { + Title: "not_child", + Path: "/videos", + }, + } + + if !assert.NoError(t, db.Save(&children).Error) { + return + } + + sub_child := models.Album{ + Title: "subchild", + Path: "/photos/child1/subchild", + ParentAlbumID: &children[0].ID, + } + + if !assert.NoError(t, db.Save(&sub_child).Error) { + return + } + + root_children, err := rootAlbum.GetChildren(db, nil) + if !assert.NoError(t, err) { + return + } + + expected_children := []*models.Album{ + { + Title: "root", + Path: "/photos", + }, + { + Title: "child1", + Path: "/photos/child1", + }, + { + Title: "child2", + Path: "/photos/child2", + }, + { + Title: "subchild", + Path: "/photos/child1/subchild", + }, + } + + assert.Equal(t, len(expected_children), len(root_children)) + + for _, expected := range expected_children { + found_expected := false + for _, item := range root_children { + if item.Title == expected.Title && item.Path == expected.Path { + found_expected = true + break + } + } + if !found_expected { + assert.Failf(t, "root children did not match", "expected to find item: %v", expected) + } + } + +} diff --git a/api/graphql/models/media_test.go b/api/graphql/models/media_test.go index 39ffac72..2cc40402 100644 --- a/api/graphql/models/media_test.go +++ b/api/graphql/models/media_test.go @@ -1,7 +1,11 @@ -package models +package models_test import ( + "fmt" "testing" + + "github.com/photoview/photoview/api/graphql/models" + "github.com/stretchr/testify/assert" ) func TestSanitizeMediaName(t *testing.T) { @@ -12,9 +16,36 @@ func TestSanitizeMediaName(t *testing.T) { {"..\\/", "__"}, } - for i, test := range tests { - if SanitizeMediaName(test[0]) != test[1] { - t.Errorf("SanitizeMediaName test %d failed: got '%s', expected '%s'", i, test[1], SanitizeMediaName(test[0])) - } + for _, test := range tests { + t.Run(fmt.Sprintf("sanitize: %s", test[0]), func(t *testing.T) { + assert.Equal(t, test[1], models.SanitizeMediaName(test[0])) + }) } } + +func TestMediaURLCachePath(t *testing.T) { + mediaUrl := models.MediaURL{} + mediaUrl.Media = nil + + _, err := mediaUrl.CachedPath() + assert.EqualError(t, err, "mediaURL.Media is nil") + + mediaUrl = models.MediaURL{ + Purpose: models.PhotoThumbnail, + MediaID: 1, + Media: &models.Media{ + Model: models.Model{ + ID: 1, + }, + Title: "media.jpg", + AlbumID: 2, + }, + MediaName: "media_thumb.jpg", + } + + path, err := mediaUrl.CachedPath() + + assert.NoError(t, err) + assert.Equal(t, "media_cache/2/1/media_thumb.jpg", path) + +} diff --git a/api/graphql/models/models_test.go b/api/graphql/models/models_test.go new file mode 100644 index 00000000..615307e2 --- /dev/null +++ b/api/graphql/models/models_test.go @@ -0,0 +1,12 @@ +package models_test + +import ( + "os" + "testing" + + "github.com/photoview/photoview/api/test_utils" +) + +func TestMain(m *testing.M) { + os.Exit(test_utils.IntegrationTestRun(m)) +} diff --git a/api/graphql/models/site_info.go b/api/graphql/models/site_info.go index 76d2dd7e..593f4fa7 100644 --- a/api/graphql/models/site_info.go +++ b/api/graphql/models/site_info.go @@ -16,32 +16,37 @@ func (SiteInfo) TableName() string { return "site_info" } +func DefaultSiteInfo() SiteInfo { + defaultConcurrentWorkers := 3 + if db_drivers.DatabaseDriver() == db_drivers.DatabaseDriverSqlite { + defaultConcurrentWorkers = 1 + } + + return SiteInfo{ + InitialSetup: true, + PeriodicScanInterval: 0, + ConcurrentWorkers: defaultConcurrentWorkers, + } +} + // GetSiteInfo gets the site info row from the database, and creates it if it does not exist func GetSiteInfo(db *gorm.DB) (*SiteInfo, error) { - var siteInfo SiteInfo + var siteInfo []*SiteInfo - if err := db.First(&siteInfo).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - - defaultConcurrentWorkers := 3 - if db_drivers.DatabaseDriver() == db_drivers.DatabaseDriverSqlite { - defaultConcurrentWorkers = 1 - } - - siteInfo = SiteInfo{ - InitialSetup: true, - PeriodicScanInterval: 0, - ConcurrentWorkers: defaultConcurrentWorkers, - } - - if err := db.Create(&siteInfo).Error; err != nil { - return nil, errors.Wrap(err, "initialize site_info") - } - } else { - return nil, errors.Wrap(err, "get site info from database") - } + if err := db.Limit(1).Find(&siteInfo).Error; err != nil { + return nil, errors.Wrap(err, "get site info from database") } - return &siteInfo, nil + if len(siteInfo) == 0 { + newSiteInfo := DefaultSiteInfo() + + if err := db.Create(&newSiteInfo).Error; err != nil { + return nil, errors.Wrap(err, "initialize site_info") + } + + return &newSiteInfo, nil + } else { + return siteInfo[0], nil + } } diff --git a/api/graphql/models/site_info_test.go b/api/graphql/models/site_info_test.go new file mode 100644 index 00000000..30ce0628 --- /dev/null +++ b/api/graphql/models/site_info_test.go @@ -0,0 +1,41 @@ +package models_test + +import ( + "testing" + + "github.com/photoview/photoview/api/graphql/models" + "github.com/photoview/photoview/api/test_utils" + "github.com/stretchr/testify/assert" + "gorm.io/gorm" +) + +func TestSiteInfo(t *testing.T) { + db := test_utils.DatabaseTest(t) + + site_info, err := models.GetSiteInfo(db) + if !assert.NoError(t, err) { + return + } + + assert.Equal(t, models.DefaultSiteInfo(), *site_info) + + site_info.InitialSetup = false + site_info.PeriodicScanInterval = 360 + site_info.ConcurrentWorkers = 10 + + if !assert.NoError(t, db.Session(&gorm.Session{AllowGlobalUpdate: true}).Save(&site_info).Error) { + return + } + + site_info, err = models.GetSiteInfo(db) + if !assert.NoError(t, err) { + return + } + + assert.Equal(t, models.SiteInfo{ + InitialSetup: false, + PeriodicScanInterval: 360, + ConcurrentWorkers: 10, + }, *site_info) + +} diff --git a/api/graphql/models/user.go b/api/graphql/models/user.go index 244221b4..b3879a19 100644 --- a/api/graphql/models/user.go +++ b/api/graphql/models/user.go @@ -108,14 +108,9 @@ func ValidRootPath(rootPath string) bool { } func RegisterUser(db *gorm.DB, username string, password *string, admin bool) (*User, error) { - // if !ValidRootPath(rootPath) { - // return nil, ErrorInvalidRootPath - // } - user := User{ Username: username, - // RootPath: rootPath, - Admin: admin, + Admin: admin, } if password != nil { @@ -148,11 +143,6 @@ func (user *User) GenerateAccessToken(db *gorm.DB) (*AccessToken, error) { token_value := string(bytes) expire := time.Now().Add(14 * 24 * time.Hour) - // expireString := expire.UTC().Format("2006-01-02 15:04:05") - - // if _, err := database.Exec("INSERT INTO access_token (value, expire, user_id) VALUES (?, ?, ?)", token_value, expireString, user.UserID); err != nil { - // return nil, err - // } token := AccessToken{ UserID: user.ID, @@ -183,14 +173,11 @@ func (user *User) FillAlbums(db *gorm.DB) error { } func (user *User) OwnsAlbum(db *gorm.DB, album *Album) (bool, error) { - - // user.QueryUserAlbums(db, db.Where("id = ?", album.ID)) - // TODO: Implement this - return true, nil + panic("not implemented") } func (user *User) OwnsMedia(db *gorm.DB, media *Media) (bool, error) { // TODO: implement this - return true, nil + panic("not implemented") } diff --git a/api/graphql/models/user_test.go b/api/graphql/models/user_test.go new file mode 100644 index 00000000..9f945132 --- /dev/null +++ b/api/graphql/models/user_test.go @@ -0,0 +1,60 @@ +package models_test + +import ( + "testing" + "time" + + "github.com/photoview/photoview/api/graphql/models" + "github.com/photoview/photoview/api/test_utils" + "github.com/stretchr/testify/assert" +) + +func TestUserRegistrationAuthorization(t *testing.T) { + db := test_utils.DatabaseTest(t) + + password := "1234" + user, err := models.RegisterUser(db, "admin", &password, true) + if !assert.NoError(t, err) { + return + } + + assert.NotNil(t, user) + assert.EqualValues(t, "admin", user.Username) + assert.NotNil(t, user.Password) + assert.NotEqualValues(t, "1234", user.Password) // should be hashed + assert.True(t, user.Admin) + + user, err = models.AuthorizeUser(db, "admin", "1234") + if !assert.NoError(t, err) { + return + } + + assert.NotNil(t, user) + assert.EqualValues(t, "admin", user.Username) + +} + +func TestAccessToken(t *testing.T) { + db := test_utils.DatabaseTest(t) + + pass := "" + user := models.User{ + Username: "user1", + Password: &pass, + Admin: false, + } + + if !assert.NoError(t, db.Save(&user).Error) { + return + } + + access_token, err := user.GenerateAccessToken(db) + if !assert.NoError(t, err) { + return + } + + assert.NotNil(t, access_token) + assert.Equal(t, user.ID, access_token.UserID) + assert.NotEmpty(t, access_token.Value) + assert.True(t, access_token.Expire.After(time.Now())) +} diff --git a/api/scanner/exif/exif.go b/api/scanner/exif/exif.go index 082f26c4..0f40a50a 100644 --- a/api/scanner/exif/exif.go +++ b/api/scanner/exif/exif.go @@ -11,7 +11,7 @@ import ( ) type exifParser interface { - ParseExif(media *models.Media) (*models.MediaEXIF, error) + ParseExif(media_path string) (*models.MediaEXIF, error) } var use_exiftool bool = false @@ -55,7 +55,7 @@ func SaveEXIF(tx *gorm.DB, media *models.Media) (*models.MediaEXIF, error) { parser = &internalExifParser{} } - exif, err := parser.ParseExif(media) + exif, err := parser.ParseExif(media.Path) if err != nil { return nil, errors.Wrap(err, "failed to parse exif data") } diff --git a/api/scanner/exif/exif_parser_external.go b/api/scanner/exif/exif_parser_external.go index b151f1c3..61d958ec 100644 --- a/api/scanner/exif/exif_parser_external.go +++ b/api/scanner/exif/exif_parser_external.go @@ -10,7 +10,7 @@ import ( type externalExifParser struct{} -func (p *externalExifParser) ParseExif(media *models.Media) (returnExif *models.MediaEXIF, returnErr error) { +func (p *externalExifParser) ParseExif(media_path string) (returnExif *models.MediaEXIF, returnErr error) { // ExifTool - No print conversion mode et, err := exiftool.NewExiftool(exiftool.NoPrintConversion()) if err != nil { @@ -19,8 +19,9 @@ func (p *externalExifParser) ParseExif(media *models.Media) (returnExif *models. } defer et.Close() - fileInfos := et.ExtractMetadata(media.Path) + fileInfos := et.ExtractMetadata(media_path) newExif := models.MediaEXIF{} + found_exif := false for _, fileInfo := range fileInfos { if fileInfo.Err != nil { @@ -31,18 +32,21 @@ func (p *externalExifParser) ParseExif(media *models.Media) (returnExif *models. // Get camera model model, err := fileInfo.GetString("Model") if err == nil { + found_exif = true newExif.Camera = &model } // Get Camera make make, err := fileInfo.GetString("Make") if err == nil { + found_exif = true newExif.Maker = &make } // Get lens lens, err := fileInfo.GetString("LensModel") if err == nil { + found_exif = true newExif.Lens = &lens } @@ -52,6 +56,7 @@ func (p *externalExifParser) ParseExif(media *models.Media) (returnExif *models. layout := "2006:01:02 15:04:05" dateTime, err := time.Parse(layout, date) if err == nil { + found_exif = true newExif.DateShot = &dateTime } } @@ -59,58 +64,71 @@ func (p *externalExifParser) ParseExif(media *models.Media) (returnExif *models. // Get exposure time exposureTime, err := fileInfo.GetFloat("ExposureTime") if err == nil { + found_exif = true newExif.Exposure = &exposureTime } // Get aperture aperture, err := fileInfo.GetFloat("Aperture") if err == nil { + found_exif = true newExif.Aperture = &aperture } // Get ISO iso, err := fileInfo.GetInt("ISO") if err == nil { + found_exif = true newExif.Iso = &iso } // Get focal length focalLen, err := fileInfo.GetFloat("FocalLength") if err == nil { + found_exif = true newExif.FocalLength = &focalLen } // Get flash info flash, err := fileInfo.GetInt("Flash") if err == nil { + found_exif = true newExif.Flash = &flash } // Get orientation orientation, err := fileInfo.GetInt("Orientation") if err == nil { + found_exif = true newExif.Orientation = &orientation } // Get exposure program expProgram, err := fileInfo.GetInt("ExposureProgram") if err == nil { + found_exif = true newExif.ExposureProgram = &expProgram } // GPS coordinates - longitude longitudeRaw, err := fileInfo.GetFloat("GPSLongitude") if err == nil { + found_exif = true newExif.GPSLongitude = &longitudeRaw } // GPS coordinates - latitude latitudeRaw, err := fileInfo.GetFloat("GPSLatitude") if err == nil { + found_exif = true newExif.GPSLatitude = &latitudeRaw } } + if !found_exif { + return nil, nil + } + returnExif = &newExif return } diff --git a/api/scanner/exif/exif_parser_internal.go b/api/scanner/exif/exif_parser_internal.go index 59523a32..5897e6e5 100644 --- a/api/scanner/exif/exif_parser_internal.go +++ b/api/scanner/exif/exif_parser_internal.go @@ -5,6 +5,7 @@ import ( "log" "math/big" "os" + "time" "github.com/photoview/photoview/api/graphql/models" "github.com/pkg/errors" @@ -15,8 +16,8 @@ import ( // internalExifParser is an exif parser that parses the media without the use of external tools type internalExifParser struct{} -func (p *internalExifParser) ParseExif(media *models.Media) (returnExif *models.MediaEXIF, returnErr error) { - photoFile, err := os.Open(media.Path) +func (p *internalExifParser) ParseExif(media_path string) (returnExif *models.MediaEXIF, returnErr error) { + photoFile, err := os.Open(media_path) if err != nil { return nil, err } @@ -33,29 +34,32 @@ func (p *internalExifParser) ParseExif(media *models.Media) (returnExif *models. exifTags, err := exif.Decode(photoFile) if err != nil { - return nil, errors.Wrap(err, "Could not decode EXIF") + return nil, nil + // return nil, errors.Wrap(err, "Could not decode EXIF") } newExif := models.MediaEXIF{} - model, err := p.readStringTag(exifTags, exif.Model, media) + model, err := p.readStringTag(exifTags, exif.Model, media_path) if err == nil { newExif.Camera = model } - maker, err := p.readStringTag(exifTags, exif.Make, media) + maker, err := p.readStringTag(exifTags, exif.Make, media_path) if err == nil { newExif.Maker = maker } - lens, err := p.readStringTag(exifTags, exif.LensModel, media) + lens, err := p.readStringTag(exifTags, exif.LensModel, media_path) if err == nil { newExif.Lens = lens } date, err := exifTags.DateTime() if err == nil { - newExif.DateShot = &date + _, tz := date.Zone() + date_utc := date.Add(time.Duration(tz) * time.Second).UTC() + newExif.DateShot = &date_utc } exposure, err := exifTags.Get(exif.ExposureTime) @@ -66,7 +70,7 @@ func (p *internalExifParser) ParseExif(media *models.Media) (returnExif *models. } } - apertureRat, err := p.readRationalTag(exifTags, exif.FNumber, media) + apertureRat, err := p.readRationalTag(exifTags, exif.FNumber, media_path) if err == nil { aperture, _ := apertureRat.Float64() newExif.Aperture = &aperture @@ -74,11 +78,11 @@ func (p *internalExifParser) ParseExif(media *models.Media) (returnExif *models. isoTag, err := exifTags.Get(exif.ISOSpeedRatings) if err != nil { - log.Printf("WARN: Could not read ISOSpeedRatings from EXIF: %s\n", media.Title) + log.Printf("WARN: Could not read ISOSpeedRatings from EXIF: %v\n", media_path) } else { iso, err := isoTag.Int(0) if err != nil { - log.Printf("WARN: Could not parse EXIF ISOSpeedRatings as integer: %s\n", media.Title) + log.Printf("WARN: Could not parse EXIF ISOSpeedRatings as integer: %v\n", media_path) } else { iso64 := int64(iso) newExif.Iso = &iso64 @@ -99,7 +103,7 @@ func (p *internalExifParser) ParseExif(media *models.Media) (returnExif *models. if err == nil { focalLength, err := focalLengthTag.Int(1) if err != nil { - log.Printf("WARN: Could not parse EXIF FocalLength as rational or integer: %s\n%s\n", media.Title, err) + log.Printf("WARN: Could not parse EXIF FocalLength as rational or integer: %v\n%s\n", media_path, err) } else { focalLenFloat := float64(focalLength) newExif.FocalLength = &focalLenFloat @@ -108,19 +112,19 @@ func (p *internalExifParser) ParseExif(media *models.Media) (returnExif *models. } } - flash, err := p.readIntegerTag(exifTags, exif.Flash, media) + flash, err := p.readIntegerTag(exifTags, exif.Flash, media_path) if err == nil { flash64 := int64(*flash) newExif.Flash = &flash64 } - orientation, err := p.readIntegerTag(exifTags, exif.Orientation, media) + orientation, err := p.readIntegerTag(exifTags, exif.Orientation, media_path) if err == nil { orientation64 := int64(*orientation) newExif.Orientation = &orientation64 } - exposureProgram, err := p.readIntegerTag(exifTags, exif.ExposureProgram, media) + exposureProgram, err := p.readIntegerTag(exifTags, exif.ExposureProgram, media_path) if err == nil { exposureProgram64 := int64(*exposureProgram) newExif.ExposureProgram = &exposureProgram64 @@ -136,59 +140,59 @@ func (p *internalExifParser) ParseExif(media *models.Media) (returnExif *models. return } -func (p *internalExifParser) readStringTag(tags *exif.Exif, name exif.FieldName, media *models.Media) (*string, error) { +func (p *internalExifParser) readStringTag(tags *exif.Exif, name exif.FieldName, media_path string) (*string, error) { tag, err := tags.Get(name) if err != nil { - return nil, errors.Wrapf(err, "could not read %s from EXIF: %s", name, media.Title) + return nil, errors.Wrapf(err, "could not read %s from EXIF: %s", name, media_path) } if tag != nil { value, err := tag.StringVal() if err != nil { - return nil, errors.Wrapf(err, "could not parse %s from EXIF as string: %s", name, media.Title) + return nil, errors.Wrapf(err, "could not parse %s from EXIF as string: %s", name, media_path) } return &value, nil } - log.Printf("WARN: EXIF tag %s returned null: %s\n", name, media.Title) + log.Printf("WARN: EXIF tag %s returned null: %s\n", name, media_path) return nil, errors.New("exif tag returned null") } -func (p *internalExifParser) readRationalTag(tags *exif.Exif, name exif.FieldName, media *models.Media) (*big.Rat, error) { +func (p *internalExifParser) readRationalTag(tags *exif.Exif, name exif.FieldName, media_path string) (*big.Rat, error) { tag, err := tags.Get(name) if err != nil { - return nil, errors.Wrapf(err, "could not read %s from EXIF: %s", name, media.Title) + return nil, errors.Wrapf(err, "could not read %s from EXIF: %s", name, media_path) } if tag != nil { value, err := tag.Rat(0) if err != nil { - return nil, errors.Wrapf(err, "could not parse %s from EXIF as rational: %s", name, media.Title) + return nil, errors.Wrapf(err, "could not parse %s from EXIF as rational: %s", name, media_path) } return value, nil } - log.Printf("WARN: EXIF tag %s returned null: %s\n", name, media.Title) + log.Printf("WARN: EXIF tag %s returned null: %s\n", name, media_path) return nil, errors.New("exif tag returned null") } -func (p *internalExifParser) readIntegerTag(tags *exif.Exif, name exif.FieldName, media *models.Media) (*int, error) { +func (p *internalExifParser) readIntegerTag(tags *exif.Exif, name exif.FieldName, media_path string) (*int, error) { tag, err := tags.Get(name) if err != nil { - return nil, errors.Wrapf(err, "could not read %s from EXIF: %s", name, media.Title) + return nil, errors.Wrapf(err, "could not read %s from EXIF: %s", name, media_path) } if tag != nil { value, err := tag.Int(0) if err != nil { - return nil, errors.Wrapf(err, "Could not parse %s from EXIF as integer: %s", name, media.Title) + return nil, errors.Wrapf(err, "Could not parse %s from EXIF as integer: %s", name, media_path) } return &value, nil } - log.Printf("WARN: EXIF tag %s returned null: %s\n", name, media.Title) + log.Printf("WARN: EXIF tag %s returned null: %s\n", name, media_path) return nil, errors.New("exif tag returned null") } diff --git a/api/scanner/exif/exif_test.go b/api/scanner/exif/exif_test.go new file mode 100644 index 00000000..956b2aa8 --- /dev/null +++ b/api/scanner/exif/exif_test.go @@ -0,0 +1,94 @@ +package exif + +import ( + "fmt" + "os" + "path" + "testing" + "time" + + "github.com/barasher/go-exiftool" + "github.com/photoview/photoview/api/graphql/models" + "github.com/photoview/photoview/api/test_utils" + "github.com/stretchr/testify/assert" +) + +func TestMain(m *testing.M) { + os.Exit(test_utils.IntegrationTestRun(m)) +} + +func TestExifParsers(t *testing.T) { + test_utils.FilesystemTest(t) + + parsers := []struct { + name string + parser exifParser + }{ + { + name: "internal", + parser: &internalExifParser{}, + }, + { + name: "external", + parser: &externalExifParser{}, + }, + } + + images := []struct { + path string + assert func(t *testing.T, exif *models.MediaEXIF) + }{ + { + path: "./test_data/bird.jpg", + assert: func(t *testing.T, exif *models.MediaEXIF) { + assert.WithinDuration(t, *exif.DateShot, time.Unix(1336318784, 0).UTC(), time.Minute) + assert.EqualValues(t, *exif.Camera, "Canon EOS 600D") + assert.EqualValues(t, *exif.Maker, "Canon") + assert.WithinDuration(t, *exif.DateShot, time.Unix(1336318784, 0).UTC(), time.Minute) + assert.EqualValues(t, *exif.Aperture, 6.3) + assert.EqualValues(t, *exif.Iso, 800) + assert.EqualValues(t, *exif.FocalLength, 300) + assert.EqualValues(t, *exif.Flash, 16) + assert.EqualValues(t, *exif.Orientation, 1) + assert.InDelta(t, *exif.GPSLatitude, 65.01681388888889, 0.0001) + assert.InDelta(t, *exif.GPSLongitude, 25.466863888888888, 0.0001) + }, + }, + { + path: "./test_data/stripped.jpg", + assert: func(t *testing.T, exif *models.MediaEXIF) { + assert.Nil(t, exif) + }, + }, + } + + for _, p := range parsers { + for _, img := range images { + t.Run(fmt.Sprintf("%s:%s", p.name, path.Base(img.path)), func(t *testing.T) { + + if p.name == "external" { + _, err := exiftool.NewExiftool() + if err != nil { + t.Skip("failed to get exiftool, skipping test") + } + } + + exif, err := p.parser.ParseExif(img.path) + + if assert.NoError(t, err) { + img.assert(t, exif) + } + }) + } + } +} + +// func TestExternalExifParser(t *testing.T) { +// parser := externalExifParser{} + +// exif, err := parser.ParseExif((bird_path)) + +// if assert.NoError(t, err) { +// assert.Equal(t, exif, &bird_exif) +// } +// } diff --git a/api/scanner/exif/test_data/bird.jpg b/api/scanner/exif/test_data/bird.jpg new file mode 100644 index 00000000..ea631f9e Binary files /dev/null and b/api/scanner/exif/test_data/bird.jpg differ diff --git a/api/scanner/exif/test_data/stripped.jpg b/api/scanner/exif/test_data/stripped.jpg new file mode 100644 index 00000000..4a1788e8 Binary files /dev/null and b/api/scanner/exif/test_data/stripped.jpg differ diff --git a/api/scanner/scanner_test.go b/api/scanner/scanner_test.go new file mode 100644 index 00000000..9f76d568 --- /dev/null +++ b/api/scanner/scanner_test.go @@ -0,0 +1,12 @@ +package scanner_test + +import ( + "os" + "testing" + + "github.com/photoview/photoview/api/test_utils" +) + +func TestMain(m *testing.M) { + os.Exit(test_utils.UnitTestRun(m)) +} diff --git a/api/test_utils/integration_setup.go b/api/test_utils/integration_setup.go new file mode 100644 index 00000000..edf231ab --- /dev/null +++ b/api/test_utils/integration_setup.go @@ -0,0 +1,70 @@ +package test_utils + +import ( + "flag" + "log" + "path" + "runtime" + "testing" + + "github.com/joho/godotenv" + "gorm.io/gorm" +) + +type integration_options struct { + Database *bool + Filesystem *bool +} + +var integration_flags integration_options = integration_options{ + Database: flag.Bool("database", false, "run database integration tests"), + Filesystem: flag.Bool("filesystem", false, "run filesystem integration tests"), +} + +var test_dbm TestDBManager = TestDBManager{} + +func UnitTestRun(m *testing.M) int { + flag.Parse() + return m.Run() +} + +func IntegrationTestRun(m *testing.M) int { + flag.Parse() + + if *integration_flags.Database { + _, file, _, ok := runtime.Caller(0) + if !ok { + log.Fatal("could not get runtime file path") + } + + envPath := path.Join(path.Dir(file), "..", "testing.env") + + if err := godotenv.Load(envPath); err != nil { + log.Println("No testing.env file found") + } + } + + result := m.Run() + + test_dbm.Close() + + return result +} + +func FilesystemTest(t *testing.T) { + if !*integration_flags.Filesystem { + t.Skip("Filesystem integration tests disabled") + } +} + +func DatabaseTest(t *testing.T) *gorm.DB { + if !*integration_flags.Database { + t.Skip("Database integration tests disabled") + } + + if err := test_dbm.SetupOrReset(); err != nil { + t.Fatalf("failed to setup or reset test database: %v", err) + } + + return test_dbm.DB +} diff --git a/api/test_utils/test_db_manager.go b/api/test_utils/test_db_manager.go new file mode 100644 index 00000000..03028d48 --- /dev/null +++ b/api/test_utils/test_db_manager.go @@ -0,0 +1,60 @@ +package test_utils + +import ( + "github.com/photoview/photoview/api/database" + "github.com/pkg/errors" + "gorm.io/gorm" +) + +type TestDBManager struct { + DB *gorm.DB +} + +func (dbm *TestDBManager) SetupOrReset() error { + if dbm.DB == nil { + return dbm.setup() + } else { + return dbm.reset() + } +} + +func (dbm *TestDBManager) Close() error { + if dbm.DB == nil { + return nil + } + + sqlDB, err := dbm.DB.DB() + if err != nil { + return errors.Wrap(err, "get db instance when closing test database") + } + + sqlDB.Close() + dbm.DB = nil + + return nil +} + +func (dbm *TestDBManager) setup() error { + config := gorm.Config{} + db, err := database.ConfigureDatabase(&config) + if err != nil { + return errors.Wrap(err, "configure test database") + } + + if err := database.MigrateDatabase(db); err != nil { + return errors.Wrap(err, "migrate test database") + } + + dbm.DB = db + + return nil +} + +func (dbm *TestDBManager) reset() error { + + if err := database.ClearDatabase(dbm.DB); err != nil { + return errors.Wrap(err, "reset test database") + } + + return nil +} diff --git a/ui/package.json b/ui/package.json index 83aabc91..c643bbeb 100644 --- a/ui/package.json +++ b/ui/package.json @@ -54,10 +54,12 @@ "start": "node --experimental-modules build.mjs watch", "build": "NODE_ENV=production node --experimental-modules build.mjs", "test": "npm run lint && npm run jest", + "test:ci": "npm run lint && npm run jest:ci", "lint": "npm run lint:types & npm run lint:eslint", "lint:eslint": "eslint ./src --max-warnings 0 --cache --config .eslintrc.js", "lint:types": "tsc --noemit", "jest": "jest --verbose", + "jest:ci": "jest --verbose --ci --coverage", "genSchemaTypes": "npx apollo client:codegen --target=typescript", "prepare": "(cd .. && npx husky install)" },