FILE / ScuroNeko/Laniakea
tgapi/uploader_api_test.go
Исходный файл и его история в репозитории.
439 lines
12 KiB
Go
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)
|
|
}
|
|
}
|