Merge pull request #339 from photoview/integration-tests

Integration tests
This commit is contained in:
Viktor Strate Kløvedal
2021-04-23 23:19:07 +02:00
committed by GitHub
25 changed files with 712 additions and 113 deletions

2
.github/mysql.testing.env vendored Normal file
View File

@@ -0,0 +1,2 @@
PHOTOVIEW_DATABASE_DRIVER=mysql
PHOTOVIEW_MYSQL_URL='photoview:photosecret@tcp(127.0.0.1)/photoview_test'

2
.github/postgres.testing.env vendored Normal file
View File

@@ -0,0 +1,2 @@
PHOTOVIEW_DATABASE_DRIVER=postgres
PHOTOVIEW_POSTGRES_URL=postgres://photoview:photosecret@127.0.0.1/photoview_test

2
.github/sqlite.testing.env vendored Normal file
View File

@@ -0,0 +1,2 @@
PHOTOVIEW_DATABASE_DRIVER=sqlite
PHOTOVIEW_SQLITE_PATH=photoview_test.db

View File

@@ -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

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@ photoview.db
photoview.db-journal
.env
testing.env
# docker
docker-compose.yml

View File

@@ -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
}

View File

@@ -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

View File

@@ -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=

View File

@@ -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)
}
}
}

View File

@@ -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)
}

View File

@@ -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))
}

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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")
}

View File

@@ -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 := "<hashed_password>"
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()))
}

View File

@@ -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")
}

View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -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)
// }
// }

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -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))
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)"
},