782 lines
29 KiB
Go
782 lines
29 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/api/pkg/apperr"
|
|
domain "RenewCMS/internal/domain/image"
|
|
"RenewCMS/internal/infrastructure/imaging"
|
|
"RenewCMS/internal/infrastructure/useCases"
|
|
"bytes"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
listedStoredName = "a1b2.jpg"
|
|
svgStoredName = "e5f6.svg"
|
|
imageNotFoundBody = "Image not found"
|
|
internalErrorBody = "Internal server error"
|
|
unreadableBodyBytes = "not an image at all"
|
|
)
|
|
|
|
func (e *variantEnv) seedPerImage(id uint32, filename string, storedName string, contentType string, width int, height int, content []byte) domain.Image {
|
|
img := domain.Image{
|
|
ID: id,
|
|
Path: domain.PublicPath(storedName),
|
|
Filename: filename,
|
|
Size: int64(len(content)),
|
|
ContentType: contentType,
|
|
Width: width,
|
|
Height: height,
|
|
SHA256: "hash-" + strconv.Itoa(int(id)),
|
|
CreatedAt: mockImageEpoch.Add(time.Duration(id) * time.Minute),
|
|
}
|
|
e.images.images[id] = img
|
|
if id > e.images.nextID {
|
|
e.images.nextID = id
|
|
}
|
|
if content != nil {
|
|
e.storage.seed(storedName, content)
|
|
}
|
|
return img
|
|
}
|
|
|
|
func (e *variantEnv) seedCurrentFile(variantName string, storedName string, size int64) domain.VariantFile {
|
|
cached := "cached-" + variantName + "-" + storedName
|
|
e.storage.seed(cached, []byte("generated"))
|
|
saved, _ := e.files.Save(domain.VariantFile{
|
|
VariantName: variantName,
|
|
SourceName: storedName,
|
|
CachedName: cached,
|
|
ContentType: "image/webp",
|
|
Size: size,
|
|
Fingerprint: e.variants.stored(variantName).Settings().Fingerprint(),
|
|
})
|
|
return saved
|
|
}
|
|
|
|
func (e *variantEnv) disable(name string) {
|
|
variant := e.variants.stored(name)
|
|
variant.Enabled = false
|
|
e.variants.variants[name] = variant
|
|
}
|
|
|
|
func (e *variantEnv) seedListingVariants() {
|
|
seedVariant(e.variants, "card", 800, 0, domain.FitInside, domain.FormatWebP, 80)
|
|
seedVariant(e.variants, "hero", 1200, 630, domain.FitCover, domain.FormatWebP, 82)
|
|
seedVariant(e.variants, "og", 1200, 630, domain.FitCover, domain.FormatJPEG, 80)
|
|
seedVariant(e.variants, "print", 0, 2400, domain.FitInside, domain.FormatPNG, 0)
|
|
seedVariant(e.variants, "square", 600, 600, domain.FitCover, domain.FormatJPEG, 85)
|
|
seedVariant(e.variants, "thumb", 400, 300, domain.FitCover, domain.FormatWebP, 80)
|
|
e.disable("print")
|
|
}
|
|
|
|
func (e *variantEnv) seedListingBackground() {
|
|
e.seedListingVariants()
|
|
e.seedPerImage(1, "north-wall.jpg", listedStoredName, "image/jpeg", 4032, 3024, nil)
|
|
e.images.images[1] = withSize(e.images.images[1], 4089446)
|
|
e.seedPerImage(2, "logo.svg", svgStoredName, "image/svg+xml", 0, 0, nil)
|
|
e.seedCurrentFile("thumb", listedStoredName, 18432)
|
|
e.seedCurrentFile("card", listedStoredName, 65536)
|
|
e.seedCurrentFile("hero", listedStoredName, 98304)
|
|
e.seedCurrentFile("square", listedStoredName, 83968)
|
|
e.seedCurrentFile("print", listedStoredName, 3250586)
|
|
}
|
|
|
|
func withSize(img domain.Image, size int64) domain.Image {
|
|
img.Size = size
|
|
return img
|
|
}
|
|
|
|
func (e *variantEnv) seedGenerateBackground() {
|
|
e.seedVariantsForGeneration()
|
|
e.seedPerImage(1, "north-wall.jpg", "a1b2.png", "image/png", 1000, 800, encodePNG(e.t, variantSolidImage(1000, 800, variantRed)))
|
|
}
|
|
|
|
func (e *variantEnv) seedVariantsForGeneration() {
|
|
seedVariant(e.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
|
|
seedVariant(e.variants, "hero", 1200, 630, domain.FitCover, domain.FormatJPEG, 82)
|
|
seedVariant(e.variants, "off", 300, 0, domain.FitInside, domain.FormatPNG, 0)
|
|
e.disable("off")
|
|
}
|
|
|
|
func itemsOf(t *testing.T, recorder *httptest.ResponseRecorder) []map[string]any {
|
|
t.Helper()
|
|
raw, ok := decodeBody(t, recorder)["items"].([]any)
|
|
if !ok {
|
|
t.Fatalf("items missing in %q", recorder.Body.String())
|
|
}
|
|
items := make([]map[string]any, 0, len(raw))
|
|
for _, entry := range raw {
|
|
items = append(items, entry.(map[string]any))
|
|
}
|
|
return items
|
|
}
|
|
|
|
func itemNames(items []map[string]any) []string {
|
|
names := make([]string, 0, len(items))
|
|
for _, item := range items {
|
|
names = append(names, item["name"].(string))
|
|
}
|
|
return names
|
|
}
|
|
|
|
func itemNamed(t *testing.T, items []map[string]any, name string) map[string]any {
|
|
t.Helper()
|
|
for _, item := range items {
|
|
if item["name"] == name {
|
|
return item
|
|
}
|
|
}
|
|
t.Fatalf("no item named %q in %v", name, itemNames(items))
|
|
return nil
|
|
}
|
|
|
|
func TestPerImageVariantRoutesAreGuarded(t *testing.T) {
|
|
rows := []struct {
|
|
method string
|
|
path string
|
|
credentials string
|
|
status int
|
|
body string
|
|
}{
|
|
{http.MethodGet, "/v1/media/1/variants", "none", 401, apperr.UnauthorizedErrorMessage},
|
|
{http.MethodGet, "/v1/media/1/variants", "stale", 401, apperr.UnauthorizedErrorMessage},
|
|
{http.MethodGet, "/v1/media/1/variants", "unverified", 403, apperr.VerificationRequiredErrorMessage},
|
|
{http.MethodGet, "/v1/media/1/variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
|
{http.MethodPost, "/v1/media/1/variants", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
|
{http.MethodPost, "/v1/media/1/variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
|
{http.MethodDelete, "/v1/media/1/variants/hero", "plain", 403, apperr.OwnerOrAdminRequiredErrorMessage},
|
|
{http.MethodGet, "/v1/media/1/variants", "apikey", 401, apperr.UnauthorizedErrorMessage},
|
|
{http.MethodPost, "/v1/media/1/variants", "apikey", 401, apperr.UnauthorizedErrorMessage},
|
|
}
|
|
for _, row := range rows {
|
|
t.Run("Every per-image variant route is guarded: "+row.method+" "+row.path+" "+row.credentials, func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
env.seedGenerateBackground()
|
|
variantCalls, fileCalls, opens := env.variants.calls, env.files.calls, env.storage.opens
|
|
|
|
request := httptest.NewRequest(row.method, row.path, nil)
|
|
switch row.credentials {
|
|
case "stale":
|
|
request.AddCookie(env.cookie(variantStaleId, 0))
|
|
case "unverified":
|
|
request.AddCookie(env.cookie(variantUnverifiedId, 0))
|
|
case "plain":
|
|
request.AddCookie(env.cookie(variantPlainUserId, 0))
|
|
case "apikey":
|
|
request.Header.Set("Authorization", "Bearer "+env.issueAPIKey())
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
env.admin.ServeHTTP(recorder, request)
|
|
|
|
assertStatusAndBody(t, recorder, row.status, row.body)
|
|
if env.variants.calls != variantCalls || env.files.calls != fileCalls || env.storage.opens != opens || env.storage.putCount() != 0 || env.renderer.renderCount() != 0 || env.events() != 0 {
|
|
t.Error("a variant, index row, blob or activity event was read or changed")
|
|
}
|
|
})
|
|
}
|
|
|
|
for name, userId := range map[string]uint32{"the owner": variantOwnerId, "an admin": variantAdminId} {
|
|
t.Run("Owners and admins are both allowed: "+name, func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
env.seedGenerateBackground()
|
|
|
|
recorder := env.send(http.MethodGet, "/v1/media/1/variants", "", userId)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Errorf("status = %d", recorder.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPerImageVariantMalformedAndUnknown(t *testing.T) {
|
|
rows := []struct {
|
|
method string
|
|
id string
|
|
suffix string
|
|
status int
|
|
body string
|
|
}{
|
|
{http.MethodGet, "abc", "", 400, apperr.IdUint32ErrorMessage},
|
|
{http.MethodPost, "abc", "", 400, apperr.IdUint32ErrorMessage},
|
|
{http.MethodGet, "9999", "", 404, imageNotFoundBody},
|
|
{http.MethodPost, "9999", "", 404, imageNotFoundBody},
|
|
{http.MethodPost, "9999", "/hero", 404, imageNotFoundBody},
|
|
{http.MethodDelete, "9999", "/hero", 404, imageNotFoundBody},
|
|
}
|
|
for _, row := range rows {
|
|
t.Run("A malformed id or an unknown image: "+row.method+" "+row.id+row.suffix, func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
env.seedGenerateBackground()
|
|
|
|
recorder := env.sendAsAdmin(row.method, "/v1/media/"+row.id+"/variants"+row.suffix, "")
|
|
|
|
assertStatusAndBody(t, recorder, row.status, row.body)
|
|
if env.events() != 0 {
|
|
t.Error("an activity event was recorded")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPerImageVariantListing(t *testing.T) {
|
|
t.Run("The enabled variants, in name order, with their output and what is cached", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
env.seedListingBackground()
|
|
|
|
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
|
|
|
|
if recorder.Code != 200 || decodeBody(t, recorder)["unsupported"] != nil {
|
|
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
|
|
}
|
|
items := itemsOf(t, recorder)
|
|
if got := strings.Join(itemNames(items), ","); got != "card,hero,og,square,thumb" {
|
|
t.Fatalf("names = %s", got)
|
|
}
|
|
expected := []struct {
|
|
name, format string
|
|
width, height any
|
|
outWidth, outHeight float64
|
|
generated bool
|
|
size any
|
|
}{
|
|
{"card", "webp", 800.0, nil, 800, 600, true, 65536.0},
|
|
{"hero", "webp", 1200.0, 630.0, 1200, 630, true, 98304.0},
|
|
{"og", "jpeg", 1200.0, 630.0, 1200, 630, false, nil},
|
|
{"square", "jpeg", 600.0, 600.0, 600, 600, true, 83968.0},
|
|
{"thumb", "webp", 400.0, 300.0, 400, 300, true, 18432.0},
|
|
}
|
|
for _, want := range expected {
|
|
item := itemNamed(t, items, want.name)
|
|
if item["width"] != want.width || item["height"] != want.height || item["output_width"] != want.outWidth || item["output_height"] != want.outHeight || item["output_format"] != want.format || item["generated"] != want.generated || item["size"] != want.size {
|
|
t.Errorf("%s = %v", want.name, item)
|
|
}
|
|
}
|
|
if itemNamed(t, items, "thumb")["url"] != "/static/uploadedImages/a1b2.jpg?v=thumb" || itemNamed(t, items, "card")["fit"] != "inside" || itemNamed(t, items, "hero")["format"] != "webp" {
|
|
t.Errorf("thumb = %v", itemNamed(t, items, "thumb"))
|
|
}
|
|
if !strings.Contains(recorder.Body.String(), `"height":null`) || !strings.Contains(recorder.Body.String(), `"size":null`) {
|
|
t.Errorf("raw body = %q", recorder.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("A row whose fingerprint is stale is not generated", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
env.seedListingBackground()
|
|
row, _, _ := env.files.Find("hero", listedStoredName)
|
|
row.Fingerprint = "older-quality"
|
|
_, _ = env.files.Save(row)
|
|
|
|
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
|
|
|
|
hero := itemNamed(t, itemsOf(t, recorder), "hero")
|
|
if hero["generated"] != false || hero["size"] != nil {
|
|
t.Errorf("hero = %v", hero)
|
|
}
|
|
})
|
|
|
|
formats := []struct {
|
|
contentType string
|
|
webp bool
|
|
want string
|
|
}{
|
|
{"image/jpeg", true, "jpeg"},
|
|
{"image/png", true, "png"},
|
|
{"image/webp", true, "webp"},
|
|
{"image/webp", false, "png"},
|
|
}
|
|
for _, row := range formats {
|
|
t.Run("A variant in the original format takes the format of the source: "+row.contentType+" webp "+strconv.FormatBool(row.webp), func(t *testing.T) {
|
|
renderer := newCountingRenderer()
|
|
if !row.webp {
|
|
renderer = newCountingRendererWithoutWebP()
|
|
}
|
|
env := newVariantEnvWith(t, renderer, variantDefaultCap)
|
|
seedVariant(env.variants, "keep", 100, 0, domain.FitInside, domain.FormatOriginal, 80)
|
|
env.seedPerImage(1, "photo", "src.bin", row.contentType, 400, 300, nil)
|
|
|
|
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
|
|
|
|
if got := itemNamed(t, itemsOf(t, recorder), "keep")["output_format"]; got != row.want {
|
|
t.Errorf("output_format = %v, want %s", got, row.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
geometry := []struct {
|
|
sw, sh, w, h int
|
|
fit domain.VariantFit
|
|
ow, oh float64
|
|
}{
|
|
{4032, 3024, 800, 0, domain.FitCover, 800, 600},
|
|
{4032, 3024, 0, 2400, domain.FitInside, 3200, 2400},
|
|
{4032, 3024, 8000, 8000, domain.FitCover, 3024, 3024},
|
|
{300, 200, 800, 0, domain.FitCover, 300, 200},
|
|
{300, 200, 400, 300, domain.FitCover, 267, 200},
|
|
{300, 200, 400, 300, domain.FitContain, 400, 300},
|
|
{300, 200, 1200, 1200, domain.FitInside, 300, 200},
|
|
}
|
|
for _, row := range geometry {
|
|
name := strconv.Itoa(row.sw) + "x" + strconv.Itoa(row.sh) + " into " + strconv.Itoa(row.w) + "x" + strconv.Itoa(row.h) + " " + string(row.fit)
|
|
t.Run("The output size never exceeds the source: "+name, func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
variant := seedVariant(env.variants, "probe", row.w, row.h, row.fit, domain.FormatPNG, 0)
|
|
env.seedPerImage(1, "photo", "src.png", "image/png", row.sw, row.sh, nil)
|
|
|
|
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
|
|
|
|
item := itemNamed(t, itemsOf(t, recorder), "probe")
|
|
if item["output_width"] != row.ow || item["output_height"] != row.oh {
|
|
t.Fatalf("output = %v x %v", item["output_width"], item["output_height"])
|
|
}
|
|
rendered, err := imaging.NewVariantRenderer().Render(bytes.NewReader(encodePNG(t, variantSolidImage(row.sw, row.sh, variantRed))), variant.Settings())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
width, height := decodedSize(t, rendered.Content)
|
|
if float64(width) != row.ow || float64(height) != row.oh {
|
|
t.Errorf("renderer produced %d x %d", width, height)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("A source that no variant can be generated for answers an empty list", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
env.seedListingBackground()
|
|
|
|
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/2/variants", "")
|
|
|
|
assertStatusAndBody(t, recorder, 200, `{"unsupported":"svg","items":[]}`)
|
|
})
|
|
|
|
t.Run("No enabled variant answers an empty array, not null", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
env.seedListingBackground()
|
|
for _, name := range []string{"card", "hero", "og", "square", "thumb"} {
|
|
env.disable(name)
|
|
}
|
|
|
|
recorder := env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
|
|
|
|
if recorder.Code != 200 || !strings.Contains(recorder.Body.String(), `"items":[]`) || !strings.Contains(recorder.Body.String(), `"unsupported":null`) {
|
|
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("Listing reads nothing it should not change", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
env.seedListingBackground()
|
|
rows := env.files.rowCount()
|
|
|
|
env.sendAsAdmin(http.MethodGet, "/v1/media/1/variants", "")
|
|
|
|
if env.storage.opens != 0 || env.storage.putCount() != 0 || env.renderer.renderCount() != 0 || env.files.saves != 5 || env.files.rowCount() != rows || env.events() != 0 {
|
|
t.Error("the listing opened a blob, rendered, wrote a row or recorded an event")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestPerImageVariantGenerateOne(t *testing.T) {
|
|
t.Run("The first generation renders, stores and indexes the file", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
env.seedGenerateBackground()
|
|
|
|
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
|
|
|
|
body := decodeBody(t, recorder)
|
|
row, found, _ := env.files.Find("thumb", "a1b2.png")
|
|
if recorder.Code != 200 || body["name"] != "thumb" || body["generated"] != true || body["output_width"] != 400.0 || body["output_height"] != 300.0 || body["output_format"] != "jpeg" || !found || body["size"] != float64(row.Size) {
|
|
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
|
|
}
|
|
stored, _ := env.storage.Open(row.CachedName)
|
|
if stored.Size != row.Size || env.storage.putCount() != 1 || env.files.rowCount() != 1 || len(env.storage.deletedNames()) != 0 || !env.storage.has("a1b2.png") || env.events() != 0 {
|
|
t.Error("the generation changed more than the one blob and row")
|
|
}
|
|
})
|
|
|
|
t.Run("The public url then serves that file without rendering", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
env.seedGenerateBackground()
|
|
first := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
|
|
row, _, _ := env.files.Find("thumb", "a1b2.png")
|
|
|
|
public := env.get("/static/uploadedImages/a1b2.png?v=thumb", nil)
|
|
|
|
if first.Code != 200 || public.Code != 200 || !bytes.Equal(public.Body.Bytes(), env.storage.blobs[row.CachedName]) || env.renderer.renderCount() != 1 {
|
|
t.Errorf("public = %d, renders = %d", public.Code, env.renderer.renderCount())
|
|
}
|
|
})
|
|
|
|
t.Run("A file generated by the public url is a hit for the admin route", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
env.seedGenerateBackground()
|
|
env.get("/static/uploadedImages/a1b2.png?v=thumb", nil)
|
|
|
|
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
|
|
|
|
if recorder.Code != 200 || env.renderer.renderCount() != 1 || env.storage.putCount() != 1 {
|
|
t.Errorf("status = %d, renders = %d", recorder.Code, env.renderer.renderCount())
|
|
}
|
|
})
|
|
|
|
t.Run("Generating a variant that is already generated renders nothing", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
env.seedGenerateBackground()
|
|
first := decodeBody(t, env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", ""))
|
|
|
|
again := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
|
|
|
|
if again.Code != 200 || decodeBody(t, again)["size"] != first["size"] || env.renderer.renderCount() != 1 {
|
|
t.Errorf("status = %d, renders = %d", again.Code, env.renderer.renderCount())
|
|
}
|
|
})
|
|
|
|
t.Run("A public request and an admin request at the same time render once", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
env.seedGenerateBackground()
|
|
env.renderer.onRender = func() {
|
|
for env.storage.openCountOf("a1b2.png") < 3 {
|
|
runtime.Gosched()
|
|
}
|
|
}
|
|
codes := make([]int, 2)
|
|
start := make(chan struct{})
|
|
var finished sync.WaitGroup
|
|
finished.Add(2)
|
|
go func() {
|
|
defer finished.Done()
|
|
<-start
|
|
codes[0] = env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "").Code
|
|
}()
|
|
go func() {
|
|
defer finished.Done()
|
|
<-start
|
|
codes[1] = env.get("/static/uploadedImages/a1b2.png?v=thumb", nil).Code
|
|
}()
|
|
close(start)
|
|
finished.Wait()
|
|
|
|
if codes[0] != 200 || codes[1] != 200 || env.renderer.renderCount() != 1 || env.storage.putCount() != 1 {
|
|
t.Errorf("codes = %v, renders = %d, puts = %d", codes, env.renderer.renderCount(), env.storage.putCount())
|
|
}
|
|
})
|
|
|
|
refused := []struct{ name, variant string }{{"unknown", "missing"}, {"disabled", "off"}, {"wrongly cased", "Thumb"}}
|
|
for _, row := range refused {
|
|
t.Run("A request that cannot be served: "+row.name, func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
env.seedGenerateBackground()
|
|
|
|
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/"+row.variant, "")
|
|
|
|
assertStatusAndBody(t, recorder, 404, variantNotFound)
|
|
if env.renderer.renderCount() != 0 || env.storage.putCount() != 0 {
|
|
t.Error("something was rendered or stored")
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("An SVG answers 422", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
env.seedGenerateBackground()
|
|
env.seedPerImage(2, "logo.svg", svgStoredName, "image/svg+xml", 0, 0, []byte("<svg/>"))
|
|
|
|
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/2/variants/thumb", "")
|
|
|
|
assertStatusAndBody(t, recorder, 422, apperr.VariantSourceUnsupportedErrorMessage)
|
|
if env.renderer.renderCount() != 0 || env.storage.putCount() != 0 {
|
|
t.Error("something was rendered or stored")
|
|
}
|
|
})
|
|
|
|
t.Run("A source beyond the byte cap answers 413", func(t *testing.T) {
|
|
env := newVariantEnvWith(t, newCountingRenderer(), 10)
|
|
env.seedGenerateBackground()
|
|
|
|
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
|
|
|
|
assertStatusAndBody(t, recorder, 413, apperr.VariantSourceTooLargeErrorMessage)
|
|
if env.storage.putCount() != 0 {
|
|
t.Error("a blob was stored")
|
|
}
|
|
})
|
|
|
|
t.Run("A source beyond the pixel cap answers 413", func(t *testing.T) {
|
|
env := newVariantEnvLimits(t, newCountingRenderer(), useCases.ServeVariantLimits{MaxSourceBytes: variantDefaultCap, MaxSourcePixels: 100, MaxConcurrentRenders: variantDefaultRenders})
|
|
env.seedGenerateBackground()
|
|
|
|
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
|
|
|
|
assertStatusAndBody(t, recorder, 413, apperr.VariantSourceTooLargeErrorMessage)
|
|
if env.storage.putCount() != 0 {
|
|
t.Error("a blob was stored")
|
|
}
|
|
})
|
|
|
|
t.Run("A source that cannot be decoded answers 415", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
env.seedGenerateBackground()
|
|
env.storage.seed("a1b2.png", []byte(unreadableBodyBytes))
|
|
|
|
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
|
|
|
|
assertStatusAndBody(t, recorder, 415, apperr.VariantSourceUnreadableErrorMessage)
|
|
if env.storage.putCount() != 0 {
|
|
t.Error("a blob was stored")
|
|
}
|
|
})
|
|
|
|
t.Run("Any other render failure is a generic 500 and is not cached", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
env.seedGenerateBackground()
|
|
env.renderer.failFirst = true
|
|
|
|
first := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
|
|
puts, rows := env.storage.putCount(), env.files.rowCount()
|
|
second := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
|
|
|
|
assertStatusAndBody(t, first, 500, internalErrorBody)
|
|
if puts != 0 || rows != 0 || second.Code != 200 {
|
|
t.Errorf("puts = %d, rows = %d, second = %d", puts, rows, second.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("A generation does not change the original, the other variants or other images", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
env.seedGenerateBackground()
|
|
env.seedPerImage(3, "other.png", "c3d4.png", "image/png", 10, 10, solidPNG(t, 10, 10))
|
|
other := env.seedCurrentFile("hero", "c3d4.png", 5)
|
|
before := env.images.images[1]
|
|
|
|
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
|
|
|
|
kept, _, _ := env.files.Find("hero", "c3d4.png")
|
|
if env.images.images[1] != before || kept != other || env.files.rowCount() != 2 {
|
|
t.Error("the original, another variant or another image changed")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestPerImageVariantGenerateAll(t *testing.T) {
|
|
seedFive := func(env *variantEnv) {
|
|
seedVariant(env.variants, "card", 800, 0, domain.FitInside, domain.FormatJPEG, 80)
|
|
seedVariant(env.variants, "hero", 1200, 630, domain.FitCover, domain.FormatJPEG, 82)
|
|
seedVariant(env.variants, "og", 1200, 630, domain.FitCover, domain.FormatJPEG, 80)
|
|
seedVariant(env.variants, "print", 0, 2400, domain.FitInside, domain.FormatPNG, 0)
|
|
seedVariant(env.variants, "square", 600, 600, domain.FitCover, domain.FormatJPEG, 85)
|
|
seedVariant(env.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
|
|
env.disable("print")
|
|
env.seedPerImage(1, "north-wall.jpg", "a1b2.png", "image/png", 1000, 800, encodePNG(env.t, variantSolidImage(1000, 800, variantRed)))
|
|
}
|
|
|
|
t.Run("Generate all renders every enabled variant and answers the refreshed list", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
seedFive(env)
|
|
|
|
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
|
|
|
|
body := decodeBody(t, recorder)
|
|
items := itemsOf(t, recorder)
|
|
if recorder.Code != 200 || body["generated"] != 5.0 || body["failed"] != 0.0 || !strings.Contains(recorder.Body.String(), `"failures":[]`) || len(items) != 5 {
|
|
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
|
|
}
|
|
for _, item := range items {
|
|
if item["generated"] != true || item["size"] == nil {
|
|
t.Errorf("item = %v", item)
|
|
}
|
|
}
|
|
if env.storage.putCount() != 5 || env.files.rowCount() != 5 || len(env.files.rowsOf("print")) != 0 {
|
|
t.Error("expected exactly five blobs and rows, none for the disabled variant")
|
|
}
|
|
})
|
|
|
|
t.Run("Variants already generated are skipped and not counted", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
seedFive(env)
|
|
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/card", "")
|
|
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
|
|
|
|
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
|
|
|
|
generated := decodeBody(t, recorder)["generated"]
|
|
if generated != 3.0 || env.renderer.renderCount() != 5 {
|
|
t.Errorf("generated = %v, renders = %d", generated, env.renderer.renderCount())
|
|
}
|
|
for _, item := range itemsOf(t, recorder) {
|
|
if item["generated"] != true {
|
|
t.Errorf("item = %v", item)
|
|
}
|
|
}
|
|
})
|
|
|
|
failures := []struct {
|
|
name string
|
|
fail error
|
|
cause string
|
|
}{
|
|
{"ErrSourceTooLarge", domain.ErrSourceTooLarge, "too_large"},
|
|
{"ErrVariantSourceUnreadable", domain.ErrVariantSourceUnreadable, "unsupported_format"},
|
|
{"a renderer error that wraps no sentinel", errors.New("boom"), "failed"},
|
|
}
|
|
for _, row := range failures {
|
|
t.Run("A failing variant is reported and the others are still generated: "+row.name, func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
seedFive(env)
|
|
heroFingerprint := env.variants.stored("hero").Settings().Fingerprint()
|
|
env.renderer.failFor = func(settings domain.VariantSettings) error {
|
|
if settings.Fingerprint() == heroFingerprint {
|
|
return row.fail
|
|
}
|
|
return nil
|
|
}
|
|
|
|
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
|
|
|
|
body := decodeBody(t, recorder)
|
|
failed, _ := body["failures"].([]any)
|
|
if recorder.Code != 200 || body["generated"] != 4.0 || body["failed"] != 1.0 || len(failed) != 1 || failed[0].(map[string]any)["name"] != "hero" || failed[0].(map[string]any)["cause"] != row.cause {
|
|
t.Fatalf("response = %d %q", recorder.Code, recorder.Body.String())
|
|
}
|
|
for _, item := range itemsOf(t, recorder) {
|
|
if (item["name"] == "hero") == (item["generated"] == true) {
|
|
t.Errorf("item = %v", item)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("Nothing to generate answers zero", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
seedFive(env)
|
|
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
|
|
rendered := env.renderer.renderCount()
|
|
|
|
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
|
|
|
|
body := decodeBody(t, recorder)
|
|
if recorder.Code != 200 || body["generated"] != 0.0 || body["failed"] != 0.0 || env.renderer.renderCount() != rendered {
|
|
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("No enabled variant answers zero", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
seedFive(env)
|
|
for _, name := range []string{"card", "hero", "og", "square", "thumb"} {
|
|
env.disable(name)
|
|
}
|
|
|
|
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants", "")
|
|
|
|
body := decodeBody(t, recorder)
|
|
if recorder.Code != 200 || body["generated"] != 0.0 || body["failed"] != 0.0 || !strings.Contains(recorder.Body.String(), `"items":[]`) {
|
|
t.Errorf("response = %d %q", recorder.Code, recorder.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("An SVG answers 422", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
seedFive(env)
|
|
env.seedPerImage(2, "logo.svg", svgStoredName, "image/svg+xml", 0, 0, []byte("<svg/>"))
|
|
|
|
recorder := env.sendAsAdmin(http.MethodPost, "/v1/media/2/variants", "")
|
|
|
|
assertStatusAndBody(t, recorder, 422, apperr.VariantSourceUnsupportedErrorMessage)
|
|
})
|
|
}
|
|
|
|
func TestPerImageVariantPurge(t *testing.T) {
|
|
seedBoth := func(env *variantEnv) {
|
|
seedVariant(env.variants, "thumb", 400, 300, domain.FitCover, domain.FormatJPEG, 80)
|
|
seedVariant(env.variants, "hero", 1200, 630, domain.FitCover, domain.FormatJPEG, 82)
|
|
env.seedPerImage(1, "north-wall.jpg", "a1b2.jpg", "image/jpeg", 100, 100, nil)
|
|
env.seedPerImage(3, "other.jpg", "c3d4.jpg", "image/jpeg", 100, 100, nil)
|
|
for _, source := range []string{"a1b2.jpg", "c3d4.jpg"} {
|
|
env.seedCurrentFile("thumb", source, 18432)
|
|
env.seedCurrentFile("hero", source, 98304)
|
|
}
|
|
}
|
|
|
|
t.Run("Purging a generated variant removes its file and its row", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
seedBoth(env)
|
|
|
|
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
|
|
|
|
assertStatusAndBody(t, recorder, 200, `{"purged_files":1,"purged_bytes":18432}`)
|
|
if got := strings.Join(env.storage.deletedNames(), ","); got != "cached-thumb-a1b2.jpg" {
|
|
t.Errorf("deleted = %s", got)
|
|
}
|
|
if _, found, _ := env.files.Find("thumb", "a1b2.jpg"); found || env.files.rowCount() != 3 {
|
|
t.Error("the row is still there, or another row went with it")
|
|
}
|
|
})
|
|
|
|
t.Run("The next public request generates the file again", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
env.seedGenerateBackground()
|
|
env.sendAsAdmin(http.MethodPost, "/v1/media/1/variants/thumb", "")
|
|
env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
|
|
puts := env.storage.putCount()
|
|
|
|
public := env.get("/static/uploadedImages/a1b2.png?v=thumb", nil)
|
|
|
|
if public.Code != 200 || env.storage.putCount() != puts+1 {
|
|
t.Errorf("status = %d, puts = %d", public.Code, env.storage.putCount())
|
|
}
|
|
})
|
|
|
|
t.Run("Purging a variant that is not generated answers zero", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
seedBoth(env)
|
|
row, _, _ := env.files.Find("thumb", "a1b2.jpg")
|
|
_ = env.files.Delete(row.ID)
|
|
|
|
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
|
|
|
|
assertStatusAndBody(t, recorder, 200, `{"purged_files":0,"purged_bytes":0}`)
|
|
})
|
|
|
|
t.Run("A disabled variant keeps its files and they can be purged", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
seedBoth(env)
|
|
env.disable("thumb")
|
|
|
|
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
|
|
|
|
if decodeBody(t, recorder)["purged_files"] != 1.0 {
|
|
t.Errorf("body = %q", recorder.Body.String())
|
|
}
|
|
})
|
|
|
|
t.Run("An unknown variant answers 404", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
seedBoth(env)
|
|
|
|
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/missing", "")
|
|
|
|
assertStatusAndBody(t, recorder, 404, variantNotFound)
|
|
})
|
|
|
|
t.Run("A blob that cannot be deleted keeps its row and the call fails", func(t *testing.T) {
|
|
env := newVariantEnv(t)
|
|
seedBoth(env)
|
|
env.storage.failDelete["cached-thumb-a1b2.jpg"] = errVariantStorageFailure
|
|
|
|
recorder := env.sendAsAdmin(http.MethodDelete, "/v1/media/1/variants/thumb", "")
|
|
|
|
assertStatusAndBody(t, recorder, 500, internalErrorBody)
|
|
if _, found, _ := env.files.Find("thumb", "a1b2.jpg"); !found {
|
|
t.Error("the index row was removed")
|
|
}
|
|
})
|
|
}
|