package tgapi import ( "encoding/json" "errors" "reflect" "strings" "testing" ) func roundtripRichBlock(t *testing.T, in RichBlock) { t.Helper() b, err := json.Marshal(in) if err != nil { t.Fatalf("marshal: %v", err) } out, err := UnmarshalRichBlock(b) if err != nil { t.Fatalf("unmarshal %s: %v", b, err) } b2, err := json.Marshal(out) if err != nil { t.Fatalf("remarshal: %v", err) } if string(b) != string(b2) { t.Fatalf("not stable:\n %s\n %s", b, b2) } } func par(s string) RichBlockWrap { return RichBlockWrap{"paragraph", RichTextPlain(s)} } func TestRichBlockRoundtrip(t *testing.T) { cases := []RichBlock{ // wrap blocks par("Hello, world"), RichBlockWrap{"footer", RichTextPlain("© 2024")}, RichBlockWrap{"thinking", RichTextPlain("Let me reason step by step.")}, // heading RichBlockSectionHeading{RichTextWrap{"bold", RichTextPlain("Chapter 1")}, 1}, RichBlockSectionHeading{RichTextPlain("smallest"), 6}, // preformatted RichBlockPreformatted{RichTextPlain(`fmt.Println("hi")`), "go"}, RichBlockPreformatted{Text: RichTextPlain("no language")}, // quotations RichBlockQuotation{[]RichBlock{par("To be or not to be")}, RichTextPlain("Shakespeare")}, RichBlockQuotation{Blocks: []RichBlock{par("anonymous"), par("second block")}}, RichBlockPullQuotation{Text: RichTextPlain("Pull me")}, RichBlockPullQuotation{RichTextPlain("Wisdom"), RichTextWrap{"italic", RichTextPlain("someone")}}, // list: label is the ready-made marker, numbering lives on the items RichBlockList{ Items: []RichBlockListItem{ {Label: "c.", Blocks: []RichBlock{par("item 3")}, Value: 3, Type: "a"}, {Label: "vii.", Blocks: []RichBlock{par("item 7")}, Value: 7, Type: "i"}, }, }, RichBlockList{ Items: []RichBlockListItem{ {Label: "•", Blocks: []RichBlock{par("todo")}, HasCheckbox: true}, {Label: "•", Blocks: []RichBlock{par("done")}, HasCheckbox: true, IsChecked: true}, }, }, // collage and slideshow RichBlockCollage{ Blocks: []RichBlock{RichBlockPhoto{Photo: []PhotoSize{{FileID: "abc123", Width: 100, Height: 100}}}}, Caption: &RichBlockCaption{Text: RichTextPlain("A photo")}, }, RichBlockSlideshow{ Blocks: []RichBlock{ RichBlockVideo{Video: Video{FileID: "vid1", Width: 640, Height: 480, Duration: 10}}, }, }, // details RichBlockDetails{ Summary: RichTextPlain("Spoiler"), Blocks: []RichBlock{par("Hidden content")}, }, RichBlockDetails{ Summary: RichTextWrap{"bold", RichTextPlain("Open details")}, Blocks: []RichBlock{RichBlockDivider{}, par("content")}, IsOpen: true, }, // table: text cells, headers, spans, alignment, invisible cell RichBlockTable{ Cells: [][]RichBlockTableCell{ { {Text: RichTextPlain("Name"), IsHeader: true, Align: "center"}, {Text: RichTextPlain("Score"), IsHeader: true, VAlign: "middle"}, }, { {Text: RichTextPlain("Alice"), ColSpan: 2}, }, { {}, // invisible cell {Text: RichTextPlain("42"), RowSpan: 2}, }, }, IsBordered: true, Caption: RichTextPlain("Results"), }, // map RichBlockMap{ Location: Location{Latitude: 55.7558, Longitude: 37.6173}, Zoom: 13, Width: 800, Height: 400, Caption: &RichBlockCaption{Text: RichTextPlain("Moscow"), Credit: RichTextPlain("OpenStreetMap")}, }, // media RichBlockPhoto{ Photo: []PhotoSize{{FileID: "p1", Width: 1280, Height: 720}}, HasSpoiler: true, Caption: &RichBlockCaption{Text: RichTextPlain("A cat"), Credit: RichTextWrap{"italic", RichTextPlain("photographer")}}, }, RichBlockVideo{Video: Video{FileID: "v1", Width: 1920, Height: 1080, Duration: 30}, HasSpoiler: true}, RichBlockAudio{ Audio: Audio{FileID: "a1", Duration: 60}, Caption: &RichBlockCaption{Text: RichTextPlain("Podcast ep. 1")}, }, RichBlockAnimation{Animation: Animation{FileID: "g1", Width: 320, Height: 240, Duration: 2}}, RichBlockVoiceNote{VoiceNote: Voice{FileID: "vn1", Duration: 5}}, // leaves RichBlockDivider{}, RichBlockMathematicalExpression{Expression: "E = mc^2"}, RichBlockAnchor{Name: "section-2"}, } for _, c := range cases { roundtripRichBlock(t, c) } } func TestRichMessageRoundtrip(t *testing.T) { for _, msg := range []RichMessage{ { Blocks: []RichBlock{ RichBlockSectionHeading{RichTextPlain("Title"), 1}, RichBlockWrap{"paragraph", RichTextArray{RichTextPlain("Some "), RichTextWrap{"bold", RichTextPlain("bold")}, RichTextPlain(" text")}}, RichBlockDivider{}, RichBlockList{ Items: []RichBlockListItem{ {Label: "1.", Blocks: []RichBlock{par("First")}, Value: 1, Type: "1"}, {Label: "2.", Blocks: []RichBlock{par("Second")}, Value: 2, Type: "1"}, }, }, RichBlockPhoto{ Photo: []PhotoSize{{FileID: "img1", Width: 10, Height: 10}}, Caption: &RichBlockCaption{Text: RichTextPlain("Fig. 1")}, }, }, }, { Blocks: []RichBlock{par("שלום")}, IsRTL: true, }, } { b, err := json.Marshal(msg) if err != nil { t.Fatalf("marshal: %v", err) } var out RichMessage if err := json.Unmarshal(b, &out); err != nil { t.Fatalf("unmarshal: %v", err) } b2, err := json.Marshal(out) if err != nil { t.Fatalf("remarshal: %v", err) } if string(b) != string(b2) { t.Fatalf("not stable:\n %s\n %s", b, b2) } } } func TestRichBlockTags(t *testing.T) { // tags per spec: heading, pre, blockquote, pullquote cases := map[string]RichBlock{ "heading": RichBlockSectionHeading{RichTextPlain("h"), 2}, "pre": RichBlockPreformatted{Text: RichTextPlain("x")}, "blockquote": RichBlockQuotation{Blocks: []RichBlock{par("q")}}, "pullquote": RichBlockPullQuotation{Text: RichTextPlain("p")}, } for want, block := range cases { b, _ := json.Marshal(block) var m map[string]any _ = json.Unmarshal(b, &m) if m["type"] != want { t.Fatalf("expected type %q, got %s", want, b) } } } func TestRichBlockOptionalFieldsOmitted(t *testing.T) { // nil credit/caption and false flags must not appear in the JSON for _, c := range []struct { block RichBlock bad []string }{ {RichBlockQuotation{Blocks: []RichBlock{par("q")}}, []string{"credit"}}, {RichBlockPullQuotation{Text: RichTextPlain("p")}, []string{"credit"}}, {RichBlockPhoto{Photo: []PhotoSize{{FileID: "p"}}}, []string{"caption", "has_spoiler"}}, {RichBlockTable{Cells: [][]RichBlockTableCell{}}, []string{"caption", "is_bordered", "is_striped"}}, {RichBlockDetails{Summary: RichTextPlain("s")}, []string{"is_open"}}, } { b, _ := json.Marshal(c.block) for _, key := range c.bad { if strings.Contains(string(b), `"`+key+`"`) { t.Fatalf("%T: %q must be omitted: %s", c.block, key, b) } } } // same for RichMessage.is_rtl b, _ := json.Marshal(RichMessage{Blocks: []RichBlock{par("x")}}) if strings.Contains(string(b), "is_rtl") { t.Fatalf("is_rtl must be omitted: %s", b) } } func TestRichBlockDividerHasNoContent(t *testing.T) { b, _ := json.Marshal(RichBlockDivider{}) var m map[string]any _ = json.Unmarshal(b, &m) if len(m) != 1 { t.Fatalf("divider must only have type field: %s", b) } if m["type"] != "divider" { t.Fatalf("unexpected type: %s", b) } } func TestUnknownRichObjectsRoundTripLosslessly(t *testing.T) { tests := []struct { name string raw string parse func([]byte) (any, error) }{ { name: "text", raw: `{"type":"future_text","text":"hello","metadata":{"flag":true},"items":[1,2]}`, parse: func(data []byte) (any, error) { return UnmarshalRichText(data) }, }, { name: "block", raw: `{"type":"future_block","value":42,"metadata":{"flag":true},"items":[1,2]}`, parse: func(data []byte) (any, error) { return UnmarshalRichBlock(data) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { input := []byte(tt.raw) value, err := tt.parse(input) if err != nil { t.Fatalf("parse failed: %v", err) } for i := range input { input[i] = ' ' } encoded, err := json.Marshal(value) if err != nil { t.Fatalf("Marshal failed: %v", err) } var got, want any if err := json.Unmarshal(encoded, &got); err != nil { t.Fatal(err) } if err := json.Unmarshal([]byte(tt.raw), &want); err != nil { t.Fatal(err) } if !reflect.DeepEqual(got, want) { t.Fatalf("unknown object changed: got %s want %s", encoded, tt.raw) } switch v := value.(type) { case RichTextUnknown: if len(v.Raw) == 0 { t.Fatal("empty preserved rich text") } case RichBlockUnknown: if len(v.Raw) == 0 { t.Fatal("empty preserved rich block") } default: t.Fatalf("unexpected fallback type %T", value) } }) } } func TestUnknownRichObjectsRoundTripInsideMessage(t *testing.T) { raw := []byte(`{"blocks":[{"type":"future_block","metadata":{"version":2}},{"type":"paragraph","text":{"type":"future_text","payload":[1,2,3]}}],"is_rtl":true}`) message, err := UnmarshalRichMessage(raw) if err != nil { t.Fatalf("UnmarshalRichMessage failed: %v", err) } if _, ok := message.Blocks[0].(RichBlockUnknown); !ok { t.Fatalf("first block type = %T, want RichBlockUnknown", message.Blocks[0]) } paragraph, ok := message.Blocks[1].(RichBlockWrap) if !ok { t.Fatalf("second block type = %T, want RichBlockWrap", message.Blocks[1]) } if _, ok := paragraph.Text.(RichTextUnknown); !ok { t.Fatalf("paragraph text type = %T, want RichTextUnknown", paragraph.Text) } encoded, err := json.Marshal(message) if err != nil { t.Fatalf("Marshal failed: %v", err) } var got, want any if err := json.Unmarshal(encoded, &got); err != nil { t.Fatal(err) } if err := json.Unmarshal(raw, &want); err != nil { t.Fatal(err) } if !reflect.DeepEqual(got, want) { t.Fatalf("message changed: got %s want %s", encoded, raw) } } func TestUnmarshalRichBlockRejectsMalformedFields(t *testing.T) { tests := []string{ `{"type":"heading","size":"large","text":"hello"}`, `{"type":"blockquote","blocks":[],"credit":{"type":"date_time","text":"now","unix_time":"soon"}}`, `{"type":"table","cells":[],"caption":{"type":"date_time","text":"now","unix_time":"soon"}}`, } for _, raw := range tests { t.Run(raw, func(t *testing.T) { if _, err := UnmarshalRichBlock([]byte(raw)); err == nil { t.Fatal("expected malformed rich block to be rejected") } }) } } func TestUnmarshalRichMessageStrict(t *testing.T) { for _, raw := range []string{`null`, `{}`, `{"blocks":null}`, `{"blocks":{}}`} { t.Run(raw, func(t *testing.T) { for _, parse := range []func([]byte) (RichMessage, error){UnmarshalRichMessage, UnmarshalRichMessageStrict} { if _, err := parse([]byte(raw)); err == nil { t.Fatal("expected strict decoder error") } } }) } if _, err := UnmarshalRichMessageStrict([]byte(`{"blocks":[]}`)); err != nil { t.Fatalf("strict decoder rejected an empty blocks array: %v", err) } } func TestRichJSONStructuralLimits(t *testing.T) { deep := strings.Repeat("[", maximumRichJSONDepth+1) + `"x"` + strings.Repeat("]", maximumRichJSONDepth+1) if _, err := UnmarshalRichText([]byte(deep)); !errors.Is(err, ErrRichJSONDepth) { t.Fatalf("expected ErrRichJSONDepth, got %v", err) } wide := "[" + strings.Repeat("0,", maximumRichJSONNodes) + "0]" if _, err := UnmarshalRichText([]byte(wide)); !errors.Is(err, ErrRichJSONNodes) { t.Fatalf("expected ErrRichJSONNodes, got %v", err) } }