diff --git a/.github/workflows/go-test.yml b/.github/workflows/_go-test.yml similarity index 61% rename from .github/workflows/go-test.yml rename to .github/workflows/_go-test.yml index 4c5f054..425b670 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/_go-test.yml @@ -8,8 +8,8 @@ jobs: name: test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version: '1.22' - run: go test -race -v ./... diff --git a/.github/workflows/_golangci-lint.yml b/.github/workflows/_golangci-lint.yml new file mode 100644 index 0000000..88dc298 --- /dev/null +++ b/.github/workflows/_golangci-lint.yml @@ -0,0 +1,15 @@ +name: golangci-lint + +on: + workflow_call: + +jobs: + golangci-lint: + name: run + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.22' + - uses: golangci/golangci-lint-action@v4 diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/_goreleaser.yml similarity index 65% rename from .github/workflows/goreleaser.yml rename to .github/workflows/_goreleaser.yml index a229950..3c47e03 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/_goreleaser.yml @@ -11,11 +11,11 @@ jobs: name: release runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: - go-version: '1.21' - - uses: goreleaser/goreleaser-action@v1 + go-version: '1.22' + - uses: goreleaser/goreleaser-action@v5 with: args: release --clean env: diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml deleted file mode 100644 index a1135b2..0000000 --- a/.github/workflows/golangci-lint.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: golangci-lint - -on: - workflow_call: - -jobs: - golangci-lint: - name: run - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 - with: - go-version: '1.21' - - uses: golangci/golangci-lint-action@v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3b5dd8b..291ea45 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,11 +10,11 @@ on: jobs: go-test: - uses: ./.github/workflows/go-test.yml + uses: ./.github/workflows/_go-test.yml golangci-lint: - uses: ./.github/workflows/golangci-lint.yml + uses: ./.github/workflows/_golangci-lint.yml goreleaser: needs: - go-test - golangci-lint - uses: ./.github/workflows/goreleaser.yml + uses: ./.github/workflows/_goreleaser.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9840676..aac8dd6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,6 @@ on: jobs: go-test: - uses: ./.github/workflows/go-test.yml + uses: ./.github/workflows/_go-test.yml golangci-lint: - uses: ./.github/workflows/golangci-lint.yml + uses: ./.github/workflows/_golangci-lint.yml diff --git a/.gitignore b/.gitignore index ebc0987..f47cb20 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ *.out -dist/ diff --git a/.golangci.yaml b/.golangci.yaml index 5382145..a3d03bf 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -1,10 +1,11 @@ run: timeout: 2m modules-download-mode: readonly - go: 1.21 + go: "1.22" output: - format: colored-line-number + formats: + - format: colored-line-number sort-results: true linters-settings: @@ -13,14 +14,10 @@ linters-settings: statements: 50 varnamelen: min-name-length: 2 - cyclop: - max-complexity: 15 exhaustruct: exclude: - 'net/http\..*' - github.com/a-kataev/tg.Chat - wsl: - allow-assign-and-anything: true tagliatelle: case: rules: @@ -44,6 +41,7 @@ linters: - bodyclose - containedctx - contextcheck + - copyloopvar - cyclop ## - deadcode # deprecated - decorder @@ -55,7 +53,6 @@ linters: - errcheck - errchkjson - errname - - errorlint - execinquery - exhaustive ## - exhaustivestruct # deprecated @@ -69,6 +66,7 @@ linters: - gocheckcompilerdirectives - gochecknoglobals - gochecknoinits + - gochecksumtype - gocognit - goconst - gocritic @@ -79,7 +77,6 @@ linters: - gofmt - gofumpt - goheader - - goimports ## - golint # deprecated - gomnd - gomoddirectives @@ -87,19 +84,22 @@ linters: - goprintffuncname - gosec - gosimple + - gosmopolitan - govet - grouper ## - ifshort # deprecated - - importas + - inamedparam - ineffassign - interfacebloat ## - interfacer # deprecated + - intrange - ireturn - lll - loggercheck - maintidx - makezero ## - maligned # deprecated + - mirror - misspell - musttag - nakedret @@ -112,28 +112,26 @@ linters: - nonamedreturns ## - nosnakecase # deprecated - nosprintfhostport - - paralleltest + - perfsprint - prealloc - predeclared - promlinter + - protogetter - reassign - revive - rowserrcheck ## - scopelint # deprecated - - sqlclosecheck + - sloglint + - spancheck - staticcheck ## - structcheck # deprecated - - stylecheck + - tagalign - tagliatelle - tenv - - testableexamples - - testpackage - thelper - tparallel - - typecheck - unconvert - unparam - - unused - usestdlibvars ## - varcheck # deprecated - varnamelen @@ -141,3 +139,4 @@ linters: - whitespace - wrapcheck - wsl + - zerologlint diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..8388f03 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @a-kataev diff --git a/cmd/tg/main.go b/cmd/tg/main.go index cd11b79..36b946d 100644 --- a/cmd/tg/main.go +++ b/cmd/tg/main.go @@ -12,6 +12,12 @@ import ( "github.com/a-kataev/tg" ) +func logFatal(log *slog.Logger, msg string) { + log.Error(msg) + + os.Exit(1) +} + func main() { fset := flag.NewFlagSet("", flag.ContinueOnError) fset.SetOutput(io.Discard) @@ -27,12 +33,6 @@ func main() { log := slog.New(slog.NewJSONHandler(os.Stdout, nil)) - logFatal := func(msg string) { - log.Error(msg) - - os.Exit(1) - } - err := fset.Parse(os.Args[1:]) if err != nil { if errors.Is(flag.ErrHelp, err) { @@ -43,7 +43,7 @@ func main() { os.Exit(1) } - logFatal(err.Error()) + logFatal(log, err.Error()) } if *token == "" { @@ -54,40 +54,40 @@ func main() { stdin, err := io.ReadAll(io.LimitReader(os.Stdin, int64(tg.MaxTextSize))) if err != nil { if !errors.Is(err, io.EOF) { - logFatal(err.Error()) + logFatal(log, err.Error()) } } *text = string(stdin) } - tgb, err := tg.NewTG(*token) + client, err := tg.NewClient(*token) if err != nil { - logFatal(err.Error()) + logFatal(log, err.Error()) } ctx := context.Background() - bot, err := tgb.GetMe(ctx) + bot, err := client.GetMe(ctx) if err != nil { - logFatal(err.Error()) + logFatal(log, err.Error()) } log = log.With(slog.String("bot_name", bot.UserName)) - msg, err := tgb.SendMessage(ctx, *chatID, *text, - tg.ChatParseMode(tg.ParseMode(*parseMode)), - tg.ChatMessageThreadID(*messageThreadID), - tg.ChatDisableWebPagePreview(*disableWebPagePreview), - tg.ChatDisableNotification(*disableNotification), - tg.ChatProtectContent(*protectContent), + msg, err := client.SendMessage(ctx, *chatID, *text, + tg.ParseModeSendOption(tg.ParseMode(*parseMode)), + tg.MessageThreadIDSendOption(*messageThreadID), + tg.DisableWebPagePreviewSendOption(*disableWebPagePreview), + tg.DisableNotificationSendOption(*disableNotification), + tg.ProtectContentSendOption(*protectContent), ) if err != nil { - logFatal(err.Error()) + logFatal(log, err.Error()) } log.Info("Success send message", slog.Int64("chat_id", *chatID), - slog.Int("message_id", msg.MessageID), + slog.Any("message_id", msg.MessageID), ) } diff --git a/go.mod b/go.mod index 6f4c93f..ef89bdf 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/a-kataev/tg -go 1.21 +go 1.22 require github.com/stretchr/testify v1.9.0 diff --git a/tg.go b/tg.go index dc0c8b5..d9d6605 100644 --- a/tg.go +++ b/tg.go @@ -9,19 +9,12 @@ import ( "io" "net/http" "net/url" + "reflect" "regexp" + "slices" "time" ) -const apiServer = "https://api.telegram.org" - -type apiMethod string - -const ( - apiMethodGetMe apiMethod = "getMe" - apiMethodSendMessage apiMethod = "sendMessage" -) - type ParseMode string const ( @@ -34,191 +27,269 @@ var parseModeList = []ParseMode{ //nolint:gochecknoglobals MarkdownV2ParseMode, MarkdownParseMode, HTMLParseMode, + "", +} + +var ErrUnknownParseMode = errors.New("unknown parse_mode") + +func (m ParseMode) Validate() error { + if !slices.Contains(parseModeList, m) { + return ErrUnknownParseMode + } + + return nil } +type BaseMessage struct { + ChatID int64 `json:"chat_id"` + Text string `json:"text"` + ParseMode ParseMode `json:"parse_mode,omitempty"` +} + +const MaxTextSize int = 4096 + var ( - ErrInvalidScheme = errors.New("invalid scheme") - ErrEmptyHost = errors.New("empty host") - ErrClientNil = errors.New("client is nil") - ErrModeUnknown = errors.New("unknown mode") - ErrInvalidToken = errors.New("invalid token") - ErrInvalidThreadID = errors.New("invalid thread id") - ErrEmptyText = errors.New("empty text") - ErrExceedsMaxText = errors.New("exeeds max text") + ErrEmptyChatID = errors.New("empty chat_id") + ErrEmptyText = errors.New("empty text") + ErrTextTooLong = errors.New("text too long") ) -// User . -type User struct { - ID int64 `json:"id"` - FirstName string `json:"first_name"` - UserName string `json:"username,omitempty"` +func (bm *BaseMessage) Validate() error { + if bm.ChatID == 0 { + return ErrEmptyChatID + } + + if bm.Text == "" { + return ErrEmptyText + } + + if len(bm.Text) > MaxTextSize { + return ErrTextTooLong + } + + if err := bm.ParseMode.Validate(); err != nil { + return err + } + + return nil } -// Chat . -type Chat struct { - ChatID int64 `json:"chat_id"` - Text string `json:"text"` - ParseMode string `json:"parse_mode,omitempty"` - MessageThreadID int64 `json:"message_thread_id,omitempty"` - DisableWebPagePreview bool `json:"disable_web_page_preview,omitempty"` - DisableNotification bool `json:"disable_notification,omitempty"` - ProtectContent bool `json:"protect_content,omitempty"` +type SendMessage struct { + BaseMessage + MessageThreadID int64 `json:"message_thread_id,omitempty"` + DisableWebPagePreview bool `json:"disable_web_page_preview,omitempty"` + DisableNotification bool `json:"disable_notification,omitempty"` + ProtectContent bool `json:"protect_content,omitempty"` } -type ChatOption func(*Chat) error +var ErrIncorrectMessageThreadID = errors.New("incorrect message_thread_id") -func ChatParseMode(mode ParseMode) ChatOption { - return func(ch *Chat) error { - for _, item := range parseModeList { - if mode == item { - ch.ParseMode = string(mode) +func (sm *SendMessage) Validate() error { + if err := sm.BaseMessage.Validate(); err != nil { + return err + } - return nil - } - } + if sm.MessageThreadID < 0 { + return ErrIncorrectMessageThreadID + } - return fmt.Errorf("MessageParseMode: %w", ErrModeUnknown) + return nil +} + +type SendOption func(*SendMessage) + +func NewSendMessage(chatID int64, text string, opts ...SendOption) (*SendMessage, error) { + sm := new(SendMessage) + + for _, opt := range opts { + opt(sm) + } + + sm.ChatID = chatID + sm.Text = text + + if err := sm.Validate(); err != nil { + return nil, fmt.Errorf("SendMessage: %w", err) } + + return sm, nil } -func ChatMessageThreadID(threadID int64) ChatOption { - return func(ch *Chat) error { - if threadID < 0 { - return ErrInvalidThreadID - } +func ParseModeSendOption(mode ParseMode) SendOption { + return func(sm *SendMessage) { + sm.ParseMode = mode + } +} - ch.MessageThreadID = threadID +func MessageThreadIDSendOption(threadID int64) SendOption { + return func(sm *SendMessage) { + sm.MessageThreadID = threadID + } +} - return nil +func DisableWebPagePreviewSendOption(disable bool) SendOption { + return func(sm *SendMessage) { + sm.DisableWebPagePreview = disable } } -func ChatDisableWebPagePreview(disable bool) ChatOption { - return func(ch *Chat) error { - ch.DisableWebPagePreview = disable +func DisableNotificationSendOption(disable bool) SendOption { + return func(sm *SendMessage) { + sm.DisableNotification = disable + } +} - return nil +func ProtectContentSendOption(protect bool) SendOption { + return func(sm *SendMessage) { + sm.ProtectContent = protect } } -func ChatDisableNotification(disable bool) ChatOption { - return func(ch *Chat) error { - ch.DisableNotification = disable +type EditMessage struct { + MessageID int64 `json:"message_id"` + BaseMessage +} - return nil +var ErrIncorrectMessageID = errors.New("incorrect message_id") + +func (em *EditMessage) Validate() error { + if em.MessageID <= 0 { + return ErrIncorrectMessageID } + + return em.BaseMessage.Validate() } -func ChatProtectContent(protect bool) ChatOption { - return func(ch *Chat) error { - ch.ProtectContent = protect +type EditOption func(*EditMessage) - return nil +func NewEditMessage(chatID int64, messageID int64, text string, opts ...EditOption) (*EditMessage, error) { + em := new(EditMessage) + + for _, opt := range opts { + opt(em) } + + em.ChatID = chatID + em.MessageID = messageID + em.Text = text + + if err := em.Validate(); err != nil { + return nil, fmt.Errorf("EditMessage: %w", err) + } + + return em, nil } -// Message . -type Message struct { - MessageID int `json:"message_id"` - Date int `json:"date"` +func ParseModeEditOption(mode ParseMode) EditOption { + return func(em *EditMessage) { + em.ParseMode = mode + } } -// APIResponse . -type APIResponse struct { - Result interface{} `json:"result,omitempty"` - APIResponseError +type DeleteMessage struct { + ChatID int64 `json:"chat_id"` + MessageID int64 `json:"message_id"` } -// APIResponseError . -type APIResponseError struct { - Ok bool `json:"ok"` - ErrorCode int `json:"error_code,omitempty"` - Description string `json:"description,omitempty"` - Parameters struct { - RetryAfter int `json:"retry_after,omitempty"` - } `json:"parameters,omitempty"` +func (dm *DeleteMessage) Validate() error { + if dm.ChatID == 0 { + return ErrEmptyChatID + } + + if dm.MessageID <= 0 { + return ErrIncorrectMessageID + } + + return nil } -func (r APIResponseError) Error() string { - return r.Description +func NewDeleteMessage(chatID int64, messageID int64) (*DeleteMessage, error) { + dm := new(DeleteMessage) + + dm.ChatID = chatID + dm.MessageID = messageID + + if err := dm.Validate(); err != nil { + return nil, fmt.Errorf("DeleteMessage: %w", err) + } + + return dm, nil } -var ( - regexpBotToken = regexp.MustCompile(`/bot([\d]+):([\d\w\-]+)/`) - regexpToken = regexp.MustCompile(`^([\d]+):([\d\w\-]+)$`) -) +type User struct { + ID int64 `json:"id"` + FirstName string `json:"first_name"` + UserName string `json:"username,omitempty"` +} -type redactError struct { - err error +type Message struct { + MessageID int64 `json:"message_id"` + Date int `json:"date"` } -func newRedactError(err error) error { - return &redactError{ - err: err, - } +type TG interface { + GetMe(ctx context.Context) (*User, error) + SendMessage(ctx context.Context, chatID int64, text string, opts ...SendOption) (*Message, error) + EditMessage(ctx context.Context, chatID, messageID int64, text string, opts ...EditOption) (*Message, error) + DeleteMessage(ctx context.Context, chatID, messageID int64) (bool, error) } -func (e *redactError) Error() string { - return regexpBotToken.ReplaceAllString(e.err.Error(), "/bot*****/") +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) } -func (e *redactError) Unwrap() error { - return e.err +type Client struct { + http HTTPClient + endpoint string } -type Option func(*TG) error +var _ TG = (*Client)(nil) -func APIServer(server string) Option { - return func(tg *TG) error { +type Option func(*Client) error + +var ( + ErrIncorrectScheme = errors.New("incorrect scheme") + ErrEmptyHost = errors.New("empty host") +) + +func WithAPIServer(server string) Option { + return func(cl *Client) error { url, err := url.ParseRequestURI(server) if err != nil { - return fmt.Errorf("APIServer: %w", err) + return fmt.Errorf("apiserver: %w", err) } if url.Scheme != "http" && url.Scheme != "https" { - return fmt.Errorf("APIServer: %w", ErrInvalidScheme) + return fmt.Errorf("apiserver: url: %w", ErrIncorrectScheme) } if url.Host == "" { - return fmt.Errorf("APIServer: %w", ErrEmptyHost) + return fmt.Errorf("apiserver: url: %w", ErrEmptyHost) } - tg.endpoint = server + cl.endpoint = server return nil } } -func HTTPClient(client *http.Client) Option { - return func(t *TG) error { +var ErrHTTPClientNil = errors.New("httpclient is nil") + +func WithHTTPClient(client HTTPClient) Option { + return func(cl *Client) error { if client == nil { - return fmt.Errorf("HTTPClient: %w", ErrClientNil) + return ErrHTTPClientNil } - t.http = client + cl.http = client return nil } } -type tg interface { - GetMe(ctx context.Context) (*User, error) - SendMessage(ctx context.Context, chatID int64, text string, opts ...ChatOption) (*Message, error) -} - -type httpClient interface { - Do(*http.Request) (*http.Response, error) -} - -// TG . -type TG struct { - http httpClient - endpoint string -} - -var _ tg = (*TG)(nil) +const defaultAPIServer = "https://api.telegram.org" -//nolint:gochecknoglobals,gomnd +//nolint:gomnd,gochecknoglobals var defaultHTTPClient = &http.Client{ Timeout: 2 * time.Second, Transport: &http.Transport{ @@ -227,135 +298,192 @@ var defaultHTTPClient = &http.Client{ }, } -// NewTG . -func NewTG(token string, options ...Option) (*TG, error) { +var regexpToken = regexp.MustCompile(`^([\d]+):([\d\w\-]+)$`) + +var ErrIncorrentToken = errors.New("incorrect token") + +func NewClient(token string, options ...Option) (*Client, error) { if !regexpToken.MatchString(token) { - return nil, fmt.Errorf("TG: %w", ErrInvalidToken) + return nil, fmt.Errorf("Client: %w", ErrIncorrentToken) } - tg := &TG{ - http: nil, - endpoint: apiServer, - } + client := new(Client) + client.endpoint = defaultAPIServer for _, opt := range options { - if err := opt(tg); err != nil { - return nil, fmt.Errorf("TG: %w", err) + if err := opt(client); err != nil { + return nil, fmt.Errorf("Client: %w", err) } } - if tg.http == nil { - tg.http = defaultHTTPClient + if client.http == nil { + client.http = defaultHTTPClient } - tg.endpoint += "/bot" + token + "/" + client.endpoint += "/bot" + token + "/" - return tg, nil + return client, nil } -func (t *TG) makeMessage(chatID int64, text string, opts ...ChatOption) (io.Reader, error) { - chat := &Chat{ - ChatID: chatID, - Text: text, +type Response struct { + Result interface{} `json:"result,omitempty"` + ResponseError +} + +type ResponseError struct { + Ok bool `json:"ok"` + ErrorCode int `json:"error_code,omitempty"` + Description string `json:"description,omitempty"` + Parameters struct { + RetryAfter int `json:"retry_after,omitempty"` + } `json:"parameters,omitempty"` +} + +func (r ResponseError) Error() string { + return r.Description +} + +var ( + ErrValueNil = errors.New("value is nil") + ErrValueNotPtr = errors.New("value not ptr") + ErrValueNotStructOrBool = errors.New("value not struct or bool") +) + +func validate(v any) error { + if v == nil { + return ErrValueNil } - for _, opt := range opts { - if err := opt(chat); err != nil { - return nil, fmt.Errorf("makeMessage: option: %w", err) - } + value := reflect.ValueOf(v) + + if value.Type().Kind() != reflect.Ptr { + return ErrValueNotPtr } - body, err := json.Marshal(chat) - if err != nil { - return nil, fmt.Errorf("makeMessage: json: %w", err) + if value.Type().Kind() == reflect.Ptr { + value = value.Elem() } - return bytes.NewReader(body), nil + if value.Kind() != reflect.Struct && value.Kind() != reflect.Bool { + return ErrValueNotStructOrBool + } + + return nil } -func (t *TG) makeRequest(ctx context.Context, method apiMethod, reader io.Reader) (*http.Request, error) { - url := t.endpoint + string(method) +func (c *Client) API(ctx context.Context, method string, req, resp any) error { + var reqBody io.Reader - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, reader) + if req != nil { + if err := validate(req); err != nil { + return fmt.Errorf("validate: req %w", err) + } + + body, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("request: json: %w", err) + } + + reqBody = bytes.NewReader(body) + } + + if err := validate(resp); err != nil { + return fmt.Errorf("validate: resp %w", err) + } + + url := c.endpoint + method + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, reqBody) if err != nil { - return nil, fmt.Errorf("makeMessage: request: %w", err) + return fmt.Errorf("request: %w", err) } - req.Header.Add("Content-Type", "application/json") + httpReq.Header.Add("Content-Type", "application/json") - return req, nil -} + httpResp, err := c.http.Do(httpReq) + if err != nil { + return fmt.Errorf("request: %w", err) + } -func (t *TG) makeResponse(resp *http.Response, result interface{}) error { - apiResp := new(APIResponse) - apiResp.Result = result + defer httpResp.Body.Close() - defer resp.Body.Close() + respBody := new(Response) + respBody.Result = resp - if err := json.NewDecoder(resp.Body).Decode(apiResp); err != nil { - return fmt.Errorf("makeResponse: body: %w", err) + if err := json.NewDecoder(httpResp.Body).Decode(respBody); err != nil { + return fmt.Errorf("response: json: %w", err) } - if !apiResp.Ok { - return apiResp.APIResponseError + if !respBody.Ok { + return fmt.Errorf("response: %w", respBody.ResponseError) } return nil } -// GetMe . -func (t *TG) GetMe(ctx context.Context) (*User, error) { - req, err := t.makeRequest(ctx, apiMethodGetMe, nil) - if err != nil { +const getMeMethod = "getMe" + +func (c *Client) GetMe(ctx context.Context) (*User, error) { + resp := new(User) + + if err := c.API(ctx, getMeMethod, nil, resp); err != nil { return nil, fmt.Errorf("GetMe: %w", err) } - resp, err := t.http.Do(req) + return resp, nil +} + +const sendMessageMethod = "sendMessage" + +func (c *Client) SendMessage(ctx context.Context, + chatID int64, text string, opts ...SendOption, +) (*Message, error) { + req, err := NewSendMessage(chatID, text, opts...) if err != nil { - return nil, fmt.Errorf("GetMe: http: %w", newRedactError(err)) + return nil, fmt.Errorf("SendMessage: %w", err) } - user := new(User) + resp := new(Message) - if err := t.makeResponse(resp, user); err != nil { - return nil, fmt.Errorf("GetMe: %w", err) + if err := c.API(ctx, sendMessageMethod, req, resp); err != nil { + return nil, fmt.Errorf("SendMessage: %w", err) } - return user, nil + return resp, nil } -const MaxTextSize int = 4096 +const editMessageTextMethod = "editMessageText" -// SendMessage . -func (t *TG) SendMessage(ctx context.Context, chatID int64, text string, opts ...ChatOption) (*Message, error) { - if text == "" { - return nil, fmt.Errorf("SendMessage: %w", ErrEmptyText) +func (c *Client) EditMessage(ctx context.Context, + chatID, messageID int64, text string, opts ...EditOption, +) (*Message, error) { + req, err := NewEditMessage(chatID, messageID, text, opts...) + if err != nil { + return nil, fmt.Errorf("EditMessage: %w", err) } - if len(text) > MaxTextSize { - return nil, fmt.Errorf("SendMessage: %w", ErrExceedsMaxText) - } + resp := new(Message) - reader, err := t.makeMessage(chatID, text, opts...) - if err != nil { - return nil, fmt.Errorf("SendMessage: %w", err) + if err := c.API(ctx, editMessageTextMethod, req, resp); err != nil { + return nil, fmt.Errorf("EditMessage: %w", err) } - req, err := t.makeRequest(ctx, apiMethodSendMessage, reader) - if err != nil { - return nil, fmt.Errorf("SendMessage: %w", err) - } + return resp, nil +} - resp, err := t.http.Do(req) +const deleteMessageMethod = "deleteMessage" + +func (c *Client) DeleteMessage(ctx context.Context, chatID, messageID int64) (bool, error) { + req, err := NewDeleteMessage(chatID, messageID) if err != nil { - return nil, fmt.Errorf("SendMessage: http: %w", newRedactError(err)) + return false, fmt.Errorf("DeleteMessage: %w", err) } - message := new(Message) + resp := false - if err := t.makeResponse(resp, message); err != nil { - return nil, fmt.Errorf("SendMessage: %w", err) + if err := c.API(ctx, deleteMessageMethod, req, &resp); err != nil { + return false, fmt.Errorf("DeleteMessage: %w", err) } - return message, nil + return resp, nil } diff --git a/tg_mock_test.go b/tg_mock_test.go index 8ce085c..ab6c57b 100644 --- a/tg_mock_test.go +++ b/tg_mock_test.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.26.1. DO NOT EDIT. +// Code generated by mockery v2.42.1. DO NOT EDIT. package tg @@ -17,6 +17,10 @@ type mockHTTPClient struct { func (_m *mockHTTPClient) Do(_a0 *http.Request) (*http.Response, error) { ret := _m.Called(_a0) + if len(ret) == 0 { + panic("no return value specified for Do") + } + var r0 *http.Response var r1 error if rf, ok := ret.Get(0).(func(*http.Request) (*http.Response, error)); ok { @@ -39,13 +43,12 @@ func (_m *mockHTTPClient) Do(_a0 *http.Request) (*http.Response, error) { return r0, r1 } -type mockConstructorTestingTnewMockHTTPClient interface { +// newMockHTTPClient creates a new instance of mockHTTPClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newMockHTTPClient(t interface { mock.TestingT Cleanup(func()) -} - -// newMockHTTPClient creates a new instance of mockHTTPClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func newMockHTTPClient(t mockConstructorTestingTnewMockHTTPClient) *mockHTTPClient { +}) *mockHTTPClient { mock := &mockHTTPClient{} mock.Mock.Test(t) diff --git a/tg_test.go b/tg_test.go index fbf6a4a..bd07a87 100644 --- a/tg_test.go +++ b/tg_test.go @@ -1,206 +1,551 @@ -package tg //nolint:testpackage +//nolint:exhaustruct +package tg import ( "bytes" "context" + "encoding/json" "errors" + "fmt" "io" + "math/rand" "net/http" - "strconv" + "net/url" + "reflect" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) -func newTestTG() *TG { - return &TG{ - http: nil, - endpoint: "", - } -} +//nolint:gochecknoglobals,gosec +var ( + testBadParseMode = ParseMode("test") -func Test_makeRequest(t *testing.T) { - testTG := newTestTG() + testText = string(make([]byte, rand.Intn(MaxTextSize-1)+1)) + testBadText = string(make([]byte, MaxTextSize+1)) + testToken = "1:test" +) + +func Test_ParseMode_Validate(t *testing.T) { t.Parallel() - t.Run("1", func(t *testing.T) { - t.Parallel() - request, err := testTG.makeRequest(nil, apiMethod(""), nil) //nolint:staticcheck - assert.Nil(t, request) - assert.EqualError(t, err, "makeMessage: request: net/http: nil Context") - }) - - t.Run("2", func(t *testing.T) { - t.Parallel() - request, err := testTG.makeRequest(context.Background(), apiMethod(""), nil) - assert.IsType(t, &http.Request{}, request) - assert.Nil(t, err) - }) + tests := []struct { + desc string + mode ParseMode + result error + }{ + { + desc: ErrUnknownParseMode.Error(), + mode: testBadParseMode, + result: ErrUnknownParseMode, + }, + { + desc: "nil_result", + mode: MarkdownParseMode, + result: nil, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, test.mode.Validate(), test.result) + }) + } } -var errTest = errors.New("test") +func Test_BaseMessage_Validate(t *testing.T) { + t.Parallel() -type errReader struct{} + tests := []struct { + desc string + msg func() *BaseMessage + result error + }{ + { + desc: ErrEmptyChatID.Error(), + msg: func() *BaseMessage { return &BaseMessage{} }, + result: ErrEmptyChatID, + }, + { + desc: ErrEmptyText.Error(), + msg: func() *BaseMessage { + return &BaseMessage{ + ChatID: 1, + } + }, + result: ErrEmptyText, + }, + { + desc: ErrTextTooLong.Error(), + msg: func() *BaseMessage { + return &BaseMessage{ + ChatID: 1, + Text: testBadText, + } + }, + result: ErrTextTooLong, + }, + { + desc: ErrUnknownParseMode.Error(), + msg: func() *BaseMessage { + return &BaseMessage{ + ChatID: 1, + Text: testText, + ParseMode: testBadParseMode, + } + }, + result: ErrUnknownParseMode, + }, + { + desc: "nil_result", + msg: func() *BaseMessage { + return &BaseMessage{ + ChatID: 1, + Text: testText, + ParseMode: HTMLParseMode, + } + }, + result: nil, + }, + } -func (e *errReader) Read(_ []byte) (int, error) { - return 0, errTest -} + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() -func (e *errReader) Close() error { - return errTest + assert.Equal(t, test.msg().Validate(), test.result) + }) + } } -func Test_makeResponse_BadReader(t *testing.T) { - testTG := newTestTG() - +func Test_SendMessage_Validate(t *testing.T) { t.Parallel() - err := testTG.makeResponse(&http.Response{ - Body: &errReader{}, - }, nil) - assert.NotNil(t, err) - assert.EqualError(t, err, "makeResponse: body: test") -} -func Test_makeResponse_Cases(t *testing.T) { - testTG := newTestTG() + tests := []struct { + desc string + msg func() *SendMessage + result error + }{ + { + desc: ErrIncorrectMessageThreadID.Error(), + msg: func() *SendMessage { + return &SendMessage{ + BaseMessage: BaseMessage{ + ChatID: 1, + Text: testText, + ParseMode: HTMLParseMode, + }, + MessageThreadID: -1, + } + }, + result: ErrIncorrectMessageThreadID, + }, + { + desc: ErrEmptyChatID.Error(), + msg: func() *SendMessage { return &SendMessage{} }, + result: ErrEmptyChatID, + }, + { + desc: "nil_result", + msg: func() *SendMessage { + return &SendMessage{ + BaseMessage: BaseMessage{ + ChatID: 1, + Text: testText, + ParseMode: MarkdownV2ParseMode, + }, + MessageThreadID: 0, + } + }, + result: nil, + }, + } - t.Parallel() + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() - type table struct { - responseBody []byte - clientError string + assert.Equal(t, test.msg().Validate(), test.result) + }) } +} - tables := []table{ +func Test_EditMessage_Validate(t *testing.T) { + t.Parallel() + + tests := []struct { + desc string + msg func() *EditMessage + result error + }{ + { + desc: ErrIncorrectMessageID.Error(), + msg: func() *EditMessage { + return &EditMessage{ + MessageID: 0, + BaseMessage: BaseMessage{ + ChatID: 1, + Text: testText, + ParseMode: HTMLParseMode, + }, + } + }, + result: ErrIncorrectMessageID, + }, { - responseBody: []byte{}, - clientError: "makeResponse: body: EOF", + desc: ErrIncorrectMessageID.Error(), + msg: func() *EditMessage { return &EditMessage{} }, + result: ErrIncorrectMessageID, }, { - responseBody: []byte("test"), - clientError: "makeResponse: body: invalid character 'e' in literal true (expecting 'r')", + desc: "nil_result", + msg: func() *EditMessage { + return &EditMessage{ + MessageID: 1, + BaseMessage: BaseMessage{ + ChatID: 1, + Text: testText, + ParseMode: HTMLParseMode, + }, + } + }, + result: nil, }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, test.msg().Validate(), test.result) + }) + } +} + +func Test_DeleteMessage_Validate(t *testing.T) { + t.Parallel() + + tests := []struct { + desc string + msg func() *DeleteMessage + result error + }{ { - responseBody: []byte("{}"), - clientError: "", + desc: ErrEmptyChatID.Error(), + msg: func() *DeleteMessage { return &DeleteMessage{} }, + result: ErrEmptyChatID, }, { - responseBody: []byte(`{"description":"test"}`), - clientError: "test", + desc: ErrIncorrectMessageID.Error(), + msg: func() *DeleteMessage { + return &DeleteMessage{ + ChatID: 1, + } + }, + result: ErrIncorrectMessageID, }, { - responseBody: []byte(`{"ok":"test"}`), - clientError: "makeResponse: body: json: cannot unmarshal string into Go struct field APIResponse.ok of type bool", + desc: "nil_result", + msg: func() *DeleteMessage { + return &DeleteMessage{ + ChatID: 1, + MessageID: 1, + } + }, + result: nil, }, } - test := func(tn int, table table) { - t.Run(strconv.Itoa(tn), func(t *testing.T) { + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { t.Parallel() - clientResponse := &http.Response{ - Body: io.NopCloser(bytes.NewBuffer(table.responseBody)), - } - err := testTG.makeResponse(clientResponse, nil) - assert.EqualErrorf(t, err, table.clientError, "%d", tn) + assert.Equal(t, test.msg().Validate(), test.result) }) } - - for tn, table := range tables { - test(tn, table) - } } -func Test_makeResponse_Update(t *testing.T) { - testTG := newTestTG() +func Test_validate(t *testing.T) { + t.Parallel() - testUser := &User{ - ID: 1, - FirstName: "test", - UserName: "", + tests := []struct { + desc string + value any + result error + }{ + { + desc: ErrValueNil.Error(), + value: nil, + result: ErrValueNil, + }, + { + desc: ErrValueNotPtr.Error(), + value: "", + result: ErrValueNotPtr, + }, + { + desc: ErrValueNotStructOrBool.Error(), + value: reflect.New(reflect.TypeOf("")).Interface(), + result: ErrValueNotStructOrBool, + }, + { + desc: "err_result", + value: reflect.New(reflect.TypeOf(struct{}{})).Interface(), + result: nil, + }, } - updateUser := &User{ - ID: 0, - FirstName: "", - UserName: "", + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, validate(test.value), test.result) + }) } +} +func Test_NewClient(t *testing.T) { t.Parallel() - err := testTG.makeResponse( - &http.Response{ - Body: io.NopCloser(bytes.NewBuffer([]byte(`{"ok":true,"result":{"id":1,"first_name":"test"}}`))), - }, updateUser) - assert.Nil(t, err) - assert.Equal(t, updateUser, testUser) + tests := []struct { + desc string + token string + options func() []Option + result error + }{ + { + desc: ErrIncorrentToken.Error(), + token: "test", + options: func() []Option { return []Option{} }, + result: ErrIncorrentToken, + }, + { + desc: "empty_url", + token: testToken, + options: func() []Option { + return []Option{ + WithAPIServer(""), + } + }, + result: fmt.Errorf("apiserver: %w", + &url.Error{ + Op: "parse", + URL: "", + Err: errors.New("empty url"), //nolint:goerr113 + }, + ), + }, + { + desc: ErrIncorrectScheme.Error(), + token: testToken, + options: func() []Option { + return []Option{ + WithAPIServer("test://"), + } + }, + result: fmt.Errorf("apiserver: url: %w", ErrIncorrectScheme), + }, + { + desc: ErrEmptyHost.Error(), + token: testToken, + options: func() []Option { + return []Option{ + WithAPIServer("http://"), + } + }, + result: fmt.Errorf("apiserver: url: %w", ErrEmptyHost), + }, + { + desc: ErrHTTPClientNil.Error(), + token: testToken, + options: func() []Option { + return []Option{ + WithAPIServer("http://test"), + WithHTTPClient(nil), + } + }, + result: ErrHTTPClientNil, + }, + { + desc: "err_return_options", + token: testToken, + options: func() []Option { + return []Option{ + WithAPIServer("http://test"), + WithHTTPClient(defaultHTTPClient), + } + }, + result: nil, + }, + { + desc: "err_return", + token: testToken, + options: func() []Option { + return []Option{} + }, + result: nil, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + opts := test.options() + _, err := NewClient(test.token, opts...) + + assert.Equal(t, errors.Unwrap(err), test.result) + }) + } } -func Test_makeResponse_OK(t *testing.T) { - testTG := newTestTG() +var errTest = errors.New("test") - t.Parallel() +type errReader struct{} + +func (e *errReader) Read(_ []byte) (int, error) { + return 0, errTest +} - err := testTG.makeResponse( - &http.Response{ - Body: io.NopCloser(bytes.NewBuffer([]byte(`{"ok":true}`))), - }, nil) - assert.Nil(t, err) +func (e *errReader) Close() error { + return errTest } -func Test_GetMe(t *testing.T) { +//nolint:funlen +func Test_Client_API(t *testing.T) { t.Parallel() - t.Run("1", func(t *testing.T) { - t.Parallel() - testTG := newTestTG() - user, err := testTG.GetMe(nil) //nolint:staticcheck - assert.EqualError(t, err, "GetMe: makeMessage: request: net/http: nil Context") - assert.Nil(t, user) - }) - - t.Run("2", func(t *testing.T) { - t.Parallel() - testTG := newTestTG() - testHTTPClient := &mockHTTPClient{} //nolint:exhaustruct - testHTTPClient.On("Do", mock.Anything, mock.Anything).Return(nil, errTest) - testTG.http = testHTTPClient - user, err := testTG.GetMe(context.Background()) - assert.EqualError(t, err, "GetMe: http: test") - assert.Nil(t, user) - }) - - t.Run("3", func(t *testing.T) { - t.Parallel() - testTG := newTestTG() - testHTTPClient := &mockHTTPClient{} //nolint:exhaustruct - testHTTPClient.On("Do", mock.Anything, mock.Anything).Return( - &http.Response{ - Body: &errReader{}, - }, nil) - testTG.http = testHTTPClient - user, err := testTG.GetMe(context.Background()) - assert.EqualError(t, err, "GetMe: makeResponse: body: test") - assert.Nil(t, user) - }) - - t.Run("4", func(t *testing.T) { - t.Parallel() - testTG := newTestTG() - testUser := &User{ - ID: 1, - FirstName: "test", - UserName: "", - } - testHTTPClient := &mockHTTPClient{} //nolint:exhaustruct - testHTTPClient.On("Do", mock.Anything, mock.Anything).Return( - &http.Response{ - Body: io.NopCloser(bytes.NewBuffer([]byte(`{"ok":true,"result":{"id":1,"first_name":"test"}}`))), - }, nil) - testTG.http = testHTTPClient - user, err := testTG.GetMe(context.Background()) - assert.Nil(t, err) - assert.Equal(t, user, testUser) - }) + tests := []struct { + desc string + http func() HTTPClient + req, resp func() any + result error + }{ + { + desc: ErrValueNotPtr.Error(), + http: func() HTTPClient { return nil }, + req: func() any { return 1 }, + resp: func() any { return nil }, + result: fmt.Errorf("validate: req %w", ErrValueNotPtr), + }, + { + desc: ErrValueNil.Error(), + http: func() HTTPClient { return nil }, + req: func() any { return nil }, + resp: func() any { return nil }, + result: fmt.Errorf("validate: resp %w", ErrValueNil), + }, + { + desc: errTest.Error(), + http: func() HTTPClient { + client := &mockHTTPClient{} + client.On("Do", mock.Anything, mock.Anything).Return(nil, errTest) + + return client + }, + req: func() any { return nil }, + resp: func() any { + return reflect.New(reflect.TypeOf(struct{}{})).Interface() + }, + result: fmt.Errorf("request: %w", errTest), + }, + { + desc: errTest.Error(), + http: func() HTTPClient { + client := &mockHTTPClient{} + client.On("Do", mock.Anything, mock.Anything). + Return(&http.Response{ + Body: &errReader{}, + }, + nil, + ) + + return client + }, + req: func() any { return nil }, + resp: func() any { + return reflect.New(reflect.TypeOf(struct{}{})).Interface() + }, + result: fmt.Errorf("response: json: %w", errTest), + }, + { + desc: io.EOF.Error(), + http: func() HTTPClient { + client := &mockHTTPClient{} + client.On("Do", mock.Anything, mock.Anything). + Return( + &http.Response{ + Body: io.NopCloser(bytes.NewBuffer([]byte{})), + }, + nil, + ) + + return client + }, + req: func() any { return nil }, + resp: func() any { + return reflect.New(reflect.TypeOf(struct{}{})).Interface() + }, + result: fmt.Errorf("response: json: %w", io.EOF), + }, + { + desc: io.EOF.Error(), + http: func() HTTPClient { + client := &mockHTTPClient{} + client.On("Do", mock.Anything, mock.Anything). + Return( + &http.Response{ + Body: io.NopCloser(bytes.NewBuffer([]byte("{}"))), //nolint:mirror + }, + nil, + ) + + return client + }, + req: func() any { return nil }, + resp: func() any { + return reflect.New(reflect.TypeOf(struct{}{})).Interface() + }, + result: fmt.Errorf("response: %w", ResponseError{}), + }, + { + desc: "nil_err", + http: func() HTTPClient { + resp := new(Response) + resp.Ok = true + + body, _ := json.Marshal(resp) //nolint:errchkjson + + client := &mockHTTPClient{} + client.On("Do", mock.Anything, mock.Anything).Return( + &http.Response{ + Body: io.NopCloser(bytes.NewBuffer(body)), + }, + nil, + ) + + return client + }, + req: func() any { return nil }, + resp: func() any { + return reflect.New(reflect.TypeOf(struct{}{})).Interface() + }, + result: nil, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + client := new(Client) + client.http = test.http() + + assert.Equal(t, client.API(context.Background(), "", test.req(), test.resp()), test.result) + }) + } }