Config: Fix assets path and disable hub updates when running unit tests

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-10-04 14:16:11 +02:00
parent 133afda8ce
commit b71ec5bce1
14 changed files with 339 additions and 71 deletions

View File

@@ -1,6 +1,6 @@
# PhotoPrism® Repository Guidelines
**Last Updated:** October 3, 2025
**Last Updated:** October 4, 2025
## Purpose
@@ -247,6 +247,8 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
- When adding persistent fixtures (photos, files, labels, etc.), always obtain new IDs via `rnd.GenerateUID(...)` with the matching prefix (`entity.PhotoUID`, `entity.FileUID`, `entity.LabelUID`, …) instead of inventing manual strings so the search helpers recognize them.
- For database updates, prefer the `entity.Values` type alias over raw `map[string]interface{}` so helpers stay type-safe and consistent with existing code.
- Reach for `config.NewMinimalTestConfig(t.TempDir())` when a test only needs filesystem/config scaffolding, and use `config.NewMinimalTestConfigWithDb("<name>", t.TempDir())` when you need a fresh SQLite schema without the cached fixture snapshot.
- Config test helpers now auto-discover the repo `assets/` directory; you should not set `PHOTOPRISM_ASSETS_PATH` manually in package `init()` functions unless you have a non-standard layout.
- Hub API traffic is disabled in tests by default via `hub.ApplyTestConfig()`; opt back in with `PHOTOPRISM_TEST_HUB=test`.
- Avoid `config.TestConfig()` in new tests unless you truly need the fully seeded fixture set: it shares a singleton instance that runs `InitializeTestData()` and wipes `storage/testdata`. Tests that write to Originals/Import (e.g. WebDAV helpers) should instead call `config.NewMinimalTestConfig(t.TempDir())` (or the DB variant) and follow up with `conf.CreateDirectories()` so they operate on an isolated sandbox.
- Shared fixtures live under `storage/testdata`; `NewTestConfig("<pkg>")` already calls `InitializeTestData()`, but call `c.InitializeTestData()` (and optionally `c.AssertTestData(t)`) when you construct custom configs so originals/import/cache/temp exist. `InitializeTestData()` clears old data, downloads fixtures if needed, then calls `CreateDirectories()`.
- `PhotoFixtures.Get()` and similar helpers return value copies; when a test needs the database-backed row (with associations preloaded), re-query by UID/ID using helpers like `entity.FindPhoto(fixture)` so updates observe persisted IDs and in-memory caches stay coherent.

View File

@@ -1,6 +1,6 @@
PhotoPrism — Backend CODEMAP
**Last Updated:** October 2, 2025
**Last Updated:** October 4, 2025
Purpose
- Give agents and contributors a fast, reliable map of where things live and how they fit together, so you can add features, fix bugs, and write tests without spelunking.
@@ -143,6 +143,8 @@ Testing
- CLI tests: `PHOTOPRISM_CLI=noninteractive` or pass `--yes` to avoid prompts; use `RunWithTestContext` to prevent `os.Exit`.
- SQLite DSN in tests is persuite (not empty). Clean up files if you capture the DSN.
- Frontend unit tests via Vitest are separate; see `frontend/CODEMAP.md`.
- Config helpers automatically disable Hub service calls for tests (`hub.ApplyTestConfig()`).
- Test configs auto-discover the repo `assets/` folder, so avoid adding per-package `PHOTOPRISM_ASSETS_PATH` shims unless you have an unusual layout.
Security & Hot Spots (Where to Look)
- Zip extraction (path traversal prevention): `pkg/fs/zip.go`

View File

@@ -20,13 +20,6 @@ import (
"github.com/photoprism/photoprism/pkg/service/http/header"
)
// Ensure assets path is set so TestMain in this package can initialize config.
func init() {
if os.Getenv("PHOTOPRISM_ASSETS_PATH") == "" {
_ = os.Setenv("PHOTOPRISM_ASSETS_PATH", fs.Abs("../../assets"))
}
}
func TestMain(m *testing.M) {
// Init test logger.
log = logrus.StandardLogger()

View File

@@ -781,7 +781,7 @@ func (c *Config) RenewApiKeysWithToken(token string) error {
return i18n.Error(i18n.ErrAccountConnect)
}
} else if err = c.hub.Save(); err != nil {
log.Warnf("config: failed to save api keys for maps and places (%s)", err)
log.Warnf("config: failed to save API keys for maps and places (%s)", err)
return i18n.Error(i18n.ErrSaveFailed)
} else {
c.hub.Propagate()

View File

@@ -10,15 +10,13 @@ import (
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/service/hub"
"github.com/photoprism/photoprism/pkg/fs"
)
// Set the assets path so that NewConfig(CliTestContext) always works for the package tests.
// Runs first when package is tested.
func init() {
if os.Getenv("PHOTOPRISM_ASSETS_PATH") == "" {
// From internal/config to repo root assets.
_ = os.Setenv("PHOTOPRISM_ASSETS_PATH", filepath.Clean("../../assets"))
}
hub.ApplyTestConfig()
}
func TestMain(m *testing.M) {

View File

@@ -17,6 +17,7 @@ import (
_ "github.com/jinzhu/gorm/dialects/sqlite"
"github.com/photoprism/photoprism/internal/config/customize"
"github.com/photoprism/photoprism/internal/service/hub"
"github.com/photoprism/photoprism/internal/thumb"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/capture"
@@ -38,13 +39,20 @@ var testConfigOnce sync.Once
var testConfigMutex sync.Mutex
var testDataMutex sync.Mutex
// testDataPath resolves the QA fixture directory that ships with the assets
// bundle. Helpers fall back to this location when the caller does not provide
// an explicit storage path.
func testDataPath(assetsPath string) string {
return assetsPath + "/testdata"
}
// PkgNameRegexp normalizes database file names by stripping unsupported
// characters from the Go package identifier supplied by tests.
var PkgNameRegexp = regexp.MustCompile("[^a-zA-Z\\-_]+")
// NewTestOptions returns valid config options for tests.
// NewTestOptions builds fully-populated Options suited for backend tests. It
// creates an isolated storage directory under storage/testdata (or the
// PHOTOPRISM_STORAGE_PATH override) and enables all test-friendly defaults.
func NewTestOptions(dbName string) *Options {
// Find storage path.
storagePath := os.Getenv("PHOTOPRISM_STORAGE_PATH")
@@ -57,7 +65,9 @@ func NewTestOptions(dbName string) *Options {
return NewTestOptionsForPath(dbName, dataPath)
}
// NewTestOptionsForPath returns new test Options using the specified data path as storage.
// NewTestOptionsForPath returns test Options using the provided storage path.
// When the caller omits the path, it falls back to storage/testdata, discovers
// the repo-level assets directory, and ensures Hub traffic is disabled.
func NewTestOptionsForPath(dbName, dataPath string) *Options {
// Default to storage/testdata is no path was specified.
if dataPath == "" {
@@ -70,34 +80,49 @@ func NewTestOptionsForPath(dbName, dataPath string) *Options {
dataPath = filepath.Join(storagePath, fs.TestdataDir)
}
dataPath = fs.Abs(dataPath)
// Enable test mode in dependencies.
hub.ApplyTestConfig()
// Create specified data path as storage.
dataPath = fs.Abs(dataPath)
if err := fs.MkdirAll(dataPath); err != nil {
log.Errorf("config: %s (create test data path)", err)
return &Options{}
}
// Create a config directory within the data path.
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.
// Find the assets paths containing models and frontend assets.
assetsPath := os.Getenv("PHOTOPRISM_ASSETS_PATH")
if assetsPath == "" {
fs.Abs("../../assets")
if wd, err := os.Getwd(); err == nil {
for dir := wd; dir != "" && dir != filepath.Dir(dir); dir = filepath.Dir(dir) {
candidate := filepath.Join(dir, "assets")
if fs.PathExists(candidate) {
assetsPath = candidate
break
}
}
}
if assetsPath == "" {
assetsPath = fs.Abs("../../assets")
}
}
// Obtain test database credentials.
//
// Example PHOTOPRISM_TEST_DSN for MariaDB / MySQL:
// - "photoprism:photoprism@tcp(mariadb:4001)/photoprism?parseTime=true"
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
@@ -183,6 +208,8 @@ func NewTestOptionsError() *Options {
return c
}
// SetNewTestConfig resets the singleton returned by TestConfig() so follow-up
// calls build a fresh fixture-backed config instance.
func SetNewTestConfig() {
testConfig = NewTestConfig("test")
}

View File

@@ -161,8 +161,9 @@ func (c *Config) DecodeSession(cached bool) (Session, error) {
}
hash := sha256.New()
if _, err := hash.Write([]byte(c.Secret)); err != nil {
return result, err
if _, hashErr := hash.Write([]byte(c.Secret)); hashErr != nil {
return result, hashErr
}
var b []byte
@@ -182,8 +183,8 @@ func (c *Config) DecodeSession(cached bool) (Session, error) {
plaintext = bytes.Trim(plaintext, "\x00")
if err := json.Unmarshal(plaintext, &result); err != nil {
return result, err
if jsonErr := json.Unmarshal(plaintext, &result); jsonErr != nil {
return result, jsonErr
}
// Cache session.
@@ -221,17 +222,23 @@ func (c *Config) ReSync(token string) (err error) {
// interrupt reading of the Response.Body.
client := &http.Client{Timeout: 60 * time.Second}
endpointUrl := ServiceURL
method := http.MethodPost
endpointUrl := GetServiceURL(c.Key)
// Return if no endpoint URL is set.
if endpointUrl == "" {
log.Debugf("config: unable to obtain API key for maps and places (service disabled)")
return nil
}
var method string
var req *http.Request
if c.Key != "" {
endpointUrl = fmt.Sprintf(ServiceURL+"/%s", c.Key)
method = http.MethodPut
log.Tracef("config: requesting updated keys for maps and places")
if c.Key == "" {
method = http.MethodPost
log.Tracef("config: requesting new API key for maps and places")
} else {
log.Tracef("config: requesting new api keys for maps and places")
method = http.MethodPut
log.Tracef("config: requesting API key for maps and places")
}
// Create JSON request.
@@ -268,7 +275,7 @@ func (c *Config) ReSync(token string) (err error) {
if err != nil {
return err
} else if r.StatusCode >= 400 {
err = fmt.Errorf("fetching api key from %s failed (error %d)", ApiHost(), r.StatusCode)
err = fmt.Errorf("requesting api key from %s failed (error %d)", GetServiceHost(), r.StatusCode)
return err
}

View File

@@ -5,13 +5,12 @@ package hub
import (
"os"
"github.com/photoprism/photoprism/pkg/clean"
)
// init lets debug builds override the Hub base URL via PHOTOPRISM_HUB_URL so
// developers can point tests at staging services without code changes.
func init() {
if debugUrl := os.Getenv("PHOTOPRISM_HUB_URL"); debugUrl != "" {
log.Infof("config: set hub url to %s", clean.Log(debugUrl))
ServiceURL = debugUrl
SetBaseURL(debugUrl)
}
}

View File

@@ -3,6 +3,7 @@ package hub
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"runtime"
@@ -13,9 +14,6 @@ import (
"github.com/photoprism/photoprism/pkg/txt"
)
// FeedbackURL is the service endpoint for submitting user feedback.
var FeedbackURL = ServiceURL + "/%s/feedback"
// Feedback represents user feedback submitted through the user interface.
type Feedback struct {
Category string `json:"Category"`
@@ -67,12 +65,19 @@ func (c *Config) SendFeedback(frm form.Feedback) (err error) {
// interrupt reading of the Response.Body.
client := &http.Client{Timeout: 60 * time.Second}
endpointUrl := fmt.Sprintf(FeedbackURL, c.Key)
// Get feedback endpoint URL.
endpointUrl := GetFeedbackServiceURL(c.Key)
// Return if no endpoint URL is set.
if endpointUrl == "" {
return errors.New("unable to send feedback (service disabled)")
}
method := http.MethodPost
var req *http.Request
log.Debugf("sending feedback to %s", ApiHost())
log.Debugf("config: sending feedback to %s", GetServiceHost())
if j, reqErr := json.Marshal(feedback); reqErr != nil {
return reqErr
@@ -103,7 +108,7 @@ func (c *Config) SendFeedback(frm form.Feedback) (err error) {
if err != nil {
return err
} else if r.StatusCode >= 400 {
err = fmt.Errorf("sending feedback to %s failed (error %d)", ApiHost(), r.StatusCode)
err = fmt.Errorf("request to %s failed (error %d)", GetServiceHost(), r.StatusCode)
return err
}

View File

@@ -34,13 +34,17 @@ func TestSendFeedback(t *testing.T) {
ClientCPU: 2,
}
feedbackForm, err := form.NewFeedback(feedback)
feedbackForm, formErr := form.NewFeedback(feedback)
if err != nil {
t.Fatal(err)
if formErr != nil {
t.Fatal(formErr)
}
err2 := c.SendFeedback(feedbackForm)
assert.Contains(t, err2.Error(), "failed")
sendErr := c.SendFeedback(feedbackForm)
assert.Error(t, sendErr)
if Disabled() {
assert.EqualError(t, sendErr, "unable to send feedback (service disabled)")
}
})
}

View File

@@ -17,7 +17,7 @@ func TestMain(m *testing.M) {
log = logrus.StandardLogger()
log.SetLevel(logrus.TraceLevel)
ServiceURL = "https://hub-int.photoprism.app/v1/hello"
ApplyTestConfig()
code := m.Run()
@@ -78,6 +78,11 @@ func TestConfig_Refresh(t *testing.T) {
t.Fatal(err)
}
// Skip assertions if disabled.
if Disabled() {
return
}
assert.Len(t, c.Key, 40)
assert.Len(t, c.Secret, 32)
assert.Equal(t, "test", c.Version)
@@ -111,7 +116,7 @@ func TestConfig_Refresh(t *testing.T) {
} else if sess.Expired() {
t.Fatal("session expired")
} else {
t.Logf("(2) session: %#v", sess)
t.Logf("session: %#v", sess)
}
if err := c.Save(); err != nil {

View File

@@ -1,13 +1,9 @@
package hub
import (
"net/url"
"runtime"
)
// ServiceURL specifies the service endpoint URL.
var ServiceURL = "https://my.photoprism.app/v1/hello"
// Request represents basic environment specs for debugging.
type Request struct {
ClientVersion string `json:"ClientVersion"`
@@ -21,7 +17,8 @@ type Request struct {
ApiToken string `json:"ApiToken"`
}
// ClientOpt returns a custom request option.
// ClientOpt hooks let tests and extensions append optional context information
// to Hub requests; callers may replace the function to emit custom strings.
var ClientOpt = func() string {
return ""
}
@@ -40,15 +37,3 @@ func NewRequest(version, serial, env, partnerId, token string) *Request {
ApiToken: token,
}
}
// ApiHost returns the backend host name.
func ApiHost() string {
u, err := url.Parse(ServiceURL)
if err != nil {
log.Warn(err)
return ""
}
return u.Host
}

View File

@@ -0,0 +1,127 @@
package hub
import (
"fmt"
"net/url"
"os"
"strings"
"github.com/photoprism/photoprism/pkg/clean"
)
// Default service base URLs for testing and production.
const (
ProdBaseURL = "https://my.photoprism.app/v1/hello"
TestBaseURL = "https://hub-int.photoprism.app/v1/hello"
)
// baseURL specifies the service endpoint URL.
var baseURL = ProdBaseURL
// GetServiceURL returns the currently configured Hub endpoint, optionally
// appending the provided API key. An empty string is returned when Hub
// requests are disabled.
func GetServiceURL(key string) string {
if baseURL == "" {
return ""
}
if key == "" {
return baseURL
}
return fmt.Sprintf(baseURL+"/%s", key)
}
// GetFeedbackServiceURL builds the feedback endpoint corresponding to the
// supplied API key. A disabled Hub service results in an empty string.
func GetFeedbackServiceURL(key string) string {
if key == "" {
return ""
}
u := GetServiceURL(key)
if u == "" {
return ""
}
return u + "/feedback"
}
// GetServiceHost extracts the Hub host name from the active base URL, or
// returns an empty string when the service is disabled or invalid.
func GetServiceHost() string {
s := GetServiceURL("")
if s == "" {
return ""
}
u, err := url.Parse(s)
if err != nil {
log.Warn(err)
return ""
}
return u.Host
}
// SetBaseURL updates the Hub endpoint, ignoring inputs that are not HTTPS or
// identical to the current value. Changes are logged so integration tests and
// developers can trace the active target.
func SetBaseURL(u string) {
// Return if it is not an HTTPS URL.
if !strings.HasPrefix(u, "https://") {
return
}
// Return if URL has not changed.
if u == baseURL {
return
}
// Set new service endpoint URL.
switch u {
case TestBaseURL:
log.Debug("config: enabled hub test service endpoint")
case ProdBaseURL:
log.Debug("config: enabled hub production service endpoint")
default:
log.Debugf("config: changed hub service endpoint to %s", clean.Log(u))
}
baseURL = u
}
// Disabled reports whether outbound Hub requests have been switched off.
func Disabled() bool {
return baseURL == ""
}
// Disable clears the Hub endpoint so no network calls are attempted.
func Disable() {
// Return if already disabled.
if Disabled() {
return
}
// Remove configured endpoint URL to disable service.
baseURL = ""
log.Debugf("config: disabled hub service requests")
}
// ApplyTestConfig reads PHOTOPRISM_TEST_HUB and switches the Hub endpoint to a
// matching environment ("test", "prod"), disabling requests by default so
// automated tests stay hermetic.
func ApplyTestConfig() {
switch os.Getenv("PHOTOPRISM_TEST_HUB") {
case "true", "test", "int":
SetBaseURL(TestBaseURL)
case "prod":
SetBaseURL(ProdBaseURL)
default:
Disable()
}
}

View File

@@ -0,0 +1,114 @@
package hub
import (
"testing"
"github.com/stretchr/testify/assert"
)
func restoreBaseURL(t *testing.T) func() {
t.Helper()
previous := GetServiceURL("")
wasDisabled := Disabled()
return func() {
if wasDisabled {
Disable()
return
}
SetBaseURL(previous)
}
}
func TestGetServiceURL(t *testing.T) {
cleanup := restoreBaseURL(t)
t.Cleanup(cleanup)
SetBaseURL(ProdBaseURL)
assert.Equal(t, ProdBaseURL, GetServiceURL(""))
assert.Equal(t, ProdBaseURL+"/demo", GetServiceURL("demo"))
Disable()
assert.Empty(t, GetServiceURL("demo"))
}
func TestGetFeedbackServiceURL(t *testing.T) {
cleanup := restoreBaseURL(t)
t.Cleanup(cleanup)
SetBaseURL(ProdBaseURL)
assert.Empty(t, GetFeedbackServiceURL(""))
assert.Equal(t, ProdBaseURL+"/demo/feedback", GetFeedbackServiceURL("demo"))
Disable()
assert.Empty(t, GetFeedbackServiceURL("demo"))
}
func TestGetServiceHost(t *testing.T) {
cleanup := restoreBaseURL(t)
t.Cleanup(cleanup)
SetBaseURL(ProdBaseURL)
assert.Equal(t, "my.photoprism.app", GetServiceHost())
Disable()
assert.Empty(t, GetServiceHost())
}
func TestSetBaseURLRejectsHTTP(t *testing.T) {
cleanup := restoreBaseURL(t)
t.Cleanup(cleanup)
SetBaseURL(ProdBaseURL)
SetBaseURL("http://example.com/v1/hello")
assert.Equal(t, ProdBaseURL, GetServiceURL(""))
}
func TestApplyTestConfig(t *testing.T) {
t.Run("DisableByDefault", func(t *testing.T) {
cleanup := restoreBaseURL(t)
t.Cleanup(cleanup)
t.Setenv("PHOTOPRISM_TEST_HUB", "")
SetBaseURL(ProdBaseURL)
ApplyTestConfig()
assert.True(t, Disabled())
})
t.Run("EnableTest", func(t *testing.T) {
cleanup := restoreBaseURL(t)
t.Cleanup(cleanup)
t.Setenv("PHOTOPRISM_TEST_HUB", "test")
Disable()
ApplyTestConfig()
assert.False(t, Disabled())
assert.Equal(t, TestBaseURL, GetServiceURL(""))
})
t.Run("EnableProd", func(t *testing.T) {
cleanup := restoreBaseURL(t)
t.Cleanup(cleanup)
t.Setenv("PHOTOPRISM_TEST_HUB", "prod")
Disable()
ApplyTestConfig()
assert.False(t, Disabled())
assert.Equal(t, ProdBaseURL, GetServiceURL(""))
})
}