Здравствуйте! Сегодня мы напишем Slack бота для Scrum покера на языке Go. Писать будем по возможности без фреймворков и внешних библиотек, так как наша цель разобраться с языком программирования Go и проверить, насколько этот язык удобен для разработки подобных проектов.
Дисклеймер
Я только познаю Go и многих вещей еще не знаю. Мой основной язык разработки Python. Поэтому часто буду отсылать к нему в тех местах, где по моему мнению в Python что-то сделано удобнее или проще. Цель этих отсылок в том, чтобы породить дискуссию, ведь вполне вероятно, что эти "удобные вещи" также присутствуют в Go, просто я их не нашел.
Также, отмечу, что все что будет описано ниже, можно было бы сделать гораздо проще (без разделения на слои и так далее), но мне показалось интересным написать больше с целью обучения и практики в "чистой" архитектуре. Да и тестировать так проще.
Хватит прелюдий, вперед в бой!
Итоговый результат
Анимация работы будущего ботаДля тех, кому читать код интересней, чем статью прошу сюда.
Структура приложения
Разобьем нашу программу на следующие слои. У нас предполагается слой взаимодействия (web), слой для рисования интерфейса средствами Slack UI Block Kit (ui), слой для сохранения / получения результатов (storage), а также место для хранения настроек (config). Давайте создадим следующие папки в проекте:
config/storage/ui/web/-- clients/-- server/main.go
Сервер
Для сервера будем использовать стандартный сервер из пакета
http
. Создадим структуру Server
следующего вида в web -> server
:
package serverimport ("context""log""net/http""os""os/signal""sync/atomic""time")type Server struct { // Здесь мы будем определять все необходимые нам зависимости и передавать их на старте приложения в main.gohealthy int32logger *log.Logger}func NewServer(logger *log.Logger) *Server {return &Server{logger: logger,}}
Эта структура будет выступать хранилищем зависимостей для наших
хэндлеров. Есть несколько подходов для организации работы с
хэндлерами и их зависимостями. Например, можно объявлять и
запускать все в main.go
, там же где мы создаем
экземпляры наших структур и интерфейсов. Но это плохой путь. Еще
есть вариант использовать глобальные переменные и просто их
импортировать. Но в таком случае становится сложно покрывать проект
тестами. Дальше мы увидим плюсы выбранного мной подхода. Итак, нам
нужно запустить наш сервер. Напишем метод:
func (s *Server) setupRouter() http.Handler { // TODOrouter := 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() {<-quits.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)} // Когда сервер остановлен и все хорошо, снова получаем управление и логируем результат<-dones.logger.Println("Server stopped")}
Теперь давайте создадим первый хэндлер. Создадим папку в
web -> server -> handlers
:
package handlersimport ("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 mainimport ("log" "os" "go-scrum-poker-bot/web/server")func main() { // Создаем логгер со стандартными флагами и префиксом "INFO:". // Писать он будет только в stdoutlogger := 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
:
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.Clientmiddleware []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() // TODOif 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)} // Применяем middlewarehandler := c.makeRequestfor _, middleware := range c.middleware {handler = middleware(handler, request)}return handler(request)}
Теперь создадим файл web -> clients
:
package clientsimport "encoding/json"type Request struct {URL stringMethod stringHeaders map[string]stringJson 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
, да и как-то глазу
приятнее:
package clients_testimport ("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 []byteerr 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:
package clientsimport "encoding/json"type Response struct {Status intHeaders map[string][]stringBody []byteError error}// Я намеренно сделал универсальный метод, чтобы можно было привезти любой ответ к нужному и не писать каждый раз эти богомерзкие if err != nilfunc (r *Response) Json(to interface{}) error {if r.Error != nil {return r.Error}return json.Unmarshal(r.Body, to)}
И также, напишем тесты для метода Json(to
interface{})
:
package clients_testimport ("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
транспорта. Однако есть и другие варианты, но
этот мне показался удобнее:
package clients_testimport ("bytes""go-scrum-poker-bot/web/clients""io/ioutil""net/http""testing""github.com/stretchr/testify/assert")// Для удобства объявим новый типtype RoundTripFunc func(request *http.Request) *http.Responsefunc (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)}// Тест на ошибочный responsefunc 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
:
package middlewareimport ("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_testimport ("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
хэндлер:
package handlersimport ("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"))) // TODOreturn}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)) // TODOreturn}})}
И создадим в web -> server -> models
. Файл
errors.go
для быстрого формирования ошибок:
package modelsimport ("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_testimport ("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() // TODOmockClient := &MockClient{}uiBuilder := ui.NewBuilder(config) // TODOresponseRec := 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() // TODOmockClient := &MockClient{Error: errMsg}uiBuilder := ui.NewBuilder(config) // TODOresponseRec := 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
клиента:
package handlers_testimport ("errors""go-scrum-poker-bot/web/clients")type MockClient struct {Called boolError string}func (c *MockClient) Make(request *clients.Request) *clients.Response {c.Called = truevar err error = nilif 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 configtype Config struct {App *AppSlack *SlackRedis *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 или выставляем defaultfunc getStrEnv(key string, defaultValue string) string {if value, ok := os.LookupEnv(key); ok {return value}return defaultValue}// Получаем int значение из env или выставляем defaultfunc 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 или выставляем defaultfunc 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_testimport ( "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
:
package mainimport ("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{ // Наши middlewareclients_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
:
package handlersimport ("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.Valueerr = userStorage.Save(data.SessionID, callback.User.Username, data.Action.SelectedOption.Value)case ui.RESULTS_VISIBILITY_ACTION_ID:visible = !visibleerr = 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
:
package storagetype UserStorage interface {All(sessionID string) map[string]stringSave(sessionID string, username string, value string) error}type SessionStorage interface {GetVisibility(sessionID string) boolSetVisibility(sessionID string, state bool) error}
Я намеренно разбил логику на два хранилища, поскольку так удобнее тестировать и если будет нужно, то легко можно будет перевести например хранение голосов пользователей в базу данных, а настройки сессии оставить в Redis (как пример).
Теперь нужно создать модель Callback
. Идем в
web -> server -> models
:
package modelsimport ("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 stringSubject stringAction *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_testimport ("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
:
// Существующий код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
:
package middlewareimport ("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_testimport ("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.Bufferlogger := log.New(os.Stdout, "INFO: ", log.LstdFlags) // Выставляем для логгера output наш буффер, чтобы все писалось в негоlogger.SetOutput(&buf)handler := &logHandler{} // Берем mock recorder из стандартной библиотеки GoresponseRec := 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
:
package storageimport ("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.Clientcontext 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_testimport ("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 storageimport ("context""fmt""strconv""github.com/go-redis/redis/v8")// Шаблон для ключейconst SESSION_VOTES_HIDDEN_TPL = "SESSION:%s:VOTES_HIDDEN"type SessionRedisStorage struct {redis *redis.Clientcontext 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_testimport ("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", trueredisClient, 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", trueredisClient, 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 serverimport ("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")// Новый тип для middlewaretype Middleware func(next http.Handler) http.Handler// Все зависимости здесьtype Server struct {healthy int32middleware []Middlewarelogger *log.LoggerwebClient clients.ClientuiBuilder *ui.BuilderuserStorage storage.UserStoragesessionStorage 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 := routerfor _, 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() {<-quits.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)}<-dones.logger.Println("Server stopped")}
package mainimport ("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 storageuserStorage := storage.NewUserRedisStorage(redisCLI) // Наш sessions storagesessionStorage := 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 теперь есть middlewareapp := 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.txtgo-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 -tags=test
Результат:
go tool cover -func coverage.txt
$ go tool cover -func coverage.txtgo-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%
Такой результат мне нравится больше :)
На этом пожалуй остановлюсь. Весь код можете найти здесь. Спасибо за внимание!