mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 21:01:58 +08:00
571 lines
17 KiB
Go
571 lines
17 KiB
Go
package config
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/urfave/cli/v2"
|
|
|
|
_ "github.com/jinzhu/gorm/dialects/mysql"
|
|
_ "github.com/jinzhu/gorm/dialects/sqlite"
|
|
|
|
"github.com/photoprism/photoprism/internal/config/customize"
|
|
"github.com/photoprism/photoprism/internal/thumb"
|
|
"github.com/photoprism/photoprism/pkg/authn"
|
|
"github.com/photoprism/photoprism/pkg/capture"
|
|
"github.com/photoprism/photoprism/pkg/clean"
|
|
"github.com/photoprism/photoprism/pkg/fs"
|
|
"github.com/photoprism/photoprism/pkg/rnd"
|
|
"github.com/photoprism/photoprism/pkg/txt/report"
|
|
)
|
|
|
|
// Download URL and ZIP hash for test files.
|
|
const (
|
|
TestDataZip = "/tmp/photoprism/testdata.zip"
|
|
TestDataURL = "https://dl.photoprism.app/qa/testdata.zip"
|
|
TestDataHash = "be394d5bee8a5634d415e9e0663eef20b5604510" // sha1sum
|
|
)
|
|
|
|
var testConfig *Config
|
|
var testConfigOnce sync.Once
|
|
var testConfigMutex sync.Mutex
|
|
var testDataMutex sync.Mutex
|
|
|
|
func testDataPath(assetsPath string) string {
|
|
return assetsPath + "/testdata"
|
|
}
|
|
|
|
var PkgNameRegexp = regexp.MustCompile("[^a-zA-Z\\-_]+")
|
|
|
|
// NewTestOptions returns valid config options for tests.
|
|
func NewTestOptions(dbName string) *Options {
|
|
// Find storage path.
|
|
storagePath := os.Getenv("PHOTOPRISM_STORAGE_PATH")
|
|
if storagePath == "" {
|
|
storagePath = fs.Abs("../../storage")
|
|
}
|
|
|
|
dataPath := filepath.Join(storagePath, fs.TestdataDir)
|
|
|
|
return NewTestOptionsForPath(dbName, dataPath)
|
|
}
|
|
|
|
// NewTestOptionsForPath returns new test Options using the specified data path as storage.
|
|
func NewTestOptionsForPath(dbName, dataPath string) *Options {
|
|
// Default to storage/testdata is no path was specified.
|
|
if dataPath == "" {
|
|
storagePath := os.Getenv("PHOTOPRISM_STORAGE_PATH")
|
|
|
|
if storagePath == "" {
|
|
storagePath = fs.Abs("../../storage")
|
|
}
|
|
|
|
dataPath = filepath.Join(storagePath, fs.TestdataDir)
|
|
}
|
|
|
|
dataPath = fs.Abs(dataPath)
|
|
|
|
if err := fs.MkdirAll(dataPath); err != nil {
|
|
log.Errorf("config: %s (create test data path)", err)
|
|
return &Options{}
|
|
}
|
|
|
|
configPath := filepath.Join(dataPath, "config")
|
|
|
|
if err := fs.MkdirAll(configPath); err != nil {
|
|
log.Errorf("config: %s (create test config path)", err)
|
|
return &Options{}
|
|
}
|
|
|
|
// Find assets path.
|
|
assetsPath := os.Getenv("PHOTOPRISM_ASSETS_PATH")
|
|
if assetsPath == "" {
|
|
fs.Abs("../../assets")
|
|
}
|
|
|
|
dbName = PkgNameRegexp.ReplaceAllString(dbName, "")
|
|
driver := os.Getenv("PHOTOPRISM_TEST_DRIVER")
|
|
dsn := os.Getenv("PHOTOPRISM_TEST_DSN")
|
|
|
|
// Config example for MySQL / MariaDB:
|
|
// driver = MySQL,
|
|
// dsn = "photoprism:photoprism@tcp(mariadb:4001)/photoprism?parseTime=true",
|
|
|
|
// Set default test database driver.
|
|
if driver == "test" || driver == "sqlite" || driver == "" || dsn == "" {
|
|
driver = SQLite3
|
|
}
|
|
|
|
// Set default database DSN.
|
|
if driver == SQLite3 {
|
|
if dsn == "" && dbName != "" {
|
|
if dsn = fmt.Sprintf(".%s.db", clean.TypeLower(dbName)); !fs.FileExists(dsn) {
|
|
log.Tracef("sqlite: test database %s does not already exist", clean.Log(dsn))
|
|
} else if err := os.Remove(dsn); err != nil {
|
|
log.Errorf("sqlite: failed to remove existing test database %s (%s)", clean.Log(dsn), err)
|
|
}
|
|
} else if dsn == "" || dsn == SQLiteTestDB {
|
|
dsn = SQLiteTestDB
|
|
if !fs.FileExists(dsn) {
|
|
log.Tracef("sqlite: test database %s does not already exist", clean.Log(dsn))
|
|
} else if err := os.Remove(dsn); err != nil {
|
|
log.Errorf("sqlite: failed to remove existing test database %s (%s)", clean.Log(dsn), err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test config options.
|
|
opts := &Options{
|
|
Name: "PhotoPrism",
|
|
Version: "0.0.0",
|
|
Copyright: "(c) 2018-2025 PhotoPrism UG. All rights reserved.",
|
|
Public: true,
|
|
Sponsor: true,
|
|
AuthMode: "",
|
|
Test: true,
|
|
Debug: true,
|
|
Trace: false,
|
|
Experimental: true,
|
|
ReadOnly: false,
|
|
UploadNSFW: false,
|
|
ExifBruteForce: false,
|
|
AssetsPath: assetsPath,
|
|
AutoIndex: -1,
|
|
IndexSchedule: DefaultIndexSchedule,
|
|
AutoImport: 7200,
|
|
StoragePath: dataPath,
|
|
CachePath: filepath.Join(dataPath, "cache"),
|
|
OriginalsPath: filepath.Join(dataPath, "originals"),
|
|
ImportPath: filepath.Join(dataPath, "import"),
|
|
ConfigPath: configPath,
|
|
DefaultsYaml: filepath.Join(configPath, "defaults.yml"),
|
|
OptionsYaml: filepath.Join(configPath, "options.yml"),
|
|
SidecarPath: filepath.Join(dataPath, "sidecar"),
|
|
TempPath: filepath.Join(dataPath, "temp"),
|
|
BackupRetain: DefaultBackupRetain,
|
|
BackupSchedule: DefaultBackupSchedule,
|
|
DatabaseDriver: driver,
|
|
DatabaseDSN: dsn,
|
|
AdminPassword: "photoprism",
|
|
OriginalsLimit: 66,
|
|
ResolutionLimit: 33,
|
|
VisionApi: true,
|
|
DetectNSFW: true,
|
|
}
|
|
|
|
return opts
|
|
}
|
|
|
|
// NewTestOptionsError returns invalid config options for tests.
|
|
func NewTestOptionsError() *Options {
|
|
assetsPath := fs.Abs("../..")
|
|
dataPath := fs.Abs("../../storage/testdata")
|
|
|
|
c := &Options{
|
|
DarktableBin: "/bin/darktable-cli",
|
|
AssetsPath: assetsPath,
|
|
StoragePath: dataPath,
|
|
CachePath: dataPath + "/cache",
|
|
OriginalsPath: dataPath + "/originals",
|
|
ImportPath: dataPath + "/import",
|
|
TempPath: dataPath + "/temp",
|
|
DatabaseDriver: SQLite3,
|
|
DatabaseDSN: ".test-error.db",
|
|
}
|
|
|
|
return c
|
|
}
|
|
|
|
func SetNewTestConfig() {
|
|
testConfig = NewTestConfig("test")
|
|
}
|
|
|
|
// TestConfig returns the existing test config instance or creates a new instance and returns it.
|
|
func TestConfig() *Config {
|
|
testConfigOnce.Do(SetNewTestConfig)
|
|
|
|
return testConfig
|
|
}
|
|
|
|
// NewMinimalTestConfig creates a lightweight test Config (no DB, minimal filesystem).
|
|
//
|
|
// Not suitable for tests requiring a database or pre-created storage directories.
|
|
func NewMinimalTestConfig(dataPath string) *Config {
|
|
return NewIsolatedTestConfig("", dataPath, false)
|
|
}
|
|
|
|
var testDbCache []byte
|
|
var testDbMutex sync.Mutex
|
|
|
|
// NewMinimalTestConfigWithDb creates a lightweight test Config (minimal filesystem).
|
|
//
|
|
// Creates an isolated SQLite DB (cached after first run) without seeding media fixtures.
|
|
func NewMinimalTestConfigWithDb(dbName, dataPath string) *Config {
|
|
c := NewIsolatedTestConfig(dbName, dataPath, true)
|
|
|
|
cachedDb := false
|
|
|
|
// Try to restore test db from cache.
|
|
if len(testDbCache) > 0 && c.DatabaseDriver() == SQLite3 && !fs.FileExists(c.DatabaseDSN()) {
|
|
if err := os.WriteFile(c.DatabaseDSN(), testDbCache, fs.ModeFile); err != nil {
|
|
log.Warnf("config: %s (restore test database)", err)
|
|
} else {
|
|
cachedDb = true
|
|
}
|
|
}
|
|
|
|
if err := c.Init(); err != nil {
|
|
log.Fatalf("config: %s (init)", err.Error())
|
|
}
|
|
|
|
c.RegisterDb()
|
|
|
|
if cachedDb {
|
|
return c
|
|
}
|
|
|
|
c.InitTestDb()
|
|
|
|
if testDbCache == nil && c.DatabaseDriver() == SQLite3 && fs.FileExistsNotEmpty(c.DatabaseDSN()) {
|
|
testDbMutex.Lock()
|
|
defer testDbMutex.Unlock()
|
|
|
|
if testDbCache != nil {
|
|
return c
|
|
}
|
|
|
|
if testDb, readErr := os.ReadFile(c.DatabaseDSN()); readErr != nil {
|
|
log.Warnf("config: could not cache test database (%s)", readErr)
|
|
} else {
|
|
testDbCache = testDb
|
|
}
|
|
}
|
|
|
|
return c
|
|
}
|
|
|
|
// NewIsolatedTestConfig constructs a lightweight Config backed by the provided config path.
|
|
//
|
|
// It avoids running migrations or loading test fixtures, making it useful for unit tests that
|
|
// only need basic access to config options (for example, JWT helpers). The caller should provide
|
|
// an isolated directory (e.g. via testing.T.TempDir) so temporary files are cleaned up automatically.
|
|
func NewIsolatedTestConfig(dbName, dataPath string, createDirs bool) *Config {
|
|
if dataPath == "" {
|
|
dataPath = filepath.Join(os.TempDir(), "photoprism-test-"+rnd.Base36(6))
|
|
}
|
|
|
|
opts := NewTestOptionsForPath(dbName, dataPath)
|
|
|
|
c := &Config{
|
|
options: opts,
|
|
token: rnd.Base36(8),
|
|
}
|
|
|
|
if !createDirs {
|
|
return c
|
|
}
|
|
|
|
if err := c.CreateDirectories(); err != nil {
|
|
log.Errorf("config: %s (create test directories)", err)
|
|
}
|
|
|
|
return c
|
|
}
|
|
|
|
// NewTestConfig initializes test data so required directories exist before tests run.
|
|
// See AGENTS.md (Test Data & Fixtures) and specs/dev/backend-testing.md for guidance.
|
|
func NewTestConfig(dbName string) *Config {
|
|
defer log.Debug(capture.Time(time.Now(), "config: new test config created"))
|
|
|
|
testConfigMutex.Lock()
|
|
defer testConfigMutex.Unlock()
|
|
|
|
c := &Config{
|
|
cliCtx: CliTestContext(),
|
|
options: NewTestOptions(dbName),
|
|
token: rnd.Base36(8),
|
|
}
|
|
|
|
s := customize.NewSettings(c.DefaultTheme(), c.DefaultLocale(), c.DefaultTimezone().String())
|
|
|
|
if err := fs.MkdirAll(c.ConfigPath()); err != nil {
|
|
log.Fatalf("config: %s", err.Error())
|
|
}
|
|
|
|
if err := s.Save(filepath.Join(c.ConfigPath(), "settings.yml")); err != nil {
|
|
log.Fatalf("config: %s", err.Error())
|
|
}
|
|
|
|
if err := c.Init(); err != nil {
|
|
log.Fatalf("config: %s", err.Error())
|
|
}
|
|
|
|
if err := c.InitializeTestData(); err != nil {
|
|
log.Errorf("config: %s", err.Error())
|
|
}
|
|
|
|
c.RegisterDb()
|
|
c.InitTestDb()
|
|
|
|
thumb.SizeCached = c.ThumbSizePrecached()
|
|
thumb.SizeOnDemand = c.ThumbSizeUncached()
|
|
thumb.Filter = c.ThumbFilter()
|
|
thumb.JpegQualityDefault = c.JpegQuality()
|
|
|
|
return c
|
|
}
|
|
|
|
// NewTestErrorConfig returns an invalid test config.
|
|
func NewTestErrorConfig() *Config {
|
|
c := &Config{options: NewTestOptionsError()}
|
|
|
|
return c
|
|
}
|
|
|
|
// NewTestContext creates a new CLI test context with the flags and arguments provided.
|
|
func NewTestContext(args []string) *cli.Context {
|
|
// Create new command-line app.
|
|
app := cli.NewApp()
|
|
app.Usage = "PhotoPrism®"
|
|
app.Version = "test"
|
|
app.Copyright = "(c) 2018-2025 PhotoPrism UG. All rights reserved."
|
|
app.EnableBashCompletion = true
|
|
app.Flags = Flags.Cli()
|
|
app.Metadata = map[string]interface{}{
|
|
"Name": "PhotoPrism",
|
|
"About": "PhotoPrism®",
|
|
"Edition": "ce",
|
|
"Version": "test",
|
|
}
|
|
|
|
// Parse command arguments.
|
|
flags := flag.NewFlagSet("test", flag.ContinueOnError)
|
|
LogErr(flags.Parse(args))
|
|
|
|
// Create and return new context.
|
|
return cli.NewContext(app, flags, nil)
|
|
}
|
|
|
|
// CliTestContext returns a CLI context for testing.
|
|
func CliTestContext() *cli.Context {
|
|
config := NewTestOptions("config-cli")
|
|
|
|
globalSet := flag.NewFlagSet("test", flag.ContinueOnError)
|
|
globalSet.String("config-path", config.ConfigPath, "doc")
|
|
globalSet.String("admin-password", config.DarktableBin, "doc")
|
|
globalSet.String("oidc-uri", config.OIDCUri, "doc")
|
|
globalSet.String("oidc-client", config.OIDCClient, "doc")
|
|
globalSet.String("oidc-secret", config.OIDCSecret, "doc")
|
|
globalSet.String("oidc-scopes", config.OIDCScopes, "doc")
|
|
globalSet.String("storage-path", config.StoragePath, "doc")
|
|
globalSet.String("sidecar-path", config.SidecarPath, "doc")
|
|
globalSet.Bool("sidecar-yaml", config.SidecarYaml, "doc")
|
|
globalSet.String("assets-path", config.AssetsPath, "doc")
|
|
globalSet.String("originals-path", config.OriginalsPath, "doc")
|
|
globalSet.String("import-path", config.OriginalsPath, "doc")
|
|
globalSet.String("cache-path", config.OriginalsPath, "doc")
|
|
globalSet.String("temp-path", config.OriginalsPath, "doc")
|
|
globalSet.String("cluster-uuid", config.ClusterUUID, "doc")
|
|
globalSet.String("backup-path", config.StoragePath, "doc")
|
|
globalSet.Int("backup-retain", config.BackupRetain, "doc")
|
|
globalSet.String("backup-schedule", config.BackupSchedule, "doc")
|
|
globalSet.String("darktable-cli", config.DarktableBin, "doc")
|
|
globalSet.String("darktable-exclude", config.DarktableExclude, "doc")
|
|
globalSet.String("sips-exclude", config.SipsExclude, "doc")
|
|
globalSet.String("wakeup-interval", "1h34m9s", "doc")
|
|
globalSet.Bool("vision-api", config.VisionApi, "doc")
|
|
globalSet.Bool("detect-nsfw", config.DetectNSFW, "doc")
|
|
globalSet.Bool("debug", false, "doc")
|
|
globalSet.Bool("sponsor", true, "doc")
|
|
globalSet.Bool("test", true, "doc")
|
|
globalSet.Int("auto-index", config.AutoIndex, "doc")
|
|
globalSet.String("auto-index-schedule", config.IndexSchedule, "doc")
|
|
globalSet.Int("auto-import", config.AutoImport, "doc")
|
|
|
|
app := cli.NewApp()
|
|
app.Version = "0.0.0"
|
|
|
|
c := cli.NewContext(app, globalSet, nil)
|
|
|
|
LogErr(c.Set("config-path", config.ConfigPath))
|
|
LogErr(c.Set("admin-password", config.AdminPassword))
|
|
LogErr(c.Set("oidc-uri", config.OIDCUri))
|
|
LogErr(c.Set("oidc-client", config.OIDCClient))
|
|
LogErr(c.Set("oidc-secret", config.OIDCSecret))
|
|
LogErr(c.Set("oidc-scopes", authn.OidcDefaultScopes))
|
|
LogErr(c.Set("storage-path", config.StoragePath))
|
|
LogErr(c.Set("sidecar-path", config.SidecarPath))
|
|
LogErr(c.Set("sidecar-yaml", fmt.Sprintf("%t", config.SidecarYaml)))
|
|
LogErr(c.Set("assets-path", config.AssetsPath))
|
|
LogErr(c.Set("originals-path", config.OriginalsPath))
|
|
LogErr(c.Set("import-path", config.ImportPath))
|
|
LogErr(c.Set("cache-path", config.CachePath))
|
|
LogErr(c.Set("temp-path", config.TempPath))
|
|
LogErr(c.Set("backup-path", config.BackupPath))
|
|
LogErr(c.Set("backup-retain", strconv.Itoa(config.BackupRetain)))
|
|
LogErr(c.Set("backup-schedule", config.BackupSchedule))
|
|
LogErr(c.Set("darktable-cli", config.DarktableBin))
|
|
LogErr(c.Set("darktable-exclude", "raf, cr3"))
|
|
LogErr(c.Set("sips-exclude", "avif, avifs, thm"))
|
|
LogErr(c.Set("wakeup-interval", "1h34m9s"))
|
|
LogErr(c.Set("vision-api", "true"))
|
|
LogErr(c.Set("detect-nsfw", "true"))
|
|
LogErr(c.Set("debug", "false"))
|
|
LogErr(c.Set("sponsor", "true"))
|
|
LogErr(c.Set("test", "true"))
|
|
LogErr(c.Set("auto-index", strconv.Itoa(config.AutoIndex)))
|
|
LogErr(c.Set("auto-index-schedule", config.IndexSchedule))
|
|
LogErr(c.Set("auto-import", strconv.Itoa(config.AutoImport)))
|
|
|
|
return c
|
|
}
|
|
|
|
// RemoveTestData deletes files in import, export, originals, and cache folders.
|
|
func (c *Config) RemoveTestData() error {
|
|
if err := os.RemoveAll(c.ImportPath()); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := os.RemoveAll(c.TempPath()); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := os.RemoveAll(c.OriginalsPath()); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := os.RemoveAll(c.CachePath()); err != nil {
|
|
log.Warnf("test: %s (remove cache)", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DownloadTestData downloads the test files from the file server.
|
|
func (c *Config) DownloadTestData() error {
|
|
if fs.FileExists(TestDataZip) {
|
|
hash := fs.Hash(TestDataZip)
|
|
|
|
if hash != TestDataHash {
|
|
if err := os.Remove(TestDataZip); err != nil {
|
|
return fmt.Errorf("config: %s", err.Error())
|
|
}
|
|
|
|
log.Debugf("config: removed outdated test data zip file (fingerprint %s)", hash)
|
|
}
|
|
}
|
|
|
|
if !fs.FileExists(TestDataZip) {
|
|
log.Debugf("config: downloading latest test data zip file from %s", TestDataURL)
|
|
|
|
if err := fs.Download(TestDataZip, TestDataURL); err != nil {
|
|
return fmt.Errorf("config: test data download failed: %s", err.Error())
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UnzipTestData extracts tests files from the zip archive.
|
|
func (c *Config) UnzipTestData() error {
|
|
if _, _, err := fs.Unzip(TestDataZip, c.StoragePath(), 2*fs.GB, -1); err != nil {
|
|
return fmt.Errorf("config: could not unzip test data: %s", err.Error())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// InitializeTestData resets "storage/testdata" to a clean state.
|
|
//
|
|
// The function removes prior artifacts, downloads fixtures when missing,
|
|
// unzips them, and then calls CreateDirectories so required directories exist.
|
|
// See AGENTS.md (Test Data & Fixtures) for details.
|
|
func (c *Config) InitializeTestData() (err error) {
|
|
testDataMutex.Lock()
|
|
defer testDataMutex.Unlock()
|
|
|
|
start := time.Now()
|
|
|
|
// Delete existing test files and directories in "storage/testdata".
|
|
if err = c.RemoveTestData(); err != nil {
|
|
return fmt.Errorf("%s (remove testdata)", err)
|
|
}
|
|
|
|
// If the test file archive "/tmp/photoprism/testdata.zip" is missing,
|
|
// download it from https://dl.photoprism.app/qa/testdata.zip.
|
|
if err = c.DownloadTestData(); err != nil {
|
|
return fmt.Errorf("%s (download testdata)", err)
|
|
}
|
|
|
|
// Extract "/tmp/photoprism/testdata.zip" in "storage/testdata".
|
|
if err = c.UnzipTestData(); err != nil {
|
|
return fmt.Errorf("%s (unzip testdata)", err)
|
|
}
|
|
|
|
// Make sure all the required directories exist in "storage/testdata.
|
|
if err = c.CreateDirectories(); err != nil {
|
|
return fmt.Errorf("%s (create directories)", err)
|
|
}
|
|
|
|
log.Infof("config: initialized test data [%s]", time.Since(start))
|
|
|
|
return nil
|
|
}
|
|
|
|
// AssertTestData verifies the existence of the required test directories in "storage/testdata".
|
|
//
|
|
// Use this helper early in tests when diagnosing fixture setup issues. It logs
|
|
// presence/emptiness of required directories to testing.T. See the backend testing
|
|
// guide for additional patterns.
|
|
func (c *Config) AssertTestData(t *testing.T) {
|
|
reportDir := func(dir string) {
|
|
if fs.PathExists(dir) {
|
|
t.Logf("testdata: dir %s exists (%s)", clean.Log(dir),
|
|
report.Bool(fs.DirIsEmpty(dir), "empty", "not empty"))
|
|
} else {
|
|
t.Logf("testdata: dir %s is missing %s, but required", clean.Log(dir), report.CrossMark)
|
|
}
|
|
}
|
|
|
|
reportErr := func(funcName string) {
|
|
t.Errorf("testdata: *Config.%s() must not return an empty string %s", funcName, report.CrossMark)
|
|
}
|
|
|
|
if dir := c.AssetsPath(); dir != "" {
|
|
reportDir(dir)
|
|
} else {
|
|
reportErr("AssetsPath")
|
|
}
|
|
|
|
if dir := c.ConfigPath(); dir != "" {
|
|
reportDir(dir)
|
|
} else {
|
|
reportErr("ConfigPath")
|
|
}
|
|
|
|
if dir := c.ImportPath(); dir != "" {
|
|
reportDir(dir)
|
|
} else {
|
|
reportErr("ImportPath")
|
|
}
|
|
|
|
if dir := c.OriginalsPath(); dir != "" {
|
|
reportDir(dir)
|
|
} else {
|
|
reportErr("OriginalsPath")
|
|
}
|
|
|
|
if dir := c.SidecarPath(); dir != "" {
|
|
reportDir(dir)
|
|
} else {
|
|
reportErr("SidecarPath")
|
|
}
|
|
}
|