refac: decouple persistence and image storage behind driver registries

This commit is contained in:
fsylvain committed 2026-10-06 02:13:35 +02:00
1 parent 421cfafec3
commit 81e299f09a
46 files changed
+1036 -374

No files matched your search

+3 -3
View File
@@ -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),
+7 -17
View File
@@ -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)
}
})
}
+82 -19
View File
@@ -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{
+5 -5
View File
@@ -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"))
+1 -1
View File
@@ -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)
+5 -3
View File
@@ -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),
+6 -6
View File
@@ -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("<html/>"), "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)
+17 -35
View File
@@ -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)
}
+1 -1
View File
@@ -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{
+3 -3
View File
@@ -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})
+210
View File
@@ -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)
}
})
}
+9 -25
View File
@@ -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")
}
}
+3 -1
View File
@@ -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))
+2 -2
View File
@@ -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)
}
+160
View File
@@ -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))
}
}
+2 -1
View File
@@ -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)
+6 -6
View File
@@ -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)
+9 -2
View File
@@ -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"} {
+4 -4
View File
@@ -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) {
+4 -4
View File
@@ -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)
}
})
+1 -1
View File
@@ -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)