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"}, }, }}, } buf, contentType, err := prepareMultipart( []UploaderFile{NewUploaderFile("animation.mp4", []byte("animation")).SetAttachName("animation")}, params, ) if err != nil { t.Fatalf("prepareMultipart returned error: %v", err) } _, contentTypeParams, err := mime.ParseMediaType(contentType) if err != nil { t.Fatalf("ParseMediaType returned error: %v", err) } reader := multipart.NewReader(buf, 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 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) } }