v0.30.0 eb3068f

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

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