refac: authenticated user can upload an image to an existing article and receives its public location; an unknown article returns 404 and a disallowed type returns 400
This commit is contained in:
1 parent
5992c8598b
commit
e474f03d91
5 files changed
+563
-14
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/<stored-filename>"
|
||||
|
||||
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
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user