v0.3.65 9ef467d

api_test.go RawHistoryFolder

1package telegram
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"errors"
8	"io"
9	"net/http"
10	"strings"
11	"sync"
12	"testing"
13	"time"
14
15	"github.com/patriceckhart/zot/packages/agent/modes/bot"
16	"github.com/patriceckhart/zot/packages/provider"
17)
18
19type roundTripFunc func(*http.Request) (*http.Response, error)
20
21func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
22	return f(req)
23}
24
25func telegramResponse(body string) *http.Response {
26	return &http.Response{
27		StatusCode: http.StatusOK,
28		Body:       io.NopCloser(strings.NewReader(body)),
29		Header:     make(http.Header),
30	}
31}
32
33func testClient(transport http.RoundTripper) *Client {
34	return &Client{
35		token: "test",
36		http:  &http.Client{Transport: transport},
37	}
38}
39
40func TestClientDeleteWebhookPreservesPendingUpdates(t *testing.T) {
41	client := testClient(roundTripFunc(func(req *http.Request) (*http.Response, error) {
42		if req.Method != http.MethodPost {
43			t.Fatalf("method = %s, want POST", req.Method)
44		}
45		if req.URL.Path != "/bottest/deleteWebhook" {
46			t.Fatalf("path = %q, want deleteWebhook endpoint", req.URL.Path)
47		}
48		body, err := io.ReadAll(req.Body)
49		if err != nil {
50			t.Fatal(err)
51		}
52		var got map[string]bool
53		if err := json.Unmarshal(body, &got); err != nil {
54			t.Fatal(err)
55		}
56		if got["drop_pending_updates"] {
57			t.Fatal("drop_pending_updates = true, want false")
58		}
59		return telegramResponse(`{"ok":true,"result":true}`), nil
60	}))
61
62	if err := client.DeleteWebhook(context.Background(), false); err != nil {
63		t.Fatalf("DeleteWebhook() error = %v", err)
64	}
65}
66
67func TestClientDeleteWebhookRejectsAPIFailure(t *testing.T) {
68	client := testClient(roundTripFunc(func(*http.Request) (*http.Response, error) {
69		return telegramResponse(`{"ok":false,"description":"not allowed","result":false}`), nil
70	}))
71
72	err := client.DeleteWebhook(context.Background(), false)
73	if err == nil || !strings.Contains(err.Error(), "deleteWebhook: not allowed") {
74		t.Fatalf("DeleteWebhook() error = %v, want Telegram API error", err)
75	}
76}
77
78func TestAdapterCommandsAreCaseInsensitive(t *testing.T) {
79	cfg := Config{AllowedUserID: 42}
80	adapter := NewAdapter(nil, &cfg, func(Config) error { return nil })
81	var got bot.Command
82	var called bool
83	adapter.handleUpdate(context.Background(), Update{Message: &Message{
84		MessageID: 1,
85		From:      &User{ID: 42},
86		Chat:      Chat{ID: 7, Type: "private"},
87		Text:      "/STOP",
88	}}, func(bot.InboundMessage) {
89		t.Fatal("uppercase slash command was forwarded as a prompt")
90	}, func(command bot.Command, _ bot.InboundMessage) {
91		got = command
92		called = true
93	})
94	if !called || got != bot.CmdStop {
95		t.Fatalf("command = %v, called = %v, want CmdStop", got, called)
96	}
97}
98
99func TestAdapterRunDeletesWebhookBeforePolling(t *testing.T) {
100	ctx, cancel := context.WithCancel(context.Background())
101	defer cancel()
102
103	var (
104		mu    sync.Mutex
105		calls []string
106	)
107	client := testClient(roundTripFunc(func(req *http.Request) (*http.Response, error) {
108		endpoint := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:]
109		mu.Lock()
110		calls = append(calls, endpoint)
111		mu.Unlock()
112		switch endpoint {
113		case "getMe":
114			return telegramResponse(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"zot_test"}}`), nil
115		case "deleteWebhook":
116			return telegramResponse(`{"ok":true,"result":true}`), nil
117		case "getUpdates":
118			cancel()
119			return telegramResponse(`{"ok":true,"result":[]}`), nil
120		default:
121			t.Fatalf("unexpected endpoint %q", endpoint)
122			return nil, errors.New("unreachable")
123		}
124	}))
125	cfg := Config{BotToken: "test", BotID: 1, BotUsername: "zot_test"}
126	adapter := NewAdapter(client, &cfg, func(Config) error { return nil })
127
128	err := adapter.Run(ctx, func(bot.InboundMessage) {}, func(bot.Command, bot.InboundMessage) {})
129	if !errors.Is(err, context.Canceled) {
130		t.Fatalf("Run() error = %v, want context canceled", err)
131	}
132	mu.Lock()
133	defer mu.Unlock()
134	want := []string{"getMe", "deleteWebhook", "getUpdates"}
135	if strings.Join(calls, ",") != strings.Join(want, ",") {
136		t.Fatalf("API calls = %v, want %v", calls, want)
137	}
138}
139
140func TestBridgeStartDeletesWebhookBeforePolling(t *testing.T) {
141	pollStarted := make(chan struct{})
142	var pollOnce sync.Once
143	var (
144		mu    sync.Mutex
145		calls []string
146	)
147	client := testClient(roundTripFunc(func(req *http.Request) (*http.Response, error) {
148		endpoint := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:]
149		mu.Lock()
150		calls = append(calls, endpoint)
151		mu.Unlock()
152		switch endpoint {
153		case "getMe":
154			return telegramResponse(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"zot_test"}}`), nil
155		case "deleteWebhook":
156			return telegramResponse(`{"ok":true,"result":true}`), nil
157		case "getUpdates":
158			pollOnce.Do(func() { close(pollStarted) })
159			<-req.Context().Done()
160			return nil, req.Context().Err()
161		default:
162			t.Fatalf("unexpected endpoint %q", endpoint)
163			return nil, errors.New("unreachable")
164		}
165	}))
166	bridge := &Bridge{
167		Client: client,
168		Config: Config{BotToken: "test", BotID: 1, BotUsername: "zot_test"},
169		Save:   func(Config) error { return nil },
170		Host:   testBridgeHost{},
171	}
172
173	if err := bridge.Start(context.Background()); err != nil {
174		t.Fatalf("Start() error = %v", err)
175	}
176	select {
177	case <-pollStarted:
178	case <-time.After(2 * time.Second):
179		t.Fatal("timed out waiting for Telegram polling to start")
180	}
181	bridge.Stop()
182
183	mu.Lock()
184	defer mu.Unlock()
185	want := []string{"getMe", "deleteWebhook", "getUpdates"}
186	if strings.Join(calls, ",") != strings.Join(want, ",") {
187		t.Fatalf("API calls = %v, want %v", calls, want)
188	}
189}
190
191type testBridgeHost struct{}
192
193func (testBridgeHost) SubmitOrQueue(string, []provider.ImageBlock) {}
194func (testBridgeHost) CancelTurn()                                 {}
195func (testBridgeHost) Status() string                              { return "" }
196func (testBridgeHost) Notify(string, string)                       {}
197
198func TestAdapterRunFailsBeforePollingWhenWebhookRemovalFails(t *testing.T) {
199	var calls bytes.Buffer
200	client := testClient(roundTripFunc(func(req *http.Request) (*http.Response, error) {
201		endpoint := req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:]
202		calls.WriteString(endpoint + "\n")
203		if endpoint == "getMe" {
204			return telegramResponse(`{"ok":true,"result":{"id":1,"is_bot":true,"username":"zot_test"}}`), nil
205		}
206		return telegramResponse(`{"ok":false,"description":"webhook removal denied","result":false}`), nil
207	}))
208	cfg := Config{BotToken: "test"}
209	adapter := NewAdapter(client, &cfg, func(Config) error { return nil })
210
211	err := adapter.Run(context.Background(), func(bot.InboundMessage) {}, func(bot.Command, bot.InboundMessage) {})
212	if err == nil || !strings.Contains(err.Error(), "remove telegram webhook before polling") {
213		t.Fatalf("Run() error = %v, want actionable webhook error", err)
214	}
215	if got, want := calls.String(), "getMe\ndeleteWebhook\n"; got != want {
216		t.Fatalf("API calls = %q, want %q", got, want)
217	}
218}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close