refac: decouple persistence and image storage behind driver registries
This commit is contained in:
1 parent
421cfafec3
commit
81e299f09a
46 files changed
+1036
-374
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"} {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user