mirror of
https://github.com/lukaszraczylo/go-telegram.git
synced 2026-06-13 02:51:55 +00:00
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:
@@ -0,0 +1,226 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// client.go option getters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBot_Getters(t *testing.T) {
|
||||
b := New("mytoken",
|
||||
WithBaseURL("http://localhost:9999"),
|
||||
WithCodec(DefaultCodec{}),
|
||||
WithLogger(NoopLogger{}),
|
||||
)
|
||||
require.Equal(t, "mytoken", b.Token())
|
||||
require.Equal(t, "http://localhost:9999", b.BaseURL())
|
||||
require.NotNil(t, b.HTTP())
|
||||
require.NotNil(t, b.Codec())
|
||||
require.NotNil(t, b.Logger())
|
||||
}
|
||||
|
||||
func TestWithLogger_NilBecomesNoop(t *testing.T) {
|
||||
b := New("t", WithLogger(nil))
|
||||
require.IsType(t, NoopLogger{}, b.Logger())
|
||||
}
|
||||
|
||||
func TestNoopLogger_AllMethods(t *testing.T) {
|
||||
l := NoopLogger{}
|
||||
// None of these should panic.
|
||||
l.Debug("msg")
|
||||
l.Info("msg", "k", "v")
|
||||
l.Warn("msg")
|
||||
l.Error("msg", "err", "oops")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RetryOption setters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRetryOptions_Applied(t *testing.T) {
|
||||
d := NewRetryDoer(nil,
|
||||
WithMaxAttempts(7),
|
||||
WithBaseBackoff(1*time.Second),
|
||||
WithMaxBackoff(60*time.Second),
|
||||
WithBackoffFactor(3.0),
|
||||
WithJitter(0.5),
|
||||
)
|
||||
require.Equal(t, 7, d.maxAttempts)
|
||||
require.Equal(t, 1*time.Second, d.base)
|
||||
require.Equal(t, 60*time.Second, d.max)
|
||||
require.Equal(t, 3.0, d.factor)
|
||||
require.Equal(t, 0.5, d.jitter)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RetryDoer.delay — override path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRetryDoer_DelayOverride(t *testing.T) {
|
||||
d := NewRetryDoer(nil)
|
||||
got := d.delay(1, 5*time.Second)
|
||||
require.Equal(t, 5*time.Second, got)
|
||||
}
|
||||
|
||||
func TestRetryDoer_DelayExponential(t *testing.T) {
|
||||
d := NewRetryDoer(nil,
|
||||
WithBaseBackoff(100*time.Millisecond),
|
||||
WithMaxBackoff(10*time.Second),
|
||||
WithJitter(0), // no jitter for deterministic test
|
||||
WithBackoffFactor(2.0),
|
||||
)
|
||||
d1 := d.delay(1, 0)
|
||||
d2 := d.delay(2, 0)
|
||||
require.Greater(t, int64(d2), int64(d1), "backoff should grow")
|
||||
}
|
||||
|
||||
func TestRetryDoer_DelayMaxCap(t *testing.T) {
|
||||
d := NewRetryDoer(nil,
|
||||
WithBaseBackoff(1*time.Second),
|
||||
WithMaxBackoff(2*time.Second),
|
||||
WithJitter(0),
|
||||
WithBackoffFactor(100.0),
|
||||
)
|
||||
delay := d.delay(10, 0)
|
||||
require.LessOrEqual(t, delay, 2*time.Second)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// errors.go — RetryAfter nil parameters + ParseError.Unwrap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAPIError_RetryAfterNilParams(t *testing.T) {
|
||||
e := &APIError{Code: 429, Description: "Too Many Requests", Parameters: nil}
|
||||
require.Equal(t, time.Duration(0), e.RetryAfter())
|
||||
}
|
||||
|
||||
func TestParseError_Unwrap(t *testing.T) {
|
||||
inner := errors.New("decode error")
|
||||
pe := &ParseError{Err: inner, Body: []byte("body")}
|
||||
require.ErrorIs(t, pe, inner)
|
||||
}
|
||||
|
||||
func TestParseError_LongBodyTruncated(t *testing.T) {
|
||||
body := bytes.Repeat([]byte("x"), 1000)
|
||||
pe := &ParseError{Err: errors.New("e"), Body: body}
|
||||
msg := pe.Error()
|
||||
// Error() truncates body to 256 for display — should not include all 1000 chars
|
||||
require.Less(t, len(msg), 800, "should truncate body in Error()")
|
||||
}
|
||||
|
||||
func TestNetworkError_Unwrap(t *testing.T) {
|
||||
inner := errors.New("tcp error")
|
||||
ne := &NetworkError{Err: inner}
|
||||
require.ErrorIs(t, ne, inner)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mapAPIError — missing sentinel branches (generic 400, unmapped 500)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMapAPIError_Generic400(t *testing.T) {
|
||||
e := mapAPIError(400, "Bad Request: some unknown thing", nil)
|
||||
require.True(t, errors.Is(e, ErrBadRequest))
|
||||
}
|
||||
|
||||
func TestMapAPIError_Unmapped500(t *testing.T) {
|
||||
e := mapAPIError(500, "Internal Server Error", nil)
|
||||
require.Nil(t, e.sentinel)
|
||||
require.Equal(t, 500, e.Code)
|
||||
}
|
||||
|
||||
func TestMapAPIError_403(t *testing.T) {
|
||||
e := mapAPIError(403, "Forbidden: bot was blocked", nil)
|
||||
require.True(t, errors.Is(e, ErrForbidden))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// callMultipart — ctx cancelled
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCallMultipart_ContextCancelled(t *testing.T) {
|
||||
// A doer that blocks then returns context error.
|
||||
blocker := &extraBlockingDoer{done: make(chan struct{})}
|
||||
|
||||
b := New("t", WithHTTPClient(blocker))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
mp := &extraFakeMultipartReq{
|
||||
fields: map[string]string{"chat_id": "1"},
|
||||
files: []MultipartFile{
|
||||
{FieldName: "document", Filename: "f.txt", Reader: bytes.NewReader([]byte("data"))},
|
||||
},
|
||||
}
|
||||
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
cancel()
|
||||
close(blocker.done)
|
||||
}()
|
||||
|
||||
_, err := callMultipart[*struct{}](ctx, b, "sendDocument", mp)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
type extraBlockingDoer struct{ done chan struct{} }
|
||||
|
||||
func (b *extraBlockingDoer) Do(r *http.Request) (*http.Response, error) {
|
||||
<-b.done
|
||||
return nil, r.Context().Err()
|
||||
}
|
||||
|
||||
type extraFakeMultipartReq struct {
|
||||
fields map[string]string
|
||||
files []MultipartFile
|
||||
}
|
||||
|
||||
func (f *extraFakeMultipartReq) HasFile() bool { return len(f.files) > 0 }
|
||||
func (f *extraFakeMultipartReq) MultipartFiles() []MultipartFile { return f.files }
|
||||
func (f *extraFakeMultipartReq) MultipartFields() map[string]string { return f.fields }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// copyBody size cap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCopyBody_LargeBodyCapped(t *testing.T) {
|
||||
big := bytes.Repeat([]byte("a"), 8000)
|
||||
out := copyBody(big)
|
||||
require.Len(t, out, 4096)
|
||||
}
|
||||
|
||||
func TestCopyBody_SmallBody(t *testing.T) {
|
||||
small := []byte("hello")
|
||||
out := copyBody(small)
|
||||
require.Equal(t, small, out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Call — 5xx non-200 HTTP status (transport level)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestCall_5xxHTTPStatus(t *testing.T) {
|
||||
m := &mockDoer{}
|
||||
m.On("Do", mock.Anything).Return(&http.Response{
|
||||
StatusCode: 500,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"ok":false,"error_code":500,"description":"Internal"}`)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
}, nil)
|
||||
|
||||
b := New("t", WithHTTPClient(m))
|
||||
_, err := Call[*echoReq, *echoResp](context.Background(), b, "x", &echoReq{})
|
||||
require.Error(t, err)
|
||||
var ae *APIError
|
||||
require.ErrorAs(t, err, &ae)
|
||||
require.Equal(t, 500, ae.Code)
|
||||
}
|
||||
Reference in New Issue
Block a user