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
|
package image
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"RenewCMS/internal/domain/image"
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
domainArticle "RenewCMS/internal/domain/article"
|
||||||
|
domainImage "RenewCMS/internal/domain/image"
|
||||||
"RenewCMS/internal/infrastructure/useCases"
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -12,21 +15,41 @@ import (
|
|||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
CreateUseCase *useCases.CreateImageUseCase
|
CreateUseCase *useCases.CreateImageUseCase
|
||||||
|
GetUseCase *useCases.GetArticleUseCase
|
||||||
UpdateUseCase *useCases.UpdateArticleUseCase
|
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{
|
return &Handler{
|
||||||
CreateUseCase: create,
|
CreateUseCase: create,
|
||||||
|
GetUseCase: get,
|
||||||
UpdateUseCase: update,
|
UpdateUseCase: update,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) {
|
func writeInternalServerError(w http.ResponseWriter) {
|
||||||
_ = r.ParseForm()
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
id := chi.URLParam(r, "id")
|
func writeArticleError(w http.ResponseWriter, err error) {
|
||||||
idInt, _ := strconv.Atoi(id)
|
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")
|
file, fileHeader, err := r.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -35,25 +58,32 @@ func (h *Handler) PostImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
newImage, err := h.CreateUseCase.CreateImage(image.ImageInput{
|
newImage, err := h.CreateUseCase.CreateImage(domainImage.ImageInput{
|
||||||
Content: file,
|
Content: file,
|
||||||
Filename: fileHeader.Filename,
|
Filename: fileHeader.Filename,
|
||||||
Size: fileHeader.Size,
|
Size: fileHeader.Size,
|
||||||
ContentType: fileHeader.Header.Get("Content-Type"),
|
ContentType: fileHeader.Header.Get("Content-Type"),
|
||||||
})
|
})
|
||||||
if err != nil {
|
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
|
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 {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
writeInternalServerError(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newJson := map[string]any{"location": newImage.Path}
|
w.WriteHeader(http.StatusCreated)
|
||||||
newJsonBytes, _ := json.Marshal(newJson)
|
_, _ = w.Write(responseJson)
|
||||||
|
|
||||||
_, _ = w.Write(newJsonBytes)
|
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"RenewCMS/api/handlers/article"
|
"RenewCMS/api/handlers/article"
|
||||||
"RenewCMS/api/handlers/auth"
|
"RenewCMS/api/handlers/auth"
|
||||||
"RenewCMS/api/handlers/frontend"
|
"RenewCMS/api/handlers/frontend"
|
||||||
|
"RenewCMS/api/handlers/image"
|
||||||
"RenewCMS/api/middleware"
|
"RenewCMS/api/middleware"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -32,6 +33,7 @@ func NewArticleRouter(container *UseCases) http.Handler {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
articleHandler := article.NewHandler(container.CreateArticleUseCase, container.GetArticleUseCase, container.ListArticlesUseCase, container.DeleteArticleUseCase, container.UpdateArticleUseCase)
|
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.Group(func(r chi.Router) {
|
||||||
r.Get("/", articleHandler.ListArticles)
|
r.Get("/", articleHandler.ListArticles)
|
||||||
@@ -44,6 +46,7 @@ func NewArticleRouter(container *UseCases) http.Handler {
|
|||||||
r.Patch("/{id}", articleHandler.UpdateArticleBody)
|
r.Patch("/{id}", articleHandler.UpdateArticleBody)
|
||||||
r.Patch("/{id}/publish", articleHandler.PublishArticle)
|
r.Patch("/{id}/publish", articleHandler.PublishArticle)
|
||||||
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
|
r.Patch("/{id}/unpublish", articleHandler.UnpublishArticle)
|
||||||
|
r.Post("/{id}/image", imageHandler.PostImage)
|
||||||
})
|
})
|
||||||
return r
|
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