161 lines
5.6 KiB
Go
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))
|
|
}
|
|
}
|