From b394423e84bb1caded673a27bd3a11ae9605b6ef Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 11 May 2024 01:09:01 +0300 Subject: [PATCH 1/9] chore: add handler test --- internal/coder/coder.go | 20 ++++++- internal/coder/coder_test.go | 72 ++++++++++++++++++++++++++ internal/delivery/http/handler.go | 32 +++++++----- internal/delivery/http/handler_test.go | 63 ++++++++++++++++++++++ 4 files changed, 173 insertions(+), 14 deletions(-) create mode 100644 internal/coder/coder_test.go create mode 100644 internal/delivery/http/handler_test.go diff --git a/internal/coder/coder.go b/internal/coder/coder.go index c8c2434..864650f 100644 --- a/internal/coder/coder.go +++ b/internal/coder/coder.go @@ -6,7 +6,11 @@ import ( "github.com/skip2/go-qrcode" ) -const utf8BOM = "\ufeff" +const ( + minSize = 32 + maxSize = 1024 + utf8BOM = "\ufeff" +) type QRCode struct { Content string `json:"content"` @@ -14,9 +18,23 @@ type QRCode struct { } func (code *QRCode) Generate() ([]byte, error) { + if err := code.Validate(); err != nil { + return nil, fmt.Errorf("QR Code validation error: %v", err) + } + qrCode, err := qrcode.Encode(utf8BOM+code.Content, qrcode.Medium, code.Size) if err != nil { return nil, fmt.Errorf("could not generate a QR code: %v", err) } return qrCode, nil } + +func (code *QRCode) Validate() error { + if code.Content == "" { + return fmt.Errorf("content is empty") + } + if code.Size <= minSize || code.Size > maxSize { + return fmt.Errorf("invalid size: %d, must be greater than %d and less than %d", code.Size, minSize, maxSize) + } + return nil +} diff --git a/internal/coder/coder_test.go b/internal/coder/coder_test.go new file mode 100644 index 0000000..a847f4f --- /dev/null +++ b/internal/coder/coder_test.go @@ -0,0 +1,72 @@ +package coder + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestQRCode_ValidateSuccess(t *testing.T) { + t.Parallel() + + code := &QRCode{ + Content: "test", + Size: 32, + } + + err := code.Validate() + assert.NoError(t, err) +} + +func TestQRCode_ValidateErr(t *testing.T) { + t.Parallel() + + type fields struct { + content string + size int + } + tests := map[string]struct { + fields fields + want string + }{ + "empty content": { + fields: fields{ + content: "", + size: 32, + }, + want: "content is empty", + }, + "size is small": { + fields: fields{ + content: "test", + size: -1, + }, + want: "invalid size: -1, must be greater than 32 and less than 1024", + }, + "size is big": { + fields: fields{ + content: "test", + size: 1025, + }, + want: "invalid size: 1025, must be greater than 32 and less than 1024", + }, + } + + for name, tt := range tests { + tt := tt + + t.Run(name, func(t *testing.T) { + t.Parallel() + + code := &QRCode{ + Content: tt.fields.content, + Size: tt.fields.size, + } + + err := code.Validate() + + assert.Error(t, err) + assert.ErrorContains(t, err, tt.want) + }) + } +} diff --git a/internal/delivery/http/handler.go b/internal/delivery/http/handler.go index f446db8..5493244 100644 --- a/internal/delivery/http/handler.go +++ b/internal/delivery/http/handler.go @@ -13,30 +13,36 @@ type QRCodeHandler struct { } func (h *QRCodeHandler) handle() http.HandlerFunc { - return func(writer http.ResponseWriter, request *http.Request) { - qrCode := coder.QRCode{} - err := json.NewDecoder(request.Body).Decode(&qrCode) + return func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := r.Body.Close(); err != nil { + log.Printf("failed to close request body %v", err) + } + }() - //writer.Header().Set("Content-Type", "application/json") + qrCode := coder.QRCode{} - if err != nil { + if err := json.NewDecoder(r.Body).Decode(&qrCode); err != nil { err = fmt.Errorf("failed to decode JSON: %w", err) log.Print(err) - http.Error(writer, err.Error(), http.StatusBadRequest) + http.Error(w, err.Error(), http.StatusBadRequest) return } - //var image []byte image, err := qrCode.Generate() if err != nil { - writer.WriteHeader(400) - json.NewEncoder(writer).Encode( - fmt.Sprintf("Could not generate QR code. %v", err), - ) + err = fmt.Errorf("failed to generate QR-code: %w", err) + log.Print(err) + http.Error(w, err.Error(), http.StatusInternalServerError) return } - writer.Header().Set("Content-Type", "image/png") - writer.Write(image) + w.Header().Set("Content-Type", "image/png") + if _, err := w.Write(image); err != nil { + err = fmt.Errorf("failed to decode JSON: %w", err) + log.Print(err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } } } diff --git a/internal/delivery/http/handler_test.go b/internal/delivery/http/handler_test.go new file mode 100644 index 0000000..999d075 --- /dev/null +++ b/internal/delivery/http/handler_test.go @@ -0,0 +1,63 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestQRCodeHandler_handle(t *testing.T) { + t.Parallel() + + t.Run("success", func(t *testing.T) { + t.Parallel() + + handler := &QRCodeHandler{} + handleFunc := handler.handle() + + requestBody := strings.NewReader(`{"size": 32, "content": "test"}`) + + request := httptest.NewRequest(http.MethodPost, "/generate", requestBody) + response := httptest.NewRecorder() + + handleFunc(response, request) + + assert.Equal(t, http.StatusOK, response.Code) + assert.Equal(t, "image/png", response.Header().Get("Content-Type")) + }) + + t.Run("bad request", func(t *testing.T) { + t.Parallel() + + handler := &QRCodeHandler{} + handleFunc := handler.handle() + + requestBody := strings.NewReader("") + request := httptest.NewRequest(http.MethodPost, "/generate", requestBody) + response := httptest.NewRecorder() + + handleFunc(response, request) + + assert.Equal(t, http.StatusBadRequest, response.Code) + assert.Contains(t, response.Body.String(), "failed to decode JSON: EOF") + }) + + t.Run("internal server error", func(t *testing.T) { + t.Parallel() + + handler := &QRCodeHandler{} + handleFunc := handler.handle() + + requestBody := strings.NewReader(`{"size": 1000000, "content": ""}`) + request := httptest.NewRequest(http.MethodPost, "/generate", requestBody) + response := httptest.NewRecorder() + + handleFunc(response, request) + + assert.Equal(t, http.StatusInternalServerError, response.Code) + assert.Contains(t, response.Body.String(), "failed to generate QR-code: QR Code validation error: content is empty") + }) +} From 31cb82a3e4f00ee35ca21ddae4f2abafd74f99c2 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 11 May 2024 01:12:02 +0300 Subject: [PATCH 2/9] fix: correct default branch in workflow --- .github/workflows/go.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 52b06b0..906b9e8 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -2,9 +2,9 @@ name: go build on: push: - branches: [ "master" ] + branches: [ "main" ] pull_request: - branches: [ "master" ] + branches: [ "main" ] jobs: From 0b56fbc9636ff1d1a6df8c231826d291001df2cc Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 11 May 2024 01:32:39 +0300 Subject: [PATCH 3/9] fix: signal chan --- cmd/qr-coder/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/qr-coder/main.go b/cmd/qr-coder/main.go index 7e9d589..6002830 100644 --- a/cmd/qr-coder/main.go +++ b/cmd/qr-coder/main.go @@ -41,7 +41,7 @@ func main() { } func gracefulStop(shutdown chan<- bool) { - c := make(chan os.Signal) + c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGHUP, syscall.SIGINT, From 9a2620698e424174cd0e4e1ee1bf649be039dd24 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 11 May 2024 01:35:38 +0300 Subject: [PATCH 4/9] fix: min size --- internal/coder/coder.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/coder/coder.go b/internal/coder/coder.go index 864650f..f8933da 100644 --- a/internal/coder/coder.go +++ b/internal/coder/coder.go @@ -33,7 +33,7 @@ func (code *QRCode) Validate() error { if code.Content == "" { return fmt.Errorf("content is empty") } - if code.Size <= minSize || code.Size > maxSize { + if code.Size < minSize || code.Size > maxSize { return fmt.Errorf("invalid size: %d, must be greater than %d and less than %d", code.Size, minSize, maxSize) } return nil From 42fee4a0fe462773cc11a9f79a7c665afaed2b17 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 11 May 2024 01:44:21 +0300 Subject: [PATCH 5/9] chore: update workflow --- .github/workflows/go.yml | 2 +- README.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 906b9e8..462bdd1 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -32,7 +32,7 @@ jobs: run: go build -v ./... - name: Lint - uses: golangci/golangci-lint-action@v3.3.1 + uses: golangci/golangci-lint-action@v6 with: version: latest args: --timeout 5m diff --git a/README.md b/README.md index b413ad6..20d7430 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ # qr-coder +[![codecov](https://codecov.io/gh/etilite/qr-coder/graph/badge.svg?token=A70ZRV50JV)](https://codecov.io/gh/etilite/qr-coder) + Microservice to generate QR-codes in png format. Text content is encoded in UTF-8. From 83e73b25111db8b2019daef1ef46ea42a5c94cba Mon Sep 17 00:00:00 2001 From: Alexander Date: Sun, 12 May 2024 19:16:15 +0300 Subject: [PATCH 6/9] chore: refactor and add more tests --- README.md | 3 +- internal/coder/coder.go | 24 +----- internal/coder/coder_test.go | 113 +++++++++++++------------ internal/delivery/http/handler.go | 22 +++-- internal/delivery/http/handler_test.go | 31 +++++-- internal/delivery/http/mux.go | 2 +- internal/delivery/http/mux_test.go | 38 +++++++++ internal/model/qrcode.go | 23 +++++ 8 files changed, 167 insertions(+), 89 deletions(-) create mode 100644 internal/delivery/http/mux_test.go create mode 100644 internal/model/qrcode.go diff --git a/README.md b/README.md index 20d7430..7615727 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ [![codecov](https://codecov.io/gh/etilite/qr-coder/graph/badge.svg?token=A70ZRV50JV)](https://codecov.io/gh/etilite/qr-coder) Microservice to generate QR-codes in png format. -Text content is encoded in UTF-8. + +By default, `qr-coder` adds to content `UTF-8 BOM` prefix for better unicode-compatibility with scanners. ## Usage ### Build project diff --git a/internal/coder/coder.go b/internal/coder/coder.go index f8933da..a63896c 100644 --- a/internal/coder/coder.go +++ b/internal/coder/coder.go @@ -3,38 +3,18 @@ package coder import ( "fmt" + "github.com/etilite/qr-coder/internal/model" "github.com/skip2/go-qrcode" ) const ( - minSize = 32 - maxSize = 1024 utf8BOM = "\ufeff" ) -type QRCode struct { - Content string `json:"content"` - Size int `json:"size"` -} - -func (code *QRCode) Generate() ([]byte, error) { - if err := code.Validate(); err != nil { - return nil, fmt.Errorf("QR Code validation error: %v", err) - } - +func Encode(code model.QRCode) ([]byte, error) { qrCode, err := qrcode.Encode(utf8BOM+code.Content, qrcode.Medium, code.Size) if err != nil { return nil, fmt.Errorf("could not generate a QR code: %v", err) } return qrCode, nil } - -func (code *QRCode) Validate() error { - if code.Content == "" { - return fmt.Errorf("content is empty") - } - if code.Size < minSize || code.Size > maxSize { - return fmt.Errorf("invalid size: %d, must be greater than %d and less than %d", code.Size, minSize, maxSize) - } - return nil -} diff --git a/internal/coder/coder_test.go b/internal/coder/coder_test.go index a847f4f..b6a27d3 100644 --- a/internal/coder/coder_test.go +++ b/internal/coder/coder_test.go @@ -3,70 +3,75 @@ package coder import ( "testing" + "github.com/etilite/qr-coder/internal/model" "github.com/stretchr/testify/assert" ) -func TestQRCode_ValidateSuccess(t *testing.T) { +func TestQRCode_Generate(t *testing.T) { t.Parallel() - code := &QRCode{ - Content: "test", - Size: 32, - } + t.Run("success", func(t *testing.T) { + t.Parallel() - err := code.Validate() - assert.NoError(t, err) -} - -func TestQRCode_ValidateErr(t *testing.T) { - t.Parallel() + code := model.QRCode{ + Content: "test", + Size: 32, + } - type fields struct { - content string - size int - } - tests := map[string]struct { - fields fields - want string - }{ - "empty content": { - fields: fields{ - content: "", - size: 32, - }, - want: "content is empty", - }, - "size is small": { - fields: fields{ - content: "test", - size: -1, - }, - want: "invalid size: -1, must be greater than 32 and less than 1024", - }, - "size is big": { - fields: fields{ - content: "test", - size: 1025, - }, - want: "invalid size: 1025, must be greater than 32 and less than 1024", - }, - } + got, err := Encode(code) - for name, tt := range tests { - tt := tt + assert.NoError(t, err) + assert.NotEmpty(t, got) + }) - t.Run(name, func(t *testing.T) { - t.Parallel() + t.Run("encode error", func(t *testing.T) { + t.Parallel() - code := &QRCode{ - Content: tt.fields.content, - Size: tt.fields.size, - } + code := model.QRCode{ + Content: tooBigContent, + Size: 32, + } - err := code.Validate() + got, err := Encode(code) - assert.Error(t, err) - assert.ErrorContains(t, err, tt.want) - }) - } + assert.Error(t, err) + assert.Empty(t, got) + }) } + +const tooBigContent = `this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_ +this_content_is_too_big_this_content_is_too_big_this_content_is_too_big_` diff --git a/internal/delivery/http/handler.go b/internal/delivery/http/handler.go index 5493244..21acf34 100644 --- a/internal/delivery/http/handler.go +++ b/internal/delivery/http/handler.go @@ -7,9 +7,15 @@ import ( "net/http" "github.com/etilite/qr-coder/internal/coder" + "github.com/etilite/qr-coder/internal/model" ) type QRCodeHandler struct { + encode func(code model.QRCode) ([]byte, error) +} + +func NewQRCodeHandler() *QRCodeHandler { + return &QRCodeHandler{encode: coder.Encode} } func (h *QRCodeHandler) handle() http.HandlerFunc { @@ -20,7 +26,7 @@ func (h *QRCodeHandler) handle() http.HandlerFunc { } }() - qrCode := coder.QRCode{} + qrCode := model.QRCode{} if err := json.NewDecoder(r.Body).Decode(&qrCode); err != nil { err = fmt.Errorf("failed to decode JSON: %w", err) @@ -29,7 +35,14 @@ func (h *QRCodeHandler) handle() http.HandlerFunc { return } - image, err := qrCode.Generate() + if err := qrCode.Validate(); err != nil { + err = fmt.Errorf("failed to validate QR Code: %v", err) + log.Print(err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + image, err := h.encode(qrCode) if err != nil { err = fmt.Errorf("failed to generate QR-code: %w", err) log.Print(err) @@ -39,10 +52,7 @@ func (h *QRCodeHandler) handle() http.HandlerFunc { w.Header().Set("Content-Type", "image/png") if _, err := w.Write(image); err != nil { - err = fmt.Errorf("failed to decode JSON: %w", err) - log.Print(err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return + log.Print(fmt.Errorf("failed to write response: %w", err)) } } } diff --git a/internal/delivery/http/handler_test.go b/internal/delivery/http/handler_test.go index 999d075..0601e38 100644 --- a/internal/delivery/http/handler_test.go +++ b/internal/delivery/http/handler_test.go @@ -1,11 +1,13 @@ package http import ( + "errors" "net/http" "net/http/httptest" "strings" "testing" + "github.com/etilite/qr-coder/internal/model" "github.com/stretchr/testify/assert" ) @@ -15,7 +17,7 @@ func TestQRCodeHandler_handle(t *testing.T) { t.Run("success", func(t *testing.T) { t.Parallel() - handler := &QRCodeHandler{} + handler := NewQRCodeHandler() handleFunc := handler.handle() requestBody := strings.NewReader(`{"size": 32, "content": "test"}`) @@ -32,7 +34,7 @@ func TestQRCodeHandler_handle(t *testing.T) { t.Run("bad request", func(t *testing.T) { t.Parallel() - handler := &QRCodeHandler{} + handler := NewQRCodeHandler() handleFunc := handler.handle() requestBody := strings.NewReader("") @@ -45,19 +47,38 @@ func TestQRCodeHandler_handle(t *testing.T) { assert.Contains(t, response.Body.String(), "failed to decode JSON: EOF") }) + t.Run("validation error", func(t *testing.T) { + t.Parallel() + + handler := NewQRCodeHandler() + handleFunc := handler.handle() + + requestBody := strings.NewReader(`{"size": 32, "content": ""}`) + request := httptest.NewRequest(http.MethodPost, "/generate", requestBody) + response := httptest.NewRecorder() + + handleFunc(response, request) + + assert.Equal(t, http.StatusBadRequest, response.Code) + assert.Contains(t, response.Body.String(), "failed to validate QR Code: content is empty") + }) + t.Run("internal server error", func(t *testing.T) { t.Parallel() - handler := &QRCodeHandler{} + handler := NewQRCodeHandler() + handler.encode = func(code model.QRCode) ([]byte, error) { + return nil, errors.New("encode error") + } handleFunc := handler.handle() - requestBody := strings.NewReader(`{"size": 1000000, "content": ""}`) + requestBody := strings.NewReader(`{"size": 32, "content": "test"}`) request := httptest.NewRequest(http.MethodPost, "/generate", requestBody) response := httptest.NewRecorder() handleFunc(response, request) assert.Equal(t, http.StatusInternalServerError, response.Code) - assert.Contains(t, response.Body.String(), "failed to generate QR-code: QR Code validation error: content is empty") + assert.Contains(t, response.Body.String(), "failed to generate QR-code: encode error") }) } diff --git a/internal/delivery/http/mux.go b/internal/delivery/http/mux.go index ef318d3..4826037 100644 --- a/internal/delivery/http/mux.go +++ b/internal/delivery/http/mux.go @@ -6,7 +6,7 @@ import ( func newMux() *http.ServeMux { mux := http.NewServeMux() - handler := &QRCodeHandler{} + handler := NewQRCodeHandler() mux.Handle("/generate", handler.handle()) diff --git a/internal/delivery/http/mux_test.go b/internal/delivery/http/mux_test.go new file mode 100644 index 0000000..ad40407 --- /dev/null +++ b/internal/delivery/http/mux_test.go @@ -0,0 +1,38 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewMux(t *testing.T) { + t.Parallel() + tests := map[string]struct { + path string + }{ + "generate": { + path: "/generate", + }, + } + for name, tt := range tests { + tt := tt + + t.Run(name, func(t *testing.T) { + t.Parallel() + + mux := newMux() + + requestBody := strings.NewReader(`{"size": 32, "content": "test"}`) + request := httptest.NewRequest(http.MethodPost, tt.path, requestBody) + response := httptest.NewRecorder() + + mux.ServeHTTP(response, request) + + assert.Equal(t, http.StatusOK, response.Code) + }) + } +} diff --git a/internal/model/qrcode.go b/internal/model/qrcode.go new file mode 100644 index 0000000..c7126b8 --- /dev/null +++ b/internal/model/qrcode.go @@ -0,0 +1,23 @@ +package model + +import "fmt" + +const ( + minSize = 32 + maxSize = 1024 +) + +type QRCode struct { + Content string `json:"content"` + Size int `json:"size"` +} + +func (code *QRCode) Validate() error { + if code.Content == "" { + return fmt.Errorf("content is empty") + } + if code.Size < minSize || code.Size > maxSize { + return fmt.Errorf("invalid size: %d, must be at least %d and at most %d", code.Size, minSize, maxSize) + } + return nil +} From aeb47005b00f601f41e3a7fd6aad35f0b05c4722 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sun, 12 May 2024 19:17:49 +0300 Subject: [PATCH 7/9] chore: add qrcode test --- internal/model/qrcode_test.go | 72 +++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 internal/model/qrcode_test.go diff --git a/internal/model/qrcode_test.go b/internal/model/qrcode_test.go new file mode 100644 index 0000000..27245b4 --- /dev/null +++ b/internal/model/qrcode_test.go @@ -0,0 +1,72 @@ +package model + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestQRCode_ValidateSuccess(t *testing.T) { + t.Parallel() + + code := &QRCode{ + Content: "test", + Size: 32, + } + + err := code.Validate() + assert.NoError(t, err) +} + +func TestQRCode_ValidateErr(t *testing.T) { + t.Parallel() + + type fields struct { + content string + size int + } + tests := map[string]struct { + fields fields + want string + }{ + "empty content": { + fields: fields{ + content: "", + size: 32, + }, + want: "content is empty", + }, + "size is small": { + fields: fields{ + content: "test", + size: -1, + }, + want: "invalid size: -1, must be at least 32 and at most 1024", + }, + "size is big": { + fields: fields{ + content: "test", + size: 1025, + }, + want: "invalid size: 1025, must be at least 32 and at most 1024", + }, + } + + for name, tt := range tests { + tt := tt + + t.Run(name, func(t *testing.T) { + t.Parallel() + + code := &QRCode{ + Content: tt.fields.content, + Size: tt.fields.size, + } + + err := code.Validate() + + assert.Error(t, err) + assert.ErrorContains(t, err, tt.want) + }) + } +} From 7f417043b634bd9deb36c9f191e02d7c45518e35 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 18 May 2024 21:09:26 +0300 Subject: [PATCH 8/9] chore: simplify app --- cmd/qr-coder/main.go | 48 ++-------------- internal/app/app.go | 44 --------------- internal/delivery/http/handler.go | 16 +++--- internal/delivery/http/mux.go | 2 +- internal/delivery/http/mux_test.go | 8 ++- internal/delivery/http/server.go | 40 ++++++++++---- internal/delivery/http/server_test.go | 80 +++++++++++++++++++++++++++ 7 files changed, 128 insertions(+), 110 deletions(-) delete mode 100644 internal/app/app.go create mode 100644 internal/delivery/http/server_test.go diff --git a/cmd/qr-coder/main.go b/cmd/qr-coder/main.go index 6002830..74b972d 100644 --- a/cmd/qr-coder/main.go +++ b/cmd/qr-coder/main.go @@ -1,63 +1,25 @@ package main import ( + "context" "log/slog" - "os" "os/signal" "syscall" - "time" "github.com/etilite/qr-coder/internal/app" httpserver "github.com/etilite/qr-coder/internal/delivery/http" ) -const ( - shutdownTime time.Duration = 10 * time.Second -) - func main() { - cfg := app.NewConfigFromEnv() + ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer done() - a := app.New(cfg) + cfg := app.NewConfigFromEnv() server := httpserver.NewServer(cfg.HTTPAddr) - if err := a.Run(server); err != nil { + if err := server.Run(ctx); err != nil { slog.Error("unable to start app", "error", err) panic(err) } - - shutdown := make(chan bool) - gracefulStop(shutdown) - <-shutdown - slog.Info("signal caught, shutting down") - - go aggressiveStop() - - if err := a.Stop(); err != nil { - slog.Error("unable to stop app", "error", err) - panic(err) - } -} - -func gracefulStop(shutdown chan<- bool) { - c := make(chan os.Signal, 1) - signal.Notify(c, - syscall.SIGHUP, - syscall.SIGINT, - syscall.SIGTERM, - syscall.SIGQUIT) - - go func() { - <-c - shutdown <- true - }() -} - -func aggressiveStop() { - ticker := time.NewTicker(shutdownTime) - <-ticker.C - - slog.Warn("application is aggressive stopped") - os.Exit(0) } diff --git a/internal/app/app.go b/internal/app/app.go deleted file mode 100644 index cbb6ced..0000000 --- a/internal/app/app.go +++ /dev/null @@ -1,44 +0,0 @@ -package app - -import ( - "log/slog" -) - -type service interface { - Run() error - Stop() error -} - -type App struct { - logger *slog.Logger - services []service -} - -func New(cfg Config) *App { - return &App{ - logger: slog.Default(), - } -} - -func (app *App) Run(services ...service) error { - for _, srv := range services { - if err := srv.Run(); err != nil { - return err - } - app.services = append(app.services, srv) - } - app.logger.Info("app started") - - return nil -} - -func (app *App) Stop() error { - for _, srv := range app.services { - if err := srv.Stop(); err != nil { - return err - } - } - app.logger.Info("app stopped") - - return nil -} diff --git a/internal/delivery/http/handler.go b/internal/delivery/http/handler.go index 21acf34..0f89553 100644 --- a/internal/delivery/http/handler.go +++ b/internal/delivery/http/handler.go @@ -3,7 +3,7 @@ package http import ( "encoding/json" "fmt" - "log" + "log/slog" "net/http" "github.com/etilite/qr-coder/internal/coder" @@ -22,37 +22,37 @@ func (h *QRCodeHandler) handle() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { defer func() { if err := r.Body.Close(); err != nil { - log.Printf("failed to close request body %v", err) + slog.Error("handler: failed to close request body", "error", err) } }() qrCode := model.QRCode{} if err := json.NewDecoder(r.Body).Decode(&qrCode); err != nil { - err = fmt.Errorf("failed to decode JSON: %w", err) - log.Print(err) + err = fmt.Errorf("failed to decode JSON: %v", err) + slog.Error("handler: bad request", "error", err) http.Error(w, err.Error(), http.StatusBadRequest) return } if err := qrCode.Validate(); err != nil { err = fmt.Errorf("failed to validate QR Code: %v", err) - log.Print(err) + slog.Error("handler: bad request", "error", err) http.Error(w, err.Error(), http.StatusBadRequest) return } image, err := h.encode(qrCode) if err != nil { - err = fmt.Errorf("failed to generate QR-code: %w", err) - log.Print(err) + err = fmt.Errorf("failed to generate QR-code: %v", err) + slog.Error("handler: internal error", "error", err) http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "image/png") if _, err := w.Write(image); err != nil { - log.Print(fmt.Errorf("failed to write response: %w", err)) + slog.Error("handler: failed to write response", "error", err) } } } diff --git a/internal/delivery/http/mux.go b/internal/delivery/http/mux.go index 4826037..5c8303a 100644 --- a/internal/delivery/http/mux.go +++ b/internal/delivery/http/mux.go @@ -8,7 +8,7 @@ func newMux() *http.ServeMux { mux := http.NewServeMux() handler := NewQRCodeHandler() - mux.Handle("/generate", handler.handle()) + mux.Handle("POST /generate", handler.handle()) return mux } diff --git a/internal/delivery/http/mux_test.go b/internal/delivery/http/mux_test.go index ad40407..fabe13c 100644 --- a/internal/delivery/http/mux_test.go +++ b/internal/delivery/http/mux_test.go @@ -12,10 +12,12 @@ import ( func TestNewMux(t *testing.T) { t.Parallel() tests := map[string]struct { - path string + method string + path string }{ "generate": { - path: "/generate", + method: http.MethodPost, + path: "/generate", }, } for name, tt := range tests { @@ -27,7 +29,7 @@ func TestNewMux(t *testing.T) { mux := newMux() requestBody := strings.NewReader(`{"size": 32, "content": "test"}`) - request := httptest.NewRequest(http.MethodPost, tt.path, requestBody) + request := httptest.NewRequest(tt.method, tt.path, requestBody) response := httptest.NewRecorder() mux.ServeHTTP(response, request) diff --git a/internal/delivery/http/server.go b/internal/delivery/http/server.go index 10a50d4..73efc38 100644 --- a/internal/delivery/http/server.go +++ b/internal/delivery/http/server.go @@ -8,8 +8,14 @@ import ( "time" ) +type httpServer interface { + ListenAndServe() error + Shutdown(ctx context.Context) error +} + type Server struct { - http *http.Server + http httpServer + addr string } func NewServer(addr string) *Server { @@ -18,30 +24,42 @@ func NewServer(addr string) *Server { Addr: addr, Handler: newMux(), }, + addr: addr, } } -func (s *Server) Run() error { - slog.Info("http-server: starting web server", "address", s.http.Addr) - +func (s *Server) Run(ctx context.Context) error { + errCh := make(chan error, 1) go func() { - if err := s.http.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - slog.Error("http-server: failed to serve", "error", err) - } + <-ctx.Done() - slog.Info("http-server: stopped gracefully") + errCh <- s.shutdown() }() + slog.Info("http-server: starting web server", "address", s.addr) + + if err := s.http.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("http-server: failed to serve", "error", err) + return err + } + + slog.Info("http-server: shutting down") + + if err := <-errCh; err != nil { + slog.Error("http-server: error stopping server", "error", err) + return err + } + + slog.Info("http-server: stopped gracefully") + return nil } -func (s *Server) Stop() error { +func (s *Server) shutdown() error { shutdownCtx, done := context.WithTimeout(context.Background(), 5*time.Second) defer done() - slog.Info("http-server: shutting down") if err := s.http.Shutdown(shutdownCtx); err != nil { - slog.Error("http-server: shutdown failed", "error", err) return err } diff --git a/internal/delivery/http/server_test.go b/internal/delivery/http/server_test.go new file mode 100644 index 0000000..08a8796 --- /dev/null +++ b/internal/delivery/http/server_test.go @@ -0,0 +1,80 @@ +package http + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestServer(t *testing.T) { + t.Parallel() + + t.Run("success run and shutdown", func(t *testing.T) { + server := NewServer(":12345") + server.http = &mockHttpServer{} + + ctx, cancel := context.WithTimeout(context.Background(), 32*time.Millisecond) + defer cancel() + + err := server.Run(ctx) + + assert.NoError(t, err) + }) + + t.Run("error run", func(t *testing.T) { + server := NewServer(":12345") + server.http = &mockHttpServer{ + listenErr: true, + } + + err := server.Run(context.Background()) + + assert.Error(t, err) + assert.ErrorContains(t, err, "listen error") + }) + + t.Run("error shutdown", func(t *testing.T) { + server := NewServer(":12345") + server.http = &mockHttpServer{ + shutdownErr: true, + } + + ctx, cancel := context.WithTimeout(context.Background(), 32*time.Millisecond) + defer cancel() + + err := server.Run(ctx) + + assert.Error(t, err) + assert.ErrorContains(t, err, "shutdown error") + }) + +} + +type mockHttpServer struct { + ch chan bool + listenErr bool + shutdownErr bool +} + +func (s *mockHttpServer) ListenAndServe() error { + if s.listenErr { + return errors.New("listen error") + } + + s.ch = make(chan bool) + <-s.ch + + return nil +} +func (s *mockHttpServer) Shutdown(_ context.Context) error { + s.ch <- true + + if s.shutdownErr { + return errors.New("shutdown error") + } + + return nil +} From bc8ec51282f34333542c0c9bf663be95519bfe04 Mon Sep 17 00:00:00 2001 From: Alexander Date: Sat, 18 May 2024 21:27:32 +0300 Subject: [PATCH 9/9] chore: fix race in mock --- internal/delivery/http/server_test.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/internal/delivery/http/server_test.go b/internal/delivery/http/server_test.go index 08a8796..6c2e14b 100644 --- a/internal/delivery/http/server_test.go +++ b/internal/delivery/http/server_test.go @@ -14,7 +14,7 @@ func TestServer(t *testing.T) { t.Run("success run and shutdown", func(t *testing.T) { server := NewServer(":12345") - server.http = &mockHttpServer{} + server.http = newHttpServerMock(false, false) ctx, cancel := context.WithTimeout(context.Background(), 32*time.Millisecond) defer cancel() @@ -26,9 +26,7 @@ func TestServer(t *testing.T) { t.Run("error run", func(t *testing.T) { server := NewServer(":12345") - server.http = &mockHttpServer{ - listenErr: true, - } + server.http = newHttpServerMock(true, false) err := server.Run(context.Background()) @@ -38,9 +36,7 @@ func TestServer(t *testing.T) { t.Run("error shutdown", func(t *testing.T) { server := NewServer(":12345") - server.http = &mockHttpServer{ - shutdownErr: true, - } + server.http = newHttpServerMock(false, true) ctx, cancel := context.WithTimeout(context.Background(), 32*time.Millisecond) defer cancel() @@ -53,6 +49,14 @@ func TestServer(t *testing.T) { } +func newHttpServerMock(listenErr, shutdownErr bool) *mockHttpServer { + return &mockHttpServer{ + ch: make(chan bool), + listenErr: listenErr, + shutdownErr: shutdownErr, + } +} + type mockHttpServer struct { ch chan bool listenErr bool @@ -64,7 +68,6 @@ func (s *mockHttpServer) ListenAndServe() error { return errors.New("listen error") } - s.ch = make(chan bool) <-s.ch return nil