package test import ( "RenewCMS/api/pkg/apperr" "RenewCMS/internal/domain/article" domain "RenewCMS/internal/domain/image" "net/http" "slices" "strings" "testing" ) const libraryCounts = `{"all":5,"unused":3,"duplicates":1}` func (e *variantEnv) saveRow(variantName string, storedName string, size int64) { e.files.Save(domain.VariantFile{VariantName: variantName, SourceName: storedName, CachedName: "cached-" + variantName + "-" + storedName, ContentType: "image/webp", Size: size, Fingerprint: "f"}) e.storage.seed("cached-"+variantName+"-"+storedName, []byte("generated")) } func (e *variantEnv) seedLibrary() { library := []struct { id uint32 filename string storedName string contentType string size int64 sha string }{ {1, "north-wall.jpg", "a1b2.jpg", "image/jpeg", 4089446, "hash-north"}, {2, "floorboards.jpg", "f2f2.jpg", "image/jpeg", 2516582, "hash-floor"}, {3, "floorboards-copy.jpg", "f3f3.jpg", "image/jpeg", 2516582, "hash-floor"}, {4, "window-frame.jpg", "w4w4.jpg", "image/jpeg", 812345, "hash-window"}, {5, "logo.svg", "e5f6.svg", "image/svg+xml", 1204, "hash-logo"}, } for _, entry := range library { img := e.seedPerImage(entry.id, entry.filename, entry.storedName, entry.contentType, 100, 100, nil) img.Size, img.SHA256 = entry.size, entry.sha e.images.images[entry.id] = img } e.articles.articles[1] = article.Article{ID: 1, Title: "Walls", Body: "see /static/uploadedImages/a1b2.jpg and /static/uploadedImages/f2f2.jpg", IsOnline: true} for variantName, size := range map[string]int64{"thumb": 18432, "card": 65536, "hero": 98304, "square": 83968} { e.saveRow(variantName, "a1b2.jpg", size) } e.saveRow("thumb", "f2f2.jpg", 41000) for _, variantName := range []string{"old-a", "old-b", "old-c"} { e.saveRow(variantName, "w4w4.jpg", 1000000) } } func (e *variantEnv) listIds(query string) ([]float64, map[string]any) { e.t.Helper() recorder := e.send(http.MethodGet, "/v1/media/"+query, "", variantOwnerId) if recorder.Code != 200 { e.t.Fatalf("list %q = %d %q", query, recorder.Code, recorder.Body.String()) } body := decodeBody(e.t, recorder) ids := []float64{} for _, item := range body["items"].([]any) { ids = append(ids, item.(map[string]any)["id"].(float64)) } return ids, body } func itemWithId(t *testing.T, body map[string]any, id float64) map[string]any { t.Helper() for _, item := range body["items"].([]any) { if item.(map[string]any)["id"] == id { return item.(map[string]any) } } t.Fatalf("no item %v", id) return nil } func TestMediaListCarriesVariantFields(t *testing.T) { t.Run("Every item carries variant_count and variant_size", func(t *testing.T) { env := newVariantEnv(t) env.seedLibrary() ids, body := env.listIds("") if !slices.Equal(ids, []float64{5, 4, 3, 2, 1}) { t.Fatalf("order = %v", ids) } expected := map[float64][2]float64{1: {4, 266240}, 2: {1, 41000}, 3: {0, 0}, 4: {3, 3000000}, 5: {0, 0}} for id, want := range expected { item := itemWithId(t, body, id) if item["variant_count"] != want[0] || item["variant_size"] != want[1] { t.Errorf("item %v = %v", id, item) } } if itemWithId(t, body, 2)["duplicate"] != true || itemWithId(t, body, 1)["used_in"] != 1.0 { t.Error("duplicate or used_in changed") } }) t.Run("The list reads the totals of all sources in one call", func(t *testing.T) { env := newVariantEnv(t) env.seedLibrary() env.listIds("") if env.files.totalsBySourceCalls != 1 { t.Errorf("calls = %d", env.files.totalsBySourceCalls) } }) t.Run("A rename answers the same fields", func(t *testing.T) { env := newVariantEnv(t) env.seedLibrary() recorder := env.send(http.MethodPatch, "/v1/media/1", `{"filename": "north-wall-01"}`, variantOwnerId) body := decodeBody(t, recorder) if recorder.Code != 200 || body["variant_count"] != 4.0 || body["variant_size"] != 266240.0 { t.Errorf("response = %d %q", recorder.Code, recorder.Body.String()) } }) } func TestMediaListVariantsFilterAndSort(t *testing.T) { filters := map[string][]float64{"with": {1, 2, 4}, "without": {3, 5}} for variants, want := range filters { t.Run("variants narrows the list and the counts stay library-wide: "+variants, func(t *testing.T) { env := newVariantEnv(t) env.seedLibrary() ids, body := env.listIds("?variants=" + variants) slices.Sort(ids) counts := body["counts"].(map[string]any) if !slices.Equal(ids, want) || counts["all"] != 5.0 || counts["unused"] != 3.0 || counts["duplicates"] != 1.0 { t.Errorf("ids = %v, counts = %v", ids, counts) } }) } t.Run("variants, filter, q and sort combine", func(t *testing.T) { env := newVariantEnv(t) env.seedLibrary() unused, _ := env.listIds("?variants=with&filter=unused") searched, _ := env.listIds("?variants=with&q=floorboards") if !slices.Equal(unused, []float64{4}) || !slices.Equal(searched, []float64{2}) { t.Errorf("unused = %v, searched = %v", unused, searched) } }) t.Run("sort=total_size orders by original plus variants, largest first, then by id", func(t *testing.T) { env := newVariantEnv(t) env.seedLibrary() ids, _ := env.listIds("?sort=total_size") if !slices.Equal(ids, []float64{1, 4, 2, 3, 5}) { t.Errorf("order = %v", ids) } }) t.Run("sort=largest is unchanged and sorts by the original alone", func(t *testing.T) { env := newVariantEnv(t) env.seedLibrary() ids, _ := env.listIds("?sort=largest") if !slices.Equal(ids, []float64{1, 2, 3, 4, 5}) { t.Errorf("order = %v", ids) } }) t.Run("Equal totals fall back to the lowest id first", func(t *testing.T) { env := newVariantEnv(t) env.seedLibrary() third := env.images.images[3] third.Size = 2557582 env.images.images[3] = third ids, _ := env.listIds("?sort=total_size") if !slices.Equal(ids, []float64{1, 4, 2, 3, 5}) { t.Errorf("order = %v", ids) } }) t.Run("variants=with and sort=total_size answer the ordered page with its total", func(t *testing.T) { env := newVariantEnv(t) env.seedLibrary() ids, body := env.listIds("?variants=with&sort=total_size&size=5&page=1") beyond, _ := env.listIds("?variants=with&sort=total_size&size=5&page=2") if !slices.Equal(ids, []float64{1, 4, 2}) || body["total"] != float64(3) || len(beyond) != 0 { t.Errorf("ids = %v, total = %v, beyond = %v", ids, body["total"], beyond) } }) t.Run("variants=with on a library with an empty cache answers an empty list", func(t *testing.T) { env := newVariantEnv(t) env.seedLibrary() for _, row := range env.files.rows { _ = env.files.Delete(row.ID) } recorder := env.send(http.MethodGet, "/v1/media/?variants=with", "", variantOwnerId) if recorder.Code != 200 || !strings.Contains(recorder.Body.String(), `"items":[]`) || !strings.Contains(recorder.Body.String(), `"counts":`+libraryCounts) { t.Errorf("response = %d %q", recorder.Code, recorder.Body.String()) } }) for _, query := range []string{"variants=bogus", "variants=all", "variants=With", "variants=", "sort=Total_size", "sort=total"} { t.Run("A malformed query answers 400: "+query, func(t *testing.T) { env := newVariantEnv(t) env.seedLibrary() recorder := env.send(http.MethodGet, "/v1/media/?"+query, "", variantOwnerId) assertStatusAndBody(t, recorder, 400, apperr.MediaQueryErrorMessage) }) } } func TestMediaLibraryVariantConsistency(t *testing.T) { t.Run("The number shown by a delete is the number a delete removes", func(t *testing.T) { env := newVariantEnv(t) env.seedLibrary() env.storage.seed("a1b2.jpg", []byte("original")) recorder := env.send(http.MethodDelete, "/v1/media/1", "", variantOwnerId) deleted := env.storage.deletedNames() slices.Sort(deleted) expected := []string{"a1b2.jpg", "cached-card-a1b2.jpg", "cached-hero-a1b2.jpg", "cached-square-a1b2.jpg", "cached-thumb-a1b2.jpg"} if recorder.Code != 204 || !slices.Equal(deleted, expected) || len(env.files.rowsOf("hero")) != 0 || env.files.rowCount() != 4 { t.Errorf("status = %d, deleted = %v, rows = %d", recorder.Code, deleted, env.files.rowCount()) } }) t.Run("An article that uses a variant url still counts as using the image", func(t *testing.T) { env := newVariantEnv(t) env.seedLibrary() env.articles.articles[1] = article.Article{ID: 1, Title: "Walls", Body: "![x](/static/uploadedImages/a1b2.jpg?v=hero)", IsOnline: true} usage := env.send(http.MethodGet, "/v1/media/1/usage", "", variantOwnerId) _, body := env.listIds("") if usage.Code != 200 || !strings.Contains(usage.Body.String(), `"id":1`) || itemWithId(t, body, 1)["used_in"] != 1.0 { t.Errorf("usage = %d %q", usage.Code, usage.Body.String()) } }) t.Run("Merging keeps the variant parameter of the switched url", func(t *testing.T) { env := newVariantEnv(t) env.seedLibrary() env.articles.articles[1] = article.Article{ID: 1, Title: "Floors", Body: "![x](/static/uploadedImages/f3f3.jpg?v=hero)", IsOnline: true} recorder := env.send(http.MethodPost, "/v1/media/merge", `{"keep_id": 2, "merge_ids": [3]}`, variantOwnerId) if recorder.Code != 200 || !strings.Contains(env.articles.articles[1].Body, "/static/uploadedImages/f2f2.jpg?v=hero") { t.Errorf("status = %d, body = %q", recorder.Code, env.articles.articles[1].Body) } }) }