diff --git a/backend/internal/integration/server_test.go b/backend/internal/integration/server_test.go index ad28f6c..697c5b0 100644 --- a/backend/internal/integration/server_test.go +++ b/backend/internal/integration/server_test.go @@ -50,9 +50,10 @@ const defaultAdminDSN = "host=/var/run/postgresql port=5434 user=h1k0 dbname=pos // --------------------------------------------------------------------------- type harness struct { - t *testing.T - server *httptest.Server - client *http.Client + t *testing.T + server *httptest.Server + client *http.Client + importDir string } // setupSuite creates an ephemeral database, runs migrations, wires the full @@ -106,6 +107,7 @@ func setupSuite(t *testing.T) *harness { // --- Temp directories for storage ---------------------------------------- filesDir := t.TempDir() thumbsDir := t.TempDir() + importDir := t.TempDir() diskStorage, err := storage.NewDiskStorage(filesDir, thumbsDir, 160, 160, 1920, 1080) require.NoError(t, err) @@ -130,7 +132,7 @@ func setupSuite(t *testing.T) *harness { tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor) categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc) poolSvc := service.NewPoolService(poolRepo, aclSvc, auditSvc) - fileSvc := service.NewFileService(fileRepo, mimeRepo, diskStorage, aclSvc, auditSvc, tagSvc, transactor, filesDir) + fileSvc := service.NewFileService(fileRepo, mimeRepo, diskStorage, aclSvc, auditSvc, tagSvc, transactor, importDir) userSvc := service.NewUserService(userRepo, sessionRepo, auditSvc) // Bootstrap the admin account the suite logs in with (replaces the old @@ -159,9 +161,10 @@ func setupSuite(t *testing.T) *harness { t.Cleanup(srv.Close) return &harness{ - t: t, - server: srv, - client: srv.Client(), + t: t, + server: srv, + client: srv.Client(), + importDir: importDir, } } @@ -899,6 +902,59 @@ func TestNonOwnerAccessControl(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode, resp.String()) } +// TestImportFromFolder verifies the admin server-side import: supported files +// are ingested, subdirectories are skipped, the source is removed from the +// import folder afterwards, and a file without EXIF takes the source's mtime as +// its content_datetime. +func TestImportFromFolder(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + h := setupSuite(t) + adminToken := h.login("admin", "admin") + + // Drop a non-EXIF JPEG into the import folder with a known mtime, plus a + // subdirectory that must be skipped. + srcPath := filepath.Join(h.importDir, "scan.jpg") + require.NoError(t, os.WriteFile(srcPath, minimalJPEG(), 0o644)) + mtime := time.Date(2021, 3, 4, 5, 6, 7, 0, time.UTC) + require.NoError(t, os.Chtimes(srcPath, mtime, mtime)) + require.NoError(t, os.Mkdir(filepath.Join(h.importDir, "nested"), 0o755)) + + resp := h.doJSON("POST", "/files/import", map[string]any{}, adminToken) + require.Equal(t, http.StatusOK, resp.StatusCode, resp.String()) + var res struct { + Imported int `json:"imported"` + Skipped int `json:"skipped"` + Errors []struct { + Filename string `json:"filename"` + Reason string `json:"reason"` + } `json:"errors"` + } + resp.decode(t, &res) + assert.Equal(t, 1, res.Imported, resp.String()) + assert.Equal(t, 1, res.Skipped, resp.String()) // the nested directory + assert.Empty(t, res.Errors, resp.String()) + + // Source file is gone from the import folder after a successful import. + _, statErr := os.Stat(srcPath) + assert.True(t, os.IsNotExist(statErr), "source should be removed after import") + + // The imported file took the mtime as content_datetime (no EXIF present). + listResp := h.doJSON("GET", "/files?limit=10", nil, adminToken) + require.Equal(t, http.StatusOK, listResp.StatusCode, listResp.String()) + var list struct { + Items []struct { + ContentDatetime string `json:"content_datetime"` + } `json:"items"` + } + listResp.decode(t, &list) + require.Len(t, list.Items, 1, listResp.String()) + ct, err := time.Parse(time.RFC3339, list.Items[0].ContentDatetime) + require.NoError(t, err) + assert.True(t, ct.Equal(mtime), "content_datetime %v should equal mtime %v", ct, mtime) +} + // TestBlockRevokesActiveSessions verifies that blocking a user immediately // invalidates their outstanding access tokens. func TestBlockRevokesActiveSessions(t *testing.T) { diff --git a/backend/internal/service/file_service.go b/backend/internal/service/file_service.go index 1c28002..8a8aa2b 100644 --- a/backend/internal/service/file_service.go +++ b/backend/internal/service/file_service.go @@ -33,8 +33,12 @@ type UploadParams struct { Notes *string Metadata json.RawMessage ContentDatetime *time.Time - IsPublic bool - TagIDs []uuid.UUID + // ContentDatetimeFallback is used for content_datetime only when neither an + // explicit ContentDatetime nor an EXIF date is available (e.g. the source + // file's mtime on a server-side import). + ContentDatetimeFallback *time.Time + IsPublic bool + TagIDs []uuid.UUID } // UpdateParams holds the parameters for updating file metadata. @@ -128,12 +132,14 @@ func (s *FileService) Upload(ctx context.Context, p UploadParams) (*domain.File, // Extract EXIF metadata (best-effort; non-image files will error silently). exifData, exifDatetime := extractEXIFWithDatetime(data) - // Resolve content datetime: explicit > EXIF > zero value. + // Resolve content datetime: explicit > EXIF > fallback (e.g. import mtime) > zero. var contentDatetime time.Time if p.ContentDatetime != nil { contentDatetime = *p.ContentDatetime } else if exifDatetime != nil { contentDatetime = *exifDatetime + } else if p.ContentDatetimeFallback != nil { + contentDatetime = *p.ContentDatetimeFallback } // Assign UUID v7 so CreatedAt can be derived from it later. @@ -584,11 +590,21 @@ func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, e continue } + // Preserve the file's mtime as a content_datetime fallback (used only when + // the file has no EXIF date) — once the source is removed below it's the + // only date left for non-photo files. + var mtime *time.Time + if info, statErr := entry.Info(); statErr == nil { + t := info.ModTime() + mtime = &t + } + name := entry.Name() _, uploadErr := s.Upload(ctx, UploadParams{ - Reader: f, - MIMEType: mimeStr, - OriginalName: &name, + Reader: f, + MIMEType: mimeStr, + OriginalName: &name, + ContentDatetimeFallback: mtime, }) f.Close() @@ -600,6 +616,16 @@ func (s *FileService) Import(ctx context.Context, path string) (*ImportResult, e continue } result.Imported++ + + // Remove the source on success so the import folder drains and re-running + // doesn't duplicate. The file is already safely copied into storage; a + // removal failure is reported but doesn't undo the import. + if rmErr := os.Remove(fullPath); rmErr != nil { + result.Errors = append(result.Errors, ImportFileError{ + Filename: entry.Name(), + Reason: fmt.Sprintf("imported, but failed to remove source: %s", rmErr), + }) + } } return result, nil