package laniakea import ( "errors" "io" "net/http" "reflect" "strconv" "strings" "sync/atomic" "testing" "git.scuroneko.dev/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/sneklog/v2" ) type roundTripFunc func(*http.Request) (*http.Response, error) func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) { var calls atomic.Int64 client := &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { calls.Add(1) return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":true}`)), }, nil }), } api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { if err := api.Close(); err != nil { t.Fatalf("Close returned error: %v", err) } }() plugin := NewPlugin[NoData]("overflow") exec := func(ctx *MessageContext, db NoData) error { return nil } for i := 0; i < 101; i++ { plugin.Command("cmd"+strconv.Itoa(i), exec) } bot := &Bot[NoData]{ api: api, logger: sneklog.NewLogger(), plugins: []Plugin[NoData]{*plugin}, } err := bot.AutoGenerateCommands() if !errors.Is(err, ErrTooManyCommands) { t.Fatalf("expected ErrTooManyCommands, got %v", err) } if calls.Load() != 0 { t.Fatalf("expected no HTTP calls before limit validation, got %d", calls.Load()) } } func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) { plugin := NewPlugin[NoData]("sorted") exec := func(ctx *MessageContext, db NoData) error { return nil } plugin.Command("zeta", exec) plugin.Command("alpha", exec) plugin.Command("mid", exec) commands, err := gatherCommandsForPlugin(*plugin) if err != nil { t.Fatalf("gatherCommandsForPlugin returned error: %v", err) } got := make([]string, 0, len(commands)) for _, cmd := range commands { got = append(got, cmd.Command) } want := []string{"alpha", "mid", "zeta"} if !reflect.DeepEqual(got, want) { t.Fatalf("unexpected command order: got %v want %v", got, want) } } func TestGeneratedCommandDescriptionBoundaries(t *testing.T) { tests := []struct { name string description string wantLength int wantErr error }{ {name: "generated usage", wantLength: len("Usage: /start")}, {name: "exact limit", description: strings.Repeat("я", 241), wantLength: 256}, {name: "over limit", description: strings.Repeat("я", 242), wantErr: ErrInvalidBotCommandDescription}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { plugin := NewPlugin[NoData]("commands") plugin.Command("start", func(ctx *MessageContext, db NoData) error { return nil }).SetDescription(tt.description) commands, err := gatherCommandsForPlugin(*plugin) if !errors.Is(err, tt.wantErr) { t.Fatalf("expected %v, got %v", tt.wantErr, err) } if err == nil { if got := len([]rune(commands[0].Description)); got != tt.wantLength { t.Fatalf("description length = %d, want %d", got, tt.wantLength) } } }) } } func TestAutoGenerateCommandsValidatesBeforeRequest(t *testing.T) { tests := []struct { name string plugins func(CommandExecutor[NoData]) []Plugin[NoData] wantErr error }{ { name: "invalid command", plugins: func(exec CommandExecutor[NoData]) []Plugin[NoData] { plugin := NewPlugin[NoData]("invalid") plugin.Command("UPPER", exec) return []Plugin[NoData]{*plugin} }, wantErr: ErrInvalidBotCommand, }, { name: "description too long", plugins: func(exec CommandExecutor[NoData]) []Plugin[NoData] { plugin := NewPlugin[NoData]("long") plugin.Command("start", exec).SetDescription(strings.Repeat("я", 257)) return []Plugin[NoData]{*plugin} }, wantErr: ErrInvalidBotCommandDescription, }, { name: "duplicate across plugins", plugins: func(exec CommandExecutor[NoData]) []Plugin[NoData] { first := NewPlugin[NoData]("first") second := NewPlugin[NoData]("second") first.Command("start", exec) second.Command("start", exec) return []Plugin[NoData]{*first, *second} }, wantErr: ErrDuplicateBotCommand, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var calls atomic.Int64 client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { calls.Add(1) return nil, errors.New("unexpected request") })} api := tgapi.NewAPI(tgapi.NewAPIOpts("token").SetAPIURL("https://example.test").SetHTTPClient(client)) defer func() { _ = api.Close() }() exec := func(ctx *MessageContext, db NoData) error { return nil } bot := &Bot[NoData]{api: api, logger: sneklog.NewLogger(), plugins: tt.plugins(exec)} defer func() { _ = bot.logger.Close() }() err := bot.AutoGenerateCommands() if !errors.Is(err, tt.wantErr) { t.Fatalf("expected %v, got %v", tt.wantErr, err) } if calls.Load() != 0 { t.Fatalf("expected no requests, got %d", calls.Load()) } }) } } func TestAutoGenerateCommandsReportsPartialScopeUpdate(t *testing.T) { var calls atomic.Int64 client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { call := calls.Add(1) body := `{"ok":true,"result":true}` if call == 2 { body = `{"ok":false,"error_code":500,"description":"boom"}` } return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body)), }, nil })} api := tgapi.NewAPI(tgapi.NewAPIOpts("token").SetAPIURL("https://example.test").SetHTTPClient(client)) defer func() { _ = api.Close() }() plugin := NewPlugin[NoData]("commands") plugin.Command("start", func(ctx *MessageContext, db NoData) error { return nil }) bot := &Bot[NoData]{api: api, logger: sneklog.NewLogger(), plugins: []Plugin[NoData]{*plugin}} defer func() { _ = bot.logger.Close() }() err := bot.AutoGenerateCommands() if !errors.Is(err, ErrPartialCommandScopeUpdate) { t.Fatalf("expected ErrPartialCommandScopeUpdate, got %v", err) } var partial *CommandScopeUpdateError if !errors.As(err, &partial) { t.Fatalf("expected CommandScopeUpdateError, got %T", err) } if !reflect.DeepEqual(partial.UpdatedScopes, []tgapi.BotCommandScopeType{tgapi.BotCommandScopePrivateType}) { t.Fatalf("unexpected updated scopes: %v", partial.UpdatedScopes) } if partial.FailedScope != tgapi.BotCommandScopeGroupType { t.Fatalf("unexpected failed scope: %q", partial.FailedScope) } } func TestAutoGenerateCommandsForNilScopeUsesSingleAtomicReplacement(t *testing.T) { var methods []string client := &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { methods = append(methods, req.URL.Path) return &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":true}`)), }, nil }), } api := tgapi.NewAPI( tgapi.NewAPIOpts("token"). SetAPIURL("https://example.test"). SetHTTPClient(client), ) defer func() { if err := api.Close(); err != nil { t.Fatalf("Close returned error: %v", err) } }() plugin := NewPlugin[NoData]("commands") plugin.Command("start", func(ctx *MessageContext, db NoData) error { return nil }) bot := &Bot[NoData]{ api: api, logger: sneklog.NewLogger(), plugins: []Plugin[NoData]{*plugin}, } defer func() { if err := bot.logger.Close(); err != nil { t.Fatalf("Close logger returned error: %v", err) } }() if err := bot.AutoGenerateCommandsForScope(nil); err != nil { t.Fatalf("AutoGenerateCommandsForScope returned error: %v", err) } if len(methods) != 1 { t.Fatalf("expected one request, got %d", len(methods)) } if !strings.HasSuffix(methods[0], "/setMyCommands") { t.Fatalf("expected setMyCommands request, got %v", methods[0]) } }