v0.29.0 21b3150

client_test.go RawHistoryFolder

1package systemviewsbridge
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"testing"
8	"time"
9
10	"github.com/bnema/dumber/internal/application/dto"
11	"github.com/bnema/dumber/internal/application/port"
12)
13
14func TestBuildMessageEnvelope(t *testing.T) {
15	t.Parallel()
16
17	payload := struct {
18		RequestID string `json:"requestId"`
19		Limit     int    `json:"limit"`
20	}{RequestID: "req-1", Limit: 25}
21
22	got, err := buildMessageEnvelope("history_timeline", payload)
23	if err != nil {
24		t.Fatalf("buildMessageEnvelope() error = %v", err)
25	}
26
27	var msg port.WebUIMessage
28	if err := json.Unmarshal(got, &msg); err != nil {
29		t.Fatalf("unmarshal envelope: %v", err)
30	}
31
32	if msg.Type != "history_timeline" {
33		t.Fatalf("msg.Type = %q, want %q", msg.Type, "history_timeline")
34	}
35
36	if gotPayload := string(msg.Payload); gotPayload != `{"requestId":"req-1","limit":25}` {
37		t.Fatalf("msg.Payload = %s, want %s", gotPayload, `{"requestId":"req-1","limit":25}`)
38	}
39}
40
41func TestClientSendPrefersNativeTransport(t *testing.T) {
42	t.Parallel()
43
44	native := &fakeTransport{available: true, response: []byte(`{"transport":"native"}`)}
45	fetch := &fakeTransport{available: true, response: []byte(`{"transport":"fetch"}`)}
46
47	client := NewClient(native, fetch)
48	got, err := client.Send(context.Background(), "favorite_list", struct {
49		RequestID string `json:"requestId"`
50	}{RequestID: "req-2"})
51	if err != nil {
52		t.Fatalf("Send() error = %v", err)
53	}
54
55	if string(got) != `{"transport":"native"}` {
56		t.Fatalf("Send() = %s, want %s", got, `{"transport":"native"}`)
57	}
58
59	if !native.called {
60		t.Fatal("native transport was not used")
61	}
62	if fetch.called {
63		t.Fatal("fetch transport was used unexpectedly")
64	}
65
66	var msg port.WebUIMessage
67	if err := json.Unmarshal(native.last, &msg); err != nil {
68		t.Fatalf("unmarshal sent envelope: %v", err)
69	}
70	if msg.Type != "favorite_list" {
71		t.Fatalf("sent type = %q, want %q", msg.Type, "favorite_list")
72	}
73}
74
75func TestClientSendFallsBackToFetchTransport(t *testing.T) {
76	t.Parallel()
77
78	native := &fakeTransport{available: false, response: []byte(`{"transport":"native"}`)}
79	fetch := &fakeTransport{available: true, response: []byte(`{"transport":"fetch"}`)}
80
81	client := NewClient(native, fetch)
82	got, err := client.Send(context.Background(), "favorite_list", struct {
83		RequestID string `json:"requestId"`
84	}{RequestID: "req-3"})
85	if err != nil {
86		t.Fatalf("Send() error = %v", err)
87	}
88
89	if string(got) != `{"transport":"fetch"}` {
90		t.Fatalf("Send() = %s, want %s", got, `{"transport":"fetch"}`)
91	}
92
93	if native.called {
94		t.Fatal("native transport was used unexpectedly")
95	}
96	if !fetch.called {
97		t.Fatal("fetch transport was not used")
98	}
99}
100
101func TestClientTimelineDecodesEntries(t *testing.T) {
102	t.Parallel()
103
104	native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-9","success":true,"data":[{"id":1,"url":"https://example.com","title":"Example"}]}`)}
105	client := NewClient(native, nil)
106
107	entries, err := client.Timeline(context.Background(), 25, 0)
108	if err != nil {
109		t.Fatalf("Timeline() error = %v", err)
110	}
111	if len(entries) != 1 {
112		t.Fatalf("Timeline() len = %d, want 1", len(entries))
113	}
114	if entries[0].URL != "https://example.com" || entries[0].Title != "Example" {
115		t.Fatalf("Timeline() entry = %+v", entries[0])
116	}
117
118	var msg port.WebUIMessage
119	if err := json.Unmarshal(native.last, &msg); err != nil {
120		t.Fatalf("unmarshal sent envelope: %v", err)
121	}
122	if msg.Type != "history_timeline" {
123		t.Fatalf("sent type = %q, want %q", msg.Type, "history_timeline")
124	}
125	if gotPayload := string(msg.Payload); gotPayload == "" {
126		t.Fatal("sent payload was empty")
127	}
128}
129
130func TestClientTimelineWindowOmitsZeroCursor(t *testing.T) {
131	t.Parallel()
132
133	native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-window","success":true}`)}
134	client := NewClient(native, nil)
135
136	_, err := client.TimelineWindow(context.Background(), time.Time{}, "")
137	if err != nil {
138		t.Fatalf("TimelineWindow() error = %v", err)
139	}
140
141	var msg port.WebUIMessage
142	if err := json.Unmarshal(native.last, &msg); err != nil {
143		t.Fatalf("unmarshal sent envelope: %v", err)
144	}
145	if msg.Type != "history_timeline_window" {
146		t.Fatalf("sent type = %q, want %q", msg.Type, "history_timeline_window")
147	}
148	var payload map[string]any
149	if err := json.Unmarshal(msg.Payload, &payload); err != nil {
150		t.Fatalf("unmarshal payload: %v", err)
151	}
152	if _, ok := payload["before"]; ok {
153		t.Fatalf("zero TimelineWindow cursor should be omitted, payload = %s", msg.Payload)
154	}
155}
156
157func TestClientDeleteRangeRejectsEmptyRange(t *testing.T) {
158	t.Parallel()
159
160	transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-range-empty","success":true}`)}
161	client := NewClient(transport, nil)
162
163	err := client.DeleteRange(context.Background(), "  ")
164	if err == nil {
165		t.Fatal("DeleteRange() error = nil, want validation error")
166	}
167	if transport.called {
168		t.Fatal("DeleteRange() sent a request for an invalid range")
169	}
170}
171
172func TestClientDeleteRangeSendsRange(t *testing.T) {
173	t.Parallel()
174
175	native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-10","success":true}`)}
176	client := NewClient(native, nil)
177
178	if err := client.DeleteRange(context.Background(), "week"); err != nil {
179		t.Fatalf("DeleteRange() error = %v", err)
180	}
181
182	var msg port.WebUIMessage
183	if err := json.Unmarshal(native.last, &msg); err != nil {
184		t.Fatalf("unmarshal sent envelope: %v", err)
185	}
186	if msg.Type != "history_delete_range" {
187		t.Fatalf("sent type = %q, want %q", msg.Type, "history_delete_range")
188	}
189}
190
191func TestClientListDecodesFavorites(t *testing.T) {
192	t.Parallel()
193
194	native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-11","success":true,"data":[{"id":1,"url":"https://example.com","title":"Example"}]}`)}
195	client := NewClient(native, nil)
196
197	favorites, err := client.List(context.Background())
198	if err != nil {
199		t.Fatalf("List() error = %v", err)
200	}
201	if len(favorites) != 1 {
202		t.Fatalf("List() len = %d, want 1", len(favorites))
203	}
204	if favorites[0].URL != "https://example.com" || favorites[0].Title != "Example" {
205		t.Fatalf("List() favorite = %+v", favorites[0])
206	}
207
208	var msg port.WebUIMessage
209	if err := json.Unmarshal(native.last, &msg); err != nil {
210		t.Fatalf("unmarshal sent envelope: %v", err)
211	}
212	if msg.Type != "favorite_list" {
213		t.Fatalf("sent type = %q, want %q", msg.Type, "favorite_list")
214	}
215}
216
217func TestClientListFoldersDecodesFolders(t *testing.T) {
218	t.Parallel()
219
220	native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-12","success":true,"data":[{"id":9,"name":"Read Later"}]}`)}
221	client := NewClient(native, nil)
222
223	folders, err := client.ListFolders(context.Background())
224	if err != nil {
225		t.Fatalf("ListFolders() error = %v", err)
226	}
227	if len(folders) != 1 {
228		t.Fatalf("ListFolders() len = %d, want 1", len(folders))
229	}
230	if folders[0].Name != "Read Later" {
231		t.Fatalf("ListFolders() folder = %+v", folders[0])
232	}
233
234	var msg port.WebUIMessage
235	if err := json.Unmarshal(native.last, &msg); err != nil {
236		t.Fatalf("unmarshal sent envelope: %v", err)
237	}
238	if msg.Type != "folder_list" {
239		t.Fatalf("sent type = %q, want %q", msg.Type, "folder_list")
240	}
241}
242
243func TestClientListTagsDecodesTags(t *testing.T) {
244	t.Parallel()
245
246	native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-13","success":true,"data":[{"id":5,"name":"Go","color":"#00ff00"}]}`)}
247	client := NewClient(native, nil)
248
249	tags, err := client.ListTags(context.Background())
250	if err != nil {
251		t.Fatalf("ListTags() error = %v", err)
252	}
253	if len(tags) != 1 {
254		t.Fatalf("ListTags() len = %d, want 1", len(tags))
255	}
256	if tags[0].Name != "Go" || tags[0].Color != "#00ff00" {
257		t.Fatalf("ListTags() tag = %+v", tags[0])
258	}
259
260	var msg port.WebUIMessage
261	if err := json.Unmarshal(native.last, &msg); err != nil {
262		t.Fatalf("unmarshal sent envelope: %v", err)
263	}
264	if msg.Type != "tag_list" {
265		t.Fatalf("sent type = %q, want %q", msg.Type, "tag_list")
266	}
267}
268
269func TestClientCurrentAndDefaultDecodeConfigPayload(t *testing.T) {
270	t.Parallel()
271
272	resp := []byte(`{"requestId":"req-14","success":true,"data":{"engine_type":"webkit","default_search_engine":"DuckDuckGo"}}`)
273
274	currentClient := NewClient(&fakeTransport{available: true, response: resp}, nil)
275	current, err := currentClient.Current(context.Background())
276	if err != nil {
277		t.Fatalf("Current() error = %v", err)
278	}
279	if current.EngineType != "webkit" || current.DefaultSearchEngine != "DuckDuckGo" {
280		t.Fatalf("Current() = %+v", current)
281	}
282
283	var msg port.WebUIMessage
284	if unmarshalErr := json.Unmarshal(currentClient.native.(*fakeTransport).last, &msg); unmarshalErr != nil {
285		t.Fatalf("unmarshal sent envelope: %v", unmarshalErr)
286	}
287	if msg.Type != "/api/config" {
288		t.Fatalf("sent type = %q, want %q", msg.Type, "/api/config")
289	}
290
291	defaultClient := NewClient(&fakeTransport{available: true, response: resp}, nil)
292	defaultCfg, err := defaultClient.Default(context.Background())
293	if err != nil {
294		t.Fatalf("Default() error = %v", err)
295	}
296	if defaultCfg.EngineType != "webkit" || defaultCfg.DefaultSearchEngine != "DuckDuckGo" {
297		t.Fatalf("Default() = %+v", defaultCfg)
298	}
299
300	if err := json.Unmarshal(defaultClient.native.(*fakeTransport).last, &msg); err != nil {
301		t.Fatalf("unmarshal sent envelope: %v", err)
302	}
303	if msg.Type != "/api/config/default" {
304		t.Fatalf("sent type = %q, want %q", msg.Type, "/api/config/default")
305	}
306}
307
308func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
309	t.Parallel()
310
311	tests := []struct {
312		name    string
313		call    func(*testing.T, *Client) error
314		want    string
315		payload any
316	}{
317		{
318			name: "save config",
319			call: func(_ *testing.T, client *Client) error {
320				return client.Save(context.Background(), dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"})
321			},
322			want:    "save_config",
323			payload: dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"},
324		},
325		{
326			name: "get keybindings",
327			call: func(t *testing.T, client *Client) error {
328				got, err := client.GetKeybindings(context.Background())
329				if err != nil {
330					return err
331				}
332				if len(got.Groups) != 1 || got.Groups[0].Mode != "default" {
333					t.Fatalf("GetKeybindings() = %#v", got)
334				}
335				return nil
336			},
337			want: "get_keybindings",
338		},
339		{
340			name: "set keybinding",
341			call: func(t *testing.T, client *Client) error {
342				got, err := client.SetKeybinding(context.Background(), port.SetKeybindingRequest{RequestID: "req-1", Mode: "default", Action: "open", Keys: []string{"ctrl+o"}})
343				if err != nil {
344					return err
345				}
346				if len(got.Conflicts) != 0 {
347					t.Fatalf("SetKeybinding() = %#v", got)
348				}
349				return nil
350			},
351			want:    "set_keybinding",
352			payload: port.SetKeybindingRequest{RequestID: "req-1", Mode: "default", Action: "open", Keys: []string{"ctrl+o"}},
353		},
354		{
355			name: "reset keybinding",
356			call: func(_ *testing.T, client *Client) error {
357				return client.ResetKeybinding(context.Background(), port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"})
358			},
359			want:    "reset_keybinding",
360			payload: port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"},
361		},
362		{
363			name: "reset all keybindings",
364			call: func(_ *testing.T, client *Client) error {
365				return client.ResetAllKeybindings(context.Background())
366			},
367			want: "reset_all_keybindings",
368		},
369	}
370
371	for _, tt := range tests {
372		t.Run(tt.name, func(t *testing.T) {
373			t.Parallel()
374
375			transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-15","success":true,"data":{"status":"success","conflicts":[]}}`)}
376			if tt.name == "save config" || tt.name == "reset keybinding" || tt.name == "reset all keybindings" {
377				transport.response = []byte(`{"requestId":"req-15","success":true}`)
378			}
379			if tt.name == "get keybindings" {
380				transport.response = []byte(`{"requestId":"req-15","success":true,"data":{"groups":[{"mode":"default","display_name":"Default","bindings":[{"action":"open","description":"Open","keys":["ctrl+o"],"default_keys":["ctrl+o"],"is_custom":false}]}]}}`)
381			}
382			client := NewClient(transport, nil)
383
384			if err := tt.call(t, client); err != nil {
385				t.Fatalf("call() error = %v", err)
386			}
387
388			var msg port.WebUIMessage
389			if err := json.Unmarshal(transport.last, &msg); err != nil {
390				t.Fatalf("unmarshal sent envelope: %v", err)
391			}
392			if msg.Type != tt.want {
393				t.Fatalf("sent type = %q, want %q", msg.Type, tt.want)
394			}
395			if tt.payload != nil && len(msg.Payload) == 0 {
396				t.Fatal("sent payload was empty")
397			}
398		})
399	}
400}
401
402func TestClientRequestUsesDefaultTimeoutWhenCallerHasNoDeadline(t *testing.T) {
403	t.Parallel()
404
405	transport := &blockingTransport{available: true}
406	client := NewClient(transport, nil, WithRequestTimeout(time.Millisecond))
407
408	_, err := client.Timeline(context.Background(), 25, 0)
409	if !errors.Is(err, context.DeadlineExceeded) {
410		t.Fatalf("Timeline() error = %v, want context deadline exceeded", err)
411	}
412	if !transport.called {
413		t.Fatal("transport was not called")
414	}
415}
416
417func TestClientCreateFolderIncludesParentID(t *testing.T) {
418	t.Parallel()
419
420	parentID := int64(42)
421	transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-16","success":true,"data":{"id":9,"name":"Nested","icon":"📁","parent_id":42}}`)}
422	client := NewClient(transport, nil)
423
424	folder, err := client.CreateFolder(context.Background(), "Nested", " 📁 ", &parentID)
425	if err != nil {
426		t.Fatalf("CreateFolder() error = %v", err)
427	}
428	if folder == nil {
429		t.Fatal("CreateFolder() returned nil folder")
430	}
431	if folder.Name != "Nested" {
432		t.Fatalf("CreateFolder() folder name = %q, want %q", folder.Name, "Nested")
433	}
434	if folder.Icon != "📁" {
435		t.Fatalf("CreateFolder() folder icon = %q, want 📁", folder.Icon)
436	}
437	if folder.ParentID == nil || int64(*folder.ParentID) != parentID {
438		t.Fatalf("CreateFolder() folder parent_id = %#v, want %d", folder.ParentID, parentID)
439	}
440
441	var msg port.WebUIMessage
442	if err := json.Unmarshal(transport.last, &msg); err != nil {
443		t.Fatalf("unmarshal sent envelope: %v", err)
444	}
445	if msg.Type != "folder_create" {
446		t.Fatalf("sent type = %q, want %q", msg.Type, "folder_create")
447	}
448
449	var payload struct {
450		RequestID string  `json:"requestId"`
451		Name      string  `json:"name"`
452		Icon      *string `json:"icon"`
453		ParentID  *int64  `json:"parent_id"`
454	}
455	if err := json.Unmarshal(msg.Payload, &payload); err != nil {
456		t.Fatalf("unmarshal sent payload: %v", err)
457	}
458	if payload.ParentID == nil || *payload.ParentID != parentID {
459		t.Fatalf("sent parent_id = %#v, want %d", payload.ParentID, parentID)
460	}
461	if payload.Name != "Nested" {
462		t.Fatalf("sent name = %q, want %q", payload.Name, "Nested")
463	}
464	if payload.Icon == nil || *payload.Icon != "📁" {
465		t.Fatalf("sent icon = %#v, want 📁", payload.Icon)
466	}
467	if payload.RequestID == "" {
468		t.Fatal("sent requestId was empty")
469	}
470}
471
472func TestClientDomainStatsRejectsNegativeLimit(t *testing.T) {
473	t.Parallel()
474
475	transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-17","success":true,"data":[]}`)}
476	client := NewClient(transport, nil)
477
478	_, err := client.DomainStats(context.Background(), -1)
479	if err == nil {
480		t.Fatal("DomainStats() error = nil, want validation error")
481	}
482	if transport.called {
483		t.Fatal("DomainStats() sent a request for a negative limit")
484	}
485}
486
487func TestClientDeleteDomainRejectsEmptyDomain(t *testing.T) {
488	t.Parallel()
489
490	transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-domain-empty","success":true}`)}
491	client := NewClient(transport, nil)
492
493	err := client.DeleteDomain(context.Background(), "  ")
494	if err == nil {
495		t.Fatal("DeleteDomain() error = nil, want validation error")
496	}
497	if transport.called {
498		t.Fatal("DeleteDomain() sent a request for an empty domain")
499	}
500}
501
502func TestClientUpdateFolderTrimsIcon(t *testing.T) {
503	t.Parallel()
504
505	transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-18","success":true}`)}
506	client := NewClient(transport, nil)
507
508	if err := client.UpdateFolder(context.Background(), 9, "Read", " 📚 "); err != nil {
509		t.Fatalf("UpdateFolder() error = %v", err)
510	}
511
512	var msg port.WebUIMessage
513	if err := json.Unmarshal(transport.last, &msg); err != nil {
514		t.Fatalf("unmarshal sent envelope: %v", err)
515	}
516	if msg.Type != "folder_update" {
517		t.Fatalf("sent type = %q, want folder_update", msg.Type)
518	}
519	var payload struct {
520		Icon *string `json:"icon"`
521	}
522	if err := json.Unmarshal(msg.Payload, &payload); err != nil {
523		t.Fatalf("unmarshal sent payload: %v", err)
524	}
525	if payload.Icon == nil || *payload.Icon != "📚" {
526		t.Fatalf("sent icon = %#v, want 📚", payload.Icon)
527	}
528}
529
530func TestClientUpdateFolderOmitsBlankIcon(t *testing.T) {
531	t.Parallel()
532
533	transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-19","success":true}`)}
534	client := NewClient(transport, nil)
535
536	if err := client.UpdateFolder(context.Background(), 9, "Read", "   "); err != nil {
537		t.Fatalf("UpdateFolder() error = %v", err)
538	}
539
540	var msg port.WebUIMessage
541	if err := json.Unmarshal(transport.last, &msg); err != nil {
542		t.Fatalf("unmarshal sent envelope: %v", err)
543	}
544	var payload struct {
545		Icon *string `json:"icon"`
546	}
547	if err := json.Unmarshal(msg.Payload, &payload); err != nil {
548		t.Fatalf("unmarshal sent payload: %v", err)
549	}
550	if payload.Icon != nil {
551		t.Fatalf("sent icon = %#v, want nil", payload.Icon)
552	}
553}
554
555func TestClientDeleteEntryRejectsInvalidID(t *testing.T) {
556	t.Parallel()
557
558	transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-20","success":true}`)}
559	client := NewClient(transport, nil)
560
561	err := client.DeleteEntry(context.Background(), 0)
562	if err == nil {
563		t.Fatal("DeleteEntry() error = nil, want validation error")
564	}
565	if transport.called {
566		t.Fatal("DeleteEntry() sent a request for an invalid id")
567	}
568}
569
570func TestClientTagActionsTrimFields(t *testing.T) {
571	t.Parallel()
572
573	createTransport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-21","success":true,"data":{"id":5,"name":"Go","color":"#00add8"}}`)}
574	client := NewClient(createTransport, nil)
575	if _, err := client.CreateTag(context.Background(), " Go ", " #00add8 "); err != nil {
576		t.Fatalf("CreateTag() error = %v", err)
577	}
578	var msg port.WebUIMessage
579	if err := json.Unmarshal(createTransport.last, &msg); err != nil {
580		t.Fatalf("unmarshal create envelope: %v", err)
581	}
582	var createPayload struct {
583		Name  string  `json:"name"`
584		Color *string `json:"color"`
585	}
586	if err := json.Unmarshal(msg.Payload, &createPayload); err != nil {
587		t.Fatalf("unmarshal create payload: %v", err)
588	}
589	if createPayload.Name != "Go" || createPayload.Color == nil || *createPayload.Color != "#00add8" {
590		t.Fatalf("CreateTag payload = %#v", createPayload)
591	}
592
593	updateTransport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-22","success":true}`)}
594	client = NewClient(updateTransport, nil)
595	if err := client.UpdateTag(context.Background(), 5, " Go ", "   "); err != nil {
596		t.Fatalf("UpdateTag() error = %v", err)
597	}
598	if err := json.Unmarshal(updateTransport.last, &msg); err != nil {
599		t.Fatalf("unmarshal update envelope: %v", err)
600	}
601	var updatePayload struct {
602		Name  *string `json:"name"`
603		Color *string `json:"color"`
604	}
605	if err := json.Unmarshal(msg.Payload, &updatePayload); err != nil {
606		t.Fatalf("unmarshal update payload: %v", err)
607	}
608	if updatePayload.Name == nil || *updatePayload.Name != "Go" || updatePayload.Color != nil {
609		t.Fatalf("UpdateTag payload = %#v", updatePayload)
610	}
611}
612
613type fakeTransport struct {
614	available bool
615	called    bool
616	last      []byte
617	response  []byte
618}
619
620func (t *fakeTransport) Available() bool { return t.available }
621
622func (t *fakeTransport) Send(_ context.Context, body []byte) ([]byte, error) {
623	t.called = true
624	t.last = append(t.last[:0], body...)
625	return t.response, nil
626}
627
628type blockingTransport struct {
629	available bool
630	called    bool
631}
632
633func (t *blockingTransport) Available() bool { return t.available }
634
635func (t *blockingTransport) Send(ctx context.Context, _ []byte) ([]byte, error) {
636	t.called = true
637	<-ctx.Done()
638	return nil, ctx.Err()
639}
640
641func TestClientRejectsInvalidFavoriteMutationIDs(t *testing.T) {
642	t.Parallel()
643
644	transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req","success":true}`)}
645	client := NewClient(transport, nil)
646
647	checks := []struct {
648		name string
649		call func() error
650	}{
651		{name: "delete favorite", call: func() error { return client.DeleteFavorite(context.Background(), 0) }},
652		{name: "set shortcut", call: func() error { return client.SetShortcut(context.Background(), 0, nil) }},
653		{name: "set folder", call: func() error { return client.SetFolder(context.Background(), 0, nil) }},
654		{name: "create folder", call: func() error { _, err := client.CreateFolder(context.Background(), " ", "", nil); return err }},
655		{name: "update folder id", call: func() error { return client.UpdateFolder(context.Background(), 0, "Folder", "") }},
656		{name: "update folder name", call: func() error { return client.UpdateFolder(context.Background(), 1, " ", "") }},
657		{name: "delete folder", call: func() error { return client.DeleteFolder(context.Background(), 0) }},
658		{name: "create tag", call: func() error { _, err := client.CreateTag(context.Background(), " ", ""); return err }},
659		{name: "update tag", call: func() error { return client.UpdateTag(context.Background(), 0, "Tag", "") }},
660		{name: "update tag name", call: func() error { return client.UpdateTag(context.Background(), 1, " ", "") }},
661		{name: "delete tag", call: func() error { return client.DeleteTag(context.Background(), 0) }},
662		{name: "assign favorite", call: func() error { return client.AssignTag(context.Background(), 0, 1) }},
663		{name: "assign tag", call: func() error { return client.AssignTag(context.Background(), 1, 0) }},
664		{name: "remove favorite", call: func() error { return client.RemoveTag(context.Background(), 0, 1) }},
665		{name: "remove tag", call: func() error { return client.RemoveTag(context.Background(), 1, 0) }},
666	}
667	for _, check := range checks {
668		t.Run(check.name, func(t *testing.T) {
669			if err := check.call(); err == nil {
670				t.Fatal("error = nil, want validation error")
671			}
672		})
673	}
674	if transport.called {
675		t.Fatal("transport called for invalid favorite mutation IDs")
676	}
677}
678
679func TestClientRejectsNegativeHistoryLimits(t *testing.T) {
680	t.Parallel()
681
682	client := NewClient(&fakeTransport{available: true}, nil)
683
684	if _, err := client.Timeline(context.Background(), -1, 0); err == nil {
685		t.Fatal("Timeline() error = nil, want error")
686	}
687	if _, err := client.Timeline(context.Background(), 1, -1); err == nil {
688		t.Fatal("Timeline() error = nil, want error")
689	}
690	if _, err := client.TimelineByDomain(context.Background(), "", 1, 0); err == nil {
691		t.Fatal("TimelineByDomain() error = nil, want error")
692	}
693	if _, err := client.TimelineByDomain(context.Background(), "example.com", -1, 0); err == nil {
694		t.Fatal("TimelineByDomain() error = nil, want error")
695	}
696	if _, err := client.TimelineByDomain(context.Background(), "example.com", 1, -1); err == nil {
697		t.Fatal("TimelineByDomain() error = nil, want error")
698	}
699	if _, err := client.Search(context.Background(), "example", -1); err == nil {
700		t.Fatal("Search() error = nil, want error")
701	}
702}

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 8

Tags 42

↑↓ moveenter openesc close