diff --git a/api/handlers/image/imageHandler.go b/api/handlers/image/imageHandler.go index 0013efb..770dcee 100644 --- a/api/handlers/image/imageHandler.go +++ b/api/handlers/image/imageHandler.go @@ -1,9 +1,12 @@ package image import ( - "RenewCMS/internal/domain/image" + "RenewCMS/api/pkg/apperr" + domainArticle "RenewCMS/internal/domain/article" + domainImage "RenewCMS/internal/domain/image" "RenewCMS/internal/infrastructure/useCases" "encoding/json" + "errors" "net/http" "strconv" @@ -12,21 +15,41 @@ import ( type Handler struct { CreateUseCase *useCases.CreateImageUseCase + GetUseCase *useCases.GetArticleUseCase UpdateUseCase *useCases.UpdateArticleUseCase } -func NewHandler(create *useCases.CreateImageUseCase, update *useCases.UpdateArticleUseCase) *Handler { +func NewHandler(create *useCases.CreateImageUseCase, get *useCases.GetArticleUseCase, update *useCases.UpdateArticleUseCase) *Handler { return &Handler{ CreateUseCase: create, + GetUseCase: get, UpdateUseCase: update, } } -func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) { - _ = r.ParseForm() +func writeInternalServerError(w http.ResponseWriter) { + http.Error(w, "Internal server error", http.StatusInternalServerError) +} - id := chi.URLParam(r, "id") - idInt, _ := strconv.Atoi(id) +func writeArticleError(w http.ResponseWriter, err error) { + if errors.Is(err, domainArticle.ErrArticleNotFound) { + http.Error(w, apperr.ArticleNotFoundErrorMessage, http.StatusNotFound) + return + } + writeInternalServerError(w) +} + +func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) { + articleId, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32) + if err != nil { + http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest) + return + } + + if _, err := h.GetUseCase.GetArticle(uint32(articleId)); err != nil { + writeArticleError(w, err) + return + } file, fileHeader, err := r.FormFile("file") if err != nil { @@ -35,25 +58,32 @@ func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) { } defer file.Close() - newImage, err := h.CreateUseCase.CreateImage(image.ImageInput{ + newImage, err := h.CreateUseCase.CreateImage(domainImage.ImageInput{ Content: file, Filename: fileHeader.Filename, Size: fileHeader.Size, ContentType: fileHeader.Header.Get("Content-Type"), }) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + if errors.Is(err, domainImage.ErrImageTypeNotAllowed) || errors.Is(err, domainImage.ErrImageTooLarge) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeInternalServerError(w) return } - err = h.UpdateUseCase.AddImage(uint32(idInt), newImage.ID) + if err := h.UpdateUseCase.AddImage(uint32(articleId), newImage.ID); err != nil { + writeArticleError(w, err) + return + } + + responseJson, err := json.Marshal(map[string]any{"location": newImage.Path}) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + writeInternalServerError(w) return } - newJson := map[string]any{"location": newImage.Path} - newJsonBytes, _ := json.Marshal(newJson) - - _, _ = w.Write(newJsonBytes) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(responseJson) } diff --git a/api/router.go b/api/router.go index 5a9d48b..90f69ae 100644 --- a/api/router.go +++ b/api/router.go @@ -4,6 +4,7 @@ import ( "RenewCMS/api/handlers/article" "RenewCMS/api/handlers/auth" "RenewCMS/api/handlers/frontend" + "RenewCMS/api/handlers/image" "RenewCMS/api/middleware" "encoding/json" "net/http" @@ -32,6 +33,7 @@ func NewArticleRouter(container *UseCases) http.Handler { r := chi.NewRouter() articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase) + imageHandler := image.NewHandler(container.CreateImageUseCase, container.GetArticleUseCase, container.UpdateArticleUseCase) r.Group(func(r chi.Router) { r.Get("/", articleHandler.ListArticles) @@ -44,6 +46,7 @@ func NewArticleRouter(container *UseCases) http.Handler { r.Patch("/{id}", articleHandler.UpdateArticleBody) r.Patch("/{id}/publish", articleHandler.PublishArticle) r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle) + r.Post("/{id}/image", imageHandler.PostImage) }) return r } diff --git a/features/upload_article_image.feature b/features/upload_article_image.feature new file mode 100644 index 0000000..ef83d83 --- /dev/null +++ b/features/upload_article_image.feature @@ -0,0 +1,94 @@ +Feature: Uploading an image to an article + + As an authenticated RenewCMS user managing the CMS + I want to attach an image file to an existing article and get back the + image's public location + So that an unknown article answers 404 the same way every other article + write does, without ever creating an orphaned image, and a disallowed + content type is rejected with 400 before it becomes part of the + article, without a maintainer needing to trust the client to send a + sane file + + Background: + Given internal/infrastructure/useCases.CreateImageUseCase.CreateImage + already checks the uploaded content type against the allowed list + (image/png, image/jpeg, image/webp, image/svg+xml) and the size + against the 10485760-byte maximum before calling image.Repository.Create, + returning image.ErrImageTypeNotAllowed or image.ErrImageTooLarge and + calling the repository not at all when either check fails + And internal/infrastructure/persistence's ArticleRepository.AddImage + already returns article.ErrArticleNotFound when the article id doesn't + match any row, and image.ErrImageNotFound when the article exists but + the image id doesn't, both asserted with errors.Is + And before this slice, api/handlers/image.Handler.PostImage existed and + was wired to CreateImageUseCase and UpdateArticleUseCase.AddImage, but + no route called it, and every error it got back from either use case + fell through to a blanket 500 + And this slice mounts POST /v1/article/{id}/image in api/router.go, in + the same jwtauth Verifier + Authenticator group as the other article + write routes, and changes the upload flow so that the article's + existence is confirmed before the image is created, so that an + unknown article id never leaves an orphaned image behind + And this slice changes PostImage's error handling so that errors.Is(err, + image.ErrImageTypeNotAllowed) or errors.Is(err, image.ErrImageTooLarge) + answers 400 with the sentinel's own message, errors.Is(err, + article.ErrArticleNotFound) answers 404 with body + apperr.ArticleNotFoundErrorMessage, and any other error still falls + back to a generic 500 + And PostImage reads the uploaded file from the multipart form field + "file" + And on success PostImage answers 201 with a JSON body {"location": ...} + holding the created image's public path, e.g. + "/static/uploadedImages/" + + Scenario: Uploading an allowed image type to an existing article succeeds + Given a valid jwt cookie for a user with id 1 + And the article repository contains an article with id 1 titled + "Backyard Renovation Update" + When the client sends POST /v1/article/1/image with that cookie and a + multipart form field "file" holding a file named "before.png" with + content type "image/png" + Then the response status is 201 + And the response body is JSON with a "location" field whose value is + "/static/uploadedImages/before.png" + And the image repository contains a new image whose Path is + "/static/uploadedImages/before.png" + And the article repository's article with id 1 has that new image's id + in its Images slice + + Scenario: Uploading to an unknown article answers 404 and creates no image + Given a valid jwt cookie for a user with id 1 + And the article repository has no article with id 9999 + When the client sends POST /v1/article/9999/image with that cookie and a + multipart form field "file" holding a file named "before.png" with + content type "image/png" + Then the response status is 404 + And the response body is the text "Article not found" + And the image repository's Create is never called + And no image exists in the image repository + + Scenario: Uploading a disallowed content type answers 400 before the article is touched + Given a valid jwt cookie for a user with id 1 + And the article repository contains an article with id 1 titled + "Backyard Renovation Update" + When the client sends POST /v1/article/1/image with that cookie and a + multipart form field "file" holding a file named "notes.pdf" with + content type "application/pdf" + Then the response status is 400 + And the response body is the text + "the file must be a PNG, JPEG, WEBP, or SVG image" + And the image repository's Create is never called + And the article repository's article with id 1 still has an empty + Images slice + + Scenario: Uploading an image still requires authentication + Given no jwt cookie is sent + And the article repository contains an article with id 1 titled + "Backyard Renovation Update" + When the client sends POST /v1/article/1/image without a cookie and a + multipart form field "file" holding a file named "before.png" with + content type "image/png" + Then the response status is 401 + And the image repository's Create is never called + And the article repository's article with id 1 still has an empty + Images slice diff --git a/features/upload_article_image.qa.md b/features/upload_article_image.qa.md new file mode 100644 index 0000000..3270aee --- /dev/null +++ b/features/upload_article_image.qa.md @@ -0,0 +1,73 @@ +# QA procedure: upload_article_image + +Package: `test` (black-box, extending `article_test.go` / `image_test.go`). + +## Setup shared by every case +1. Call `validator.InitValidator()` once. +2. Reuse `mockArticleRepo` (`test/article_test.go`) and `mockImageRepo` + (`test/image_test.go`) as-is; both already implement `AddImage` / + `Create` returning `article.ErrArticleNotFound` / `image.ErrImageNotFound` + for an unknown id, per `features/write_not_found_errors.feature`. +3. Wire the real use cases over the fakes: `useCases.NewCreateArticleUseCase`, + `useCases.NewGetArticleUseCase`, `useCases.NewListArticlesUseCase`, + `useCases.NewUpdateArticleUseCase(articleRepo)`, + `useCases.NewDeleteArticleUseCase(articleRepo, imageRepo)`, + `useCases.NewCreateImageUseCase(imageRepo)`. +4. Build `article.NewHandler(...)` with its existing constructor shape, and + `image.NewHandler(createImageUseCase, updateArticleUseCase)` + (`api/handlers/image`). +5. Build a test `jwtauth.New("HS256", []byte("test-secret"), nil)`. +6. Mount the routes on a fresh `chi.NewRouter()` mirroring `api/router.go`'s + shape after this slice: `GET /` under `jwtauth.Verifier` only; + `GET /{id}`, `POST /`, `DELETE /{id}`, `PATCH /{id}`, + `PATCH /{id}/publish`, `PATCH /{id}/unpublish` and the new + `POST /{id}/image` (routed to `imageHandler.PostImage`) under + `jwtauth.Verifier` + `jwtauth.Authenticator`. +7. Mint a valid jwt cookie for user id 1: + `token.Encode(map[string]any{"user_id": uint32(1)})`, set as the `jwt` + cookie on the relevant `httptest.NewRequest`. +8. Build multipart request bodies with `mime/multipart.Writer`, field name + `"file"`, setting the part's `Content-Type` header explicitly (Go's + multipart writer defaults to sniffing otherwise, so set it via + `CreatePart` with an explicit MIME header rather than + `CreateFormFile`), and set the request's `Content-Type` header to the + writer's `FormDataContentType()`. + +## HTTP checks +9. Seed `articleRepo.articles[1] = article.Article{ID: 1, Title: "Backyard + Renovation Update"}`. Send `POST /v1/article/1/image` with the valid + cookie and a multipart `"file"` part named `"before.png"`, + content type `"image/png"`, some non-empty byte content. Assert status + `201`; decode the JSON body and assert `location` equals + `"/static/uploadedImages/before.png"`; assert `imageRepo.images` gained + exactly one entry whose `Path` matches that location; assert + `articleRepo.articles[1].Images` now contains that image's id. +10. Empty `articleRepo`, empty `imageRepo`. Send `POST /v1/article/9999/image` + with the valid cookie and the same `"before.png"` / `"image/png"` part. + Assert status `404`, body `"Article not found\n"`; assert + `imageRepo.images` is still empty (the article's existence is confirmed + before any image is created, so nothing is left orphaned). +11. Seed `articleRepo.articles[1] = article.Article{ID: 1, Title: "Backyard + Renovation Update"}` with an empty `Images` slice. Send + `POST /v1/article/1/image` with the valid cookie and a multipart + `"file"` part named `"notes.pdf"`, content type `"application/pdf"`. + Assert status `400`, body + `"the file must be a PNG, JPEG, WEBP, or SVG image\n"`; assert + `imageRepo.images` is still empty; assert + `articleRepo.articles[1].Images` is still empty. +12. Seed the same article id 1 as step 11. Send `POST /v1/article/1/image` + with **no** cookie and the `"before.png"` / `"image/png"` part. Assert + status `401`; assert `imageRepo.images` is still empty; assert + `articleRepo.articles[1].Images` is still empty. + +## Manual verification (not exercised by the Go test) +- Real upload against a real SQLite file and real `UPLOAD_DIR`: confirm that + after `POST /v1/article/{id}/image` on a real running instance, the file + actually lands on disk under `UPLOAD_DIR`, the `images` row's `path` + resolves to a URL the SPA can load, and the `article_images` join table + gets the new row. Not exercised by `task test` (fakes only); requires + `task dev` or a built `task build` binary with a scratch DB/upload dir. +- SPA-visible behaviour: confirm the back-office article editor (if/when an + image-upload UI exists) shows the uploaded image at that location. No such + UI exists yet as of this slice. +- No SMTP involvement. diff --git a/test/upload_article_image_http_test.go b/test/upload_article_image_http_test.go new file mode 100644 index 0000000..8d2b3cd --- /dev/null +++ b/test/upload_article_image_http_test.go @@ -0,0 +1,349 @@ +package test + +import ( + "RenewCMS/api/handlers/image" + "RenewCMS/api/pkg/apperr" + validator "RenewCMS/api/pkg/validator" + domainArticle "RenewCMS/internal/domain/article" + domainImage "RenewCMS/internal/domain/image" + "RenewCMS/internal/infrastructure/useCases" + "bytes" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/jwtauth/v5" +) + +func newUploadArticleImageRouter(articleRepo *mockArticleRepo, imageRepo *mockImageRepo, token *jwtauth.JWTAuth) *chi.Mux { + imageHandler := image.NewHandler( + useCases.NewCreateImageUseCase(imageRepo), + useCases.NewGetArticleUseCase(articleRepo), + useCases.NewUpdateArticleUseCase(articleRepo), + ) + + r := chi.NewRouter() + r.Route("/v1", func(r chi.Router) { + r.Route("/article", func(r chi.Router) { + r.Group(func(r chi.Router) { + r.Use(jwtauth.Verifier(token)) + r.Group(func(r chi.Router) { + r.Use(jwtauth.Authenticator(token)) + r.Post("/{id}/image", imageHandler.PostImage) + }) + }) + }) + }) + return r +} + +func setUploadJwtCookie(t *testing.T, req *http.Request, token *jwtauth.JWTAuth) { + t.Helper() + _, tokenString, err := token.Encode(map[string]any{"user_id": uint32(1)}) + if err != nil { + t.Fatalf("failed to encode jwt: %v", err) + } + req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString}) +} + +func newMultipartImageUploadRequest(t *testing.T, url string, filename string, contentType string) *http.Request { + t.Helper() + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreatePart(map[string][]string{ + "Content-Disposition": {`form-data; name="file"; filename="` + filename + `"`}, + "Content-Type": {contentType}, + }) + if err != nil { + t.Fatalf("failed to create multipart part: %v", err) + } + if _, err := io.WriteString(part, "fake-image-data"); err != nil { + t.Fatalf("failed to write file content: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("failed to close multipart writer: %v", err) + } + req := httptest.NewRequest(http.MethodPost, url, body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + return req +} + +func TestUploadArticleImageHttp(t *testing.T) { + validator.InitValidator() + token := jwtauth.New("HS256", []byte("test-secret"), nil) + + t.Run("Uploading an allowed image type to an existing article succeeds", func(t *testing.T) { + articleRepo := newMockArticleRepo() + articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"} + articleRepo.images[1] = &domainImage.Image{ID: 1, Path: "/static/uploadedImages/before.png"} + imageRepo := newMockImageRepo() + router := newUploadArticleImageRouter(articleRepo, imageRepo, token) + + req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "before.png", "image/png") + setUploadJwtCookie(t, req, token) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected status 201, got %d, body %q", rec.Code, rec.Body.String()) + } + + var got map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if got["location"] != "/static/uploadedImages/before.png" { + t.Errorf("expected location %q, got %q", "/static/uploadedImages/before.png", got["location"]) + } + + if len(imageRepo.images) != 1 { + t.Fatalf("expected the image repository to gain exactly one entry, got %d", len(imageRepo.images)) + } + var storedImageID uint32 + found := false + for id, img := range imageRepo.images { + if img.Path == "/static/uploadedImages/before.png" { + storedImageID = id + found = true + } + } + if !found { + t.Fatalf("expected the image repository to contain the new image") + } + + updatedArticle := articleRepo.articles[1] + imageAttached := false + for _, img := range updatedArticle.Images { + if img.ID == storedImageID { + imageAttached = true + } + } + if !imageAttached { + t.Errorf("expected article 1 to reference the new image's id, got %+v", updatedArticle.Images) + } + }) + + t.Run("Uploading to an unknown article answers 404 and creates no image", func(t *testing.T) { + articleRepo := newMockArticleRepo() + imageRepo := newMockImageRepo() + router := newUploadArticleImageRouter(articleRepo, imageRepo, token) + + req := newMultipartImageUploadRequest(t, "/v1/article/9999/image", "before.png", "image/png") + setUploadJwtCookie(t, req, token) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected status 404, got %d", rec.Code) + } + if rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" { + t.Errorf("unexpected body: %q", rec.Body.String()) + } + if len(imageRepo.images) != 0 { + t.Errorf("expected no image to be created, got %d", len(imageRepo.images)) + } + }) + + t.Run("Uploading a disallowed content type answers 400 before the article is touched", func(t *testing.T) { + articleRepo := newMockArticleRepo() + articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"} + imageRepo := newMockImageRepo() + router := newUploadArticleImageRouter(articleRepo, imageRepo, token) + + req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "notes.pdf", "application/pdf") + setUploadJwtCookie(t, req, token) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, 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 len(imageRepo.images) != 0 { + t.Errorf("expected no image to be created, got %d", len(imageRepo.images)) + } + if len(articleRepo.articles[1].Images) != 0 { + t.Errorf("expected article 1 to still have an empty Images slice, got %+v", articleRepo.articles[1].Images) + } + }) + + t.Run("Uploading an image still requires authentication", func(t *testing.T) { + articleRepo := newMockArticleRepo() + articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"} + imageRepo := newMockImageRepo() + router := newUploadArticleImageRouter(articleRepo, imageRepo, token) + + req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "before.png", "image/png") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected status 401, got %d", rec.Code) + } + if len(imageRepo.images) != 0 { + t.Errorf("expected no image to be created, got %d", len(imageRepo.images)) + } + if len(articleRepo.articles[1].Images) != 0 { + t.Errorf("expected article 1 to still have an empty Images slice, got %+v", articleRepo.articles[1].Images) + } + }) + + t.Run("Uploading with a non-numeric article id answers 400", func(t *testing.T) { + articleRepo := newMockArticleRepo() + imageRepo := newMockImageRepo() + router := newUploadArticleImageRouter(articleRepo, imageRepo, token) + + req := newMultipartImageUploadRequest(t, "/v1/article/abc/image", "before.png", "image/png") + setUploadJwtCookie(t, req, token) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", rec.Code) + } + if rec.Body.String() != apperr.IdUint32ErrorMessage+"\n" { + t.Errorf("unexpected body: %q", rec.Body.String()) + } + }) + + t.Run("Uploading without a file field answers 400", func(t *testing.T) { + articleRepo := newMockArticleRepo() + articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"} + imageRepo := newMockImageRepo() + router := newUploadArticleImageRouter(articleRepo, imageRepo, token) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + if err := writer.Close(); err != nil { + t.Fatalf("failed to close multipart writer: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/v1/article/1/image", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + setUploadJwtCookie(t, req, token) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", rec.Code) + } + if len(imageRepo.images) != 0 { + t.Errorf("expected no image to be created, got %d", len(imageRepo.images)) + } + }) + + t.Run("A repository failure while fetching the article answers 500", func(t *testing.T) { + articleRepo := newMockArticleRepo() + articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"} + articleRepo.fail.failNext("Get") + imageRepo := newMockImageRepo() + router := newUploadArticleImageRouter(articleRepo, imageRepo, token) + + req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "before.png", "image/png") + setUploadJwtCookie(t, req, token) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", rec.Code) + } + }) + + t.Run("A repository failure while creating the image answers 500", func(t *testing.T) { + articleRepo := newMockArticleRepo() + articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"} + imageRepo := newMockImageRepo() + imageRepo.fail.failNext("Create") + router := newUploadArticleImageRouter(articleRepo, imageRepo, token) + + req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "before.png", "image/png") + setUploadJwtCookie(t, req, token) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", rec.Code) + } + }) + + t.Run("Uploading a file over the size limit answers 400", func(t *testing.T) { + articleRepo := newMockArticleRepo() + articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"} + imageRepo := newMockImageRepo() + router := newUploadArticleImageRouter(articleRepo, imageRepo, token) + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreatePart(map[string][]string{ + "Content-Disposition": {`form-data; name="file"; filename="huge.png"`}, + "Content-Type": {"image/png"}, + }) + if err != nil { + t.Fatalf("failed to create multipart part: %v", err) + } + if _, err := io.Copy(part, strings.NewReader(strings.Repeat("a", int(domainImage.MaxImageSize)+1))); err != nil { + t.Fatalf("failed to write file content: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("failed to close multipart writer: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/v1/article/1/image", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + setUploadJwtCookie(t, req, token) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d", rec.Code) + } + if rec.Body.String() != domainImage.ErrImageTooLarge.Error()+"\n" { + t.Errorf("unexpected body: %q", rec.Body.String()) + } + if len(imageRepo.images) != 0 { + t.Errorf("expected no image to be created, got %d", len(imageRepo.images)) + } + }) + + t.Run("A repository failure while attaching the image to the article answers 500", func(t *testing.T) { + articleRepo := newMockArticleRepo() + articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"} + articleRepo.fail.failNext("AddImage") + imageRepo := newMockImageRepo() + router := newUploadArticleImageRouter(articleRepo, imageRepo, token) + + req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "before.png", "image/png") + setUploadJwtCookie(t, req, token) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", rec.Code) + } + }) + + t.Run("AddImage reporting a missing article after the initial lookup still answers 404", func(t *testing.T) { + articleRepo := newMockArticleRepo() + articleRepo.articles[1] = domainArticle.Article{ID: 1, Title: "Backyard Renovation Update"} + articleRepo.fail.failNextWith("AddImage", domainArticle.ErrArticleNotFound) + imageRepo := newMockImageRepo() + router := newUploadArticleImageRouter(articleRepo, imageRepo, token) + + req := newMultipartImageUploadRequest(t, "/v1/article/1/image", "before.png", "image/png") + setUploadJwtCookie(t, req, token) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected status 404, got %d", rec.Code) + } + if rec.Body.String() != apperr.ArticleNotFoundErrorMessage+"\n" { + t.Errorf("unexpected body: %q", rec.Body.String()) + } + }) +}