package test import ( "RenewCMS/api/handlers/image" "RenewCMS/api/middleware" "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 { userRepo := newVerifiedUserRepo() getUserUseCase := useCases.NewGetUserUseCase(userRepo) 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.Use(middleware.RequireVerifiedUser(getUserUseCase)) 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()) } }) }