From 81e299f09a0265bd557d87ba0d4188f67fb6e980 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Tue, 6 Oct 2026 02:13:35 +0200 Subject: [PATCH] refac: decouple persistence and image storage behind driver registries --- api/dependecyInjection.go | 75 +++---- .../staticimages/staticImagesHandler.go | 53 ++--- api/router.go | 2 +- docs/architecture.md | 14 +- internal/domain/image/imageDomain.go | 9 + .../domain/image/imageRepositoryInterface.go | 4 +- internal/domain/image/imageStorage.go | 27 +++ internal/infrastructure/config/settings.go | 5 + .../persistence/database/driver.go | 52 +++++ .../persistence/database/migrate.go | 15 ++ .../persistence/database/sqlite.go | 33 +++ .../database/sqliteBackup.go} | 14 +- .../userRoleBackfill.go | 2 +- .../repositories/articleRepository.go | 2 +- .../persistence/repositories/imageBackfill.go | 77 ------- .../repositories/imageRepository.go | 88 +++----- .../persistence/repositories/notFound.go | 6 + .../infrastructure/storage/localStorage.go | 74 ++++++ internal/infrastructure/storage/storage.go | 22 ++ .../useCases/BackfillImageMetadata.go | 90 ++++++++ .../infrastructure/useCases/CreateImage.go | 19 +- .../infrastructure/useCases/DeleteMedia.go | 11 +- .../infrastructure/useCases/MergeMedia.go | 7 +- internal/infrastructure/useCases/OpenMedia.go | 15 ++ .../infrastructure/useCases/mediaRemover.go | 15 ++ test/api_keys_http_test.go | 6 +- test/image_extension_mapping_test.go | 24 +- test/image_test.go | 101 +++++++-- test/image_upload_rules_test.go | 10 +- test/media_cleanup_test.go | 2 +- test/media_env_test.go | 8 +- test/media_library_http_test.go | 12 +- test/media_persistence_test.go | 52 ++--- test/media_purge_scheduler_test.go | 2 +- test/media_purge_test.go | 6 +- test/media_storage_orchestration_test.go | 210 ++++++++++++++++++ test/runtime_settings_injection_http_test.go | 34 +-- test/serve_uploaded_images_test.go | 4 +- test/sqlite_busy_timeout_test.go | 4 +- test/storage_seam_test.go | 160 +++++++++++++ test/update_download_http_test.go | 3 +- test/update_download_test.go | 12 +- test/update_restart_http_test.go | 11 +- test/use_case_baseline_test.go | 8 +- test/user_roles_test.go | 8 +- test/write_not_found_errors_test.go | 2 +- 46 files changed, 1036 insertions(+), 374 deletions(-) create mode 100644 internal/domain/image/imageStorage.go create mode 100644 internal/infrastructure/persistence/database/driver.go create mode 100644 internal/infrastructure/persistence/database/migrate.go create mode 100644 internal/infrastructure/persistence/database/sqlite.go rename internal/infrastructure/{update/sqliteDatabaseBackup.go => persistence/database/sqliteBackup.go} (81%) rename internal/infrastructure/persistence/{repositories => database}/userRoleBackfill.go (97%) delete mode 100644 internal/infrastructure/persistence/repositories/imageBackfill.go create mode 100644 internal/infrastructure/storage/localStorage.go create mode 100644 internal/infrastructure/storage/storage.go create mode 100644 internal/infrastructure/useCases/BackfillImageMetadata.go create mode 100644 internal/infrastructure/useCases/OpenMedia.go create mode 100644 internal/infrastructure/useCases/mediaRemover.go create mode 100644 test/media_storage_orchestration_test.go create mode 100644 test/storage_seam_test.go diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index 03e2400..d74426f 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -13,13 +13,13 @@ import ( "RenewCMS/internal/infrastructure/hashing" "RenewCMS/internal/infrastructure/mailer" "RenewCMS/internal/infrastructure/markdown" - "RenewCMS/internal/infrastructure/persistence/models" + "RenewCMS/internal/infrastructure/persistence/database" persistence "RenewCMS/internal/infrastructure/persistence/repositories" "RenewCMS/internal/infrastructure/scheduler" + "RenewCMS/internal/infrastructure/storage" "RenewCMS/internal/infrastructure/update" "RenewCMS/internal/infrastructure/useCases" "crypto/ed25519" - "fmt" "log" "net/http" "os" @@ -28,9 +28,7 @@ import ( "sync" "time" - "github.com/glebarez/sqlite" "github.com/go-chi/jwtauth/v5" - "gorm.io/gorm" ) type UseCases struct { @@ -60,6 +58,7 @@ type UseCases struct { AcceptInviteUseCase *useCases.AcceptInviteUseCase SendMailUseCase *useCases.SendMailUseCase CreateImageUseCase *useCases.CreateImageUseCase + OpenMediaUseCase *useCases.OpenMediaUseCase ListMediaUseCase *useCases.ListMediaUseCase RenameMediaUseCase *useCases.RenameMediaUseCase DeleteMediaUseCase *useCases.DeleteMediaUseCase @@ -103,7 +102,6 @@ type Repositories struct { } const ( - sqliteBusyTimeoutMs = 5000 updateStatusFileName = "update-status.json" updateJobFileName = "update-job.json" updateHelperFileName = "update-helper.json" @@ -111,32 +109,23 @@ const ( backupsDirName = "backups" ) -func SQLiteDSN(dbName string) string { - return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs) +func openImageStorage(settings config.Settings) domainImage.Storage { + imageStorage, err := storage.Open(storage.Config{Driver: storage.DriverLocal, Location: settings.UploadDir}) + if err != nil { + panic("Unable to open the image storage: " + err.Error()) + } + return imageStorage } -func getDb(dbName string, uploadDir string) *gorm.DB { - if err := os.MkdirAll(filepath.Dir(dbName), os.ModePerm); err != nil { - panic("Unable to create necessary subdirectories: " + err.Error()) - } - db, err := gorm.Open(sqlite.Open(SQLiteDSN(dbName)), &gorm.Config{}) +func openDatabase(settings config.Settings) *database.Connection { + connection, err := database.Connect(database.Config{Driver: database.DriverSQLite, Source: settings.DBFile}) if err != nil { panic("Unable to open the database: " + err.Error()) } - - if err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}); err != nil { + if err := database.Migrate(connection.DB); err != nil { panic("Failed to migrate database: " + err.Error()) } - - if err := persistence.BackfillUserRoles(db); err != nil { - panic("Failed to backfill user roles: " + err.Error()) - } - - if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil { - panic("Failed to backfill image metadata: " + err.Error()) - } - - return db + return connection } func releasePublicKey() ed25519.PublicKey { @@ -147,12 +136,13 @@ func releasePublicKey() ed25519.PublicKey { return key } -func getRepositories(db *gorm.DB, settings config.Settings) *Repositories { - dataDir := filepath.Dir(settings.DBFile) +func getRepositories(connection *database.Connection, settings config.Settings) *Repositories { + db := connection.DB + dataDir := settings.DataDir() return &Repositories{ ArticleRepo: persistence.NewArticleRepository(db), UserRepo: persistence.NewUserRepository(db), - ImageRepo: persistence.NewImageRepository(db, settings.UploadDir), + ImageRepo: persistence.NewImageRepository(db), InviteRepo: persistence.NewInviteRepository(db), APIKeyRepo: persistence.NewAPIKeyRepository(db), PurgeSettingsRepo: persistence.NewPurgeSettingsRepository(db), @@ -164,18 +154,18 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories { AssetFetcher: update.NewHTTPAssetFetcher(settings.UpdateFeedURL), SignatureVerifier: update.NewEd25519SignatureVerifier(releasePublicKey()), StagedRunner: update.NewExecStagedBinaryRunner(), - DatabaseBackup: update.NewSQLiteDatabaseBackup(db, filepath.Join(dataDir, backupsDirName), time.Now), + DatabaseBackup: connection.NewBackup(filepath.Join(dataDir, backupsDirName), time.Now), UpdateResults: update.NewJSONResultStore(filepath.Join(dataDir, domainUpdate.ResultFileName)), RestartRequests: update.NewJSONRestartRequestStore(filepath.Join(dataDir, domainUpdate.RestartRequestFileName)), RunningInfo: update.NewJSONRunningInfoStore(filepath.Join(dataDir, domainUpdate.RunningInfoFileName)), } } -func getUseCases(repos *Repositories, settings config.Settings) *UseCases { +func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings config.Settings) *UseCases { hasher := hashing.NewBcryptHasher() renderer := markdown.NewConverter() keyGenerator := hashing.NewSHA256KeyGenerator() - mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo) + mergeMedia := useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo, imageStorage) runPurge := useCases.NewRunPurgeUseCase(repos.ImageRepo, repos.ArticleRepo, mergeMedia, repos.PurgeSettingsRepo, time.Now, log.Default()) purgeScheduler := useCases.NewPurgeSchedulerUseCase(repos.PurgeSettingsRepo, runPurge) @@ -204,10 +194,11 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases { GetInviteUseCase: useCases.NewGetInviteUseCase(repos.InviteRepo, hasher), AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(repos.UserRepo, repos.InviteRepo, hasher), SendMailUseCase: useCases.NewSendMailUseCase(repos.MailRepo), - CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo), + CreateImageUseCase: useCases.NewCreateImageUseCase(repos.ImageRepo, imageStorage), + OpenMediaUseCase: useCases.NewOpenMediaUseCase(imageStorage), ListMediaUseCase: useCases.NewListMediaUseCase(repos.ImageRepo, repos.ArticleRepo, repos.UserRepo), RenameMediaUseCase: useCases.NewRenameMediaUseCase(repos.ImageRepo), - DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo), + DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(repos.ImageRepo, imageStorage), MergeMediaUseCase: mergeMedia, GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo), GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(repos.PurgeSettingsRepo), @@ -238,7 +229,7 @@ func startUpdateDownloadUseCase(repos *Repositories, settings config.Settings) * Verifier: repos.SignatureVerifier, Runner: repos.StagedRunner, Backup: repos.DatabaseBackup, - Staging: update.NewOSStagingArea(filepath.Join(filepath.Dir(settings.DBFile), updatesDirName)), + Staging: update.NewOSStagingArea(filepath.Join(settings.DataDir(), updatesDirName)), Logger: log.Default(), CurrentVersion: settings.Version, Platform: domainUpdate.Platform{OS: runtime.GOOS, Arch: runtime.GOARCH}, @@ -248,19 +239,13 @@ func startUpdateDownloadUseCase(repos *Repositories, settings config.Settings) * } func InitContainer(settings config.Settings) (*UseCases, func() error) { - db := getDb(settings.DBFile, settings.UploadDir) - repos := getRepositories(db, settings) - return getUseCases(repos, settings), closeDb(db) -} - -func closeDb(db *gorm.DB) func() error { - return func() error { - sqlDb, err := db.DB() - if err != nil { - return err - } - return sqlDb.Close() + imageStorage := openImageStorage(settings) + connection := openDatabase(settings) + repos := getRepositories(connection, settings) + if err := useCases.NewBackfillImageMetadataUseCase(repos.ImageRepo, imageStorage).Run(); err != nil { + panic("Failed to backfill image metadata: " + err.Error()) } + return getUseCases(repos, imageStorage, settings), connection.Close } type BrowserUpdateCommands struct { diff --git a/api/handlers/staticimages/staticImagesHandler.go b/api/handlers/staticimages/staticImagesHandler.go index 1b79394..f284656 100644 --- a/api/handlers/staticimages/staticImagesHandler.go +++ b/api/handlers/staticimages/staticImagesHandler.go @@ -1,11 +1,13 @@ package staticimages import ( + "RenewCMS/internal/domain/image" + "RenewCMS/internal/infrastructure/useCases" + "errors" "io" - "io/fs" "net/http" - "os" "path" + "strconv" "strings" "time" @@ -24,13 +26,13 @@ var contentTypeExtensions = map[string]string{ } type Handler struct { - uploadDir fs.FS + openMedia *useCases.OpenMediaUseCase modTime time.Time } -func NewHandler(uploadDir string) *Handler { +func NewHandler(openMedia *useCases.OpenMediaUseCase) *Handler { return &Handler{ - uploadDir: os.DirFS(uploadDir), + openMedia: openMedia, modTime: time.Now(), } } @@ -39,18 +41,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") cleaned := cleanPath(requestedPath(r)) - file, stat, ok := h.openFile(cleaned) - if !ok { + blob, err := h.openMedia.Open(cleaned) + if errors.Is(err, image.ErrBlobNotFound) { http.NotFound(w, r) return } - defer file.Close() - - content, ok := file.(io.ReadSeeker) - if !ok { - http.Error(w, "File not seekable", http.StatusInternalServerError) + if err != nil { + http.Error(w, "Unable to read the file", http.StatusInternalServerError) return } + defer blob.Content.Close() contentType := contentTypeFor(cleaned) w.Header().Set("Content-Type", contentType) @@ -59,7 +59,18 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Security-Policy", svgContentSecurityPolicy) } - http.ServeContent(w, r, cleaned, stat.ModTime(), content) + if content, ok := blob.Content.(io.ReadSeeker); ok { + http.ServeContent(w, r, cleaned, blob.ModTime, content) + return + } + w.Header().Set("Content-Length", strconv.FormatInt(blob.Size, 10)) + if !blob.ModTime.IsZero() { + w.Header().Set("Last-Modified", blob.ModTime.UTC().Format(http.TimeFormat)) + } + w.WriteHeader(http.StatusOK) + if r.Method != http.MethodHead { + _, _ = io.Copy(w, blob.Content) + } } func requestedPath(r *http.Request) string { @@ -73,22 +84,6 @@ func cleanPath(p string) string { return strings.TrimPrefix(path.Clean("/"+p), "/") } -func (h *Handler) openFile(p string) (fs.File, fs.FileInfo, bool) { - if p == "" || p == "." { - return nil, nil, false - } - file, err := h.uploadDir.Open(p) - if err != nil { - return nil, nil, false - } - stat, err := file.Stat() - if err != nil || stat.IsDir() { - file.Close() - return nil, nil, false - } - return file, stat, true -} - func contentTypeFor(p string) string { ext := path.Ext(p) if contentType, ok := contentTypeExtensions[ext]; ok { diff --git a/api/router.go b/api/router.go index 938a72d..84db634 100644 --- a/api/router.go +++ b/api/router.go @@ -292,7 +292,7 @@ func InitRoutes(container *UseCases, settings config.Settings) *chi.Mux { apiRouter.Use(chimiddleware.GetHead) apiRouter.Get("/healthz", GetHealth) apiRouter.Mount("/v1", backend) - apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(settings.UploadDir)) + apiRouter.Mount("/static/uploadedImages", staticimages.NewHandler(container.OpenMediaUseCase)) apiRouter.Mount("/", frontend) return apiRouter diff --git a/docs/architecture.md b/docs/architecture.md index 037e1cc..555d902 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,8 +15,10 @@ api/ ──▶ internal/infrastructure/useCases ──▶ internal/domain | Domain | `internal/domain//` | Entities, `Err…` sentinel errors, `Repository` interfaces. Imports only the stdlib and other domain packages, with no GORM tags. | | Use cases | `internal/infrastructure/useCases/` | Application logic. Depends only on domain interfaces, never on persistence, mailer, GORM or `net/http`. | | Adapters | `internal/infrastructure/persistence/{models,mappers,repositories}`, `internal/infrastructure/mailer` | Implement a domain `Repository` (assert with `var _ domain.Repository = &X{}`). GORM structs stay in `models` and are converted by `mappers`. | +| Database drivers | `internal/infrastructure/persistence/database` | `database.Connect(Config{Driver, Source})` looks the driver up in a registry and returns a `Connection` (GORM handle, `NewBackup`, `Close`). A `Driver` implements `Open` and `NewBackup`, so a new database is one new file plus one registry entry. `database.Migrate` owns the `AutoMigrate` model list and the user-role backfill. Only `sqlite` exists today. | +| Blob storage | `domain/image.Storage`, `internal/infrastructure/storage` | `Storage` (`Put`, `Open`, `Delete`) is the port for uploaded image bytes. `storage.Open(Config{Driver, Location})` picks the adapter; `local` is the only one. Repositories never touch files: use cases coordinate `Storage` and `image.Repository`. | | Delivery | `api/handlers//`, `api/router.go` | HTTP handlers receive use cases through `NewHandler(...)`. They never import persistence, mailer or GORM. | -| Composition root | `api/dependecyInjection.go` | The only place that opens the DB, builds repositories and use cases, and lists models for `AutoMigrate`. | +| Composition root | `api/dependecyInjection.go` | The only place that picks the database and storage drivers, builds repositories and use cases, and runs `database.Migrate` and the image metadata backfill at boot. | | Entry point | `cmd/server/main.go` | Env/config, HTTP server, graceful shutdown. | Nothing under `internal/` imports `api/`. @@ -55,7 +57,7 @@ The use cases `GetPurgeSettings`, `UpdatePurgeSettings`, `PreviewPurge` and `Run ### Image metadata backfill -At boot, `BackfillImageMetadata` fills `filename`, `size`, `content_type`, `width`, `height` and `sha256` for image rows that lack them, reading the files in `UPLOAD_DIR`. It is idempotent, and a row whose file is missing or unreadable is left as it is without stopping the boot. The old `article_id` column and the `article_images` table are kept and never written. +At boot, `BackfillImageMetadataUseCase` (through `image.Repository.ListMissingMetadata` and `UpdateMetadata`) fills `filename`, `size`, `content_type`, `width`, `height` and `sha256` for image rows that lack them, reading the bytes through `image.Storage` (the files in `UPLOAD_DIR` with the local adapter). It is idempotent, and a row whose file is missing or unreadable is left as it is without stopping the boot. The old `article_id` column and the `article_images` table are kept and never written. ## Frontend ↔ backend @@ -73,3 +75,11 @@ bin/renewcms ── /v1/* API - **Production:** a single origin. The SPA calls the API with relative URLs, so the JWT cookie works without CORS. Files under `assets/` are content-hashed by Vite and cached for a year. `index.html` is never cached. - **Development:** built with `-tags nofrontend` (`assets_stub.go`, `assets.Dist == nil`). Vite serves the SPA on :5173 and proxies `/v1` to the API on :8080. See [development.md](development.md). + +## Storage and database seams + +Uploaded images are addressed by a stored name (`.`). `CreateImageUseCase` calls `Storage.Put` (which generates the name), then `Repository.Create` with `Path = /static/uploadedImages/`, and deletes the blob again if the row cannot be created. `DeleteMediaUseCase` and `MergeMediaUseCase` remove the blob first and the row second (`mediaRemover`), so a failed blob delete keeps the row and the delete can be retried. `GET /static/uploadedImages/` is served by `OpenMediaUseCase` through `api/handlers/staticimages`, which uses `http.ServeContent` when the blob is seekable and streams it otherwise; `ErrBlobNotFound` maps to 404 and any other error to 500. + +SQL in repositories must run on SQLite, MySQL/MariaDB and PostgreSQL: substring searches use `LIKE ... ESCAPE '!'` through `containsPattern` instead of `instr`. `Settings.DataDir()` is where update state, staging and backups live; it is derived from `DB_FILE` today and does not depend on where the database is. + +Adding a database driver: implement `database.Driver` (`Open`, and `NewBackup` returning a `domain/update.DatabaseBackup`), register it in `drivers`, then select it in `openDatabase` in the composition root (and add the env vars to `main.go`, `.env.example` and `configuration.md`). Adding a storage backend: implement `image.Storage`, add a case to `storage.Open`, and select it in `openImageStorage`. No use case, handler or repository changes. diff --git a/internal/domain/image/imageDomain.go b/internal/domain/image/imageDomain.go index 6d2ca68..e00790a 100644 --- a/internal/domain/image/imageDomain.go +++ b/internal/domain/image/imageDomain.go @@ -191,6 +191,15 @@ type ImageInput struct { UploadedByID uint32 } +type Metadata struct { + Filename string + Size int64 + ContentType string + Width int + Height int + SHA256 string +} + type Image struct { ID uint32 `json:"id"` Path string `json:"path"` diff --git a/internal/domain/image/imageRepositoryInterface.go b/internal/domain/image/imageRepositoryInterface.go index 00f2664..d7b8d23 100644 --- a/internal/domain/image/imageRepositoryInterface.go +++ b/internal/domain/image/imageRepositoryInterface.go @@ -6,7 +6,7 @@ type SearchQuery struct { } type Repository interface { - Create(input ImageInput) (Image, error) + Create(image Image) (Image, error) Get(id uint32) (Image, error) Delete(id uint32) error List(query SearchQuery) ([]Image, error) @@ -14,4 +14,6 @@ type Repository interface { Rename(id uint32, filename string) (Image, error) ListDuplicates() ([]Image, error) Counts() (Counts, error) + ListMissingMetadata() ([]Image, error) + UpdateMetadata(id uint32, metadata Metadata) error } diff --git a/internal/domain/image/imageStorage.go b/internal/domain/image/imageStorage.go new file mode 100644 index 0000000..29386b6 --- /dev/null +++ b/internal/domain/image/imageStorage.go @@ -0,0 +1,27 @@ +package image + +import ( + "errors" + "io" + "time" +) + +var ErrBlobNotFound = errors.New("stored image content not found") + +const PublicPathPrefix = "/static/uploadedImages/" + +func PublicPath(storedName string) string { + return PublicPathPrefix + storedName +} + +type Blob struct { + Content io.ReadCloser + Size int64 + ModTime time.Time +} + +type Storage interface { + Put(contentType string, content io.Reader) (string, error) + Open(storedName string) (Blob, error) + Delete(storedName string) error +} diff --git a/internal/infrastructure/config/settings.go b/internal/infrastructure/config/settings.go index 94e4da1..e0fca2c 100644 --- a/internal/infrastructure/config/settings.go +++ b/internal/infrastructure/config/settings.go @@ -2,6 +2,7 @@ package config import ( "os" + "path/filepath" "strconv" "strings" "time" @@ -36,6 +37,10 @@ type Settings struct { Container bool } +func (s Settings) DataDir() string { + return filepath.Dir(s.DBFile) +} + func (s Settings) IsProduction() bool { return s.Environment == "production" } diff --git a/internal/infrastructure/persistence/database/driver.go b/internal/infrastructure/persistence/database/driver.go new file mode 100644 index 0000000..5c5600d --- /dev/null +++ b/internal/infrastructure/persistence/database/driver.go @@ -0,0 +1,52 @@ +package database + +import ( + domainUpdate "RenewCMS/internal/domain/update" + "fmt" + "time" + + "gorm.io/gorm" +) + +type Config struct { + Driver string + Source string +} + +type Driver interface { + Open(source string) (*gorm.DB, error) + NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup +} + +var drivers = map[string]Driver{ + DriverSQLite: sqliteDriver{}, +} + +type Connection struct { + DB *gorm.DB + driver Driver +} + +func Connect(config Config) (*Connection, error) { + driver, ok := drivers[config.Driver] + if !ok { + return nil, fmt.Errorf("unsupported database driver %q", config.Driver) + } + db, err := driver.Open(config.Source) + if err != nil { + return nil, err + } + return &Connection{DB: db, driver: driver}, nil +} + +func (c *Connection) NewBackup(directory string, now func() time.Time) domainUpdate.DatabaseBackup { + return c.driver.NewBackup(c.DB, directory, now) +} + +func (c *Connection) Close() error { + sqlDb, err := c.DB.DB() + if err != nil { + return err + } + return sqlDb.Close() +} diff --git a/internal/infrastructure/persistence/database/migrate.go b/internal/infrastructure/persistence/database/migrate.go new file mode 100644 index 0000000..1e63450 --- /dev/null +++ b/internal/infrastructure/persistence/database/migrate.go @@ -0,0 +1,15 @@ +package database + +import ( + "RenewCMS/internal/infrastructure/persistence/models" + + "gorm.io/gorm" +) + +func Migrate(db *gorm.DB) error { + err := db.AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}) + if err != nil { + return err + } + return BackfillUserRoles(db) +} diff --git a/internal/infrastructure/persistence/database/sqlite.go b/internal/infrastructure/persistence/database/sqlite.go new file mode 100644 index 0000000..bd9a23b --- /dev/null +++ b/internal/infrastructure/persistence/database/sqlite.go @@ -0,0 +1,33 @@ +package database + +import ( + domainUpdate "RenewCMS/internal/domain/update" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +const DriverSQLite = "sqlite" + +const sqliteBusyTimeoutMs = 5000 + +type sqliteDriver struct{} + +func SQLiteDSN(dbName string) string { + return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs) +} + +func (sqliteDriver) Open(file string) (*gorm.DB, error) { + if err := os.MkdirAll(filepath.Dir(file), os.ModePerm); err != nil { + return nil, fmt.Errorf("unable to create necessary subdirectories: %w", err) + } + return gorm.Open(sqlite.Open(SQLiteDSN(file)), &gorm.Config{}) +} + +func (sqliteDriver) NewBackup(db *gorm.DB, directory string, now func() time.Time) domainUpdate.DatabaseBackup { + return NewSQLiteBackup(db, directory, now) +} diff --git a/internal/infrastructure/update/sqliteDatabaseBackup.go b/internal/infrastructure/persistence/database/sqliteBackup.go similarity index 81% rename from internal/infrastructure/update/sqliteDatabaseBackup.go rename to internal/infrastructure/persistence/database/sqliteBackup.go index 63ac3db..1ccc3b9 100644 --- a/internal/infrastructure/update/sqliteDatabaseBackup.go +++ b/internal/infrastructure/persistence/database/sqliteBackup.go @@ -1,4 +1,4 @@ -package update +package database import ( domain "RenewCMS/internal/domain/update" @@ -21,19 +21,19 @@ const ( backupTimeLayout = "20060102T150405.000000000Z" ) -type SQLiteDatabaseBackup struct { +type SQLiteBackup struct { db *gorm.DB directory string now func() time.Time } -var _ domain.DatabaseBackup = &SQLiteDatabaseBackup{} +var _ domain.DatabaseBackup = &SQLiteBackup{} -func NewSQLiteDatabaseBackup(db *gorm.DB, directory string, now func() time.Time) *SQLiteDatabaseBackup { - return &SQLiteDatabaseBackup{db: db, directory: directory, now: now} +func NewSQLiteBackup(db *gorm.DB, directory string, now func() time.Time) *SQLiteBackup { + return &SQLiteBackup{db: db, directory: directory, now: now} } -func (b *SQLiteDatabaseBackup) Backup() (string, error) { +func (b *SQLiteBackup) Backup() (string, error) { if err := os.MkdirAll(b.directory, os.ModePerm); err != nil { return "", err } @@ -56,7 +56,7 @@ type backupFile struct { modified time.Time } -func (b *SQLiteDatabaseBackup) prune() error { +func (b *SQLiteBackup) prune() error { entries, err := os.ReadDir(b.directory) if err != nil { return err diff --git a/internal/infrastructure/persistence/repositories/userRoleBackfill.go b/internal/infrastructure/persistence/database/userRoleBackfill.go similarity index 97% rename from internal/infrastructure/persistence/repositories/userRoleBackfill.go rename to internal/infrastructure/persistence/database/userRoleBackfill.go index 4e3c9e1..e3793fb 100644 --- a/internal/infrastructure/persistence/repositories/userRoleBackfill.go +++ b/internal/infrastructure/persistence/database/userRoleBackfill.go @@ -1,4 +1,4 @@ -package persistence +package database import ( domainUser "RenewCMS/internal/domain/user" diff --git a/internal/infrastructure/persistence/repositories/articleRepository.go b/internal/infrastructure/persistence/repositories/articleRepository.go index ac4fb99..af420ab 100644 --- a/internal/infrastructure/persistence/repositories/articleRepository.go +++ b/internal/infrastructure/persistence/repositories/articleRepository.go @@ -126,7 +126,7 @@ func (a *ArticleRepository) Delete(id uint32) error { func (a *ArticleRepository) FindByBodyContaining(filename string) ([]domain.Article, error) { var entityArticles []entity.Article - err := a.db.Model(&entity.Article{}).Where("instr(body, ?) > 0", filename).Order("id asc").Find(&entityArticles).Error + err := a.db.Model(&entity.Article{}).Where("body LIKE ? ESCAPE '!'", containsPattern(filename)).Order("id asc").Find(&entityArticles).Error if err != nil { return nil, err } diff --git a/internal/infrastructure/persistence/repositories/imageBackfill.go b/internal/infrastructure/persistence/repositories/imageBackfill.go deleted file mode 100644 index 93be4f8..0000000 --- a/internal/infrastructure/persistence/repositories/imageBackfill.go +++ /dev/null @@ -1,77 +0,0 @@ -package persistence - -import ( - entity "RenewCMS/internal/infrastructure/persistence/models" - "bytes" - "crypto/sha256" - "encoding/hex" - stdimage "image" - "os" - "path/filepath" - - _ "golang.org/x/image/webp" - _ "image/jpeg" - _ "image/png" - - "gorm.io/gorm" -) - -var extensionContentTypes = map[string]string{ - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".webp": "image/webp", - ".svg": "image/svg+xml", -} - -func BackfillImageMetadata(db *gorm.DB, uploadDir string) error { - var pending []entity.Image - err := db.Where("filename = '' OR filename IS NULL").Order("id asc").Find(&pending).Error - if err != nil { - return err - } - - for _, row := range pending { - metadata, ok := readStoredImage(uploadDir, row.Path) - if !ok { - continue - } - err := db.Model(&entity.Image{}).Where("id = ?", row.ID).UpdateColumns(metadata).Error - if err != nil { - return err - } - } - return nil -} - -func readStoredImage(uploadDir string, storedPath string) (map[string]any, bool) { - fileName := filepath.Base(storedPath) - contentType, known := extensionContentTypes[filepath.Ext(fileName)] - if !known { - return nil, false - } - - data, err := os.ReadFile(filepath.Join(uploadDir, fileName)) - if err != nil { - return nil, false - } - - width, height := 0, 0 - if contentType != "image/svg+xml" { - config, _, err := stdimage.DecodeConfig(bytes.NewReader(data)) - if err != nil { - return nil, false - } - width, height = config.Width, config.Height - } - - checksum := sha256.Sum256(data) - return map[string]any{ - "filename": fileName, - "size": int64(len(data)), - "content_type": contentType, - "width": width, - "height": height, - "sha256": hex.EncodeToString(checksum[:]), - }, true -} diff --git a/internal/infrastructure/persistence/repositories/imageRepository.go b/internal/infrastructure/persistence/repositories/imageRepository.go index 16144e4..a6e3805 100644 --- a/internal/infrastructure/persistence/repositories/imageRepository.go +++ b/internal/infrastructure/persistence/repositories/imageRepository.go @@ -4,67 +4,24 @@ import ( domain "RenewCMS/internal/domain/image" "RenewCMS/internal/infrastructure/persistence/mappers" entity "RenewCMS/internal/infrastructure/persistence/models" - "errors" - "io" - "os" - "path/filepath" "strings" - "github.com/google/uuid" "gorm.io/gorm" ) -const uploadedImagesURLPrefix = "/static/uploadedImages/" - type ImageRepository struct { - db *gorm.DB - uploadDir string + db *gorm.DB } -func NewImageRepository(db *gorm.DB, uploadDir string) *ImageRepository { - return &ImageRepository{db, uploadDir} +func NewImageRepository(db *gorm.DB) *ImageRepository { + return &ImageRepository{db} } -var contentTypeExtensions = map[string]string{ - "image/png": ".png", - "image/jpeg": ".jpeg", - "image/webp": ".webp", - "image/svg+xml": ".svg", -} - -func (i ImageRepository) Create(input domain.ImageInput) (domain.Image, error) { - fileBytes, err := io.ReadAll(input.Content) - if err != nil { - return domain.Image{}, err - } - - err = os.MkdirAll(i.uploadDir, 0755) - if err != nil { - return domain.Image{}, err - } - - newName := uuid.NewString() + contentTypeExtensions[input.ContentType] - finalPath := filepath.Join(i.uploadDir, newName) - err = os.WriteFile(finalPath, fileBytes, 0666) - if err != nil { - return domain.Image{}, err - } - - model := mappers.ImageToModel(domain.Image{ - Path: uploadedImagesURLPrefix + newName, - Filename: input.Filename, - Size: input.Size, - ContentType: input.ContentType, - Width: input.Width, - Height: input.Height, - SHA256: input.SHA256, - UploadedByID: input.UploadedByID, - }) +func (i ImageRepository) Create(image domain.Image) (domain.Image, error) { + model := mappers.ImageToModel(image) if err := i.db.Create(&model).Error; err != nil { - _ = os.Remove(finalPath) return domain.Image{}, err } - return mappers.ImageToDomain(model), nil } @@ -78,24 +35,13 @@ func (i ImageRepository) Get(id uint32) (domain.Image, error) { } func (i ImageRepository) Delete(id uint32) error { - var model entity.Image - err := i.db.First(&model, id).Error - if err != nil { - return mapNotFoundErr(err, domain.ErrImageNotFound) - } - - err = os.Remove(filepath.Join(i.uploadDir, filepath.Base(model.Path))) - if err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - - return i.db.Delete(&entity.Image{}, id).Error + return deleteOrNotFound(i.db.Delete(&entity.Image{}, id), domain.ErrImageNotFound) } func (i ImageRepository) List(query domain.SearchQuery) ([]domain.Image, error) { statement := i.db.Model(&entity.Image{}) if query.Search != "" { - statement = statement.Where("instr(lower(filename), ?) > 0", strings.ToLower(query.Search)) + statement = statement.Where("LOWER(filename) LIKE ? ESCAPE '!'", containsPattern(strings.ToLower(query.Search))) } if query.Hash != "" { statement = statement.Where("sha256 = ?", query.Hash) @@ -171,4 +117,24 @@ func imagesToDomain(models []entity.Image) []domain.Image { return images } +func (i ImageRepository) ListMissingMetadata() ([]domain.Image, error) { + var models []entity.Image + err := i.db.Where("filename = '' OR filename IS NULL").Order("id asc").Find(&models).Error + if err != nil { + return nil, err + } + return imagesToDomain(models), nil +} + +func (i ImageRepository) UpdateMetadata(id uint32, metadata domain.Metadata) error { + return i.db.Model(&entity.Image{}).Where("id = ?", id).UpdateColumns(map[string]any{ + "filename": metadata.Filename, + "size": metadata.Size, + "content_type": metadata.ContentType, + "width": metadata.Width, + "height": metadata.Height, + "sha256": metadata.SHA256, + }).Error +} + var _ domain.Repository = &ImageRepository{} diff --git a/internal/infrastructure/persistence/repositories/notFound.go b/internal/infrastructure/persistence/repositories/notFound.go index 7784879..e4b0e71 100644 --- a/internal/infrastructure/persistence/repositories/notFound.go +++ b/internal/infrastructure/persistence/repositories/notFound.go @@ -2,6 +2,7 @@ package persistence import ( "errors" + "strings" "gorm.io/gorm" ) @@ -22,3 +23,8 @@ func deleteOrNotFound(result *gorm.DB, sentinel error) error { } return nil } + +func containsPattern(fragment string) string { + escaped := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_").Replace(fragment) + return "%" + escaped + "%" +} diff --git a/internal/infrastructure/storage/localStorage.go b/internal/infrastructure/storage/localStorage.go new file mode 100644 index 0000000..ccba1df --- /dev/null +++ b/internal/infrastructure/storage/localStorage.go @@ -0,0 +1,74 @@ +package storage + +import ( + domain "RenewCMS/internal/domain/image" + "errors" + "io" + "os" + "path/filepath" + + "github.com/google/uuid" +) + +var contentTypeExtensions = map[string]string{ + "image/png": ".png", + "image/jpeg": ".jpeg", + "image/webp": ".webp", + "image/svg+xml": ".svg", +} + +type LocalStorage struct { + directory string +} + +var _ domain.Storage = &LocalStorage{} + +func NewLocalStorage(directory string) *LocalStorage { + return &LocalStorage{directory: directory} +} + +func (s *LocalStorage) Put(contentType string, content io.Reader) (string, error) { + data, err := io.ReadAll(content) + if err != nil { + return "", err + } + if err := os.MkdirAll(s.directory, 0755); err != nil { + return "", err + } + storedName := uuid.NewString() + contentTypeExtensions[contentType] + if err := os.WriteFile(filepath.Join(s.directory, storedName), data, 0666); err != nil { + return "", err + } + return storedName, nil +} + +func (s *LocalStorage) Open(storedName string) (domain.Blob, error) { + if !isPlainName(storedName) { + return domain.Blob{}, domain.ErrBlobNotFound + } + file, err := os.Open(filepath.Join(s.directory, storedName)) + if err != nil { + return domain.Blob{}, domain.ErrBlobNotFound + } + info, err := file.Stat() + if err != nil || info.IsDir() { + _ = file.Close() + return domain.Blob{}, domain.ErrBlobNotFound + } + return domain.Blob{Content: file, Size: info.Size(), ModTime: info.ModTime()}, nil +} + +func (s *LocalStorage) Delete(storedName string) error { + if !isPlainName(storedName) { + return nil + } + err := os.Remove(filepath.Join(s.directory, storedName)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + +func isPlainName(name string) bool { + return name != "" && name != "." && name != ".." && filepath.Base(name) == name && filepath.ToSlash(name) == name +} diff --git a/internal/infrastructure/storage/storage.go b/internal/infrastructure/storage/storage.go new file mode 100644 index 0000000..db754b6 --- /dev/null +++ b/internal/infrastructure/storage/storage.go @@ -0,0 +1,22 @@ +package storage + +import ( + domain "RenewCMS/internal/domain/image" + "fmt" +) + +const DriverLocal = "local" + +type Config struct { + Driver string + Location string +} + +func Open(config Config) (domain.Storage, error) { + switch config.Driver { + case DriverLocal: + return NewLocalStorage(config.Location), nil + default: + return nil, fmt.Errorf("unsupported storage driver %q", config.Driver) + } +} diff --git a/internal/infrastructure/useCases/BackfillImageMetadata.go b/internal/infrastructure/useCases/BackfillImageMetadata.go new file mode 100644 index 0000000..8408906 --- /dev/null +++ b/internal/infrastructure/useCases/BackfillImageMetadata.go @@ -0,0 +1,90 @@ +package useCases + +import ( + "RenewCMS/internal/domain/image" + "bytes" + "crypto/sha256" + "encoding/hex" + stdimage "image" + "io" + "path" + + _ "golang.org/x/image/webp" + _ "image/jpeg" + _ "image/png" +) + +var extensionContentTypes = map[string]string{ + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".svg": "image/svg+xml", +} + +type BackfillImageMetadataUseCase struct { + imageRepository image.Repository + storage image.Storage +} + +func NewBackfillImageMetadataUseCase(imageRepository image.Repository, storage image.Storage) *BackfillImageMetadataUseCase { + return &BackfillImageMetadataUseCase{imageRepository, storage} +} + +func (u *BackfillImageMetadataUseCase) Run() error { + pending, err := u.imageRepository.ListMissingMetadata() + if err != nil { + return err + } + for _, row := range pending { + metadata, ok := u.readStoredImage(row.Path) + if !ok { + continue + } + if err := u.imageRepository.UpdateMetadata(row.ID, metadata); err != nil { + return err + } + } + return nil +} + +func (u *BackfillImageMetadataUseCase) readStoredImage(storedPath string) (image.Metadata, bool) { + fileName := image.StoredName(storedPath) + contentType, known := extensionContentTypes[path.Ext(fileName)] + if !known { + return image.Metadata{}, false + } + + data, err := u.read(fileName) + if err != nil { + return image.Metadata{}, false + } + + width, height := 0, 0 + if contentType != "image/svg+xml" { + config, _, err := stdimage.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return image.Metadata{}, false + } + width, height = config.Width, config.Height + } + + checksum := sha256.Sum256(data) + return image.Metadata{ + Filename: fileName, + Size: int64(len(data)), + ContentType: contentType, + Width: width, + Height: height, + SHA256: hex.EncodeToString(checksum[:]), + }, true +} + +func (u *BackfillImageMetadataUseCase) read(storedName string) ([]byte, error) { + blob, err := u.storage.Open(storedName) + if err != nil { + return nil, err + } + defer blob.Content.Close() + return io.ReadAll(blob.Content) +} diff --git a/internal/infrastructure/useCases/CreateImage.go b/internal/infrastructure/useCases/CreateImage.go index eedab90..1bc0690 100644 --- a/internal/infrastructure/useCases/CreateImage.go +++ b/internal/infrastructure/useCases/CreateImage.go @@ -24,10 +24,11 @@ var rasterFormats = map[string]string{ type CreateImageUseCase struct { imageRepository image.Repository + storage image.Storage } -func NewCreateImageUseCase(imageRepository image.Repository) *CreateImageUseCase { - return &CreateImageUseCase{imageRepository} +func NewCreateImageUseCase(imageRepository image.Repository, storage image.Storage) *CreateImageUseCase { + return &CreateImageUseCase{imageRepository, storage} } func (g *CreateImageUseCase) CreateImage(input image.ImageInput) (image.Image, error) { @@ -46,8 +47,13 @@ func (g *CreateImageUseCase) CreateImage(input image.ImageInput) (image.Image, e } checksum := sha256.Sum256(data) - return g.imageRepository.Create(image.ImageInput{ - Content: bytes.NewReader(data), + storedName, err := g.storage.Put(input.ContentType, bytes.NewReader(data)) + if err != nil { + return image.Image{}, err + } + + created, err := g.imageRepository.Create(image.Image{ + Path: image.PublicPath(storedName), Filename: image.NormalizeUploadFilename(input.Filename, input.ContentType), Size: int64(len(data)), ContentType: input.ContentType, @@ -56,6 +62,11 @@ func (g *CreateImageUseCase) CreateImage(input image.ImageInput) (image.Image, e SHA256: hex.EncodeToString(checksum[:]), UploadedByID: input.UploadedByID, }) + if err != nil { + _ = g.storage.Delete(storedName) + return image.Image{}, err + } + return created, nil } func readWithinLimit(content io.Reader) ([]byte, error) { diff --git a/internal/infrastructure/useCases/DeleteMedia.go b/internal/infrastructure/useCases/DeleteMedia.go index accb77b..e07af0f 100644 --- a/internal/infrastructure/useCases/DeleteMedia.go +++ b/internal/infrastructure/useCases/DeleteMedia.go @@ -4,12 +4,17 @@ import "RenewCMS/internal/domain/image" type DeleteMediaUseCase struct { imageRepository image.Repository + remover mediaRemover } -func NewDeleteMediaUseCase(imageRepository image.Repository) *DeleteMediaUseCase { - return &DeleteMediaUseCase{imageRepository} +func NewDeleteMediaUseCase(imageRepository image.Repository, storage image.Storage) *DeleteMediaUseCase { + return &DeleteMediaUseCase{imageRepository, mediaRemover{imageRepository, storage}} } func (u *DeleteMediaUseCase) Delete(id uint32) error { - return u.imageRepository.Delete(id) + img, err := u.imageRepository.Get(id) + if err != nil { + return err + } + return u.remover.remove(img) } diff --git a/internal/infrastructure/useCases/MergeMedia.go b/internal/infrastructure/useCases/MergeMedia.go index ab15a81..eac5316 100644 --- a/internal/infrastructure/useCases/MergeMedia.go +++ b/internal/infrastructure/useCases/MergeMedia.go @@ -18,10 +18,11 @@ type MergeMediaResult struct { type MergeMediaUseCase struct { imageRepository image.Repository articleRepository article.Repository + remover mediaRemover } -func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository) *MergeMediaUseCase { - return &MergeMediaUseCase{imageRepository, articleRepository} +func NewMergeMediaUseCase(imageRepository image.Repository, articleRepository article.Repository, storage image.Storage) *MergeMediaUseCase { + return &MergeMediaUseCase{imageRepository, articleRepository, mediaRemover{imageRepository, storage}} } type bodyRewrite struct { @@ -53,7 +54,7 @@ func (u *MergeMediaUseCase) Merge(keepId uint32, mergeIds []uint32) (MergeMediaR } for _, duplicate := range copies { - if err := u.imageRepository.Delete(duplicate.ID); err != nil { + if err := u.remover.remove(duplicate); err != nil { return MergeMediaResult{}, err } } diff --git a/internal/infrastructure/useCases/OpenMedia.go b/internal/infrastructure/useCases/OpenMedia.go new file mode 100644 index 0000000..4af64d2 --- /dev/null +++ b/internal/infrastructure/useCases/OpenMedia.go @@ -0,0 +1,15 @@ +package useCases + +import "RenewCMS/internal/domain/image" + +type OpenMediaUseCase struct { + storage image.Storage +} + +func NewOpenMediaUseCase(storage image.Storage) *OpenMediaUseCase { + return &OpenMediaUseCase{storage} +} + +func (u *OpenMediaUseCase) Open(storedName string) (image.Blob, error) { + return u.storage.Open(storedName) +} diff --git a/internal/infrastructure/useCases/mediaRemover.go b/internal/infrastructure/useCases/mediaRemover.go new file mode 100644 index 0000000..2928484 --- /dev/null +++ b/internal/infrastructure/useCases/mediaRemover.go @@ -0,0 +1,15 @@ +package useCases + +import "RenewCMS/internal/domain/image" + +type mediaRemover struct { + imageRepository image.Repository + storage image.Storage +} + +func (r mediaRemover) remove(img image.Image) error { + if err := r.storage.Delete(image.StoredName(img.Path)); err != nil { + return err + } + return r.imageRepository.Delete(img.ID) +} diff --git a/test/api_keys_http_test.go b/test/api_keys_http_test.go index 1ab5917..c2dfbc8 100644 --- a/test/api_keys_http_test.go +++ b/test/api_keys_http_test.go @@ -111,11 +111,11 @@ func newAPIKeyEnv(t *testing.T) *apiKeyEnv { ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, env.invites), GetInviteUseCase: useCases.NewGetInviteUseCase(env.invites, hasher), AcceptInviteUseCase: useCases.NewAcceptInviteUseCase(env.users, env.invites, hasher), - CreateImageUseCase: useCases.NewCreateImageUseCase(env.images), + CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, newMockImageStorage()), ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users), RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images), - DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images), - MergeMediaUseCase: useCases.NewMergeMediaUseCase(env.images, env.articles), + DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, newMockImageStorage()), + MergeMediaUseCase: useCases.NewMergeMediaUseCase(env.images, env.articles, newMockImageStorage()), GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles), GetSettingsUseCase: useCases.NewGetSettingsUseCase(apiKeyRunningVersion), GetUpdateStatusUseCase: newUpdateStatusUseCase(env.updates, &fakeJobStore{}, &fakeHelperDetector{}, apiKeyRunningVersion), diff --git a/test/image_extension_mapping_test.go b/test/image_extension_mapping_test.go index b6c8d74..da9bba1 100644 --- a/test/image_extension_mapping_test.go +++ b/test/image_extension_mapping_test.go @@ -2,20 +2,15 @@ package test import ( imageDomain "RenewCMS/internal/domain/image" - "RenewCMS/internal/infrastructure/persistence/models" - persistence "RenewCMS/internal/infrastructure/persistence/repositories" + "RenewCMS/internal/infrastructure/storage" "path/filepath" "reflect" "strings" "testing" ) -func TestImageRepositoryHasAnExtensionForEveryAllowedContentType(t *testing.T) { - db := openInMemoryDb(t) - if err := db.AutoMigrate(&models.Image{}); err != nil { - t.Fatalf("failed to migrate: %v", err) - } - repo := persistence.NewImageRepository(db, t.TempDir()) +func TestLocalStorageHasAnExtensionForEveryAllowedContentType(t *testing.T) { + store := storage.NewLocalStorage(t.TempDir()) allowedContentTypes := imageDomain.AllowedContentTypes() expectedContentTypes := []string{"image/jpeg", "image/png", "image/svg+xml", "image/webp"} @@ -25,17 +20,12 @@ func TestImageRepositoryHasAnExtensionForEveryAllowedContentType(t *testing.T) { for _, contentType := range allowedContentTypes { t.Run(contentType, func(t *testing.T) { - result, err := repo.Create(imageDomain.ImageInput{ - Content: strings.NewReader("fake-image-data"), - Filename: "upload", - ContentType: contentType, - }) + storedName, err := store.Put(contentType, strings.NewReader("fake-image-data")) if err != nil { - t.Fatalf("Create failed: %v", err) + t.Fatalf("Put failed: %v", err) } - extension := filepath.Ext(result.Path) - if extension == "" { - t.Errorf("expected a non-empty extension for %q, got path %q", contentType, result.Path) + if filepath.Ext(storedName) == "" { + t.Errorf("expected a non-empty extension for %q, got name %q", contentType, storedName) } }) } diff --git a/test/image_test.go b/test/image_test.go index 672027e..0fab20c 100644 --- a/test/image_test.go +++ b/test/image_test.go @@ -21,6 +21,55 @@ var mockImageExtensions = map[string]string{ "image/svg+xml": ".svg", } +type mockImageStorage struct { + blobs map[string][]byte + putCalls int + nextName int + lastContent []byte + deleted []string + failPut error + failDelete error +} + +func newMockImageStorage() *mockImageStorage { + return &mockImageStorage{blobs: map[string][]byte{}} +} + +func (m *mockImageStorage) Put(contentType string, content io.Reader) (string, error) { + m.putCalls++ + if m.failPut != nil { + return "", m.failPut + } + data, err := io.ReadAll(content) + if err != nil { + return "", err + } + m.nextName++ + name := "stored-" + strconv.Itoa(m.nextName) + mockImageExtensions[contentType] + m.blobs[name] = data + m.lastContent = data + return name, nil +} + +func (m *mockImageStorage) Open(name string) (imageDomain.Blob, error) { + data, ok := m.blobs[name] + if !ok { + return imageDomain.Blob{}, imageDomain.ErrBlobNotFound + } + return imageDomain.Blob{Content: io.NopCloser(strings.NewReader(string(data))), Size: int64(len(data))}, nil +} + +func (m *mockImageStorage) Delete(name string) error { + m.deleted = append(m.deleted, name) + if m.failDelete != nil { + return m.failDelete + } + delete(m.blobs, name) + return nil +} + +var _ imageDomain.Storage = &mockImageStorage{} + type mockImageRepo struct { images map[uint32]imageDomain.Image nextID uint32 @@ -28,7 +77,6 @@ type mockImageRepo struct { failDeleteFor map[uint32]error deleteCalls []uint32 createCalls int - lastContent []byte onDelete func(id uint32) } @@ -45,34 +93,49 @@ func (m *mockImageRepo) sortedImages() []imageDomain.Image { return result } -func (m *mockImageRepo) Create(input imageDomain.ImageInput) (imageDomain.Image, error) { +func (m *mockImageRepo) Create(input imageDomain.Image) (imageDomain.Image, error) { m.createCalls++ if err := m.fail.trigger("Create"); err != nil { return imageDomain.Image{}, err } - if input.Content != nil { - m.lastContent, _ = io.ReadAll(input.Content) - } for m.images[m.nextID+1].ID != 0 { m.nextID++ } m.nextID++ - img := imageDomain.Image{ - ID: m.nextID, - Path: "/static/uploadedImages/" + strconv.Itoa(int(m.nextID)) + mockImageExtensions[input.ContentType], - Filename: input.Filename, - Size: input.Size, - ContentType: input.ContentType, - Width: input.Width, - Height: input.Height, - SHA256: input.SHA256, - UploadedByID: input.UploadedByID, - CreatedAt: mockImageEpoch.Add(time.Duration(m.nextID) * time.Minute), - } + img := input + img.ID = m.nextID + img.CreatedAt = mockImageEpoch.Add(time.Duration(m.nextID) * time.Minute) m.images[img.ID] = img return img, nil } +func (m *mockImageRepo) ListMissingMetadata() ([]imageDomain.Image, error) { + if err := m.fail.trigger("ListMissingMetadata"); err != nil { + return nil, err + } + pending := []imageDomain.Image{} + for _, img := range m.sortedImages() { + if img.Filename == "" { + pending = append(pending, img) + } + } + return pending, nil +} + +func (m *mockImageRepo) UpdateMetadata(id uint32, metadata imageDomain.Metadata) error { + if err := m.fail.trigger("UpdateMetadata"); err != nil { + return err + } + img, ok := m.images[id] + if !ok { + return imageDomain.ErrImageNotFound + } + img.Filename, img.Size, img.ContentType = metadata.Filename, metadata.Size, metadata.ContentType + img.Width, img.Height, img.SHA256 = metadata.Width, metadata.Height, metadata.SHA256 + m.images[id] = img + return nil +} + func (m *mockImageRepo) Get(id uint32) (imageDomain.Image, error) { if err := m.fail.trigger("Get"); err != nil { return imageDomain.Image{}, err @@ -186,8 +249,8 @@ var _ imageDomain.Repository = &mockImageRepo{} func TestImageUseCases(t *testing.T) { repo := newMockImageRepo() - createUc := useCases.NewCreateImageUseCase(repo) - deleteUc := useCases.NewDeleteMediaUseCase(repo) + createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage()) + deleteUc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage()) t.Run("Create Image", func(t *testing.T) { input := imageDomain.ImageInput{ diff --git a/test/image_upload_rules_test.go b/test/image_upload_rules_test.go index 2df62dc..5a31a02 100644 --- a/test/image_upload_rules_test.go +++ b/test/image_upload_rules_test.go @@ -39,7 +39,7 @@ func TestImageUploadRules(t *testing.T) { for _, contentType := range []string{"image/png", "image/jpeg", "image/webp", "image/svg+xml"} { t.Run(contentType, func(t *testing.T) { repo := newMockImageRepo() - createUc := useCases.NewCreateImageUseCase(repo) + createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage()) result, err := createUc.CreateImage(inputOf(paddedTo(validBytesFor(t, contentType), int(imageDomain.MaxImageSize)), contentType)) @@ -58,7 +58,7 @@ func TestImageUploadRules(t *testing.T) { t.Run("CreateImage rejects a disallowed content type before reaching the repository", func(t *testing.T) { repo := newMockImageRepo() - createUc := useCases.NewCreateImageUseCase(repo) + createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage()) _, err := createUc.CreateImage(inputOf([]byte("%PDF"), "application/pdf")) @@ -72,7 +72,7 @@ func TestImageUploadRules(t *testing.T) { t.Run("CreateImage rejects a file that exceeds the size limit", func(t *testing.T) { repo := newMockImageRepo() - createUc := useCases.NewCreateImageUseCase(repo) + createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage()) _, err := createUc.CreateImage(inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)+1), "image/png")) @@ -86,7 +86,7 @@ func TestImageUploadRules(t *testing.T) { t.Run("A file exactly at the size limit is accepted, one byte over is not", func(t *testing.T) { repo := newMockImageRepo() - createUc := useCases.NewCreateImageUseCase(repo) + createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage()) _, err := createUc.CreateImage(inputOf(paddedTo(pngBytes(t, 3, 2), int(imageDomain.MaxImageSize)), "image/png")) if err != nil { @@ -108,7 +108,7 @@ func TestImageUploadRules(t *testing.T) { t.Run("A repository failure unrelated to type or size still propagates unchanged", func(t *testing.T) { repo := newMockImageRepo() repo.fail.failNextWith("Create", errRepositoryFailure) - createUc := useCases.NewCreateImageUseCase(repo) + createUc := useCases.NewCreateImageUseCase(repo, newMockImageStorage()) _, err := createUc.CreateImage(inputOf(pngBytes(t, 3, 2), "image/png")) diff --git a/test/media_cleanup_test.go b/test/media_cleanup_test.go index 08b459a..477c4a4 100644 --- a/test/media_cleanup_test.go +++ b/test/media_cleanup_test.go @@ -60,7 +60,7 @@ func TestMediaMergeRewritesArticlesInIdOrder(t *testing.T) { env.seedArticle(id, "Article", true, env.urlOf(3)) } recorder := &mediaOrderRecordingArticleRepo{mockArticleRepo: env.articles} - uc := useCases.NewMergeMediaUseCase(env.images, recorder) + uc := useCases.NewMergeMediaUseCase(env.images, recorder, env.storage) if _, err := uc.Merge(2, []uint32{3}); err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/test/media_env_test.go b/test/media_env_test.go index 2e5ac04..d44e2dd 100644 --- a/test/media_env_test.go +++ b/test/media_env_test.go @@ -32,6 +32,7 @@ const ( type mediaEnv struct { articles *mockArticleRepo images *mockImageRepo + storage *mockImageStorage users *mockUserRepo purgeSettings *mockPurgeSettingsRepo clock time.Time @@ -47,6 +48,7 @@ func newMediaEnv(t *testing.T) *mediaEnv { env := &mediaEnv{ articles: newMockArticleRepo(), images: newMockImageRepo(), + storage: newMockImageStorage(), users: newMockUserRepo(), token: jwtauth.New("HS256", []byte("test-secret"), nil), purgeSettings: newMockPurgeSettingsRepo(), @@ -58,7 +60,7 @@ func newMediaEnv(t *testing.T) *mediaEnv { env.users.users[mediaUnverifiedUserId] = user.User{ID: 3, Username: "pending", IsVerified: false} env.users.users[mediaStaleUserId] = user.User{ID: 4, Username: "stale", IsVerified: true, TokenVersion: 1} - merge := useCases.NewMergeMediaUseCase(env.images, env.articles) + merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage) now := func() time.Time { return env.clock } container := &api.UseCases{ Token: env.token, @@ -68,10 +70,10 @@ func newMediaEnv(t *testing.T) *mediaEnv { UpdateArticleUseCase: useCases.NewUpdateArticleUseCase(env.articles, &fakeBodyRenderer{}), DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles), GetUserUseCase: useCases.NewGetUserUseCase(env.users), - CreateImageUseCase: useCases.NewCreateImageUseCase(env.images), + CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage), ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users), RenameMediaUseCase: useCases.NewRenameMediaUseCase(env.images), - DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images), + DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(env.images, env.storage), MergeMediaUseCase: merge, GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(env.images, env.articles), GetPurgeSettingsUseCase: useCases.NewGetPurgeSettingsUseCase(env.purgeSettings), diff --git a/test/media_library_http_test.go b/test/media_library_http_test.go index 548bb48..2300a02 100644 --- a/test/media_library_http_test.go +++ b/test/media_library_http_test.go @@ -411,7 +411,7 @@ func TestMediaUploading(t *testing.T) { if len(env.images.images) != 1 || env.images.images[1].UploadedByID != mediaVerifiedUserId { t.Errorf("expected one new image with UploadedByID 1, got %+v", env.images.images) } - if !reflect.DeepEqual(env.images.lastContent, content) { + if !reflect.DeepEqual(env.storage.lastContent, content) { t.Errorf("expected the repository to receive the uploaded bytes") } }) @@ -947,7 +947,7 @@ func TestMediaMerging(t *testing.T) { t.Run("A duplicated id reaching the use case is refused before anything is read", func(t *testing.T) { env := newEnv(t) - uc := useCases.NewMergeMediaUseCase(env.images, env.articles) + uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage) _, err := uc.Merge(2, []uint32{3, 3}) @@ -996,7 +996,7 @@ func TestMediaMerging(t *testing.T) { func TestMediaSentinels(t *testing.T) { t.Run("Create reports unreadable bytes as ErrImageUnreadable", func(t *testing.T) { - uc := useCases.NewCreateImageUseCase(newMockImageRepo()) + uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage()) for _, contentType := range domainImage.AllowedContentTypes() { _, err := uc.CreateImage(inputOf([]byte("not an image"), contentType)) @@ -1007,7 +1007,7 @@ func TestMediaSentinels(t *testing.T) { }) t.Run("An svg without an svg root is unreadable", func(t *testing.T) { - uc := useCases.NewCreateImageUseCase(newMockImageRepo()) + uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage()) _, err := uc.CreateImage(inputOf([]byte(""), "image/svg+xml")) @@ -1017,7 +1017,7 @@ func TestMediaSentinels(t *testing.T) { }) t.Run("Bytes of another raster format than the declared type are unreadable", func(t *testing.T) { - uc := useCases.NewCreateImageUseCase(newMockImageRepo()) + uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage()) _, err := uc.CreateImage(inputOf(jpegBytes(t, 3, 2), "image/png")) @@ -1042,7 +1042,7 @@ func TestMediaSentinels(t *testing.T) { env.seedImage(2, "b.png", 1, "image/png", 1, 1, hashB, 1) env.seedImage(3, "c.png", 1, "image/png", 1, 1, "", 1) env.seedImage(4, "d.png", 1, "image/png", 1, 1, "", 1) - uc := useCases.NewMergeMediaUseCase(env.images, env.articles) + uc := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage) if _, err := uc.Merge(1, []uint32{9999}); !errors.Is(err, domainImage.ErrMergeCopyMissing) { t.Errorf("expected ErrMergeCopyMissing, got %v", err) diff --git a/test/media_persistence_test.go b/test/media_persistence_test.go index 9c91ccf..6901d69 100644 --- a/test/media_persistence_test.go +++ b/test/media_persistence_test.go @@ -5,7 +5,8 @@ import ( domainImage "RenewCMS/internal/domain/image" "RenewCMS/internal/infrastructure/persistence/models" persistence "RenewCMS/internal/infrastructure/persistence/repositories" - "bytes" + "RenewCMS/internal/infrastructure/storage" + "RenewCMS/internal/infrastructure/useCases" "errors" "os" "path/filepath" @@ -21,13 +22,13 @@ func newMediaPersistence(t *testing.T) (*gorm.DB, *persistence.ImageRepository, t.Fatalf("failed to migrate: %v", err) } uploadDir := t.TempDir() - return db, persistence.NewImageRepository(db, uploadDir), uploadDir + return db, persistence.NewImageRepository(db), uploadDir } func createStoredImage(t *testing.T, repo *persistence.ImageRepository, filename string, content []byte, contentType string) domainImage.Image { t.Helper() - created, err := repo.Create(domainImage.ImageInput{ - Content: bytes.NewReader(content), + created, err := repo.Create(domainImage.Image{ + Path: domainImage.PublicPath(filename), Filename: filename, Size: int64(len(content)), ContentType: contentType, @@ -42,8 +43,8 @@ func createStoredImage(t *testing.T, repo *persistence.ImageRepository, filename } func TestMediaImageRepository(t *testing.T) { - t.Run("Create stores the file and the metadata and returns the assigned id", func(t *testing.T) { - _, repo, uploadDir := newMediaPersistence(t) + t.Run("Create stores the metadata and returns the assigned id", func(t *testing.T) { + _, repo, _ := newMediaPersistence(t) content := pngBytes(t, 3, 2) created := createStoredImage(t, repo, "a.png", content, "image/png") @@ -51,10 +52,6 @@ func TestMediaImageRepository(t *testing.T) { if created.ID == 0 || created.Filename != "a.png" || created.SHA256 != sha256Hex(content) || created.Width != 3 || created.Size != int64(len(content)) { t.Errorf("unexpected image %+v", created) } - onDisk, err := os.ReadFile(filepath.Join(uploadDir, filepath.Base(created.Path))) - if err != nil || !bytes.Equal(onDisk, content) { - t.Errorf("expected the file on disk, got %v", err) - } got, err := repo.Get(created.ID) if err != nil || got.SHA256 != created.SHA256 { t.Errorf("Get returned %+v, %v", got, err) @@ -69,34 +66,19 @@ func TestMediaImageRepository(t *testing.T) { } }) - t.Run("Delete removes the file and the row", func(t *testing.T) { - _, repo, uploadDir := newMediaPersistence(t) + t.Run("Delete removes the row", func(t *testing.T) { + _, repo, _ := newMediaPersistence(t) created := createStoredImage(t, repo, "a.png", pngBytes(t, 3, 2), "image/png") - if err := repo.Delete(created.ID); err != nil { - t.Fatalf("Delete failed: %v", err) - } - if _, err := os.Stat(filepath.Join(uploadDir, filepath.Base(created.Path))); !os.IsNotExist(err) { - t.Errorf("expected the file to be gone") - } - if _, err := repo.Get(created.ID); !errors.Is(err, domainImage.ErrImageNotFound) { - t.Errorf("expected the row to be gone, got %v", err) - } - }) - - t.Run("A file already missing from disk still deletes the row", func(t *testing.T) { - _, repo, uploadDir := newMediaPersistence(t) - created := createStoredImage(t, repo, "a.png", pngBytes(t, 3, 2), "image/png") - if err := os.Remove(filepath.Join(uploadDir, filepath.Base(created.Path))); err != nil { - t.Fatalf("failed to remove the file: %v", err) - } - if err := repo.Delete(created.ID); err != nil { t.Fatalf("Delete failed: %v", err) } if _, err := repo.Get(created.ID); !errors.Is(err, domainImage.ErrImageNotFound) { t.Errorf("expected the row to be gone, got %v", err) } + if err := repo.Delete(created.ID); !errors.Is(err, domainImage.ErrImageNotFound) { + t.Errorf("expected ErrImageNotFound on a second delete, got %v", err) + } }) t.Run("List, FindByHash, Rename, ListDuplicates and Counts", func(t *testing.T) { @@ -183,7 +165,7 @@ func TestMediaImageBackfill(t *testing.T) { writeFile(t, uploadDir, "two.svg", svg) pngRow, svgRow := seedLegacyRow(t, db, "one.png"), seedLegacyRow(t, db, "two.svg") - if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil { + if err := useCases.NewBackfillImageMetadataUseCase(repo, storage.NewLocalStorage(uploadDir)).Run(); err != nil { t.Fatalf("backfill failed: %v", err) } @@ -201,7 +183,7 @@ func TestMediaImageBackfill(t *testing.T) { db, repo, uploadDir := newMediaPersistence(t) row := seedLegacyRow(t, db, "gone.png") - if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil { + if err := useCases.NewBackfillImageMetadataUseCase(repo, storage.NewLocalStorage(uploadDir)).Run(); err != nil { t.Fatalf("backfill failed: %v", err) } @@ -217,7 +199,7 @@ func TestMediaImageBackfill(t *testing.T) { writeFile(t, uploadDir, "fine.png", pngBytes(t, 3, 2)) broken, fine := seedLegacyRow(t, db, "broken.png"), seedLegacyRow(t, db, "fine.png") - if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil { + if err := useCases.NewBackfillImageMetadataUseCase(repo, storage.NewLocalStorage(uploadDir)).Run(); err != nil { t.Fatalf("backfill failed: %v", err) } @@ -233,7 +215,7 @@ func TestMediaImageBackfill(t *testing.T) { db, repo, uploadDir := newMediaPersistence(t) writeFile(t, uploadDir, "one.png", pngBytes(t, 3, 2)) row := seedLegacyRow(t, db, "one.png") - if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil { + if err := useCases.NewBackfillImageMetadataUseCase(repo, storage.NewLocalStorage(uploadDir)).Run(); err != nil { t.Fatalf("backfill failed: %v", err) } afterFirst, _ := repo.Get(row.ID) @@ -241,7 +223,7 @@ func TestMediaImageBackfill(t *testing.T) { t.Fatal(err) } - if err := persistence.BackfillImageMetadata(db, uploadDir); err != nil { + if err := useCases.NewBackfillImageMetadataUseCase(repo, storage.NewLocalStorage(uploadDir)).Run(); err != nil { t.Fatalf("second backfill failed: %v", err) } diff --git a/test/media_purge_scheduler_test.go b/test/media_purge_scheduler_test.go index b297f24..26d9c30 100644 --- a/test/media_purge_scheduler_test.go +++ b/test/media_purge_scheduler_test.go @@ -31,7 +31,7 @@ type schedulerEnv struct { func newSchedulerEnv(t *testing.T) *schedulerEnv { t.Helper() env := newPurgeEnv(t) - merge := useCases.NewMergeMediaUseCase(env.images, env.articles) + merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage) now := func() time.Time { return env.clock } runPurge := useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, now, log.New(env.logs, "", 0)) return &schedulerEnv{ diff --git a/test/media_purge_test.go b/test/media_purge_test.go index 7920c06..b0a0e21 100644 --- a/test/media_purge_test.go +++ b/test/media_purge_test.go @@ -32,7 +32,7 @@ func TestUpdatePurgeSettingsRejectsOutOfRangeIntervals(t *testing.T) { func TestRunPurgeDistinguishesBusyFromConflict(t *testing.T) { newRun := func(env *mediaEnv) *useCases.RunPurgeUseCase { - merge := useCases.NewMergeMediaUseCase(env.images, env.articles) + merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage) return useCases.NewRunPurgeUseCase(env.images, env.articles, merge, env.purgeSettings, func() time.Time { return env.clock }, log.New(&bytes.Buffer{}, "", 0)) } @@ -148,7 +148,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) { env.useImages(1, 2) env.useImages(2, 1, 2) env.useImages(3, 1) - merge := useCases.NewMergeMediaUseCase(env.images, env.articles) + merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage) result, err := merge.Merge(1, []uint32{2}) @@ -163,7 +163,7 @@ func TestMergeMediaExposesTheRewrittenArticleIds(t *testing.T) { env.useImages(1, 2) env.useImages(2, 2) env.articles.failUpdateBodyFor = map[uint32]error{2: errRepositoryFailure} - merge := useCases.NewMergeMediaUseCase(env.images, env.articles) + merge := useCases.NewMergeMediaUseCase(env.images, env.articles, env.storage) result, err := merge.Merge(1, []uint32{2}) diff --git a/test/media_storage_orchestration_test.go b/test/media_storage_orchestration_test.go new file mode 100644 index 0000000..4de05f5 --- /dev/null +++ b/test/media_storage_orchestration_test.go @@ -0,0 +1,210 @@ +package test + +import ( + domainImage "RenewCMS/internal/domain/image" + "RenewCMS/internal/infrastructure/persistence/database" + "RenewCMS/internal/infrastructure/persistence/models" + "RenewCMS/internal/infrastructure/storage" + "RenewCMS/internal/infrastructure/useCases" + "bytes" + "errors" + "strings" + "testing" + "time" +) + +func pngUpload(t *testing.T) domainImage.ImageInput { + t.Helper() + return domainImage.ImageInput{ + Content: bytes.NewReader(pngBytes(t, 3, 2)), + Filename: "a.png", + ContentType: "image/png", + } +} + +func uploadPng(t *testing.T, uc *useCases.CreateImageUseCase) domainImage.Image { + t.Helper() + created, err := uc.CreateImage(pngUpload(t)) + if err != nil { + t.Fatalf("CreateImage failed: %v", err) + } + return created +} + +func TestCreateImageStoresTheBlobThenTheRow(t *testing.T) { + t.Run("The row points at the stored name and the blob holds the bytes", func(t *testing.T) { + repo, store := newMockImageRepo(), newMockImageStorage() + + created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store)) + + if !strings.HasPrefix(created.Path, domainImage.PublicPathPrefix) { + t.Errorf("unexpected path %q", created.Path) + } + if !bytes.Equal(store.blobs[domainImage.StoredName(created.Path)], pngBytes(t, 3, 2)) { + t.Error("expected the blob to hold the uploaded bytes") + } + }) + + t.Run("A storage failure creates no row", func(t *testing.T) { + repo, store := newMockImageRepo(), newMockImageStorage() + store.failPut = errRepositoryFailure + + _, err := useCases.NewCreateImageUseCase(repo, store).CreateImage(pngUpload(t)) + + if !errors.Is(err, errRepositoryFailure) || repo.createCalls != 0 { + t.Errorf("got %v with %d Create calls", err, repo.createCalls) + } + }) + + t.Run("A row failure removes the blob that was just stored", func(t *testing.T) { + repo, store := newMockImageRepo(), newMockImageStorage() + repo.fail.failNextWith("Create", errRepositoryFailure) + + _, err := useCases.NewCreateImageUseCase(repo, store).CreateImage(pngUpload(t)) + + if !errors.Is(err, errRepositoryFailure) { + t.Fatalf("expected the repository failure, got %v", err) + } + if len(store.blobs) != 0 || len(store.deleted) != 1 { + t.Errorf("expected the orphan blob to be deleted, blobs=%d deleted=%v", len(store.blobs), store.deleted) + } + }) +} + +func TestDeleteMediaRemovesTheBlobAndTheRow(t *testing.T) { + t.Run("Both disappear", func(t *testing.T) { + repo, store := newMockImageRepo(), newMockImageStorage() + created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store)) + + if err := useCases.NewDeleteMediaUseCase(repo, store).Delete(created.ID); err != nil { + t.Fatalf("Delete failed: %v", err) + } + if len(store.blobs) != 0 || len(repo.images) != 0 { + t.Errorf("expected nothing left, blobs=%d rows=%d", len(store.blobs), len(repo.images)) + } + }) + + t.Run("An unknown id is ErrImageNotFound and touches no blob", func(t *testing.T) { + repo, store := newMockImageRepo(), newMockImageStorage() + + err := useCases.NewDeleteMediaUseCase(repo, store).Delete(7) + + if !errors.Is(err, domainImage.ErrImageNotFound) || len(store.deleted) != 0 { + t.Errorf("got %v, deleted %v", err, store.deleted) + } + }) + + t.Run("A blob failure keeps the row so the delete can be retried", func(t *testing.T) { + repo, store := newMockImageRepo(), newMockImageStorage() + created := uploadPng(t, useCases.NewCreateImageUseCase(repo, store)) + store.failDelete = errRepositoryFailure + + err := useCases.NewDeleteMediaUseCase(repo, store).Delete(created.ID) + + if !errors.Is(err, errRepositoryFailure) || len(repo.images) != 1 || len(repo.deleteCalls) != 0 { + t.Errorf("got %v, rows=%d deleteCalls=%v", err, len(repo.images), repo.deleteCalls) + } + }) +} + +func TestMergeMediaRemovesTheMergedBlobs(t *testing.T) { + repo, articles, store := newMockImageRepo(), newMockArticleRepo(), newMockImageStorage() + create := useCases.NewCreateImageUseCase(repo, store) + kept, copyOne, copyTwo := uploadPng(t, create), uploadPng(t, create), uploadPng(t, create) + + result, err := useCases.NewMergeMediaUseCase(repo, articles, store).Merge(kept.ID, []uint32{copyOne.ID, copyTwo.ID}) + + if err != nil || result.Merged != 2 { + t.Fatalf("merge returned %+v, %v", result, err) + } + if len(store.blobs) != 1 || store.blobs[domainImage.StoredName(kept.Path)] == nil { + t.Errorf("expected only the kept blob to remain, got %d blobs", len(store.blobs)) + } +} + +func TestBackfillImageMetadataUseCase(t *testing.T) { + t.Run("Rows without metadata are filled from the stored blob and unreadable ones are skipped", func(t *testing.T) { + repo, store := newMockImageRepo(), newMockImageStorage() + store.blobs["one.png"] = pngBytes(t, 3, 2) + store.blobs["broken.png"] = []byte("nope") + repo.images[1] = domainImage.Image{ID: 1, Path: domainImage.PublicPath("one.png")} + repo.images[2] = domainImage.Image{ID: 2, Path: domainImage.PublicPath("broken.png")} + repo.images[3] = domainImage.Image{ID: 3, Path: domainImage.PublicPath("missing.png")} + repo.images[4] = domainImage.Image{ID: 4, Path: domainImage.PublicPath("notes.txt")} + repo.images[5] = domainImage.Image{ID: 5, Path: domainImage.PublicPath("done.png"), Filename: "done.png"} + + if err := useCases.NewBackfillImageMetadataUseCase(repo, store).Run(); err != nil { + t.Fatalf("Run failed: %v", err) + } + + got := repo.images[1] + if got.Filename != "one.png" || got.ContentType != "image/png" || got.Width != 3 || got.Height != 2 || got.SHA256 != sha256Hex(store.blobs["one.png"]) { + t.Errorf("unexpected backfilled row %+v", got) + } + for _, id := range []uint32{2, 3, 4} { + if repo.images[id].Filename != "" { + t.Errorf("row %d must be left as is, got %+v", id, repo.images[id]) + } + } + }) + + t.Run("Repository failures stop the run", func(t *testing.T) { + repo, store := newMockImageRepo(), newMockImageStorage() + repo.fail.failNextWith("ListMissingMetadata", errRepositoryFailure) + if err := useCases.NewBackfillImageMetadataUseCase(repo, store).Run(); !errors.Is(err, errRepositoryFailure) { + t.Errorf("expected the list failure, got %v", err) + } + + repo, store = newMockImageRepo(), newMockImageStorage() + store.blobs["one.png"] = pngBytes(t, 1, 1) + repo.images[1] = domainImage.Image{ID: 1, Path: domainImage.PublicPath("one.png")} + repo.fail.failNextWith("UpdateMetadata", errRepositoryFailure) + if err := useCases.NewBackfillImageMetadataUseCase(repo, store).Run(); !errors.Is(err, errRepositoryFailure) { + t.Errorf("expected the update failure, got %v", err) + } + }) +} + +func TestDriverRegistries(t *testing.T) { + t.Run("An unknown database driver is rejected", func(t *testing.T) { + if _, err := database.Connect(database.Config{Driver: "oracle", Source: "x"}); err == nil || !strings.Contains(err.Error(), "oracle") { + t.Errorf("expected an unsupported driver error, got %v", err) + } + }) + + t.Run("An unknown storage driver is rejected", func(t *testing.T) { + if _, err := storage.Open(storage.Config{Driver: "floppy"}); err == nil || !strings.Contains(err.Error(), "floppy") { + t.Errorf("expected an unsupported driver error, got %v", err) + } + }) + + t.Run("The sqlite driver connects, migrates, backs up and closes", func(t *testing.T) { + dir := t.TempDir() + connection, err := database.Connect(database.Config{Driver: database.DriverSQLite, Source: dir + "/nested/app.db"}) + if err != nil { + t.Fatalf("Connect failed: %v", err) + } + if err := database.Migrate(connection.DB); err != nil { + t.Fatalf("Migrate failed: %v", err) + } + if err := connection.DB.Create(&models.Article{Title: "t"}).Error; err != nil { + t.Fatalf("expected the schema to exist: %v", err) + } + if _, err := connection.NewBackup(dir+"/backups", time.Now).Backup(); err != nil { + t.Errorf("Backup failed: %v", err) + } + if err := connection.Close(); err != nil { + t.Errorf("Close failed: %v", err) + } + }) + + t.Run("The storage registry returns a working local storage", func(t *testing.T) { + store, err := storage.Open(storage.Config{Driver: storage.DriverLocal, Location: t.TempDir()}) + if err != nil { + t.Fatalf("Open failed: %v", err) + } + if _, err := store.Put("image/png", strings.NewReader("x")); err != nil { + t.Errorf("Put failed: %v", err) + } + }) +} diff --git a/test/runtime_settings_injection_http_test.go b/test/runtime_settings_injection_http_test.go index f8d4677..290907c 100644 --- a/test/runtime_settings_injection_http_test.go +++ b/test/runtime_settings_injection_http_test.go @@ -8,8 +8,7 @@ import ( "RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/hashing" "RenewCMS/internal/infrastructure/mailer" - "RenewCMS/internal/infrastructure/persistence/models" - persistence "RenewCMS/internal/infrastructure/persistence/repositories" + "RenewCMS/internal/infrastructure/storage" "RenewCMS/internal/infrastructure/useCases" "bytes" "encoding/json" @@ -19,8 +18,6 @@ import ( "path/filepath" "testing" - imageDomain "RenewCMS/internal/domain/image" - "github.com/go-chi/chi/v5" "github.com/go-chi/jwtauth/v5" ) @@ -143,11 +140,11 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases { CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo), ListUsersUseCase: useCases.NewListUsersUseCase(userRepo), SendMailUseCase: useCases.NewSendMailUseCase(mailRepo), - CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo), + CreateImageUseCase: useCases.NewCreateImageUseCase(imageRepo, newMockImageStorage()), ListMediaUseCase: useCases.NewListMediaUseCase(imageRepo, articleRepo, userRepo), RenameMediaUseCase: useCases.NewRenameMediaUseCase(imageRepo), - DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo), - MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo), + DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(imageRepo, newMockImageStorage()), + MergeMediaUseCase: useCases.NewMergeMediaUseCase(imageRepo, articleRepo, newMockImageStorage()), GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(imageRepo, articleRepo), } } @@ -200,34 +197,21 @@ func TestRuntimeSettingsInjectionCors(t *testing.T) { } func TestRuntimeSettingsInjectionUploadDirectory(t *testing.T) { - db := openInMemoryDb(t) - if err := db.AutoMigrate(&models.Image{}); err != nil { - t.Fatalf("failed to migrate: %v", err) - } injectedUploadDir := t.TempDir() - repo := persistence.NewImageRepository(db, injectedUploadDir) + store := storage.NewLocalStorage(injectedUploadDir) processUploadDir := t.TempDir() t.Setenv("UPLOAD_DIR", processUploadDir) - result, err := repo.Create(imageDomain.ImageInput{ - Content: bytes.NewReader([]byte("fake-image-data")), - Filename: "upload.png", - ContentType: "image/png", - }) + storedName, err := store.Put("image/png", bytes.NewReader([]byte("fake-image-data"))) if err != nil { - t.Fatalf("Create failed: %v", err) + t.Fatalf("Put failed: %v", err) } - if got := result.Path; len(got) < len("/static/uploadedImages/") || got[:len("/static/uploadedImages/")] != "/static/uploadedImages/" { - t.Errorf("expected Path to start with /static/uploadedImages/, got %q", got) - } - - fileName := filepath.Base(result.Path) - if _, err := os.Stat(filepath.Join(injectedUploadDir, fileName)); err != nil { + if _, err := os.Stat(filepath.Join(injectedUploadDir, storedName)); err != nil { t.Errorf("expected the file to exist under the injected upload dir: %v", err) } - if _, err := os.Stat(filepath.Join(processUploadDir, fileName)); !os.IsNotExist(err) { + if _, err := os.Stat(filepath.Join(processUploadDir, storedName)); !os.IsNotExist(err) { t.Errorf("expected the file to not exist under the process UPLOAD_DIR") } } diff --git a/test/serve_uploaded_images_test.go b/test/serve_uploaded_images_test.go index 4267a71..b413e29 100644 --- a/test/serve_uploaded_images_test.go +++ b/test/serve_uploaded_images_test.go @@ -3,6 +3,8 @@ package test import ( "RenewCMS/api/handlers/staticimages" validator "RenewCMS/api/pkg/validator" + "RenewCMS/internal/infrastructure/storage" + "RenewCMS/internal/infrastructure/useCases" "net/http" "net/http/httptest" "os" @@ -18,7 +20,7 @@ const spaIndexMarker = "SPA-INDEX-MARKER" func newServeUploadedImagesRouter(uploadDir string) *chi.Mux { r := chi.NewRouter() r.Use(chimiddleware.GetHead) - r.Mount("/static/uploadedImages", staticimages.NewHandler(uploadDir)) + r.Mount("/static/uploadedImages", staticimages.NewHandler(useCases.NewOpenMediaUseCase(storage.NewLocalStorage(uploadDir)))) r.Mount("/", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(spaIndexMarker)) diff --git a/test/sqlite_busy_timeout_test.go b/test/sqlite_busy_timeout_test.go index 7c2aae5..e85f2ac 100644 --- a/test/sqlite_busy_timeout_test.go +++ b/test/sqlite_busy_timeout_test.go @@ -1,7 +1,7 @@ package test import ( - "RenewCMS/api" + "RenewCMS/internal/infrastructure/persistence/database" "RenewCMS/internal/infrastructure/persistence/models" "path/filepath" "testing" @@ -16,7 +16,7 @@ func TestProductionSQLiteDSNWaitsForTheWriteLock(t *testing.T) { t.Run("A second writer waits for the first instead of failing with a locked database", func(t *testing.T) { path := filepath.Join(t.TempDir(), "busy.db") open := func() *gorm.DB { - db, err := gorm.Open(sqlite.Open(api.SQLiteDSN(path)), &gorm.Config{Logger: logger.Discard}) + db, err := gorm.Open(sqlite.Open(database.SQLiteDSN(path)), &gorm.Config{Logger: logger.Discard}) if err != nil { t.Fatalf("open: %v", err) } diff --git a/test/storage_seam_test.go b/test/storage_seam_test.go new file mode 100644 index 0000000..9b6d7a1 --- /dev/null +++ b/test/storage_seam_test.go @@ -0,0 +1,160 @@ +package test + +import ( + "RenewCMS/api/handlers/staticimages" + domainArticle "RenewCMS/internal/domain/article" + domainImage "RenewCMS/internal/domain/image" + "RenewCMS/internal/infrastructure/persistence/models" + persistence "RenewCMS/internal/infrastructure/persistence/repositories" + "RenewCMS/internal/infrastructure/storage" + "RenewCMS/internal/infrastructure/useCases" + "bytes" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type streamOnlyStorage struct { + data []byte +} + +func (s streamOnlyStorage) Put(string, io.Reader) (string, error) { return "", errors.New("read only") } +func (s streamOnlyStorage) Delete(string) error { return nil } +func (s streamOnlyStorage) Open(name string) (domainImage.Blob, error) { + if name != "remote.png" { + return domainImage.Blob{}, domainImage.ErrBlobNotFound + } + return domainImage.Blob{ + Content: io.NopCloser(bytes.NewReader(s.data)), + Size: int64(len(s.data)), + ModTime: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), + }, nil +} + +type failingStorage struct{ streamOnlyStorage } + +func (failingStorage) Open(string) (domainImage.Blob, error) { + return domainImage.Blob{}, errors.New("boom") +} + +func serveFrom(t *testing.T, store domainImage.Storage, method, target string) *httptest.ResponseRecorder { + t.Helper() + handler := staticimages.NewHandler(useCases.NewOpenMediaUseCase(store)) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(method, target, nil)) + return rec +} + +func TestLocalStorage(t *testing.T) { + t.Run("Put then Open returns the stored bytes and Delete removes them", func(t *testing.T) { + store := storage.NewLocalStorage(t.TempDir() + "/nested") + name, err := store.Put("image/png", strings.NewReader("hello")) + if err != nil || !strings.HasSuffix(name, ".png") { + t.Fatalf("Put returned %q, %v", name, err) + } + blob, err := store.Open(name) + if err != nil { + t.Fatalf("Open failed: %v", err) + } + got, _ := io.ReadAll(blob.Content) + _ = blob.Content.Close() + if string(got) != "hello" || blob.Size != 5 { + t.Errorf("unexpected blob %q size %d", got, blob.Size) + } + if err := store.Delete(name); err != nil { + t.Fatalf("Delete failed: %v", err) + } + if _, err := store.Open(name); !errors.Is(err, domainImage.ErrBlobNotFound) { + t.Errorf("expected ErrBlobNotFound after delete, got %v", err) + } + if err := store.Delete(name); err != nil { + t.Errorf("deleting a missing file must not fail, got %v", err) + } + }) + + t.Run("Names that are not plain file names are never opened or deleted", func(t *testing.T) { + dir := t.TempDir() + store := storage.NewLocalStorage(dir) + for _, name := range []string{"", ".", "..", "../x.png", "a/b.png", `a\b.png`} { + if _, err := store.Open(name); !errors.Is(err, domainImage.ErrBlobNotFound) { + t.Errorf("Open(%q) = %v, want ErrBlobNotFound", name, err) + } + if err := store.Delete(name); err != nil { + t.Errorf("Delete(%q) = %v, want nil", name, err) + } + } + }) +} + +func TestStaticImagesWithNonSeekableStorage(t *testing.T) { + store := streamOnlyStorage{data: []byte("remote-bytes")} + + t.Run("GET streams the body with length and modification headers", func(t *testing.T) { + rec := serveFrom(t, store, http.MethodGet, "/remote.png") + if rec.Code != http.StatusOK || rec.Body.String() != "remote-bytes" { + t.Fatalf("got %d %q", rec.Code, rec.Body.String()) + } + if rec.Header().Get("Content-Length") != "12" || rec.Header().Get("Content-Type") != "image/png" { + t.Errorf("unexpected headers %v", rec.Header()) + } + if rec.Header().Get("Last-Modified") == "" { + t.Error("expected Last-Modified") + } + }) + + t.Run("HEAD sends the headers without a body", func(t *testing.T) { + rec := serveFrom(t, store, http.MethodHead, "/remote.png") + if rec.Code != http.StatusOK || rec.Body.Len() != 0 { + t.Errorf("got %d with %d body bytes", rec.Code, rec.Body.Len()) + } + }) + + t.Run("A missing blob answers 404 and any other storage error answers 500", func(t *testing.T) { + if rec := serveFrom(t, store, http.MethodGet, "/other.png"); rec.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", rec.Code) + } + if rec := serveFrom(t, failingStorage{}, http.MethodGet, "/remote.png"); rec.Code != http.StatusInternalServerError { + t.Errorf("expected 500, got %d", rec.Code) + } + }) +} + +func TestMediaSearchTreatsWildcardsLiterally(t *testing.T) { + db, repo, _ := newMediaPersistence(t) + if err := db.AutoMigrate(&models.Article{}); err != nil { + t.Fatalf("failed to migrate: %v", err) + } + percent := createStoredImage(t, repo, "100%_done.png", pngBytes(t, 1, 1), "image/png") + createStoredImage(t, repo, "plain.png", pngBytes(t, 2, 2), "image/png") + + for _, search := range []string{"%", "_", "!", "100%_"} { + found, err := repo.List(domainImage.SearchQuery{Search: search}) + if err != nil { + t.Fatalf("List(%q) failed: %v", search, err) + } + if search == "!" { + if len(found) != 0 { + t.Errorf("List(%q) matched %d images, want 0", search, len(found)) + } + continue + } + if len(found) != 1 || found[0].ID != percent.ID { + t.Errorf("List(%q) = %+v, want only the 100%%_done image", search, found) + } + } + + articles := persistence.NewArticleRepository(db) + if _, err := articles.Create(domainArticle.Article{Title: "t", Body: "uses 50%_off.png here"}); err != nil { + t.Fatalf("article create failed: %v", err) + } + if found, _ := articles.FindByBodyContaining("0%_o"); len(found) != 1 { + t.Errorf("expected the literal match, got %d", len(found)) + } + if found, _ := articles.FindByBodyContaining("0x_o"); len(found) != 0 { + t.Errorf("underscore must not act as a wildcard, got %d", len(found)) + } +} diff --git a/test/update_download_http_test.go b/test/update_download_http_test.go index 2fd90da..d5a6845 100644 --- a/test/update_download_http_test.go +++ b/test/update_download_http_test.go @@ -6,6 +6,7 @@ import ( "RenewCMS/api/pkg/apperr" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/update" + "RenewCMS/internal/infrastructure/persistence/database" infraUpdate "RenewCMS/internal/infrastructure/update" "RenewCMS/internal/infrastructure/useCases" "context" @@ -551,7 +552,7 @@ func TestUpdateBackupBeforeReadyHTTP(t *testing.T) { } backdate(t, path, time.Duration(10-index)*time.Hour) } - rig.realBackup = infraUpdate.NewSQLiteDatabaseBackup(newBackupTestDatabase(t), backups, time.Now) + rig.realBackup = database.NewSQLiteBackup(newBackupTestDatabase(t), backups, time.Now) rig.rebuild() h := newDownloadHTTP(t, rig) diff --git a/test/update_download_test.go b/test/update_download_test.go index bed1046..7bda369 100644 --- a/test/update_download_test.go +++ b/test/update_download_test.go @@ -1,8 +1,8 @@ package test import ( - "RenewCMS/api" "RenewCMS/internal/domain/update" + "RenewCMS/internal/infrastructure/persistence/database" "RenewCMS/internal/infrastructure/persistence/models" infraUpdate "RenewCMS/internal/infrastructure/update" "RenewCMS/internal/infrastructure/useCases" @@ -768,7 +768,7 @@ func TestSQLiteDatabaseBackup(t *testing.T) { db := newBackupTestDatabase(t) backups := filepath.Join(t.TempDir(), "backups") clock := time.Date(2026, 9, 30, 10, 0, 0, 0, time.UTC) - backup := infraUpdate.NewSQLiteDatabaseBackup(db, backups, func() time.Time { + backup := database.NewSQLiteBackup(db, backups, func() time.Time { clock = clock.Add(time.Second) return clock }) @@ -813,7 +813,7 @@ func TestSQLiteDatabaseBackup(t *testing.T) { t.Run("a failed copy prunes nothing", func(t *testing.T) { directory := t.TempDir() fixed := func() time.Time { return clock } - failing := infraUpdate.NewSQLiteDatabaseBackup(db, directory, fixed) + failing := database.NewSQLiteBackup(db, directory, fixed) if _, err := failing.Backup(); err != nil { t.Fatal(err) } @@ -837,7 +837,7 @@ func TestSQLiteDatabaseBackup(t *testing.T) { func newBackupTestDatabase(t *testing.T) *gorm.DB { t.Helper() path := filepath.Join(t.TempDir(), "live.db") - db, err := gorm.Open(sqlite.Open(api.SQLiteDSN(path)), &gorm.Config{Logger: logger.Discard}) + db, err := gorm.Open(sqlite.Open(database.SQLiteDSN(path)), &gorm.Config{Logger: logger.Discard}) if err != nil { t.Fatal(err) } @@ -904,7 +904,7 @@ func TestSQLiteDatabaseBackupStaleCopyHeldOpen(t *testing.T) { t.Fatal(err) } defer held.Close() - backup := infraUpdate.NewSQLiteDatabaseBackup(db, directory, time.Now) + backup := database.NewSQLiteBackup(db, directory, time.Now) if _, err := backup.Backup(); err == nil { t.Error("a stale copy that cannot be removed must surface as an error") @@ -1001,7 +1001,7 @@ func TestSQLiteDatabaseBackupKeepsTheNewestNameOnEqualTimes(t *testing.T) { t.Fatal(err) } } - backup := infraUpdate.NewSQLiteDatabaseBackup(newBackupTestDatabase(t), directory, time.Now) + backup := database.NewSQLiteBackup(newBackupTestDatabase(t), directory, time.Now) if _, err := backup.Backup(); err != nil { t.Fatal(err) diff --git a/test/update_restart_http_test.go b/test/update_restart_http_test.go index f3910cb..f1d60a0 100644 --- a/test/update_restart_http_test.go +++ b/test/update_restart_http_test.go @@ -6,6 +6,7 @@ import ( validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/update" "RenewCMS/internal/infrastructure/config" + "RenewCMS/internal/infrastructure/storage" infraUpdate "RenewCMS/internal/infrastructure/update" "RenewCMS/internal/infrastructure/useCases" "encoding/json" @@ -551,7 +552,10 @@ func TestRestartResultLivesUntilTheNextJob(t *testing.T) { func TestHealthzIsUnchanged(t *testing.T) { t.Run("Healthz is unchanged", func(t *testing.T) { - container := &api.UseCases{Token: jwtauth.New("HS256", []byte("test-secret"), nil)} + container := &api.UseCases{ + Token: jwtauth.New("HS256", []byte("test-secret"), nil), + OpenMediaUseCase: useCases.NewOpenMediaUseCase(storage.NewLocalStorage(t.TempDir())), + } router := api.InitRoutes(container, config.Settings{Environment: "production"}) rec := httptest.NewRecorder() @@ -609,7 +613,10 @@ func TestRunningInfoFileAtStartup(t *testing.T) { }) t.Run("the file is not served by any route", func(t *testing.T) { - container := &api.UseCases{Token: jwtauth.New("HS256", []byte("test-secret"), nil)} + container := &api.UseCases{ + Token: jwtauth.New("HS256", []byte("test-secret"), nil), + OpenMediaUseCase: useCases.NewOpenMediaUseCase(storage.NewLocalStorage(t.TempDir())), + } router := api.InitRoutes(container, config.Settings{Environment: "production"}) for _, target := range []string{"/update-running.json", "/v1/update-running.json", "/static/uploadedImages/update-running.json"} { diff --git a/test/use_case_baseline_test.go b/test/use_case_baseline_test.go index 09ff2da..4775955 100644 --- a/test/use_case_baseline_test.go +++ b/test/use_case_baseline_test.go @@ -334,7 +334,7 @@ func TestCreateImageDelegatesContentTypeCheck(t *testing.T) { for _, tc := range cases { t.Run(strings.ReplaceAll(tc.contentType, "/", "_"), func(t *testing.T) { repo := newMockImageRepo() - uc := useCases.NewCreateImageUseCase(repo) + uc := useCases.NewCreateImageUseCase(repo, newMockImageStorage()) content := []byte("fake-image-data") if tc.valid { @@ -368,7 +368,7 @@ func TestCreateImageDelegatesContentTypeCheck(t *testing.T) { func TestCreateImagePropagatesRepositoryFailure(t *testing.T) { repo := newMockImageRepo() repo.fail.failNext("Create") - uc := useCases.NewCreateImageUseCase(repo) + uc := useCases.NewCreateImageUseCase(repo, newMockImageStorage()) _, err := uc.CreateImage(imageDomain.ImageInput{ Content: bytes.NewReader(pngBytes(t, 3, 2)), @@ -385,7 +385,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) { t.Run("id_1", func(t *testing.T) { repo := newMockImageRepo() repo.images[1] = imageDomain.Image{ID: 1} - uc := useCases.NewDeleteMediaUseCase(repo) + uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage()) err := uc.Delete(1) if err != nil { @@ -399,7 +399,7 @@ func TestDeleteImageRequiresExistence(t *testing.T) { t.Run("id_9", func(t *testing.T) { repo := newMockImageRepo() repo.images[1] = imageDomain.Image{ID: 1} - uc := useCases.NewDeleteMediaUseCase(repo) + uc := useCases.NewDeleteMediaUseCase(repo, newMockImageStorage()) err := uc.Delete(9) if !errors.Is(err, imageDomain.ErrImageNotFound) { diff --git a/test/user_roles_test.go b/test/user_roles_test.go index 57b4e71..072f5fd 100644 --- a/test/user_roles_test.go +++ b/test/user_roles_test.go @@ -7,8 +7,8 @@ import ( "RenewCMS/api/pkg/apperr" validator "RenewCMS/api/pkg/validator" "RenewCMS/internal/domain/user" + "RenewCMS/internal/infrastructure/persistence/database" entity "RenewCMS/internal/infrastructure/persistence/models" - persistence "RenewCMS/internal/infrastructure/persistence/repositories" "RenewCMS/internal/infrastructure/useCases" "bytes" "net/http" @@ -451,7 +451,7 @@ func TestBackfillUserRoles(t *testing.T) { seed(t, db, 5, "") seed(t, db, 9, "") - if err := persistence.BackfillUserRoles(db); err != nil { + if err := database.BackfillUserRoles(db); err != nil { t.Fatalf("backfill: %v", err) } @@ -466,7 +466,7 @@ func TestBackfillUserRoles(t *testing.T) { seed(t, db, 2, "admin") seed(t, db, 4, "owner") - if err := persistence.BackfillUserRoles(db); err != nil { + if err := database.BackfillUserRoles(db); err != nil { t.Fatalf("backfill: %v", err) } @@ -479,7 +479,7 @@ func TestBackfillUserRoles(t *testing.T) { t.Run("An empty database is left alone", func(t *testing.T) { db := openDB(t) - if err := persistence.BackfillUserRoles(db); err != nil { + if err := database.BackfillUserRoles(db); err != nil { t.Fatalf("backfill: %v", err) } }) diff --git a/test/write_not_found_errors_test.go b/test/write_not_found_errors_test.go index b32198b..064d080 100644 --- a/test/write_not_found_errors_test.go +++ b/test/write_not_found_errors_test.go @@ -172,7 +172,7 @@ func TestTheImageRepositoryReportsAMissingImageAsADomainErrorOnDelete(t *testing if err := db.Create(&models.Image{Path: "/static/uploadedImages/one.png"}).Error; err != nil { t.Fatalf("failed to seed image: %v", err) } - repo := persistence.NewImageRepository(db, t.TempDir()) + repo := persistence.NewImageRepository(db) err := repo.Delete(9) assertWriteResultIsImageNotFound(t, err)