Initial release of go-telegram

A fully-generated, strongly-typed Go client for the Telegram Bot API.

* 176 methods + 301 types generated from Bot API v10.0
* 1408 auto-generated tests (8 scenarios per method)
* Typed unions throughout — no 'any' in the public surface
* Pluggable HTTP transport and JSON codec (default goccy/go-json)
* Built-in retry middleware honouring Telegram's retry_after
* Generic dispatcher with filters and conversation handlers
* Self-verifying codegen pipeline (regen → audit → emit → run tests)
* 14 example bots covering common patterns
This commit is contained in:
2026-05-09 13:09:27 +01:00
commit ac7cae8fa7
164 changed files with 100239 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
package api
import (
"context"
"fmt"
"io"
"net/http"
"github.com/lukaszraczylo/go-telegram/client"
)
// DownloadFile fetches the contents of a Telegram-hosted file given a
// previously-uploaded file_id. It calls GetFile to resolve the file's
// download path, then issues an HTTP GET to the file CDN endpoint.
//
// The returned io.ReadCloser must be closed by the caller. The size of
// the file is reported via *File.FileSize when known.
//
// For files larger than 20 MB, Telegram requires a self-hosted Bot API
// server (default api.telegram.org has a 20 MB limit on getFile).
func DownloadFile(ctx context.Context, b *client.Bot, fileID string) (io.ReadCloser, *File, error) {
f, err := GetFile(ctx, b, &GetFileParams{FileID: fileID})
if err != nil {
return nil, nil, fmt.Errorf("getFile: %w", err)
}
if f == nil || f.FilePath == "" {
return nil, f, fmt.Errorf("telegram: file %q has no download path", fileID)
}
rc, err := DownloadFileByPath(ctx, b, f.FilePath)
if err != nil {
return nil, f, err
}
return rc, f, nil
}
// DownloadFileByPath fetches a file by its file_path (typically obtained
// from a prior File response). Useful when the caller already has a
// *File and wants to skip the GetFile round-trip.
func DownloadFileByPath(ctx context.Context, b *client.Bot, filePath string) (io.ReadCloser, error) {
url := fmt.Sprintf("%s/file/bot%s/%s", b.BaseURL(), b.Token(), filePath)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := b.HTTP().Do(req)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
return nil, fmt.Errorf("download: %w", err)
}
if resp.StatusCode != http.StatusOK {
_ = resp.Body.Close()
return nil, fmt.Errorf("download: HTTP %d", resp.StatusCode)
}
return resp.Body, nil
}
+80
View File
@@ -0,0 +1,80 @@
package api
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/stretchr/testify/require"
)
func TestDownloadFile_HappyPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/getFile"):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"result":{"file_id":"abc","file_unique_id":"u","file_size":11,"file_path":"documents/hello.txt"}}`))
case strings.HasPrefix(r.URL.Path, "/file/bot"):
_, _ = w.Write([]byte("hello world"))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
bot := client.New("123:abc", client.WithBaseURL(srv.URL))
rc, file, err := DownloadFile(context.Background(), bot, "abc")
require.NoError(t, err)
defer rc.Close()
require.Equal(t, "documents/hello.txt", file.FilePath)
body, err := io.ReadAll(rc)
require.NoError(t, err)
require.Equal(t, "hello world", string(body))
}
func TestDownloadFile_GetFileFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":false,"error_code":400,"description":"Bad Request: invalid file_id"}`))
}))
t.Cleanup(srv.Close)
bot := client.New("t", client.WithBaseURL(srv.URL))
_, _, err := DownloadFile(context.Background(), bot, "bad")
require.Error(t, err)
require.Contains(t, err.Error(), "getFile")
}
func TestDownloadFile_NoFilePath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// result without file_path
_, _ = w.Write([]byte(`{"ok":true,"result":{"file_id":"abc","file_unique_id":"u"}}`))
}))
t.Cleanup(srv.Close)
bot := client.New("t", client.WithBaseURL(srv.URL))
_, _, err := DownloadFile(context.Background(), bot, "abc")
require.Error(t, err)
require.Contains(t, err.Error(), "no download path")
}
func TestDownloadFileByPath_HTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/file/bot") {
w.WriteHeader(http.StatusForbidden)
return
}
http.NotFound(w, r)
}))
t.Cleanup(srv.Close)
bot := client.New("t", client.WithBaseURL(srv.URL))
_, err := DownloadFileByPath(context.Background(), bot, "secret/file")
require.Error(t, err)
require.Contains(t, err.Error(), "403")
}
+60
View File
@@ -0,0 +1,60 @@
// Code generated by cmd/genapi. DO NOT EDIT.
//go:build !ignore_autogenerated
package api
// ParseMode controls how Telegram interprets formatting in message text.
type ParseMode string
const (
ParseModeMarkdown ParseMode = "Markdown" // legacy
ParseModeMarkdownV2 ParseMode = "MarkdownV2"
ParseModeHTML ParseMode = "HTML"
)
// ChatType is the type of a Telegram chat.
type ChatType string
const (
ChatTypePrivate ChatType = "private"
ChatTypeGroup ChatType = "group"
ChatTypeSupergroup ChatType = "supergroup"
ChatTypeChannel ChatType = "channel"
)
// UpdateType identifies an Update payload variant. Used by allowed_updates
// in getUpdates / setWebhook.
type UpdateType string
const (
UpdateMessage UpdateType = "message"
UpdateEditedMessage UpdateType = "edited_message"
UpdateChannelPost UpdateType = "channel_post"
UpdateEditedChannelPost UpdateType = "edited_channel_post"
UpdateCallbackQuery UpdateType = "callback_query"
UpdateInlineQuery UpdateType = "inline_query"
)
// MessageEntityType is the kind of an entity (mention, hashtag, command, ...).
type MessageEntityType string
const (
EntityMention MessageEntityType = "mention"
EntityHashtag MessageEntityType = "hashtag"
EntityCashtag MessageEntityType = "cashtag"
EntityBotCommand MessageEntityType = "bot_command"
EntityURL MessageEntityType = "url"
EntityEmail MessageEntityType = "email"
EntityPhoneNumber MessageEntityType = "phone_number"
EntityBold MessageEntityType = "bold"
EntityItalic MessageEntityType = "italic"
EntityUnderline MessageEntityType = "underline"
EntityStrike MessageEntityType = "strikethrough"
EntitySpoiler MessageEntityType = "spoiler"
EntityCode MessageEntityType = "code"
EntityPre MessageEntityType = "pre"
EntityTextLink MessageEntityType = "text_link"
EntityTextMention MessageEntityType = "text_mention"
EntityCustomEmoji MessageEntityType = "custom_emoji"
)
+50
View File
@@ -0,0 +1,50 @@
package api
import (
"context"
"sync"
"github.com/lukaszraczylo/go-telegram/client"
)
// MeCache caches the result of GetMe across calls. Construct one per
// Bot and call Get to retrieve the cached User on subsequent invocations.
//
// var meCache api.MeCache
// me, err := meCache.Get(ctx, bot)
//
// MeCache is safe for concurrent use.
type MeCache struct {
mu sync.Mutex
cached *User
}
// Get returns the User from a cached GetMe call. If the cache is empty,
// it calls GetMe and populates the cache on success.
func (c *MeCache) Get(ctx context.Context, b *client.Bot) (*User, error) {
c.mu.Lock()
if c.cached != nil {
u := c.cached
c.mu.Unlock()
return u, nil
}
c.mu.Unlock()
u, err := GetMe(ctx, b, &GetMeParams{})
if err != nil {
return nil, err
}
c.mu.Lock()
c.cached = u
c.mu.Unlock()
return u, nil
}
// Reset clears the cache. Useful in tests or after the bot's identity
// is known to have changed (very rare).
func (c *MeCache) Reset() {
c.mu.Lock()
c.cached = nil
c.mu.Unlock()
}
+58
View File
@@ -0,0 +1,58 @@
package api
import (
"context"
"net/http"
"strings"
"sync/atomic"
"testing"
"github.com/lukaszraczylo/go-telegram/client"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestMeCache_FetchesOnce(t *testing.T) {
m := &mockDoer{}
var calls atomic.Int32
m.On("Do", mock.MatchedBy(func(r *http.Request) bool {
if strings.HasSuffix(r.URL.Path, "/getMe") {
calls.Add(1)
return true
}
return false
})).Return(newJSONResp(200, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"echo","username":"echo_bot"}}`), nil)
bot := client.New("t", client.WithHTTPClient(m))
var cache MeCache
me1, err := cache.Get(context.Background(), bot)
require.NoError(t, err)
require.Equal(t, "echo_bot", me1.Username)
me2, err := cache.Get(context.Background(), bot)
require.NoError(t, err)
require.Same(t, me1, me2)
require.Equal(t, int32(1), calls.Load(), "should fetch only once")
}
func TestMeCache_Reset(t *testing.T) {
var calls atomic.Int32
m := &mockDoer{}
m.On("Do", mock.Anything).Run(func(args mock.Arguments) {
calls.Add(1)
}).Return(newJSONResp(200, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"echo","username":"echo_bot"}}`), nil).Once()
m.On("Do", mock.Anything).Run(func(args mock.Arguments) {
calls.Add(1)
}).Return(newJSONResp(200, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"echo","username":"echo_bot"}}`), nil).Once()
bot := client.New("t", client.WithHTTPClient(m))
var cache MeCache
_, err := cache.Get(context.Background(), bot)
require.NoError(t, err)
cache.Reset()
_, err = cache.Get(context.Background(), bot)
require.NoError(t, err)
require.Equal(t, int32(2), calls.Load())
}
+5145
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
// Package api contains the Telegram Bot API types and method wrappers.
// Most of the package is generated by cmd/genapi from internal/spec/api.json;
// this file holds the runtime types that are intentionally hand-coded.
//
// InputFile carries either a local upload (Reader+Filename) or a reference
// to a previously-uploaded file (file_id) / URL Telegram can fetch. It is
// not a pure JSON type, so the codegen skips it (see runtimeTypes in
// cmd/genapi/emitter.go).
//
// ResponseParameters mirrors client.ResponseParameters so callers importing
// only `api` can access retry_after and migrate_to_chat_id without pulling
// in the client package.
package api
import (
"bytes"
"fmt"
"github.com/goccy/go-json"
"io"
"strconv"
)
// InputFile carries either a file path (for upload) or a Telegram file_id
// / URL string (for reuse). When PathOrID names a local file, the request
// is sent as multipart/form-data; otherwise the value is sent inline.
type InputFile struct {
// PathOrID is one of: an absolute or relative filesystem path, a
// previously-uploaded Telegram file_id, or an HTTPS URL Telegram
// can fetch.
PathOrID string
// Reader, when non-nil, is used as the file content (Filename names it).
Reader io.Reader
// Filename is the upload filename used when Reader is set.
Filename string
}
// IsLocalUpload reports whether this InputFile triggers a multipart upload.
func (f *InputFile) IsLocalUpload() bool {
if f == nil {
return false
}
return f.Reader != nil
}
// ResponseParameters is the optional metadata Telegram includes on certain
// failures. The most common is RetryAfter (seconds) on 429 responses.
//
// https://core.telegram.org/bots/api#responseparameters
type ResponseParameters struct {
MigrateToChatID int64 `json:"migrate_to_chat_id,omitempty"`
RetryAfter int `json:"retry_after,omitempty"`
}
// ChatID identifies a chat by either numeric id or "@username". The Telegram
// Bot API spells the same field as either an integer or a string; ChatID
// preserves both forms with explicit constructors and a custom MarshalJSON
// so callers never see `any` at the source level.
type ChatID struct {
int64Set bool
intID int64
username string
}
// ChatIDFromInt builds a ChatID for a numeric chat identifier (e.g. -1001234567890).
func ChatIDFromInt(id int64) ChatID { return ChatID{int64Set: true, intID: id} }
// ChatIDFromUsername builds a ChatID for a public chat (e.g. "@channel").
// The leading "@" is required by Telegram for usernames.
func ChatIDFromUsername(name string) ChatID { return ChatID{username: name} }
// IsZero reports whether c carries no value.
func (c ChatID) IsZero() bool { return !c.int64Set && c.username == "" }
// String returns the wire form (decimal integer or "@name") for use in
// multipart bodies.
func (c ChatID) String() string {
if c.int64Set {
return strconv.FormatInt(c.intID, 10)
}
return c.username
}
// MarshalJSON emits either a JSON number (integer form) or a JSON string
// (@username form). Empty values marshal as "null".
func (c ChatID) MarshalJSON() ([]byte, error) {
if c.int64Set {
return []byte(strconv.FormatInt(c.intID, 10)), nil
}
if c.username != "" {
return json.Marshal(c.username)
}
return []byte("null"), nil
}
// UnmarshalJSON accepts either a JSON number or a JSON string.
func (c *ChatID) UnmarshalJSON(data []byte) error {
data = bytes.TrimSpace(data)
if len(data) == 0 || bytes.Equal(data, []byte("null")) {
*c = ChatID{}
return nil
}
if data[0] == '"' {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
*c = ChatIDFromUsername(s)
return nil
}
var n int64
if err := json.Unmarshal(data, &n); err != nil {
return fmt.Errorf("ChatID: %w", err)
}
*c = ChatIDFromInt(n)
return nil
}
// MessageOrBool wraps the "Message or True" return shape Telegram uses on
// edit methods (editMessageText, editMessageCaption, etc.). When the bot
// edits a regular chat message, Message is non-nil; when it edits an
// inline message, OK is true.
type MessageOrBool struct {
Message *Message
OK bool
}
// UnmarshalJSON decodes either {...} into Message or `true`/`false` into OK.
func (m *MessageOrBool) UnmarshalJSON(data []byte) error {
data = bytes.TrimSpace(data)
if len(data) == 0 {
return nil
}
if data[0] == '{' {
m.Message = new(Message)
return json.Unmarshal(data, m.Message)
}
var b bool
if err := json.Unmarshal(data, &b); err != nil {
return fmt.Errorf("MessageOrBool: %w", err)
}
m.OK = b
return nil
}
+93
View File
@@ -0,0 +1,93 @@
package api
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestChatID_IntForm(t *testing.T) {
c := ChatIDFromInt(-1001234567890)
require.False(t, c.IsZero())
require.Equal(t, "-1001234567890", c.String())
data, err := json.Marshal(c)
require.NoError(t, err)
require.Equal(t, "-1001234567890", string(data))
var c2 ChatID
require.NoError(t, json.Unmarshal(data, &c2))
require.Equal(t, c, c2)
}
func TestChatID_UsernameForm(t *testing.T) {
c := ChatIDFromUsername("@channel")
require.False(t, c.IsZero())
require.Equal(t, "@channel", c.String())
data, err := json.Marshal(c)
require.NoError(t, err)
require.Equal(t, `"@channel"`, string(data))
var c2 ChatID
require.NoError(t, json.Unmarshal(data, &c2))
require.Equal(t, c, c2)
}
func TestChatID_Zero(t *testing.T) {
var c ChatID
require.True(t, c.IsZero())
require.Equal(t, "", c.String())
data, err := json.Marshal(c)
require.NoError(t, err)
require.Equal(t, "null", string(data))
var c2 ChatID
require.NoError(t, json.Unmarshal([]byte("null"), &c2))
require.True(t, c2.IsZero())
}
func TestChatID_UnmarshalInvalid(t *testing.T) {
var c ChatID
err := json.Unmarshal([]byte(`"not-a-number"`), &c)
require.NoError(t, err) // string always succeeds as username
require.Equal(t, "not-a-number", c.username)
}
func TestMessageOrBool_TrueForm(t *testing.T) {
var m MessageOrBool
require.NoError(t, json.Unmarshal([]byte("true"), &m))
require.True(t, m.OK)
require.Nil(t, m.Message)
}
func TestMessageOrBool_FalseForm(t *testing.T) {
var m MessageOrBool
require.NoError(t, json.Unmarshal([]byte("false"), &m))
require.False(t, m.OK)
require.Nil(t, m.Message)
}
func TestMessageOrBool_MessageForm(t *testing.T) {
// Message is a generated type; we can only test that it unmarshals without
// error into the struct — the generated api/*.gen.go is not available in
// the test build unless built. Use build tag !ignore_autogenerated default.
// Skip if Message type is not yet present (bootstrap phase).
data := []byte(`{"message_id":42,"date":0,"chat":{"id":1,"type":"private"}}`)
var m MessageOrBool
require.NoError(t, json.Unmarshal(data, &m))
require.NotNil(t, m.Message)
require.False(t, m.OK)
}
func TestInputFile_IsLocalUpload(t *testing.T) {
require.False(t, (*InputFile)(nil).IsLocalUpload())
require.False(t, (&InputFile{PathOrID: "AgADAgADu7gxG..."}).IsLocalUpload())
require.True(t, (&InputFile{Reader: nopReader{}}).IsLocalUpload())
}
type nopReader struct{}
func (nopReader) Read(p []byte) (int, error) { return 0, nil }
+90
View File
@@ -0,0 +1,90 @@
package api
// Sender condenses the various ways a Telegram update can identify the
// originator of a message or reaction into a single shape. Use the
// GetSender methods on supported types to construct one.
type Sender struct {
// User is the human user who sent the update, when applicable.
User *User
// Chat is the chat that sent the update (channel forwards,
// anonymous group admins, anonymous channel posts).
Chat *Chat
// IsAutomaticForward is true when the update originated as an
// automatic forward from a linked channel.
IsAutomaticForward bool
// ChatID is the chat the update was delivered into. Used to
// distinguish "this user" from "this anonymous admin posting
// in <chat>" when User is nil.
ChatID int64
// AuthorSignature is the custom title of an anonymous group
// administrator. Only meaningful when Chat == this chat.
AuthorSignature string
}
// ID returns the most-specific identifier available: prefers Chat.ID
// over User.ID. Returns 0 if neither is set.
func (s *Sender) ID() int64 {
if s == nil {
return 0
}
if s.Chat != nil {
return s.Chat.ID
}
if s.User != nil {
return s.User.ID
}
return 0
}
// IsAnonymousAdmin reports whether the sender is a group admin posting
// anonymously (Chat equals the message's own chat).
func (s *Sender) IsAnonymousAdmin() bool {
return s != nil && s.Chat != nil && s.Chat.ID == s.ChatID
}
// IsAnonymousChannel reports whether the sender is an anonymous
// channel post (Chat differs from the message's own chat).
func (s *Sender) IsAnonymousChannel() bool {
return s != nil && s.Chat != nil && s.Chat.ID != s.ChatID
}
// GetSender constructs a Sender for a Message. The result is never nil.
func (m *Message) GetSender() *Sender {
if m == nil {
return &Sender{}
}
isAuto := false
if m.IsAutomaticForward != nil {
isAuto = *m.IsAutomaticForward
}
return &Sender{
User: m.From,
Chat: m.SenderChat,
IsAutomaticForward: isAuto,
ChatID: m.Chat.ID,
AuthorSignature: m.AuthorSignature,
}
}
// GetSender constructs a Sender for a MessageReactionUpdated.
func (mru *MessageReactionUpdated) GetSender() *Sender {
if mru == nil {
return &Sender{}
}
return &Sender{
User: mru.User,
Chat: mru.ActorChat,
ChatID: mru.Chat.ID,
}
}
// GetSender constructs a Sender for a PollAnswer.
func (pa *PollAnswer) GetSender() *Sender {
if pa == nil {
return &Sender{}
}
return &Sender{
User: pa.User,
Chat: pa.VoterChat,
}
}
+366
View File
@@ -0,0 +1,366 @@
package api
import (
"testing"
)
func TestSenderID(t *testing.T) {
tests := []struct {
name string
sender *Sender
want int64
}{
{
name: "nil sender",
sender: nil,
want: 0,
},
{
name: "empty sender",
sender: &Sender{},
want: 0,
},
{
name: "user only",
sender: &Sender{
User: &User{ID: 123},
},
want: 123,
},
{
name: "chat only",
sender: &Sender{
Chat: &Chat{ID: 456},
},
want: 456,
},
{
name: "chat prefers over user",
sender: &Sender{
User: &User{ID: 123},
Chat: &Chat{ID: 456},
},
want: 456,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.sender.ID()
if got != tt.want {
t.Errorf("ID() = %d, want %d", got, tt.want)
}
})
}
}
func chatEqual(a, b *Chat) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return a.ID == b.ID
}
func userEqual(a, b *User) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return a.ID == b.ID
}
func TestSenderIsAnonymousAdmin(t *testing.T) {
tests := []struct {
name string
sender *Sender
want bool
}{
{
name: "nil sender",
sender: nil,
want: false,
},
{
name: "no chat",
sender: &Sender{User: &User{ID: 123}, ChatID: 456},
want: false,
},
{
name: "chat id matches (anonymous admin)",
sender: &Sender{
Chat: &Chat{ID: 789},
ChatID: 789,
},
want: true,
},
{
name: "chat id differs (not anonymous admin)",
sender: &Sender{
Chat: &Chat{ID: 789},
ChatID: 456,
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.sender.IsAnonymousAdmin()
if got != tt.want {
t.Errorf("IsAnonymousAdmin() = %v, want %v", got, tt.want)
}
})
}
}
func TestSenderIsAnonymousChannel(t *testing.T) {
tests := []struct {
name string
sender *Sender
want bool
}{
{
name: "nil sender",
sender: nil,
want: false,
},
{
name: "no chat",
sender: &Sender{User: &User{ID: 123}, ChatID: 456},
want: false,
},
{
name: "chat id differs (anonymous channel)",
sender: &Sender{
Chat: &Chat{ID: 789},
ChatID: 456,
},
want: true,
},
{
name: "chat id matches (not anonymous channel)",
sender: &Sender{
Chat: &Chat{ID: 789},
ChatID: 789,
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.sender.IsAnonymousChannel()
if got != tt.want {
t.Errorf("IsAnonymousChannel() = %v, want %v", got, tt.want)
}
})
}
}
func TestMessageGetSender(t *testing.T) {
tests := []struct {
name string
msg *Message
want *Sender
}{
{
name: "nil message",
msg: nil,
want: &Sender{},
},
{
name: "regular user message",
msg: &Message{
From: &User{ID: 123},
Chat: Chat{ID: 456},
},
want: &Sender{
User: &User{ID: 123},
ChatID: 456,
},
},
{
name: "channel forward",
msg: &Message{
From: &User{ID: 123},
SenderChat: &Chat{ID: 789},
Chat: Chat{ID: 456},
},
want: &Sender{
User: &User{ID: 123},
Chat: &Chat{ID: 789},
ChatID: 456,
},
},
{
name: "anonymous admin",
msg: &Message{
SenderChat: &Chat{ID: 456},
Chat: Chat{ID: 456},
AuthorSignature: "Admin Signature",
},
want: &Sender{
Chat: &Chat{ID: 456},
ChatID: 456,
AuthorSignature: "Admin Signature",
},
},
{
name: "anonymous channel post",
msg: &Message{
SenderChat: &Chat{ID: 789},
Chat: Chat{ID: 456},
},
want: &Sender{
Chat: &Chat{ID: 789},
ChatID: 456,
},
},
{
name: "automatic forward",
msg: &Message{
From: &User{ID: 123},
IsAutomaticForward: func() *bool {
b := true
return &b
}(),
Chat: Chat{ID: 456},
},
want: &Sender{
User: &User{ID: 123},
IsAutomaticForward: true,
ChatID: 456,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.msg.GetSender()
if got == nil {
t.Fatal("GetSender() returned nil")
}
if !userEqual(got.User, tt.want.User) {
t.Errorf("User: got %v, want %v", got.User, tt.want.User)
}
if !chatEqual(got.Chat, tt.want.Chat) {
t.Errorf("Chat: got %v, want %v", got.Chat, tt.want.Chat)
}
if got.IsAutomaticForward != tt.want.IsAutomaticForward {
t.Errorf("IsAutomaticForward: got %v, want %v", got.IsAutomaticForward, tt.want.IsAutomaticForward)
}
if got.ChatID != tt.want.ChatID {
t.Errorf("ChatID: got %d, want %d", got.ChatID, tt.want.ChatID)
}
if got.AuthorSignature != tt.want.AuthorSignature {
t.Errorf("AuthorSignature: got %q, want %q", got.AuthorSignature, tt.want.AuthorSignature)
}
})
}
}
func TestMessageReactionUpdatedGetSender(t *testing.T) {
tests := []struct {
name string
mru *MessageReactionUpdated
want *Sender
}{
{
name: "nil reaction",
mru: nil,
want: &Sender{},
},
{
name: "user reaction",
mru: &MessageReactionUpdated{
User: &User{ID: 123},
Chat: Chat{ID: 456},
},
want: &Sender{
User: &User{ID: 123},
ChatID: 456,
},
},
{
name: "anonymous reaction",
mru: &MessageReactionUpdated{
ActorChat: &Chat{ID: 789},
Chat: Chat{ID: 456},
},
want: &Sender{
Chat: &Chat{ID: 789},
ChatID: 456,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.mru.GetSender()
if got == nil {
t.Fatal("GetSender() returned nil")
}
if !userEqual(got.User, tt.want.User) {
t.Errorf("User: got %v, want %v", got.User, tt.want.User)
}
if !chatEqual(got.Chat, tt.want.Chat) {
t.Errorf("Chat: got %v, want %v", got.Chat, tt.want.Chat)
}
if got.ChatID != tt.want.ChatID {
t.Errorf("ChatID: got %d, want %d", got.ChatID, tt.want.ChatID)
}
})
}
}
func TestPollAnswerGetSender(t *testing.T) {
tests := []struct {
name string
pa *PollAnswer
want *Sender
}{
{
name: "nil poll answer",
pa: nil,
want: &Sender{},
},
{
name: "user vote",
pa: &PollAnswer{
User: &User{ID: 123},
},
want: &Sender{
User: &User{ID: 123},
},
},
{
name: "anonymous vote",
pa: &PollAnswer{
VoterChat: &Chat{ID: 789},
},
want: &Sender{
Chat: &Chat{ID: 789},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.pa.GetSender()
if got == nil {
t.Fatal("GetSender() returned nil")
}
if !userEqual(got.User, tt.want.User) {
t.Errorf("User: got %v, want %v", got.User, tt.want.User)
}
if !chatEqual(got.Chat, tt.want.Chat) {
t.Errorf("Chat: got %v, want %v", got.Chat, tt.want.Chat)
}
})
}
}
+29
View File
@@ -0,0 +1,29 @@
package api
import (
"bytes"
"io"
"net/http"
"github.com/stretchr/testify/mock"
)
// mockDoer is a testify-mock HTTPDoer shared by hand-written tests.
type mockDoer struct{ mock.Mock }
func (m *mockDoer) Do(r *http.Request) (*http.Response, error) {
args := m.Called(r)
if v := args.Get(0); v != nil {
return v.(*http.Response), args.Error(1)
}
return nil, args.Error(1)
}
// newJSONResp constructs an *http.Response with a JSON body.
func newJSONResp(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Body: io.NopCloser(bytes.NewBufferString(body)),
Header: http.Header{"Content-Type": []string{"application/json"}},
}
}
+5871
View File
File diff suppressed because it is too large Load Diff