FILE / ScuroNeko/Laniakea

tgapi/uploader_api_test.go

Исходный файл и его история в репозитории.
FILE dev
Files
Laniakea/tgapi/uploader_api_test.go
ScuroNeko 29b208eeec
Golang lint / lint (push) Successful in 11m32s
(new): v1.2 release
2026-08-19 14:58:25 +03:00

439 lines
12 KiB
Go

package tgapi
import (
"context"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"strings"
"testing"
)
func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *testing.T) {
var (
gotPath string
gotAcceptEncoding string
gotFields map[string]string
gotFileName string
gotFileData []byte
roundTripErr error
)
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
gotPath = req.URL.Path
gotAcceptEncoding = req.Header.Get("Accept-Encoding")
gotFields, gotFileName, gotFileData, roundTripErr = readMultipartRequest(req)
if roundTripErr != nil {
roundTripErr = fmt.Errorf("readMultipartRequest: %w", roundTripErr)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":5,"date":1}}`)),
}, nil
}),
}
api := NewAPI(
NewAPIOpts("token").
SetAPIURL("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
uploader := NewUploader(api)
defer func() {
if err := uploader.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
msg, err := uploader.SendPhoto(
UploadPhoto{
ChatID: 42,
CaptionEntities: []MessageEntity{{
Type: MessageEntityBold,
Offset: 0,
Length: 4,
}},
ReplyMarkup: &ReplyMarkup{
InlineKeyboard: [][]InlineKeyboardButton{{
{Text: "A", CallbackData: "b"},
}},
},
},
NewUploaderFile("photo.jpg", []byte("img")),
)
if err != nil {
t.Fatalf("SendPhoto returned error: %v", err)
}
if msg.MessageID != 5 {
t.Fatalf("unexpected message id: %d", msg.MessageID)
}
if roundTripErr != nil {
t.Fatalf("multipart parse failed: %v", roundTripErr)
}
if gotPath != "/bottoken/sendPhoto" {
t.Fatalf("unexpected request path: %s", gotPath)
}
if gotAcceptEncoding != "" {
t.Fatalf("expected empty Accept-Encoding header, got %q", gotAcceptEncoding)
}
if got := gotFields["chat_id"]; got != "42" {
t.Fatalf("chat_id mismatch: %q", got)
}
if got := gotFields["caption_entities"]; got != `[{"type":"bold","offset":0,"length":4}]` {
t.Fatalf("caption_entities mismatch: %q", got)
}
if got := gotFields["reply_markup"]; got != `{"inline_keyboard":[[{"text":"A","callback_data":"b"}]]}` {
t.Fatalf("reply_markup mismatch: %q", got)
}
if gotFileName != "photo.jpg" {
t.Fatalf("unexpected file name: %q", gotFileName)
}
if string(gotFileData) != "img" {
t.Fatalf("unexpected file content: %q", string(gotFileData))
}
}
func TestUploaderRejectsDirectRichMessageDraftUpload(t *testing.T) {
uploader := &Uploader{}
_, err := uploader.SendRichMessageDraft(
SendRichMessageDraft{ChatID: 42, DraftID: 1},
NewUploaderFile("photo.jpg", []byte("photo")),
)
if !errors.Is(err, ErrRichMessageDraftUploadUnsupported) {
t.Fatalf("expected ErrRichMessageDraftUploadUnsupported, got %v", err)
}
}
func TestUploaderSurfacesResponseErrorForTelegramFailure(t *testing.T) {
const responseBody = `{"ok":false,"error_code":400,"description":"Bad Request: chat not found"}`
client := &http.Client{
Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(responseBody)),
}, nil
}),
}
api := NewAPI(
NewAPIOpts("token").
SetAPIURL("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
uploader := NewUploader(api)
defer func() {
if err := uploader.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
_, err := uploader.SendPhoto(
UploadPhoto{ChatID: 42},
NewUploaderFile("photo.jpg", []byte("img")),
)
if err == nil {
t.Fatal("expected error, got nil")
}
var respErr *ResponseError
if !errors.As(err, &respErr) {
t.Fatalf("expected *ResponseError, got %T: %v", err, err)
}
if respErr.Code != 400 {
t.Fatalf("unexpected ResponseError.Code: got %d want 400", respErr.Code)
}
if !strings.Contains(respErr.Description, "chat not found") {
t.Fatalf("unexpected ResponseError.Description: %q", respErr.Description)
}
}
func TestNewUploaderFileDetectsFileTypeCaseInsensitively(t *testing.T) {
tests := []struct {
name string
filename string
want UploaderFileType
}{
{name: "uppercase photo", filename: "PHOTO.JPG", want: UploaderPhotoType},
{name: "uppercase voice", filename: "voice.OGG", want: UploaderVoiceType},
{name: "unknown defaults to document", filename: "archive.BIN", want: UploaderDocumentType},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
file := NewUploaderFile(tt.filename, []byte("x"))
if file.field != tt.want {
t.Fatalf("unexpected uploader field: got %q want %q", file.field, tt.want)
}
})
}
}
func TestUploaderSendLivePhotoUsesRequiredMultipartFields(t *testing.T) {
tests := []struct {
name string
send func(*Uploader) (Message, error)
}{
{
name: "background context",
send: func(uploader *Uploader) (Message, error) {
return uploader.SendLivePhoto(
UploadLivePhoto{ChatID: 42},
NewUploaderFile("live.mp4", []byte("video")),
NewUploaderFile("photo.jpg", []byte("image")),
)
},
},
{
name: "explicit context",
send: func(uploader *Uploader) (Message, error) {
return uploader.SendLivePhotoWithContext(
context.Background(),
UploadLivePhoto{ChatID: 42},
NewUploaderFile("live.mp4", []byte("video")),
NewUploaderFile("photo.jpg", []byte("image")),
)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var (
gotPath string
gotFiles map[string]multipartFile
parseErr error
)
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
gotPath = req.URL.Path
gotFiles, parseErr = readMultipartFiles(req)
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":5,"date":1}}`)),
}, nil
}),
}
api := NewAPI(
NewAPIOpts("token").
SetAPIURL("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Errorf("Close API returned error: %v", err)
}
}()
uploader := NewUploader(api)
defer func() {
if err := uploader.Close(); err != nil {
t.Errorf("Close uploader returned error: %v", err)
}
}()
if _, err := tt.send(uploader); err != nil {
t.Fatalf("SendLivePhoto returned error: %v", err)
}
if parseErr != nil {
t.Fatalf("multipart parse failed: %v", parseErr)
}
if gotPath != "/bottoken/sendLivePhoto" {
t.Fatalf("unexpected request path: %q", gotPath)
}
assertMultipartFile(t, gotFiles, "live_photo", "live.mp4", "video")
assertMultipartFile(t, gotFiles, "photo", "photo.jpg", "image")
})
}
}
func TestPrepareMultipartRichMessageUsesAttachName(t *testing.T) {
params := SendRichMessage{
ChatID: 42,
RichMessage: InputRichMessage{Blocks: []InputRichBlock{
InputRichBlockAnimation{
Type: InputRichTypeAnimation,
Animation: InputMedia{Type: InputMediaTypeAnimation, Media: "attach://animation"},
},
}},
}
body, contentType := prepareMultipartStream(
[]UploaderFile{NewUploaderFile("animation.mp4", []byte("animation")).SetAttachName("animation")},
params,
)
defer func() { _ = body.Close() }()
_, contentTypeParams, err := mime.ParseMediaType(contentType)
if err != nil {
t.Fatalf("ParseMediaType returned error: %v", err)
}
reader := multipart.NewReader(body, contentTypeParams["boundary"])
parts := make(map[string]string)
var fileData []byte
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("NextPart returned error: %v", err)
}
data, err := io.ReadAll(part)
if err != nil {
t.Fatalf("ReadAll returned error: %v", err)
}
if part.FileName() != "" {
if part.FormName() != "animation" {
t.Errorf("file form name = %q, want animation", part.FormName())
}
fileData = data
continue
}
parts[part.FormName()] = string(data)
}
if string(fileData) != "animation" {
t.Errorf("file data = %q, want animation", fileData)
}
if got := parts["rich_message"]; !strings.Contains(got, `"media":"attach://animation"`) {
t.Errorf("rich_message = %s, want attach reference", got)
}
}
func TestUploaderStopsAfterConfiguredRetryLimit(t *testing.T) {
calls := 0
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
_, _ = io.Copy(io.Discard, req.Body)
calls++
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(
`{"ok":false,"error_code":429,"description":"retry","parameters":{"retry_after":0}}`,
)),
}, nil
})}
api := NewAPI(NewAPIOpts("token").
SetAPIURL("https://example.test").
SetHTTPClient(client).
SetMaxRetries(1))
defer func() { _ = api.Close() }()
uploader := NewUploader(api)
defer func() { _ = uploader.Close() }()
_, err := uploader.SendPhoto(
UploadPhoto{ChatID: 42},
NewUploaderFile("photo.jpg", []byte("img")),
)
if !errors.Is(err, ErrRetryLimit) {
t.Fatalf("expected ErrRetryLimit, got %v", err)
}
var responseErr *ResponseError
if !errors.As(err, &responseErr) || responseErr.Code != http.StatusTooManyRequests {
t.Fatalf("expected wrapped 429 ResponseError, got %v", err)
}
if calls != 2 {
t.Fatalf("request count = %d, want 2", calls)
}
}
func readMultipartRequest(req *http.Request) (map[string]string, string, []byte, error) {
_, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
if err != nil {
return nil, "", nil, err
}
reader := multipart.NewReader(req.Body, params["boundary"])
fields := make(map[string]string)
var fileName string
var fileData []byte
for {
part, err := reader.NextPart()
if err == io.EOF {
return fields, fileName, fileData, nil
}
if err != nil {
return nil, "", nil, err
}
data, err := io.ReadAll(part)
if err != nil {
return nil, "", nil, err
}
if part.FileName() != "" {
fileName = part.FileName()
fileData = data
continue
}
fields[part.FormName()] = string(data)
}
}
type multipartFile struct {
name string
data string
}
func readMultipartFiles(req *http.Request) (map[string]multipartFile, error) {
_, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
if err != nil {
return nil, err
}
reader := multipart.NewReader(req.Body, params["boundary"])
files := make(map[string]multipartFile)
for {
part, err := reader.NextPart()
if err == io.EOF {
return files, nil
}
if err != nil {
return nil, err
}
if part.FileName() == "" {
continue
}
data, err := io.ReadAll(part)
if err != nil {
return nil, err
}
files[part.FormName()] = multipartFile{name: part.FileName(), data: string(data)}
}
}
func assertMultipartFile(t *testing.T, files map[string]multipartFile, field, name, data string) {
t.Helper()
file, ok := files[field]
if !ok {
t.Fatalf("multipart field %q is missing", field)
}
if file.name != name {
t.Errorf("multipart field %q filename = %q, want %q", field, file.name, name)
}
if file.data != data {
t.Errorf("multipart field %q data = %q, want %q", field, file.data, data)
}
}