Пишем Slack бота для Scrum покера на Go

Здравствуйте! Сегодня мы напишем Slack бота для Scrum покера на языке Go. Писать будем по возможности без фреймворков и внешних библиотек, так как наша цель — разобраться с языком программирования Go и проверить, насколько этот язык удобен для разработки подобных проектов.

5e7b944122245a121285f82f8a0a6e24.png

Дисклеймер

Я только познаю Go и многих вещей еще не знаю. Мой основной язык разработки Python. Поэтому часто буду отсылать к нему в тех местах, где по моему мнению в Python что-то сделано удобнее или проще. Цель этих отсылок в том, чтобы породить дискуссию, ведь вполне вероятно, что эти «удобные вещи» также присутствуют в Go, просто я их не нашел.

Также отмечу, что все что будет описано ниже, можно было бы сделать гораздо проще (без разделения на слои и так далее), но мне показалось интересным написать больше с целью обучения и практики в «чистой» архитектуре. Да и тестировать так проще.

Хватит прелюдий, вперед в бой!

Итоговый результат

Анимация работы будущего бота
7ddf71ee8552c630ec637d44b2a2e923.gif

Для тех, кому читать код интересней, чем статью — прошу сюда.

Структура приложения

Разобьем нашу программу на следующие слои. У нас предполагается слой взаимодействия (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. В ответ вы должны получить что-то вроде этого:

7dab0dd67e55b0502bd1163d00f82834.png

Но это не единственный вариант взаимодействия со 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%

Неплохо, но нам не нужно учитывать в coverage main.go (мое мнение) и server.go (здесь можно поспорить), поэтому есть хак:). Нужно добавить в начало файлов, которые мы хотим исключить из оценки следующую строчку с тегами:

//+build !test

Перезапустим с тегом:

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

© Habrahabr.ru