Files
RenewCMS/test/media_library_http_test.go
T
2026-09-30 18:35:28 +02:00

1074 lines
37 KiB
Go

package test
import (
"RenewCMS/api/pkg/apperr"
domainImage "RenewCMS/internal/domain/image"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
"net/url"
"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, `<img src="`+env.urlOf(1)+`"><img src="`+env.urlOf(2)+`">`)
env.seedArticle(2, "Backyard Notes", false, `<img src="`+env.urlOf(1)+`">`)
env.seedArticle(3, "Empty", true, "no image here")
}
type listBody struct {
Items []map[string]any `json:"items"`
NextCursor *string `json:"next_cursor"`
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 got.NextCursor != nil {
t.Errorf("expected next_cursor null, got %v", *got.NextCursor)
}
if !strings.Contains(raw, `"next_cursor":null`) {
t.Errorf("expected next_cursor null in the raw body")
}
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("limit and cursor page through the list", func(t *testing.T) {
env := newMediaEnv(t)
seedListingLibrary(env)
first, _ := env.list(t, "?limit=2")
expectIds(t, idsOf(first.Items), []uint32{5, 4})
if first.NextCursor == nil || *first.NextCursor == "" {
t.Fatalf("expected a next_cursor on page 1")
}
second, _ := env.list(t, "?limit=2&cursor="+url.QueryEscape(*first.NextCursor))
expectIds(t, idsOf(second.Items), []uint32{3, 2})
if second.NextCursor == nil || *second.NextCursor == "" {
t.Fatalf("expected a next_cursor on page 2")
}
third, _ := env.list(t, "?limit=2&cursor="+url.QueryEscape(*second.NextCursor))
expectIds(t, idsOf(third.Items), []uint32{1})
if third.NextCursor != nil {
t.Errorf("expected next_cursor null on the last page, got %v", *third.NextCursor)
}
})
t.Run("A malformed query answers 400", func(t *testing.T) {
for _, query := range []string{"filter=bogus", "sort=bogus", "limit=0", "limit=101", "limit=abc", "cursor=%%%", "cursor=bogus", "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.images.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, `<img src="/static/uploadedImages/aaa.jpeg">`)
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, "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, "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)
_, err := uc.Merge(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())
for _, contentType := range domainImage.AllowedContentTypes() {
_, err := uc.CreateImage(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())
_, err := uc.CreateImage(inputOf([]byte("<html/>"), "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())
_, err := uc.CreateImage(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)
if _, err := uc.Rename(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)
if _, err := uc.Merge(1, []uint32{9999}); !errors.Is(err, domainImage.ErrMergeCopyMissing) {
t.Errorf("expected ErrMergeCopyMissing, got %v", err)
}
if _, err := uc.Merge(1, []uint32{2}); !errors.Is(err, domainImage.ErrMergeHashMismatch) {
t.Errorf("expected ErrMergeHashMismatch, got %v", err)
}
if _, err := uc.Merge(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)
}
}
}
})
}