[Мессенджеры, Программирование, Go, Agile] Пишем Slack бота для Scrum покера на Go. Часть 1
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Здравствуйте! Сегодня мы напишем 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:server.go
package server
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"sync/atomic"
"time"
)
type Server struct {
// Здесь мы будем определять все необходимые нам зависимости и передавать их на старте приложения в main.go
healthy int32
logger *log.Logger
}
func NewServer(logger *log.Logger) *Server {
return &Server{
logger: logger,
}
}
Эта структура будет выступать хранилищем зависимостей для наших хэндлеров. Есть несколько подходов для организации работы с хэндлерами и их зависимостями. Например, можно объявлять и запускать все в main.go, там же где мы создаем экземпляры наших структур и интерфейсов. Но это плохой путь. Еще есть вариант использовать глобальные переменные и просто их импортировать. Но в таком случае становится сложно покрывать проект тестами. Дальше мы увидим плюсы выбранного мной подхода. Итак, нам нужно запустить наш сервер. Напишем метод:server.go
func (s *Server) setupRouter() http.Handler { // TODO
router := http.NewServeMux()
return router
}
func (s *Server) Serve(address string) {
server := &http.Server{
Addr: address,
Handler: s.setupRouter(),
ErrorLog: s.logger, // Наш логгер
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 15 * time.Second,
}
// Создаем каналы для корректного завершения процесса
done := make(chan bool)
quit := make(chan os.Signal, 1)
// Настраиваем сигнал для корректного завершения процесса
signal.Notify(quit, os.Interrupt)
go func() {
<-quit
s.logger.Println("Server is shutting down...")
// Эта переменная пригодится для healthcheck'а например
atomic.StoreInt32(&s.healthy, 0)
// Даем клиентам 30 секунд для завершения всех операций, прежде чем сервер будет остановлен
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Информируем сервер о том, что не нужно держать существующие коннекты
server.SetKeepAlivesEnabled(false)
// Выключаем сервер
if err := server.Shutdown(ctx); err != nil {
s.logger.Fatalf("Could not gracefully shutdown the server: %v\n", err)
}
close(done)
}()
s.logger.Println("Server is ready to handle requests at", address)
// Переменная для проверки того, что сервер запустился и все хорошо
atomic.StoreInt32(&s.healthy, 1)
// Запускаем сервер
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
s.logger.Fatalf("Could not listen on %s: %v\n", address, err)
}
// Когда сервер остановлен и все хорошо, снова получаем управление и логируем результат
<-done
s.logger.Println("Server stopped")
}
Теперь давайте создадим первый хэндлер. Создадим папку в web -> server -> handlers:healthcheck.go
package handlers
import (
"net/http"
)
func Healthcheck() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write("OK")
})
}
Добавим наш хэндлер в роутер:server.go
// Наш код выше
func (s *Server) setupRouter() http.Handler {
router := http.NewServeMux()
router.Handle(
"/healthcheck",
handlers.Healthcheck(),
)
return router
}
// Наш код ниже
Идем в main.go и пробуем запустить наш сервер:
package main
import (
"log"
"os"
"go-scrum-poker-bot/web/server"
)
func main() {
// Создаем логгер со стандартными флагами и префиксом "INFO:".
// Писать он будет только в stdout
logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)
app := server.NewServer(logger)
app.Serve(":8000")
}
Пробуем запустить проект:
go run main.go
Если все хорошо, то сервер запустится на :8000 порту. Наш текущий подход к созданию хэндлеров позволяет передавать в них любые зависимости. Это нам еще пригодится, когда мы будем писать тесты. ;) Прежде чем идти дальше, нам нужно немного настроить нашу локальную среду, чтобы Slack смог с нами взаимодействовать.NGROKДля того, чтобы можно было локально проверять работу нашего бота, нам нужно установить себе туннель ngrok. Вообще можно любой другой, но этот вариант удобный и прост в использовании. Да и Slack его советует. В общем, когда все будет готово, запустите его командой:
ngrok http 8000
Если все хорошо, то вы увидите что-то вроде этого:
ngrok by @inconshreveable (Ctrl+C to quit)
Session Status online
Account Sayakhov Ilya (Plan: Free)
Version 2.3.35
Region United States (us)
Web Interface http://127.0.0.1:4040
Forwarding http://ffd3cfcc460c.ngrok.io -> http://localhost:8000
Forwarding https://ffd3cfcc460c.ngrok.io -> http://localhost:8000
Connections ttl opn rt1 rt5 p50 p90
0 0 0.00 0.00 0.00 0.00
Нас интересует строчка https://ffd3cfcc460c.ngrok.io. Она нам понадобится дальше.Slash commandsСоздадим наше приложение в Slack. Для этого нужно перейти сюда -> Create New App. Далее указываем имя GoScrumPokerBot и добавляем его в свой Workspace. Далее, нам нужно дать нашему боту права. Для этого идем в OAuth & Permissions -> Scopes и добавляем следующие права: chat:write, commands. Первый набор прав нужен, чтобы бот мог писать в каналы, а второй для slash команд. И наконец нажимаем на Reinstall to Workspace. Готово! Теперь идем в раздел Slash commands и добавляем нашу команду /poker . В Request URL нужно вписать адрес из пункта выше + путь. Пусть будет так: https://ffd3cfcc460c.ngrok.io/play-poker.Slash command handlerТеперь создадим хэндлер для обработки событий на только созданную команду. Идем в web -> server -> handlers и создаем файл play_poker.go:
func PlayPokerCommand() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"response_type": "ephemeral", "text": "Hello world!"}`))
})
}
Добавляем наш хэндлер в роутер:server.go
func (s *Server) setupRouter() http.Handler {
router := http.NewServeMux()
router.Handle(
"/healthcheck",
handlers.Healthcheck(),
)
router.Handle(
"/play-poker",
handlers.PlayPokerCommand(),
)
return router
}
Идем в Slack и пробуем выполнить эту команду: /poker. В ответ вы должны получить что-то вроде этого:
Но это не единственный вариант взаимодействия со Slack. Мы также можем слать сообщения в канал. Этот вариант мне понравился больше и плюс у него больше возможностей в сравнении с ответом на команду. Например вы можете послать сообщение в фоне (если оно требует долгих вычислений). Давайте напишем наш http клиента. Идем в web -> clients. Создаем файл client.go:client.go
package clients
// Создадим новый тип для наших хэндлеров
type Handler func(request *Request) *Response
// Создадим новый тип для middleware (о них чуть позже)
type Middleware func(handler Handler, request *Request) Handler
// Создадим интерфейс http клиента
type Client interface {
Make(request *Request) *Response
}
// Наша реализация клиента
type BasicClient struct {
client *http.Client
middleware []Middleware
}
func NewBasicClient(client *http.Client, middleware []Middleware) Client {
return &BasicClient{client: client, middleware: middleware}
}
// Приватный метод для всей грязной работы
func (c *BasicClient) makeRequest(request *Request) *Response {
payload, err := request.ToBytes() // TODO
if err != nil {
return &Response{Error: err}
}
// Создаем новый request, передаем в него данные
req, err := http.NewRequest(request.Method, request.URL, bytes.NewBuffer(payload))
if err != nil {
return &Response{Error: err}
}
// Применяем заголовки
for name, value := range request.Headers {
req.Header.Add(name, value)
}
// Выполняем запрос
resp, err := c.client.Do(req)
if err != nil {
return &Response{Error: err}
}
defer resp.Body.Close()
// Читаем тело ответа
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return &Response{Error: err}
}
err = nil
// Если вернулось что-то отличное выше или ниже 20x, то ошибка
if resp.StatusCode > http.StatusIMUsed || resp.StatusCode < http.StatusOK {
err = fmt.Errorf("Bad response. Status: %d, Body: %s", resp.StatusCode, string(body))
}
return &Response{
Status: resp.StatusCode,
Body: body,
Headers: resp.Header,
Error: err,
}
}
// Наш публичный метод для запросов
func (c *BasicClient) Make(request *Request) *Response {
if request.Headers == nil {
request.Headers = make(map[string]string)
}
// Применяем middleware
handler := c.makeRequest
for _, middleware := range c.middleware {
handler = middleware(handler, request)
}
return handler(request)
}
Теперь создадим файл web -> clients:request.go
package clients
import "encoding/json"
type Request struct {
URL string
Method string
Headers map[string]string
Json interface{}
}
func (r *Request) ToBytes() ([]byte, error) {
if r.Json != nil {
result, err := json.Marshal(r.Json)
if err != nil {
return []byte{}, err
}
return result, nil
}
return []byte{}, nil
}
Сразу напишем тесты к методу ToBytes(). Для тестов я взял testify/assert, так как без нее была бы куча if'ов, а меня они напрягают :) . К тому же, я привык к pytest и его assert, да и как-то глазу приятнее:request_test.go
package clients_test
import (
"encoding/json"
"go-scrum-poker-bot/web/clients"
"reflect"
"testing"
"github.com/stretchr/testify/assert"
)
func TestRequestToBytes(t *testing.T) {
// Здесь мы делаем что-то вроде pytest.parametrize (жаль, что в Go нет сахара для декораторов, это было бы удобнее)
testCases := []struct {
json interface{}
data []byte
err error
}{
{map[string]string{"test_key": "test_value"}, []byte("{"test_key":"test_value"}"), nil},
{nil, []byte{}, nil},
{make(chan int), []byte{}, &json.UnsupportedTypeError{Type: reflect.TypeOf(make(chan int))}},
}
// Проходимся по нашим тест кейсам
for _, testCase := range testCases {
request := clients.Request{
URL: "https://example.com",
Method: "GET",
Headers: nil,
Json: testCase.json,
}
actual, err := request.ToBytes()
// Проверяем результаты
assert.Equal(t, testCase.err, err)
assert.Equal(t, testCase.data, actual)
}
}
И нам нужен web -> clients:response.go
package clients
import "encoding/json"
type Response struct {
Status int
Headers map[string][]string
Body []byte
Error error
}
// Я намеренно сделал универсальный метод, чтобы можно было привезти любой ответ к нужному и не писать каждый раз эти богомерзкие if err != nil
func (r *Response) Json(to interface{}) error {
if r.Error != nil {
return r.Error
}
return json.Unmarshal(r.Body, to)
}
И также, напишем тесты для метода Json(to interface{}):response_test.go
package clients_test
import (
"errors"
"go-scrum-poker-bot/web/clients"
"testing"
"github.com/stretchr/testify/assert"
)
// Один тест на позитивный кейс
func TestResponseJson(t *testing.T) {
to := struct {
TestKey string `json:"test_key"`
}{}
response := clients.Response{
Status: 200,
Headers: nil,
Body: []byte(`{"test_key": "test_value"}`),
Error: nil,
}
err := response.Json(&to)
assert.Equal(t, nil, err)
assert.Equal(t, "test_value", to.TestKey)
}
// Один тест на ошибку
func TestResponseJsonError(t *testing.T) {
expectedErr := errors.New("Error!")
response := clients.Response{
Status: 200,
Headers: nil,
Body: nil,
Error: expectedErr,
}
err := response.Json(map[string]string{})
assert.Equal(t, expectedErr, err)
}
Теперь, когда у нас есть все необходимое, нам нужно написать тесты для клиента. Есть несколько вариантов написания тестов для http клиента. Я выбрал вариант с подменой http транспорта. Однако есть и другие варианты, но этот мне показался удобнее:client_test.go
package clients_test
import (
"bytes"
"go-scrum-poker-bot/web/clients"
"io/ioutil"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
// Для удобства объявим новый тип
type RoundTripFunc func(request *http.Request) *http.Response
func (f RoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return f(request), nil
}
// Создание mock тестового клиента
func NewTestClient(fn RoundTripFunc) *http.Client {
return &http.Client{
Transport: RoundTripFunc(fn),
}
}
// Валидный тест
func TestMakeRequest(t *testing.T) {
url := "https://example.com/ok"
// Создаем mock клиента и пишем нужный нам ответ
httpClient := NewTestClient(func(req *http.Request) *http.Response {
assert.Equal(t, req.URL.String(), url)
return &http.Response{
StatusCode: http.StatusOK,
Body: ioutil.NopCloser(bytes.NewBufferString("OK")),
Header: make(http.Header),
}
})
// Создаем нашего http клиента с замоканным http клиентом
webClient := clients.NewBasicClient(httpClient, nil)
response := webClient.Make(&clients.Request{
URL: url,
Method: "GET",
Headers: map[string]string{"Content-Type": "application/json"},
Json: nil,
})
assert.Equal(t, http.StatusOK, response.Status)
}
// Тест на ошибочный response
func TestMakeRequestError(t *testing.T) {
url := "https://example.com/error"
httpClient := NewTestClient(func(req *http.Request) *http.Response {
assert.Equal(t, req.URL.String(), url)
return &http.Response{
StatusCode: http.StatusBadGateway,
Body: ioutil.NopCloser(bytes.NewBufferString("Bad gateway")),
Header: make(http.Header),
}
})
webClient := clients.NewBasicClient(httpClient, nil)
response := webClient.Make(&clients.Request{
URL: url,
Method: "GET",
Headers: map[string]string{"Content-Type": "application/json"},
Json: nil,
})
assert.Equal(t, http.StatusBadGateway, response.Status)
}
Отлично! Теперь давайте напишем middleware. Я привык для каждой, даже самой маленькой задачи, писать отдельную маленькую middleware. Так можно легко переиспользовать такой код в разных проектах / для разных API с разными требованиями к заголовкам / авторизации и так далее. Slack требует при отправке сообщений в канал указывать Authorization заголовок с токеном, который вы сможете найти в разделе OAuth & Permissions. Создаем в web -> clients -> middleware:auth.go
package middleware
import (
"fmt"
"go-scrum-poker-bot/web/clients"
)
// Токен будем передавать при определении middleware на этапе инициализации клиента
func Auth(token string) clients.Middleware {
return func(handler clients.Handler, request *clients.Request) clients.Handler {
return func(request *clients.Request) *clients.Response {
request.Headers["Authorization"] = fmt.Sprintf("Bearer %s", token)
return handler(request)
}
}
}
И напишем тест к ней:auth_test.go
package middleware_test
import (
"fmt"
"go-scrum-poker-bot/web/clients"
"go-scrum-poker-bot/web/clients/middleware"
"testing"
"github.com/stretchr/testify/assert"
)
func TestAuthMiddleware(t *testing.T) {
token := "test"
request := &clients.Request{
Headers: map[string]string{},
}
handler := middleware.Auth(token)(
func(request *clients.Request) *clients.Response {
return &clients.Response{}
},
request,
)
handler(request)
assert.Equal(t, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", token)}, request.Headers)
}
Также в репозитории вы сможете найти middleware для логирования и установки Content-Type: application/json. Здесь я не буду приводить этот код в целях экономии времени и места :). Давайте перепишем наш PlayPoker хэндлер:play_poker.go
package handlers
import (
"errors"
"go-scrum-poker-bot/ui"
"go-scrum-poker-bot/web/clients"
"go-scrum-poker-bot/web/server/models"
"net/http"
"github.com/google/uuid"
)
func PlayPokerCommand(webClient clients.Client, uiBuilder *ui.Builder) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Добавим проверку, что нам пришли данные из POST Form с текстом и ID канала
if r.PostFormValue("channel_id") == "" || r.PostFormValue("text") == "" {
w.Write(models.ResponseError(errors.New("Please write correct subject"))) // TODO
return
}
resp := webClient.Make(&clients.Request{
URL: "https://slack.com/api/chat.postMessage",
Method: "POST",
Json: uiBuilder.Build( // TODO: Напишем builder позже
r.PostFormValue("channel_id"),
uuid.New().String(),
r.PostFormValue("text"),
nil,
false,
),
})
if resp.Error != nil {
w.Write(models.ResponseError(resp.Error)) // TODO
return
}
})
}
И создадим в web -> server -> models . Файл errors.go для быстрого формирования ошибок:errors.go
package models
import (
"encoding/json"
"fmt"
)
type SlackError struct {
ResponseType string `json:"response_type"`
Text string `json:"text"`
}
func ResponseError(err error) []byte {
resp, err := json.Marshal(
SlackError{
ResponseType: "ephemeral",
Text: fmt.Sprintf("Sorry, there is some error happened. Error: %s", err.Error()),
},
)
if err != nil {
return []byte("Sorry. Some error happened")
}
return resp
}
Напишем тесты для хэндлера:play_poker_test.go
package handlers_test
import (
"errors"
"go-scrum-poker-bot/config"
"go-scrum-poker-bot/ui"
"go-scrum-poker-bot/web/server/handlers"
"go-scrum-poker-bot/web/server/models"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPlayPokerHandler(t *testing.T) {
config := config.NewConfig() // TODO
mockClient := &MockClient{}
uiBuilder := ui.NewBuilder(config) // TODO
responseRec := httptest.NewRecorder()
router := http.NewServeMux()
router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder))
payload := url.Values{"channel_id": {"test"}, "text": {"test"}}.Encode()
request, err := http.NewRequest("POST", "/play-poker", strings.NewReader(payload))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
router.ServeHTTP(responseRec, request)
assert.Nil(t, err)
assert.Equal(t, http.StatusOK, responseRec.Code)
assert.Empty(t, responseRec.Body.String())
assert.Equal(t, true, mockClient.Called)
}
func TestPlayPokerHandlerEmptyBodyError(t *testing.T) {
config := config.NewConfig()
mockClient := &MockClient{}
uiBuilder := ui.NewBuilder(config)
responseRec := httptest.NewRecorder()
router := http.NewServeMux()
router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder))
payload := url.Values{}.Encode()
request, _ := http.NewRequest("POST", "/play-poker", strings.NewReader(payload))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
router.ServeHTTP(responseRec, request)
expected := string(models.ResponseError(errors.New("Please write correct subject")))
assert.Equal(t, http.StatusOK, responseRec.Code)
assert.Equal(t, expected, responseRec.Body.String())
assert.Equal(t, false, mockClient.Called)
}
func TestPlayPokerHandlerRequestError(t *testing.T) {
errMsg := "Error msg"
config := config.NewConfig() // TODO
mockClient := &MockClient{Error: errMsg}
uiBuilder := ui.NewBuilder(config) // TODO
responseRec := httptest.NewRecorder()
router := http.NewServeMux()
router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder))
payload := url.Values{"channel_id": {"test"}, "text": {"test"}}.Encode()
request, _ := http.NewRequest("POST", "/play-poker", strings.NewReader(payload))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
router.ServeHTTP(responseRec, request)
expected := string(models.ResponseError(errors.New(errMsg)))
assert.Equal(t, http.StatusOK, responseRec.Code)
assert.Equal(t, expected, responseRec.Body.String())
assert.Equal(t, true, mockClient.Called)
}
Теперь нам нужно написать mock для нашего http клиента:common_test.go
package handlers_test
import (
"errors"
"go-scrum-poker-bot/web/clients"
)
type MockClient struct {
Called bool
Error string
}
func (c *MockClient) Make(request *clients.Request) *clients.Response {
c.Called = true
var err error = nil
if c.Error != "" {
err = errors.New(c.Error)
}
return &clients.Response{Error: err}
}
Как видите, код хэндлера PlayPoker аккуратный и его просто покрывать тестами и не страшно в случае чего изменять.Теперь можно приступить к написанию UI строителя интерфейсов для Slack UI Block Kit. Там все довольно просто, но много однотипного кода. Отмечу лишь, что Slack API мне не очень понравился и было тяжело с ним работать. Сам UI Builder можно глянуть в папке ui здесь. А здесь, в целях экономии времени, я не буду на нем заострять внимания. Отмечу лишь, что в качестве якоря для понимания того, событие от какого сообщения пришло и какой был текст для голосования (его мы не будем сохранять у себя, а будем брать непосредственно из события) будем использовать block_id. А для определения типа события будем смотреть на action_id.Давайте создадим конфиг для нашего приложения. Идем в config и создаем:config.go
package config
type Config struct {
App *App
Slack *Slack
Redis *Redis
}
func NewConfig() *Config {
return &Config{
App: &App{
ServerAddress: getStrEnv("WEB_SERVER_ADDRESS", ":8000"),
PokerRanks: getListStrEnv("POKER_RANKS", "?,0,0.5,1,2,3,5,8,13,20,40,100"),
},
Slack: &Slack{
Token: getStrEnv("SLACK_TOKEN", "FILL_ME"),
},
// Скоро понадобится
Redis: &Redis{
Host: getStrEnv("REDIS_HOST", "0.0.0.0"),
Port: getIntEnv("REDIS_PORT", "6379"),
DB: getIntEnv("REDIS_DB", "0"),
},
}
}
// Получаем значение из env или выставляем default
func getStrEnv(key string, defaultValue string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return defaultValue
}
// Получаем int значение из env или выставляем default
func getIntEnv(key string, defaultValue string) int {
value, err := strconv.Atoi(getStrEnv(key, defaultValue))
if err != nil {
panic(fmt.Sprintf("Incorrect env value for %s", key))
}
return value
}
// Получаем список (e.g. 0,1,2,3,4,5) из env или выставляем default
func getListStrEnv(key string, defaultValue string) []string {
value := []string{}
for _, item := range strings.Split(getStrEnv(key, defaultValue), ",") {
value = append(value, strings.TrimSpace(item))
}
return value
}
И напишем тесты к нему. Будем тестировать только публичные методы:config_test.go
package config_test
import (
"go-scrum-poker-bot/config"
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewConfig(t *testing.T) {
c := config.NewConfig()
assert.Equal(t, "0.0.0.0", c.Redis.Host)
assert.Equal(t, 6379, c.Redis.Port)
assert.Equal(t, 0, c.Redis.DB)
assert.Equal(t, []string{"?", "0", "0.5", "1", "2", "3", "5", "8", "13", "20", "40", "100"}, c.App.PokerRanks)
}
func TestNewConfigIncorrectIntFromEnv(t *testing.T) {
os.Setenv("REDIS_PORT", "-")
assert.Panics(t, func() { config.NewConfig() })
}
Я намеренно сделал обязательность выставления значений по умолчанию, хотя это не самый правильный путь. Изменим main.go:main.go
package main
import (
"fmt"
"go-scrum-poker-bot/config"
"go-scrum-poker-bot/ui"
"go-scrum-poker-bot/web/clients"
clients_middleware "go-scrum-poker-bot/web/clients/middleware"
"go-scrum-poker-bot/web/server"
"log"
"net/http"
"os"
"time"
)
func main() {
logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)
config := config.NewConfig()
builder := ui.NewBuilder(config)
webClient := clients.NewBasicClient(
&http.Client{
Timeout: 5 * time.Second,
},
[]clients.Middleware{ // Наши middleware
clients_middleware.Auth(config.Slack.Token),
clients_middleware.JsonContentType,
clients_middleware.Log(logger),
},
)
app := server.NewServer(
logger,
webClient,
builder,
)
app.Serve(config.App.ServerAddress)
}
Теперь при запуске команды /poker мы в ответ получим наш симпатичный минималистичный интерфейс. Slack InteractivityДавайте научимся реагировать на события при взаимодействии пользователя с ним. Зайдем Your apps -> Наш бот -> Interactivity & Shortcuts. В Request URL введем:
https://ffd3cfcc460c.ngrok.io/interactivity
Создадим еще один хэндлер InteractionCallback в web -> server -> handlers:interaction_callback.go
package handlers
import (
"go-scrum-poker-bot/storage"
"go-scrum-poker-bot/ui"
"go-scrum-poker-bot/ui/blocks"
"go-scrum-poker-bot/web/clients"
"go-scrum-poker-bot/web/server/models"
"net/http"
)
func InteractionCallback(
userStorage storage.UserStorage,
sessionStorage storage.SessionStorage,
uiBuilder *ui.Builder,
webClient clients.Client,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var callback models.Callback
// Об этом ниже
data, err := callback.SerializedData([]byte(r.PostFormValue("payload")))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// TODO: Скоро доберемся до них
users := userStorage.All(data.SessionID)
visible := sessionStorage.GetVisibility(data.SessionID)
err = nil
// Определяем какое событие к нам поступило и реализуем немного логики исходя из него
switch data.Action.ActionID {
case ui.VOTE_ACTION_ID:
users[callback.User.Username] = data.Action.SelectedOption.Value
err = userStorage.Save(data.SessionID, callback.User.Username, data.Action.SelectedOption.Value)
case ui.RESULTS_VISIBILITY_ACTION_ID:
visible = !visible
err = sessionStorage.SetVisibility(data.SessionID, visible)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Шлем ответ перерисовывая интерфейс сообщения через response URL. Для пользователя все пройдет незаметно
resp := webClient.Make(&clients.Request{
URL: callback.ResponseURL,
Method: "POST",
Json: &blocks.Interactive{
ReplaceOriginal: true,
Blocks: uiBuilder.BuildBlocks(data.Subject, users, data.SessionID, visible),
LinkNames: true,
},
})
if resp.Error != nil {
http.Error(w, resp.Error.Error(), http.StatusInternalServerError)
return
}
})
}
Мы пока не определили наше хранилище. Давайте определим их интерфейсы и напишем тест на этот хэндлер. Идем в storage:storage.go
package storage
type UserStorage interface {
All(sessionID string) map[string]string
Save(sessionID string, username string, value string) error
}
type SessionStorage interface {
GetVisibility(sessionID string) bool
SetVisibility(sessionID string, state bool) error
}
Я намеренно разбил логику на два хранилища, поскольку так удобнее тестировать и если будет нужно, то легко можно будет перевести например хранение голосов пользователей в базу данных, а настройки сессии оставить в Redis (как пример).Теперь нужно создать модель Callback. Идем в web -> server -> models:callback.go
package models
import (
"encoding/json"
"errors"
"go-scrum-poker-bot/ui"
)
type User struct {
Username string `json:"username"`
}
type Text struct {
Type string `json:"type"`
Text string `json:"text"`
}
type Block struct {
Type string `json:"type"`
BlockID string `json:"block_id"`
Text *Text `json:"text,omitempty"`
}
type Message struct {
Blocks []*Block `json:"blocks,omitempty"`
}
type SelectedOption struct {
Value string `json:"value"`
}
type Action struct {
BlockID string `json:"block_id"`
ActionID string `json:"action_id"`
Value string `json:"value,omitempty"`
SelectedOption *SelectedOption `json:"selected_option,omitempty"`
}
type SerializedData struct {
SessionID string
Subject string
Action *Action
}
type Callback struct {
ResponseURL string `json:"response_url"`
User *User `json:"user"`
Actions []*Action `json:"actions"`
Message *Message `json:"message,omitempty"`
}
// Грязно достаем ID сессии, но другого способа я не смог придумать
func (c *Callback) getSessionID() (string, error) {
for _, action := range c.Actions {
if action.BlockID != "" {
return action.BlockID, nil
}
}
return "", errors.New("Invalid session ID")
}
// Текст для голосования
func (c *Callback) getSubject() (string, error) {
for _, block := range c.Message.Blocks {
if block.BlockID == ui.SUBJECT_BLOCK_ID && block.Text != nil {
return block.Text.Text, nil
}
}
return "", errors.New("Invalid subject")
}
// Какое событие к нам пришло
func (c *Callback) getAction() (*Action, error) {
for _, action := range c.Actions {
if action.ActionID == ui.VOTE_ACTION_ID || action.ActionID == ui.RESULTS_VISIBILITY_ACTION_ID {
return action, nil
}
}
return nil, errors.New("Invalid action")
}
func (c *Callback) SerializedData(data []byte) (*SerializedData, error) {
err := json.Unmarshal(data, c)
if err != nil {
return nil, err
}
sessionID, err := c.getSessionID()
if err != nil {
return nil, err
}
subject, err := c.getSubject()
if err != nil {
return nil, err
}
action, err := c.getAction()
if err != nil {
return nil, err
}
return &SerializedData{
SessionID: sessionID,
Subject: subject,
Action: action,
}, nil
}
Давайте напишем тест на наш хэндлер:interaction_callback_test.go
package handlers_test
import (
"encoding/json"
"go-scrum-poker-bot/config"
"go-scrum-poker-bot/ui"
"go-scrum-poker-bot/web/server/handlers"
"go-scrum-poker-bot/web/server/models"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestInteractionCallbackHandlerActions(t *testing.T) {
config := config.NewConfig()
mockClient := &MockClient{}
mockUserStorage := &MockUserStorage{}
mockSessionStorage := &MockSessionStorage{}
uiBuilder := ui.NewBuilder(config)
router := http.NewServeMux()
router.Handle(
"/interactivity",
handlers.InteractionCallback(mockUserStorage, mockSessionStorage, uiBuilder, mockClient),
)
actions := []*models.Action{
{
BlockID: "test",
ActionID: ui.RESULTS_VISIBILITY_ACTION_ID,
Value: "test",
SelectedOption: nil,
},
{
BlockID: "test",
ActionID: ui.VOTE_ACTION_ID,
Value: "test",
SelectedOption: &models.SelectedOption{Value: "1"},
},
}
// Проверяем на двух разных типах событий
for _, action := range actions {
responseRec := httptest.NewRecorder()
data, _ := json.Marshal(models.Callback{
ResponseURL: "test",
User: &models.User{Username: "test"},
Actions: []*models.Action{action},
Message: &models.Message{
Blocks: []*models.Block{
{
Type: "test",
BlockID: ui.SUBJECT_BLOCK_ID,
Text: &models.Text{Type: "test", Text: "test"},
},
},
},
})
payload := url.Values{"payload": {string(data)}}.Encode()
request, err := http.NewRequest("POST", "/interactivity", strings.NewReader(payload))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
router.ServeHTTP(responseRec, request)
assert.Nil(t, err)
assert.Equal(t, http.StatusOK, responseRec.Code)
assert.Empty(t, responseRec.Body.String())
assert.Equal(t, true, mockClient.Called)
}
}
Осталось определить mock для наших хранилищ. Обновим файл common_test.go:common_test.go
// Существующий код
type MockUserStorage struct{}
func (s *MockUserStorage) All(sessionID string) map[string]string {
return map[string]string{"user": "1"}
}
func (s *MockUserStorage) Save(sessionID string, username string, value string) error {
return nil
}
type MockSessionStorage struct{}
func (s *MockSessionStorage) GetVisibility(sessionID string) bool {
return true
}
func (s *MockSessionStorage) SetVisibility(sessionID string, state bool) error {
return nil
}
Добавив в роутер новый хэндлер:server.go
// Существующий код
func (s *Server) setupRouter() http.Handler {
router := http.NewServeMux()
router.Handle(
"/healthcheck",
handlers.Healthcheck(),
)
router.Handle(
"/play-poker",
handlers.PlayPokerCommand(s.webClient, s.uiBuilder),
)
router.Handle(
"/interactivity",
handlers.InteractionCallback(s.userStorage, s.sessionStorage, s.uiBuilder, s.webClient),
)
return router
}
// Существующий код
Все хорошо, но наш сервер никак не уведомляет нас о том, что к нему поступил запрос + если мы где-то поймаем панику, то сервер может упасть. Давайте это исправим через middleware. Создаем папку web -> server -> middleware:log.go
package middleware
import (
"log"
"net/http"
)
func Log(logger *log.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
logger.Printf(
"Handle request: [%s]: %s - %s - %s",
r.Method, r.URL.Path, r.RemoteAddr, r.UserAgent(),
)
}()
next.ServeHTTP(w, r)
})
}
}
И напишем для нее тест:log_test.go
package middleware_test
import (
"bytes"
"go-scrum-poker-bot/web/server/middleware"
"log"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
type logHandler struct{}
func (h *logHandler) ServeHTTP(http.ResponseWriter, *http.Request) {}
func TestLogMiddleware(t *testing.T) {
var buf bytes.Buffer
logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)
// Выставляем для логгера output наш буффер, чтобы все писалось в него
logger.SetOutput(&buf)
handler := &logHandler{}
// Берем mock recorder из стандартной библиотеки Go
responseRec := httptest.NewRecorder()
router := http.NewServeMux()
router.Handle("/test", middleware.Log(logger)(handler))
request, err := http.NewRequest("GET", "/test", strings.NewReader(""))
router.ServeHTTP(responseRec, request)
assert.Nil(t, err)
assert.Equal(t, http.StatusOK, responseRec.Code)
// Проверяем, что в буффер что-то пришло. Этого нам достаточно, чтобы понять, что middleware успешно отработала
assert.NotEmpty(t, buf.String())
}
Остальные middleware можете найти здесь.Ну и наконец слой хранения данных. Я решил взять Redis, так как это проще, да и не нужно для такого рода задач что-то большее, как мне кажется. Воспользуемся библиотекой go-redis и там же возьмем redismock для тестов.Для начала научимся сохранять и получать всех пользователей переданной Scrum Poker сессии. Идем в storage:users.go
package storage
import (
"context"
"fmt"
"github.com/go-redis/redis/v8"
)
// Шаблоны ключей
const SESSION_USERS_TPL = "SESSION:%s:USERS"
const USER_VOTE_TPL = "SESSION:%s:USERNAME:%s:VOTE"
type UserRedisStorage struct {
redis *redis.Client
context context.Context
}
func NewUserRedisStorage(redisClient *redis.Client) *UserRedisStorage {
return &UserRedisStorage{
redis: redisClient,
context: context.Background(),
}
}
func (s *UserRedisStorage) All(sessionID string) map[string]string {
users := make(map[string]string)
// Пользователей будем хранить в set, так как сортировка для нас не принципиальна.
// Заодно избавимся от необходимости искать дубликаты
for _, username := range s.redis.SMembers(s.context, fmt.Sprintf(SESSION_USERS_TPL, sessionID)).Val() {
users[username] = s.redis.Get(s.context, fmt.Sprintf(USER_VOTE_TPL, sessionID, username)).Val()
}
return users
}
func (s *UserRedisStorage) Save(sessionID string, username string, value string) error {
err := s.redis.SAdd(s.context, fmt.Sprintf(SESSION_USERS_TPL, sessionID), username).Err()
if err != nil {
return err
}
// Голоса пользователей будем хранить в обычных ключах.
// Я сделал вечное хранение, но это легко можно поменять, изменив -1 на нужное значение
err = s.redis.Set(s.context, fmt.Sprintf(USER_VOTE_TPL, sessionID, username), value, -1).Err()
if err != nil {
return err
}
return nil
}
Напишем тесты:users_test.go
package storage_test
import (
"errors"
"fmt"
"go-scrum-poker-bot/storage"
"testing"
"github.com/go-redis/redismock/v8"
"github.com/stretchr/testify/assert"
)
func TestAll(t *testing.T) {
sessionID, username, value := "test", "user", "1"
redisClient, mock := redismock.NewClientMock()
usersStorage := storage.NewUserRedisStorage(redisClient)
// Redis mock требует обязательного указания всех ожидаемых команд и результаты их выполнения
mock.ExpectSMembers(
fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),
).SetVal([]string{username})
mock.ExpectGet(
fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username),
).SetVal(value)
assert.Equal(t, map[string]string{username: value}, usersStorage.All(sessionID))
}
func TestSave(t *testing.T) {
sessionID, username, value := "test", "user", "1"
redisClient, mock := redismock.NewClientMock()
usersStorage := storage.NewUserRedisStorage(redisClient)
mock.ExpectSAdd(
fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),
username,
).SetVal(1)
mock.ExpectSet(
fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username),
value,
-1,
).SetVal(value)
assert.Equal(t, nil, usersStorage.Save(sessionID, username, value))
}
func TestSaveSAddErr(t *testing.T) {
sessionID, username, value, err := "test", "user", "1", errors.New("ERROR")
redisClient, mock := redismock.NewClientMock()
usersStorage := storage.NewUserRedisStorage(redisClient)
mock.ExpectSAdd(
fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),
username,
).SetErr(err)
assert.Equal(t, err, usersStorage.Save(sessionID, username, value))
}
func TestSaveSetErr(t *testing.T) {
sessionID, username, value, err := "test", "user", "1", errors.New("ERROR")
redisClient, mock := redismock.NewClientMock()
usersStorage := storage.NewUserRedisStorage(redisClient)
mock.ExpectSAdd(
fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),
username,
).SetVal(1)
mock.ExpectSet(
fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username),
value,
-1,
).SetErr(err)
assert.Equal(t, err, usersStorage.Save(sessionID, username, value))
}
Теперь определим хранилище для "покерной" сессии. Пока там будет лежать статус видимости голосов:sessions.go
package storage
import (
"context"
"fmt"
"strconv"
"github.com/go-redis/redis/v8"
)
// Шаблон для ключей
const SESSION_VOTES_HIDDEN_TPL = "SESSION:%s:VOTES_HIDDEN"
type SessionRedisStorage struct {
redis *redis.Client
context context.Context
}
func NewSessionRedisStorage(redisClient *redis.Client) *SessionRedisStorage {
return &SessionRedisStorage{
redis: redisClient,
context: context.Background(),
}
}
func (s *SessionRedisStorage) GetVisibility(sessionID string) bool {
value, _ := strconv.ParseBool(
s.redis.Get(s.context, fmt.Sprintf(SESSION_VOTES_HIDDEN_TPL, sessionID)).Val(),
)
return value
}
func (s *SessionRedisStorage) SetVisibility(sessionID string, state bool) error {
return s.redis.Set(
s.context,
fmt.Sprintf(SESSION_VOTES_HIDDEN_TPL, sessionID),
strconv.FormatBool(state),
-1,
).Err()
}
И сразу напишем тесты для только что созданных методов:sessions_test.go
package storage_test
import (
"errors"
"fmt"
"go-scrum-poker-bot/storage"
"strconv"
"testing"
"github.com/go-redis/redismock/v8"
"github.com/stretchr/testify/assert"
)
func TestGetVisibility(t *testing.T) {
sessionID, state := "test", true
redisClient, mock := redismock.NewClientMock()
mock.ExpectGet(
fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID),
).SetVal(strconv.FormatBool(state))
sessionStorage := storage.NewSessionRedisStorage(redisClient)
assert.Equal(t, state, sessionStorage.GetVisibility(sessionID))
}
func TestSetVisibility(t *testing.T) {
sessionID, state := "test", true
redisClient, mock := redismock.NewClientMock()
mock.ExpectSet(
fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID),
strconv.FormatBool(state),
-1,
).SetVal("1")
sessionStorage := storage.NewSessionRedisStorage(redisClient)
assert.Equal(t, nil, sessionStorage.SetVisibility(sessionID, state))
}
func TestSetVisibilityErr(t *testing.T) {
sessionID, state, err := "test", true, errors.New("ERROR")
redisClient, mock := redismock.NewClientMock()
mock.ExpectSet(
fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID),
strconv.FormatBool(state),
-1,
).SetErr(err)
sessionStorage := storage.NewSessionRedisStorage(redisClient)
assert.Equal(t, err, sessionStorage.SetVisibility(sessionID, state))
}
Отлично! Осталось изменить main.go и server.go:server.go
package server
import (
"context"
"go-scrum-poker-bot/storage"
"go-scrum-poker-bot/ui"
"go-scrum-poker-bot/web/clients"
"go-scrum-poker-bot/web/server/handlers"
"log"
"net/http"
"os"
"os/signal"
"sync/atomic"
"time"
)
// Новый тип для middleware
type Middleware func(next http.Handler) http.Handler
// Все зависимости здесь
type Server struct {
healthy int32
middleware []Middleware
logger *log.Logger
webClient clients.Client
uiBuilder *ui.Builder
userStorage storage.UserStorage
sessionStorage storage.SessionStorage
}
// Добавляем их при инициализации сервера
func NewServer(
logger *log.Logger,
webClient clients.Client,
uiBuilder *ui.Builder,
userStorage storage.UserStorage,
sessionStorage storage.SessionStorage,
middleware []Middleware,
) *Server {
return &Server{
logger: logger,
webClient: webClient,
uiBuilder: uiBuilder,
userStorage: userStorage,
sessionStorage: sessionStorage,
middleware: middleware,
}
}
func (s *Server) setupRouter() http.Handler {
router := http.NewServeMux()
router.Handle(
"/healthcheck",
handlers.Healthcheck(),
)
router.Handle(
"/play-poker",
handlers.PlayPokerCommand(s.webClient, s.uiBuilder),
)
router.Handle(
"/interactivity",
handlers.InteractionCallback(s.userStorage, s.sessionStorage, s.uiBuilder, s.webClient),
)
return router
}
func (s *Server) setupMiddleware(router http.Handler) http.Handler {
handler := router
for _, middleware := range s.middleware {
handler = middleware(handler)
}
return handler
}
func (s *Server) Serve(address string) {
server := &http.Server{
Addr: address,
Handler: s.setupMiddleware(s.setupRouter()),
ErrorLog: s.logger,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 15 * time.Second,
}
done := make(chan bool)
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
go func() {
<-quit
s.logger.Println("Server is shutting down...")
atomic.StoreInt32(&s.healthy, 0)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server.SetKeepAlivesEnabled(false)
if err := server.Shutdown(ctx); err != nil {
s.logger.Fatalf("Could not gracefully shutdown the server: %v\n", err)
}
close(done)
}()
s.logger.Println("Server is ready to handle requests at", address)
atomic.StoreInt32(&s.healthy, 1)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
s.logger.Fatalf("Could not listen on %s: %v\n", address, err)
}
<-done
s.logger.Println("Server stopped")
}
main.go
package main
import (
"fmt"
"go-scrum-poker-bot/config"
"go-scrum-poker-bot/storage"
"go-scrum-poker-bot/ui"
"go-scrum-poker-bot/web/clients"
clients_middleware "go-scrum-poker-bot/web/clients/middleware"
"go-scrum-poker-bot/web/server"
server_middleware "go-scrum-poker-bot/web/server/middleware"
"log"
"net/http"
"os"
"time"
"github.com/go-redis/redis/v8"
)
func main() {
logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)
config := config.NewConfig()
// Объявляем Redis клиент
redisCLI := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", config.Redis.Host, config.Redis.Port),
DB: config.Redis.DB,
})
// Наш users storage
userStorage := storage.NewUserRedisStorage(redisCLI)
// Наш sessions storage
sessionStorage := storage.NewSessionRedisStorage(redisCLI)
builder := ui.NewBuilder(config)
webClient := clients.NewBasicClient(
&http.Client{
Timeout: 5 * time.Second,
},
[]clients.Middleware{
clients_middleware.Auth(config.Slack.Token),
clients_middleware.JsonContentType,
clients_middleware.Log(logger),
},
)
// В Server теперь есть middleware
app := server.NewServer(
logger,
webClient,
builder,
userStorage,
sessionStorage,
[]server.Middleware{server_middleware.Recover(logger), server_middleware.Log(logger), server_middleware.Json},
)
app.Serve(config.App.ServerAddress)
}
Запустим тесты:
go test ./... -race -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic
Результат:go tool cover -func coverage.txt
$ go tool cover -func coverage.txt
go-scrum-poker-bot/config/config.go:9: NewConfig 100.0%
go-scrum-poker-bot/config/helpers.go:10: getStrEnv 100.0%
go-scrum-poker-bot/config/helpers.go:17: getIntEnv 100.0%
go-scrum-poker-bot/config/helpers.go:26: getListStrEnv 100.0%
go-scrum-poker-bot/main.go:22: main 0.0%
go-scrum-poker-bot/storage/sessions.go:18: NewSessionRedisStorage 100.0%
go-scrum-poker-bot/storage/sessions.go:25: GetVisibility 100.0%
go-scrum-poker-bot/storage/sessions.go:33: SetVisibility 100.0%
go-scrum-poker-bot/storage/users.go:18: NewUserRedisStorage 100.0%
go-scrum-poker-bot/storage/users.go:25: All 100.0%
go-scrum-poker-bot/storage/users.go:34: Save 100.0%
go-scrum-poker-bot/ui/blocks/action.go:9: BlockType 100.0%
go-scrum-poker-bot/ui/blocks/button.go:11: BlockType 100.0%
go-scrum-poker-bot/ui/blocks/context.go:9: BlockType 100.0%
go-scrum-poker-bot/ui/blocks/section.go:9: BlockType 100.0%
go-scrum-poker-bot/ui/blocks/select.go:10: BlockType 100.0%
go-scrum-poker-bot/ui/builder.go:14: NewBuilder 100.0%
go-scrum-poker-bot/ui/builder.go:18: getGetResultsText 100.0%
go-scrum-poker-bot/ui/builder.go:26: getResults 100.0%
go-scrum-poker-bot/ui/builder.go:41: getOptions 100.0%
go-scrum-poker-bot/ui/builder.go:50: BuildBlocks 100.0%
go-scrum-poker-bot/ui/builder.go:100: Build 100.0%
go-scrum-poker-bot/web/clients/client.go:22: NewBasicClient 100.0%
go-scrum-poker-bot/web/clients/client.go:26: makeRequest 78.9%
go-scrum-poker-bot/web/clients/client.go:65: Make 66.7%
go-scrum-poker-bot/web/clients/middleware/auth.go:8: Auth 100.0%
go-scrum-poker-bot/web/clients/middleware/json.go:5: JsonContentType 100.0%
go-scrum-poker-bot/web/clients/middleware/log.go:8: Log 87.5%
go-scrum-poker-bot/web/clients/request.go:12: ToBytes 100.0%
go-scrum-poker-bot/web/clients/response.go:12: Json 100.0%
go-scrum-poker-bot/web/server/handlers/healthcheck.go:10: Healthcheck 66.7%
go-scrum-poker-bot/web/server/handlers/interaction_callback.go:12: InteractionCallback 71.4%
go-scrum-poker-bot/web/server/handlers/play_poker.go:13: PlayPokerCommand 100.0%
go-scrum-poker-bot/web/server/middleware/json.go:5: Json 100.0%
go-scrum-poker-bot/web/server/middleware/log.go:8: Log 100.0%
go-scrum-poker-bot/web/server/middleware/recover.go:9: Recover 100.0%
go-scrum-poker-bot/web/server/models/callback.go:52: getSessionID 100.0%
go-scrum-poker-bot/web/server/models/callback.go:62: getSubject 100.0%
go-scrum-poker-bot/web/server/models/callback.go:72: getAction 100.0%
go-scrum-poker-bot/web/server/models/callback.go:82: SerializedData 92.3%
go-scrum-poker-bot/web/server/models/errors.go:13: ResponseError 75.0%
go-scrum-poker-bot/web/server/server.go:31: NewServer 0.0%
go-scrum-poker-bot/web/server/server.go:49: setupRouter 0.0%
go-scrum-poker-bot/web/server/server.go:67: setupMiddleware 0.0%
go-scrum-poker-bot/web/server/server.go:76: Serve 0.0%
total: (statements) 75.1%
Неплохо, но нам не нужно учитывать в 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.txt
go-scrum-poker-bot/config/config.go:9: NewConfig 100.0%
go-scrum-poker-bot/config/helpers.go:10: getStrEnv 100.0%
go-scrum-poker-bot/config/helpers.go:17: getIntEnv 100.0%
go-scrum-poker-bot/config/helpers.go:26: getListStrEnv 100.0%
go-scrum-poker-bot/storage/sessions.go:18: NewSessionRedisStorage 100.0%
go-scrum-poker-bot/storage/sessions.go:25: GetVisibility 100.0%
go-scrum-poker-bot/storage/sessions.go:33: SetVisibility 100.0%
go-scrum-poker-bot/storage/users.go:18: NewUserRedisStorage 100.0%
go-scrum-poker-bot/storage/users.go:25: All 100.0%
go-scrum-poker-bot/storage/users.go:34: Save 100.0%
go-scrum-poker-bot/ui/blocks/action.go:9: BlockType 100.0%
go-scrum-poker-bot/ui/blocks/button.go:11: BlockType 100.0%
go-scrum-poker-bot/ui/blocks/context.go:9: BlockType 100.0%
go-scrum-poker-bot/ui/blocks/section.go:9: BlockType 100.0%
go-scrum-poker-bot/ui/blocks/select.go:10: BlockType 100.0%
go-scrum-poker-bot/ui/builder.go:14: NewBuilder 100.0%
go-scrum-poker-bot/ui/builder.go:18: getGetResultsText 100.0%
go-scrum-poker-bot/ui/builder.go:26: getResults 100.0%
go-scrum-poker-bot/ui/builder.go:41: getOptions 100.0%
go-scrum-poker-bot/ui/builder.go:50: BuildBlocks 100.0%
go-scrum-poker-bot/ui/builder.go:100: Build 100.0%
go-scrum-poker-bot/web/clients/client.go:22: NewBasicClient 100.0%
go-scrum-poker-bot/web/clients/client.go:26: makeRequest 78.9%
go-scrum-poker-bot/web/clients/client.go:65: Make 66.7%
go-scrum-poker-bot/web/clients/middleware/auth.go:8: Auth 100.0%
go-scrum-poker-bot/web/clients/middleware/json.go:5: JsonContentType 100.0%
go-scrum-poker-bot/web/clients/middleware/log.go:8: Log 87.5%
go-scrum-poker-bot/web/clients/request.go:12: ToBytes 100.0%
go-scrum-poker-bot/web/clients/response.go:12: Json 100.0%
go-scrum-poker-bot/web/server/handlers/healthcheck.go:10: Healthcheck 66.7%
go-scrum-poker-bot/web/server/handlers/interaction_callback.go:12: InteractionCallback 71.4%
go-scrum-poker-bot/web/server/handlers/play_poker.go:13: PlayPokerCommand 100.0%
go-scrum-poker-bot/web/server/middleware/json.go:5: Json 100.0%
go-scrum-poker-bot/web/server/middleware/log.go:8: Log 100.0%
go-scrum-poker-bot/web/server/middleware/recover.go:9: Recover 100.0%
go-scrum-poker-bot/web/server/models/callback.go:52: getSessionID 100.0%
go-scrum-poker-bot/web/server/models/callback.go:62: getSubject 100.0%
go-scrum-poker-bot/web/server/models/callback.go:72: getAction 100.0%
go-scrum-poker-bot/web/server/models/callback.go:82: SerializedData 92.3%
go-scrum-poker-bot/web/server/models/errors.go:13: ResponseError 75.0%
total: (statements) 90.9%
Такой результат мне нравится больше :) На этом пожалуй остановлюсь. Весь код можете найти здесь. Спасибо за внимание!
===========
Источник:
habr.com
===========
Похожие новости:
- [JavaScript, Программирование, HTML, TensorFlow] Обнаружение эмоций на лице в реальном времени с помощью веб-камеры в браузере с использованием TensorFlow.js. Часть 3 (перевод)
- [Смартфоны, AR и VR] Google повышает производительность дополненной реальности на некоторых телефонах Android с двумя камерами
- [Занимательные задачки, JavaScript, Google Chrome, Браузеры] Как обойти ограничение браузера и прикрепить сразу два файла и более: мультидобавление файлов
- [Программирование микроконтроллеров, Умный дом, DIY или Сделай сам, Электроника для начинающих] Micropyserver. Реализуем Basic Auth для IoT устройств
- [Программирование, Разработка под Android] Чем отличаются Dagger, Hilt и Koin под капотом? (перевод)
- [Поисковые технологии, API] Свободное API для поиска в интернете
- [Программирование, Анализ и проектирование систем, Проектирование и рефакторинг] Погружение в CQRS (перевод)
- [Поисковая оптимизация] Это нормально, если 30-40% URL выдают ошибку 404? Google — об отношении поисковых ботов к битым ссылкам
- [Программирование, Профессиональная литература, Читальный зал, Копирайт] Андрей Столяров выложил в свободный доступ второе издание книги «Программирование: введение в профессию»
- [JavaScript, Программирование, HTML, TensorFlow] Обнаружение эмоций на лице в браузере с помощью глубокого обучения и TensorFlow.js. Часть 2 (перевод)
Теги для поиска: #_messendzhery (Мессенджеры), #_programmirovanie (Программирование), #_go, #_agile, #_go, #_slack, #_bot, #_golang, #_tutorial, #_scrum, #_agile, #_messendzhery (
Мессенджеры
), #_programmirovanie (
Программирование
), #_go, #_agile
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 07:38
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Здравствуйте! Сегодня мы напишем Slack бота для Scrum покера на языке Go. Писать будем по возможности без фреймворков и внешних библиотек, так как наша цель — разобраться с языком программирования Go и проверить, насколько этот язык удобен для разработки подобных проектов. ДисклеймерЯ только познаю Go и многих вещей еще не знаю. Мой основной язык разработки Python. Поэтому часто буду отсылать к нему в тех местах, где по моему мнению в Python что-то сделано удобнее или проще. Цель этих отсылок в том, чтобы породить дискуссию, ведь вполне вероятно, что эти "удобные вещи" также присутствуют в Go, просто я их не нашел. Также, отмечу, что все что будет описано ниже, можно было бы сделать гораздо проще (без разделения на слои и так далее), но мне показалось интересным написать больше с целью обучения и практики в "чистой" архитектуре. Да и тестировать так проще.Хватит прелюдий, вперед в бой!Итоговый результатАнимация работы будущего бота Для тех, кому читать код интересней, чем статью — прошу сюда.Структура приложенияРазобьем нашу программу на следующие слои. У нас предполагается слой взаимодействия (web), слой для рисования интерфейса средствами Slack UI Block Kit(ui), слой для сохранения / получения результатов (storage), а также место для хранения настроек (config). Давайте создадим следующие папки в проекте: config/
storage/ ui/ web/ -- clients/ -- server/ main.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, } } 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") } package handlers
import ( "net/http" ) func Healthcheck() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write("OK") }) } // Наш код выше
func (s *Server) setupRouter() http.Handler { router := http.NewServeMux() router.Handle( "/healthcheck", handlers.Healthcheck(), ) return router } // Наш код ниже 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
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 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!"}`)) }) } func (s *Server) setupRouter() http.Handler {
router := http.NewServeMux() router.Handle( "/healthcheck", handlers.Healthcheck(), ) router.Handle( "/play-poker", handlers.PlayPokerCommand(), ) return router } Но это не единственный вариант взаимодействия со 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) } 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 } 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) } } 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) } 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) } 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) } 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) } } } 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) } 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 } }) } 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 } 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) } 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} } 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 } 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() }) } 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) } https://ffd3cfcc460c.ngrok.io/interactivity
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 } }) } 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 } 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 } 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) } } // Существующий код
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 } // Существующий код
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 } // Существующий код 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) }) } } 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()) } 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 } 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)) } 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() } 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)) } 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") } 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-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% //+build !test
go test ./... -race -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic -tags=test
$ go tool cover -func coverage.txt
go-scrum-poker-bot/config/config.go:9: NewConfig 100.0% go-scrum-poker-bot/config/helpers.go:10: getStrEnv 100.0% go-scrum-poker-bot/config/helpers.go:17: getIntEnv 100.0% go-scrum-poker-bot/config/helpers.go:26: getListStrEnv 100.0% go-scrum-poker-bot/storage/sessions.go:18: NewSessionRedisStorage 100.0% go-scrum-poker-bot/storage/sessions.go:25: GetVisibility 100.0% go-scrum-poker-bot/storage/sessions.go:33: SetVisibility 100.0% go-scrum-poker-bot/storage/users.go:18: NewUserRedisStorage 100.0% go-scrum-poker-bot/storage/users.go:25: All 100.0% go-scrum-poker-bot/storage/users.go:34: Save 100.0% go-scrum-poker-bot/ui/blocks/action.go:9: BlockType 100.0% go-scrum-poker-bot/ui/blocks/button.go:11: BlockType 100.0% go-scrum-poker-bot/ui/blocks/context.go:9: BlockType 100.0% go-scrum-poker-bot/ui/blocks/section.go:9: BlockType 100.0% go-scrum-poker-bot/ui/blocks/select.go:10: BlockType 100.0% go-scrum-poker-bot/ui/builder.go:14: NewBuilder 100.0% go-scrum-poker-bot/ui/builder.go:18: getGetResultsText 100.0% go-scrum-poker-bot/ui/builder.go:26: getResults 100.0% go-scrum-poker-bot/ui/builder.go:41: getOptions 100.0% go-scrum-poker-bot/ui/builder.go:50: BuildBlocks 100.0% go-scrum-poker-bot/ui/builder.go:100: Build 100.0% go-scrum-poker-bot/web/clients/client.go:22: NewBasicClient 100.0% go-scrum-poker-bot/web/clients/client.go:26: makeRequest 78.9% go-scrum-poker-bot/web/clients/client.go:65: Make 66.7% go-scrum-poker-bot/web/clients/middleware/auth.go:8: Auth 100.0% go-scrum-poker-bot/web/clients/middleware/json.go:5: JsonContentType 100.0% go-scrum-poker-bot/web/clients/middleware/log.go:8: Log 87.5% go-scrum-poker-bot/web/clients/request.go:12: ToBytes 100.0% go-scrum-poker-bot/web/clients/response.go:12: Json 100.0% go-scrum-poker-bot/web/server/handlers/healthcheck.go:10: Healthcheck 66.7% go-scrum-poker-bot/web/server/handlers/interaction_callback.go:12: InteractionCallback 71.4% go-scrum-poker-bot/web/server/handlers/play_poker.go:13: PlayPokerCommand 100.0% go-scrum-poker-bot/web/server/middleware/json.go:5: Json 100.0% go-scrum-poker-bot/web/server/middleware/log.go:8: Log 100.0% go-scrum-poker-bot/web/server/middleware/recover.go:9: Recover 100.0% go-scrum-poker-bot/web/server/models/callback.go:52: getSessionID 100.0% go-scrum-poker-bot/web/server/models/callback.go:62: getSubject 100.0% go-scrum-poker-bot/web/server/models/callback.go:72: getAction 100.0% go-scrum-poker-bot/web/server/models/callback.go:82: SerializedData 92.3% go-scrum-poker-bot/web/server/models/errors.go:13: ResponseError 75.0% total: (statements) 90.9% =========== Источник: habr.com =========== Похожие новости:
Мессенджеры ), #_programmirovanie ( Программирование ), #_go, #_agile |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 07:38
Часовой пояс: UTC + 5