Files
RenewCMS/test/storage_seam_test.go
T

161 lines
5.6 KiB
Go

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), newInertServeVariantUseCase(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{}, &models.Author{}, &models.ArticleAuthor{}); 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))
}
}