package laniakea import ( "errors" "reflect" "strings" "testing" ) func TestInlineKeyboardWrapsRowsAndEncodesJSONPayloads(t *testing.T) { kb := NewInlineKeyboardJSON(2). AddCallbackButton("A", "cmd", 1). AddCallbackButton("B", "cmd", 2). AddCallbackButton("C", "cmd", 3) markup, err := kb.Get() if err != nil { t.Fatalf("keyboard encoding error: %s", err) } if got := len(markup.InlineKeyboard); got != 2 { t.Fatalf("unexpected row count: %d", got) } if got := len(markup.InlineKeyboard[0]); got != 2 { t.Fatalf("unexpected first row size: %d", got) } if got := len(markup.InlineKeyboard[1]); got != 1 { t.Fatalf("unexpected second row size: %d", got) } if !strings.Contains(markup.InlineKeyboard[0][0].CallbackData, `"cmd":"cmd"`) { t.Fatalf("expected JSON callback payload, got %q", markup.InlineKeyboard[0][0].CallbackData) } } func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) { kb := NewInlineKeyboardBase64(3). AddButton( NewInlineKeyboardButton("Docs"). SetStyle(ButtonStylePrimary). SetURL("https://example.test"), ) markup, err := kb.Get() if err != nil { t.Fatalf("unexpected err: %v", err) } button := markup.InlineKeyboard[0][0] if button.Style != ButtonStylePrimary { t.Fatalf("unexpected style: %q", button.Style) } if button.URL != "https://example.test" { t.Fatalf("unexpected url: %q", button.URL) } } func TestInlineKeyboardButtonBuilderSetCallbackDataDefaultsToJSON(t *testing.T) { kb := NewInlineKeyboardBase64(1). AddButton(NewInlineKeyboardButton("A").SetCallbackData("cmd", 1, "two")) markup, err := kb.Get() if err != nil { t.Fatalf("unexpected err: %v", err) } button := markup.InlineKeyboard[0][0] if !strings.Contains(button.CallbackData, `"cmd":"cmd"`) { t.Fatalf("expected JSON callback payload, got %q", button.CallbackData) } } func TestInlineKeyboardButtonBuilderSetCallbackDataUsesConfiguredPayloadType(t *testing.T) { kb := NewInlineKeyboardJSON(1). AddButton(NewInlineKeyboardButton("A"). SetPayloadType(BotPayloadBase64). SetCallbackData("cmd", 1, "two"), ) markup, err := kb.Get() if err != nil { t.Fatalf("unexpected err: %v", err) } button := markup.InlineKeyboard[0][0] got, _, err := decodePayload(BotPayloadJSON, button.CallbackData, false) if err != nil { t.Fatalf("decodePayload returned error: %v", err) } want := CallbackData{Command: "cmd", Args: []string{"1", "two"}} if !reflect.DeepEqual(got, want) { t.Fatalf("unexpected payload: got %#v want %#v", got, want) } } func TestInlineKeyboardButtonBuilderKeepsExactlyOneAction(t *testing.T) { callback := NewInlineKeyboardButton("Action"). SetURL("https://example.test"). SetCallbackDataJSON("confirm"). build() if callback.URL != "" || callback.CallbackData == "" { t.Fatalf("callback action was not exclusive: %#v", callback) } link := NewInlineKeyboardButton("Action"). SetCallbackDataJSON("confirm"). SetURL("https://example.test"). build() if link.URL == "" || link.CallbackData != "" { t.Fatalf("URL action was not exclusive: %#v", link) } } func TestInlineKeyboardGetReturnsIndependentMarkup(t *testing.T) { keyboard := NewInlineKeyboardJSON(1).AddURLButton("Docs", "https://example.test") first, err := keyboard.Get() if err != nil { t.Fatalf("unexpected err: %v", err) } first.InlineKeyboard[0][0].Text = "mutated" second, err := keyboard.Get() if err != nil { t.Fatalf("unexpected err: %v", err) } if got := second.InlineKeyboard[0][0].Text; got != "Docs" { t.Fatalf("Get exposed builder state for mutation: got %q", got) } } func TestInlineKeyboardGetPayloadTypeReturnsLocalOverride(t *testing.T) { kb := NewInlineKeyboardJSON(2) if got := kb.GetPayloadType(); got != BotPayloadJSON { t.Fatalf("unexpected initial payload type: %q", got) } kb.SetPayloadType(BotPayloadBase64) if got := kb.GetPayloadType(); got != BotPayloadBase64 { t.Fatalf("unexpected updated payload type: %q", got) } } func TestDecodePayloadAcceptsBase64KeyboardPayloadWhenBotPrefersJSON(t *testing.T) { kb := NewInlineKeyboardBase64(1). AddCallbackButton("A", "cmd", 1, "two") markup, err := kb.Get() if err != nil { t.Fatalf("unexpected err: %v", err) } got, _, err := decodePayload(BotPayloadJSON, markup.InlineKeyboard[0][0].CallbackData, false) if err != nil { t.Fatalf("decodePayload returned error: %v", err) } want := CallbackData{Command: "cmd", Args: []string{"1", "two"}} if !reflect.DeepEqual(got, want) { t.Fatalf("unexpected payload: got %#v want %#v", got, want) } } func TestDecodePayloadAcceptsJSONKeyboardPayloadWhenBotPrefersBase64(t *testing.T) { kb := NewInlineKeyboardJSON(1). AddCallbackButton("A", "cmd", 1, "two") markup, err := kb.Get() if err != nil { t.Fatalf("unexpected err: %v", err) } got, _, err := decodePayload(BotPayloadBase64, markup.InlineKeyboard[0][0].CallbackData, false) if err != nil { t.Fatalf("decodePayload returned error: %v", err) } want := CallbackData{Command: "cmd", Args: []string{"1", "two"}} if !reflect.DeepEqual(got, want) { t.Fatalf("unexpected payload: got %#v want %#v", got, want) } } func TestDecodePayloadAcceptsCompactKeyboardPayloadWhenBotPrefersJSON(t *testing.T) { kb := NewInlineKeyboardCompact(1). AddCallbackButton("A", "cmd", 1, "two") markup, err := kb.Get() if err != nil { t.Fatalf("unexpected err: %v", err) } got, decodedType, err := decodePayload(BotPayloadJSON, markup.InlineKeyboard[0][0].CallbackData, false) if err != nil { t.Fatalf("decodePayload returned error: %v", err) } if decodedType != BotPayloadCompact { t.Fatalf("unexpected decoded payload type: got %q want %q", decodedType, BotPayloadCompact) } want := CallbackData{Command: "cmd", Args: []string{"1", "two"}} if !reflect.DeepEqual(got, want) { t.Fatalf("unexpected payload: got %#v want %#v", got, want) } } func TestDecodePayloadAcceptsCompactBase64KeyboardPayloadWhenBotPrefersJSON(t *testing.T) { kb := NewInlineKeyboardCompactBase64(1). AddCallbackButton("A", "cmd", 1, "two") markup, err := kb.Get() if err != nil { t.Fatalf("unexpected err: %v", err) } got, decodedType, err := decodePayload(BotPayloadJSON, markup.InlineKeyboard[0][0].CallbackData, false) if err != nil { t.Fatalf("decodePayload returned error: %v", err) } if decodedType != BotPayloadCompactBase64 { t.Fatalf("unexpected decoded payload type: got %q want %q", decodedType, BotPayloadCompactBase64) } want := CallbackData{Command: "cmd", Args: []string{"1", "two"}} if !reflect.DeepEqual(got, want) { t.Fatalf("unexpected payload: got %#v want %#v", got, want) } } // TestCompactPayloadRoundTripsWithSeparatorChars guards the compact-encoding // escape fix. Args containing the , | or \ separator bytes previously corrupted // on decode; now they must round-trip exactly. // // Note: the compact format coalesces "no args" with "single empty arg" — both // emit "cmd|" and decode to nil args. Use other encodings if that distinction // matters. func TestCompactPayloadRoundTripsWithSeparatorChars(t *testing.T) { tests := []struct { name string data CallbackData }{ {name: "plain", data: CallbackData{Command: "cmd", Args: []string{"one", "two"}}}, {name: "no args", data: CallbackData{Command: "cmd"}}, {name: "comma in arg", data: CallbackData{Command: "cmd", Args: []string{"a,b", "c"}}}, {name: "pipe in arg", data: CallbackData{Command: "cmd", Args: []string{"a|b", "c"}}}, {name: "backslash in arg", data: CallbackData{Command: "cmd", Args: []string{`a\b`, "c"}}}, {name: "all specials in arg", data: CallbackData{Command: "cmd", Args: []string{`a,b|c\d`}}}, {name: "specials in command", data: CallbackData{Command: "a|b,c", Args: []string{"x"}}}, {name: "two empty args", data: CallbackData{Command: "cmd", Args: []string{"", ""}}}, {name: "utf8 args", data: CallbackData{Command: "cmd", Args: []string{"привет", "мир"}}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { encoded, err := encodeCompactPayload(tt.data) if err != nil { t.Fatalf("encodeCompactPayload returned error: %v", err) } got, err := decodeCompactPayload(encoded) if err != nil { t.Fatalf("decodeCompactPayload returned error: %v", err) } if got.Command != tt.data.Command { t.Fatalf("command mismatch: got %q want %q (encoded=%q)", got.Command, tt.data.Command, encoded) } if len(got.Args) != len(tt.data.Args) { t.Fatalf("args length mismatch: got %v want %v (encoded=%q)", got.Args, tt.data.Args, encoded) } for i := range tt.data.Args { if got.Args[i] != tt.data.Args[i] { t.Fatalf("arg %d mismatch: got %q want %q (encoded=%q)", i, got.Args[i], tt.data.Args[i], encoded) } } }) } } func TestCompactPayloadDecodeRejectsMissingSeparator(t *testing.T) { if _, err := decodeCompactPayload("noseparator"); err == nil { t.Fatal("expected error decoding payload without separator") } } func TestDecodePayloadStrictRejectsCompactMismatchedType(t *testing.T) { kb := NewInlineKeyboardCompact(1). AddCallbackButton("A", "cmd", 1) markup, err := kb.Get() if err != nil { t.Fatalf("unexpected err: %v", err) } _, _, err = decodePayload(BotPayloadJSON, markup.InlineKeyboard[0][0].CallbackData, true) if !errors.Is(err, ErrPayloadTypeMismatch) { t.Fatalf("expected ErrPayloadTypeMismatch, got %v", err) } } func TestDecodePayloadStrictRejectsMismatchedType(t *testing.T) { kb := NewInlineKeyboardBase64(1). AddCallbackButton("A", "cmd", 1) markup, err := kb.Get() if err != nil { t.Fatalf("unexpected err: %v", err) } _, _, err = decodePayload(BotPayloadJSON, markup.InlineKeyboard[0][0].CallbackData, true) if !errors.Is(err, ErrPayloadTypeMismatch) { t.Fatalf("expected ErrPayloadTypeMismatch, got %v", err) } } func TestInlineKeyboardValidation(t *testing.T) { if err := NewInlineKeyboardButton("missing action").Validate(); !errors.Is(err, ErrInlineKeyboardButtonAction) { t.Fatalf("expected ErrInlineKeyboardButtonAction, got %v", err) } long := strings.Repeat("я", 33) button := NewInlineKeyboardButton("long").SetCallbackDataCompact(long) if err := button.Validate(); !errors.Is(err, ErrCallbackDataLength) { t.Fatalf("expected ErrCallbackDataLength, got %v", err) } keyboard := NewInlineKeyboardCompact(1).AddButton(button) if _, err := keyboard.Get(); !errors.Is(err, ErrCallbackDataLength) { t.Fatalf("expected validated keyboard to reject callback data, got %v", err) } } func TestCallbackDataEncodeBoundaries(t *testing.T) { tests := []struct { name string command string wantLen int wantErr error }{ {name: "one byte", command: "", wantLen: 1}, {name: "64 bytes", command: strings.Repeat("a", 63), wantLen: 64}, {name: "65 bytes", command: strings.Repeat("a", 64), wantErr: ErrCallbackDataLength}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { encoded, err := (CallbackData{Command: tt.command}).Encode(BotPayloadCompact) if !errors.Is(err, tt.wantErr) { t.Fatalf("expected %v, got %v", tt.wantErr, err) } if err == nil && len([]byte(encoded)) != tt.wantLen { t.Fatalf("encoded length = %d, want %d", len([]byte(encoded)), tt.wantLen) } }) } if _, err := (CallbackData{Command: "ok"}).Encode(BotPayloadType("unknown")); !errors.Is(err, ErrInvalidPayloadType) { t.Fatalf("expected ErrInvalidPayloadType, got %v", err) } } func TestInlineKeyboardLoweredMaxRowStillWraps(t *testing.T) { keyboard := NewInlineKeyboardJSON(3). AddURLButton("A", "https://example.test/a"). AddURLButton("B", "https://example.test/b"). SetMaxRow(1). AddURLButton("C", "https://example.test/c") markup, err := keyboard.Get() if !errors.Is(err, ErrInlineKeyboardRowTooLong) || markup != nil { t.Fatalf("expected strict validation failure for the existing oversized row: %v", err) } if len(keyboard.Lines) != 2 || len(keyboard.Lines[0]) != 2 || len(keyboard.Lines[1]) != 1 { t.Fatalf("lowered maxRow stopped automatic wrapping: %#v", keyboard.Lines) } } func TestInlineKeyboardUnlimitedRowsMustBeExplicit(t *testing.T) { for _, maxRow := range []int{0, -1} { keyboard := NewInlineKeyboardJSON(maxRow).AddURLButton("A", "https://example.test/a") if markup, err := keyboard.Get(); !errors.Is(err, ErrInlineKeyboardMaxRow) || markup != nil { t.Fatalf("expected invalid row limit %d, markup=%#v err=%v", maxRow, markup, err) } } keyboard := NewInlineKeyboardJSON(1). SetUnlimitedRows(). AddURLButton("A", "https://example.test/a"). AddURLButton("B", "https://example.test/b") markup, err := keyboard.Get() if err != nil { t.Fatalf("explicit unlimited rows failed: %v", err) } if len(markup.InlineKeyboard) != 1 || len(markup.InlineKeyboard[0]) != 2 { t.Fatalf("unexpected unlimited keyboard: %#v", markup.InlineKeyboard) } } func TestInlineKeyboardDisabledButton(t *testing.T) { button, err := NewInlineKeyboardButton("Unavailable").SetDisabled().Build() if err != nil { t.Fatalf("Build returned error: %v", err) } if button.Disabled == nil || button.URL != "" || button.CallbackData != "" { t.Fatalf("unexpected disabled button: %#v", button) } button, err = NewInlineKeyboardButton("Open").SetDisabled().SetURL("https://example.test").Build() if err != nil { t.Fatalf("SetURL didn't replace disabled action: %v", err) } if button.Disabled != nil || button.URL == "" { t.Fatalf("unexpected URL button: %#v", button) } }