package test import ( "RenewCMS/api/pkg/apperr" domainImage "RenewCMS/internal/domain/image" "RenewCMS/internal/infrastructure/useCases" "encoding/json" "errors" "net/http" "os/exec" "reflect" "regexp" "sort" "strings" "testing" ) var ( hashA = sha256Hex([]byte("A")) hashB = sha256Hex([]byte("B")) hashC = sha256Hex([]byte("C")) hashD = sha256Hex([]byte("D")) ) func seedListingLibrary(env *mediaEnv) { env.seedImage(1, "north-wall.jpg", 4089446, "image/jpeg", 3600, 2400, hashA, 1) env.seedImage(2, "floorboards.jpg", 2516582, "image/jpeg", 3600, 2400, hashB, 1) env.seedImage(3, "floorboards-copy.jpg", 2516582, "image/jpeg", 3600, 2400, hashB, 2) env.seedImage(4, "window-frame.jpg", 812345, "image/jpeg", 1200, 800, hashC, 2) env.seedImage(5, "logo.svg", 1204, "image/svg+xml", 0, 0, hashD, 1) env.seedArticle(1, "Kitchen Renovation", true, ``) env.seedArticle(2, "Backyard Notes", false, ``) env.seedArticle(3, "Empty", true, "no image here") } type listBody struct { Items []map[string]any `json:"items"` Total int `json:"total"` Page int `json:"page"` Size int `json:"size"` Counts map[string]int `json:"counts"` } func (e *mediaEnv) list(t *testing.T, query string) (listBody, string) { t.Helper() rec := e.send(t, http.MethodGet, "/v1/media/"+query, "", mediaVerifiedUserId) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d, body %q", rec.Code, rec.Body.String()) } var decoded listBody if err := json.Unmarshal(rec.Body.Bytes(), &decoded); err != nil { t.Fatalf("failed to decode list: %v", err) } return decoded, rec.Body.String() } func idsOf(items []map[string]any) []uint32 { ids := make([]uint32, 0, len(items)) for _, item := range items { ids = append(ids, numberOf(item["id"])) } return ids } func sortedIdsOf(items []map[string]any) []uint32 { ids := idsOf(items) sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) return ids } func itemById(t *testing.T, items []map[string]any, id uint32) map[string]any { t.Helper() for _, item := range items { if numberOf(item["id"]) == id { return item } } t.Fatalf("expected item %d in %v", id, items) return nil } func expectIds(t *testing.T, got []uint32, want []uint32) { t.Helper() if !reflect.DeepEqual(got, want) { t.Errorf("expected ids %v, got %v", want, got) } } func TestMediaAuthentication(t *testing.T) { examples := []struct { method string path string userId uint32 description string status int message string }{ {http.MethodGet, "/v1/media/", 0, "no cookie", 401, ""}, {http.MethodPost, "/v1/media/", 0, "no cookie", 401, ""}, {http.MethodPatch, "/v1/media/1", 0, "no cookie", 401, ""}, {http.MethodDelete, "/v1/media/1", 0, "no cookie", 401, ""}, {http.MethodPost, "/v1/media/merge", 0, "no cookie", 401, ""}, {http.MethodGet, "/v1/media/1/usage", 0, "no cookie", 401, ""}, {http.MethodGet, "/v1/media/", mediaStaleUserId, "a stale token_version cookie", 401, apperr.UnauthorizedErrorMessage}, {http.MethodGet, "/v1/media/", mediaUnverifiedUserId, "cookie of an unverified user", 403, apperr.VerificationRequiredErrorMessage}, {http.MethodDelete, "/v1/media/1", mediaUnverifiedUserId, "cookie of an unverified user", 403, apperr.VerificationRequiredErrorMessage}, } for _, example := range examples { t.Run(example.method+" "+example.path+" "+example.description, func(t *testing.T) { env := newMediaEnv(t) seedListingLibrary(env) bodiesBefore := env.bodies() imagesBefore := env.imageIds() rec := env.send(t, example.method, example.path, "", example.userId) if rec.Code != example.status { t.Fatalf("expected status %d, got %d", example.status, rec.Code) } if example.message != "" && rec.Body.String() != example.message+"\n" { t.Errorf("unexpected body %q", rec.Body.String()) } if !reflect.DeepEqual(env.bodies(), bodiesBefore) || !reflect.DeepEqual(env.imageIds(), imagesBefore) { t.Errorf("expected no image or article to change") } if len(env.images.deleteCalls) != 0 || env.images.createCalls != 0 { t.Errorf("expected no image write") } }) } } func TestMediaListing(t *testing.T) { t.Run("The default list is newest first with used_in, duplicate and tab counts", func(t *testing.T) { env := newMediaEnv(t) seedListingLibrary(env) got, raw := env.list(t, "") expectIds(t, idsOf(got.Items), []uint32{5, 4, 3, 2, 1}) first := itemById(t, got.Items, 1) if numberOf(first["used_in"]) != 2 || first["duplicate"] != false || first["uploaded_by"] != "Inès Duarte" { t.Errorf("unexpected item 1: %v", first) } if itemById(t, got.Items, 2)["duplicate"] != true || itemById(t, got.Items, 3)["duplicate"] != true { t.Errorf("expected items 2 and 3 to be duplicates") } if itemById(t, got.Items, 3)["uploaded_by"] != "marta" { t.Errorf("expected item 3 uploaded_by marta, got %v", itemById(t, got.Items, 3)["uploaded_by"]) } logo := itemById(t, got.Items, 5) if logo["width"] != nil || logo["height"] != nil || numberOf(logo["used_in"]) != 0 { t.Errorf("unexpected item 5: %v", logo) } if !strings.Contains(raw, `"width":null`) || !strings.Contains(raw, `"height":null`) { t.Errorf("expected null width and height in the raw body, got %s", raw) } if strings.Contains(raw, "next_cursor") { t.Errorf("expected no next_cursor in the raw body") } if got.Total != 5 || got.Page != 1 || got.Size != 25 { t.Errorf("unexpected paging %d %d %d", got.Total, got.Page, got.Size) } if !reflect.DeepEqual(got.Counts, map[string]int{"all": 5, "unused": 3, "duplicates": 1}) { t.Errorf("unexpected counts %v", got.Counts) } if first["url"] != env.urlOf(1) || first["sha256"] != hashA || first["content_type"] != "image/jpeg" || numberOf(first["size"]) != 4089446 { t.Errorf("unexpected metadata on item 1: %v", first) } }) t.Run("An empty library answers an empty list, not null", func(t *testing.T) { env := newMediaEnv(t) got, raw := env.list(t, "") if !strings.Contains(raw, `"items":[]`) { t.Errorf("expected an empty items array, got %s", raw) } if !reflect.DeepEqual(got.Counts, map[string]int{"all": 0, "unused": 0, "duplicates": 0}) { t.Errorf("unexpected counts %v", got.Counts) } }) t.Run("sort orders the list", func(t *testing.T) { examples := map[string][]uint32{ "newest": {5, 4, 3, 2, 1}, "oldest": {1, 2, 3, 4, 5}, "name": {2, 3, 5, 1, 4}, "largest": {1, 2, 3, 4, 5}, } for sortName, want := range examples { t.Run(sortName, func(t *testing.T) { env := newMediaEnv(t) seedListingLibrary(env) got, _ := env.list(t, "?sort="+sortName) expectIds(t, idsOf(got.Items), want) }) } }) t.Run("filter narrows the list and the counts stay library-wide", func(t *testing.T) { examples := map[string][]uint32{ "all": {1, 2, 3, 4, 5}, "unused": {3, 4, 5}, "duplicates": {2, 3}, } for filter, want := range examples { t.Run(filter, func(t *testing.T) { env := newMediaEnv(t) seedListingLibrary(env) got, _ := env.list(t, "?filter="+filter) expectIds(t, sortedIdsOf(got.Items), want) if !reflect.DeepEqual(got.Counts, map[string]int{"all": 5, "unused": 3, "duplicates": 1}) { t.Errorf("unexpected counts %v", got.Counts) } }) } }) t.Run("filter keeps the newest first order", func(t *testing.T) { examples := map[string][]uint32{ "all": {5, 4, 3, 2, 1}, "unused": {5, 4, 3}, "duplicates": {3, 2}, } for filter, want := range examples { t.Run(filter, func(t *testing.T) { env := newMediaEnv(t) seedListingLibrary(env) got, _ := env.list(t, "?filter="+filter) expectIds(t, idsOf(got.Items), want) }) } }) t.Run("q matches the filename case-insensitively", func(t *testing.T) { examples := []struct { q string want []uint32 }{ {"FLOORBOARD", []uint32{3, 2}}, {"wall", []uint32{1}}, {"zzz", []uint32{}}, } for _, example := range examples { t.Run(example.q, func(t *testing.T) { env := newMediaEnv(t) seedListingLibrary(env) got, _ := env.list(t, "?q="+example.q) expectIds(t, idsOf(got.Items), example.want) if !reflect.DeepEqual(got.Counts, map[string]int{"all": 5, "unused": 3, "duplicates": 1}) { t.Errorf("unexpected counts %v", got.Counts) } }) } }) t.Run("q, filter and sort combine", func(t *testing.T) { env := newMediaEnv(t) seedListingLibrary(env) got, _ := env.list(t, "?q=floorboards&filter=unused&sort=oldest") expectIds(t, idsOf(got.Items), []uint32{3}) }) t.Run("hash returns only the images with that content", func(t *testing.T) { env := newMediaEnv(t) seedListingLibrary(env) got, _ := env.list(t, "?hash="+hashB) expectIds(t, sortedIdsOf(got.Items), []uint32{2, 3}) }) t.Run("hash of unknown content returns no item", func(t *testing.T) { env := newMediaEnv(t) seedListingLibrary(env) got, _ := env.list(t, "?hash="+strings.Repeat("0", 64)) if len(got.Items) != 0 { t.Errorf("expected no item, got %v", got.Items) } }) t.Run("page and size page through the list", func(t *testing.T) { env := newMediaEnv(t) for id := uint32(1); id <= 12; id++ { env.seedImage(id, "image.jpg", 10, "image/jpeg", 3, 2, hashA, 1) } first, _ := env.list(t, "?size=5&page=1") second, _ := env.list(t, "?size=5&page=2") third, _ := env.list(t, "?size=5&page=3") expectIds(t, idsOf(first.Items), []uint32{12, 11, 10, 9, 8}) expectIds(t, idsOf(second.Items), []uint32{7, 6, 5, 4, 3}) expectIds(t, idsOf(third.Items), []uint32{2, 1}) if third.Total != 12 || third.Page != 3 || third.Size != 5 { t.Errorf("unexpected paging %d %d %d", third.Total, third.Page, third.Size) } }) t.Run("A malformed query answers 400", func(t *testing.T) { for _, query := range []string{"filter=bogus", "sort=bogus", "page=0", "page=abc", "size=10", "size=200", "size=", "hash=notahash"} { t.Run(query, func(t *testing.T) { env := newMediaEnv(t) seedListingLibrary(env) rec := env.send(t, http.MethodGet, "/v1/media/?"+query, "", mediaVerifiedUserId) if rec.Code != http.StatusBadRequest { t.Fatalf("expected status 400, got %d", rec.Code) } if rec.Body.String() != apperr.MediaQueryErrorMessage+"\n" { t.Errorf("unexpected body %q", rec.Body.String()) } }) } }) t.Run("A row without metadata still lists", func(t *testing.T) { env := newMediaEnv(t) env.images.images[1] = domainImage.Image{ID: 1, Path: "/static/uploadedImages/legacy.png"} got, raw := env.list(t, "") item := itemById(t, got.Items, 1) if numberOf(item["size"]) != 0 || item["width"] != nil || item["height"] != nil || item["sha256"] != "" || item["duplicate"] != false || item["uploaded_by"] != "" { t.Errorf("unexpected item %v in %s", item, raw) } }) t.Run("A repository failure is a generic 500", func(t *testing.T) { env := newMediaEnv(t) env.images.fail.failNext("List") rec := env.send(t, http.MethodGet, "/v1/media/", "", mediaVerifiedUserId) if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" { t.Errorf("expected generic 500, got %d %q", rec.Code, rec.Body.String()) } }) } func TestMediaUploading(t *testing.T) { allowed := []struct { name string contentType string content func(t *testing.T) []byte width any height any }{ {"north-wall.png", "image/png", func(t *testing.T) []byte { return pngBytes(t, 3, 2) }, 3, 2}, {"IMG_2208.jpg", "image/jpeg", func(t *testing.T) []byte { return jpegBytes(t, 3, 2) }, 3, 2}, {"hero.webp", "image/webp", webpBytes, 1, 1}, {"logo.svg", "image/svg+xml", func(t *testing.T) []byte { return []byte(svgFixture) }, nil, nil}, } t.Run("An allowed image is always stored, with its metadata and hash", func(t *testing.T) { urlPattern := regexp.MustCompile(`^/static/uploadedImages/[^/]+\.(png|jpeg|jpg|webp|svg)$`) for _, example := range allowed { t.Run(example.name, func(t *testing.T) { env := newMediaEnv(t) content := example.content(t) req := newMultipartFileRequest(t, http.MethodPost, "/v1/media/", example.name, example.contentType, content) rec := env.serve(t, req, mediaVerifiedUserId) if rec.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d, body %q", rec.Code, rec.Body.String()) } item := decodeObject(t, rec.Body.String()) if item["filename"] != example.name || item["content_type"] != example.contentType { t.Errorf("unexpected filename or type: %v", item) } if int(numberOf(item["size"])) != len(content) { t.Errorf("expected size %d, got %v", len(content), item["size"]) } expectedWidth, expectedHeight := example.width, example.height if expectedWidth != nil { expectedWidth, expectedHeight = float64(expectedWidth.(int)), float64(expectedHeight.(int)) } if item["width"] != expectedWidth || item["height"] != expectedHeight { t.Errorf("expected %vx%v, got %vx%v", expectedWidth, expectedHeight, item["width"], item["height"]) } if item["sha256"] != sha256Hex(content) { t.Errorf("unexpected sha256 %v", item["sha256"]) } if item["uploaded_by"] != "Inès Duarte" || numberOf(item["used_in"]) != 0 || item["duplicate"] != false { t.Errorf("unexpected item %v", item) } if !urlPattern.MatchString(item["url"].(string)) { t.Errorf("unexpected url %v", item["url"]) } 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.storage.lastContent, content) { t.Errorf("expected the repository to receive the uploaded bytes") } }) } }) t.Run("Uploading the same bytes twice stores both and marks both as duplicates", func(t *testing.T) { env := newMediaEnv(t) content := pngBytes(t, 3, 2) env.seedImage(1, "north-wall.png", int64(len(content)), "image/png", 3, 2, sha256Hex(content), 1) req := newMultipartFileRequest(t, http.MethodPost, "/v1/media/", "north-wall-again.png", "image/png", content) rec := env.serve(t, req, mediaVerifiedUserId) if rec.Code != http.StatusCreated || decodeObject(t, rec.Body.String())["duplicate"] != true { t.Fatalf("expected 201 with duplicate true, got %d %q", rec.Code, rec.Body.String()) } if len(env.images.images) != 2 || env.images.images[1].Path == env.images.images[2].Path { t.Errorf("expected two images with different urls, got %+v", env.images.images) } got, _ := env.list(t, "") if got.Counts["duplicates"] != 1 { t.Errorf("expected duplicates 1, got %v", got.Counts) } }) t.Run("A filename without extension gets the canonical one, a path is stripped", func(t *testing.T) { env := newMediaEnv(t) req := newMultipartFileRequest(t, http.MethodPost, "/v1/media/", `C:\\photos\\north wall`, "image/png", pngBytes(t, 3, 2)) rec := env.serve(t, req, mediaVerifiedUserId) if rec.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d, body %q", rec.Code, rec.Body.String()) } if got := decodeObject(t, rec.Body.String())["filename"]; got != "north wall.png" { t.Errorf("expected filename %q, got %v", "north wall.png", got) } }) t.Run("A file exactly at 10485760 bytes is accepted, one byte more is 413", func(t *testing.T) { env := newMediaEnv(t) req := newMultipartFileRequest(t, http.MethodPost, "/v1/media/", "max.png", "image/png", paddedPngBytes(t, int(domainImage.MaxImageSize))) if rec := env.serve(t, req, mediaVerifiedUserId); rec.Code != http.StatusCreated { t.Fatalf("expected status 201 at the limit, got %d, body %q", rec.Code, rec.Body.String()) } req = newMultipartFileRequest(t, http.MethodPost, "/v1/media/", "over.png", "image/png", paddedPngBytes(t, int(domainImage.MaxImageSize)+1)) rec := env.serve(t, req, mediaVerifiedUserId) if rec.Code != http.StatusRequestEntityTooLarge { t.Fatalf("expected status 413, got %d", rec.Code) } if rec.Body.String() != "the file exceeds the maximum upload size\n" { t.Errorf("unexpected body %q", rec.Body.String()) } if env.images.createCalls != 1 { t.Errorf("expected Create to be called once in total, got %d", env.images.createCalls) } }) t.Run("A disallowed content type is 415", func(t *testing.T) { env := newMediaEnv(t) req := newMultipartFileRequest(t, http.MethodPost, "/v1/media/", "notes.pdf", "application/pdf", []byte("%PDF-1.4")) rec := env.serve(t, req, mediaVerifiedUserId) if rec.Code != http.StatusUnsupportedMediaType { t.Fatalf("expected status 415, got %d", rec.Code) } if rec.Body.String() != "the file must be a PNG, JPEG, WEBP, or SVG image\n" { t.Errorf("unexpected body %q", rec.Body.String()) } if env.images.createCalls != 0 { t.Errorf("expected Create to never be called") } }) t.Run("Bytes that cannot be read as the declared type are 400", func(t *testing.T) { examples := []struct{ name, contentType string }{ {"broken.png", "image/png"}, {"broken.jpg", "image/jpeg"}, {"broken.webp", "image/webp"}, {"broken.svg", "image/svg+xml"}, } for _, example := range examples { t.Run(example.name, func(t *testing.T) { env := newMediaEnv(t) req := newMultipartFileRequest(t, http.MethodPost, "/v1/media/", example.name, example.contentType, []byte("not an image")) rec := env.serve(t, req, mediaVerifiedUserId) if rec.Code != http.StatusBadRequest { t.Fatalf("expected status 400, got %d", rec.Code) } if rec.Body.String() != "the image could not be read\n" { t.Errorf("unexpected body %q", rec.Body.String()) } if env.images.createCalls != 0 { t.Errorf("expected Create to never be called") } }) } }) t.Run("A request without the \"file\" part is 400", func(t *testing.T) { env := newMediaEnv(t) rec := env.serve(t, newMultipartWithoutFileRequest(t, "/v1/media/"), mediaVerifiedUserId) if rec.Code != http.StatusBadRequest { t.Fatalf("expected status 400, got %d", rec.Code) } if env.images.createCalls != 0 { t.Errorf("expected Create to never be called") } }) t.Run("A repository failure is a generic 500", func(t *testing.T) { env := newMediaEnv(t) env.images.fail.failNext("Create") req := newMultipartFileRequest(t, http.MethodPost, "/v1/media/", "a.png", "image/png", pngBytes(t, 3, 2)) rec := env.serve(t, req, mediaVerifiedUserId) if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" { t.Errorf("expected generic 500, got %d %q", rec.Code, rec.Body.String()) } }) } func TestMediaRenaming(t *testing.T) { newEnv := func(t *testing.T) *mediaEnv { env := newMediaEnv(t) env.images.images[1] = domainImage.Image{ID: 1, Path: "/static/uploadedImages/aaa.jpeg", Filename: "north-wall.jpg", ContentType: "image/jpeg", UploadedByID: 1} env.seedArticle(1, "Kitchen Renovation", true, ``) return env } t.Run("Rename changes the display name, keeps the extension and the url", func(t *testing.T) { env := newEnv(t) bodiesBefore := env.bodies() rec := env.send(t, http.MethodPatch, "/v1/media/1", `{"filename": "north-wall-01"}`, mediaVerifiedUserId) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d, body %q", rec.Code, rec.Body.String()) } item := decodeObject(t, rec.Body.String()) if item["filename"] != "north-wall-01.jpg" || item["url"] != "/static/uploadedImages/aaa.jpeg" { t.Errorf("unexpected item %v", item) } if !reflect.DeepEqual(env.bodies(), bodiesBefore) { t.Errorf("expected article bodies to be unchanged") } got, _ := env.list(t, "?q=north-wall-01") expectIds(t, idsOf(got.Items), []uint32{1}) }) t.Run("Surrounding whitespace is trimmed and unicode letters are allowed", func(t *testing.T) { env := newEnv(t) rec := env.send(t, http.MethodPatch, "/v1/media/1", `{"filename": " Été_2026-a "}`, mediaVerifiedUserId) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", rec.Code) } if got := decodeObject(t, rec.Body.String())["filename"]; got != "Été_2026-a.jpg" { t.Errorf("unexpected filename %v", got) } }) t.Run("An invalid new name is 400 and nothing changes", func(t *testing.T) { examples := []struct { name string body string }{ {"empty", `{"filename": ""}`}, {"blank", `{"filename": " "}`}, {"symbols", `{"filename": "***"}`}, {"dot", `{"filename": "a.b"}`}, {"space", `{"filename": "a b"}`}, {"101 letters", `{"filename": "` + strings.Repeat("a", 101) + `"}`}, {"no field", `{}`}, {"not json", `not json`}, } for _, example := range examples { t.Run(example.name, func(t *testing.T) { env := newEnv(t) rec := env.send(t, http.MethodPatch, "/v1/media/1", example.body, mediaVerifiedUserId) if rec.Code != http.StatusBadRequest { t.Fatalf("expected status 400, got %d", rec.Code) } if example.name == "not json" { if rec.Body.String() != apperr.BodyErrorMessage+"\n" { t.Errorf("unexpected body %q", rec.Body.String()) } } else if rec.Body.String() != apperr.MediaFilenameErrorMessage+"\n" { t.Errorf("unexpected body %q", rec.Body.String()) } if env.images.images[1].Filename != "north-wall.jpg" { t.Errorf("expected the filename to be unchanged, got %q", env.images.images[1].Filename) } }) } }) t.Run("A name of exactly 100 letters is accepted", func(t *testing.T) { env := newEnv(t) rec := env.send(t, http.MethodPatch, "/v1/media/1", `{"filename": "`+strings.Repeat("é", 100)+`"}`, mediaVerifiedUserId) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", rec.Code) } }) t.Run("Renaming an unknown image is 404", func(t *testing.T) { env := newEnv(t) rec := env.send(t, http.MethodPatch, "/v1/media/9999", `{"filename": "x"}`, mediaVerifiedUserId) if rec.Code != http.StatusNotFound || rec.Body.String() != "Image not found\n" { t.Errorf("expected 404 Image not found, got %d %q", rec.Code, rec.Body.String()) } }) t.Run("A malformed id is 400", func(t *testing.T) { env := newEnv(t) rec := env.send(t, http.MethodPatch, "/v1/media/abc", `{"filename": "x"}`, mediaVerifiedUserId) if rec.Code != http.StatusBadRequest || rec.Body.String() != apperr.IdUint32ErrorMessage+"\n" { t.Errorf("expected 400 with the id message, got %d %q", rec.Code, rec.Body.String()) } }) t.Run("A repository failure is a generic 500", func(t *testing.T) { env := newEnv(t) env.images.fail.failNext("Rename") rec := env.send(t, http.MethodPatch, "/v1/media/1", `{"filename": "x"}`, mediaVerifiedUserId) if rec.Code != http.StatusInternalServerError { t.Errorf("expected status 500, got %d", rec.Code) } }) } func TestMediaDeleting(t *testing.T) { newEnv := func(t *testing.T) *mediaEnv { env := newMediaEnv(t) seedListingLibrary(env) return env } t.Run("An image in use can be deleted and article bodies are left alone", func(t *testing.T) { env := newEnv(t) bodiesBefore := env.bodies() rec := env.send(t, http.MethodDelete, "/v1/media/1", "", mediaVerifiedUserId) if rec.Code != http.StatusNoContent || rec.Body.Len() != 0 { t.Fatalf("expected an empty 204, got %d %q", rec.Code, rec.Body.String()) } if _, ok := env.images.images[1]; ok { t.Errorf("expected image 1 to be gone") } if !reflect.DeepEqual(env.images.deleteCalls, []uint32{1}) { t.Errorf("expected deleteCalls [1], got %v", env.images.deleteCalls) } if !reflect.DeepEqual(env.bodies(), bodiesBefore) { t.Errorf("expected article bodies to be byte-for-byte unchanged") } }) t.Run("Deleting an unknown image is 404", func(t *testing.T) { env := newEnv(t) rec := env.send(t, http.MethodDelete, "/v1/media/9999", "", mediaVerifiedUserId) if rec.Code != http.StatusNotFound || rec.Body.String() != "Image not found\n" { t.Errorf("expected 404 Image not found, got %d %q", rec.Code, rec.Body.String()) } }) t.Run("A malformed id is 400", func(t *testing.T) { env := newEnv(t) rec := env.send(t, http.MethodDelete, "/v1/media/abc", "", mediaVerifiedUserId) if rec.Code != http.StatusBadRequest || rec.Body.String() != apperr.IdUint32ErrorMessage+"\n" { t.Errorf("expected 400 with the id message, got %d %q", rec.Code, rec.Body.String()) } }) t.Run("A repository failure is a generic 500 and the image stays", func(t *testing.T) { env := newEnv(t) env.images.fail.failNext("Delete") rec := env.send(t, http.MethodDelete, "/v1/media/1", "", mediaVerifiedUserId) if rec.Code != http.StatusInternalServerError { t.Errorf("expected status 500, got %d", rec.Code) } if _, ok := env.images.images[1]; !ok { t.Errorf("expected image 1 to still exist") } }) } func TestMediaUsage(t *testing.T) { newEnv := func(t *testing.T) *mediaEnv { env := newMediaEnv(t) seedListingLibrary(env) return env } t.Run("Usage lists the articles whose body contains the url", func(t *testing.T) { env := newEnv(t) rec := env.send(t, http.MethodGet, "/v1/media/1/usage", "", mediaVerifiedUserId) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", rec.Code) } want := `[{"id": 1, "title": "Kitchen Renovation", "published": true}, {"id": 2, "title": "Backyard Notes", "published": false}]` expectJSONEqual(t, rec.Body.String(), want) }) t.Run("An unused image has an empty array, not null", func(t *testing.T) { env := newEnv(t) rec := env.send(t, http.MethodGet, "/v1/media/4/usage", "", mediaVerifiedUserId) if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != "[]" { t.Errorf("expected [], got %d %q", rec.Code, rec.Body.String()) } }) t.Run("An absolute url still counts as used", func(t *testing.T) { env := newEnv(t) env.seedArticle(3, "Empty", true, "https://cms.example.com"+env.urlOf(1)) rec := env.send(t, http.MethodGet, "/v1/media/1/usage", "", mediaVerifiedUserId) var usage []map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &usage); err != nil { t.Fatalf("failed to decode usage: %v", err) } found := false for _, entry := range usage { if numberOf(entry["id"]) == 3 { found = true } } if !found { t.Errorf("expected article 3 in %v", usage) } }) t.Run("Unknown or malformed ids", func(t *testing.T) { examples := []struct { id string status int message string }{ {"9999", http.StatusNotFound, "Image not found"}, {"abc", http.StatusBadRequest, apperr.IdUint32ErrorMessage}, } for _, example := range examples { t.Run(example.id, func(t *testing.T) { env := newEnv(t) rec := env.send(t, http.MethodGet, "/v1/media/"+example.id+"/usage", "", mediaVerifiedUserId) if rec.Code != example.status || rec.Body.String() != example.message+"\n" { t.Errorf("expected %d %q, got %d %q", example.status, example.message, rec.Code, rec.Body.String()) } }) } }) t.Run("A repository failure is a generic 500", func(t *testing.T) { env := newEnv(t) env.articles.fail.failNext("FindByBodyContaining") rec := env.send(t, http.MethodGet, "/v1/media/1/usage", "", mediaVerifiedUserId) if rec.Code != http.StatusInternalServerError { t.Errorf("expected status 500, got %d", rec.Code) } }) } func expectJSONEqual(t *testing.T, got string, want string) { t.Helper() var gotValue, wantValue any if err := json.Unmarshal([]byte(got), &gotValue); err != nil { t.Fatalf("failed to decode %q: %v", got, err) } if err := json.Unmarshal([]byte(want), &wantValue); err != nil { t.Fatalf("failed to decode %q: %v", want, err) } if !reflect.DeepEqual(gotValue, wantValue) { t.Errorf("expected %s, got %s", want, got) } } func TestMediaMerging(t *testing.T) { newEnv := func(t *testing.T) *mediaEnv { env := newMediaEnv(t) env.seedImage(1, "north-wall.jpg", 10, "image/jpeg", 3, 2, hashA, 1) env.seedImage(2, "floorboards.jpg", 20, "image/jpeg", 3, 2, hashB, 1) env.seedImage(3, "floorboards-copy.jpg", 20, "image/jpeg", 3, 2, hashB, 2) env.seedImage(6, "floorboards-2.jpg", 20, "image/jpeg", 3, 2, hashB, 2) env.seedArticle(1, "One", true, "a "+env.urlOf(2)+" b "+env.urlOf(3)) env.seedArticle(2, "Two", false, env.urlOf(3)+" and "+env.urlOf(3)) env.seedArticle(3, "Three", true, env.urlOf(2)) env.seedArticle(4, "Four", true, "nothing") return env } t.Run("Merge switches bodies to the kept url and deletes the copies", func(t *testing.T) { env := newEnv(t) keep, copy3 := env.urlOf(2), env.urlOf(3) rec := env.send(t, http.MethodPost, "/v1/media/merge", `{"keep_id": 2, "merge_ids": [3, 6]}`, mediaVerifiedUserId) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d, body %q", rec.Code, rec.Body.String()) } expectJSONEqual(t, rec.Body.String(), `{"articles_updated": 2, "sites_updated": 0, "authors_updated": 0, "merged": 2}`) if got, want := env.articles.articles[1].Body, "a "+keep+" b "+keep; got != want { t.Errorf("article 1 body: want %q, got %q", want, got) } if got, want := env.articles.articles[2].Body, keep+" and "+keep; got != want { t.Errorf("article 2 body: want %q, got %q", want, got) } if env.articles.articles[3].Body != keep || env.articles.articles[4].Body != "nothing" { t.Errorf("expected articles 3 and 4 to be unchanged") } if strings.Contains(env.articles.articles[1].Body, copy3) { t.Errorf("expected image 3's url to be gone") } expectIds(t, env.imageIds(), []uint32{1, 2}) }) t.Run("Merging copies that no article uses updates zero articles", func(t *testing.T) { env := newEnv(t) rec := env.send(t, http.MethodPost, "/v1/media/merge", `{"keep_id": 2, "merge_ids": [6]}`, mediaVerifiedUserId) if rec.Code != http.StatusOK { t.Fatalf("expected status 200, got %d", rec.Code) } expectJSONEqual(t, rec.Body.String(), `{"articles_updated": 0, "sites_updated": 0, "authors_updated": 0, "merged": 1}`) }) t.Run("A copy that is gone answers 409 and changes nothing", func(t *testing.T) { env := newEnv(t) bodiesBefore := env.bodies() rec := env.send(t, http.MethodPost, "/v1/media/merge", `{"keep_id": 2, "merge_ids": [3, 9999]}`, mediaVerifiedUserId) if rec.Code != http.StatusConflict || rec.Body.String() != apperr.MediaMergeConflictErrorMessage+"\n" { t.Fatalf("expected 409 with the conflict message, got %d %q", rec.Code, rec.Body.String()) } if !reflect.DeepEqual(env.bodies(), bodiesBefore) || len(env.images.deleteCalls) != 0 { t.Errorf("expected nothing to change") } expectIds(t, env.imageIds(), []uint32{1, 2, 3, 6}) }) t.Run("A copy that does not share the hash answers 409 and changes nothing", func(t *testing.T) { env := newEnv(t) bodiesBefore := env.bodies() rec := env.send(t, http.MethodPost, "/v1/media/merge", `{"keep_id": 2, "merge_ids": [3, 1]}`, mediaVerifiedUserId) if rec.Code != http.StatusConflict { t.Fatalf("expected status 409, got %d", rec.Code) } if !reflect.DeepEqual(env.bodies(), bodiesBefore) || len(env.images.deleteCalls) != 0 { t.Errorf("expected nothing to change") } expectIds(t, env.imageIds(), []uint32{1, 2, 3, 6}) }) t.Run("A kept image that is gone answers 404", func(t *testing.T) { env := newEnv(t) bodiesBefore := env.bodies() rec := env.send(t, http.MethodPost, "/v1/media/merge", `{"keep_id": 9999, "merge_ids": [3]}`, mediaVerifiedUserId) if rec.Code != http.StatusNotFound || rec.Body.String() != "Image not found\n" { t.Fatalf("expected 404 Image not found, got %d %q", rec.Code, rec.Body.String()) } if !reflect.DeepEqual(env.bodies(), bodiesBefore) || len(env.images.deleteCalls) != 0 { t.Errorf("expected nothing to change") } }) t.Run("An invalid merge request is 400 and nothing changes", func(t *testing.T) { bodies := []string{ `{"keep_id": 2, "merge_ids": []}`, `{"keep_id": 2}`, `{"merge_ids": [3]}`, `{"keep_id": 2, "merge_ids": [2, 3]}`, `{"keep_id": 2, "merge_ids": [3, 3]}`, `{"keep_id": 0, "merge_ids": [3]}`, `{"keep_id": 2, "merge_ids": [0]}`, `not json`, } for _, body := range bodies { t.Run(body, func(t *testing.T) { env := newEnv(t) bodiesBefore := env.bodies() rec := env.send(t, http.MethodPost, "/v1/media/merge", body, mediaVerifiedUserId) if rec.Code != http.StatusBadRequest || rec.Body.String() != apperr.MediaMergeRequestErrorMessage+"\n" { t.Fatalf("expected 400 with the merge message, got %d %q", rec.Code, rec.Body.String()) } if !reflect.DeepEqual(env.bodies(), bodiesBefore) || len(env.images.deleteCalls) != 0 { t.Errorf("expected nothing to change") } }) } }) 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, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()) _, err := uc.Merge(testCtx, 2, []uint32{3, 3}) if !errors.Is(err, domainImage.ErrInvalidMergeRequest) { t.Errorf("expected ErrInvalidMergeRequest, got %v", err) } }) t.Run("A failure rewriting a body restores the bodies already rewritten", func(t *testing.T) { env := newEnv(t) bodiesBefore := env.bodies() env.articles.failUpdateBodyFor = map[uint32]error{2: errRepositoryFailure} rec := env.send(t, http.MethodPost, "/v1/media/merge", `{"keep_id": 2, "merge_ids": [3]}`, mediaVerifiedUserId) if rec.Code != http.StatusInternalServerError { t.Fatalf("expected status 500, got %d", rec.Code) } if !reflect.DeepEqual(env.bodies(), bodiesBefore) { t.Errorf("expected every body back to its original text, got %v", env.bodies()) } if len(env.images.deleteCalls) != 0 { t.Errorf("expected no Delete call, got %v", env.images.deleteCalls) } expectIds(t, env.imageIds(), []uint32{1, 2, 3, 6}) }) t.Run("A failure deleting a copy after the rewrite is 500 and the bodies stay switched", func(t *testing.T) { env := newEnv(t) env.images.failDeleteFor = map[uint32]error{3: errRepositoryFailure} keep := env.urlOf(2) rec := env.send(t, http.MethodPost, "/v1/media/merge", `{"keep_id": 2, "merge_ids": [3]}`, mediaVerifiedUserId) if rec.Code != http.StatusInternalServerError { t.Fatalf("expected status 500, got %d", rec.Code) } if !strings.Contains(env.articles.articles[1].Body, keep) || strings.Contains(env.articles.articles[1].Body, env.urlOf(3)) { t.Errorf("expected article 1 to use image 2's url, got %q", env.articles.articles[1].Body) } if _, ok := env.images.images[3]; !ok { t.Errorf("expected image 3 to still exist") } }) } func TestMediaSentinels(t *testing.T) { t.Run("Create reports unreadable bytes as ErrImageUnreadable", func(t *testing.T) { uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder) for _, contentType := range domainImage.AllowedContentTypes() { _, err := uc.CreateImage(testCtx, inputOf([]byte("not an image"), contentType)) if !errors.Is(err, domainImage.ErrImageUnreadable) { t.Errorf("%s: expected ErrImageUnreadable, got %v", contentType, err) } } }) t.Run("An svg without an svg root is unreadable", func(t *testing.T) { uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder) _, err := uc.CreateImage(testCtx, inputOf([]byte(""), "image/svg+xml")) if !errors.Is(err, domainImage.ErrImageUnreadable) { t.Errorf("expected ErrImageUnreadable, got %v", err) } }) t.Run("Bytes of another raster format than the declared type are unreadable", func(t *testing.T) { uc := useCases.NewCreateImageUseCase(newMockImageRepo(), newMockImageStorage(), discardRecorder) _, err := uc.CreateImage(testCtx, inputOf(jpegBytes(t, 3, 2), "image/png")) if !errors.Is(err, domainImage.ErrImageUnreadable) { t.Errorf("expected ErrImageUnreadable, got %v", err) } }) t.Run("Rename reports an invalid stem as ErrInvalidFilename", func(t *testing.T) { repo := newMockImageRepo() repo.images[1] = domainImage.Image{ID: 1, Filename: "a.png"} uc := useCases.NewRenameMediaUseCase(repo, discardRecorder) if _, err := uc.Rename(testCtx, 1, "a b"); !errors.Is(err, domainImage.ErrInvalidFilename) { t.Errorf("expected ErrInvalidFilename, got %v", err) } }) t.Run("Merge reports a missing copy and a hash mismatch", func(t *testing.T) { env := newMediaEnv(t) env.seedImage(1, "a.png", 1, "image/png", 1, 1, hashA, 1) 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, env.storage, newMockVariantFileRepo(), discardRecorder, plainSites(), plainAuthors()) if _, err := uc.Merge(testCtx, 1, []uint32{9999}); !errors.Is(err, domainImage.ErrMergeCopyMissing) { t.Errorf("expected ErrMergeCopyMissing, got %v", err) } if _, err := uc.Merge(testCtx, 1, []uint32{2}); !errors.Is(err, domainImage.ErrMergeHashMismatch) { t.Errorf("expected ErrMergeHashMismatch, got %v", err) } if _, err := uc.Merge(testCtx, 3, []uint32{4}); !errors.Is(err, domainImage.ErrMergeHashMismatch) { t.Errorf("expected images without a hash never to merge, got %v", err) } }) } func TestMediaHandlerLayering(t *testing.T) { t.Run("api/handlers/media imports no persistence, mailer, models or gorm package", func(t *testing.T) { output, err := exec.Command("go", "list", "-tags", "nofrontend", "-deps", "RenewCMS/api/handlers/media").CombinedOutput() if err != nil { t.Fatalf("go list failed: %v: %s", err, output) } for _, dependency := range strings.Split(string(output), "\n") { for _, forbidden := range []string{"persistence", "mailer", "gorm"} { if strings.Contains(dependency, forbidden) { t.Errorf("api/handlers/media must not depend on %s", dependency) } } } }) }