1

I am creating an API endpoint to handle form submissions.

The form takes the following:

  1. Name
  2. Email
  3. Phone
  4. Photo files (up to 5)

Then basically, sends an email to some email address with the photos as attachments.

I want to write tests for my handler to make sure everything is working well, however, I am struggling.

CODE:

Below is my HTTP handler (will run in AWS lambda, not that it matters).

package aj

import (
    "fmt"
    "mime"
    "net/http"

    "go.uber.org/zap"
)

const expectedContentType string = "multipart/form-data"

func FormSubmissionHandler(logger *zap.Logger, emailSender EmailSender) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // enforce a multipart/form-data content-type
        contentType := r.Header.Get("content-type")

        mediatype, _, err := mime.ParseMediaType(contentType)
        if err != nil {
            logger.Error("error when parsing the mime type", zap.Error(err))
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }

        if mediatype != expectedContentType {
            logger.Error("unsupported content-type", zap.Error(err))
            http.Error(w, fmt.Sprintf("api expects %v content-type", expectedContentType), http.StatusUnsupportedMediaType)
            return
        }

        err = r.ParseMultipartForm(32 << 20)
        if err != nil {
            logger.Error("error parsing form data", zap.Error(err))
            http.Error(w, "error parsing form data", http.StatusBadRequest)
            return
        }

        name := r.PostForm.Get("name")
        if name == "" {
            fmt.Println("inside if statement for name")
            logger.Error("name not set", zap.Error(err))
            http.Error(w, "api expects name to be set", http.StatusBadRequest)
            return
        }

        email := r.PostForm.Get("email")
        if email == "" {
            logger.Error("email not set", zap.Error(err))
            http.Error(w, "api expects email to be set", http.StatusBadRequest)
            return
        }

        phone := r.PostForm.Get("phone")
        if phone == "" {
            logger.Error("phone not set", zap.Error(err))
            http.Error(w, "api expects phone to be set", http.StatusBadRequest)
            return
        }

        emailService := NewEmailService()

        m := NewMessage("Test", "Body message.")

        err = emailService.SendEmail(logger, r.Context(), m)
        if err != nil {
            logger.Error("an error occurred sending the email", zap.Error(err))
            http.Error(w, "error sending email", http.StatusBadRequest)
            return
        }

        w.WriteHeader(http.StatusOK)
    })
}

The now updated test giving me trouble is:

package aj

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "image/jpeg"
    "io"
    "mime/multipart"
    "net/http"
    "net/http/httptest"
    "os"
    "strings"
    "testing"

    "go.uber.org/zap"
)

type StubEmailService struct {
    sendEmail func(logger *zap.Logger, ctx context.Context, email *Message) error
}

func (s *StubEmailService) SendEmail(logger *zap.Logger, ctx context.Context, email *Message) error {
    return s.sendEmail(logger, ctx, email)
}

func TestFormSubmissionHandler(t *testing.T) {
    // create the logger
    logger, _ := zap.NewProduction()

    t.Run("returns 400 (bad request) when name is not set in the body", func(t *testing.T) {
        // set up a pipe avoid buffering
        pipeReader, pipeWriter := io.Pipe()

        // this writer is going to transform what we pass to it to multipart form data
        // and write it to our io.Pipe
        multipartWriter := multipart.NewWriter(pipeWriter)

        go func() {
            // close it when it has done its job
            defer multipartWriter.Close()

            // create a form field writer for name
            nameField, err := multipartWriter.CreateFormField("name")
            if err != nil {
                t.Error(err)
            }

            // write string to the form field writer for name
            nameField.Write([]byte("John Doe"))

            // we create the form data field 'photo' which returns another writer to write the actual file
            fileField, err := multipartWriter.CreateFormFile("photo", "test.png")
            if err != nil {
                t.Error(err)
            }

            // read image file as array of bytes
            fileBytes, err := os.ReadFile("../../00-test-image.jpg")

            // create an io.Reader
            reader := bytes.NewReader(fileBytes)

            // convert the bytes to a jpeg image
            image, err := jpeg.Decode(reader)
            if err != nil {
                t.Error(err)
            }

            // Encode() takes an io.Writer. We pass the multipart field 'photo' that we defined
            // earlier which, in turn, writes to our io.Pipe
            err = jpeg.Encode(fileField, image, &jpeg.Options{Quality: 75})
            if err != nil {
                t.Error(err)
            }
        }()

        formData := HandleFormRequest{Name: "John Doe", Email: "john.doe@example.com", Phone: "07542147833"}

        // create the stub patient store
        emailService := StubEmailService{
            sendEmail: func(_ *zap.Logger, _ context.Context, email *Message) error {
                if !strings.Contains(email.Body, formData.Name) {
                    t.Errorf("expected email.Body to contain %s", formData.Name)
                }
                return nil
            },
        }

        // create a request to pass to our handler
        req := httptest.NewRequest(http.MethodPost, "/handler", pipeReader)

        // set the content type
        req.Header.Set("content-type", "multipart/form-data")

        // create a response recorder
        res := httptest.NewRecorder()

        // get the handler
        handler := FormSubmissionHandler(logger, &emailService)

        // our handler satisfies http.handler, so we can call its serve http method
        // directly and pass in our request and response recorder
        handler.ServeHTTP(res, req)

        // assert status code is what we expect
        assertStatusCode(t, res.Code, http.StatusBadRequest)
    })
}

func assertStatusCode(t testing.TB, got, want int) {
    t.Helper()

    if got != want {
        t.Errorf("handler returned wrong status code: got %v want %v", got, want)
    }
}

As mentioned in the test name, I want to make sure a Name property is coming through with the request.

When I run go test ./... -v I get:

=== RUN TestFormSubmissionHandler/returns_400_(bad_request)_when_name_is_not_set_in_the_body {"level":"error","ts":1675459283.4969518,"caller":"aj/handler.go:33","msg":"error parsing form data","error":"no multipart boundary param in Content-Type","stacktrace":"github.com/jwankhalaf-dh/ajgenerators.co.uk__form-handler/api/aj.FormSubmissionHandler.func1\n\t/home/j/code/go/src/github.com/jwankhalaf-dh/ajgenerators.co.uk__form-handler/api/aj/handler.go:33\nnet/http.HandlerFunc.ServeHTTP\n\t/usr/local/go/src/net/http/server.go:2109\ngithub.com/jwankhalaf-dh/ajgenerators.co.uk__form-handler/api/aj.TestFormSubmissionHandler.func3\n\t/home/j/code/go/src/github.com/jwankhalaf-dh/ajgenerators.co.uk__form-handler/api/aj/handler_test.go:132\ntesting.tRunner\n\t/usr/local/go/src/testing/testing.go:1446"}

I understand the error, but I am not sure how to overcome it.

My next test would be to test the same thing but for email, and then phone, then finally, I'd like to test file data, but I'm not sure how.

Jason Aller
  • 3,541
  • 28
  • 38
  • 38
J86
  • 14,345
  • 47
  • 130
  • 228
  • 3
    First, I'd just using [`httptest.NewRequest`](https://pkg.go.dev/net/http/httptest#NewRequest) to create your test requests for convenience. But to fix the issue, your handler expects multipart form data, but you're explicitly sending JSON instead. HTTP's form data format is not JSON. [You want to use `url.Values` which can `Encode` to the expected format](https://stackoverflow.com/questions/19253469/make-a-url-encoded-post-request-using-http-newrequest). – Adrian Feb 03 '23 at 22:47
  • Thank you @Adrian, the question you linked to uses `application/x-www-form-urlencoded`, would it be the exact same thing if I want `multipart/form-data`? You're right about me sending JSON, I wasn't sure how to send `multipart/form-data` with files – J86 Feb 04 '23 at 12:48
  • 1
    @J86 Answers to questions [1](https://stackoverflow.com/questions/20205796/), [2](https://stackoverflow.com/questions/63636454/), [3](https://stackoverflow.com/questions/58105449/), [4](https://stackoverflow.com/questions/44302374/) show how to construct a multipart/form-data request body. – Charlie Tumahai Feb 04 '23 at 16:42
  • @CeriseLimón I believe I've made some progress in constructing the `multipart/form-data` though it still gives me the same boundary error, I'm close, but not quite there yet. – J86 Feb 04 '23 at 16:51
  • 2
    Include the [writer's boundary](https://pkg.go.dev/mime/multipart#Writer.FormDataContentType) in the content type. See answers for use of FormDataContentType. – Charlie Tumahai Feb 04 '23 at 17:36

1 Answers1

0

Thanks to Adrian and Cerise I was able to correctly construct the multipart/form-data in the request (updated code is in the question).

However, it was still not working, and the reason was, I was doing:

// set the content type
req.Header.Set("content-type", "multipart/form-data")

instead of:

// set the content type
req.Header.Add("content-type", multipartWriter.FormDataContentType())
J86
  • 14,345
  • 47
  • 130
  • 228