Writing a Slack Bot for Scrum Poker in Go

Hello! Today we will write a Slack bot for Scrum poker in Go language. We will write, if possible, without frameworks and external libraries, since our goal is to understand the Go programming language and check how convenient this language is for developing such projects.





Disclaimer

Go . Python. , Python - . , , , " " Go, .





, , ( ), "" . .





, !





, , — .





. (web), Slack UI Block Kit (ui), / (storage), (config). :





config/
storage/
ui/
web/
-- clients/
-- server/
main.go
      
      



http



. Server



web -> server



:





server.go
package server

import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"sync/atomic"
	"time"
)

type Server struct {
  //                main.go
	healthy        int32
	logger         *log.Logger
}

func NewServer(logger *log.Logger) *Server {
	return &Server{
		logger: logger,
	}
}

      
      



. . , main.go



, . . . . . , . :





server.go
func (s *Server) setupRouter() http.Handler {  // TODO
	router := http.NewServeMux()
  return router
}

func (s *Server) Serve(address string) {
	server := &http.Server{
		Addr:         address,
    Handler:      s.setupRouter(),
		ErrorLog:     s.logger, //  
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
		IdleTimeout:  15 * time.Second,
	}

  //      
	done := make(chan bool)
	quit := make(chan os.Signal, 1)
  //      
	signal.Notify(quit, os.Interrupt)

	go func() {
		<-quit
		s.logger.Println("Server is shutting down...")
    //     healthcheck' 
		atomic.StoreInt32(&s.healthy, 0)

    //   30     ,     
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()

    //    ,      
		server.SetKeepAlivesEnabled(false)
    //  
		if err := server.Shutdown(ctx); err != nil {
			s.logger.Fatalf("Could not gracefully shutdown the server: %v\n", err)
		}
		close(done)
	}()

	s.logger.Println("Server is ready to handle requests at", address)
  //    ,      
	atomic.StoreInt32(&s.healthy, 1)
  //  
	if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		s.logger.Fatalf("Could not listen on %s: %v\n", address, err)
	}

  //      ,      
	<-done
	s.logger.Println("Server stopped")
}

      
      



. web -> server -> handlers



:





healthcheck.go
package handlers

import (
	"net/http"
)

func Healthcheck() http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write("OK")
	})
}

      
      



:





server.go
//   

func (s *Server) setupRouter() http.Handler {
	router := http.NewServeMux()
	router.Handle(
		"/healthcheck",
		handlers.Healthcheck(),
	)
  return router
}

//   
      
      



main.go



:





package main

import (
	"log"
  "os"
  "go-scrum-poker-bot/web/server"
)

func main() {
  //        "INFO:". 
  //      stdout
	logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)
	app := server.NewServer(logger)

	app.Serve(":8000")
}
      
      



:





go run main.go
      
      



, :8000



. . , . ;) , , Slack .





NGROK

, , ngrok. , . Slack . , , :





ngrok http 8000
      
      



, - :





ngrok by @inconshreveable                                                                                                            (Ctrl+C to quit)
                                                                                                                                                     
Session Status                online                                                                                                                 
Account                       Sayakhov Ilya (Plan: Free)                                                                                             
Version                       2.3.35                                                                                                                 
Region                        United States (us)                                                                                                     
Web Interface                 http://127.0.0.1:4040                                                                                                  
Forwarding                    http://ffd3cfcc460c.ngrok.io -> http://localhost:8000                                                                  
Forwarding                    https://ffd3cfcc460c.ngrok.io -> http://localhost:8000                                                                 
                                                                                                                                                     
Connections                   ttl     opn     rt1     rt5     p50     p90                                                                            
                              0       0       0.00    0.00    0.00    0.00     
      
      



https://ffd3cfcc460c.ngrok.io



. .





Slash commands

Slack. -> Create New App



. GoScrumPokerBot



Workspace



. , . OAuth & Permissions -> Scopes



: chat:write



, commands



. , , slash . Reinstall to Workspace



. ! Slash commands



/poker



.





Request URL + . : https://ffd3cfcc460c.ngrok.io/play-poker



.





Slash command handler

. web -> server -> handlers



play_poker.go



:





func PlayPokerCommand() http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.Write([]byte(`{"response_type": "ephemeral", "text": "Hello world!"}`))
	})
}
      
      



:





server.go
func (s *Server) setupRouter() http.Handler {
	router := http.NewServeMux()
	router.Handle(
		"/healthcheck",
		handlers.Healthcheck(),
	)
	router.Handle(
		"/play-poker",
		handlers.PlayPokerCommand(),
	)
  return router
}
      
      



Slack : /poker



. - :





Slack. . . ( ). http



. web -> clients



. client.go



:





client.go
package clients

//      
type Handler func(request *Request) *Response

//     middleware (   )
type Middleware func(handler Handler, request *Request) Handler

//   http 
type Client interface {
	Make(request *Request) *Response
}

//   
type BasicClient struct {
	client     *http.Client
	middleware []Middleware
}

func NewBasicClient(client *http.Client, middleware []Middleware) Client {
	return &BasicClient{client: client, middleware: middleware}
}

//      
func (c *BasicClient) makeRequest(request *Request) *Response {
	payload, err := request.ToBytes() // TODO
	if err != nil {
		return &Response{Error: err}
	}

  //   request,    
	req, err := http.NewRequest(request.Method, request.URL, bytes.NewBuffer(payload))
	if err != nil {
		return &Response{Error: err}
	}

  //  
	for name, value := range request.Headers {
		req.Header.Add(name, value)
	}

  //  
	resp, err := c.client.Do(req)
	if err != nil {
		return &Response{Error: err}
	}
	defer resp.Body.Close()

  //   
	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return &Response{Error: err}
	}

	err = nil
  //   -     20x,  
	if resp.StatusCode > http.StatusIMUsed || resp.StatusCode < http.StatusOK {
		err = fmt.Errorf("Bad response. Status: %d, Body: %s", resp.StatusCode, string(body))
	}

	return &Response{
		Status:  resp.StatusCode,
		Body:    body,
		Headers: resp.Header,
		Error:   err,
	}
}

//     
func (c *BasicClient) Make(request *Request) *Response {
	if request.Headers == nil {
		request.Headers = make(map[string]string)
	}
  
  //  middleware
	handler := c.makeRequest
	for _, middleware := range c.middleware {
		handler = middleware(handler, request)
	}

	return handler(request)
}

      
      



web -> clients



:





request.go
package clients

import "encoding/json"

type Request struct {
	URL     string
	Method  string
	Headers map[string]string
	Json    interface{}
}

func (r *Request) ToBytes() ([]byte, error) {
	if r.Json != nil {
		result, err := json.Marshal(r.Json)
		if err != nil {
			return []byte{}, err
		}
		return result, nil
	}

	return []byte{}, nil
}

      
      



ToBytes()



. testify/assert, if', :) . , pytest



assert



, - :





request_test.go
package clients_test

import (
	"encoding/json"
	"go-scrum-poker-bot/web/clients"
	"reflect"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestRequestToBytes(t *testing.T) {
  //    -  pytest.parametrize (,   Go    ,    )
	testCases := []struct {
		json interface{}
		data []byte
		err  error
	}{
		{map[string]string{"test_key": "test_value"}, []byte("{\"test_key\":\"test_value\"}"), nil},
		{nil, []byte{}, nil},
		{make(chan int), []byte{}, &json.UnsupportedTypeError{Type: reflect.TypeOf(make(chan int))}},
	}

  //     
	for _, testCase := range testCases {
		request := clients.Request{
			URL:     "https://example.com",
			Method:  "GET",
			Headers: nil,
			Json:    testCase.json,
		}

		actual, err := request.ToBytes()

    //  
		assert.Equal(t, testCase.err, err)
		assert.Equal(t, testCase.data, actual)
	}
}

      
      



web -> clients:







response.go
package clients

import "encoding/json"

type Response struct {
	Status  int
	Headers map[string][]string
	Body    []byte
	Error   error
}

//     ,                if err != nil
func (r *Response) Json(to interface{}) error {
	if r.Error != nil {
		return r.Error
	}
	return json.Unmarshal(r.Body, to)
}

      
      



, Json(to interface{})



:





response_test.go
package clients_test

import (
	"errors"
	"go-scrum-poker-bot/web/clients"
	"testing"

	"github.com/stretchr/testify/assert"
)

//     
func TestResponseJson(t *testing.T) {
	to := struct {
		TestKey string `json:"test_key"`
	}{}
	response := clients.Response{
		Status:  200,
		Headers: nil,
		Body:    []byte(`{"test_key": "test_value"}`),
		Error:   nil,
	}

	err := response.Json(&to)

	assert.Equal(t, nil, err)
	assert.Equal(t, "test_value", to.TestKey)
}

//    
func TestResponseJsonError(t *testing.T) {
	expectedErr := errors.New("Error!")
	response := clients.Response{
		Status:  200,
		Headers: nil,
		Body:    nil,
		Error:   expectedErr,
	}

	err := response.Json(map[string]string{})

	assert.Equal(t, expectedErr, err)
}

      
      



, , . http



. http



. , :





client_test.go
package clients_test

import (
	"bytes"
	"go-scrum-poker-bot/web/clients"
	"io/ioutil"
	"net/http"
	"testing"

	"github.com/stretchr/testify/assert"
)

//     
type RoundTripFunc func(request *http.Request) *http.Response

func (f RoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
	return f(request), nil
}

//  mock  
func NewTestClient(fn RoundTripFunc) *http.Client {
	return &http.Client{
		Transport: RoundTripFunc(fn),
	}
}

//  
func TestMakeRequest(t *testing.T) {
	url := "https://example.com/ok"

  //  mock      
	httpClient := NewTestClient(func(req *http.Request) *http.Response {
		assert.Equal(t, req.URL.String(), url)

		return &http.Response{
			StatusCode: http.StatusOK,
			Body:       ioutil.NopCloser(bytes.NewBufferString("OK")),
			Header:     make(http.Header),
		}
	})

  //   http    http 
	webClient := clients.NewBasicClient(httpClient, nil)
	response := webClient.Make(&clients.Request{
		URL:     url,
		Method:  "GET",
		Headers: map[string]string{"Content-Type": "application/json"},
		Json:    nil,
	})

	assert.Equal(t, http.StatusOK, response.Status)
}

//    response
func TestMakeRequestError(t *testing.T) {
	url := "https://example.com/error"

	httpClient := NewTestClient(func(req *http.Request) *http.Response {
		assert.Equal(t, req.URL.String(), url)

		return &http.Response{
			StatusCode: http.StatusBadGateway,
			Body:       ioutil.NopCloser(bytes.NewBufferString("Bad gateway")),
			Header:     make(http.Header),
		}
	})

	webClient := clients.NewBasicClient(httpClient, nil)
	response := webClient.Make(&clients.Request{
		URL:     url,
		Method:  "GET",
		Headers: map[string]string{"Content-Type": "application/json"},
		Json:    nil,
	})

	assert.Equal(t, http.StatusBadGateway, response.Status)
}

      
      



! middleware



. , , middleware



. / API / . Slack Authorization



, OAuth & Permissions



. web -> clients -> middleware



:





auth.go
package middleware

import (
	"fmt"
	"go-scrum-poker-bot/web/clients"
)

//      middleware    
func Auth(token string) clients.Middleware {
	return func(handler clients.Handler, request *clients.Request) clients.Handler {
		return func(request *clients.Request) *clients.Response {
			request.Headers["Authorization"] = fmt.Sprintf("Bearer %s", token)
			return handler(request)
		}
	}
}

      
      



:





auth_test.go
package middleware_test

import (
	"fmt"
	"go-scrum-poker-bot/web/clients"
	"go-scrum-poker-bot/web/clients/middleware"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestAuthMiddleware(t *testing.T) {
	token := "test"
	request := &clients.Request{
		Headers: map[string]string{},
	}
	handler := middleware.Auth(token)(
		func(request *clients.Request) *clients.Response {
			return &clients.Response{}
		},
		request,
	)
	handler(request)

	assert.Equal(t, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", token)}, request.Headers)
}

      
      



middleware



Content-Type: application/json



. :).





PlayPoker



:





play_poker.go
package handlers

import (
	"errors"
	"go-scrum-poker-bot/ui"
	"go-scrum-poker-bot/web/clients"
	"go-scrum-poker-bot/web/server/models"
	"net/http"

	"github.com/google/uuid"
)

func PlayPokerCommand(webClient clients.Client, uiBuilder *ui.Builder) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    //  ,      POST Form    ID 
		if r.PostFormValue("channel_id") == "" || r.PostFormValue("text") == "" {
			w.Write(models.ResponseError(errors.New("Please write correct subject"))) // TODO
			return
		}

		resp := webClient.Make(&clients.Request{
			URL:    "https://slack.com/api/chat.postMessage",
			Method: "POST",
      Json: uiBuilder.Build( // TODO:  builder 
				r.PostFormValue("channel_id"),
				uuid.New().String(),
				r.PostFormValue("text"),
				nil,
				false,
			),
		})
		if resp.Error != nil {
			w.Write(models.ResponseError(resp.Error)) // TODO
			return
		}
	})
}

      
      



web -> server -> models



. errors.go



:





errors.go
package models

import (
	"encoding/json"
	"fmt"
)

type SlackError struct {
	ResponseType string `json:"response_type"`
	Text         string `json:"text"`
}

func ResponseError(err error) []byte {
	resp, err := json.Marshal(
		SlackError{
			ResponseType: "ephemeral",
			Text:         fmt.Sprintf("Sorry, there is some error happened. Error: %s", err.Error()),
		},
	)
	if err != nil {
		return []byte("Sorry. Some error happened")
	}
	return resp
}

      
      



:





play_poker_test.go
package handlers_test

import (
	"errors"
	"go-scrum-poker-bot/config"
	"go-scrum-poker-bot/ui"
	"go-scrum-poker-bot/web/server/handlers"
	"go-scrum-poker-bot/web/server/models"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestPlayPokerHandler(t *testing.T) {
	config := config.NewConfig() // TODO
	mockClient := &MockClient{}
	uiBuilder := ui.NewBuilder(config) // TODO

	responseRec := httptest.NewRecorder()

	router := http.NewServeMux()
	router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder))

	payload := url.Values{"channel_id": {"test"}, "text": {"test"}}.Encode()
	request, err := http.NewRequest("POST", "/play-poker", strings.NewReader(payload))
	request.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	router.ServeHTTP(responseRec, request)

	assert.Nil(t, err)
	assert.Equal(t, http.StatusOK, responseRec.Code)
	assert.Empty(t, responseRec.Body.String())
	assert.Equal(t, true, mockClient.Called)
}

func TestPlayPokerHandlerEmptyBodyError(t *testing.T) {
	config := config.NewConfig()
	mockClient := &MockClient{}
	uiBuilder := ui.NewBuilder(config)

	responseRec := httptest.NewRecorder()

	router := http.NewServeMux()
	router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder))

	payload := url.Values{}.Encode()
	request, _ := http.NewRequest("POST", "/play-poker", strings.NewReader(payload))
	request.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	router.ServeHTTP(responseRec, request)

	expected := string(models.ResponseError(errors.New("Please write correct subject")))

	assert.Equal(t, http.StatusOK, responseRec.Code)
	assert.Equal(t, expected, responseRec.Body.String())
	assert.Equal(t, false, mockClient.Called)
}

func TestPlayPokerHandlerRequestError(t *testing.T) {
	errMsg := "Error msg"
	config := config.NewConfig() // TODO
	mockClient := &MockClient{Error: errMsg}
	uiBuilder := ui.NewBuilder(config) // TODO

	responseRec := httptest.NewRecorder()

	router := http.NewServeMux()
	router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder))

	payload := url.Values{"channel_id": {"test"}, "text": {"test"}}.Encode()
	request, _ := http.NewRequest("POST", "/play-poker", strings.NewReader(payload))
	request.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	router.ServeHTTP(responseRec, request)

	expected := string(models.ResponseError(errors.New(errMsg)))

	assert.Equal(t, http.StatusOK, responseRec.Code)
	assert.Equal(t, expected, responseRec.Body.String())
	assert.Equal(t, true, mockClient.Called)
}

      
      



mock



http



:





common_test.go
package handlers_test

import (
	"errors"
	"go-scrum-poker-bot/web/clients"
)

type MockClient struct {
	Called bool
	Error  string
}

func (c *MockClient) Make(request *clients.Request) *clients.Response {
	c.Called = true

	var err error = nil
	if c.Error != "" {
		err = errors.New(c.Error)
	}
	return &clients.Response{Error: err}
}
      
      



, PlayPoker



.





UI Slack UI Block Kit. , . , Slack API . UI Builder



ui . , , . , , ( , ) block_id



. action_id



.





. config :





config.go
package config

type Config struct {
	App   *App
	Slack *Slack
	Redis *Redis
}

func NewConfig() *Config {
	return &Config{
		App: &App{
			ServerAddress: getStrEnv("WEB_SERVER_ADDRESS", ":8000"),
			PokerRanks:    getListStrEnv("POKER_RANKS", "?,0,0.5,1,2,3,5,8,13,20,40,100"),
		},
		Slack: &Slack{
			Token: getStrEnv("SLACK_TOKEN", "FILL_ME"),
		},
    //  
		Redis: &Redis{
			Host: getStrEnv("REDIS_HOST", "0.0.0.0"),
			Port: getIntEnv("REDIS_PORT", "6379"),
			DB:   getIntEnv("REDIS_DB", "0"),
		},
	}
}

//    env   default
func getStrEnv(key string, defaultValue string) string {
	if value, ok := os.LookupEnv(key); ok {
		return value
	}
	return defaultValue
}

//  int   env   default
func getIntEnv(key string, defaultValue string) int {
	value, err := strconv.Atoi(getStrEnv(key, defaultValue))
	if err != nil {
		panic(fmt.Sprintf("Incorrect env value for %s", key))
	}

	return value
}

//   (e.g. 0,1,2,3,4,5)  env   default
func getListStrEnv(key string, defaultValue string) []string {
	value := []string{}
	for _, item := range strings.Split(getStrEnv(key, defaultValue), ",") {
		value = append(value, strings.TrimSpace(item))
	}
	return value
}

      
      



. :





config_test.go
package config_test

import (
    "go-scrum-poker-bot/config"
    "os"
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestNewConfig(t *testing.T) {
    c := config.NewConfig()

    assert.Equal(t, "0.0.0.0", c.Redis.Host)
    assert.Equal(t, 6379, c.Redis.Port)
    assert.Equal(t, 0, c.Redis.DB)
    assert.Equal(t, []string{"?", "0", "0.5", "1", "2", "3", "5", "8", "13", "20", "40", "100"}, c.App.PokerRanks)
}

func TestNewConfigIncorrectIntFromEnv(t *testing.T) {
    os.Setenv("REDIS_PORT", "-")

    assert.Panics(t, func() { config.NewConfig() })
}

      
      



, . main.go



:





main.go
package main

import (
	"fmt"
	"go-scrum-poker-bot/config"
	"go-scrum-poker-bot/ui"
	"go-scrum-poker-bot/web/clients"
	clients_middleware "go-scrum-poker-bot/web/clients/middleware"
	"go-scrum-poker-bot/web/server"
  "log"
	"net/http"
	"os"
	"time"
)

func main() {
	logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)
	config := config.NewConfig()
	builder := ui.NewBuilder(config)
	webClient := clients.NewBasicClient(
		&http.Client{
			Timeout: 5 * time.Second,
		},
		[]clients.Middleware{ //  middleware
			clients_middleware.Auth(config.Slack.Token),
			clients_middleware.JsonContentType,
			clients_middleware.Log(logger),
		},
	)

	app := server.NewServer(
		logger,
		webClient,
		builder,
	)
	app.Serve(config.App.ServerAddress)
}

      
      



/poker



.





Slack Interactivity

. Your apps -> -> Interactivity & Shortcuts



. Request URL :





https://ffd3cfcc460c.ngrok.io/interactivity
      
      



InteractionCallback



web -> server -> handlers



:





interaction_callback.go
package handlers

import (
	"go-scrum-poker-bot/storage"
	"go-scrum-poker-bot/ui"
	"go-scrum-poker-bot/ui/blocks"
	"go-scrum-poker-bot/web/clients"
	"go-scrum-poker-bot/web/server/models"
	"net/http"
)

func InteractionCallback(
	userStorage storage.UserStorage,
	sessionStorage storage.SessionStorage,
	uiBuilder *ui.Builder,
	webClient clients.Client,
) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var callback models.Callback
    //   
		data, err := callback.SerializedData([]byte(r.PostFormValue("payload")))
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}

    // TODO:    
		users := userStorage.All(data.SessionID)
		visible := sessionStorage.GetVisibility(data.SessionID)

		err = nil
    //             
		switch data.Action.ActionID {
		case ui.VOTE_ACTION_ID:
			users[callback.User.Username] = data.Action.SelectedOption.Value
			err = userStorage.Save(data.SessionID, callback.User.Username, data.Action.SelectedOption.Value)
		case ui.RESULTS_VISIBILITY_ACTION_ID:
			visible = !visible
			err = sessionStorage.SetVisibility(data.SessionID, visible)
		}
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}

    //       response URL.     
		resp := webClient.Make(&clients.Request{
			URL:    callback.ResponseURL,
			Method: "POST",
			Json: &blocks.Interactive{
				ReplaceOriginal: true,
				Blocks:          uiBuilder.BuildBlocks(data.Subject, users, data.SessionID, visible),
				LinkNames:       true,
			},
		})
		if resp.Error != nil {
			http.Error(w, resp.Error.Error(), http.StatusInternalServerError)
			return
		}
	})
}

      
      



. . storage



:





storage.go
package storage

type UserStorage interface {
	All(sessionID string) map[string]string
	Save(sessionID string, username string, value string) error
}

type SessionStorage interface {
	GetVisibility(sessionID string) bool
	SetVisibility(sessionID string, state bool) error
}

      
      



, , , Redis ( ).





Callback



. web -> server -> models



:





callback.go
package models

import (
	"encoding/json"
	"errors"
	"go-scrum-poker-bot/ui"
)

type User struct {
	Username string `json:"username"`
}

type Text struct {
	Type string `json:"type"`
	Text string `json:"text"`
}

type Block struct {
	Type    string `json:"type"`
	BlockID string `json:"block_id"`
	Text    *Text  `json:"text,omitempty"`
}

type Message struct {
	Blocks []*Block `json:"blocks,omitempty"`
}

type SelectedOption struct {
	Value string `json:"value"`
}

type Action struct {
	BlockID        string          `json:"block_id"`
	ActionID       string          `json:"action_id"`
	Value          string          `json:"value,omitempty"`
	SelectedOption *SelectedOption `json:"selected_option,omitempty"`
}

type SerializedData struct {
	SessionID string
	Subject   string
	Action    *Action
}

type Callback struct {
	ResponseURL string    `json:"response_url"`
	User        *User     `json:"user"`
	Actions     []*Action `json:"actions"`
	Message     *Message  `json:"message,omitempty"`
}

//   ID ,       
func (c *Callback) getSessionID() (string, error) {
	for _, action := range c.Actions {
		if action.BlockID != "" {
			return action.BlockID, nil
		}
	}

	return "", errors.New("Invalid session ID")
}

//   
func (c *Callback) getSubject() (string, error) {
	for _, block := range c.Message.Blocks {
		if block.BlockID == ui.SUBJECT_BLOCK_ID && block.Text != nil {
			return block.Text.Text, nil
		}
	}

	return "", errors.New("Invalid subject")
}

//     
func (c *Callback) getAction() (*Action, error) {
	for _, action := range c.Actions {
		if action.ActionID == ui.VOTE_ACTION_ID || action.ActionID == ui.RESULTS_VISIBILITY_ACTION_ID {
			return action, nil
		}
	}

	return nil, errors.New("Invalid action")
}

func (c *Callback) SerializedData(data []byte) (*SerializedData, error) {
	err := json.Unmarshal(data, c)
	if err != nil {
		return nil, err
	}

	sessionID, err := c.getSessionID()
	if err != nil {
		return nil, err
	}

	subject, err := c.getSubject()
	if err != nil {
		return nil, err
	}

	action, err := c.getAction()
	if err != nil {
		return nil, err
	}

	return &SerializedData{
		SessionID: sessionID,
		Subject:   subject,
		Action:    action,
	}, nil
}

      
      



:





interaction_callback_test.go
package handlers_test

import (
	"encoding/json"
	"go-scrum-poker-bot/config"
	"go-scrum-poker-bot/ui"
	"go-scrum-poker-bot/web/server/handlers"
	"go-scrum-poker-bot/web/server/models"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestInteractionCallbackHandlerActions(t *testing.T) {
	config := config.NewConfig()
	mockClient := &MockClient{}
	mockUserStorage := &MockUserStorage{}
	mockSessionStorage := &MockSessionStorage{}
	uiBuilder := ui.NewBuilder(config)

	router := http.NewServeMux()
	router.Handle(
		"/interactivity",
		handlers.InteractionCallback(mockUserStorage, mockSessionStorage, uiBuilder, mockClient),
	)

	actions := []*models.Action{
		{
			BlockID:        "test",
			ActionID:       ui.RESULTS_VISIBILITY_ACTION_ID,
			Value:          "test",
			SelectedOption: nil,
		},
		{
			BlockID:        "test",
			ActionID:       ui.VOTE_ACTION_ID,
			Value:          "test",
			SelectedOption: &models.SelectedOption{Value: "1"},
		},
	}

  //      
	for _, action := range actions {
		responseRec := httptest.NewRecorder()

		data, _ := json.Marshal(models.Callback{
			ResponseURL: "test",
			User:        &models.User{Username: "test"},
			Actions:     []*models.Action{action},
			Message: &models.Message{
				Blocks: []*models.Block{
					{
						Type:    "test",
						BlockID: ui.SUBJECT_BLOCK_ID,
						Text:    &models.Text{Type: "test", Text: "test"},
					},
				},
			},
		})
		payload := url.Values{"payload": {string(data)}}.Encode()
		request, err := http.NewRequest("POST", "/interactivity", strings.NewReader(payload))
		request.Header.Set("Content-Type", "application/x-www-form-urlencoded")

		router.ServeHTTP(responseRec, request)

		assert.Nil(t, err)
		assert.Equal(t, http.StatusOK, responseRec.Code)
		assert.Empty(t, responseRec.Body.String())
		assert.Equal(t, true, mockClient.Called)
	}
}

      
      



mock



. common_test.go



:





common_test.go
//  

type MockUserStorage struct{}

func (s *MockUserStorage) All(sessionID string) map[string]string {
	return map[string]string{"user": "1"}
}

func (s *MockUserStorage) Save(sessionID string, username string, value string) error {
	return nil
}

type MockSessionStorage struct{}

func (s *MockSessionStorage) GetVisibility(sessionID string) bool {
	return true
}

func (s *MockSessionStorage) SetVisibility(sessionID string, state bool) error {
	return nil
}

      
      



:





server.go
//  

func (s *Server) setupRouter() http.Handler {
	router := http.NewServeMux()
	router.Handle(
		"/healthcheck",
		handlers.Healthcheck(),
	)
	router.Handle(
		"/play-poker",
		handlers.PlayPokerCommand(s.webClient, s.uiBuilder),
	)
	router.Handle(
		"/interactivity",
		handlers.InteractionCallback(s.userStorage, s.sessionStorage, s.uiBuilder, s.webClient),
	)

	return router
}

//  
      
      



, , + - , . middleware



. web -> server -> middleware



:





log.go
package middleware

import (
	"log"
	"net/http"
)

func Log(logger *log.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				logger.Printf(
					"Handle request: [%s]: %s - %s - %s",
					r.Method, r.URL.Path, r.RemoteAddr, r.UserAgent(),
				)
			}()
			next.ServeHTTP(w, r)
		})
	}
}

      
      



:





log_test.go
package middleware_test

import (
	"bytes"
	"go-scrum-poker-bot/web/server/middleware"
	"log"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"
)

type logHandler struct{}

func (h *logHandler) ServeHTTP(http.ResponseWriter, *http.Request) {}

func TestLogMiddleware(t *testing.T) {
	var buf bytes.Buffer
	logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)
  //    output  ,     
	logger.SetOutput(&buf)

	handler := &logHandler{}
  //  mock recorder    Go
	responseRec := httptest.NewRecorder()

	router := http.NewServeMux()
	router.Handle("/test", middleware.Log(logger)(handler))

	request, err := http.NewRequest("GET", "/test", strings.NewReader(""))

	router.ServeHTTP(responseRec, request)

	assert.Nil(t, err)
	assert.Equal(t, http.StatusOK, responseRec.Code)
  // ,    - .   ,  ,  middleware  
	assert.NotEmpty(t, buf.String())
}

      
      



middleware



.





. Redis, , - , . go-redis redismock .





Scrum Poker . storage



:





users.go
package storage

import (
	"context"
	"fmt"

	"github.com/go-redis/redis/v8"
)

//  
const SESSION_USERS_TPL = "SESSION:%s:USERS"
const USER_VOTE_TPL = "SESSION:%s:USERNAME:%s:VOTE"

type UserRedisStorage struct {
	redis   *redis.Client
	context context.Context
}

func NewUserRedisStorage(redisClient *redis.Client) *UserRedisStorage {
	return &UserRedisStorage{
		redis:   redisClient,
		context: context.Background(),
	}
}

func (s *UserRedisStorage) All(sessionID string) map[string]string {
	users := make(map[string]string)

  //     set,       . 
  //      
	for _, username := range s.redis.SMembers(s.context, fmt.Sprintf(SESSION_USERS_TPL, sessionID)).Val() {
		users[username] = s.redis.Get(s.context, fmt.Sprintf(USER_VOTE_TPL, sessionID, username)).Val()
	}
	return users
}

func (s *UserRedisStorage) Save(sessionID string, username string, value string) error {
	err := s.redis.SAdd(s.context, fmt.Sprintf(SESSION_USERS_TPL, sessionID), username).Err()
	if err != nil {
		return err
	}

  //       . 
  //    ,     ,  -1   
	err = s.redis.Set(s.context, fmt.Sprintf(USER_VOTE_TPL, sessionID, username), value, -1).Err()
	if err != nil {
		return err
	}

	return nil
}

      
      



:





users_test.go
package storage_test

import (
	"errors"
	"fmt"
	"go-scrum-poker-bot/storage"
	"testing"

	"github.com/go-redis/redismock/v8"
	"github.com/stretchr/testify/assert"
)

func TestAll(t *testing.T) {
	sessionID, username, value := "test", "user", "1"

	redisClient, mock := redismock.NewClientMock()
	usersStorage := storage.NewUserRedisStorage(redisClient)

  // Redis mock          
	mock.ExpectSMembers(
		fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),
	).SetVal([]string{username})
	mock.ExpectGet(
		fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username),
	).SetVal(value)

	assert.Equal(t, map[string]string{username: value}, usersStorage.All(sessionID))
}

func TestSave(t *testing.T) {
	sessionID, username, value := "test", "user", "1"

	redisClient, mock := redismock.NewClientMock()
	usersStorage := storage.NewUserRedisStorage(redisClient)

	mock.ExpectSAdd(
		fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),
		username,
	).SetVal(1)
	mock.ExpectSet(
		fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username),
		value,
		-1,
	).SetVal(value)

	assert.Equal(t, nil, usersStorage.Save(sessionID, username, value))
}

func TestSaveSAddErr(t *testing.T) {
	sessionID, username, value, err := "test", "user", "1", errors.New("ERROR")

	redisClient, mock := redismock.NewClientMock()
	usersStorage := storage.NewUserRedisStorage(redisClient)

	mock.ExpectSAdd(
		fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),
		username,
	).SetErr(err)

	assert.Equal(t, err, usersStorage.Save(sessionID, username, value))
}

func TestSaveSetErr(t *testing.T) {
	sessionID, username, value, err := "test", "user", "1", errors.New("ERROR")

	redisClient, mock := redismock.NewClientMock()
	usersStorage := storage.NewUserRedisStorage(redisClient)

	mock.ExpectSAdd(
		fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),
		username,
	).SetVal(1)
	mock.ExpectSet(
		fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username),
		value,
		-1,
	).SetErr(err)

	assert.Equal(t, err, usersStorage.Save(sessionID, username, value))
}

      
      



"" . :





sessions.go
package storage

import (
	"context"
	"fmt"
	"strconv"

	"github.com/go-redis/redis/v8"
)

//   
const SESSION_VOTES_HIDDEN_TPL = "SESSION:%s:VOTES_HIDDEN"

type SessionRedisStorage struct {
	redis   *redis.Client
	context context.Context
}

func NewSessionRedisStorage(redisClient *redis.Client) *SessionRedisStorage {
	return &SessionRedisStorage{
		redis:   redisClient,
		context: context.Background(),
	}
}

func (s *SessionRedisStorage) GetVisibility(sessionID string) bool {
	value, _ := strconv.ParseBool(
		s.redis.Get(s.context, fmt.Sprintf(SESSION_VOTES_HIDDEN_TPL, sessionID)).Val(),
	)

	return value
}

func (s *SessionRedisStorage) SetVisibility(sessionID string, state bool) error {
	return s.redis.Set(
		s.context,
		fmt.Sprintf(SESSION_VOTES_HIDDEN_TPL, sessionID),
		strconv.FormatBool(state),
		-1,
	).Err()
}

      
      



:





sessions_test.go
package storage_test

import (
	"errors"
	"fmt"
	"go-scrum-poker-bot/storage"
	"strconv"
	"testing"

	"github.com/go-redis/redismock/v8"
	"github.com/stretchr/testify/assert"
)

func TestGetVisibility(t *testing.T) {
	sessionID, state := "test", true

	redisClient, mock := redismock.NewClientMock()

	mock.ExpectGet(
		fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID),
	).SetVal(strconv.FormatBool(state))

	sessionStorage := storage.NewSessionRedisStorage(redisClient)

	assert.Equal(t, state, sessionStorage.GetVisibility(sessionID))
}

func TestSetVisibility(t *testing.T) {
	sessionID, state := "test", true

	redisClient, mock := redismock.NewClientMock()

	mock.ExpectSet(
		fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID),
		strconv.FormatBool(state),
		-1,
	).SetVal("1")

	sessionStorage := storage.NewSessionRedisStorage(redisClient)

	assert.Equal(t, nil, sessionStorage.SetVisibility(sessionID, state))
}

func TestSetVisibilityErr(t *testing.T) {
	sessionID, state, err := "test", true, errors.New("ERROR")

	redisClient, mock := redismock.NewClientMock()

	mock.ExpectSet(
		fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID),
		strconv.FormatBool(state),
		-1,
	).SetErr(err)

	sessionStorage := storage.NewSessionRedisStorage(redisClient)

	assert.Equal(t, err, sessionStorage.SetVisibility(sessionID, state))
}

      
      



! main.go server.go:





server.go
package server

import (
	"context"
	"go-scrum-poker-bot/storage"
	"go-scrum-poker-bot/ui"
	"go-scrum-poker-bot/web/clients"
	"go-scrum-poker-bot/web/server/handlers"
	"log"
	"net/http"
	"os"
	"os/signal"
	"sync/atomic"
	"time"
)

//    middleware
type Middleware func(next http.Handler) http.Handler

//   
type Server struct {
	healthy        int32
	middleware     []Middleware
	logger         *log.Logger
	webClient      clients.Client
	uiBuilder      *ui.Builder
	userStorage    storage.UserStorage
	sessionStorage storage.SessionStorage
}

//     
func NewServer(
	logger *log.Logger,
	webClient clients.Client,
	uiBuilder *ui.Builder,
	userStorage storage.UserStorage,
	sessionStorage storage.SessionStorage,
	middleware []Middleware,
) *Server {
	return &Server{
		logger:         logger,
		webClient:      webClient,
		uiBuilder:      uiBuilder,
		userStorage:    userStorage,
		sessionStorage: sessionStorage,
		middleware:     middleware,
	}
}

func (s *Server) setupRouter() http.Handler {
	router := http.NewServeMux()
	router.Handle(
		"/healthcheck",
		handlers.Healthcheck(),
	)
	router.Handle(
		"/play-poker",
		handlers.PlayPokerCommand(s.webClient, s.uiBuilder),
	)
	router.Handle(
		"/interactivity",
		handlers.InteractionCallback(s.userStorage, s.sessionStorage, s.uiBuilder, s.webClient),
	)

	return router
}

func (s *Server) setupMiddleware(router http.Handler) http.Handler {
	handler := router
	for _, middleware := range s.middleware {
		handler = middleware(handler)
	}

	return handler
}

func (s *Server) Serve(address string) {
	server := &http.Server{
		Addr:         address,
		Handler:      s.setupMiddleware(s.setupRouter()),
		ErrorLog:     s.logger,
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
		IdleTimeout:  15 * time.Second,
	}

	done := make(chan bool)
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, os.Interrupt)

	go func() {
		<-quit
		s.logger.Println("Server is shutting down...")
		atomic.StoreInt32(&s.healthy, 0)

		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()

		server.SetKeepAlivesEnabled(false)
		if err := server.Shutdown(ctx); err != nil {
			s.logger.Fatalf("Could not gracefully shutdown the server: %v\n", err)
		}
		close(done)
	}()

	s.logger.Println("Server is ready to handle requests at", address)
	atomic.StoreInt32(&s.healthy, 1)
	if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		s.logger.Fatalf("Could not listen on %s: %v\n", address, err)
	}

	<-done
	s.logger.Println("Server stopped")
}

      
      



main.go
package main

import (
	"fmt"
	"go-scrum-poker-bot/config"
	"go-scrum-poker-bot/storage"
	"go-scrum-poker-bot/ui"
	"go-scrum-poker-bot/web/clients"
	clients_middleware "go-scrum-poker-bot/web/clients/middleware"
	"go-scrum-poker-bot/web/server"
	server_middleware "go-scrum-poker-bot/web/server/middleware"
	"log"
	"net/http"
	"os"
	"time"

	"github.com/go-redis/redis/v8"
)

func main() {
	logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)
	config := config.NewConfig()
  //  Redis 
	redisCLI := redis.NewClient(&redis.Options{
		Addr: fmt.Sprintf("%s:%d", config.Redis.Host, config.Redis.Port),
		DB:   config.Redis.DB,
	})
  //  users storage
	userStorage := storage.NewUserRedisStorage(redisCLI)
  //  sessions storage
	sessionStorage := storage.NewSessionRedisStorage(redisCLI)
	builder := ui.NewBuilder(config)
	webClient := clients.NewBasicClient(
		&http.Client{
			Timeout: 5 * time.Second,
		},
		[]clients.Middleware{
			clients_middleware.Auth(config.Slack.Token),
			clients_middleware.JsonContentType,
			clients_middleware.Log(logger),
		},
	)

  //  Server   middleware
	app := server.NewServer(
		logger,
		webClient,
		builder,
		userStorage,
		sessionStorage,
		[]server.Middleware{server_middleware.Recover(logger), server_middleware.Log(logger), server_middleware.Json},
	)
	app.Serve(config.App.ServerAddress)
}

      
      



:





go test ./... -race -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic
      
      



:





go tool cover -func coverage.txt
$ go tool cover -func coverage.txt

go-scrum-poker-bot/config/config.go:9:                                  NewConfig               100.0%
go-scrum-poker-bot/config/helpers.go:10:                                getStrEnv               100.0%
go-scrum-poker-bot/config/helpers.go:17:                                getIntEnv               100.0%
go-scrum-poker-bot/config/helpers.go:26:                                getListStrEnv           100.0%
go-scrum-poker-bot/main.go:22:                                          main                    0.0%
go-scrum-poker-bot/storage/sessions.go:18:                              NewSessionRedisStorage  100.0%
go-scrum-poker-bot/storage/sessions.go:25:                              GetVisibility           100.0%
go-scrum-poker-bot/storage/sessions.go:33:                              SetVisibility           100.0%
go-scrum-poker-bot/storage/users.go:18:                                 NewUserRedisStorage     100.0%
go-scrum-poker-bot/storage/users.go:25:                                 All                     100.0%
go-scrum-poker-bot/storage/users.go:34:                                 Save                    100.0%
go-scrum-poker-bot/ui/blocks/action.go:9:                               BlockType               100.0%
go-scrum-poker-bot/ui/blocks/button.go:11:                              BlockType               100.0%
go-scrum-poker-bot/ui/blocks/context.go:9:                              BlockType               100.0%
go-scrum-poker-bot/ui/blocks/section.go:9:                              BlockType               100.0%
go-scrum-poker-bot/ui/blocks/select.go:10:                              BlockType               100.0%
go-scrum-poker-bot/ui/builder.go:14:                                    NewBuilder              100.0%
go-scrum-poker-bot/ui/builder.go:18:                                    getGetResultsText       100.0%
go-scrum-poker-bot/ui/builder.go:26:                                    getResults              100.0%
go-scrum-poker-bot/ui/builder.go:41:                                    getOptions              100.0%
go-scrum-poker-bot/ui/builder.go:50:                                    BuildBlocks             100.0%
go-scrum-poker-bot/ui/builder.go:100:                                   Build                   100.0%
go-scrum-poker-bot/web/clients/client.go:22:                            NewBasicClient          100.0%
go-scrum-poker-bot/web/clients/client.go:26:                            makeRequest             78.9%
go-scrum-poker-bot/web/clients/client.go:65:                            Make                    66.7%
go-scrum-poker-bot/web/clients/middleware/auth.go:8:                    Auth                    100.0%
go-scrum-poker-bot/web/clients/middleware/json.go:5:                    JsonContentType         100.0%
go-scrum-poker-bot/web/clients/middleware/log.go:8:                     Log                     87.5%
go-scrum-poker-bot/web/clients/request.go:12:                           ToBytes                 100.0%
go-scrum-poker-bot/web/clients/response.go:12:                          Json                    100.0%
go-scrum-poker-bot/web/server/handlers/healthcheck.go:10:               Healthcheck             66.7%
go-scrum-poker-bot/web/server/handlers/interaction_callback.go:12:      InteractionCallback     71.4%
go-scrum-poker-bot/web/server/handlers/play_poker.go:13:                PlayPokerCommand        100.0%
go-scrum-poker-bot/web/server/middleware/json.go:5:                     Json                    100.0%
go-scrum-poker-bot/web/server/middleware/log.go:8:                      Log                     100.0%
go-scrum-poker-bot/web/server/middleware/recover.go:9:                  Recover                 100.0%
go-scrum-poker-bot/web/server/models/callback.go:52:                    getSessionID            100.0%
go-scrum-poker-bot/web/server/models/callback.go:62:                    getSubject              100.0%
go-scrum-poker-bot/web/server/models/callback.go:72:                    getAction               100.0%
go-scrum-poker-bot/web/server/models/callback.go:82:                    SerializedData          92.3%
go-scrum-poker-bot/web/server/models/errors.go:13:                      ResponseError           75.0%
go-scrum-poker-bot/web/server/server.go:31:                             NewServer               0.0%
go-scrum-poker-bot/web/server/server.go:49:                             setupRouter             0.0%
go-scrum-poker-bot/web/server/server.go:67:                             setupMiddleware         0.0%
go-scrum-poker-bot/web/server/server.go:76:                             Serve                   0.0%
total:                                                                  (statements)            75.1%
      
      



Not bad, but we don't need to factor in coverage main.go



(my opinion) and server.go



(here you can argue), so there is a hack :). We need to add the following line with tags to the beginning of the files that we want to exclude from the assessment:





//+build !test
      
      



Restart with the tag:





go test ./... -race -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic -tags=test
      
      



Result:





go tool cover -func coverage.txt
$ go tool cover -func coverage.txt

go-scrum-poker-bot/config/config.go:9:                                  NewConfig               100.0%
go-scrum-poker-bot/config/helpers.go:10:                                getStrEnv               100.0%
go-scrum-poker-bot/config/helpers.go:17:                                getIntEnv               100.0%
go-scrum-poker-bot/config/helpers.go:26:                                getListStrEnv           100.0%
go-scrum-poker-bot/storage/sessions.go:18:                              NewSessionRedisStorage  100.0%
go-scrum-poker-bot/storage/sessions.go:25:                              GetVisibility           100.0%
go-scrum-poker-bot/storage/sessions.go:33:                              SetVisibility           100.0%
go-scrum-poker-bot/storage/users.go:18:                                 NewUserRedisStorage     100.0%
go-scrum-poker-bot/storage/users.go:25:                                 All                     100.0%
go-scrum-poker-bot/storage/users.go:34:                                 Save                    100.0%
go-scrum-poker-bot/ui/blocks/action.go:9:                               BlockType               100.0%
go-scrum-poker-bot/ui/blocks/button.go:11:                              BlockType               100.0%
go-scrum-poker-bot/ui/blocks/context.go:9:                              BlockType               100.0%
go-scrum-poker-bot/ui/blocks/section.go:9:                              BlockType               100.0%
go-scrum-poker-bot/ui/blocks/select.go:10:                              BlockType               100.0%
go-scrum-poker-bot/ui/builder.go:14:                                    NewBuilder              100.0%
go-scrum-poker-bot/ui/builder.go:18:                                    getGetResultsText       100.0%
go-scrum-poker-bot/ui/builder.go:26:                                    getResults              100.0%
go-scrum-poker-bot/ui/builder.go:41:                                    getOptions              100.0%
go-scrum-poker-bot/ui/builder.go:50:                                    BuildBlocks             100.0%
go-scrum-poker-bot/ui/builder.go:100:                                   Build                   100.0%
go-scrum-poker-bot/web/clients/client.go:22:                            NewBasicClient          100.0%
go-scrum-poker-bot/web/clients/client.go:26:                            makeRequest             78.9%
go-scrum-poker-bot/web/clients/client.go:65:                            Make                    66.7%
go-scrum-poker-bot/web/clients/middleware/auth.go:8:                    Auth                    100.0%
go-scrum-poker-bot/web/clients/middleware/json.go:5:                    JsonContentType         100.0%
go-scrum-poker-bot/web/clients/middleware/log.go:8:                     Log                     87.5%
go-scrum-poker-bot/web/clients/request.go:12:                           ToBytes                 100.0%
go-scrum-poker-bot/web/clients/response.go:12:                          Json                    100.0%
go-scrum-poker-bot/web/server/handlers/healthcheck.go:10:               Healthcheck             66.7%
go-scrum-poker-bot/web/server/handlers/interaction_callback.go:12:      InteractionCallback     71.4%
go-scrum-poker-bot/web/server/handlers/play_poker.go:13:                PlayPokerCommand        100.0%
go-scrum-poker-bot/web/server/middleware/json.go:5:                     Json                    100.0%
go-scrum-poker-bot/web/server/middleware/log.go:8:                      Log                     100.0%
go-scrum-poker-bot/web/server/middleware/recover.go:9:                  Recover                 100.0%
go-scrum-poker-bot/web/server/models/callback.go:52:                    getSessionID            100.0%
go-scrum-poker-bot/web/server/models/callback.go:62:                    getSubject              100.0%
go-scrum-poker-bot/web/server/models/callback.go:72:                    getAction               100.0%
go-scrum-poker-bot/web/server/models/callback.go:82:                    SerializedData          92.3%
go-scrum-poker-bot/web/server/models/errors.go:13:                      ResponseError           75.0%
total:                                                                  (statements)            90.9%
      
      



I like this result more :)





I’ll stop here. You can find all the code here . Thanks for attention!





UPDATE: I thought that there would be material for two articles, but it did not work out, so there will be no second part.








All Articles